kernel/event/record/tool.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one tool call records: what was asked, and what came back.
7
8use serde::{Deserialize, Serialize};
9
10use crate::event::payload::Payload;
11use crate::tool::{Effect, RenderIntent, ToolName};
12
13/// Argument names that say what a call acted on, in the order they are
14/// preferred. Taken from the tool definitions rather than guessed:
15/// `path` is what most of them take, and the rest name their one
16/// subject.
17const SUBJECT_KEYS: [&str; 4] = ["path", "addr", "program", "arm"];
18
19/// `tool_called`: the call the model asked for, before anything ran.
20///
21/// The arguments, and the subject read from them, reach the ledger
22/// through `runtime::turn::ledger::Journal::append_redacted`, so a
23/// credential the model quoted into them is replaced before this line
24/// exists.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct ToolCalled {
28 /// The wire identity both dialects pair a call and its result by.
29 pub id: String,
30 #[cfg_attr(feature = "schema", schemars(with = "String"))]
31 pub name: ToolName,
32 pub args: Payload,
33 /// What the call acted on, in the one reading a person recognises
34 /// a call by: a path, an address, a program name.
35 ///
36 /// Carried by the record rather than derived from `args` at each
37 /// reader. Two readers deriving it each iterated their own map
38 /// order, so one call answered differently on the two sides of the
39 /// wire: for `{"10": "a", "2": "b"}` the ledger's byte order
40 /// gives `"a"` and a browser's own-property order gives `"b"`.
41 /// [`ToolCalled::subject_of`] decides it once, here.
42 ///
43 /// `None` is a call whose arguments name nothing of that kind,
44 /// which is a real state rather than a failure.
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub subject: Option<String>,
47 /// The effect the tool was registered with at the moment of the
48 /// call, copied by the tool wave from its `ToolMeta` (`crates/kernel/spec/Event/Record.lean`
49 /// ยง8-75). Absent for a tool the bench did not know and on every line
50 /// written before the key existed.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub effect: Option<Effect>,
53 /// How the tool was registered to be drawn at the moment of the
54 /// call; absent in the same cases as `effect`. A `Diff`'s locations
55 /// are the registration's, empty for every tool today: the file an
56 /// edit touched is `subject`.
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub render: Option<RenderIntent>,
59}
60
61impl ToolCalled {
62 /// The one argument a person recognises a call by, read from the
63 /// arguments once, when the call becomes a record.
64 ///
65 /// The preferred keys are tried in their declared order; a call
66 /// whose tool names none of them still says something rather than
67 /// falling back to the bare tool name, and the fallback takes the
68 /// first string in the payload's own key order so it is the same
69 /// answer on every machine.
70 #[must_use]
71 pub fn subject_of(args: &Payload) -> Option<String> {
72 for key in SUBJECT_KEYS {
73 if let Some(named) = args.as_map().get(key).and_then(serde_json::Value::as_str) {
74 return Some(named.to_owned());
75 }
76 }
77 args.as_map()
78 .values()
79 .find_map(serde_json::Value::as_str)
80 .map(str::to_owned)
81 }
82}
83
84/// `tool_result`: what the call returned, under the id it answers.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
87pub struct ToolResult {
88 /// The `tool_called` id this line answers.
89 pub tool_use_id: String,
90 #[cfg_attr(feature = "schema", schemars(with = "String"))]
91 pub name: ToolName,
92 #[serde(flatten)]
93 pub answer: ToolAnswer,
94 /// Whole microseconds from the moment the call started to the moment
95 /// it answered, read off the turn's monotonic clock
96 /// (`crates/kernel/spec/Event/Record.lean` D20). Absent on every line
97 /// written before the key existed: a reader then falls back to the
98 /// difference of the two envelope moments, in milliseconds.
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub took_us: Option<u64>,
101}
102
103/// A call answers exactly one way, and the enum is what says so: two
104/// independent optional keys would let a line hold both or neither, and
105/// every reader would have to invent a verdict for the two shapes that
106/// cannot happen.
107///
108/// Untagged, because the keys are the discriminator the hand-written
109/// writer left behind and a ledger already on disk cannot be
110/// re-spelled.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(untagged)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub enum ToolAnswer {
115 /// The tool answered; the payload is the tool's own result shape.
116 Answered { result: Payload },
117 /// The tool refused or failed; the payload is the serialized
118 /// `AxError`, which carries the code, the subject and the recovery
119 /// the model is expected to act on.
120 Failed { error: Payload },
121}
122
123#[cfg(test)]
124#[allow(
125 clippy::unwrap_used,
126 clippy::expect_used,
127 clippy::panic,
128 clippy::indexing_slicing,
129 reason = "test code"
130)]
131mod tests;