synaps-engine 0.11.0

Runtime engine — streaming, tools, MCP, skills, extensions, sidecar
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Tool system — trait, registry, and built-in tool implementations.
//!
//! All tools implement the `Tool` trait and are registered in `ToolRegistry`.
//! Subagents get `ToolRegistry::without_subagent()` to prevent recursion.
use crate::Result;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

// ── Module declarations ──────────────────────────────────────────────────────────

#[cfg(feature = "command-exec")]
mod bash;
mod confirm_latch;
pub(crate) mod context_checkpoint;
mod edit;
mod extension;
mod find;
pub mod forum;
mod grep;
mod ls;
pub mod memory;
mod memory_context;
#[cfg(feature = "command-exec")]
mod powershell;
pub(crate) mod read;
mod secret_prompt;
mod subagent;
pub(crate) use subagent::finalize::build_reload_carry_event;
#[doc(hidden)]
pub use subagent::{legacy_fresh_runtime, spawn_runtime};
mod write;

pub mod activation;
mod agent;
pub mod catalog;
pub mod discovery;
pub mod ledger;
pub mod output;
pub mod prompt_catalog;
mod registry;
pub mod respond;
pub mod send_channel;
pub mod shell;
pub(crate) mod util;
pub(crate) mod utf8_stream;
pub(crate) use util::expand_path;
pub mod watcher_exit;

// ── Re-exports ──────────────────────────────────────────────────────────────────

pub use crate::runtime::subagent::{
    SubagentDisplayRow, SubagentHandle, SubagentRegistry, SubagentResult, SubagentState,
    SubagentStatus,
};
pub use agent::resolve_agent_prompt;
#[cfg(feature = "command-exec")]
pub use bash::{bash_intermediary_snapshot, BashIntermediarySnapshot, BashTool};
pub use discovery::{ActivateToolsTool, SearchToolsTool};
pub use edit::EditTool;
pub use extension::ExtensionTool;
pub use find::FindTool;
pub use grep::GrepTool;
pub use ls::LsTool;
pub use memory_context::MemoryContextTool;
#[cfg(feature = "command-exec")]
pub use powershell::PowerShellTool;
pub use read::ReadTool;
pub use registry::{
    DeferredToolSplit, DroppedSessionMember, SessionSchemaProjection, ToolRegistry,
};
pub use respond::RespondTool;
pub use confirm_latch::{latch_keys, LatchKey, SessionConfirmLatch};
pub use secret_prompt::SecretPromptQueue;
pub use secret_prompt::{
    ConfirmChoice, ConfirmScope, PromptKind, SecretPromptHandle, SecretPromptRequest,
    CONFIRM_ANSWER_ALLOW_ALL,
};
pub use send_channel::SendChannelTool;
pub use shell::{ShellEndTool, ShellSendTool, ShellStartTool};
pub use subagent::{
    SubagentCollectTool, SubagentModelAuthorizeTool, SubagentModelsTool, SubagentResumeTool,
    SubagentStartTool, SubagentStatusTool, SubagentSteerTool, SubagentTool,
};
pub use watcher_exit::{WatcherExitTool, WATCHER_EXIT_ACK};
pub use write::WriteTool;

// Re-export util items used by sibling tool modules via `super::`
pub(crate) use util::{
    peek_next_subagent_id, resolve_path_in, seed_next_subagent_id, strip_ansi, NEXT_SUBAGENT_ID,
};

// Facade: expose finalize internals for integration tests without making the
// subagent module pub. Tests import `agent_engine::tools::{build_completion_event, finalize_subagent}`.
#[doc(hidden)]
pub use subagent::finalize::{build_completion_event, finalize_subagent};

// ── Tool Trait ──────────────────────────────────────────────────────────────────

/// Streaming channels — carry partial tool output and stream events.
///
/// `tx_delta` is the Task 26 bounded delta lane (spec §8.4): a non-blocking
/// coalesce-then-drop producer handle whose consumer enforces the UI-preview
/// byte budget. It replaced the previous unbounded `mpsc` sender so a fast
/// producer with a slow consumer can no longer grow memory without bound.
pub struct ToolChannels {
    pub tx_delta: Option<crate::tools::output::DeltaSender>,
    pub tx_events: Option<tokio::sync::mpsc::UnboundedSender<crate::StreamEvent>>,
}

