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}
95
96/// A call answers exactly one way, and the enum is what says so: two
97/// independent optional keys would let a line hold both or neither, and
98/// every reader would have to invent a verdict for the two shapes that
99/// cannot happen.
100///
101/// Untagged, because the keys are the discriminator the hand-written
102/// writer left behind and a ledger already on disk cannot be
103/// re-spelled.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(untagged)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub enum ToolAnswer {
108 /// The tool answered; the payload is the tool's own result shape.
109 Answered { result: Payload },
110 /// The tool refused or failed; the payload is the serialized
111 /// `AxError`, which carries the code, the subject and the recovery
112 /// the model is expected to act on.
113 Failed { error: Payload },
114}
115
116#[cfg(test)]
117#[allow(
118 clippy::unwrap_used,
119 clippy::expect_used,
120 clippy::panic,
121 clippy::indexing_slicing,
122 reason = "test code"
123)]
124mod tests;