1use std::collections::HashMap;
10use std::error::Error;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, mpsc};
14use std::time::{Duration, Instant};
15
16use tau_proto::{
17 ActionError, ActionInvoke, ActionOutput, ActionResult, AgentContextKey, AgentContextValue,
18 CborValue, DiscoveryModifiedMicros, DiscoverySkillCandidate, Event, ExtAgentContextPublish,
19 ExtensionAgentDiscoverySnapshotDeclared, ExtensionContextReady, ExtensionSessionContextReady,
20 ExtensionSessionDiscoverySnapshotDeclared, HarnessInputMessage, PromptContent, PromptFragment,
21 PromptPriority, SessionAgentLoaded, SessionStarted, ToolCancelled, ToolExample,
22 ToolExampleSelector, ToolResult, ToolResultKind, ToolSpec, ToolTag,
23};
24use tracing::{debug, trace};
25
26#[cfg(test)]
27static DETACHED_OUTPUT_OVERLOAD_NOTIFY: Mutex<Option<mpsc::Sender<()>>> = Mutex::new(None);
28
29use crate::tools::{shell as path_crate_tools_shell, world as path_crate_tools_world};
30use crate::{
31 dir_lock as path_crate_dir_lock, display as path_crate_display, tools as path_crate_tools,
32};
33
34mod agents;
35mod argument;
36mod artifact_transfer;
37mod config;
38mod cwd_state;
39mod diff;
40mod dir_lock;
41mod discovery_source;
42mod display;
43mod isolation;
44#[cfg(any(target_os = "android", target_os = "linux", target_os = "macos"))]
45mod pty_stdio;
46mod runtime;
47mod scheduler;
48mod shell_output_spool;
49mod shell_process;
50mod terminal_frame;
51mod tool_lifecycle;
52mod tool_started_identity;
53mod tools;
54mod truncate;
55mod ui_shell_shutdown_generation;
56
57#[cfg(test)]
58mod tests;
59
60use crate::agents::ancestor_dirs;
61use crate::artifact_transfer::{ArtifactTransferControl, ArtifactTransferManager};
62use crate::config::{ExtConfig, ShellConfig};
63use crate::cwd_state::{CwdState, WorkdirSnapshot};
64use crate::dir_lock::{DIR_LOCK_TOOL_NAME, DirLockManager};
65use crate::runtime::ShellRuntime;
66use crate::scheduler::{WorkMeta, WorkPriority, WorkScheduler};
67use crate::tool_lifecycle::{ToolCancellationState, ToolLifecycle};
68#[cfg(any(test, feature = "echo-agent"))]
69use crate::tools::ECHO_TOOL_NAME;
70use crate::tools::shell::{ShellAccessMode, ShellCommandMode};
71use crate::tools::{
72 APPLY_PATCH_TOOL_NAME, EDIT_TOOL_NAME, EXPORT_TOOL_NAME, FIND_TOOL_NAME, GPT_SHELL_TOOL_NAME,
73 GREP_TOOL_NAME, IMPORT_TOOL_NAME, LS_TOOL_NAME, READ_TOOL_NAME, REPLACE_TOOL_NAME,
74 SHELL_TOOL_NAME, WORKDIR_TOOL_NAME, execute_tool,
75};
76use crate::ui_shell_shutdown_generation::{
77 UiShellShutdownGeneration, UiShellShutdownGenerationCounter,
78};
79
80#[derive(Clone)]
90pub(crate) struct Output {
91 inner: OutputInner,
93 tool_name_scope: Option<(tau_proto::ToolName, tau_proto::ToolName)>,
95 failure: Arc<Mutex<MandatoryOutputFailure>>,
97}
98
99#[derive(Default)]
101struct MandatoryOutputFailure {
102 message: Option<String>,
104 failed: bool,
106 waker: Option<tau_client::ManualRuntimeWaker>,
108}
109
110#[derive(Clone)]
112enum OutputInner {
113 Client(tau_client::ClientHandle),
115 #[cfg(test)]
116 Channel(mpsc::Sender<HarnessInputMessage>),
118}
119
120impl Output {
121 fn client(handle: tau_client::ClientHandle) -> Self {
122 Self {
123 inner: OutputInner::Client(handle),
124 tool_name_scope: None,
125 failure: Arc::default(),
126 }
127 }
128
129 #[cfg(test)]
130 fn channel(tx: mpsc::Sender<HarnessInputMessage>) -> Self {
131 Self {
132 inner: OutputInner::Channel(tx),
133 tool_name_scope: None,
134 failure: Arc::default(),
135 }
136 }
137
138 fn scoped_tool(&self, local: tau_proto::ToolName, wire: tau_proto::ToolName) -> Self {
139 Self {
140 inner: self.inner.clone(),
141 tool_name_scope: Some((local, wire)),
142 failure: Arc::clone(&self.failure),
143 }
144 }
145
146 fn scope_tool_name(&self, tool_name: &mut tau_proto::ToolName) {
147 if let Some((local, wire)) = &self.tool_name_scope
148 && tool_name == local
149 {
150 *tool_name = wire.clone();
151 }
152 }
153
154 fn send(&self, mut message: HarnessInputMessage) -> tau_client::ClientResult<()> {
155 self.scope_message(&mut message);
156 let result = match &self.inner {
157 OutputInner::Client(handle) => handle.send_detached(message),
158 #[cfg(test)]
159 OutputInner::Channel(tx) => tx
160 .send(message)
161 .map_err(|_| tau_client::ClientError::WriterClosed),
162 };
163 #[cfg(test)]
164 if matches!(result, Err(tau_client::ClientError::Overloaded))
165 && let Some(notify) = DETACHED_OUTPUT_OVERLOAD_NOTIFY
166 .lock()
167 .expect("detached overload notification")
168 .as_ref()
169 {
170 let _ = notify.send(());
171 }
172 result
173 }
174
175 fn send_checked(&self, mut message: HarnessInputMessage) -> tau_client::ClientResult<()> {
177 self.scope_message(&mut message);
178 let result = match &self.inner {
179 OutputInner::Client(handle) => handle.send(message),
180 #[cfg(test)]
181 OutputInner::Channel(tx) => tx
182 .send(message)
183 .map_err(|_| tau_client::ClientError::WriterClosed),
184 };
185 self.retain_mandatory_failure(result)
186 }
187
188 fn scope_message(&self, message: &mut HarnessInputMessage) {
189 if let HarnessInputMessage::Emit(emit) = message {
190 let tool_name = match emit.event.as_mut() {
191 Event::ToolProgressReported(event) => Some(&mut event.tool_name),
192 Event::ToolResultReported(event) => Some(&mut event.tool_name),
193 Event::ToolResult(event) => Some(&mut event.tool_name),
194 Event::ToolErrorReported(event) => Some(&mut event.tool_name),
195 Event::ToolError(event) => Some(&mut event.tool_name),
196 Event::ToolCancelledReported(event) => Some(&mut event.tool_name),
197 Event::ToolCancelled(event) => Some(&mut event.tool_name),
198 _ => None,
199 };
200 if let Some(tool_name) = tool_name {
201 self.scope_tool_name(tool_name);
202 }
203 }
204 }
205
206 fn report_tool_progress(
208 &self,
209 progress: tau_proto::ToolProgress,
210 ) -> tau_client::ClientResult<()> {
211 self.send(HarnessInputMessage::emit_with_persist(
212 Event::ToolProgressReported(progress),
213 false,
214 ))
215 }
216
217 fn report_tool_terminal(&self, event: Event) -> tau_client::ClientResult<()> {
219 let result = (|| {
220 let mut outcome =
221 tau_client::ToolTerminalOutcome::try_from(event).map_err(|event| {
222 tau_client::ClientError::handler(format!(
223 "terminal report helper received {}",
224 event.name()
225 ))
226 })?;
227 self.scope_tool_name(outcome.tool_name_mut());
228 let message = terminal_frame::budget_terminal_report(outcome.into_reported_event())?;
229 match &self.inner {
230 OutputInner::Client(handle) => handle.send(message),
231 #[cfg(test)]
232 OutputInner::Channel(tx) => tx
233 .send(message)
234 .map_err(|_| tau_client::ClientError::WriterClosed),
235 }
236 })();
237 self.retain_mandatory_failure(result)
238 }
239
240 fn install_waker(&self, waker: tau_client::ManualRuntimeWaker) {
243 self.failure
244 .lock()
245 .expect("mandatory output failure lock poisoned")
246 .waker = Some(waker);
247 }
248
249 fn take_mandatory_failure(&self) -> tau_client::ClientResult<()> {
251 let message = self
252 .failure
253 .lock()
254 .expect("mandatory output failure lock poisoned")
255 .message
256 .take();
257 match message {
258 Some(message) => Err(tau_client::ClientError::handler(message)),
259 None => Ok(()),
260 }
261 }
262
263 fn mandatory_output_failed(&self) -> bool {
265 self.failure
266 .lock()
267 .expect("mandatory output failure lock poisoned")
268 .failed
269 }
270
271 fn retain_mandatory_failure(
273 &self,
274 result: tau_client::ClientResult<()>,
275 ) -> tau_client::ClientResult<()> {
276 if let Err(error) = &result {
277 let waker = {
278 let mut failure = self
279 .failure
280 .lock()
281 .expect("mandatory output failure lock poisoned");
282 failure.message.get_or_insert_with(|| error.to_string());
283 failure.failed = true;
284 failure.waker.clone()
285 };
286 if let Some(waker) = waker {
287 waker.wake();
288 }
289 }
290 result
291 }
292
293 fn register_local_tool(
294 &self,
295 registration: tau_proto::ToolRegistrationDeclared,
296 ) -> tau_client::ClientResult<()> {
297 match &self.inner {
298 OutputInner::Client(handle) => handle.register_local_tool(registration),
299 #[cfg(test)]
300 OutputInner::Channel(tx) => tx
301 .send(HarnessInputMessage::emit_with_persist(
302 Event::ToolRegistrationDeclared(registration),
303 false,
304 ))
305 .map_err(|_| tau_client::ClientError::WriterClosed),
306 }
307 }
308}
309
310fn tool_tags(tags: &[&str]) -> Vec<ToolTag> {
311 tags.iter().map(|tag| ToolTag::new(*tag)).collect()
312}
313
314fn example_field(name: &str, value: CborValue) -> (CborValue, CborValue) {
315 (CborValue::Text(name.to_owned()), value)
316}
317
318fn example_text(value: &str) -> CborValue {
319 CborValue::Text(value.to_owned())
320}
321
322fn example_int(value: i64) -> CborValue {
323 CborValue::Integer(value.into())
324}
325
326const SHELL_DIR_FORCE_UNLOCK_ACTION_ID: &str = "shell.dir.force_unlock";
327
328const SLOW_LOCK_WAIT_THRESHOLD_SECS: u64 = 5;
329const LOCK_WAIT_DURATION_SECONDS_HEADER: &str = "lock_wait_duration_seconds";
330const XDG_USER_SKILL_SOURCE_PRECEDENCE: u32 = 0;
331const LEGACY_USER_SKILL_SOURCE_PRECEDENCE: u32 = 1;
332
333#[derive(Clone, Copy)]
334enum DiscoverySourcePolicy {
335 Environment,
336 #[cfg(any(test, feature = "echo-agent"))]
337 EmptyFixture,
338}
339
340impl DiscoverySourcePolicy {
341 const fn reads_environment(self) -> bool {
342 matches!(self, Self::Environment)
343 }
344}
345
346enum RuntimeCwdSource {
347 Process,
348 #[cfg(any(test, feature = "echo-agent"))]
349 Fixture(PathBuf),
350}
351
352struct DiscoveryScan {
355 snapshot: ExtensionSessionDiscoverySnapshotDeclared,
357 diagnostics: Vec<HarnessInputMessage>,
359}
360
361pub fn run_stdio() -> Result<(), Box<dyn Error>> {
363 tau_client::init_logging_for("tau_ext_shell");
364 tracing::info!(
365 target: "tau_ext_shell",
366 package = env!("CARGO_PKG_NAME"),
367 version = env!("CARGO_PKG_VERSION"),
368 revision = tau_client::diagnostic_build_revision(),
369 "extension startup identity"
370 );
371 run_impl(
372 std::io::stdin(),
373 std::io::stdout(),
374 DiscoverySourcePolicy::Environment,
375 RuntimeCwdSource::Process,
376 )
377}
378
379pub fn run<R, W>(reader: R, writer: W) -> Result<(), Box<dyn Error>>
384where
385 R: Read + Send + 'static,
386 W: Write + Send + 'static,
387{
388 run_impl(
389 reader,
390 writer,
391 DiscoverySourcePolicy::Environment,
392 RuntimeCwdSource::Process,
393 )
394}
395
396#[cfg(any(test, feature = "echo-agent"))]
404pub fn run_for_test_harness<R, W>(
405 reader: R,
406 writer: W,
407 fixture_cwd: PathBuf,
408) -> Result<(), Box<dyn Error>>
409where
410 R: Read + Send + 'static,
411 W: Write + Send + 'static,
412{
413 run_impl(
414 reader,
415 writer,
416 DiscoverySourcePolicy::EmptyFixture,
417 RuntimeCwdSource::Fixture(fixture_cwd),
418 )
419}
420
421fn registered_tool_specs(dir_lock_enabled: bool) -> Vec<ToolSpec> {
422 #[cfg(any(test, feature = "echo-agent"))]
423 let echo_tool = Some(ToolSpec {
424 provider_scope: None,
425 name: tau_proto::ToolName::new(ECHO_TOOL_NAME),
426 model_visible_name: None,
427 description: Some("Echo the provided payload unchanged".to_owned()),
428 tool_type: tau_proto::ToolType::Function,
429 parameters: None,
430 format: None,
431 tags: tool_tags(&["test:echo"]),
432 enabled_by_default: false,
433 background_support: None,
434 examples: Vec::new(),
435 });
436 #[cfg(not(any(test, feature = "echo-agent")))]
437 let echo_tool: Option<ToolSpec> = None;
438 let mut tools = Vec::new();
439 if let Some(echo_tool) = echo_tool {
440 tools.push(echo_tool);
441 }
442 let read_tool = ToolSpec {
443 provider_scope: None,
444 name: tau_proto::ToolName::new(READ_TOOL_NAME),
445 model_visible_name: None,
446 description: Some(
447 "Reads a file. Defaults to reading the whole file in one call — \
448 output is capped at 2000 lines / 10 KiB. Truncated output keeps \
449 the first 1000 and last 1000 lines separated by a literal `...` line. \
450 Files over 10 MiB are rejected by an input safety cap before output truncation. \
451 Prefer one full read. Pass inclusive `start_line`/`end_line` only to \
452 fetch one specific known slice, or `ranges` for up to 100 slices; \
453 range chunks are separated by one empty line and may overlap, but large overlapping \
454 multi-range expansions can be rejected before rendering to keep memory bounded. `start_line` past EOF errors, \
455 while `end_line` past EOF returns available lines. Returned content lines are prefixed \
456 by their 1-based line number and a space; \
457 CRLF, CR, and missing final line endings are marked after the number, e.g. \
458 `2(crlf)`, `3(cr)`, or `4(no_nl)`. Invalid UTF-8 is shown with \
459 Unicode replacement characters and an `invalid-utf8` line flag. Lines that would exceed \
460 the 10 KiB visible output budget are marker-only, e.g. `1(truncated)`. Truncated results include `truncated: true`, `total_lines`, \
461 and `total_bytes`, plus a private path to bounded saved output (or `saved_output_unavailable: true` when private storage fails); `valid_utf8: false` is included only when applicable."
462 .to_owned(),
463 ),
464 tool_type: tau_proto::ToolType::Function,
465 parameters: Some(serde_json::json!({
466 "type": "object",
467 "properties": {
468 "path": {
469 "type": "string",
470 "description": "Path to the file"
471 },
472 "start_line": {
473 "type": "integer",
474 "minimum": 1,
475 "description": "Optional, 1-based inclusive. Omit to start at line 1 (the default)."
476 },
477 "end_line": {
478 "type": "integer",
479 "minimum": 1,
480 "description": "Optional, 1-based inclusive. Omit to read to end of file (the default and preferred mode). Set this only to continue past a previous truncation, or to fetch a known specific slice of a large file — do NOT pre-slice an ordinary file you haven't already established is large."
481 },
482 "ranges": {
483 "type": "array",
484 "description": "Optional list of inclusive line ranges to read. Cannot be combined with top-level start_line or end_line. Each chunk is separated by one empty line in the output, and overlapping ranges are returned redundantly. Requests whose overlapping ranges would expand into too much rendered content are rejected before rendering.",
485 "minItems": 1,
486 "maxItems": 100,
487 "items": {
488 "type": "object",
489 "properties": {
490 "start_line": {
491 "type": "integer",
492 "minimum": 1,
493 "description": "1-based inclusive start line to read."
494 },
495 "end_line": {
496 "type": "integer",
497 "minimum": 1,
498 "description": "1-based inclusive end line to read."
499 }
500 },
501 "required": ["start_line", "end_line"],
502 "additionalProperties": false
503 }
504 }
505 },
506 "required": ["path"],
507 "additionalProperties": false
508 })),
509 format: None,
510 tags: tool_tags(&["shell:read", tau_proto::TURN_DATA_FETCH_TOOL_TAG]),
511 enabled_by_default: true,
512 background_support: None,
513 examples: vec![ToolExample {
514 id: "read-file".to_owned(),
515 title: Some("Read a file".to_owned()),
516 arguments: CborValue::Map(vec![example_field("path", example_text("src/main.rs"))]),
517 note: Some("Use only the path field for a full-file read.".to_owned()),
518 subcommand: None,
519 }],
520 };
521 let export_tool = ToolSpec {
522 provider_scope: None,
523 name: tau_proto::ToolName::new(EXPORT_TOOL_NAME),
524 model_visible_name: None,
525 description: Some(
526 "Export one local regular file to the shared content-addressed artifact store. \
527 Originals are limited to 16 MiB. A successful export returns a canonical \
528 <tau-artifact:FULL_KEY> reference and byte size as output headers and renews shared \
529 artifact age, including for duplicate bytes. Original bytes persist independently \
530 of ephemeral session transcripts."
531 .to_owned(),
532 ),
533 tool_type: tau_proto::ToolType::Function,
534 parameters: Some(serde_json::json!({
535 "type": "object",
536 "properties": {
537 "path": {"type": "string", "description": "Path to one local regular file"}
538 },
539 "required": ["path"],
540 "additionalProperties": false
541 })),
542 format: None,
543 tags: tool_tags(&[
544 "shell:read",
545 "artifact:write",
546 tau_proto::TURN_DATA_FETCH_TOOL_TAG,
547 ]),
548 enabled_by_default: true,
549 background_support: None,
550 examples: vec![ToolExample {
551 id: "export-artifact".to_owned(),
552 title: Some("Export an original".to_owned()),
553 arguments: CborValue::Map(vec![example_field("path", example_text("output.png"))]),
554 note: Some(
555 "Pass the returned artifact reference directly to import or read_image.".to_owned(),
556 ),
557 subcommand: None,
558 }],
559 };
560 let import_tool = ToolSpec {
561 provider_scope: None,
562 name: tau_proto::ToolName::new(IMPORT_TOOL_NAME),
563 model_visible_name: None,
564 description: Some(
565 "Import one <tau-artifact:FULL_KEY> reference to a private, unpredictable, \
566 non-executable temporary file on this shell host. A bare internal key is also \
567 accepted. Size and digest are verified before success. The local path and byte size \
568 are returned as output headers. Import does not renew retention age; pass the \
569 returned local path to filesystem tools."
570 .to_owned(),
571 ),
572 tool_type: tau_proto::ToolType::Function,
573 parameters: Some(serde_json::json!({
574 "type": "object",
575 "properties": {
576 "key": {
577 "type": "string",
578 "pattern": "^(?:blake3:[0-9a-f]{64}|<tau-artifact:blake3:[0-9a-f]{64}>)$",
579 "description": "Canonical <tau-artifact:FULL_KEY> reference returned by export; a bare key is also accepted"
580 }
581 },
582 "required": ["key"],
583 "additionalProperties": false
584 })),
585 format: None,
586 tags: tool_tags(&[
587 "shell:read",
588 "artifact:read",
589 tau_proto::TURN_DATA_FETCH_TOOL_TAG,
590 ]),
591 enabled_by_default: true,
592 background_support: None,
593 examples: Vec::new(),
594 };
595 let edit_tool = ToolSpec {
596 provider_scope: None,
597 name: tau_proto::ToolName::new(EDIT_TOOL_NAME),
598 model_visible_name: None,
599 description: Some(
600 "Edit a file using line-oriented replacements. Each edit fully replaces \
601 the 1-based half-open `start_line`..`end_line_exclusive` range \
602 with `newText`. `start_line` is included and `end_line_exclusive` \
603 is excluded. Empty insertion ranges use \
604 `start_line == end_line_exclusive`; for example, `1..<1` inserts \
605 at the start of the file and `total_lines + 1 ..< total_lines + 1` \
606 appends at EOF. All ranges use the original file numbering as if \
607 applied simultaneously. Non-empty replacements are kept as whole \
608 lines. Ranges must be non-overlapping. Missing files are treated as \
609 empty and missing parent directories are created. Per-edit `context_line` \
610 must exactly match the original content of `start_line`. Use an empty \
611 context_line when `start_line` is the append slot past the end of the \
612 file."
613 .to_owned(),
614 ),
615 tool_type: tau_proto::ToolType::Function,
616 parameters: Some(serde_json::json!({
617 "type": "object",
618 "properties": {
619 "path": {
620 "type": "string",
621 "description": "Path to the file"
622 },
623 "edits": {
624 "type": "array",
625 "description": "One or more line ranges to replace in the original file",
626 "minItems": 1,
627 "maxItems": 100,
628 "items": {
629 "type": "object",
630 "properties": {
631 "start_line": {
632 "type": "integer",
633 "minimum": 1,
634 "description": "1-based included start line or insertion slot. Use 1 for the start of the file. To append at EOF, use total_lines + 1. Use together with end_line_exclusive."
635 },
636 "end_line_exclusive": {
637 "type": "integer",
638 "minimum": 1,
639 "description": "1-based excluded end line or insertion slot. Empty insertion ranges have end_line_exclusive == start_line. To replace read output lines A through B, use start_line A and end_line_exclusive B + 1. Use together with start_line."
640 },
641 "newText": {
642 "type": "string",
643 "description": "Replacement text. Non-empty replacements stay whole-line."
644 },
645 "context_line": {
646 "type": "string",
647 "description": "Exact expected content of the original start_line, including spaces and tabs. Use an empty context_line when start_line is the append slot past the end of the file. If it does not match, the edit fails and returns current line-numbered context around the expected context line."
648 }
649 },
650 "required": ["start_line", "end_line_exclusive", "newText", "context_line"],
651 "additionalProperties": false
652 }
653 }
654 },
655 "required": ["path", "edits"],
656 "additionalProperties": false
657 })),
658 format: None,
659 tags: tool_tags(&[
660 "shell:edit",
661 "shell:edit:line",
662 "shell:mutates-files",
663 tau_proto::TURN_MANIPULATOR_TOOL_TAG,
664 ]),
665 enabled_by_default: true,
666 background_support: None,
667 examples: vec![ToolExample {
668 id: "replace-lines".to_owned(),
669 title: Some("Replace one line".to_owned()),
670 arguments: CborValue::Map(vec![
671 example_field("path", example_text("src/main.rs")),
672 (
673 CborValue::Text("edits".to_owned()),
674 CborValue::Array(vec![CborValue::Map(vec![
675 example_field("start_line", example_int(10)),
676 example_field("end_line_exclusive", example_int(11)),
677 example_field("newText", example_text("replacement line")),
678 example_field("context_line", example_text("line being replaced")),
679 ])]),
680 ),
681 ]),
682 note: Some("end_line_exclusive is one past the last line replaced.".to_owned()),
683 subcommand: None,
684 }],
685 };
686 let apply_patch_tool = ToolSpec {
687 provider_scope: None,
688 name: tau_proto::ToolName::new(APPLY_PATCH_TOOL_NAME),
689 model_visible_name: None,
690 description: Some("Use the `apply_patch` tool to edit files.".to_owned()),
691 tool_type: tau_proto::ToolType::Custom,
692 parameters: None,
693 format: Some(tau_proto::ToolFormat::Text),
694 tags: tool_tags(&[
695 "shell:edit",
696 "shell:edit:apply_patch",
697 "shell:mutates-files",
698 tau_proto::TURN_MANIPULATOR_TOOL_TAG,
699 ]),
700 enabled_by_default: false,
701 background_support: None,
702 examples: Vec::new(),
703 };
704 let replace_tool = ToolSpec {
705 provider_scope: None,
706 name: tau_proto::ToolName::new(REPLACE_TOOL_NAME),
707 model_visible_name: Some(tau_proto::ToolName::new(EDIT_TOOL_NAME)),
708 description: Some(
709 "Replace exact text in one existing UTF-8 file. Each oldText must occur exactly \
710 once in the same original file snapshot; all edits apply atomically. Matching \
711 ignores only an initial UTF-8 BOM and normalizes CRLF/CR to LF. Use newText \
712 as an empty string to delete text."
713 .to_owned(),
714 ),
715 tool_type: tau_proto::ToolType::Function,
716 parameters: Some(serde_json::json!({
717 "type": "object",
718 "properties": {
719 "path": { "type": "string", "minLength": 1 },
720 "edits": {
721 "type": "array",
722 "minItems": 1,
723 "maxItems": 100,
724 "items": {
725 "type": "object",
726 "properties": {
727 "oldText": { "type": "string", "minLength": 1 },
728 "newText": { "type": "string" }
729 },
730 "required": ["oldText", "newText"],
731 "additionalProperties": false
732 }
733 }
734 },
735 "required": ["path", "edits"],
736 "additionalProperties": false
737 })),
738 format: None,
739 tags: tool_tags(&[
740 "shell:edit",
741 "shell:edit:replace",
742 "shell:mutates-files",
743 tau_proto::TURN_MANIPULATOR_TOOL_TAG,
744 ]),
745 enabled_by_default: false,
746 background_support: None,
747 examples: Vec::new(),
748 };
749 let dir_lock_tool = dir_lock_tool_spec(dir_lock_enabled);
750 let grep_tool = ToolSpec {
751 provider_scope: None,
752 name: tau_proto::ToolName::new(GREP_TOOL_NAME),
753 model_visible_name: None,
754 description: Some(
755 "Search file contents for a pattern using in-process ripgrep libraries. Patterns are literal by default; \
756 regex metacharacters like `|` require `regex: true`. Returns matching lines \
757 with file paths and line numbers. Respects .gitignore. Output is truncated at \
758 `limit` matches or 10 KiB of visible output. Visible-cap truncation provides a private saved-output path, or explicit unavailable metadata when storage fails; limit-only and per-line truncation retain native metadata. Long lines are truncated to 500 chars."
759 .to_owned(),
760 ),
761 tool_type: tau_proto::ToolType::Function,
762 parameters: Some(serde_json::json!({
763 "type": "object",
764 "properties": {
765 "pattern": {
766 "type": "string",
767 "description": "Search pattern. Treated as a literal string by default. Set `regex: true` to interpret as a regex."
768 },
769 "path": {
770 "type": "string",
771 "description": "Directory or file to search (default: current directory)"
772 },
773 "glob": {
774 "type": "string",
775 "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.rs'"
776 },
777 "ignoreCase": {
778 "type": "boolean",
779 "description": "Case-insensitive search (default: false)"
780 },
781 "regex": {
782 "type": "boolean",
783 "description": "Interpret `pattern` as a regex instead of a literal string (default: false)"
784 },
785 "context": {
786 "type": "integer",
787 "description": "Number of lines to show before and after each match (default: 0, max: 20)"
788 },
789 "limit": {
790 "type": "integer",
791 "description": "Maximum number of matches to return (default: 100, max: 2000)"
792 }
793 },
794 "required": ["pattern"],
795 "additionalProperties": false
796 })),
797 format: None,
798 tags: tool_tags(&[
799 "shell:read",
800 "shell:search",
801 tau_proto::TURN_DATA_FETCH_TOOL_TAG,
802 ]),
803 enabled_by_default: true,
804 background_support: None,
805 examples: vec![ToolExample {
806 id: "search-literal".to_owned(),
807 title: Some("Search literal text".to_owned()),
808 arguments: CborValue::Map(vec![
809 example_field("pattern", example_text("TODO")),
810 example_field("path", example_text("src")),
811 example_field("glob", example_text("**/*.rs")),
812 ]),
813 note: Some("Set regex=true only when pattern is a regular expression.".to_owned()),
814 subcommand: None,
815 }],
816 };
817 let find_tool = ToolSpec {
818 provider_scope: None,
819 name: tau_proto::ToolName::new(FIND_TOOL_NAME),
820 model_visible_name: None,
821 description: Some(
822 "Search for files by glob pattern. Returns only file paths (directories are \
823 never included, even with '**/*') relative to the search directory. Respects \
824 .gitignore. Output is truncated at `limit` results or 10 KiB of visible output. Visible-cap truncation provides a private saved-output path, or explicit unavailable metadata when storage fails; limit-only truncation retains native metadata. Use the ls tool \
825 if you want to see directory entries."
826 .to_owned(),
827 ),
828 tool_type: tau_proto::ToolType::Function,
829 parameters: Some(serde_json::json!({
830 "type": "object",
831 "properties": {
832 "pattern": {
833 "type": "string",
834 "description": "Glob pattern matched against file paths relative to `path`. `**` matches any number of intermediate directories, including zero — so `**/*.rs` finds both top-level `a.rs` and nested `src/a.rs`. Directories are not returned, even with `**/*`."
835 },
836 "path": {
837 "type": "string",
838 "description": "Directory to search (default: current directory)"
839 },
840 "limit": {
841 "type": "integer",
842 "description": "Maximum number of results to return (default: 1000, max: 2000)"
843 }
844 },
845 "required": ["pattern"],
846 "additionalProperties": false
847 })),
848 format: None,
849 tags: tool_tags(&[
850 "shell:read",
851 "shell:search",
852 tau_proto::TURN_DATA_FETCH_TOOL_TAG,
853 ]),
854 enabled_by_default: true,
855 background_support: None,
856 examples: vec![ToolExample {
857 id: "find-rust-files".to_owned(),
858 title: Some("Find files by glob".to_owned()),
859 arguments: CborValue::Map(vec![
860 example_field("pattern", example_text("**/*.rs")),
861 example_field("path", example_text("crates")),
862 ]),
863 note: None,
864 subcommand: None,
865 }],
866 };
867 let ls_tool = ToolSpec {
868 provider_scope: None,
869 name: tau_proto::ToolName::new(LS_TOOL_NAME),
870 model_visible_name: None,
871 description: Some(
872 "List directory contents. Returns entries sorted alphabetically, with '/' suffix \
873 for directories. Includes dotfiles. Output lines are prefixed with 1-based \
874 entry numbers plus flags such as `escaped`, `invalid-utf8`, or `truncated`; \
875 output is capped at `limit` entries, 2000 lines, or 10 KiB of visible output with saved-output metadata and standard truncation headers. \
876 When `limit_reached` is true, entries are a bounded filesystem-order sample sorted \
877 for display, not a complete alphabetic prefix."
878 .to_owned(),
879 ),
880 tool_type: tau_proto::ToolType::Function,
881 parameters: Some(serde_json::json!({
882 "type": "object",
883 "properties": {
884 "path": {
885 "type": "string",
886 "description": "Directory to list (default: current directory)"
887 },
888 "limit": {
889 "type": "integer",
890 "minimum": 1,
891 "description": "Maximum number of entries to return (default: 500, max: 2001)"
892 }
893 },
894 "additionalProperties": false
895 })),
896 format: None,
897 tags: tool_tags(&[
898 "shell:read",
899 "shell:list",
900 tau_proto::TURN_DATA_FETCH_TOOL_TAG,
901 ]),
902 enabled_by_default: true,
903 background_support: None,
904 examples: vec![ToolExample {
905 id: "list-directory".to_owned(),
906 title: Some("List a directory".to_owned()),
907 arguments: CborValue::Map(vec![example_field("path", example_text("src"))]),
908 note: None,
909 subcommand: None,
910 }],
911 };
912 let workdir_tool = ToolSpec {
913 provider_scope: None,
914 name: tau_proto::ToolName::new(WORKDIR_TOOL_NAME),
915 model_visible_name: None,
916 description: Some(
917 "Read or change your durable workdir. Omit `path` \
918 to read the current path and availability. A provided path is resolved from the \
919 last committed workdir, validated, canonicalized, and persisted. Do not combine a \
920 workdir change with shell or filesystem calls that rely on the new directory."
921 .to_owned(),
922 ),
923 tool_type: tau_proto::ToolType::Function,
924 parameters: Some(serde_json::json!({
925 "type": "object",
926 "properties": { "path": { "type": "string", "minLength": 1, "description": "Optional directory to persist as this instance's workdir" } },
927 "additionalProperties": false
928 })),
929 format: None,
930 tags: tool_tags(&["shell:workdir", tau_proto::TURN_MANIPULATOR_TOOL_TAG]),
931 enabled_by_default: true,
932 background_support: None,
933 examples: vec![ToolExample {
934 id: "change-directory".to_owned(),
935 title: Some("Change directory".to_owned()),
936 arguments: CborValue::Map(vec![example_field("path", example_text("crates/tau"))]),
937 note: None,
938 subcommand: None,
939 }],
940 };
941 let shell_tool = ToolSpec {
942 provider_scope: None,
943 name: tau_proto::ToolName::new(SHELL_TOOL_NAME),
944 model_visible_name: None,
945 description: Some(
946 "Execute a shell command via `sh -c`. When directory locking is enabled, commands \
947 are inferred read-write only while the agent holds a matching `dir_lock`; otherwise \
948 they are read-only. When directory locking is disabled, shell commands run read-write. \
949 Non-zero exits and timeouts are returned as structured command results with output details. \
950 The native `output` body is capped at 2000 lines / 15 KiB; small result \
951 metadata is deliberately outside that budget, which does not cap fully \
952 rendered provider text. Truncated output keeps the first 1000 and last 1000 lines \
953 separated by a literal `...` line. Output lines are prefixed with `out ` \
954 for stdout or `err ` for stderr; missing trailing newlines are marked, e.g. \
955 `out(no_nl)`; CRLF and CR line endings are marked as `out(crlf)` \
956 or `out(cr)`. Invalid UTF-8 is shown with Unicode replacement characters and \
957 an `invalid-utf8` line flag. Lines that would exceed the 15 KiB output budget \
958 are marker-only, e.g. `err(truncated)`. Truncated results include complete totals, a warning, and normally an exact temporary path to up to 16 MiB of rendered output; output beyond that saved cap is explicitly marked incomplete, while platforms or filesystems that cannot enforce private storage report `saved_output_unavailable: true`. Byte totals and artifacts count the complete rendered UTF-8 records, including stream prefixes, flags, and separators, rather than raw process bytes. \
959 Stdin is closed and commands cannot receive interactive input. Stdout and stderr may be TTY-backed even though no controlling terminal exists. Use explicit noninteractive flags/messages; do not launch prompts, pagers, or editors. \
960 Commands taking longer than 5 seconds include duration metadata. Prefer dedicated \
961 tools like `read`, `grep`, and `find` when they fit."
962 .to_owned(),
963 ),
964 tool_type: tau_proto::ToolType::Function,
965 parameters: Some(serde_json::json!({
966 "type": "object",
967 "properties": {
968 "command": {
969 "type": "string",
970 "description": "The shell command to execute"
971 },
972 "timeout": {
973 "type": "integer",
974 "minimum": 0,
975 "description": "Timeout in seconds. The command is killed if it exceeds this. Default: 300"
976 },
977 "cwd": {
978 "type": "string",
979 "description": "Working directory for this invocation only. Relative paths resolve from this shell instance's remembered workdir; omission uses the remembered workdir. This does not change later calls; use workdir in an earlier turn to change later calls."
980 }
981 },
982 "required": ["command"],
983 "additionalProperties": false
984 })),
985 format: None,
986 tags: tool_tags(&[
987 "shell:exec",
988 "shell:exec:generic",
989 tau_proto::TURN_MANIPULATOR_TOOL_TAG,
990 ]),
991 enabled_by_default: true,
992 background_support: None,
993 examples: vec![ToolExample {
994 id: "run-command".to_owned(),
995 title: Some("Run a command".to_owned()),
996 arguments: CborValue::Map(vec![
997 example_field("command", example_text("cargo test -p dpc-tau-core")),
998 example_field("timeout", example_int(300)),
999 ]),
1000 note: Some("For file edits, prefer apply_patch when available.".to_owned()),
1001 subcommand: None,
1002 }],
1003 };
1004 let gpt_shell_tool = ToolSpec {
1005 provider_scope: None,
1006 name: tau_proto::ToolName::new(GPT_SHELL_TOOL_NAME),
1007 model_visible_name: Some(tau_proto::ToolName::new("shell_command")),
1008 description: Some(
1009 "Run a shell command. The native `output` body is capped at 2000 lines / 15 KiB; \
1010 small result metadata is deliberately outside that budget, which does not cap fully \
1011 rendered provider text. \
1012 truncated results normally provide an exact temporary path to up to 16 MiB of rendered output and mark an incomplete saved artifact honestly; private-storage failures instead report `saved_output_unavailable: true`. \
1013 Output lines are prefixed with `out ` for stdout or `err ` for stderr; missing \
1014 trailing newlines are marked with `(no_nl)`. Byte totals and artifacts count the complete rendered UTF-8 records, including stream prefixes, flags, and separators, rather than raw process bytes. Stdin is closed and commands cannot receive interactive input. Stdout and stderr may be TTY-backed even though no controlling terminal exists. Use explicit noninteractive flags/messages; do not launch prompts, pagers, or editors. For file changes, prefer apply_patch."
1015 .to_owned(),
1016 ),
1017 tool_type: tau_proto::ToolType::Function,
1018 parameters: Some(serde_json::json!({
1019 "type": "object",
1020 "properties": {
1021 "command": {
1022 "type": "string",
1023 "description": "The shell command to execute"
1024 },
1025 "timeout": {
1026 "type": "integer",
1027 "description": "Timeout in seconds. The command is killed if it exceeds this. Default: 300"
1028 },
1029 "workdir": {
1030 "type": "string",
1031 "description": "Optional working directory for this shell_command invocation only. Relative paths resolve from this shell instance's remembered persistent workdir; omission uses that remembered workdir. This does not change later calls; use the separate top-level workdir(path) tool in an earlier turn to change later calls."
1032 }
1033 },
1034 "required": ["command"],
1035 "additionalProperties": false
1036 })),
1037 format: None,
1038 tags: tool_tags(&[
1039 "shell:exec",
1040 "shell:exec:shell_command",
1041 tau_proto::TURN_MANIPULATOR_TOOL_TAG,
1042 ]),
1043 enabled_by_default: false,
1044 background_support: None,
1045 examples: vec![ToolExample {
1046 id: "run-command".to_owned(),
1047 title: Some("Run a command".to_owned()),
1048 arguments: CborValue::Map(vec![
1049 example_field("command", example_text("cargo test -p dpc-tau-core")),
1050 example_field("timeout", example_int(300)),
1051 ]),
1052 note: Some("For file edits, prefer apply_patch when available.".to_owned()),
1053 subcommand: None,
1054 }],
1055 };
1056 let builtin_tools = [
1057 read_tool,
1058 export_tool,
1059 import_tool,
1060 edit_tool,
1061 replace_tool,
1062 apply_patch_tool,
1063 dir_lock_tool,
1064 grep_tool,
1065 find_tool,
1066 ls_tool,
1067 workdir_tool,
1068 shell_tool,
1069 gpt_shell_tool,
1070 ];
1071 tools.extend(builtin_tools);
1072 tools
1073}
1074
1075fn run_impl<R, W>(
1076 reader: R,
1077 writer: W,
1078 discovery_policy: DiscoverySourcePolicy,
1079 runtime_cwd_source: RuntimeCwdSource,
1080) -> Result<(), Box<dyn Error>>
1081where
1082 R: Read + Send + 'static,
1083 W: Write + Send + 'static,
1084{
1085 let initial_config = ExtConfig::default();
1086 let mut runtime = tau_client::TauExtensionRunner::new(ShellExtension {
1087 initial_config: initial_config.clone(),
1088 })
1089 .start_manual_loop_with_state(reader, writer, |handle| match runtime_cwd_source {
1090 RuntimeCwdSource::Process => ShellRuntime::new_with_artifacts(
1091 Output::client(handle.clone()),
1092 ArtifactTransferManager::new(tau_client::ArtifactClient::new(handle)),
1093 initial_config,
1094 discovery_policy,
1095 ),
1096 #[cfg(any(test, feature = "echo-agent"))]
1097 RuntimeCwdSource::Fixture(fixture_cwd) => ShellRuntime::new_for_test_harness(
1098 Output::client(handle),
1099 initial_config,
1100 discovery_policy,
1101 fixture_cwd,
1102 ),
1103 })?;
1104
1105 let waker = runtime.waker();
1106 runtime.state().install_waker(waker);
1107 let loop_result = run_shell_manual_loop(&mut runtime);
1108
1109 runtime.state_mut().final_shutdown();
1115 let finish_result = runtime.finish().map(|_| ());
1116 match (loop_result, finish_result) {
1117 (Ok(()), Ok(())) => Ok(()),
1118 (_, Err(error)) => Err(Box::new(error)),
1119 (Err(error), _) => Err(Box::new(error)),
1120 }
1121}
1122
1123fn run_shell_manual_loop(
1124 runtime: &mut tau_client::ManualExtensionRuntime<ShellRuntime>,
1125) -> tau_client::ClientResult<()> {
1126 loop {
1127 runtime.state_mut().drain_artifact_commands()?;
1128 runtime.state().take_mandatory_output_failure()?;
1129 match runtime.try_recv()? {
1130 tau_client::ManualRuntimePoll::Message(message) => {
1131 if let tau_proto::HarnessOutputMessage::ArtifactResult(result) = message {
1132 runtime.state_mut().handle_artifact_result(*result)?;
1133 continue;
1134 }
1135 match runtime.dispatch_one(message)? {
1136 tau_client::DispatchOutcome::Continue => {}
1137 tau_client::DispatchOutcome::StopRequested
1138 | tau_client::DispatchOutcome::Disconnect(_) => return Ok(()),
1139 }
1140 }
1141 tau_client::ManualRuntimePoll::InputClosed => return Ok(()),
1142 tau_client::ManualRuntimePoll::Empty => runtime.wait_for_wake(),
1143 }
1144 }
1145}
1146
1147struct ShellExtension {
1148 initial_config: ExtConfig,
1149}
1150
1151impl tau_client::TauExtension for ShellExtension {
1152 type State = ShellRuntime;
1153
1154 fn name(&self) -> &'static str {
1155 "tau-ext-shell"
1156 }
1157
1158 fn register(self, builder: &mut tau_client::ExtensionBuilder<Self::State>) {
1159 let tools = registered_tool_specs(self.initial_config.dir_lock.enable);
1160
1161 let shell_tool_group = tau_proto::ToolGroup {
1165 name: tau_proto::ToolGroupName::new("shell"),
1166 prompt_fragment: None,
1167 };
1168 let test_tool_group = tau_proto::ToolGroup {
1169 name: tau_proto::ToolGroupName::new("test"),
1170 prompt_fragment: None,
1171 };
1172
1173 for tool in tools {
1174 let tool_group = if tool.name.as_str() == "echo" {
1175 test_tool_group.clone()
1176 } else {
1177 shell_tool_group.clone()
1178 };
1179 builder.tool_with_group_and_prompt_fragment(tool, Some(tool_group), None, |cx| {
1180 let local_tool_name = cx.local_tool_name().clone();
1181 cx.state
1182 .handle_scoped_tool_started(cx.invoke.clone(), &local_tool_name)
1183 });
1184 }
1185 builder
1186 .register_context_provider()
1187 .register_session_context_provider()
1188 .publish_prompt_fragment(shell_workdir_prompt_fragment(&self.initial_config.shell))
1189 .publish_actions(shell_action_schema())
1190 .on_live::<tau_proto::ToolCancelRequest>(|cx| {
1191 cx.state
1192 .handle_event(Event::ToolCancelRequest(cx.event.clone()), false)
1193 })
1194 .on_raw_live(
1195 tau_proto::EventSelector::Exact(tau_proto::EventName::ACTION_INVOKE),
1196 |cx| cx.state.handle_event(cx.event().clone(), false),
1197 )
1198 .on_restore::<tau_proto::SessionStarted>(|cx| {
1199 cx.state
1200 .handle_event(Event::SessionStarted(cx.event.clone()), true)
1201 })
1202 .on_live::<tau_proto::SessionStarted>(|cx| {
1203 cx.state
1204 .handle_event(Event::SessionStarted(cx.event.clone()), false)
1205 })
1206 .on_restore::<tau_proto::SessionAgentLoaded>(|cx| {
1207 cx.state
1208 .handle_event(Event::SessionAgentLoaded(cx.event.clone()), true)
1209 })
1210 .on_live::<tau_proto::SessionAgentLoaded>(|cx| {
1211 cx.state
1212 .handle_event(Event::SessionAgentLoaded(cx.event.clone()), false)
1213 })
1214 .on_restore::<tau_proto::SessionAgentUnloaded>(|cx| {
1215 cx.state
1216 .handle_event(Event::SessionAgentUnloaded(cx.event.clone()), true)
1217 })
1218 .on_live::<tau_proto::SessionAgentUnloaded>(|cx| {
1219 cx.state
1220 .handle_event(Event::SessionAgentUnloaded(cx.event.clone()), false)
1221 })
1222 .on_live::<tau_proto::AgentReplayComplete>(|cx| {
1223 cx.state
1224 .handle_event(Event::AgentReplayComplete(cx.event.clone()), false)
1225 })
1226 .on_live::<tau_proto::HarnessAgentDiscoveryRefreshRequested>(|cx| {
1227 cx.state.handle_event(
1228 Event::HarnessAgentDiscoveryRefreshRequested(cx.event.clone()),
1229 false,
1230 )
1231 })
1232 .on_live::<tau_proto::HarnessAgentContextInitialized>(|cx| {
1233 cx.state.handle_event(
1234 Event::HarnessAgentContextInitialized(cx.event.clone()),
1235 false,
1236 )
1237 })
1238 .on_restore::<tau_proto::AgentMetadataSet>(|cx| {
1239 cx.state
1240 .handle_event(Event::AgentMetadataSet(cx.event.clone()), true)
1241 })
1242 .on_live::<tau_proto::AgentMetadataSet>(|cx| {
1243 cx.state
1244 .handle_event(Event::AgentMetadataSet(cx.event.clone()), false)
1245 })
1246 .on_restore::<tau_proto::AgentMetadataUnset>(|cx| {
1247 cx.state
1248 .handle_event(Event::AgentMetadataUnset(cx.event.clone()), true)
1249 })
1250 .on_live::<tau_proto::AgentMetadataUnset>(|cx| {
1251 cx.state
1252 .handle_event(Event::AgentMetadataUnset(cx.event.clone()), false)
1253 })
1254 .on_live::<tau_proto::SessionShutdown>(|cx| {
1255 cx.state
1256 .handle_event(Event::SessionShutdown(cx.event.clone()), false)
1257 })
1258 .on_live::<tau_proto::StartAgentAccepted>(|cx| {
1259 cx.state
1260 .handle_event(Event::StartAgentAccepted(cx.event.clone()), false)
1261 })
1262 .on_live::<tau_proto::StartAgentResult>(|cx| {
1263 cx.state
1264 .handle_event(Event::StartAgentResult(cx.event.clone()), false)
1265 })
1266 .on_live::<tau_proto::UiShellCommand>(|cx| {
1267 cx.state
1268 .handle_event(Event::UiShellCommand(cx.event.clone()), false)
1269 })
1270 .configure_raw(|cx| {
1271 let cfg = cx.parse_config::<ExtConfig>()?;
1272 cx.state.apply_config(
1273 cx.configure.instance_name.clone(),
1274 cx.configure.tool_prefix.clone(),
1275 cfg,
1276 )
1277 })
1278 .ready_message("filesystem and shell tools ready");
1279 }
1280}
1281
1282fn apply_working_directory(
1283 current: &ExtConfig,
1284 next: &ExtConfig,
1285 runtime_started: bool,
1286) -> Result<(), String> {
1287 match (¤t.working_directory, &next.working_directory) {
1288 (None, Some(_)) if runtime_started => Err(
1289 "ext-shell working_directory cannot be set after runtime events have started"
1290 .to_owned(),
1291 ),
1292 (None, Some(working_directory)) => set_process_working_directory(working_directory),
1293 (Some(current), Some(next)) if current == next => Ok(()),
1294 (Some(current), Some(next)) => Err(format!(
1295 "ext-shell working_directory cannot be changed after startup (current: {}, requested: {})",
1296 current.display(),
1297 next.display()
1298 )),
1299 _ => Ok(()),
1300 }
1301}
1302
1303fn set_process_working_directory(working_directory: &Path) -> Result<(), String> {
1304 std::env::set_current_dir(working_directory).map_err(|err| {
1305 format!(
1306 "failed to set ext-shell working_directory to {}: {err}",
1307 working_directory.display()
1308 )
1309 })
1310}
1311
1312fn dir_lock_tool_spec(enabled_by_default: bool) -> ToolSpec {
1313 let tags = if enabled_by_default {
1314 tool_tags(&["shell:lock", tau_proto::TURN_WAIT_TOOL_TAG])
1315 } else {
1316 tool_tags(&[tau_proto::TURN_WAIT_TOOL_TAG])
1317 };
1318 ToolSpec {
1319 provider_scope: None,
1320 name: tau_proto::ToolName::new(DIR_LOCK_TOOL_NAME),
1321 model_visible_name: None,
1322 description: Some(
1323 "Lock or unlock a directory and its contents for updates. Waits for the lock when \
1324 necessary."
1325 .to_owned(),
1326 ),
1327 tool_type: tau_proto::ToolType::Function,
1328 parameters: Some(serde_json::json!({
1329 "type": "object",
1330 "properties": {
1331 "command": {
1332 "type": "string",
1333 "enum": ["update", "unlock"],
1334 "description": "Lock or unlock the directory for updates"
1335 },
1336 "directory": {
1337 "type": "string",
1338 "description": "Existing directory to canonicalize before locking"
1339 },
1340 "owner_agent_id": {
1341 "type": "string",
1342 "description": "Optional owner agent id for force-unlocking a manual lock held by another agent"
1343 }
1344 },
1345 "required": ["command", "directory"],
1346 "additionalProperties": false
1347 })),
1348 format: None,
1349 tags,
1350 enabled_by_default,
1351 background_support: None,
1352 examples: vec![
1353 ToolExample {
1354 id: "update-lock".to_owned(),
1355 title: Some("Acquire update lock".to_owned()),
1356 arguments: CborValue::Map(vec![
1357 example_field("command", example_text("update")),
1358 example_field("directory", example_text(".")),
1359 ]),
1360 note: Some(
1361 "Acquire before making file changes when directory locking is enabled."
1362 .to_owned(),
1363 ),
1364 subcommand: Some(ToolExampleSelector {
1365 path: vec!["command".to_owned()],
1366 value: example_text("update"),
1367 }),
1368 },
1369 ToolExample {
1370 id: "unlock".to_owned(),
1371 title: Some("Release update lock".to_owned()),
1372 arguments: CborValue::Map(vec![
1373 example_field("command", example_text("unlock")),
1374 example_field("directory", example_text(".")),
1375 ]),
1376 note: None,
1377 subcommand: Some(ToolExampleSelector {
1378 path: vec!["command".to_owned()],
1379 value: example_text("unlock"),
1380 }),
1381 },
1382 ],
1383 }
1384}
1385
1386fn shell_action_schema() -> tau_actions::ActionSchema {
1387 tau_actions::ActionSchema {
1388 version: tau_actions::ACTION_SCHEMA_VERSION,
1389 roots: vec![tau_actions::ActionCommand {
1390 name: ":shell-dir-force-unlock".to_owned(),
1391 description: "Force-release ext-shell manual directory locks overlapping a directory"
1392 .to_owned(),
1393 action_id: Some(SHELL_DIR_FORCE_UNLOCK_ACTION_ID.to_owned()),
1394 args: vec![tau_actions::ActionArg {
1395 name: "directory".to_owned(),
1396 description: "Existing directory whose overlapping manual locks should be released"
1397 .to_owned(),
1398 required: true,
1399 suggestions: Vec::new(),
1400 kind: tau_actions::ActionArgKind::RestString,
1401 }],
1402 children: Vec::new(),
1403 }],
1404 }
1405}
1406
1407fn dispatch_action_invoke(invoke: ActionInvoke, lock_manager: &DirLockManager) -> Event {
1408 if invoke.action_id != SHELL_DIR_FORCE_UNLOCK_ACTION_ID {
1409 return action_error(invoke, "unknown shell action".to_owned());
1410 }
1411 let Some(directory) = invoke.argv.first().map(String::as_str) else {
1412 return action_error(invoke, "missing directory argument".to_owned());
1413 };
1414 let dir = match crate::dir_lock::canonical_existing_dir(Path::new(directory)) {
1415 Ok(dir) => dir,
1416 Err(message) => return action_error(invoke, message),
1417 };
1418 let removed = match lock_manager.force_unlock_overlapping(&dir) {
1419 Ok(removed) => removed,
1420 Err(message) => {
1421 return action_error(invoke, format!("dir_lock backend error: {message}"));
1422 }
1423 };
1424 if removed.is_empty() {
1425 return action_error(
1426 invoke,
1427 format!("no manual directory locks overlap {}", dir.display()),
1428 );
1429 }
1430
1431 let mut lines = vec![format!(
1432 "Force-unlocked {} manual directory lock(s) overlapping {}.",
1433 removed.len(),
1434 dir.display()
1435 )];
1436 for entry in removed {
1437 lines.push(format!("{} owner={}", entry.dir.display(), entry.owner));
1438 }
1439 Event::ActionResultReported(ActionResult {
1440 invocation_id: invoke.invocation_id,
1441 action_id: invoke.action_id,
1442 output: ActionOutput::Text {
1443 text: lines.join("\n"),
1444 },
1445 })
1446}
1447
1448fn action_error(invoke: ActionInvoke, message: String) -> Event {
1449 Event::ActionErrorReported(ActionError {
1450 invocation_id: invoke.invocation_id,
1451 action_id: invoke.action_id,
1452 message,
1453 details: None,
1454 })
1455}
1456
1457fn rewrite_invoke_for_cwd(
1458 mut invoke: tau_proto::ToolStarted,
1459 base: &Path,
1460) -> tau_proto::ToolStarted {
1461 if invoke.tool_name == WORKDIR_TOOL_NAME {
1462 return invoke;
1463 }
1464 let field = match invoke.tool_name.as_str() {
1465 SHELL_TOOL_NAME => path_crate_tools::ShellSurface::Generic.directory_argument(),
1466 GPT_SHELL_TOOL_NAME => path_crate_tools::ShellSurface::ChatGpt.directory_argument(),
1467 READ_TOOL_NAME | EXPORT_TOOL_NAME | EDIT_TOOL_NAME | REPLACE_TOOL_NAME | FIND_TOOL_NAME
1468 | GREP_TOOL_NAME | LS_TOOL_NAME => "path",
1469 DIR_LOCK_TOOL_NAME => "directory",
1470 _ => return invoke,
1471 };
1472 let explicit_path = cbor_optional_text(&invoke.arguments, field);
1473 if explicit_path.is_none() && cbor_has_field(&invoke.arguments, field) {
1474 return invoke;
1476 }
1477 let Some(path) = explicit_path
1478 .clone()
1479 .or_else(|| matches!(field, "path").then(|| ".".to_owned()))
1480 .or_else(|| {
1481 matches!(
1482 invoke.tool_name.as_str(),
1483 SHELL_TOOL_NAME | GPT_SHELL_TOOL_NAME
1484 )
1485 .then(|| base.display().to_string())
1486 })
1487 else {
1488 return invoke;
1489 };
1490 let path = PathBuf::from(path);
1491 let absolute = if path.is_absolute() {
1492 path
1493 } else {
1494 base.join(path)
1495 };
1496 if let Some(canonical) = canonicalize_existing_dir_for_cwd_field(&absolute, field) {
1497 set_cbor_text_field(
1498 &mut invoke.arguments,
1499 field,
1500 canonical.display().to_string(),
1501 );
1502 } else {
1503 set_cbor_text_field(&mut invoke.arguments, field, absolute.display().to_string());
1504 }
1505 invoke
1506}
1507
1508fn canonicalize_existing_dir_for_cwd_field(path: &Path, field: &str) -> Option<PathBuf> {
1509 (field == "cwd" || field == "workdir" || field == "directory" || field == "path")
1510 .then(|| path.canonicalize().ok())
1511 .flatten()
1512 .filter(|path| path.is_dir())
1513}
1514
1515fn cbor_optional_text(arguments: &CborValue, field: &str) -> Option<String> {
1516 let CborValue::Map(entries) = arguments else {
1517 return None;
1518 };
1519 entries.iter().find_map(|(key, value)| match (key, value) {
1520 (CborValue::Text(key), CborValue::Text(value)) if key == field => Some(value.clone()),
1521 _ => None,
1522 })
1523}
1524
1525fn cbor_has_field(arguments: &CborValue, field: &str) -> bool {
1526 let CborValue::Map(entries) = arguments else {
1527 return false;
1528 };
1529 entries
1530 .iter()
1531 .any(|(key, _)| matches!(key, CborValue::Text(key) if key == field))
1532}
1533
1534fn set_cbor_text_field(arguments: &mut CborValue, field: &str, value: String) {
1535 let CborValue::Map(entries) = arguments else {
1536 return;
1537 };
1538 if let Some((_, existing)) = entries
1539 .iter_mut()
1540 .find(|(key, _)| matches!(key, CborValue::Text(key) if key == field))
1541 {
1542 *existing = CborValue::Text(value);
1543 } else {
1544 entries.push((CborValue::Text(field.to_owned()), CborValue::Text(value)));
1545 }
1546}
1547
1548#[expect(
1549 clippy::too_many_arguments,
1550 reason = "admission receives independently owned scheduler, policy, lifecycle, cwd, and Artifact routes"
1551)]
1552fn schedule_tool_started(
1553 (invoke, local_tool_name): (tau_proto::ToolStarted, &tau_proto::ToolName),
1554 scheduler: &WorkScheduler,
1555 tx: &Output,
1556 config: ExtConfig,
1557 lock_manager: DirLockManager,
1558 cancellation: ToolCancellationState,
1559 cwd_state: CwdState,
1560 artifact_control: ArtifactTransferControl,
1561) -> Result<
1562 (),
1563 Box<(
1564 tool_started_identity::ToolStartedIdentity,
1565 crate::display::ToolFailure,
1566 )>,
1567> {
1568 let (identity, arguments) =
1569 tool_started_identity::ToolStartedIdentity::split(invoke, local_tool_name.clone());
1570 let tx = tx.scoped_tool(
1571 identity.local_tool_name.clone(),
1572 identity.wire_tool_name.clone(),
1573 );
1574 let workdir_snapshot = cwd_state.snapshot(&identity.agent_id).map_err(|message| {
1575 Box::new((
1576 identity.clone(),
1577 path_crate_display::ToolFailure::new(message),
1578 ))
1579 })?;
1580 if matches!(workdir_snapshot, WorkdirSnapshot::Invalid)
1581 && identity.local_tool_name != WORKDIR_TOOL_NAME
1582 {
1583 return Err(Box::new((
1584 identity,
1585 path_crate_display::ToolFailure::new(
1586 "remembered workdir metadata is invalid; repair it with an absolute workdir path",
1587 ),
1588 )));
1589 }
1590 if matches!(workdir_snapshot, WorkdirSnapshot::ReplayFailed) {
1591 return Err(Box::new((
1592 identity,
1593 path_crate_display::ToolFailure::new(
1594 "workdir replay failed for this agent; reload the agent before retrying",
1595 ),
1596 )));
1597 }
1598 if matches!(workdir_snapshot, WorkdirSnapshot::Invalid) {
1599 let requested = cbor_optional_text(&arguments, "path");
1600 if !requested
1601 .as_deref()
1602 .is_none_or(|path| Path::new(path).is_absolute())
1603 {
1604 return Err(Box::new((
1605 identity,
1606 path_crate_display::ToolFailure::new(
1607 "remembered workdir metadata is invalid; repair it with an absolute workdir path",
1608 ),
1609 )));
1610 }
1611 }
1612 let mut invoke = identity.clone().into_local_started(arguments);
1613 invoke = match &workdir_snapshot {
1614 WorkdirSnapshot::Valid(cwd) => rewrite_invoke_for_cwd(invoke, cwd),
1615 WorkdirSnapshot::Invalid => invoke,
1616 WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
1617 };
1618 if invoke.tool_name == WORKDIR_TOOL_NAME
1619 && cbor_optional_text(&invoke.arguments, "path").is_some()
1620 {
1621 let base = match &workdir_snapshot {
1622 WorkdirSnapshot::Valid(path) => Some(path.as_path()),
1623 WorkdirSnapshot::Invalid => None,
1624 WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
1625 };
1626 let path = path_crate_tools::workdir::target_dir(&invoke.arguments, base)
1627 .map_err(|failure| Box::new((identity.clone(), failure)))?;
1628 cwd_state
1629 .start_pending_workdir_result(
1630 invoke.agent_id.clone(),
1631 path,
1632 identity.clone(),
1633 None,
1634 )
1635 .map_err(|_| {
1636 Box::new((
1637 identity.clone(),
1638 path_crate_display::ToolFailure::new(
1639 "another workdir change is already pending for this agent and shell instance",
1640 ),
1641 ))
1642 })?;
1643 cwd_state.mark_pending_workdir_awaiting_echo(&invoke.agent_id, &identity.call_id);
1644 let path = cwd_state
1645 .pending_workdir_target(&invoke.agent_id, &identity.call_id)
1646 .expect("newly reserved workdir target");
1647 let mutation_id =
1648 cwd_state.pending_workdir_mutation_id(&invoke.agent_id, &identity.call_id);
1649 if tx
1650 .send_checked(HarnessInputMessage::emit_transient(
1651 Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
1652 agent_id: invoke.agent_id,
1653 key: cwd_state.key(),
1654 value: CborValue::Text(path.display().to_string()),
1655 mutation_id,
1656 inheritable: true,
1657 }),
1658 ))
1659 .is_err()
1660 {
1661 let failure =
1662 path_crate_display::ToolFailure::new("failed to request workdir metadata commit");
1663 if send_identity_failure(identity.clone(), failure, &tx).is_ok() {
1664 cwd_state.take_pending_workdir_by_call(&identity.call_id);
1665 }
1666 return Ok(());
1667 }
1668 return Ok(());
1669 }
1670 let priority = priority_for_tool(&invoke, &config);
1671 let meta = WorkMeta {
1672 call_id: Some(invoke.call_id.clone()),
1673 agent_id: Some(invoke.agent_id.clone()),
1674 queued_bytes: approximate_tool_bytes(&invoke, scheduler.queued_bytes_limit()),
1675 };
1676 #[cfg(test)]
1677 tool_started_identity::ownership_probe::record_queued_bytes(
1678 &identity.call_id,
1679 meta.queued_bytes,
1680 );
1681 let tx_for_job = tx.clone();
1682 let lifecycle = cancellation.lifecycles.admit(
1683 invoke.call_id.clone(),
1684 invoke.tool_name.clone(),
1685 invoke.agent_id.clone(),
1686 tx_for_job.clone(),
1687 );
1688 let lifecycle_for_error = lifecycle.clone();
1689 let identity_for_error = identity;
1690 let cwd_state_for_error = cwd_state.clone();
1691 scheduler
1692 .enqueue(priority, meta, move || {
1693 #[cfg(test)]
1694 lifecycle.test_pause_after_dequeue();
1695 if invoke.tool_name == DIR_LOCK_TOOL_NAME {
1696 if lifecycle.start_effect() {
1697 crate::dir_lock::dispatch_dir_lock_tool(
1698 invoke,
1699 &lock_manager,
1700 config.dir_lock.enable,
1701 &tx_for_job,
1702 lifecycle.clone(),
1703 );
1704 }
1705 } else if config.dir_lock.enable && is_dir_lock_update_tool(invoke.tool_name.as_str()) {
1706 dispatch_locked_tool_invoke(
1707 invoke,
1708 ToolDispatchContext {
1709 shell_config: config.shell,
1710 tx: tx_for_job.clone(),
1711 running_calls: Arc::clone(&cancellation.running_calls),
1712 enforce_ro_bind: config.dir_lock.enforce_ro_bind,
1713 cwd_state: cwd_state.clone(),
1714 lifecycle: lifecycle.clone(),
1715 },
1716 &lock_manager,
1717 match &workdir_snapshot {
1718 WorkdirSnapshot::Valid(cwd) => cwd.clone(),
1719 WorkdirSnapshot::Invalid => {
1720 unreachable!("only workdir admits invalid state")
1721 }
1722 WorkdirSnapshot::ReplayFailed => {
1723 unreachable!("replay failures return above")
1724 }
1725 },
1726 );
1727 } else if invoke.tool_name == EXPORT_TOOL_NAME || invoke.tool_name == IMPORT_TOOL_NAME {
1728 if lifecycle.start_effect()
1729 && artifact_control.prepare(
1730 invoke,
1731 lifecycle.clone(),
1732 match &workdir_snapshot {
1733 WorkdirSnapshot::Valid(cwd) => cwd,
1734 WorkdirSnapshot::Invalid | WorkdirSnapshot::ReplayFailed => {
1735 unreachable!("artifact tools require a valid workdir")
1736 }
1737 },
1738 &tx_for_job,
1739 )
1740 {
1741 return;
1742 }
1743 } else {
1744 if lifecycle.start_effect() {
1745 dispatch_tool_invoke(
1746 invoke,
1747 ToolDispatchContext {
1748 shell_config: config.shell,
1749 tx: tx_for_job.clone(),
1750 running_calls: Arc::clone(&cancellation.running_calls),
1751 enforce_ro_bind: config.dir_lock.enforce_ro_bind,
1752 cwd_state: cwd_state.clone(),
1753 lifecycle: lifecycle.clone(),
1754 },
1755 None,
1756 config
1757 .dir_lock
1758 .enable
1759 .then_some(ShellCommandMode::visible(ShellAccessMode::ReadOnly)),
1760 workdir_snapshot.clone(),
1761 );
1762 }
1763 }
1764 if !tx_for_job.mandatory_output_failed() {
1765 lifecycle.finish();
1766 }
1767 })
1768 .map_err(|error| {
1769 lifecycle_for_error.finish();
1770 cwd_state_for_error.take_pending_workdir_by_call(&identity_for_error.call_id);
1771 Box::new((
1772 identity_for_error,
1773 path_crate_display::ToolFailure::new(error.message),
1774 ))
1775 })
1776}
1777
1778struct UiShellScheduleContext<'a> {
1780 scheduler: &'a WorkScheduler,
1782 tx: &'a Output,
1784 shell_config: ShellConfig,
1786 running_ui_commands: Arc<Mutex<HashMap<tau_proto::ShellCommandId, mpsc::Sender<()>>>>,
1788 shutdown_generation_counter: Arc<UiShellShutdownGenerationCounter>,
1790 scheduled_generation: UiShellShutdownGeneration,
1792 cwd: PathBuf,
1794}
1795
1796fn schedule_ui_shell_command(
1797 cmd: tau_proto::UiShellCommand,
1798 context: UiShellScheduleContext<'_>,
1799) -> Result<(), Box<(tau_proto::UiShellCommand, String)>> {
1800 let UiShellScheduleContext {
1801 scheduler,
1802 tx,
1803 shell_config,
1804 running_ui_commands,
1805 shutdown_generation_counter,
1806 scheduled_generation,
1807 cwd,
1808 } = context;
1809 let meta = WorkMeta {
1810 call_id: None,
1811 agent_id: cmd.target_agent_id.clone(),
1812 queued_bytes: cmd.command.len(),
1813 };
1814 let tx_for_job = tx.clone();
1815 let cmd_for_error = cmd.clone();
1816 let command_id = cmd.command_id.clone();
1817 scheduler
1818 .enqueue(WorkPriority::User, meta, move || {
1819 let (cancel_tx, cancel_rx) = mpsc::channel();
1820 running_ui_commands
1821 .lock()
1822 .expect("running ui shell registry lock poisoned")
1823 .insert(command_id.clone(), cancel_tx.clone());
1824 if shutdown_generation_counter.current() != scheduled_generation {
1825 let _ = cancel_tx.send(());
1826 }
1827 path_crate_tools::shell::dispatch_user_shell_command(
1828 cmd,
1829 shell_config,
1830 &tx_for_job,
1831 cancel_rx,
1832 cwd,
1833 );
1834 running_ui_commands
1835 .lock()
1836 .expect("running ui shell registry lock poisoned")
1837 .remove(&command_id);
1838 })
1839 .map_err(|error| Box::new((cmd_for_error, error.message)))
1840}
1841
1842fn priority_for_tool(invoke: &tau_proto::ToolStarted, config: &ExtConfig) -> WorkPriority {
1843 if invoke.tool_name == DIR_LOCK_TOOL_NAME {
1844 if is_dir_lock_update_invocation(&invoke.arguments) {
1845 return WorkPriority::Bulk;
1846 }
1847 return WorkPriority::Control;
1848 }
1849 if matches!(
1850 invoke.tool_name.as_str(),
1851 READ_TOOL_NAME
1852 | EXPORT_TOOL_NAME
1853 | IMPORT_TOOL_NAME
1854 | GREP_TOOL_NAME
1855 | FIND_TOOL_NAME
1856 | LS_TOOL_NAME
1857 ) {
1858 return WorkPriority::Cheap;
1859 }
1860 if config.dir_lock.enable && is_dir_lock_update_tool(invoke.tool_name.as_str()) {
1861 return WorkPriority::Bulk;
1862 }
1863 WorkPriority::Bulk
1864}
1865
1866fn approximate_tool_bytes(invoke: &tau_proto::ToolStarted, queued_bytes_limit: usize) -> usize {
1867 let cap = queued_bytes_limit.saturating_add(1);
1868 let base = invoke
1869 .call_id
1870 .as_str()
1871 .len()
1872 .saturating_add(invoke.tool_name.as_str().len())
1873 .saturating_add(invoke.agent_id.as_str().len());
1874 saturating_add_capped(base, estimate_cbor_bytes(&invoke.arguments, cap), cap)
1875}
1876
1877fn estimate_cbor_bytes(value: &CborValue, cap: usize) -> usize {
1878 if cap == 0 {
1879 return 0;
1880 }
1881 match value {
1882 CborValue::Integer(_) | CborValue::Float(_) | CborValue::Bool(_) | CborValue::Null => {
1883 8.min(cap)
1884 }
1885 CborValue::Bytes(bytes) => bytes.len().min(cap),
1886 CborValue::Text(text) => text.len().min(cap),
1887 CborValue::Tag(_, inner) => saturating_add_capped(8, estimate_cbor_bytes(inner, cap), cap),
1888 CborValue::Array(values) => estimate_cbor_sequence(values.iter(), cap),
1889 CborValue::Map(entries) => {
1890 let mut total = 1usize;
1891 for (key, value) in entries {
1892 total = saturating_add_capped(total, estimate_cbor_bytes(key, cap - total), cap);
1893 if cap <= total {
1894 return cap;
1895 }
1896 total = saturating_add_capped(total, estimate_cbor_bytes(value, cap - total), cap);
1897 if cap <= total {
1898 return cap;
1899 }
1900 }
1901 total
1902 }
1903 _ => 8.min(cap),
1904 }
1905}
1906
1907fn estimate_cbor_sequence<'a>(values: impl Iterator<Item = &'a CborValue>, cap: usize) -> usize {
1908 let mut total = 1usize;
1909 for value in values {
1910 total = saturating_add_capped(total, estimate_cbor_bytes(value, cap - total), cap);
1911 if cap <= total {
1912 return cap;
1913 }
1914 }
1915 total
1916}
1917
1918fn saturating_add_capped(lhs: usize, rhs: usize, cap: usize) -> usize {
1919 lhs.saturating_add(rhs).min(cap)
1920}
1921
1922struct ToolDispatchContext {
1924 shell_config: ShellConfig,
1926 tx: Output,
1928 running_calls: Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
1930 enforce_ro_bind: bool,
1932 cwd_state: CwdState,
1934 lifecycle: ToolLifecycle,
1936}
1937
1938fn dispatch_locked_tool_invoke(
1939 invoke: tau_proto::ToolStarted,
1940 context: ToolDispatchContext,
1941 lock_manager: &DirLockManager,
1942 cwd: PathBuf,
1943) {
1944 let ToolDispatchContext {
1945 shell_config,
1946 tx,
1947 running_calls,
1948 enforce_ro_bind,
1949 cwd_state,
1950 lifecycle,
1951 } = context;
1952 let dirs = match crate::dir_lock::automatic_lock_dirs_for_tool_in_dir(
1953 invoke.tool_name.as_str(),
1954 &invoke.arguments,
1955 &cwd,
1956 ) {
1957 Ok(dirs) => crate::dir_lock::normalize_lock_dirs(dirs),
1958 Err(error) => {
1959 if lifecycle.claim_terminal_before_effect() {
1960 let _ = send_tool_failure(invoke, error, &tx);
1961 }
1962 return;
1963 }
1964 };
1965 let shell_command_mode = is_shell_command_tool(invoke.tool_name.as_str())
1966 .then_some(ShellCommandMode::visible(ShellAccessMode::ReadWrite));
1967
1968 let lock_wait_started = Instant::now();
1969 let wait_progress = crate::dir_lock::waiting_progress(&invoke, &dirs, shell_command_mode);
1970 let wait_tx = tx.clone();
1971 let on_wait = move || {
1972 let _ = wait_tx.report_tool_progress(wait_progress);
1973 };
1974 let guard = match if shell_command_mode.is_some() {
1975 lock_manager.acquire_auto_if_manual_covers(
1976 invoke.call_id.clone(),
1977 invoke.agent_id.clone(),
1978 dirs,
1979 on_wait,
1980 )
1981 } else {
1982 lock_manager.acquire_auto(
1983 invoke.call_id.clone(),
1984 invoke.agent_id.clone(),
1985 dirs,
1986 on_wait,
1987 )
1988 } {
1989 Ok(guard) => guard,
1990 Err(path_crate_dir_lock::LockAcquireError::NotCovered) => {
1991 if lifecycle.start_effect() {
1992 dispatch_tool_invoke(
1993 invoke,
1994 ToolDispatchContext {
1995 shell_config,
1996 tx,
1997 running_calls,
1998 enforce_ro_bind,
1999 cwd_state,
2000 lifecycle: lifecycle.clone(),
2001 },
2002 None,
2003 Some(ShellCommandMode::visible(ShellAccessMode::ReadOnly)),
2004 WorkdirSnapshot::Valid(cwd),
2005 );
2006 }
2007 return;
2008 }
2009 Err(path_crate_dir_lock::LockAcquireError::Cancelled) => {
2010 lifecycle.report_cancelled_before_effect();
2011 return;
2012 }
2013 Err(path_crate_dir_lock::LockAcquireError::Abandoned(lock)) => {
2014 if lifecycle.claim_terminal_before_effect() {
2015 let _ = send_tool_failure(invoke, lock.tool_failure(), &tx);
2016 }
2017 return;
2018 }
2019 Err(path_crate_dir_lock::LockAcquireError::SelfConflict {
2020 uncovered_dir,
2021 held_dir,
2022 }) => {
2023 if lifecycle.claim_terminal_before_effect() {
2024 let _ = send_tool_failure(
2025 invoke,
2026 path_crate_display::ToolFailure::new(format!(
2027 "automatic directory lock is outside your manual lock coverage: requested {}; held {}",
2028 uncovered_dir.display(),
2029 held_dir.display()
2030 )),
2031 &tx,
2032 );
2033 }
2034 return;
2035 }
2036 Err(path_crate_dir_lock::LockAcquireError::Backend(message)) => {
2037 if lifecycle.claim_terminal_before_effect() {
2038 let _ = send_tool_failure(
2039 invoke,
2040 path_crate_display::ToolFailure::new(format!(
2041 "dir_lock backend error: {message}"
2042 )),
2043 &tx,
2044 );
2045 }
2046 return;
2047 }
2048 };
2049
2050 let lock_wait_duration_seconds =
2051 reported_lock_wait_duration_seconds(lock_wait_started.elapsed());
2052 #[cfg(test)]
2053 lifecycle.test_pause_after_lock();
2054 if lifecycle.start_effect() {
2055 dispatch_tool_invoke(
2056 invoke,
2057 ToolDispatchContext {
2058 shell_config,
2059 tx,
2060 running_calls,
2061 enforce_ro_bind,
2062 cwd_state,
2063 lifecycle: lifecycle.clone(),
2064 },
2065 lock_wait_duration_seconds,
2066 shell_command_mode,
2067 WorkdirSnapshot::Valid(cwd),
2068 );
2069 }
2070 drop(guard);
2071}
2072
2073fn send_ui_shell_saturated_failure(cmd: tau_proto::UiShellCommand, message: String, tx: &Output) {
2074 let _ = tx.send_checked(HarnessInputMessage::emit(
2075 Event::ShellCommandFinishedReported(tau_proto::ShellCommandFinished {
2076 command_id: cmd.command_id,
2077 session_id: cmd.session_id,
2078 command: cmd.command,
2079 include_in_context: cmd.include_in_context,
2080 target_agent_id: cmd.target_agent_id,
2081 output: message,
2082 exit_code: None,
2083 cancelled: false,
2084 }),
2085 ));
2086}
2087
2088fn send_identity_failure(
2089 identity: tool_started_identity::ToolStartedIdentity,
2090 failure: crate::display::ToolFailure,
2091 tx: &Output,
2092) -> tau_client::ClientResult<()> {
2093 let crate::display::ToolFailure {
2094 message,
2095 details,
2096 display,
2097 } = failure;
2098 tx.report_tool_terminal(Event::ToolError(tau_proto::ToolError {
2099 presentation: Default::default(),
2100 call_id: identity.call_id,
2101 tool_name: identity.wire_tool_name,
2102 tool_type: tau_proto::ToolType::Function,
2103 message,
2104 details: details.map(|details| *details),
2105 display: Some(*display),
2106 originator: identity.originator,
2107 }))
2108}
2109
2110fn send_tool_failure(
2111 invoke: tau_proto::ToolStarted,
2112 failure: crate::display::ToolFailure,
2113 tx: &Output,
2114) -> tau_client::ClientResult<()> {
2115 send_identity_failure(invoke.into(), failure, tx)
2116}
2117
2118fn reported_lock_wait_duration_seconds(elapsed: Duration) -> Option<u64> {
2119 if elapsed <= Duration::from_secs(SLOW_LOCK_WAIT_THRESHOLD_SECS) {
2120 return None;
2121 }
2122
2123 let whole_seconds = elapsed.as_secs();
2124 if Duration::from_secs(whole_seconds) < elapsed {
2125 Some(whole_seconds.saturating_add(1))
2126 } else {
2127 Some(whole_seconds)
2128 }
2129}
2130
2131fn with_lock_wait_duration(event: Event, lock_wait_duration_seconds: Option<u64>) -> Event {
2132 let Some(seconds) = lock_wait_duration_seconds else {
2133 return event;
2134 };
2135
2136 match event {
2137 Event::ToolResult(mut result) => {
2138 result.result = cbor_value_with_lock_wait_duration(result.result, seconds, "output");
2139 Event::ToolResult(result)
2140 }
2141 Event::ToolError(mut error) => {
2142 error.details = Some(match error.details {
2143 Some(details) => cbor_value_with_lock_wait_duration(details, seconds, "details"),
2144 None => CborValue::Map(vec![lock_wait_duration_entry(seconds)]),
2145 });
2146 Event::ToolError(error)
2147 }
2148 event => event,
2149 }
2150}
2151
2152fn cbor_value_with_lock_wait_duration(
2153 value: CborValue,
2154 seconds: u64,
2155 non_map_payload_key: &str,
2156) -> CborValue {
2157 match value {
2158 CborValue::Map(mut entries) => {
2159 prepend_lock_wait_duration(&mut entries, seconds);
2160 CborValue::Map(entries)
2161 }
2162 value => CborValue::Map(vec![
2163 lock_wait_duration_entry(seconds),
2164 (CborValue::Text(non_map_payload_key.to_owned()), value),
2165 ]),
2166 }
2167}
2168
2169fn prepend_lock_wait_duration(entries: &mut Vec<(CborValue, CborValue)>, seconds: u64) {
2170 entries.retain(|(key, _)| match key {
2171 CborValue::Text(key) => key != LOCK_WAIT_DURATION_SECONDS_HEADER,
2172 _ => true,
2173 });
2174 entries.insert(0, lock_wait_duration_entry(seconds));
2175}
2176
2177fn lock_wait_duration_entry(seconds: u64) -> (CborValue, CborValue) {
2178 let seconds = i64::try_from(seconds).unwrap_or(i64::MAX);
2179 (
2180 CborValue::Text(LOCK_WAIT_DURATION_SECONDS_HEADER.to_owned()),
2181 CborValue::Integer(seconds.into()),
2182 )
2183}
2184
2185fn dispatch_tool_invoke(
2187 mut invoke: tau_proto::ToolStarted,
2188 context: ToolDispatchContext,
2189 lock_wait_duration_seconds: Option<u64>,
2190 shell_command_mode: Option<ShellCommandMode>,
2191 workdir_snapshot: WorkdirSnapshot,
2192) {
2193 let ToolDispatchContext {
2194 shell_config,
2195 tx,
2196 running_calls,
2197 enforce_ro_bind,
2198 cwd_state,
2199 lifecycle,
2200 } = context;
2201 if invoke.tool_name == WORKDIR_TOOL_NAME {
2202 if cbor_optional_text(&invoke.arguments, "path").is_none() {
2203 let output = path_crate_tools::workdir::status_output(match &workdir_snapshot {
2204 WorkdirSnapshot::Valid(path) => Some(path.as_path()),
2205 WorkdirSnapshot::Invalid => None,
2206 WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
2207 });
2208 let _ = tx.report_tool_terminal(Event::ToolResult(ToolResult {
2209 presentation: Default::default(),
2210 call_id: invoke.call_id,
2211 tool_name: invoke.tool_name,
2212 tool_type: tau_proto::ToolType::Function,
2213 result: output.result,
2214 provider_content: output.provider_content,
2215 kind: ToolResultKind::Final,
2216 display: Some(output.display),
2217 originator: invoke.originator,
2218 }));
2219 return;
2220 }
2221 let agent_id = invoke.agent_id.clone();
2222 if let Some(path) = cwd_state.pending_workdir_target(&agent_id, &invoke.call_id) {
2223 if cwd_state.mark_pending_workdir_awaiting_echo(&agent_id, &invoke.call_id) {
2224 let metadata = Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
2225 agent_id,
2226 key: cwd_state.key(),
2227 value: CborValue::Text(path.display().to_string()),
2228 mutation_id: None,
2229 inheritable: true,
2230 });
2231 let _ = tx.send_checked(HarnessInputMessage::emit_transient(metadata));
2232 }
2233 return;
2234 }
2235 return;
2238 }
2239 let tool_cwd = match workdir_snapshot {
2240 WorkdirSnapshot::Valid(cwd) => cwd,
2241 WorkdirSnapshot::Invalid => unreachable!("non-workdir calls reject invalid state"),
2242 WorkdirSnapshot::ReplayFailed => unreachable!("replay failures return above"),
2243 };
2244 if matches!(
2245 invoke.tool_name.as_str(),
2246 READ_TOOL_NAME
2247 | EDIT_TOOL_NAME
2248 | GREP_TOOL_NAME
2249 | FIND_TOOL_NAME
2250 | LS_TOOL_NAME
2251 | SHELL_TOOL_NAME
2252 | GPT_SHELL_TOOL_NAME
2253 ) {
2254 crate::shell_output_spool::note_call();
2255 }
2256 let (world, authorized_cwd) = match world_after_shell_authorization(
2257 &mut invoke,
2258 &shell_config,
2259 tau_vcr::VcrConfig::from_env(),
2260 tool_cwd,
2261 ) {
2262 Ok(world) => world,
2263 Err(crate::display::ToolFailure {
2264 message,
2265 details,
2266 display,
2267 }) => {
2268 let event = Event::ToolError(tau_proto::ToolError {
2269 presentation: Default::default(),
2270 call_id: invoke.call_id.clone(),
2271 tool_name: invoke.tool_name.clone(),
2272 tool_type: tau_proto::ToolType::Function,
2273 message,
2274 details: details.map(|details| *details),
2275 display: Some(*display),
2276 originator: invoke.originator.clone(),
2277 });
2278 let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2279 let _ = tx.report_tool_terminal(event);
2280 return;
2281 }
2282 };
2283
2284 if invoke.tool_name == SHELL_TOOL_NAME || invoke.tool_name == GPT_SHELL_TOOL_NAME {
2285 dispatch_cancellable_shell_tool(CancellableShellDispatch {
2286 invoke,
2287 shell_config,
2288 tx: &tx,
2289 running_calls: &running_calls,
2290 lifecycle: &lifecycle,
2291 lock_wait_duration_seconds,
2292 shell_command_mode: shell_command_mode.unwrap_or(ShellCommandMode::READ_WRITE_HIDDEN),
2293 enforce_ro_bind,
2294 world,
2295 authorized_cwd,
2296 });
2297 return;
2298 }
2299
2300 if invoke.tool_name == GREP_TOOL_NAME || invoke.tool_name == FIND_TOOL_NAME {
2301 dispatch_cancellable_non_shell_tool(
2302 invoke,
2303 &tx,
2304 &running_calls,
2305 &lifecycle,
2306 lock_wait_duration_seconds,
2307 world,
2308 );
2309 return;
2310 }
2311
2312 if let Some(display) = crate::tools::initial_display(&invoke) {
2313 let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2314 call_id: invoke.call_id.clone(),
2315 tool_name: invoke.tool_name.clone(),
2316 message: None,
2317 progress: None,
2318 display: Some(display),
2319 });
2320 }
2321
2322 let events = execute_tool(invoke, world);
2323 for event in events {
2324 let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2325 let _ = tx.report_tool_terminal(event);
2326 }
2327}
2328
2329fn world_after_shell_authorization(
2332 invoke: &mut tau_proto::ToolStarted,
2333 shell_config: &ShellConfig,
2334 vcr_config: Option<tau_vcr::VcrConfig>,
2335 tool_cwd: PathBuf,
2336) -> Result<(path_crate_tools_world::ShellWorld, Option<PathBuf>), crate::display::ToolFailure> {
2337 let authorized_cwd = if let Some(surface) =
2338 path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2339 {
2340 path_crate_tools::shell::prepare_tool_invocation(surface, &invoke.arguments, shell_config)?
2341 } else {
2342 None
2343 };
2344 if let Some(surface) = path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2345 && let Some(canonical_cwd) = authorized_cwd.as_ref()
2346 {
2347 set_cbor_text_field(
2348 &mut invoke.arguments,
2349 surface.directory_argument(),
2350 canonical_cwd.display().to_string(),
2351 );
2352 }
2353 let world = path_crate_tools_world::ShellWorld::for_tool_in_dir(
2354 invoke.tool_name.as_str(),
2355 invoke.call_id.as_str(),
2356 &invoke.arguments,
2357 vcr_config,
2358 tool_cwd,
2359 )?;
2360 Ok((world, authorized_cwd))
2361}
2362
2363fn dispatch_cancellable_non_shell_tool(
2364 invoke: tau_proto::ToolStarted,
2365 tx: &Output,
2366 running_calls: &Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
2367 lifecycle: &ToolLifecycle,
2368 lock_wait_duration_seconds: Option<u64>,
2369 world: path_crate_tools::world::ShellWorld,
2370) {
2371 #[cfg(test)]
2372 lifecycle.test_pause_before_active_registration();
2373 let (cancel_tx, cancel_rx) = mpsc::channel();
2374 running_calls
2375 .lock()
2376 .expect("running call registry lock poisoned")
2377 .insert(invoke.call_id.clone(), cancel_tx.clone());
2378 if lifecycle.effect_cancel_requested() {
2379 let _ = cancel_tx.send(());
2380 }
2381
2382 if let Some(display) = crate::tools::initial_display(&invoke) {
2383 let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2384 call_id: invoke.call_id.clone(),
2385 tool_name: invoke.tool_name.clone(),
2386 message: None,
2387 progress: None,
2388 display: Some(display),
2389 });
2390 }
2391
2392 let call_id = invoke.call_id.clone();
2393 let tool_name = invoke.tool_name.clone();
2394 let outcome = crate::tools::execute_cancellable_tool(invoke, world, cancel_rx);
2395
2396 running_calls
2397 .lock()
2398 .expect("running call registry lock poisoned")
2399 .remove(&call_id);
2400
2401 match outcome {
2402 path_crate_tools::CancellableToolOutcome::Finished(events) => {
2403 for event in events {
2404 let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2405 let _ = tx.report_tool_terminal(event);
2406 }
2407 }
2408 path_crate_tools::CancellableToolOutcome::Cancelled => {
2409 let event = Event::ToolCancelled(ToolCancelled {
2410 presentation: Default::default(),
2411 call_id,
2412 tool_name,
2413 tool_type: tau_proto::ToolType::Function,
2414 display: None,
2415 });
2416 let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2417 let _ = tx.report_tool_terminal(event);
2418 }
2419 }
2420}
2421
2422struct CancellableShellDispatch<'a> {
2424 invoke: tau_proto::ToolStarted,
2426 shell_config: ShellConfig,
2428 tx: &'a Output,
2430 running_calls: &'a Arc<Mutex<HashMap<tau_proto::ToolCallId, mpsc::Sender<()>>>>,
2433 lifecycle: &'a ToolLifecycle,
2435 lock_wait_duration_seconds: Option<u64>,
2437 shell_command_mode: ShellCommandMode,
2439 enforce_ro_bind: bool,
2442 world: path_crate_tools::world::ShellWorld,
2444 authorized_cwd: Option<PathBuf>,
2446}
2447
2448fn dispatch_cancellable_shell_tool(params: CancellableShellDispatch<'_>) {
2449 let CancellableShellDispatch {
2450 invoke,
2451 shell_config,
2452 tx,
2453 running_calls,
2454 lifecycle,
2455 lock_wait_duration_seconds,
2456 shell_command_mode,
2457 enforce_ro_bind,
2458 mut world,
2459 authorized_cwd,
2460 } = params;
2461 #[cfg(test)]
2462 lifecycle.test_pause_before_active_registration();
2463 let (cancel_tx, cancel_rx) = mpsc::channel();
2464 debug!(
2465 call_id = %invoke.call_id,
2466 tool_name = %invoke.tool_name,
2467 "registering cancellable shell call"
2468 );
2469 running_calls
2470 .lock()
2471 .expect("running call registry lock poisoned")
2472 .insert(invoke.call_id.clone(), cancel_tx.clone());
2473 if lifecycle.effect_cancel_requested() {
2474 let _ = cancel_tx.send(());
2475 }
2476
2477 let _ = tx.report_tool_progress(tau_proto::ToolProgress {
2478 call_id: invoke.call_id.clone(),
2479 tool_name: invoke.tool_name.clone(),
2480 message: None,
2481 progress: None,
2482 display: Some(path_crate_tools::shell::initial_display(
2483 &invoke.arguments,
2484 shell_command_mode,
2485 )),
2486 });
2487 let result = path_crate_tools::shell::run_command_cancellable_for_tool(
2488 path_crate_tools::shell::ShellInvocation {
2489 surface: path_crate_tools::ShellSurface::for_tool_name(invoke.tool_name.as_str())
2490 .expect("shell dispatch accepts only known shell tools"),
2491 call_id: invoke.call_id.as_str(),
2492 arguments: &invoke.arguments,
2493 authorized_cwd: authorized_cwd.as_deref(),
2494 },
2495 &shell_config,
2496 shell_command_mode,
2497 enforce_ro_bind,
2498 Some(cancel_rx),
2499 &mut world,
2500 );
2501 let outcome = match (result, world.finish()) {
2502 (Ok(outcome), Ok(())) => Ok(outcome),
2503 (Ok(_), Err(failure)) | (Err(failure), Ok(())) | (Err(failure), Err(_)) => Err(failure),
2504 };
2505 let event = match outcome {
2506 Ok(path_crate_tools_shell::CommandOutcome::Finished(output)) => {
2507 debug!(call_id = %invoke.call_id, tool_name = %invoke.tool_name, "cancellable shell call finished");
2508 Event::ToolResult(ToolResult {
2509 presentation: Default::default(),
2510 call_id: invoke.call_id.clone(),
2511 tool_name: invoke.tool_name.clone(),
2512 tool_type: tau_proto::ToolType::Function,
2513 result: output.result,
2514 provider_content: Vec::new(),
2515 kind: ToolResultKind::Final,
2516 display: Some(output.display),
2517 originator: invoke.originator.clone(),
2518 })
2519 }
2520 Ok(path_crate_tools_shell::CommandOutcome::Cancelled) => {
2521 debug!(call_id = %invoke.call_id, tool_name = %invoke.tool_name, "cancellable shell call cancelled");
2522 Event::ToolCancelled(ToolCancelled {
2523 presentation: Default::default(),
2524 call_id: invoke.call_id.clone(),
2525 tool_name: invoke.tool_name.clone(),
2526 tool_type: tau_proto::ToolType::Function,
2527 display: None,
2528 })
2529 }
2530 Err(crate::display::ToolFailure {
2531 message,
2532 details,
2533 display,
2534 }) => {
2535 debug!(
2536 call_id = %invoke.call_id,
2537 tool_name = %invoke.tool_name,
2538 message,
2539 "cancellable shell call failed"
2540 );
2541 Event::ToolError(tau_proto::ToolError {
2542 presentation: Default::default(),
2543 call_id: invoke.call_id.clone(),
2544 tool_name: invoke.tool_name.clone(),
2545 tool_type: tau_proto::ToolType::Function,
2546 message,
2547 details: details.map(|details| *details),
2548 display: Some(*display),
2549 originator: invoke.originator.clone(),
2550 })
2551 }
2552 };
2553
2554 running_calls
2555 .lock()
2556 .expect("running call registry lock poisoned")
2557 .remove(&invoke.call_id);
2558 trace!(call_id = %invoke.call_id, "removed shell call from cancellation registry");
2559 let event = with_lock_wait_duration(event, lock_wait_duration_seconds);
2560 if tx.report_tool_terminal(event).is_err() {
2561 debug!(call_id = %invoke.call_id, "failed to send terminal shell event to harness");
2562 }
2563}
2564
2565fn dispatch_session_started(
2566 started: SessionStarted,
2567 tx: &Output,
2568 discovery_policy: DiscoverySourcePolicy,
2569) -> tau_client::ClientResult<()> {
2570 let session_id = started.session_id.clone();
2571 let scan = build_discovery_snapshot(started, discovery_policy);
2572 for diagnostic in scan.diagnostics {
2573 let _ = tx.send(diagnostic);
2574 }
2575 dispatch_session_discovery_messages(
2576 session_id,
2577 vec![HarnessInputMessage::emit_transient(
2578 Event::ExtensionSessionDiscoverySnapshotDeclared(scan.snapshot),
2579 )],
2580 tx,
2581 )
2582}
2583
2584fn dispatch_session_discovery_messages(
2587 session_id: tau_proto::SessionId,
2588 messages: Vec<HarnessInputMessage>,
2589 tx: &Output,
2590) -> tau_client::ClientResult<()> {
2591 for message in messages {
2592 tx.send_checked(message)?;
2593 }
2594 tx.send_checked(HarnessInputMessage::emit_transient(
2595 Event::ExtensionSessionContextReady(ExtensionSessionContextReady { session_id }),
2596 ))
2597}
2598
2599fn apply_started_cwd_metadata(
2600 started: tau_proto::AgentStarted,
2601 tx: &Output,
2602 cwd_state: &CwdState,
2603 is_replay: bool,
2604) -> tau_client::ClientResult<()> {
2605 for item in started.metadata {
2606 if item.key == cwd_state.key() {
2607 if let CborValue::Text(path) = item.value {
2608 let cwd = PathBuf::from(path);
2609 if cwd_state.set_metadata_text(started.agent_id.clone(), cwd.clone())
2610 && !is_replay
2611 && let Some((session_id, initialization_id)) =
2612 cwd_state.initialization(&started.agent_id)
2613 {
2614 tx.send_checked(HarnessInputMessage::emit_transient(cwd_context_event(
2615 session_id,
2616 started.agent_id.clone(),
2617 initialization_id,
2618 &cwd,
2619 cwd_state,
2620 )))?;
2621 }
2622 } else {
2623 cwd_state.set_invalid(started.agent_id.clone());
2624 }
2625 }
2626 }
2627 Ok(())
2628}
2629
2630fn dispatch_session_agent_loaded(
2631 loaded: SessionAgentLoaded,
2632 tx: &Output,
2633 cwd_state: &CwdState,
2634 defer_default_until_replay_complete: bool,
2635 discovery_policy: DiscoverySourcePolicy,
2636) -> tau_client::ClientResult<()> {
2637 if defer_default_until_replay_complete {
2638 cwd_state.set_pending_ready(
2639 loaded.agent_id,
2640 loaded.session_id,
2641 loaded.agent_initialization_id,
2642 );
2643 return Ok(());
2644 }
2645 publish_agent_discovery_snapshot(&loaded, tx, discovery_policy)?;
2646 if let Some(cwd) = cwd_state.get(&loaded.agent_id) {
2647 tx.send_checked(HarnessInputMessage::emit_transient(cwd_context_event(
2648 loaded.session_id.clone(),
2649 loaded.agent_id.clone(),
2650 loaded.agent_initialization_id.clone(),
2651 &cwd,
2652 cwd_state,
2653 )))?;
2654 tx.send_checked(HarnessInputMessage::emit_transient(
2655 Event::ExtensionContextReady(ExtensionContextReady {
2656 session_id: loaded.session_id,
2657 agent_id: loaded.agent_id,
2658 agent_initialization_id: loaded.agent_initialization_id,
2659 }),
2660 ))?;
2661 return Ok(());
2662 }
2663
2664 cwd_state.set_pending_ready(
2665 loaded.agent_id.clone(),
2666 loaded.session_id,
2667 loaded.agent_initialization_id,
2668 );
2669 let Ok(cwd) = cwd_state.process_default() else {
2670 return Ok(());
2671 };
2672 tx.send_checked(HarnessInputMessage::emit_transient(
2673 Event::AgentMetadataSetRequest(tau_proto::AgentMetadataSet {
2674 agent_id: loaded.agent_id,
2675 key: cwd_state.key(),
2676 value: CborValue::Text(cwd.display().to_string()),
2677 mutation_id: None,
2678 inheritable: true,
2679 }),
2680 ))
2681}
2682
2683fn cwd_context_event(
2684 session_id: tau_proto::SessionId,
2685 agent_id: tau_proto::AgentId,
2686 agent_initialization_id: tau_proto::AgentInitializationId,
2687 cwd: &Path,
2688 cwd_state: &CwdState,
2689) -> Event {
2690 let status = if cwd.is_dir() {
2691 "available"
2692 } else {
2693 "unavailable"
2694 };
2695 Event::ExtAgentContextPublish(ExtAgentContextPublish {
2696 session_id,
2697 agent_id,
2698 agent_initialization_id,
2699 key: AgentContextKey::new("workdir"),
2700 value: AgentContextValue(serde_json::json!({
2701 "label": cwd_state.context_label(),
2702 "path": cwd.display().to_string(),
2703 "status": status,
2704 })),
2705 })
2706}
2707
2708fn invalid_cwd_context_event(
2709 session_id: tau_proto::SessionId,
2710 agent_id: tau_proto::AgentId,
2711 agent_initialization_id: tau_proto::AgentInitializationId,
2712 cwd_state: &CwdState,
2713) -> Event {
2714 Event::ExtAgentContextPublish(ExtAgentContextPublish {
2715 session_id,
2716 agent_id,
2717 agent_initialization_id,
2718 key: AgentContextKey::new("workdir"),
2719 value: AgentContextValue(serde_json::json!({
2720 "label": cwd_state.context_label(),
2721 "path": "<invalid>",
2722 "status": "invalid",
2723 })),
2724 })
2725}
2726
2727fn cwd_notice_event(agent_id: tau_proto::AgentId, cwd: &Path) -> Event {
2728 Event::AgentUserMessageInjected(tau_proto::AgentUserMessageInjected {
2729 inference_activation: false,
2730 agent_id,
2731 text: format!("Your working directory changed to {}.", cwd.display()),
2732 message_class: tau_proto::PromptMessageClass::Internal,
2733 })
2734}
2735
2736fn is_shell_tool(name: &str) -> bool {
2737 matches!(
2738 name,
2739 READ_TOOL_NAME
2740 | EXPORT_TOOL_NAME
2741 | IMPORT_TOOL_NAME
2742 | EDIT_TOOL_NAME
2743 | REPLACE_TOOL_NAME
2744 | APPLY_PATCH_TOOL_NAME
2745 | GREP_TOOL_NAME
2746 | FIND_TOOL_NAME
2747 | LS_TOOL_NAME
2748 | WORKDIR_TOOL_NAME
2749 | SHELL_TOOL_NAME
2750 | GPT_SHELL_TOOL_NAME
2751 | DIR_LOCK_TOOL_NAME
2752 ) || is_echo_tool(name)
2753}
2754
2755fn is_dir_lock_update_invocation(arguments: &CborValue) -> bool {
2756 crate::argument::optional_argument_text(arguments, "command")
2757 .ok()
2758 .flatten()
2759 .as_deref()
2760 == Some("update")
2761}
2762
2763fn is_dir_lock_update_tool(name: &str) -> bool {
2764 matches!(
2765 name,
2766 EDIT_TOOL_NAME
2767 | REPLACE_TOOL_NAME
2768 | APPLY_PATCH_TOOL_NAME
2769 | SHELL_TOOL_NAME
2770 | GPT_SHELL_TOOL_NAME
2771 )
2772}
2773
2774fn is_shell_command_tool(name: &str) -> bool {
2775 matches!(name, SHELL_TOOL_NAME | GPT_SHELL_TOOL_NAME)
2776}
2777
2778#[cfg(any(test, feature = "echo-agent"))]
2779fn is_echo_tool(name: &str) -> bool {
2780 name == ECHO_TOOL_NAME
2781}
2782
2783#[cfg(not(any(test, feature = "echo-agent")))]
2784fn is_echo_tool(_name: &str) -> bool {
2785 false
2786}
2787
2788fn build_discovery_snapshot(
2789 _started: SessionStarted,
2790 discovery_policy: DiscoverySourcePolicy,
2791) -> DiscoveryScan {
2792 if discovery_policy.reads_environment() {
2793 return discovery_source::DiscoverySource::new(dirs::home_dir())
2794 .scan_project(_started.session_id, std::env::current_dir().ok().as_deref());
2795 }
2796 DiscoveryScan {
2797 snapshot: ExtensionSessionDiscoverySnapshotDeclared {
2798 frontmatter_diagnostics: Vec::new(),
2799 session_id: _started.session_id,
2800 skills: Vec::new(),
2801 agents_files: Vec::new(),
2802 },
2803 diagnostics: Vec::new(),
2804 }
2805}
2806
2807fn discovery_skill_candidate(
2808 skill: tau_skills::Skill,
2809 modified: Option<std::time::SystemTime>,
2810) -> DiscoverySkillCandidate {
2811 let file_path = skill.file_path.canonicalize().unwrap_or(skill.file_path);
2812 let sampled_modified = modified.and_then(system_time_to_discovery_micros);
2813 DiscoverySkillCandidate {
2814 visibility: skill.visibility,
2815 name: skill.name,
2816 description: skill.description,
2817 file_path,
2818 add_to_prompt: skill.add_to_prompt,
2819 user_invocable: skill.user_invocable,
2820 disable_model_invocation: skill.disable_model_invocation,
2821 argument_hint: skill.argument_hint,
2822 sampled_modified,
2823 }
2824}
2825
2826fn system_time_to_discovery_micros(time: std::time::SystemTime) -> Option<DiscoveryModifiedMicros> {
2827 match time.duration_since(std::time::UNIX_EPOCH) {
2828 Ok(duration) => i64::try_from(duration.as_micros())
2829 .ok()
2830 .map(DiscoveryModifiedMicros::new),
2831 Err(error) => i64::try_from(error.duration().as_micros())
2832 .ok()
2833 .and_then(i64::checked_neg)
2834 .map(DiscoveryModifiedMicros::new),
2835 }
2836}
2837
2838fn publish_agent_discovery_snapshot(
2839 loaded: &SessionAgentLoaded,
2840 tx: &Output,
2841 discovery_policy: DiscoverySourcePolicy,
2842) -> tau_client::ClientResult<()> {
2843 publish_agent_discovery_snapshot_for(
2844 loaded.session_id.clone(),
2845 loaded.agent_id.clone(),
2846 loaded.agent_initialization_id.clone(),
2847 tx,
2848 discovery_policy,
2849 )
2850}
2851
2852fn publish_agent_discovery_snapshot_for(
2853 session_id: tau_proto::SessionId,
2854 agent_id: tau_proto::AgentId,
2855 agent_initialization_id: tau_proto::AgentInitializationId,
2856 tx: &Output,
2857 discovery_policy: DiscoverySourcePolicy,
2858) -> tau_client::ClientResult<()> {
2859 let session = SessionStarted {
2860 session_id: session_id.clone(),
2861 reason: tau_proto::SessionStartReason::Resume,
2862 };
2863 publish_agent_discovery_scan(
2864 build_discovery_snapshot(session, discovery_policy),
2865 agent_id,
2866 agent_initialization_id,
2867 tx,
2868 )
2869}
2870
2871fn publish_agent_discovery_scan(
2873 scan: DiscoveryScan,
2874 agent_id: tau_proto::AgentId,
2875 agent_initialization_id: tau_proto::AgentInitializationId,
2876 tx: &Output,
2877) -> tau_client::ClientResult<()> {
2878 tx.send_checked(agent_discovery_message(
2879 scan.snapshot,
2880 agent_id,
2881 agent_initialization_id,
2882 ))
2883}
2884
2885fn agent_discovery_message(
2887 snapshot: ExtensionSessionDiscoverySnapshotDeclared,
2888 agent_id: tau_proto::AgentId,
2889 agent_initialization_id: tau_proto::AgentInitializationId,
2890) -> HarnessInputMessage {
2891 HarnessInputMessage::emit_transient(Event::ExtensionAgentDiscoverySnapshotDeclared(
2892 ExtensionAgentDiscoverySnapshotDeclared {
2893 workdir_binding: None,
2894 refresh_id: None,
2895 discovery_error: None,
2896 frontmatter_diagnostics: snapshot.frontmatter_diagnostics,
2897 session_id: snapshot.session_id,
2898 agent_id,
2899 agent_initialization_id,
2900 skills: snapshot.skills,
2901 agents_files: snapshot.agents_files,
2902 },
2903 ))
2904}
2905
2906fn shell_workdir_prompt_fragment(shell: &config::ShellConfig) -> PromptFragment {
2907 let mut template = String::from(
2908 "{{#if agent_context.workdir}}### Shell workdirs\n\nEach shell extension instance \
2909 has its own persistent workdir; there is no global shell cwd.\n\
2910 {{#each agent_context.workdir}}- {{#if (eq value.label \"default\")}}default shell \
2911 tools (`workdir`){{else}}`{{value.label}}_*` shell tools \
2912 (`{{value.label}}_workdir`){{/if}}: `{{value.path}}` \
2913 [{{value.status}}]\n{{/each}}\nNormally set the matching workdir tool to the project \
2914 root before project work. It sets the cwd/base for later shell and filesystem calls \
2915 in that same instance. The cwd can select configured directory-scoped wrappers, \
2916 notably `direnv exec .`, and affect other cwd-sensitive wrappers/tools. After \
2917 changing it, make dependent calls only in a later tool turn after success; sibling \
2918 calls have no workdir-first ordering.{{/if}}",
2919 );
2920 if let Some(allowlist) = shell.allowlist_prompt_fragment() {
2921 template.push_str(&allowlist);
2922 }
2923 PromptFragment::new(
2924 "shell.workdir",
2925 PromptPriority::new(900),
2926 PromptContent::new(template),
2927 )
2928}
2929
2930fn push_skill_diagnostic_requests(
2931 messages: &mut Vec<HarnessInputMessage>,
2932 diagnostics: Vec<tau_skills::SkillDiagnostic>,
2933) {
2934 for diagnostic in diagnostics {
2935 let (kind, level) = match diagnostic.kind {
2936 tau_skills::DiagnosticKind::Frontmatter => continue,
2938 tau_skills::DiagnosticKind::Warning => ("warning", tau_proto::NoticeLevel::Info),
2939 tau_skills::DiagnosticKind::Collision => ("collision", tau_proto::NoticeLevel::Trace),
2940 tau_skills::DiagnosticKind::Skipped => ("skipped", tau_proto::NoticeLevel::Warning),
2941 };
2942 messages.push(HarnessInputMessage::ExtensionNoticeRequest(
2943 tau_proto::ExtensionNoticeRequest {
2944 message: format!(
2945 "skill {kind}: {}\n{}",
2946 diagnostic.path.display(),
2947 diagnostic.message
2948 ),
2949 level,
2950 },
2951 ));
2952 }
2953}
2954
2955fn session_skill_dirs(
2956 cwd: Option<std::path::PathBuf>,
2957 home: Option<std::path::PathBuf>,
2958) -> Vec<tau_skills::SkillDir> {
2959 let mut skill_dirs = project_skill_dirs(cwd.as_deref(), home.as_deref());
2960 if let Some(home) = home {
2961 skill_dirs.push(user_skill_dir_precedence(
2962 home.join(".config").join("agents").join("skills"),
2963 XDG_USER_SKILL_SOURCE_PRECEDENCE,
2964 ));
2965 skill_dirs.push(user_skill_dir_precedence(
2966 home.join(".config").join("agents.local").join("skills"),
2967 XDG_USER_SKILL_SOURCE_PRECEDENCE,
2968 ));
2969 skill_dirs.push(user_skill_dir_precedence(
2970 home.join(".agents").join("skills"),
2971 LEGACY_USER_SKILL_SOURCE_PRECEDENCE,
2972 ));
2973 skill_dirs.push(user_skill_dir_precedence(
2974 home.join(".agents.local").join("skills"),
2975 LEGACY_USER_SKILL_SOURCE_PRECEDENCE,
2976 ));
2977 }
2978 skill_dirs
2979}
2980
2981fn project_skill_dirs(cwd: Option<&Path>, home: Option<&Path>) -> Vec<tau_skills::SkillDir> {
2982 let mut skill_dirs = Vec::new();
2983 if let Some(cwd) = cwd {
2984 for project_dir in project_skill_ancestor_dirs(cwd, home) {
2985 push_existing_project_skill_dir(
2986 &mut skill_dirs,
2987 project_dir.join(".agents").join("skills"),
2988 );
2989 push_existing_project_skill_dir(
2990 &mut skill_dirs,
2991 project_dir.join(".agents.local").join("skills"),
2992 );
2993 }
2994 }
2995 skill_dirs
2996}
2997
2998fn project_skill_ancestor_dirs(
2999 cwd: &std::path::Path,
3000 home: Option<&std::path::Path>,
3001) -> Vec<std::path::PathBuf> {
3002 ancestor_dirs(cwd)
3003 .into_iter()
3004 .filter(|dir| dir.parent().is_some())
3005 .filter(|dir| {
3006 let Some(home) = home else {
3007 return true;
3008 };
3009 !cwd.starts_with(home) || (dir.starts_with(home) && dir != home)
3010 })
3011 .collect()
3012}
3013
3014fn push_existing_project_skill_dir(
3015 skill_dirs: &mut Vec<tau_skills::SkillDir>,
3016 path: std::path::PathBuf,
3017) {
3018 if path.is_dir() {
3019 skill_dirs.push(project_skill_dir(path));
3020 }
3021}
3022
3023fn project_skill_dir(path: std::path::PathBuf) -> tau_skills::SkillDir {
3024 tau_skills::SkillDir {
3025 path,
3026 add_to_prompt_by_default: true,
3027 source_precedence: None,
3028 }
3029}
3030
3031fn user_skill_dir_precedence(
3032 path: std::path::PathBuf,
3033 source_precedence: u32,
3034) -> tau_skills::SkillDir {
3035 tau_skills::SkillDir {
3036 path,
3037 add_to_prompt_by_default: false,
3038 source_precedence: Some(source_precedence),
3039 }
3040}