/// Runtime capability handles — shared services a tool may require.
pub struct ToolCapabilities {
    /// Parent-session extension Confirm latch: the set of extension ids the
    /// user chose "Allow all" for. Workers share the Arc (including live
    /// updates and revokes), never a snapshot or host-global approval. This
    /// grants no `activate_tools` authority. None is for contexts without a
    /// parent session.
    pub session_allow_all: Option<Arc<SessionConfirmLatch>>,
    /// Host-owned note storage binding. Runtime contexts always provide the
    /// same binding; `None` is reserved for manually constructed contexts.
    /// A present binding is exclusive: errors never permit legacy fallback.
    pub memory_backend: Option<crate::memory_backend::MemoryBinding>,
    /// Parent-session telemetry for the subagents this context spawns: they
    /// record at the same level through the parent's session writer. `None`
    /// (manual contexts) leaves workers `Off`.
    pub telemetry: Option<crate::runtime::telemetry::WorkerTelemetry>,
    pub watcher_exit_path: Option<PathBuf>,
    pub tool_register_tx: Option<tokio::sync::mpsc::UnboundedSender<Vec<Arc<dyn Tool>>>>,
    pub session_manager: Option<std::sync::Arc<crate::tools::shell::SessionManager>>,
    pub subagent_registry: Option<Arc<Mutex<SubagentRegistry>>>,
    /// Originating turn cancellation, pinned before tool dispatch. A late
    /// registration cannot inherit a later user's renewed launch authority.
    pub launch_cancel: Option<crate::CancellationToken>,
    pub event_queue: Option<Arc<crate::events::EventQueue>>,
    /// Current worker handle when this context belongs to a delegated
    /// runtime. `None` denotes the foreground root.
    pub delegation_parent: Option<String>,
    /// Host-built foreground Codex plan for exact-model Ultra inheritance.
    /// Not a model-supplied tool parameter or an authorization grant.
    pub codex_parent_plan: Option<crate::runtime::openai::catalog::CodexExecutionPlan>,
    pub secret_prompt: Option<SecretPromptHandle>,
    /// Runtime-enforced delegation/lifecycle policy. When present, every spawn
    /// path must authorize before creating channels, threads, or provider runtimes.
    pub orchestration: Option<Arc<crate::orchestration::OrchestrationRuntime>>,
    /// Discovery/activation capability context (Task 17): passive catalog
    /// snapshot + retained session-set handle + host-supplied activation
    /// authority. `None` (default for manual fixtures and non-stream
    /// contexts) means the discovery/activation builtins fail typed and no
    /// model-initiated activation is possible.
    pub tool_activation: Option<crate::tools::discovery::ActivationCapability>,
    /// Session-scoped exact MCP lease capability (Task 19): exact session
    /// identity + shared runtime manager. `None` (default for non-stream
    /// contexts) means deferred MCP tools fail typed and start nothing.
    pub mcp_leases: Option<crate::mcp::McpLeaseCapability>,
    /// Session-scoped exact EXTENSION lease capability (Task 20): exact
    /// session identity + shared extension runtime manager. `None`
    /// (default for non-stream contexts) means deferred extension tools
    /// fail typed and start nothing.
    pub extension_leases: Option<crate::extensions::lease::ExtensionLeaseCapability>,
    /// Session-scoped continuous-memory context capability (task A4, spec
    /// §7.2): shared per-session memory state + host-owned grant scope.
    /// `None` (default for non-stream contexts) means the `memory_context`
    /// builtin can commit nothing — lease-granting actions fail typed,
    /// while `status`/`disable` still answer deterministically `Off`
    /// (memory off requires no infrastructure).
    pub memory_context: Option<crate::runtime::memory_context::MemoryContextCapability>,
    /// Per-session working directory (Phase 2 daemons serve sessions from N
    /// directories in one process). `None` = inherit the process cwd — the
    /// only value Phase 1 ever sets, so behaviour is byte-identical.
    pub cwd: Option<PathBuf>,
    /// Per-session environment snapshot (session-identity T1). `None` =
    /// inherit process env. When `Some`, tools call `env_clear().envs()`
    /// so no daemon env leaks into session subprocesses.
    pub env: Option<crate::session::types::SessionEnv>,
    /// Names of env vars stripped as secrets (T5). Tools use this for
    /// loud notices when a command fails referencing a stripped var.
    pub env_stripped: Vec<String>,
    /// Per-name "already warned" guard (T5): prevents duplicate notices
    /// within a single session. Shared via `Arc<Mutex<…>>` so the same
    /// set survives across tool calls (caps are rebuilt per call in
    /// stream.rs). Poisoned-mutex recovery via `into_inner`.
    pub env_warned: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
    /// Owning conversation id of the tool call, as carried on hook events.
    /// `None` for workers/subagents and for host contexts with no
    /// conversation. Forwarded to extension `tool.call` as
    /// `params.session_id` so a daemon-shared plugin knows its caller.
    /// Not the lease scope (`tool_session_id`, a random runtime id).
    pub session_id: Option<String>,
}

