Skip to main content

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;