Skip to main content

agent_engine/tools/
mod.rs

1//! Tool system — trait, registry, and built-in tool implementations.
2//!
3//! All tools implement the `Tool` trait and are registered in `ToolRegistry`.
4//! Subagents get `ToolRegistry::without_subagent()` to prevent recursion.
5use crate::Result;
6use serde_json::Value;
7use std::path::PathBuf;
8use std::sync::{Arc, Mutex};
9
10// ── Module declarations ──────────────────────────────────────────────────────────
11
12#[cfg(feature = "command-exec")]
13mod bash;
14mod confirm_latch;
15pub(crate) mod context_checkpoint;
16mod edit;
17mod extension;
18mod find;
19mod grep;
20mod ls;
21pub mod memory;
22mod multi_edit;
23#[cfg(feature = "command-exec")]
24mod powershell;
25pub(crate) mod read;
26mod search;
27mod unix_programs;
28mod secret_prompt;
29mod subagent;
30#[cfg(target_os = "linux")]
31pub mod sudo_askpass;
32pub(crate) use subagent::archive::write_window_transcript;
33#[cfg(test)]
34pub(crate) use subagent::archive::resume_messages;
35pub(crate) use subagent::finalize::build_reload_carry_event;
36#[doc(hidden)]
37pub use subagent::{legacy_fresh_runtime, spawn_runtime};
38mod write;
39
40pub mod activation;
41mod agent;
42pub mod catalog;
43pub mod discovery;
44pub mod ledger;
45pub mod output;
46pub mod prompt_catalog;
47mod registry;
48pub mod respond;
49pub mod send_channel;
50pub mod shell;
51pub(crate) mod util;
52pub(crate) mod utf8_stream;
53pub(crate) use util::expand_path;
54
55// ── Re-exports ──────────────────────────────────────────────────────────────────
56
57pub use crate::runtime::subagent::{
58    SubagentDisplayRow, SubagentHandle, SubagentRegistry, SubagentResult, SubagentState,
59    SubagentStatus,
60};
61pub use agent::resolve_agent_prompt;
62#[cfg(feature = "command-exec")]
63pub use bash::{bash_intermediary_snapshot, BashIntermediarySnapshot, BashTool};
64pub use discovery::{ActivateToolsTool, SearchToolsTool};
65pub use edit::EditTool;
66pub use extension::ExtensionTool;
67pub use find::FindTool;
68pub use grep::GrepTool;
69pub use ls::LsTool;
70pub use multi_edit::MultiEditTool;
71pub(crate) use multi_edit::{label as multi_edit_label, paths as multi_edit_paths};
72#[cfg(feature = "command-exec")]
73pub use powershell::PowerShellTool;
74pub use read::ReadTool;
75pub use registry::{
76    DeferredToolSplit, DroppedSessionMember, SessionSchemaProjection, ToolRegistry,
77};
78pub use respond::RespondTool;
79pub use confirm_latch::{latch_keys, LatchKey, SessionConfirmLatch};
80pub use secret_prompt::SecretPromptQueue;
81pub use secret_prompt::{
82    ConfirmChoice, ConfirmScope, PromptKind, SecretPromptHandle, SecretPromptRequest,
83    CONFIRM_ANSWER_ALLOW_ALL,
84};
85pub use send_channel::SendChannelTool;
86
87/// Call first thing in `main`: when this process was started by sudo as
88/// the bash tool's askpass helper, runs it and returns its exit code (see
89/// `sudo_askpass`). Linux only; elsewhere always `None`.
90pub fn run_sudo_askpass_helper_if_invoked() -> Option<i32> {
91    #[cfg(target_os = "linux")]
92    {
93        sudo_askpass::run_helper_if_invoked()
94    }
95    #[cfg(not(target_os = "linux"))]
96    {
97        None
98    }
99}
100pub use shell::{ShellEndTool, ShellSendTool, ShellStartTool};
101pub use subagent::{
102    SubagentCollectTool, SubagentModelAuthorizeTool, SubagentModelsTool, SubagentResumeTool,
103    SubagentStartTool, SubagentStatusTool, SubagentSteerTool, SubagentTool,
104};
105pub use write::WriteTool;
106
107// Re-export util items used by sibling tool modules via `super::`
108pub(crate) use util::{
109    peek_next_subagent_id, resolve_path_in, seed_next_subagent_id, strip_ansi, NEXT_SUBAGENT_ID,
110};
111
112// Facade: expose finalize internals for integration tests without making the
113// subagent module pub. Tests import `agent_engine::tools::{build_completion_event, finalize_subagent}`.
114#[doc(hidden)]
115pub use subagent::finalize::{build_completion_event, finalize_subagent};
116
117// ── Tool Trait ──────────────────────────────────────────────────────────────────
118
119/// Streaming channels — carry partial tool output and stream events.
120///
121/// `tx_delta` is the Task 26 bounded delta lane (spec §8.4): a non-blocking
122/// coalesce-then-drop producer handle whose consumer enforces the UI-preview
123/// byte budget. It replaced the previous unbounded `mpsc` sender so a fast
124/// producer with a slow consumer can no longer grow memory without bound.
125pub struct ToolChannels {
126    pub tx_delta: Option<crate::tools::output::DeltaSender>,
127    pub tx_events: Option<tokio::sync::mpsc::UnboundedSender<crate::StreamEvent>>,
128}
129
130/// Runtime capability handles — shared services a tool may require.
131#[derive(Clone, Default)]
132pub struct ToolCapabilities {
133    /// Parent-session extension Confirm latch: the set of extension ids the
134    /// user chose "Allow all" for. Workers share the Arc (including live
135    /// updates and revokes), never a snapshot or host-global approval. This
136    /// grants no `activate_tools` authority. None is for contexts without a
137    /// parent session.
138    pub session_allow_all: Option<Arc<SessionConfirmLatch>>,
139    /// Host-owned note storage binding. Every context made for a chat has
140    /// its chat's: a turn's ([`TurnToolContext`]) and a slash command's
141    /// (`skills::commands::CommandChat`). `None` is left for a context made
142    /// for no chat ([`TurnToolContext::no_chat`]) and for the ones tests
143    /// build by hand; there the memory tools refuse (they never
144    /// derive a project from the process folder).
145    pub memory_backend: Option<crate::memory_backend::MemoryBinding>,
146    /// Parent-session telemetry for the subagents this context spawns: they
147    /// record at the same level through the parent's session writer. `None`
148    /// (manual contexts) leaves workers `Off`.
149    pub telemetry: Option<crate::runtime::telemetry::WorkerTelemetry>,
150    pub tool_register_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<Arc<dyn Tool>>>>,
151    pub session_manager: Option<std::sync::Arc<crate::tools::shell::SessionManager>>,
152    pub subagent_registry: Option<Arc<Mutex<SubagentRegistry>>>,
153    /// Originating turn cancellation, pinned before tool dispatch. A late
154    /// registration cannot inherit a later user's renewed launch authority.
155    pub launch_cancel: Option<crate::CancellationToken>,
156    pub event_queue: Option<Arc<crate::events::EventQueue>>,
157    /// Current worker handle when this context belongs to a delegated
158    /// runtime. `None` denotes the foreground root.
159    pub delegation_parent: Option<String>,
160    /// Host-built foreground Codex plan for exact-model Ultra inheritance.
161    /// Not a model-supplied tool parameter or an authorization grant.
162    pub codex_parent_plan: Option<crate::runtime::openai::catalog::CodexExecutionPlan>,
163    pub secret_prompt: Option<SecretPromptHandle>,
164    /// Runtime-enforced delegation/lifecycle policy. When present, every spawn
165    /// path must authorize before creating channels, threads, or provider runtimes.
166    pub orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
167    /// Discovery/activation capability context (Task 17): passive catalog
168    /// snapshot + retained session-set handle + host-supplied activation
169    /// authority. `None` (default for manual fixtures and non-stream
170    /// contexts) means the discovery/activation builtins fail typed and no
171    /// model-initiated activation is possible.
172    pub tool_activation: Option<crate::tools::discovery::ActivationCapability>,
173    /// Session-scoped exact MCP lease capability (Task 19): exact session
174    /// identity + shared runtime manager. `None` (default for non-stream
175    /// contexts) means deferred MCP tools fail typed and start nothing.
176    pub mcp_leases: Option<crate::mcp::McpLeaseCapability>,
177    /// Session-scoped exact EXTENSION lease capability (Task 20): exact
178    /// session identity + shared extension runtime manager. `None`
179    /// (default for non-stream contexts) means deferred extension tools
180    /// fail typed and start nothing.
181    pub extension_leases: Option<crate::extensions::lease::ExtensionLeaseCapability>,
182    /// Per-session working directory (Phase 2 daemons serve sessions from N
183    /// directories in one process). `None` = inherit the process cwd — the
184    /// only value Phase 1 ever sets, so behaviour is byte-identical.
185    pub cwd: Option<PathBuf>,
186    /// Per-session environment snapshot (session-identity T1). `None` =
187    /// inherit process env. When `Some`, tools call `env_clear().envs()`
188    /// so no daemon env leaks into session subprocesses.
189    pub env: Option<crate::session::types::ToolEnv>,
190    /// Names of env vars stripped as secrets (T5). Tools use this for
191    /// loud notices when a command fails referencing a stripped var.
192    pub env_stripped: Vec<String>,
193    /// Per-name "already warned" guard (T5): prevents duplicate notices
194    /// within a single session. Shared via `Arc<Mutex<…>>` so the same
195    /// set survives across tool calls (caps are rebuilt per call in
196    /// stream.rs). Poisoned-mutex recovery via `into_inner`.
197    pub env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
198    /// Owning conversation id of the tool call, as carried on hook events.
199    /// `None` for workers/subagents and for host contexts with no
200    /// conversation. Forwarded to extension `tool.call` as
201    /// `params.session_id` so a daemon-shared plugin knows its caller.
202    /// Not the lease scope (`tool_session_id`, a random runtime id).
203    pub session_id: Option<String>,
204    /// Extension hook bus, for the notify-only events a builtin tool sends
205    /// (`memory_forget`: `on_memory_forgotten`). A worker carries its own
206    /// view, which sends nothing. `None` wherever no bus exists; the tool
207    /// then works the same and nothing is announced.
208    pub hook_bus: Option<Arc<crate::extensions::hooks::HookBus>>,
209    /// The calling chat's rollover state, where `context_checkpoint` records
210    /// its phase and note. The tool is one instance shared by every chat on
211    /// the host and has no state of its own. `None` for a context with no
212    /// chat: the tool refuses there. (A worker carries its own state, which
213    /// is never enabled, and its registry has no such tool.)
214    pub continuation: Option<crate::runtime::continuation::SharedContinuation>,
215}
216
217impl ToolCapabilities {
218    /// T5: a notice for each stripped secret `pick` selects that this
219    /// session was not told about yet (once per name per session). `bash`
220    /// picks the names a failed command references; `shell_start` all of
221    /// them, up front (an interactive shell's commands never reach a tool
222    /// that could tell).
223    pub(crate) fn stripped_env_notices(&self, pick: impl Fn(&str) -> bool) -> String {
224        let mut notice = String::new();
225        if self.env_stripped.is_empty() {
226            return notice;
227        }
228        let mut warned = self.env_warned.lock().unwrap_or_else(|p| p.into_inner());
229        for name in self.env_stripped.iter().filter(|n| pick(n)) {
230            if warned.insert(name.clone()) {
231                notice.push_str(&format!(
232                    "\nnote: ${name} was stripped from the session env as a secret; \
233                     reattach from a shell that has it \
234                     (synaps --system … from that shell) \
235                     — the daemon never receives credentials."
236                ));
237            }
238        }
239        notice
240    }
241}
242
243/// Configuration limits and timeouts.
244#[derive(Clone)]
245pub struct ToolLimits {
246    /// Context-budget ceiling — final tool output entering history is truncated
247    /// to this. Applied AFTER the `after_tool_call` hook so compression
248    /// extensions see the FULL buffered output and can decide what to keep.
249    pub max_tool_output: usize,
250    /// Memory/pipe safety bound — the maximum bytes a tool may buffer
251    /// internally before truncating with a marker. Sized well above
252    /// `max_tool_output` so transform extensions are never starved.
253    pub max_tool_buffer: usize,
254    pub bash_timeout: u64,
255    pub bash_max_timeout: u64,
256    pub subagent_timeout: u64,
257}
258
259/// Context passed to tool execution — composition of channels, capabilities, and limits.
260pub struct ToolContext {
261    pub channels: ToolChannels,
262    pub capabilities: ToolCapabilities,
263    pub limits: ToolLimits,
264}
265
266/// What the tool calls of one turn are given, whichever loop runs them.
267///
268/// A turn's tool calls are run by the loop that received the model's
269/// answer: the stream loop (its serial and its parallel lane) or, for a
270/// model an extension provides, the loop inside the provider round
271/// (`runtime::openai::extension_route`). The host builds one of these per
272/// turn and each loop makes every call's [`ToolContext`] from it
273/// ([`Self::for_call`]). So what one loop's tools are given, the others'
274/// are given too: there is no second list of fields to keep in step.
275///
276/// Two capabilities belong to one dispatch of the stream loop and are not
277/// part of the turn's context: `tool_activation` (the catalog snapshot of
278/// one provider round) and `tool_register_tx` (a channel that dispatch
279/// drains). The stream loop adds them with [`Self::for_dispatch`]. The
280/// extension model's loop does not have them, on purpose: its tool list is
281/// fixed for the whole provider round, so a tool activated inside it could
282/// not be offered or called there, and nothing would drain a registration.
283///
284/// The event channel is the turn's too (`tx_events` of every call): the
285/// stream's own sender, which outlives a provider round. Only the delta
286/// lane is made per call.
287///
288/// One pointer wide: it rides in `ApiOptions` and in the stream loop's
289/// state, where a copy of every capability per clone is stack the loop does
290/// not have to spare in a debug build.
291#[derive(Clone)]
292pub struct TurnToolContext(Arc<TurnTools>);
293
294struct TurnTools {
295    capabilities: ToolCapabilities,
296    limits: ToolLimits,
297    events: Option<tokio::sync::mpsc::UnboundedSender<crate::StreamEvent>>,
298}
299
300impl TurnToolContext {
301    /// `capabilities` as the host has them for this turn. Its
302    /// `tool_activation` and `tool_register_tx` are per dispatch
303    /// ([`Self::for_dispatch`]) and are dropped here. `events` is the
304    /// turn's event channel (`None`: the turn has no frontend to tell).
305    pub fn new(
306        mut capabilities: ToolCapabilities,
307        limits: ToolLimits,
308        events: Option<tokio::sync::mpsc::UnboundedSender<crate::StreamEvent>>,
309    ) -> Self {
310        capabilities.tool_activation = None;
311        capabilities.tool_register_tx = None;
312        Self(Arc::new(TurnTools {
313            capabilities,
314            limits,
315            events,
316        }))
317    }
318
319    /// For a call that belongs to no chat (the public routing facade, used
320    /// by tests): no capability at all, so the tools run in the process
321    /// folder with the process environment, for no conversation, and the
322    /// memory tools refuse.
323    pub fn no_chat() -> Self {
324        Self(Arc::new(TurnTools {
325            capabilities: ToolCapabilities::default(),
326            limits: ToolLimits {
327                max_tool_output: 30000,
328                max_tool_buffer: 256 * 1024,
329                bash_timeout: 30,
330                bash_max_timeout: 300,
331                subagent_timeout: 300,
332            },
333            events: None,
334        }))
335    }
336
337    /// This turn's context for one dispatch of the stream loop: with the
338    /// round's activation capability and the dispatch's registration channel.
339    pub fn for_dispatch(
340        &self,
341        tool_register_tx: tokio::sync::mpsc::UnboundedSender<Vec<Arc<dyn Tool>>>,
342        tool_activation: crate::tools::discovery::ActivationCapability,
343    ) -> Self {
344        let mut capabilities = self.0.capabilities.clone();
345        capabilities.tool_register_tx = Some(tool_register_tx);
346        capabilities.tool_activation = Some(tool_activation);
347        Self(Arc::new(TurnTools {
348            capabilities,
349            limits: self.0.limits.clone(),
350            events: self.0.events.clone(),
351        }))
352    }
353
354    /// The context of one tool call. `tx_delta` is the call's own lane for
355    /// partial output (`None` where the call is not shown as it runs).
356    pub fn for_call(&self, tx_delta: Option<crate::tools::output::DeltaSender>) -> ToolContext {
357        ToolContext {
358            channels: ToolChannels {
359                tx_delta,
360                tx_events: self.0.events.clone(),
361            },
362            capabilities: self.0.capabilities.clone(),
363            limits: self.0.limits.clone(),
364        }
365    }
366
367    pub fn capabilities(&self) -> &ToolCapabilities {
368        &self.0.capabilities
369    }
370
371    pub fn limits(&self) -> &ToolLimits {
372        &self.0.limits
373    }
374}
375
376/// Fields elided: capabilities hold handles, and a session's forwarded
377/// secrets must not be printed.
378impl std::fmt::Debug for TurnToolContext {
379    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380        f.debug_struct("TurnToolContext")
381            .field("session_id", &self.0.capabilities.session_id)
382            .field("cwd", &self.0.capabilities.cwd)
383            .finish_non_exhaustive()
384    }
385}
386
387/// Explicit runtime-origin identity of a registered tool implementation.
388///
389/// This is the metadata boundary the capability catalog trusts for
390/// provenance. The default is conservative: a tool that declares nothing is
391/// [`ToolOrigin::Unknown`] (or [`ToolOrigin::Extension`] when it already
392/// declares an owning extension via [`Tool::extension_id`]) — never builtin.
393#[derive(Clone, Debug, PartialEq, Eq)]
394pub enum ToolOrigin {
395    /// Compiled into this binary.
396    Builtin,
397    /// Registered by a locally installed extension (`<extension_id>:<tool>`
398    /// runtime names).
399    Extension { extension_id: String },
400    /// Bridged from an MCP server; identifies the server and the tool name
401    /// as the server knows it (not the prefixed runtime name).
402    Mcp {
403        server_id: String,
404        server_tool_name: String,
405    },
406    /// Declared by a plugin definition.
407    Plugin {
408        plugin_id: String,
409        tool_name: String,
410    },
411    /// No declared origin. Cataloged conservatively, never as builtin.
412    Unknown,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub enum ConcurrencyKey {
417    Key(String),
418    Serialize,
419}
420
421/// Structured tool output. `Text` is the legacy contract (what `execute`
422/// returns). `Blocks` carries Anthropic-shaped content blocks destined for
423/// `tool_result.content`, plus a plain-text `summary` used everywhere a
424/// `String` is expected today: UI preview, `after_tool_call` hooks, trace
425/// ledger byte counts, headless logs, and providers that cannot carry the
426/// blocks. INVARIANT: `blocks[0]` MUST be a `{"type":"text"}` block whose
427/// `text` == `summary` (compaction reads `blocks[0].text`; non-Anthropic
428/// translators join only text sub-blocks).
429#[derive(Debug, Clone)]
430pub enum ToolOutput {
431    Text(String),
432    Blocks { blocks: Vec<Value>, summary: String },
433}
434
435impl ToolOutput {
436    pub fn summary(&self) -> &str {
437        match self {
438            Self::Text(s) => s,
439            Self::Blocks { summary, .. } => summary,
440        }
441    }
442
443    /// `(summary, Some(blocks))` for rich output, `(text, None)` for plain.
444    /// This is the one choke point every rich result passes through, so the
445    /// `blocks[0]` text-first invariant is ENFORCED here in every build: a
446    /// `Blocks` value whose first block is not `{"type":"text"}` gets a text
447    /// block built from `summary` prepended. Debug builds additionally
448    /// assert that an existing leading text block matches `summary`.
449    pub fn into_parts(self) -> (String, Option<Vec<Value>>) {
450        match self {
451            Self::Text(s) => (s, None),
452            Self::Blocks {
453                mut blocks,
454                summary,
455            } => {
456                let text_first = blocks.first().is_some_and(|b| b["type"] == "text");
457                if !text_first {
458                    blocks.insert(0, serde_json::json!({"type": "text", "text": summary}));
459                }
460                debug_assert!(
461                    blocks[0]["text"] == summary,
462                    "ToolOutput::Blocks invariant: blocks[0] text must equal summary"
463                );
464                (summary, Some(blocks))
465            }
466        }
467    }
468
469    pub fn into_summary(self) -> String {
470        self.into_parts().0
471    }
472}
473
474/// The core trait for all tools. Implement this to add a new tool.
475#[async_trait::async_trait]
476pub trait Tool: Send + Sync {
477    /// Tool name as it appears in the API (e.g. "bash", "read").
478    fn name(&self) -> &str;
479
480    /// Human-readable description sent to the model.
481    fn description(&self) -> &str;
482
483    /// JSON Schema for the tool's parameters.
484    fn parameters(&self) -> Value;
485
486    /// Execute the tool with the given parameters.
487    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String>;
488
489    /// Structured variant of [`Tool::execute`]. DEFAULTED — existing tools
490    /// keep returning `String` via `execute` and never see this. Tools that
491    /// need to place non-text content blocks (images) into the model history
492    /// override this and make `execute` delegate to it.
493    ///
494    /// RECURSION TRAP: the default delegates to `execute`. If you make
495    /// `execute` delegate here (as `ReadTool` does) you MUST override this
496    /// method too, or the pair recurses until the stack blows.
497    async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
498        self.execute(params, ctx).await.map(ToolOutput::Text)
499    }
500
501    /// Owning extension id for tools registered by an extension. Built-in tools return `None`.
502    fn extension_id(&self) -> Option<&str> {
503        None
504    }
505
506    /// The operation name this tool was registered under by its source (an
507    /// extension's own tool name, before the `<extension>:` runtime prefix),
508    /// when that differs from [`Tool::name`]. Typed metadata for policy that
509    /// must not depend on the exposed name alone; `None` when not declared.
510    fn source_operation(&self) -> Option<&str> {
511        None
512    }
513
514    /// Runtime-origin identity used for catalog provenance. Conservative by
515    /// default: tools that declare nothing are `Unknown` (fail-closed source
516    /// trust), and tools that declare an owning extension classify as
517    /// extension-provided. Built-in implementations override this explicitly.
518    fn origin(&self) -> ToolOrigin {
519        match self.extension_id() {
520            Some(extension_id) => ToolOrigin::Extension {
521                extension_id: extension_id.to_string(),
522            },
523            None => ToolOrigin::Unknown,
524        }
525    }
526
527    /// Conservative effect class (Task 24, spec §8.2). The default keeps
528    /// unknown/dynamic tools `NonIdempotent` — serialized execution;
529    /// implementations opt IN to weaker classes explicitly.
530    fn effect(&self) -> crate::tools::catalog::ToolEffect {
531        crate::tools::catalog::ToolEffect::NonIdempotent
532    }
533
534    /// Each call is its own independent actor (a subagent): sibling calls in
535    /// one batch each get their own scheduler lane and run concurrently,
536    /// instead of joining the shared serial lane that `NonIdempotent` tools
537    /// use. Scheduling only — the effect class, and with it the rule that an
538    /// interrupted call is never re-run automatically, is unchanged.
539    fn runs_independently(&self) -> bool {
540        false
541    }
542
543    /// Key resolution derived from validated input. `Serialize` explicitly
544    /// fails closed into the global mutation lane; `None` is the conservative
545    /// default for tools with no key support.
546    fn concurrency_key(&self, _validated_input: &Value) -> Option<ConcurrencyKey> {
547        None
548    }
549
550    /// [`Self::concurrency_key`] for a call made in `cwd`, the chat's folder
551    /// (`None`: the process folder). The scheduler asks this one. A tool
552    /// whose key is a path resolves a relative one against `cwd`, as its
553    /// `execute` does, so the key names the file the call will touch; a
554    /// tool whose key does not depend on the folder has nothing to add.
555    fn concurrency_key_in(
556        &self,
557        validated_input: &Value,
558        _cwd: Option<&std::path::Path>,
559    ) -> Option<ConcurrencyKey> {
560        self.concurrency_key(validated_input)
561    }
562}
563
564#[cfg(test)]
565pub(crate) mod test_helpers;
566
567#[cfg(test)]
568mod stripped_env_notice_tests {
569    /// One notice per picked stripped name, once per session (the shared
570    /// set), the same text `bash` and `shell_start` print.
571    #[test]
572    fn notices_name_each_picked_secret_once_per_session() {
573        let mut caps = super::test_helpers::create_tool_context().capabilities;
574        assert_eq!(caps.stripped_env_notices(|_| true), "", "nothing stripped: nothing to say");
575        caps.env_stripped = vec!["GH_TOKEN".into(), "AWS_SECRET_ACCESS_KEY".into()];
576        let first = caps.stripped_env_notices(|n| n == "GH_TOKEN");
577        assert!(first.contains("note: $GH_TOKEN was stripped from the session env as a secret"), "{first}");
578        assert!(!first.contains("AWS_SECRET_ACCESS_KEY"), "{first}");
579        let all = caps.stripped_env_notices(|_| true);
580        assert!(all.contains("$AWS_SECRET_ACCESS_KEY") && !all.contains("$GH_TOKEN"), "{all}");
581        assert_eq!(caps.stripped_env_notices(|_| true), "", "each name once");
582    }
583}
584
585#[cfg(test)]
586mod turn_tool_context_tests {
587    use super::*;
588
589    /// A turn's context is carried by pointer. Held by value in
590    /// `ApiOptions` and the stream loop's state it overflowed a 2 MiB
591    /// worker stack in a debug build (`session_actor_cache_keepwarm`).
592    #[test]
593    fn a_turns_tool_context_is_one_pointer_wide() {
594        eprintln!(
595            "ToolCapabilities: {} bytes, ToolContext: {} bytes",
596            std::mem::size_of::<ToolCapabilities>(),
597            std::mem::size_of::<ToolContext>(),
598        );
599        assert_eq!(
600            std::mem::size_of::<Option<TurnToolContext>>(),
601            std::mem::size_of::<usize>()
602        );
603    }
604
605    /// The two capabilities of one stream dispatch are not a turn's: `new`
606    /// drops them, `for_dispatch` adds them to its own copy only, and a
607    /// call's context is the turn's otherwise.
608    #[test]
609    fn dispatch_capabilities_never_reach_the_turns_context() {
610        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
611        let (events, _events_rx) = tokio::sync::mpsc::unbounded_channel();
612        let turn = TurnToolContext::new(
613            ToolCapabilities {
614                tool_register_tx: Some(tx.clone()),
615                session_id: Some("chat-A".into()),
616                env_stripped: vec!["SECRET".into()],
617                ..Default::default()
618            },
619            TurnToolContext::no_chat().limits().clone(),
620            Some(events.clone()),
621        );
622        assert!(turn.capabilities().tool_register_tx.is_none());
623
624        let set = crate::tools::activation::SessionToolSet::progressive_core_for_catalog(
625            crate::tools::activation::SessionId::parse("turn-tools").unwrap(),
626            &crate::tools::catalog::ToolCatalog::empty(),
627        );
628        let dispatch = turn.for_dispatch(
629            tx,
630            crate::tools::discovery::ActivationCapability::new(
631                crate::tools::catalog::ToolCatalog::empty(),
632                Arc::new(std::sync::RwLock::new(set)),
633                crate::tools::activation::ActivationAuthority::ModelConfirmed,
634            ),
635        );
636        let (of_turn, of_dispatch) = (turn.for_call(None), dispatch.for_call(None));
637        assert!(of_turn.capabilities.tool_register_tx.is_none());
638        assert!(of_turn.capabilities.tool_activation.is_none());
639        assert!(of_dispatch.capabilities.tool_register_tx.is_some());
640        assert!(of_dispatch.capabilities.tool_activation.is_some());
641        for ctx in [&of_turn, &of_dispatch] {
642            assert_eq!(ctx.capabilities.session_id.as_deref(), Some("chat-A"));
643            assert_eq!(ctx.capabilities.env_stripped, ["SECRET"]);
644            // The turn's event channel, whichever loop makes the call.
645            assert!(ctx
646                .channels
647                .tx_events
648                .as_ref()
649                .is_some_and(|tx| tx.same_channel(&events)));
650            assert!(ctx.channels.tx_delta.is_none());
651        }
652        let none = TurnToolContext::no_chat().for_call(None);
653        assert!(none.channels.tx_events.is_none());
654        assert!(none.capabilities.session_id.is_none() && none.capabilities.env.is_none());
655        assert!(none.capabilities.memory_backend.is_none());
656    }
657}
658
659#[cfg(test)]
660mod tool_output_tests {
661    use super::ToolOutput;
662    use serde_json::json;
663
664    #[test]
665    fn into_parts_prepends_summary_text_when_blocks_not_text_first() {
666        let img = json!({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}});
667        let out = ToolOutput::Blocks {
668            blocks: vec![img.clone()],
669            summary: "[image 1x1]".into(),
670        };
671        let (summary, blocks) = out.into_parts();
672        let blocks = blocks.unwrap();
673        assert_eq!(summary, "[image 1x1]");
674        assert_eq!(blocks.len(), 2);
675        assert_eq!(blocks[0], json!({"type": "text", "text": "[image 1x1]"}));
676        assert_eq!(blocks[1], img);
677    }
678
679    #[test]
680    fn into_parts_prepends_summary_text_when_blocks_empty() {
681        let out = ToolOutput::Blocks {
682            blocks: vec![],
683            summary: "s".into(),
684        };
685        let (_, blocks) = out.into_parts();
686        assert_eq!(blocks.unwrap(), vec![json!({"type": "text", "text": "s"})]);
687    }
688
689    #[test]
690    fn into_parts_leaves_well_formed_blocks_alone() {
691        let blocks = vec![
692            json!({"type": "text", "text": "ok"}),
693            json!({"type": "image"}),
694        ];
695        let out = ToolOutput::Blocks {
696            blocks: blocks.clone(),
697            summary: "ok".into(),
698        };
699        let (_, got) = out.into_parts();
700        assert_eq!(got.unwrap(), blocks);
701    }
702}