/// Configuration limits and timeouts.
pub struct ToolLimits {
    /// Context-budget ceiling — final tool output entering history is truncated
    /// to this. Applied AFTER the `after_tool_call` hook so compression
    /// extensions see the FULL buffered output and can decide what to keep.
    pub max_tool_output: usize,
    /// Memory/pipe safety bound — the maximum bytes a tool may buffer
    /// internally before truncating with a marker. Sized well above
    /// `max_tool_output` so transform extensions are never starved.
    pub max_tool_buffer: usize,
    pub bash_timeout: u64,
    pub bash_max_timeout: u64,
    pub subagent_timeout: u64,
}

/// Context passed to tool execution — composition of channels, capabilities, and limits.
pub struct ToolContext {
    pub channels: ToolChannels,
    pub capabilities: ToolCapabilities,
    pub limits: ToolLimits,
}

/// Explicit runtime-origin identity of a registered tool implementation.
///
/// This is the metadata boundary the capability catalog trusts for
/// provenance. The default is conservative: a tool that declares nothing is
/// [`ToolOrigin::Unknown`] (or [`ToolOrigin::Extension`] when it already
/// declares an owning extension via [`Tool::extension_id`]) — never builtin.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToolOrigin {
    /// Compiled into this binary.
    Builtin,
    /// Registered by a locally installed extension (`<extension_id>:<tool>`
    /// runtime names).
    Extension { extension_id: String },
    /// Bridged from an MCP server; identifies the server and the tool name
    /// as the server knows it (not the prefixed runtime name).
    Mcp {
        server_id: String,
        server_tool_name: String,
    },
    /// Declared by a plugin definition.
    Plugin {
        plugin_id: String,
        tool_name: String,
    },
    /// No declared origin. Cataloged conservatively, never as builtin.
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConcurrencyKey {
    Key(String),
    Serialize,
}

/// Structured tool output. `Text` is the legacy contract (what `execute`
/// returns). `Blocks` carries Anthropic-shaped content blocks destined for
/// `tool_result.content`, plus a plain-text `summary` used everywhere a
/// `String` is expected today: UI preview, `after_tool_call` hooks, trace
/// ledger byte counts, headless logs, and providers that cannot carry the
/// blocks. INVARIANT: `blocks[0]` MUST be a `{"type":"text"}` block whose
/// `text` == `summary` (compaction reads `blocks[0].text`; non-Anthropic
/// translators join only text sub-blocks).
#[derive(Debug, Clone)]
pub enum ToolOutput {
    Text(String),
    Blocks { blocks: Vec<Value>, summary: String },
}

impl ToolOutput {
    pub fn summary(&self) -> &str {
        match self {
            Self::Text(s) => s,
            Self::Blocks { summary, .. } => summary,
        }
    }

    /// `(summary, Some(blocks))` for rich output, `(text, None)` for plain.
    /// This is the one choke point every rich result passes through, so the
    /// `blocks[0]` text-first invariant is ENFORCED here in every build: a
    /// `Blocks` value whose first block is not `{"type":"text"}` gets a text
    /// block built from `summary` prepended. Debug builds additionally
    /// assert that an existing leading text block matches `summary`.
    pub fn into_parts(self) -> (String, Option<Vec<Value>>) {
        match self {
            Self::Text(s) => (s, None),
            Self::Blocks {
                mut blocks,
                summary,
            } => {
                let text_first = blocks.first().is_some_and(|b| b["type"] == "text");
                if !text_first {
                    blocks.insert(0, serde_json::json!({"type": "text", "text": summary}));
                }
                debug_assert!(
                    blocks[0]["text"] == summary,
                    "ToolOutput::Blocks invariant: blocks[0] text must equal summary"
                );
                (summary, Some(blocks))
            }
        }
    }

    pub fn into_summary(self) -> String {
        self.into_parts().0
    }
}

