1mod agent_dir_script_tool;
13mod artifacts;
14pub(crate) mod builtin;
15mod invocation;
16mod pagination;
17pub(crate) mod process;
18mod program_tool;
19mod registry;
20mod result_transform;
21mod selector;
22pub mod skill;
23pub mod task;
24mod types;
25
26pub use crate::dynamic_workflow::register_dynamic_workflow;
27pub use agent_dir_script_tool::AgentDirScriptTool;
28pub use artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
29pub use builtin::{
30 register_generate_object, register_program, register_program_with_catalog, register_task,
31 register_task_with_mcp, register_task_with_mcp_managers,
32};
33pub(crate) use builtin::{register_skill, register_task_with_mcp_managers_and_scheduler};
34pub(crate) use invocation::{
35 registry_tool_invoker, HostDirectPolicy, InvocationOrigin, ToolInvocation, ToolInvoker,
36};
37pub use program_tool::{ProgramTool, MAX_PROGRAM_SCRIPT_SOURCE_BYTES};
38pub use registry::ToolRegistry;
39pub use result_transform::{
40 ToolResultTransformPolicyV1, TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
41 TOOL_RESULT_TRANSFORM_SCHEMA_V1,
42};
43pub(crate) use selector::is_standalone_conversation;
44pub use selector::{select_tools_for_messages, select_tools_for_prompt};
45pub use task::{
46 parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
47 TaskExecutor, TaskParams, TaskResult, TaskTool,
48};
49pub(crate) use types::{AgentEventBarrier, AgentEventBarrierReceiver};
50pub use types::{
51 InvocationRuntime, Tool, ToolCapabilities, ToolContext, ToolErrorKind, ToolEventSender,
52 ToolOutput, ToolOutputKind, ToolStreamEvent,
53};
54
55use crate::llm::ToolDefinition;
56use anyhow::Result;
57use serde::{Deserialize, Serialize};
58use sha2::{Digest, Sha256};
59use std::collections::HashMap;
60use std::path::PathBuf;
61use std::sync::Arc;
62
63pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; pub const MAX_READ_LINES: usize = 2000;
68
69pub const MAX_LINE_LENGTH: usize = 2000;
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub(crate) struct ToolOutputArtifact {
74 pub artifact_id: String,
75 pub artifact_uri: String,
76 pub original_bytes: usize,
77 pub shown_bytes: usize,
78}
79
80#[derive(Debug, Clone)]
81pub(crate) struct TruncatedToolOutput {
82 pub content: String,
83 pub artifact: Option<ToolOutputArtifact>,
84 pub loss_mode: ToolResultLossModeV1,
85}
86
87#[cfg(test)]
88pub(crate) fn truncate_tool_output_with_artifact(
89 tool_name: &str,
90 output: &str,
91) -> TruncatedToolOutput {
92 transform_tool_output_with_artifact(
93 tool_name,
94 output,
95 &ToolResultTransformPolicyV1::conservative(),
96 )
97}
98
99pub(crate) fn transform_tool_output_with_artifact(
100 tool_name: &str,
101 output: &str,
102 policy: &ToolResultTransformPolicyV1,
103) -> TruncatedToolOutput {
104 let transformed = result_transform::transform(output, policy);
105 if transformed.loss_mode == ToolResultLossModeV1::None {
106 return TruncatedToolOutput {
107 content: transformed.content,
108 artifact: None,
109 loss_mode: transformed.loss_mode,
110 };
111 }
112 let artifact = tool_output_artifact(tool_name, output, transformed.retained_original_bytes);
113 let artifact_uri = artifact.artifact_uri.clone();
114 let content = format!(
115 "{}\n\n[Full output artifact: {artifact_uri}]",
116 transformed.content
117 );
118
119 TruncatedToolOutput {
120 content,
121 artifact: Some(artifact),
122 loss_mode: transformed.loss_mode,
123 }
124}
125
126pub(crate) fn tool_output_artifact(
127 tool_name: &str,
128 output: &str,
129 shown_bytes: usize,
130) -> ToolOutputArtifact {
131 let digest = format!("{:x}", Sha256::digest(output.as_bytes()));
132 let sanitized_tool = tool_name
133 .chars()
134 .map(|ch| {
135 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
136 ch
137 } else {
138 '_'
139 }
140 })
141 .collect::<String>();
142 let artifact_id = format!("tool-output:{sanitized_tool}:{digest}");
143 let artifact_uri = format!("a3s://tool-output/{sanitized_tool}/{digest}");
144
145 ToolOutputArtifact {
146 artifact_id,
147 artifact_uri,
148 original_bytes: output.len(),
149 shown_bytes,
150 }
151}
152
153pub(crate) fn merge_tool_output_artifact_metadata(
154 metadata: Option<serde_json::Value>,
155 artifact: &ToolOutputArtifact,
156) -> serde_json::Value {
157 let artifact_json = serde_json::json!({
158 "artifact_id": artifact.artifact_id,
159 "artifact_uri": artifact.artifact_uri,
160 "original_bytes": artifact.original_bytes,
161 "shown_bytes": artifact.shown_bytes,
162 });
163
164 match metadata {
165 Some(serde_json::Value::Object(mut object)) => {
166 object.insert("artifact".to_string(), artifact_json);
167 serde_json::Value::Object(object)
168 }
169 Some(value) => serde_json::json!({
170 "artifact": artifact_json,
171 "previous_metadata": value,
172 }),
173 None => serde_json::json!({
174 "artifact": artifact_json,
175 }),
176 }
177}
178
179pub const TOOL_RESULT_EVIDENCE_SCHEMA_V1: &str = "a3s.code.tool-result-evidence.v1";
180pub const TOOL_RESULT_TOKEN_ESTIMATOR_V1: &str = "utf8-bytes-ceil-div-4/v1";
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct ToolResultEvidenceV1 {
185 pub schema: String,
186 pub original_bytes: usize,
187 pub projected_bytes: usize,
188 pub original_estimated_tokens: usize,
189 pub projected_estimated_tokens: usize,
190 pub token_estimator: String,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub transform_algorithm: Option<String>,
195 pub content_digest: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub projected_digest: Option<String>,
198 pub repeat_key: String,
199 pub content_ref: String,
200 pub loss_mode: ToolResultLossModeV1,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub byte_delta: Option<i64>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub estimated_token_delta: Option<i64>,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum ToolResultLossModeV1 {
210 None,
211 BoundedPreview,
212 HeadTail,
213 DeterministicTransform,
214 Composite,
215}
216
217pub(crate) fn attach_tool_result_evidence(
218 metadata: Option<serde_json::Value>,
219 original: &str,
220 projected: &str,
221 loss_mode: ToolResultLossModeV1,
222) -> serde_json::Value {
223 let digest = format!("sha256:{:x}", Sha256::digest(original.as_bytes()));
224 let projected_digest = format!("sha256:{:x}", Sha256::digest(projected.as_bytes()));
225 let artifact_uri = metadata
226 .as_ref()
227 .and_then(|value| value.pointer("/artifact/artifact_uri"))
228 .and_then(serde_json::Value::as_str);
229 let evidence = ToolResultEvidenceV1 {
230 schema: TOOL_RESULT_EVIDENCE_SCHEMA_V1.to_string(),
231 original_bytes: original.len(),
232 projected_bytes: projected.len(),
233 original_estimated_tokens: estimated_text_tokens(original),
234 projected_estimated_tokens: estimated_text_tokens(projected),
235 token_estimator: TOOL_RESULT_TOKEN_ESTIMATOR_V1.to_string(),
236 transform_algorithm: Some(TOOL_RESULT_TRANSFORM_ALGORITHM_V1.to_string()),
237 content_digest: digest.clone(),
238 projected_digest: Some(projected_digest),
239 repeat_key: digest.clone(),
240 content_ref: artifact_uri
241 .map(str::to_owned)
242 .unwrap_or_else(|| format!("inline:{digest}")),
243 loss_mode,
244 byte_delta: Some(signed_delta(projected.len(), original.len())),
245 estimated_token_delta: Some(signed_delta(
246 estimated_text_tokens(projected),
247 estimated_text_tokens(original),
248 )),
249 };
250 let evidence = serde_json::json!({
251 "schema": evidence.schema,
252 "original_bytes": evidence.original_bytes,
253 "projected_bytes": evidence.projected_bytes,
254 "original_estimated_tokens": evidence.original_estimated_tokens,
255 "projected_estimated_tokens": evidence.projected_estimated_tokens,
256 "token_estimator": evidence.token_estimator,
257 "transform_algorithm": evidence.transform_algorithm,
258 "content_digest": evidence.content_digest,
259 "projected_digest": evidence.projected_digest,
260 "repeat_key": evidence.repeat_key,
261 "content_ref": evidence.content_ref,
262 "loss_mode": evidence.loss_mode,
263 "byte_delta": evidence.byte_delta,
264 "estimated_token_delta": evidence.estimated_token_delta,
265 });
266 match metadata {
267 Some(serde_json::Value::Object(mut object)) => {
268 object.insert("a3s_tool_result_evidence".to_string(), evidence);
269 serde_json::Value::Object(object)
270 }
271 Some(value) => serde_json::json!({
272 "a3s_tool_result_evidence": evidence,
273 "previous_metadata": value,
274 }),
275 None => serde_json::json!({"a3s_tool_result_evidence": evidence}),
276 }
277}
278
279pub(crate) fn ensure_tool_result_evidence(
280 metadata: Option<serde_json::Value>,
281 output: &str,
282) -> serde_json::Value {
283 match metadata {
284 Some(value) if value.get("a3s_tool_result_evidence").is_some() => value,
285 metadata => {
286 attach_tool_result_evidence(metadata, output, output, ToolResultLossModeV1::None)
287 }
288 }
289}
290
291pub(crate) fn has_tool_metadata_beyond_evidence(metadata: Option<&serde_json::Value>) -> bool {
292 match metadata {
293 None => false,
294 Some(serde_json::Value::Object(object)) => {
295 object.keys().any(|key| key != "a3s_tool_result_evidence")
296 }
297 Some(_) => true,
298 }
299}
300
301fn estimated_text_tokens(value: &str) -> usize {
302 value.len().saturating_add(3) / 4
303}
304
305fn signed_delta(value: usize, baseline: usize) -> i64 {
306 if value >= baseline {
307 i64::try_from(value - baseline).unwrap_or(i64::MAX)
308 } else {
309 -i64::try_from(baseline - value).unwrap_or(i64::MAX)
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ToolResult {
316 pub name: String,
317 pub output: String,
318 pub exit_code: i32,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 pub metadata: Option<serde_json::Value>,
321 #[serde(skip)]
323 pub images: Vec<crate::llm::Attachment>,
324 #[serde(skip_serializing_if = "Option::is_none")]
330 pub error_kind: Option<types::ToolErrorKind>,
331}
332
333impl ToolResult {
334 pub fn success(name: &str, output: String) -> Self {
335 Self {
336 name: name.to_string(),
337 output,
338 exit_code: 0,
339 metadata: None,
340 images: Vec::new(),
341 error_kind: None,
342 }
343 }
344
345 pub fn error(name: &str, message: String) -> Self {
346 Self {
347 name: name.to_string(),
348 output: message,
349 exit_code: 1,
350 metadata: None,
351 images: Vec::new(),
352 error_kind: None,
353 }
354 }
355
356 pub fn error_with_kind(name: &str, message: String, kind: types::ToolErrorKind) -> Self {
357 let mut result = Self::error(name, message);
358 result.error_kind = Some(kind);
359 result
360 }
361}
362
363impl From<ToolOutput> for ToolResult {
364 fn from(output: ToolOutput) -> Self {
365 Self {
366 name: String::new(),
367 output: output.content,
368 exit_code: if output.success { 0 } else { 1 },
369 metadata: output.metadata,
370 images: output.images,
371 error_kind: output.error_kind,
372 }
373 }
374}
375
376pub struct ToolExecutor {
380 workspace: PathBuf,
381 registry: Arc<ToolRegistry>,
382 command_env: Option<Arc<HashMap<String, String>>>,
383}
384
385fn redacted_tool_log_summary(name: &str, args: &serde_json::Value) -> String {
393 let arg_keys: Vec<&str> = match args.as_object() {
394 Some(map) => {
395 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
396 keys.sort_unstable();
397 keys
398 }
399 None => Vec::new(),
400 };
401 format!(
402 "Executing tool: {} (arg_keys={:?}, {} bytes)",
403 name,
404 arg_keys,
405 args.to_string().len()
406 )
407}
408
409fn log_tool_invocation(name: &str, args: &serde_json::Value) {
412 tracing::info!("{}", redacted_tool_log_summary(name, args));
413 tracing::trace!("Tool {} full args: {}", name, args);
414}
415
416impl ToolExecutor {
417 pub fn new(workspace: String) -> Self {
418 let workspace_services =
419 crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
420 Self::build(
421 workspace,
422 None,
423 ArtifactStoreLimits::default(),
424 workspace_services,
425 )
426 }
427
428 pub fn new_with_artifact_limits(
429 workspace: String,
430 artifact_limits: ArtifactStoreLimits,
431 ) -> Self {
432 let workspace_services =
433 crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
434 Self::build(workspace, None, artifact_limits, workspace_services)
435 }
436
437 pub fn new_with_workspace_services(
438 workspace: String,
439 workspace_services: Arc<crate::workspace::WorkspaceServices>,
440 ) -> Self {
441 Self::build(
442 workspace,
443 None,
444 ArtifactStoreLimits::default(),
445 workspace_services,
446 )
447 }
448
449 pub fn new_with_workspace_services_and_artifact_limits(
450 workspace: String,
451 workspace_services: Arc<crate::workspace::WorkspaceServices>,
452 artifact_limits: ArtifactStoreLimits,
453 ) -> Self {
454 Self::build(workspace, None, artifact_limits, workspace_services)
455 }
456
457 fn build(
458 workspace: String,
459 command_env: Option<HashMap<String, String>>,
460 artifact_limits: ArtifactStoreLimits,
461 workspace_services: Arc<crate::workspace::WorkspaceServices>,
462 ) -> Self {
463 let workspace_path = PathBuf::from(&workspace);
464 let command_env = command_env.map(Arc::new);
465 let registry = Arc::new(ToolRegistry::with_artifact_limits_and_workspace_services(
466 workspace_path.clone(),
467 artifact_limits,
468 Arc::clone(&workspace_services),
469 ));
470 if let Some(env) = command_env.clone() {
471 registry.set_command_env(env);
472 }
473
474 builtin::register_builtins(®istry, &workspace_services);
478 builtin::register_batch(®istry);
480 builtin::register_program(®istry);
481
482 Self {
483 workspace: workspace_path,
484 registry,
485 command_env,
486 }
487 }
488
489 fn check_workspace_boundary(
490 name: &str,
491 args: &serde_json::Value,
492 ctx: &ToolContext,
493 ) -> Result<()> {
494 let path_field = match name {
495 "read" | "write" | "edit" | "patch" | "download" => Some("file_path"),
496 "ls" | "search" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
497 Some("path")
498 }
499 _ => None,
500 };
501
502 if let Some(field) = path_field {
503 if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
504 ctx.resolve_workspace_path(path_str).map_err(|e| {
505 anyhow::anyhow!(
506 "Workspace boundary check failed for tool '{}' path '{}': {}",
507 name,
508 path_str,
509 e
510 )
511 })?;
512 }
513 }
514
515 Ok(())
516 }
517
518 pub fn workspace(&self) -> &PathBuf {
519 &self.workspace
520 }
521
522 pub fn registry(&self) -> &Arc<ToolRegistry> {
523 &self.registry
524 }
525
526 pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
528 self.registry.get_artifact(artifact_uri)
529 }
530
531 pub fn artifact_store(&self) -> ArtifactStore {
533 self.registry.artifact_store()
534 }
535
536 pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
538 self.registry.set_trace_sink(sink);
539 }
540
541 pub fn trace_sink(&self) -> Arc<dyn crate::trace::TraceSink> {
543 self.registry.trace_sink()
544 }
545
546 pub fn command_env(&self) -> Option<Arc<HashMap<String, String>>> {
547 self.command_env.clone()
548 }
549
550 pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
551 self.registry.register(tool);
552 }
553
554 pub(crate) fn register_dynamic_tool_with_shadow(
555 &self,
556 tool: Arc<dyn Tool>,
557 ) -> (bool, Option<Arc<dyn Tool>>) {
558 self.registry.register_with_shadow(tool)
559 }
560
561 pub(crate) fn restore_dynamic_tool_if_same(
562 &self,
563 name: &str,
564 expected: &Arc<dyn Tool>,
565 replacement: Option<Arc<dyn Tool>>,
566 ) -> bool {
567 self.registry.restore_if_same(name, expected, replacement)
568 }
569
570 pub(crate) fn register_dynamic_tool_if_absent(&self, tool: Arc<dyn Tool>) -> bool {
571 self.registry.register_if_absent(tool)
572 }
573
574 pub fn unregister_dynamic_tool(&self, name: &str) {
575 self.registry.unregister(name);
576 }
577
578 pub fn unregister_tools_by_prefix(&self, prefix: &str) {
580 self.registry.unregister_by_prefix(prefix);
581 }
582
583 pub fn register_program_catalog(&self, catalog: crate::program::ProgramCatalog) {
585 builtin::register_program_with_catalog(&self.registry, catalog);
586 }
587
588 pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
597 let ctx = self.registry.context();
598 if let Err(e) = Self::check_workspace_boundary(name, args, &ctx) {
599 return Ok(ToolResult::error(name, e.to_string()));
600 }
601
602 log_tool_invocation(name, args);
603 let mut result = self.registry.execute_with_context(name, args, &ctx).await;
604 if let Ok(ref mut r) = result {
605 self.attach_diff_metadata(name, args, r);
606 }
607 match &result {
608 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
609 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
610 }
611 result
612 }
613
614 pub async fn execute_with_context(
620 &self,
621 name: &str,
622 args: &serde_json::Value,
623 ctx: &ToolContext,
624 ) -> Result<ToolResult> {
625 Self::check_workspace_boundary(name, args, ctx)?;
626 log_tool_invocation(name, args);
627 let mut result = self.registry.execute_with_context(name, args, ctx).await;
628 if let Ok(ref mut r) = result {
629 self.attach_diff_metadata(name, args, r);
630 }
631 match &result {
632 Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
633 Err(e) => tracing::error!("Tool {} failed: {}", name, e),
634 }
635 result
636 }
637
638 fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
639 if !matches!(name, "write" | "edit" | "patch") {
640 return;
641 }
642 let Some(file_path) = args.get("file_path").and_then(serde_json::Value::as_str) else {
643 return;
644 };
645 let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
648 meta["file_path"] = serde_json::Value::String(file_path.to_string());
649 }
650
651 pub fn definitions(&self) -> Vec<ToolDefinition> {
652 self.registry.definitions()
653 }
654}
655
656#[cfg(test)]
657#[path = "tests.rs"]
658mod tests;