1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4
5use tokio_util::sync::CancellationToken;
6
7use crate::error::RuntimeError;
8use crate::value::Value;
9
10pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
12
13pub type ToolResult = Result<Value, RuntimeError>;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Tier {
18 Zero,
19 One,
20 Two,
21 Three,
22 Four,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum ApprovalLevel {
27 Auto,
28 Approve,
29 Dangerous,
30}
31
32impl ApprovalLevel {
33 pub fn from_tier(tier: Tier) -> Self {
34 match tier {
35 Tier::Zero => ApprovalLevel::Auto,
36 Tier::One | Tier::Two => ApprovalLevel::Approve,
37 Tier::Three | Tier::Four => ApprovalLevel::Dangerous,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CancelBehavior {
44 AbortSafe,
45 Revertible,
46 Atomic,
47 Irreversible,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum InvocationPlane {
52 Ordinary,
53 PermissionControl,
54}
55
56#[derive(Debug, Default, Clone)]
57pub struct ToolArgs {
58 pub positional: Vec<Value>,
59 pub named: Vec<(String, Value)>,
60}
61
62impl ToolArgs {
63 pub fn positional(&self, index: usize) -> Result<&Value, RuntimeError> {
64 self.positional
65 .get(index)
66 .ok_or_else(|| RuntimeError::MissingArg(format!("positional[{index}]")))
67 }
68
69 pub fn named(&self, name: &str) -> Option<&Value> {
70 self.named.iter().find(|(k, _)| k == name).map(|(_, v)| v)
71 }
72}
73
74#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
75pub enum HistorySegment {
76 #[default]
77 Root,
78 Spawned,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum PathOrigin {
83 Omitted,
84 Relative,
85 ExplicitInside,
86 ExplicitExternal,
87 Unbound,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct ResolvedPath {
92 pub path: std::path::PathBuf,
93 pub origin: PathOrigin,
94}
95
96#[derive(Clone, Default)]
97pub struct ToolCtx {
98 pub cancel: CancellationToken,
99 pub turn_id: Option<crate::event::TurnId>,
100 pub flow_run_id: Option<crate::event::FlowRunId>,
101 pub history_segment: HistorySegment,
102 pub event_seq: Option<u64>,
103 pub prompt_resolver: Option<std::sync::Arc<dyn crate::rendezvous::PromptResolver>>,
104 pub registry: Option<std::sync::Arc<ToolRegistry>>,
105 pub sandbox: Option<std::sync::Arc<dyn crate::sandbox::Sandbox>>,
106 pub events: Option<crate::event::EventSink>,
107 pub stdout_broadcast: Option<tokio::sync::broadcast::Sender<String>>,
108 pub session_messages: Option<std::sync::Arc<Vec<crate::message::Message>>>,
109 pub session_messages_handle:
110 Option<std::sync::Arc<std::sync::Mutex<Vec<crate::message::Message>>>>,
111 pub session_runtime: Option<std::sync::Arc<crate::session::Session>>,
112 pub compact_lock_handle: Option<std::sync::Arc<tokio::sync::Mutex<()>>>,
113 pub(crate) context_epoch_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
114 pub(crate) context_prefix_tracker:
115 Option<std::sync::Arc<std::sync::Mutex<crate::context_plan::ContextPrefixTracker>>>,
116 pub current_node_id: Option<String>,
117 pub stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
118 pub read_files:
119 Option<std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>>,
120 pub approval: Option<std::sync::Arc<crate::session::ApprovalRegistry>>,
121 pub permission_broker: Option<std::sync::Arc<crate::permission::PermissionBroker>>,
122 pub(crate) invocation_authorization: Option<crate::permission::InvocationAuthorization>,
123 pub forms: Option<std::sync::Arc<crate::session::FormRegistry>>,
124 pub providers: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
125 pub session_dir: Option<std::path::PathBuf>,
126 pub output_store: Option<std::sync::Arc<crate::tools::tool_output::OutputStore>>,
127 pub data_root: Option<std::path::PathBuf>,
128 pub project_index: Option<std::sync::Arc<crate::index::AnchorIndex>>,
129 pub fs_access: crate::fs_access::FsAccessPolicy,
130 pub workspace: Option<crate::git_workspace::WorkspaceBinding>,
131 pub flow_workspace_service: Option<std::sync::Arc<crate::flow_workspace::FlowWorkspaceService>>,
132 pub lifecycle_fire_tx:
133 Option<tokio::sync::mpsc::UnboundedSender<atman_dsl::ast::LifecycleEvent>>,
134 pub bg_registry: Option<std::sync::Arc<crate::tools::bash_bg::BgRegistry>>,
135 pub term_registry: Option<std::sync::Arc<crate::tools::term::TermRegistry>>,
136 pub watch_hub: Option<std::sync::Arc<crate::watch::WatchHub>>,
137 pub flow_registry: Option<std::sync::Arc<crate::tools::agent_ctrl::FlowRegistry>>,
138 pub flow_identity: Option<std::sync::Arc<crate::flow_authority::FlowIdentity>>,
139 pub task_registry: Option<crate::task_registry::TaskRegistry>,
140 pub session_id: Option<String>,
141 pub trust: Option<crate::trust::TrustConfig>,
142 pub safety: Option<crate::safety::SafetyConfig>,
143 pub current_model: Option<String>,
144 pub call_intent: Option<crate::message::ToolCallIntent>,
145 pub(crate) model_tool_exposures: Option<ToolExposureRegistry>,
146 pub(crate) invocation_env: crate::invocation_env::InvocationEnv,
147 pub watch_rules: Option<crate::streaming::WatchRules>,
148 pub on_memory_recent: Option<std::sync::Arc<dyn Fn(u16) + Send + Sync>>,
150 pub history_store: Option<std::sync::Arc<dyn crate::history_store::HistoryStore>>,
151 pub agent_entry: Option<std::sync::Arc<crate::tools::agent_ctrl::FlowEntry>>,
152 pub tool_output_budget: crate::tools::tool_output::ToolOutputBudget,
153}
154
155#[derive(Clone, Default)]
156pub(crate) struct ToolExposureRegistry {
157 pending: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<ToolExposureKey, usize>>>,
158}
159
160#[derive(Clone, Debug, PartialEq, Eq, Hash)]
161struct ToolExposureKey {
162 flow_run_id: Option<crate::event::FlowRunId>,
163 tool_use_id: String,
164 tool_name: String,
165}
166
167impl ToolExposureRegistry {
168 pub(crate) fn register_response<I, S>(
169 &self,
170 flow_run_id: Option<&crate::event::FlowRunId>,
171 message: &crate::message::Message,
172 exposed_names: I,
173 ) where
174 I: IntoIterator<Item = S>,
175 S: AsRef<str>,
176 {
177 let exposed_names: std::collections::HashSet<String> = exposed_names
178 .into_iter()
179 .map(|name| name.as_ref().to_string())
180 .collect();
181 let mut id_counts = std::collections::HashMap::new();
182 for part in &message.parts {
183 if let crate::message::MessagePart::ToolUse { id, .. } = part {
184 *id_counts.entry(id.as_str()).or_insert(0usize) += 1;
185 }
186 }
187
188 let mut pending = self.pending.lock().unwrap();
189 for part in &message.parts {
190 let crate::message::MessagePart::ToolUse { id, name, .. } = part else {
191 continue;
192 };
193 if id_counts.get(id.as_str()) != Some(&1) || !exposed_names.contains(name) {
194 continue;
195 }
196 *pending
197 .entry(ToolExposureKey {
198 flow_run_id: flow_run_id.cloned(),
199 tool_use_id: id.clone(),
200 tool_name: name.clone(),
201 })
202 .or_insert(0) += 1;
203 }
204 }
205
206 pub(crate) fn claim(
207 &self,
208 flow_run_id: Option<&crate::event::FlowRunId>,
209 tool_use_id: &str,
210 tool_name: &str,
211 ) -> bool {
212 let key = ToolExposureKey {
213 flow_run_id: flow_run_id.cloned(),
214 tool_use_id: tool_use_id.to_string(),
215 tool_name: tool_name.to_string(),
216 };
217 let mut pending = self.pending.lock().unwrap();
218 let Some(count) = pending.get_mut(&key) else {
219 return false;
220 };
221 *count -= 1;
222 if *count == 0 {
223 pending.remove(&key);
224 }
225 true
226 }
227}
228
229impl ToolCtx {
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 pub fn with_anchors(
235 mut self,
236 turn_id: Option<crate::event::TurnId>,
237 flow_run_id: Option<crate::event::FlowRunId>,
238 event_seq: Option<u64>,
239 ) -> Self {
240 self.turn_id = turn_id;
241 self.flow_run_id = flow_run_id;
242 self.event_seq = event_seq;
243 self
244 }
245
246 pub fn with_history_segment(mut self, segment: HistorySegment) -> Self {
247 self.history_segment = segment;
248 self
249 }
250
251 pub fn with_call_intent(mut self, call_intent: Option<crate::message::ToolCallIntent>) -> Self {
252 self.call_intent = call_intent;
253 self
254 }
255
256 pub(crate) fn with_invocation_env(
257 mut self,
258 invocation_env: crate::invocation_env::InvocationEnv,
259 ) -> Self {
260 self.invocation_env = invocation_env;
261 self
262 }
263
264 pub fn message_flow_run_id(&self) -> Option<crate::event::FlowRunId> {
265 match self.history_segment {
266 HistorySegment::Root => None,
267 HistorySegment::Spawned => self.flow_run_id.clone(),
268 }
269 }
270
271 pub fn with_registry(mut self, registry: std::sync::Arc<ToolRegistry>) -> Self {
272 self.registry = Some(registry);
273 self
274 }
275
276 pub fn with_sandbox(mut self, sandbox: std::sync::Arc<dyn crate::sandbox::Sandbox>) -> Self {
277 self.sandbox = Some(sandbox);
278 self
279 }
280
281 pub fn with_events(mut self, events: crate::event::EventSink) -> Self {
282 self.events = Some(events);
283 self
284 }
285
286 pub fn with_stdout_broadcast(mut self, tx: tokio::sync::broadcast::Sender<String>) -> Self {
287 self.stdout_broadcast = Some(tx);
288 self
289 }
290
291 pub fn with_session_messages(
292 mut self,
293 msgs: std::sync::Arc<Vec<crate::message::Message>>,
294 ) -> Self {
295 self.session_messages = Some(msgs);
296 self
297 }
298
299 pub fn with_session_messages_handle(
300 mut self,
301 handle: std::sync::Arc<std::sync::Mutex<Vec<crate::message::Message>>>,
302 ) -> Self {
303 self.session_messages_handle = Some(handle);
304 self
305 }
306
307 pub fn with_session_runtime(
308 mut self,
309 session: std::sync::Arc<crate::session::Session>,
310 ) -> Self {
311 self.session_runtime = Some(session);
312 self
313 }
314
315 pub fn with_compact_lock_handle(
316 mut self,
317 handle: std::sync::Arc<tokio::sync::Mutex<()>>,
318 ) -> Self {
319 self.compact_lock_handle = Some(handle);
320 self
321 }
322
323 pub(crate) fn context_epoch_seed(&self) -> Option<String> {
324 self.context_epoch_handle.as_ref().map(|epoch| {
325 format!(
326 "generation:{}",
327 epoch.load(std::sync::atomic::Ordering::Relaxed)
328 )
329 })
330 }
331
332 pub(crate) fn advance_context_epoch(&self) {
333 if let Some(epoch) = self.context_epoch_handle.as_ref() {
334 epoch.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
335 }
336 }
337
338 pub fn with_current_node(mut self, node_id: Option<String>) -> Self {
339 self.current_node_id = node_id;
340 self
341 }
342
343 pub fn with_read_files(
344 mut self,
345 set: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
346 ) -> Self {
347 self.read_files = Some(set);
348 self
349 }
350
351 pub fn with_providers(
352 mut self,
353 providers: std::sync::Arc<crate::provider::ProviderRegistry>,
354 ) -> Self {
355 self.providers = Some(providers);
356 self
357 }
358
359 pub fn with_session_dir(mut self, dir: std::path::PathBuf) -> Self {
360 self.output_store = Some(std::sync::Arc::new(
361 crate::tools::tool_output::OutputStore::at(dir.clone()),
362 ));
363 self.session_dir = Some(dir);
364 self
365 }
366
367 pub fn with_output_store(
368 mut self,
369 store: std::sync::Arc<crate::tools::tool_output::OutputStore>,
370 ) -> Self {
371 self.output_store = Some(store);
372 self
373 }
374
375 pub fn with_data_root(mut self, dir: std::path::PathBuf) -> Self {
376 self.data_root = Some(dir);
377 self
378 }
379
380 pub fn with_approval(
381 mut self,
382 approval: std::sync::Arc<crate::session::ApprovalRegistry>,
383 ) -> Self {
384 self.approval = Some(approval);
385 self
386 }
387
388 pub fn with_permission_broker(
389 mut self,
390 broker: std::sync::Arc<crate::permission::PermissionBroker>,
391 ) -> Self {
392 self.permission_broker = Some(broker);
393 self
394 }
395
396 pub(crate) fn authorized_for(
398 &self,
399 authorization: crate::permission::InvocationAuthorization,
400 ) -> Self {
401 let mut ctx = self.clone();
402 ctx.invocation_authorization = Some(authorization);
403 ctx
404 }
405
406 pub(crate) fn invocation_authorization(
407 &self,
408 ) -> Option<&crate::permission::InvocationAuthorization> {
409 self.invocation_authorization.as_ref()
410 }
411
412 pub(crate) fn invocation_authorization_for(
413 &self,
414 tool_name: &str,
415 ) -> Result<&crate::permission::InvocationAuthorization, crate::error::RuntimeError> {
416 let authorization = self.invocation_authorization.as_ref().ok_or_else(|| {
417 crate::error::RuntimeError::ToolFailed(format!(
418 "{tool_name}: missing invocation authorization"
419 ))
420 })?;
421 if authorization.tool_name() != tool_name {
422 return Err(crate::error::RuntimeError::ToolFailed(format!(
423 "{tool_name}: invocation authorization belongs to {}",
424 authorization.tool_name()
425 )));
426 }
427 Ok(authorization)
428 }
429
430 pub fn with_fs_access(mut self, policy: crate::fs_access::FsAccessPolicy) -> Self {
431 self.fs_access = policy;
432 self
433 }
434
435 pub fn with_workspace(mut self, binding: crate::git_workspace::WorkspaceBinding) -> Self {
436 self.fs_access.workspace = Some(binding.path.clone());
437 self.workspace = Some(binding);
438 self
439 }
440
441 pub fn with_flow_workspace_service(
442 mut self,
443 service: std::sync::Arc<crate::flow_workspace::FlowWorkspaceService>,
444 ) -> Self {
445 self.flow_workspace_service = Some(service);
446 self
447 }
448
449 pub fn resolve_cwd(
450 &self,
451 explicit: Option<&std::path::Path>,
452 ) -> Result<std::path::PathBuf, RuntimeError> {
453 Ok(self.resolve_cwd_with_origin(explicit)?.path)
454 }
455
456 pub fn resolve_cwd_with_origin(
457 &self,
458 explicit: Option<&std::path::Path>,
459 ) -> Result<ResolvedPath, RuntimeError> {
460 match explicit {
461 Some(path) => self.resolve_path_with_origin(path),
462 None => {
463 let mut resolved = self.resolve_path_with_origin(std::path::Path::new("."))?;
464 resolved.origin = if self.workspace.is_some() {
465 PathOrigin::Omitted
466 } else {
467 PathOrigin::Unbound
468 };
469 Ok(resolved)
470 }
471 }
472 }
473
474 pub fn resolve_path(&self, path: &std::path::Path) -> Result<std::path::PathBuf, RuntimeError> {
475 Ok(self.resolve_path_with_origin(path)?.path)
476 }
477
478 pub fn resolve_path_with_origin(
479 &self,
480 path: &std::path::Path,
481 ) -> Result<ResolvedPath, RuntimeError> {
482 let Some(binding) = &self.workspace else {
483 let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
484 let candidate = if path.is_absolute() {
485 path.to_path_buf()
486 } else {
487 base.join(path)
488 };
489 return Ok(ResolvedPath {
490 path: crate::fs_access::canonicalize_stable(&candidate),
491 origin: PathOrigin::Unbound,
492 });
493 };
494 let root = crate::fs_access::canonicalize_stable(&binding.path);
495 let candidate = if path.is_absolute() {
496 path.to_path_buf()
497 } else {
498 binding.path.join(path)
499 };
500 let resolved = crate::fs_access::canonicalize_stable(&candidate);
501 if path.is_absolute() {
502 return Ok(ResolvedPath {
503 origin: if resolved.starts_with(&root) {
504 PathOrigin::ExplicitInside
505 } else {
506 PathOrigin::ExplicitExternal
507 },
508 path: resolved,
509 });
510 }
511 if !resolved.starts_with(&root) {
512 return Err(RuntimeError::ToolFailed(format!(
513 "managed workspace path {} escapes workspace root {}",
514 path.display(),
515 binding.path.display()
516 )));
517 }
518 Ok(ResolvedPath {
519 path: resolved,
520 origin: PathOrigin::Relative,
521 })
522 }
523
524 pub fn with_lifecycle_fire_tx(
525 mut self,
526 tx: tokio::sync::mpsc::UnboundedSender<atman_dsl::ast::LifecycleEvent>,
527 ) -> Self {
528 self.lifecycle_fire_tx = Some(tx);
529 self
530 }
531
532 pub fn with_forms(mut self, forms: std::sync::Arc<crate::session::FormRegistry>) -> Self {
533 self.forms = Some(forms);
534 self
535 }
536
537 pub fn with_bg_registry(
538 mut self,
539 registry: std::sync::Arc<crate::tools::bash_bg::BgRegistry>,
540 ) -> Self {
541 self.bg_registry = Some(registry);
542 self
543 }
544
545 pub fn with_session_id(mut self, id: impl Into<String>) -> Self {
546 self.session_id = Some(id.into());
547 self
548 }
549
550 pub fn with_trust(mut self, trust: crate::trust::TrustConfig) -> Self {
551 self.trust = Some(trust);
552 self
553 }
554
555 pub fn for_tool_invocation(mut self, _tier: Tier) -> Self {
559 if let Some(session) = self.session_runtime.as_ref() {
560 self.trust = Some(session.trust_config());
561 }
562 self
563 }
564
565 pub fn with_safety(mut self, safety: crate::safety::SafetyConfig) -> Self {
566 self.safety = Some(safety);
567 self
568 }
569
570 pub fn with_current_model(mut self, model: impl Into<String>) -> Self {
571 self.current_model = Some(model.into());
572 self
573 }
574
575 pub fn with_watch_rules(mut self, rules: crate::streaming::WatchRules) -> Self {
576 self.watch_rules = Some(rules);
577 self
578 }
579
580 pub fn with_term_registry(
581 mut self,
582 registry: std::sync::Arc<crate::tools::term::TermRegistry>,
583 ) -> Self {
584 self.term_registry = Some(registry);
585 self
586 }
587
588 pub fn with_watch_hub(mut self, hub: std::sync::Arc<crate::watch::WatchHub>) -> Self {
589 self.watch_hub = Some(hub);
590 self
591 }
592
593 pub fn with_flow_registry(
594 mut self,
595 registry: std::sync::Arc<crate::tools::agent_ctrl::FlowRegistry>,
596 ) -> Self {
597 self.flow_registry = Some(registry);
598 self
599 }
600
601 pub fn with_task_registry(mut self, registry: crate::task_registry::TaskRegistry) -> Self {
602 self.task_registry = Some(registry);
603 self
604 }
605
606 pub fn note_read(&self, path: &std::path::Path) {
607 if let Some(set) = &self.read_files
608 && let Ok(mut lock) = set.lock()
609 {
610 lock.insert(path.to_path_buf());
611 }
612 }
613
614 pub fn has_read(&self, path: &std::path::Path) -> bool {
615 self.read_files
616 .as_ref()
617 .and_then(|set| set.lock().ok().map(|lock| lock.contains(path)))
618 .unwrap_or(false)
619 }
620
621 pub fn with_project_index(mut self, idx: std::sync::Arc<crate::index::AnchorIndex>) -> Self {
622 self.project_index = Some(idx);
623 self
624 }
625
626 pub fn with_history_store(
627 mut self,
628 store: std::sync::Arc<dyn crate::history_store::HistoryStore>,
629 ) -> Self {
630 self.history_store = Some(store);
631 self
632 }
633
634 pub fn with_agent_entry(
635 mut self,
636 entry: std::sync::Arc<crate::tools::agent_ctrl::FlowEntry>,
637 ) -> Self {
638 self.agent_entry = Some(entry);
639 self
640 }
641
642 pub fn with_stream_tx(
643 mut self,
644 tx: tokio::sync::broadcast::Sender<crate::stream::StreamFrame>,
645 ) -> Self {
646 self.stream_tx = Some(tx);
647 self
648 }
649}
650
651pub trait Tool: Send + Sync {
652 fn name(&self) -> &str;
653 fn tier(&self) -> Tier;
654 fn invocation_plane(&self) -> InvocationPlane {
655 InvocationPlane::Ordinary
656 }
657 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
658 ApprovalLevel::from_tier(self.tier())
659 }
660 fn cancel_behavior(&self) -> CancelBehavior {
661 CancelBehavior::AbortSafe
662 }
663 fn description(&self) -> Option<&str> {
664 None
665 }
666 fn input_schema(&self) -> serde_json::Value {
667 serde_json::json!({"type": "object"})
668 }
669 fn invocation_provenance(
671 &self,
672 _args: &ToolArgs,
673 _ctx: &ToolCtx,
674 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
675 Ok(crate::permission::ResourceProvenance::none())
676 }
677 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult>;
678 fn preview_call<'a>(
679 &'a self,
680 _args: &'a ToolArgs,
681 _ctx: &'a ToolCtx,
682 ) -> BoxFut<'a, Option<String>> {
683 Box::pin(async { None })
684 }
685}
686
687pub fn tool_spec(tool: &dyn Tool) -> ToolSpec {
688 let mut input_schema = tool.input_schema();
689 decorate_tool_input_schema(&mut input_schema);
690 canonicalize_json_object_keys(&mut input_schema);
691 ToolSpec {
692 name: tool.name().to_string(),
693 description: tool.description().map(str::to_string),
694 input_schema,
695 }
696}
697
698fn canonicalize_json_object_keys(value: &mut serde_json::Value) {
699 match value {
700 serde_json::Value::Object(object) => {
701 let mut entries: Vec<_> = std::mem::take(object).into_iter().collect();
702 for (_, value) in &mut entries {
703 canonicalize_json_object_keys(value);
704 }
705 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
706 object.extend(entries);
707 }
708 serde_json::Value::Array(items) => {
709 for item in items {
710 canonicalize_json_object_keys(item);
711 }
712 }
713 _ => {}
714 }
715}
716
717fn tool_call_intent_schema() -> serde_json::Value {
718 serde_json::json!({"type": "string"})
719}
720
721fn decorate_tool_input_schema(schema: &mut serde_json::Value) {
722 let Some(root) = schema.as_object_mut() else {
723 return;
724 };
725 if root.get("type").and_then(serde_json::Value::as_str) != Some("object") {
726 return;
727 }
728 let properties = root
729 .entry("properties")
730 .or_insert_with(|| serde_json::Value::Object(Default::default()));
731 let Some(properties) = properties.as_object_mut() else {
732 return;
733 };
734 if properties.contains_key(crate::message::TOOL_CALL_INTENT_FIELD) {
735 return;
736 }
737 properties.insert(
738 crate::message::TOOL_CALL_INTENT_FIELD.into(),
739 tool_call_intent_schema(),
740 );
741}
742
743fn tool_spec_call_intent_support(tool_name: &str, tools: &[ToolSpec]) -> Option<bool> {
744 tools
745 .iter()
746 .find(|tool| tool.name == tool_name)
747 .map(|tool| {
748 tool.input_schema
749 .get("properties")
750 .and_then(serde_json::Value::as_object)
751 .and_then(|properties| properties.get(crate::message::TOOL_CALL_INTENT_FIELD))
752 == Some(&tool_call_intent_schema())
753 })
754}
755
756pub fn tool_spec_supports_call_intent(tool_name: &str, tools: &[ToolSpec]) -> bool {
757 tool_spec_call_intent_support(tool_name, tools) == Some(true)
758}
759
760pub fn tool_spec_blocks_call_intent(tool_name: &str, tools: &[ToolSpec]) -> bool {
761 tool_spec_call_intent_support(tool_name, tools) == Some(false)
762}
763
764pub fn tool_schema_uses_call_intent_field(schema: &serde_json::Value) -> bool {
765 schema
766 .get("properties")
767 .and_then(serde_json::Value::as_object)
768 .is_some_and(|properties| properties.contains_key(crate::message::TOOL_CALL_INTENT_FIELD))
769}
770
771#[derive(Debug, Clone, serde::Serialize)]
772pub struct ToolSpec {
773 pub name: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 pub description: Option<String>,
776 pub input_schema: serde_json::Value,
777}
778
779#[derive(Default, Clone)]
780pub struct ToolRegistry {
781 tools: std::sync::Arc<std::sync::RwLock<HashMap<String, std::sync::Arc<dyn Tool>>>>,
782}
783
784impl ToolRegistry {
785 pub fn new() -> Self {
786 Self::default()
787 }
788
789 pub fn register(&self, tool: std::sync::Arc<dyn Tool>) {
790 assert!(
791 !crate::eval::is_evaluator_intrinsic(tool.name()),
792 "tool name `{}` is reserved for an evaluator intrinsic",
793 tool.name()
794 );
795 self.tools
796 .write()
797 .unwrap()
798 .insert(tool.name().to_string(), tool);
799 }
800
801 pub fn get(&self, name: &str) -> Option<std::sync::Arc<dyn Tool>> {
802 self.tools.read().unwrap().get(name).cloned()
803 }
804
805 pub fn has(&self, name: &str) -> bool {
806 self.tools.read().unwrap().contains_key(name)
807 }
808
809 pub fn names(&self) -> Vec<String> {
810 self.tools.read().unwrap().keys().cloned().collect()
811 }
812
813 pub fn iter(&self) -> Vec<(String, std::sync::Arc<dyn Tool>)> {
814 self.tools
815 .read()
816 .unwrap()
817 .iter()
818 .map(|(k, v)| (k.clone(), v.clone()))
819 .collect()
820 }
821
822 pub fn replace_namespace(&self, prefix: &str, tools: Vec<std::sync::Arc<dyn Tool>>) {
824 assert!(!prefix.is_empty(), "tool namespace prefix cannot be empty");
825 assert!(
826 tools.iter().all(|tool| tool.name().starts_with(prefix)),
827 "replacement tools must belong to namespace `{prefix}`"
828 );
829 let mut registry = self.tools.write().unwrap();
830 registry.retain(|name, _| !name.starts_with(prefix));
831 for tool in tools {
832 registry.insert(tool.name().to_string(), tool);
833 }
834 }
835
836 pub fn retain_namespaces(&self, root_prefix: &str, retained_prefixes: &[String]) {
838 assert!(
839 retained_prefixes
840 .iter()
841 .all(|prefix| prefix.starts_with(root_prefix)),
842 "retained namespaces must belong to root `{root_prefix}`"
843 );
844 self.tools.write().unwrap().retain(|name, _| {
845 !name.starts_with(root_prefix)
846 || retained_prefixes
847 .iter()
848 .any(|prefix| name.starts_with(prefix))
849 });
850 }
851
852 pub fn unregister_prefix(&self, prefix: &str) {
854 self.tools
855 .write()
856 .unwrap()
857 .retain(|k, _| !k.starts_with(prefix));
858 }
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 struct NamedTool(&'static str);
866
867 impl Tool for NamedTool {
868 fn name(&self) -> &str {
869 self.0
870 }
871
872 fn tier(&self) -> Tier {
873 Tier::Zero
874 }
875
876 fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
877 Box::pin(async { Ok(crate::value::Value::Unit) })
878 }
879 }
880
881 #[test]
882 fn namespace_replacement_removes_stale_tools_without_touching_peers() {
883 let registry = ToolRegistry::new();
884 registry.register(std::sync::Arc::new(NamedTool("mcp.alpha.old")));
885 registry.register(std::sync::Arc::new(NamedTool("mcp.beta.keep")));
886
887 registry.replace_namespace(
888 "mcp.alpha.",
889 vec![std::sync::Arc::new(NamedTool("mcp.alpha.new"))],
890 );
891
892 assert!(!registry.has("mcp.alpha.old"));
893 assert!(registry.has("mcp.alpha.new"));
894 assert!(registry.has("mcp.beta.keep"));
895 }
896
897 #[test]
898 fn namespace_retention_only_removes_disabled_sources() {
899 let registry = ToolRegistry::new();
900 registry.register(std::sync::Arc::new(NamedTool("mcp.alpha.keep")));
901 registry.register(std::sync::Arc::new(NamedTool("mcp.beta.remove")));
902 registry.register(std::sync::Arc::new(NamedTool("fs.read")));
903
904 registry.retain_namespaces("mcp.", &["mcp.alpha.".to_string()]);
905
906 assert!(registry.has("mcp.alpha.keep"));
907 assert!(!registry.has("mcp.beta.remove"));
908 assert!(registry.has("fs.read"));
909 }
910
911 #[test]
912 fn model_tool_exposure_is_scoped_exact_and_single_use() {
913 let exposures = ToolExposureRegistry::default();
914 let run = crate::event::FlowRunId::now();
915 let other_run = crate::event::FlowRunId::now();
916 let response = crate::message::Message {
917 role: crate::message::MessageRole::Assistant,
918 parts: vec![crate::message::MessagePart::ToolUse {
919 id: "call-1".into(),
920 name: "allowed.probe".into(),
921 input: serde_json::json!({}),
922 intent: None,
923 }],
924 turn_id: crate::event::TurnId::now(),
925 origin: crate::message::MessageOrigin::User,
926 };
927 exposures.register_response(Some(&run), &response, ["allowed.probe"]);
928
929 assert!(!exposures.claim(Some(&other_run), "call-1", "allowed.probe"));
930 assert!(!exposures.claim(Some(&run), "call-1", "changed.probe"));
931 assert!(exposures.claim(Some(&run), "call-1", "allowed.probe"));
932 assert!(!exposures.claim(Some(&run), "call-1", "allowed.probe"));
933 }
934
935 #[test]
936 fn model_tool_exposure_rejects_unexposed_and_duplicate_response_ids() {
937 let exposures = ToolExposureRegistry::default();
938 let run = crate::event::FlowRunId::now();
939 let response = crate::message::Message {
940 role: crate::message::MessageRole::Assistant,
941 parts: vec![
942 crate::message::MessagePart::ToolUse {
943 id: "duplicate".into(),
944 name: "allowed.probe".into(),
945 input: serde_json::json!({}),
946 intent: None,
947 },
948 crate::message::MessagePart::ToolUse {
949 id: "duplicate".into(),
950 name: "allowed.probe".into(),
951 input: serde_json::json!({}),
952 intent: None,
953 },
954 crate::message::MessagePart::ToolUse {
955 id: "hidden".into(),
956 name: "hidden.probe".into(),
957 input: serde_json::json!({}),
958 intent: None,
959 },
960 ],
961 turn_id: crate::event::TurnId::now(),
962 origin: crate::message::MessageOrigin::User,
963 };
964 exposures.register_response(Some(&run), &response, ["allowed.probe"]);
965
966 assert!(!exposures.claim(Some(&run), "duplicate", "allowed.probe"));
967 assert!(!exposures.claim(Some(&run), "hidden", "hidden.probe"));
968 }
969
970 struct ReservedEnvTool;
971
972 struct ObjectTool;
973
974 impl Tool for ObjectTool {
975 fn name(&self) -> &str {
976 "probe"
977 }
978
979 fn tier(&self) -> Tier {
980 Tier::Zero
981 }
982
983 fn input_schema(&self) -> serde_json::Value {
984 serde_json::json!({
985 "type": "object",
986 "properties": {"value": {"type": "integer"}},
987 "required": ["value"]
988 })
989 }
990
991 fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
992 Box::pin(async { Ok(Value::Unit) })
993 }
994 }
995
996 struct CollidingTool;
997
998 impl Tool for CollidingTool {
999 fn name(&self) -> &str {
1000 "collision"
1001 }
1002
1003 fn tier(&self) -> Tier {
1004 Tier::Zero
1005 }
1006
1007 fn input_schema(&self) -> serde_json::Value {
1008 serde_json::json!({
1009 "type": "object",
1010 "properties": {"_atman_intent": {"type": "integer"}}
1011 })
1012 }
1013
1014 fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1015 Box::pin(async { Ok(Value::Unit) })
1016 }
1017 }
1018
1019 impl Tool for ReservedEnvTool {
1020 fn name(&self) -> &str {
1021 "env"
1022 }
1023
1024 fn tier(&self) -> Tier {
1025 Tier::Zero
1026 }
1027
1028 fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
1029 Box::pin(async { Ok(Value::Unit) })
1030 }
1031 }
1032
1033 #[test]
1034 #[should_panic(expected = "tool name `env` is reserved for an evaluator intrinsic")]
1035 fn evaluator_intrinsic_names_cannot_be_registered_as_tools() {
1036 ToolRegistry::new().register(std::sync::Arc::new(ReservedEnvTool));
1037 }
1038
1039 #[test]
1040 fn tool_spec_adds_optional_call_intent_without_mutating_required() {
1041 let spec = tool_spec(&ObjectTool);
1042 assert_eq!(spec.input_schema["required"], serde_json::json!(["value"]));
1043 assert_eq!(
1044 spec.input_schema["properties"][crate::message::TOOL_CALL_INTENT_FIELD],
1045 tool_call_intent_schema()
1046 );
1047 assert!(tool_spec_supports_call_intent("probe", &[spec]));
1048 assert_eq!(
1049 serde_json::to_vec(&tool_spec(&ObjectTool)).unwrap(),
1050 serde_json::to_vec(&tool_spec(&ObjectTool)).unwrap()
1051 );
1052 }
1053
1054 #[test]
1055 fn tool_spec_preserves_business_field_collision() {
1056 let spec = tool_spec(&CollidingTool);
1057 assert_eq!(
1058 spec.input_schema["properties"][crate::message::TOOL_CALL_INTENT_FIELD],
1059 serde_json::json!({"type": "integer"})
1060 );
1061 assert!(!tool_spec_supports_call_intent("collision", &[spec]));
1062 }
1063
1064 #[test]
1065 fn canonical_json_orders_nested_object_keys() {
1066 let mut left: serde_json::Value =
1067 serde_json::from_str(r#"{"zeta":{"y":1,"x":2},"alpha":0}"#).unwrap();
1068 let mut right: serde_json::Value =
1069 serde_json::from_str(r#"{"alpha":0,"zeta":{"x":2,"y":1}}"#).unwrap();
1070 canonicalize_json_object_keys(&mut left);
1071 canonicalize_json_object_keys(&mut right);
1072 assert_eq!(
1073 serde_json::to_vec(&left).unwrap(),
1074 serde_json::to_vec(&right).unwrap()
1075 );
1076 }
1077
1078 #[test]
1079 fn tool_call_input_codec_round_trips_metadata_and_preserves_collisions() {
1080 let spec = tool_spec(&ObjectTool);
1081 let wire = serde_json::json!({
1082 "value": 7,
1083 "_atman_intent": " inspect\nstate "
1084 });
1085 let (clean, intent) =
1086 crate::message::decode_tool_call_input(wire, "probe", std::slice::from_ref(&spec));
1087 assert_eq!(clean, serde_json::json!({"value": 7}));
1088 assert_eq!(
1089 intent.as_ref().map(|value| value.as_str()),
1090 Some("inspect state")
1091 );
1092 assert_eq!(
1093 crate::message::encode_tool_call_input(&clean, intent.as_ref(), "probe", &[spec]),
1094 serde_json::json!({"value": 7, "_atman_intent": "inspect state"})
1095 );
1096 assert_eq!(
1097 crate::message::encode_tool_call_input(&clean, intent.as_ref(), "probe", &[]),
1098 serde_json::json!({"value": 7, "_atman_intent": "inspect state"})
1099 );
1100
1101 let spec = tool_spec(&ObjectTool);
1102 let (clean, intent) = crate::message::decode_tool_call_input(
1103 serde_json::json!({"value": 7, "_atman_intent": 42}),
1104 "probe",
1105 &[spec],
1106 );
1107 assert_eq!(clean, serde_json::json!({"value": 7}));
1108 assert!(intent.is_none());
1109
1110 let collision = tool_spec(&CollidingTool);
1111 let business_input = serde_json::json!({"_atman_intent": 42});
1112 let (clean, intent) = crate::message::decode_tool_call_input(
1113 business_input.clone(),
1114 "collision",
1115 &[collision],
1116 );
1117 assert_eq!(clean, business_input);
1118 assert!(intent.is_none());
1119 }
1120
1121 #[test]
1122 fn approval_level_default_maps_from_tier() {
1123 assert_eq!(ApprovalLevel::from_tier(Tier::Zero), ApprovalLevel::Auto);
1124 assert_eq!(ApprovalLevel::from_tier(Tier::One), ApprovalLevel::Approve);
1125 assert_eq!(ApprovalLevel::from_tier(Tier::Two), ApprovalLevel::Approve);
1126 assert_eq!(
1127 ApprovalLevel::from_tier(Tier::Three),
1128 ApprovalLevel::Dangerous
1129 );
1130 assert_eq!(
1131 ApprovalLevel::from_tier(Tier::Four),
1132 ApprovalLevel::Dangerous
1133 );
1134 }
1135
1136 #[test]
1137 fn approval_level_ordered_auto_lt_approve_lt_dangerous() {
1138 assert!(ApprovalLevel::Auto < ApprovalLevel::Approve);
1139 assert!(ApprovalLevel::Approve < ApprovalLevel::Dangerous);
1140 }
1141
1142 #[test]
1143 fn invocation_snapshot_tracks_session_trust() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let initial = crate::trust::TrustConfig::default();
1146 let session = std::sync::Arc::new(
1147 crate::session::Session::open_with_trust(dir.path(), initial.clone()).unwrap(),
1148 );
1149 let sandbox: std::sync::Arc<dyn crate::sandbox::Sandbox> =
1150 std::sync::Arc::new(crate::sandbox::SandboxExec::new(dir.path()));
1151 let flows = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
1152 let identity = flows
1153 .register_root(
1154 session.id().to_string(),
1155 crate::event::FlowRunId::now(),
1156 crate::flow_authority::EffectiveAuthority::root(&initial, true, None),
1157 )
1158 .unwrap();
1159 let mut base = ToolCtx::new()
1160 .with_trust(crate::trust::TrustConfig {
1161 mode: crate::trust::TrustMode::Reckless,
1162 ..crate::trust::TrustConfig::default()
1163 })
1164 .with_session_runtime(std::sync::Arc::clone(&session))
1165 .with_sandbox(sandbox);
1166 base.flow_identity = Some(identity);
1167
1168 let controlled = base.clone().for_tool_invocation(Tier::Four);
1169 assert_eq!(controlled.trust, Some(initial));
1170 assert!(controlled.sandbox.is_some());
1171
1172 let reckless = crate::trust::TrustConfig {
1173 mode: crate::trust::TrustMode::Reckless,
1174 ..crate::trust::TrustConfig::default()
1175 };
1176 session.update_trust(reckless.clone(), |_| Ok(())).unwrap();
1177 let unrestricted = base.for_tool_invocation(Tier::Four);
1178
1179 assert_eq!(unrestricted.trust, Some(reckless));
1180 assert!(unrestricted.sandbox.is_some());
1181 assert!(controlled.sandbox.is_some());
1182 }
1183
1184 #[test]
1185 fn invocation_context_retains_sandbox_across_control_tools() {
1186 let dir = tempfile::tempdir().unwrap();
1187 let sandbox: std::sync::Arc<dyn crate::sandbox::Sandbox> =
1188 std::sync::Arc::new(crate::sandbox::SandboxExec::new(dir.path()));
1189 let base = ToolCtx::new().with_sandbox(sandbox);
1190
1191 let control_ctx = base.for_tool_invocation(Tier::Two);
1192 assert!(control_ctx.sandbox.is_some());
1193 assert!(
1194 control_ctx
1195 .for_tool_invocation(Tier::Four)
1196 .sandbox
1197 .is_some()
1198 );
1199 }
1200
1201 fn binding(path: std::path::PathBuf) -> crate::git_workspace::WorkspaceBinding {
1202 crate::git_workspace::WorkspaceBinding {
1203 workspace_id: "workspace".into(),
1204 repository_root: path.clone(),
1205 path,
1206 branch: None,
1207 }
1208 }
1209
1210 #[test]
1211 fn workspace_resolver_rebinds_policy_without_changing_process_cwd() {
1212 let workspace = tempfile::tempdir().unwrap();
1213 let process_cwd = std::env::current_dir().unwrap();
1214 let ctx = ToolCtx::new()
1215 .with_fs_access(crate::fs_access::FsAccessPolicy {
1216 mode: crate::fs_access::FsAccessMode::ReadOnly,
1217 workspace: Some(process_cwd.clone()),
1218 })
1219 .with_workspace(binding(workspace.path().to_path_buf()));
1220
1221 let canonical_workspace = crate::fs_access::canonicalize_stable(workspace.path());
1222 let omitted = ctx.resolve_cwd_with_origin(None).unwrap();
1223 assert_eq!(omitted.path, canonical_workspace);
1224 assert_eq!(omitted.origin, PathOrigin::Omitted);
1225
1226 let relative = ctx
1227 .resolve_path_with_origin(std::path::Path::new("nested/file"))
1228 .unwrap();
1229 assert_eq!(relative.path, canonical_workspace.join("nested/file"));
1230 assert_eq!(relative.origin, PathOrigin::Relative);
1231
1232 let inside = ctx
1233 .resolve_path_with_origin(&workspace.path().join("inside"))
1234 .unwrap();
1235 assert_eq!(inside.origin, PathOrigin::ExplicitInside);
1236
1237 let external = ctx.resolve_path_with_origin(&process_cwd).unwrap();
1238 assert_eq!(external.origin, PathOrigin::ExplicitExternal);
1239 assert_eq!(ctx.fs_access.mode, crate::fs_access::FsAccessMode::ReadOnly);
1240 assert_eq!(ctx.fs_access.workspace.as_deref(), Some(workspace.path()));
1241 assert_eq!(std::env::current_dir().unwrap(), process_cwd);
1242 }
1243
1244 #[test]
1245 fn workspace_resolver_rejects_parent_escape() {
1246 let workspace = tempfile::tempdir().unwrap();
1247 let ctx = ToolCtx::new().with_workspace(binding(workspace.path().to_path_buf()));
1248 let error = ctx
1249 .resolve_path(std::path::Path::new("../outside"))
1250 .unwrap_err();
1251 assert!(error.to_string().contains("escapes workspace root"));
1252 }
1253
1254 #[cfg(unix)]
1255 #[test]
1256 fn workspace_resolver_rejects_symlink_escape() {
1257 let workspace = tempfile::tempdir().unwrap();
1258 let outside = tempfile::tempdir().unwrap();
1259 std::os::unix::fs::symlink(outside.path(), workspace.path().join("link")).unwrap();
1260 let ctx = ToolCtx::new().with_workspace(binding(workspace.path().to_path_buf()));
1261
1262 let error = ctx
1263 .resolve_path(std::path::Path::new("link/file"))
1264 .unwrap_err();
1265 assert!(error.to_string().contains("escapes workspace root"));
1266 }
1267
1268 #[test]
1269 fn ordinary_context_keeps_process_cwd_semantics() {
1270 let process_cwd = std::env::current_dir().unwrap();
1271 let ctx = ToolCtx::new();
1272 assert_eq!(ctx.resolve_cwd(None).unwrap(), process_cwd);
1273 assert_eq!(
1274 ctx.resolve_path(std::path::Path::new("child")).unwrap(),
1275 process_cwd.join("child")
1276 );
1277 }
1278}