/// The core trait for all tools. Implement this to add a new tool.
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
    /// Tool name as it appears in the API (e.g. "bash", "read").
    fn name(&self) -> &str;

    /// Human-readable description sent to the model.
    fn description(&self) -> &str;

    /// JSON Schema for the tool's parameters.
    fn parameters(&self) -> Value;

    /// Execute the tool with the given parameters.
    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String>;

    /// Structured variant of [`Tool::execute`]. DEFAULTED — existing tools
    /// keep returning `String` via `execute` and never see this. Tools that
    /// need to place non-text content blocks (images) into the model history
    /// override this and make `execute` delegate to it.
    ///
    /// RECURSION TRAP: the default delegates to `execute`. If you make
    /// `execute` delegate here (as `ReadTool` does) you MUST override this
    /// method too, or the pair recurses until the stack blows.
    async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
        self.execute(params, ctx).await.map(ToolOutput::Text)
    }

    /// Owning extension id for tools registered by an extension. Built-in tools return `None`.
    fn extension_id(&self) -> Option<&str> {
        None
    }

    /// The operation name this tool was registered under by its source (an
    /// extension's own tool name, before the `<extension>:` runtime prefix),
    /// when that differs from [`Tool::name`]. Typed metadata for policy that
    /// must not depend on the exposed name alone; `None` when not declared.
    fn source_operation(&self) -> Option<&str> {
        None
    }

    /// Runtime-origin identity used for catalog provenance. Conservative by
    /// default: tools that declare nothing are `Unknown` (fail-closed source
    /// trust), and tools that declare an owning extension classify as
    /// extension-provided. Built-in implementations override this explicitly.
    fn origin(&self) -> ToolOrigin {
        match self.extension_id() {
            Some(extension_id) => ToolOrigin::Extension {
                extension_id: extension_id.to_string(),
            },
            None => ToolOrigin::Unknown,
        }
    }

    /// Conservative effect class (Task 24, spec §8.2). The default keeps
    /// unknown/dynamic tools `NonIdempotent` — serialized execution;
    /// implementations opt IN to weaker classes explicitly.
    fn effect(&self) -> crate::tools::catalog::ToolEffect {
        crate::tools::catalog::ToolEffect::NonIdempotent
    }

    /// Each call is its own independent actor (a subagent): sibling calls in
    /// one batch each get their own scheduler lane and run concurrently,
    /// instead of joining the shared serial lane that `NonIdempotent` tools
    /// use. Scheduling only — the effect class, and with it the rule that an
    /// interrupted call is never re-run automatically, is unchanged.
    fn runs_independently(&self) -> bool {
        false
    }

    /// Key resolution derived from validated input. `Serialize` explicitly
    /// fails closed into the global mutation lane; `None` is the conservative
    /// default for tools with no key support.
    fn concurrency_key(&self, _validated_input: &Value) -> Option<ConcurrencyKey> {
        None
    }
}

#[cfg(test)]
pub(crate) mod test_helpers;

#[cfg(test)]
mod tool_output_tests {
    use super::ToolOutput;
    use serde_json::json;

    #[test]
    fn into_parts_prepends_summary_text_when_blocks_not_text_first() {
        let img = json!({"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA=="}});
        let out = ToolOutput::Blocks {
            blocks: vec![img.clone()],
            summary: "[image 1x1]".into(),
        };
        let (summary, blocks) = out.into_parts();
        let blocks = blocks.unwrap();
        assert_eq!(summary, "[image 1x1]");
        assert_eq!(blocks.len(), 2);
        assert_eq!(blocks[0], json!({"type": "text", "text": "[image 1x1]"}));
        assert_eq!(blocks[1], img);
    }

    #[test]
    fn into_parts_prepends_summary_text_when_blocks_empty() {
        let out = ToolOutput::Blocks {
            blocks: vec![],
            summary: "s".into(),
        };
        let (_, blocks) = out.into_parts();
        assert_eq!(blocks.unwrap(), vec![json!({"type": "text", "text": "s"})]);
    }

    #[test]
    fn into_parts_leaves_well_formed_blocks_alone() {
        let blocks = vec![
            json!({"type": "text", "text": "ok"}),
            json!({"type": "image"}),
        ];
        let out = ToolOutput::Blocks {
            blocks: blocks.clone(),
            summary: "ok".into(),
        };
        let (_, got) = out.into_parts();
        assert_eq!(got.unwrap(), blocks);
    }
}