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}