1#![deny(missing_docs)]
14
15pub mod builtin_tools;
17pub mod config;
19mod filesystem;
20mod retry;
21pub mod seatbelt;
23pub mod session;
25pub mod sidiff;
27pub mod skill_inject;
29#[cfg(test)]
30pub(crate) mod test_support;
31mod tool_protocol;
32mod tool_runtime;
33mod user_instructions;
34
35use std::collections::{BTreeMap, BTreeSet};
36use std::ops::ControlFlow;
37use std::path::PathBuf;
38use std::sync::Arc;
39use std::sync::Mutex as StdMutex;
40use std::sync::atomic::{AtomicBool, Ordering};
41
42use claudius::chat::{ChatAgent, ChatConfig};
43use claudius::{
44 Agent, AgentStreamContext, Anthropic, BashPtyConfig, BashPtyResult, BashPtySession, Budget,
45 Content, ContentBlock, Error, FileSystem, IntermediateToolResult, Message, MessageParam,
46 MessageParamContent, MessageRole, Metadata, Model, MountHierarchy, OperatorLine, Renderer,
47 StopReason, StreamContext, SystemPrompt, TextBlock, ThinkingConfig, Tool, ToolBash20250124,
48 ToolCallback, ToolChoice, ToolParam, ToolResult, ToolResultBlock, ToolResultBlockContent,
49 ToolTextEditor20250728, ToolUnionParam, ToolUseBlock, TurnOutcome, Usage,
50};
51use handled::SError;
52use rc_conf::SwitchPosition;
53use tokio::sync::Mutex;
54use utf8path::Path;
55
56use crate::config::{
57 AGENTS_CONF_FILE, AgentConfig, Config, SkillConfig, TOOLS_CONF_FILE, ToolConfig,
58 is_valid_anthropic_tool_name, resolve_canonical_tool_id,
59};
60use crate::filesystem::{build_agent_filesystem, build_default_filesystem, resolve_agent_skills};
61use crate::seatbelt::WritableRoots;
62use crate::session::{CompactionExpertConfig, CompactionProvenance, SidSession};
63use crate::tool_runtime::ToolRuntimeContext;
64use crate::user_instructions::{
65 UserInstructionRuntimeContext, UserInstructionSettings, append_agents_md_to_system_prompt,
66 append_user_instruction_block, build_user_instruction_block,
67 disabled_user_instruction_settings, resolve_user_instruction_settings,
68};
69
70const DEFAULT_AGENT_ID: &str = "sid";
71const DEFAULT_COMPACTOR_AGENT_ID: &str = "compact";
72const USER_CANCELLED_ACTION: &str = "user cancelled action";
73const ASK_AN_EXPERT_TOOL_NAME: &str = "ask_an_expert";
74const MAX_ASK_AN_EXPERT_DEPTH: usize = 8;
75const BASH_STATE_CAPTURE_COMMAND: &str = concat!(
76 "builtin printf 'builtin cd -- %q\\n' \"$PWD\"\n",
77 "builtin set +o\n",
78 "builtin shopt -p\n",
79 "builtin export -p\n",
80 "builtin alias -p\n",
81 "builtin declare -pf\n",
82);
83const DEFAULT_COMPACTOR_SYSTEM_PROMPT: &str = concat!(
84 "You are the sid compaction agent.\n",
85 "Turn the conversation history into a compact, high-signal handoff summary for a future session.\n",
86 "Preserve objectives, constraints, decisions, unfinished work, notable files, commands, errors, and next steps.\n",
87 "Prefer concrete facts over narration.\n",
88 "State uncertainty explicitly.\n",
89 "Do not ask follow-up questions.\n",
90);
91pub const COMPACTION_REQUEST_PROMPT: &str = concat!(
93 "Compact this conversation into a standalone handoff summary for the next session.\n",
94 "Write only the summary.\n",
95);
96const MEMORY_EXPERT_PROMPT_ADDENDUM: &str = concat!(
97 "\n\n# Expert follow-up mode\n",
98 "You previously wrote the summary for a compacted sid session.\n",
99 "Answer the latest user question from contextual memory only.\n",
100 "If the answer is not supported by the conversation context you have, reply exactly: I don't know\n",
101 "You may use ask_an_expert only to consult the earlier summary writer when available.\n",
102);
103const COMPACTED_SESSION_CONTEXT_TEMPLATE: &str = concat!(
104 "This session is a compacted continuation of session {session_id}.\n",
105 "The next assistant message is the handoff summary written by the previous owner of the project.\n",
106 "Use that summary as working context. If you need details that are not in it, use ask_an_expert.\n",
107);
108
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110enum SidToolScope {
111 Normal,
112 MemoryOnly,
113}
114
115impl SidToolScope {
116 fn exposes_standard_tools(self) -> bool {
117 matches!(self, Self::Normal)
118 }
119}
120
121pub struct SidAgent {
123 id: String,
124 enabled: SwitchPosition,
125 config: ChatConfig,
126 tools: Vec<Arc<dyn Tool<Self>>>,
127 builtin_bindings: BuiltinToolBindings,
128 skills: Vec<SkillConfig>,
129 config_root: Path<'static>,
130 workspace_root: Path<'static>,
131 user_instructions: UserInstructionSettings,
132 writable_roots: WritableRoots,
133 filesystem: MountHierarchy,
134 session: Option<Arc<SidSession>>,
135 memory_source: Option<CompactionProvenance>,
136 tool_scope: SidToolScope,
137 memory_depth: usize,
138 bash_session: Mutex<Option<BashPtySession>>,
139 tool_cancellation_pending: AtomicBool,
140 token_usage_totals: StdMutex<TokenUsageTotals>,
141}
142
143impl SidAgent {
144 pub fn new(config: ChatConfig, workspace_root: Path<'static>) -> Self {
150 Self::new_with_roots(config, workspace_root.clone(), workspace_root)
151 }
152
153 fn new_with_roots(
154 config: ChatConfig,
155 config_root: Path<'static>,
156 workspace_root: Path<'static>,
157 ) -> Self {
158 Self::new_custom(
159 DEFAULT_AGENT_ID.to_string(),
160 config,
161 config_root,
162 workspace_root,
163 UserInstructionSettings::default(),
164 )
165 }
166
167 fn new_custom(
168 id: String,
169 config: ChatConfig,
170 config_root: Path<'static>,
171 workspace_root: Path<'static>,
172 user_instructions: UserInstructionSettings,
173 ) -> Self {
174 let filesystem = build_default_filesystem(&workspace_root);
175 let writable_roots = default_writable_roots(&workspace_root);
176 Self::with_parts(
177 id,
178 SwitchPosition::Yes,
179 config,
180 vec![],
181 BuiltinToolBindings::default(),
182 Vec::new(),
183 config_root,
184 workspace_root,
185 user_instructions,
186 writable_roots,
187 filesystem,
188 None,
189 None,
190 SidToolScope::Normal,
191 0,
192 )
193 }
194
195 pub fn from_workspace(root: &Path, fallback: ChatConfig) -> Result<Self, SError> {
205 Self::from_workspace_with_config_root(root, root, fallback)
206 }
207
208 pub fn from_workspace_with_config_root(
219 workspace_root: &Path,
220 config_root: &Path,
221 fallback: ChatConfig,
222 ) -> Result<Self, SError> {
223 if !workspace_has_config(config_root) {
224 let mut agent = Self::new_with_roots(
225 fallback,
226 config_root.clone().into_owned(),
227 workspace_root.clone().into_owned(),
228 );
229 agent.append_agents_md_to_system_prompt()?;
230 return Ok(agent);
231 }
232 let config = Config::load(config_root)?;
233 let agent = default_agent_id(&config)?;
234 Self::from_loaded_config(
235 &config,
236 &agent,
237 Some(&fallback),
238 workspace_root.clone().into_owned(),
239 )
240 }
241
242 pub fn from_workspace_agent(
249 root: &Path,
250 agent: &str,
251 fallback: ChatConfig,
252 ) -> Result<Self, SError> {
253 Self::from_workspace_agent_with_config_root(root, root, agent, fallback)
254 }
255
256 pub fn from_workspace_agent_with_config_root(
263 workspace_root: &Path,
264 config_root: &Path,
265 agent: &str,
266 fallback: ChatConfig,
267 ) -> Result<Self, SError> {
268 if !workspace_has_config(config_root) {
269 return if agent == DEFAULT_AGENT_ID {
270 let mut agent = Self::new_with_roots(
271 fallback,
272 config_root.clone().into_owned(),
273 workspace_root.clone().into_owned(),
274 );
275 agent.append_agents_md_to_system_prompt()?;
276 Ok(agent)
277 } else {
278 Err(missing_agent_error(agent))
279 };
280 }
281 let config = Config::load(config_root)?;
282 Self::from_loaded_config(
283 &config,
284 agent,
285 Some(&fallback),
286 workspace_root.clone().into_owned(),
287 )
288 }
289
290 pub fn from_workspace_compactor_with_config_root(
301 workspace_root: &Path,
302 config_root: &Path,
303 fallback: ChatConfig,
304 ) -> Result<Self, SError> {
305 if workspace_has_config(config_root) {
306 let config = Config::load(config_root)?;
307 if config.agents.contains_key(DEFAULT_COMPACTOR_AGENT_ID) {
308 return Self::from_loaded_config_inner(
309 &config,
310 DEFAULT_COMPACTOR_AGENT_ID,
311 Some(&fallback),
312 workspace_root.clone().into_owned(),
313 true,
314 )
315 .map(|agent| agent.with_tool_scope(SidToolScope::MemoryOnly));
316 }
317 }
318
319 let mut config = fallback;
320 config.set_system_prompt(Some(DEFAULT_COMPACTOR_SYSTEM_PROMPT.to_string()));
321 Ok(Self::new_custom(
322 DEFAULT_COMPACTOR_AGENT_ID.to_string(),
323 config,
324 config_root.clone().into_owned(),
325 workspace_root.clone().into_owned(),
326 disabled_user_instruction_settings(),
327 )
328 .with_tool_scope(SidToolScope::MemoryOnly))
329 }
330
331 pub fn from_config(
338 config: &Config,
339 agent: &str,
340 filesystem: Path<'static>,
341 ) -> Result<Self, SError> {
342 Self::from_loaded_config(config, agent, None, filesystem)
343 }
344
345 pub fn id(&self) -> &str {
347 &self.id
348 }
349
350 pub fn requires_confirmation(&self) -> bool {
352 self.enabled == SwitchPosition::Manual
353 }
354
355 fn append_agents_md_to_system_prompt(&mut self) -> Result<(), SError> {
356 append_agents_md_to_system_prompt(
357 &mut self.config,
358 &self.user_instructions,
359 &self.workspace_root,
360 )
361 }
362
363 fn from_loaded_config(
364 config: &Config,
365 agent: &str,
366 fallback: Option<&ChatConfig>,
367 workspace_root: Path<'static>,
368 ) -> Result<Self, SError> {
369 Self::from_loaded_config_inner(config, agent, fallback, workspace_root, false)
370 }
371
372 fn from_loaded_config_inner(
373 config: &Config,
374 agent: &str,
375 fallback: Option<&ChatConfig>,
376 workspace_root: Path<'static>,
377 allow_disabled: bool,
378 ) -> Result<Self, SError> {
379 let agent_config = config
380 .agents
381 .get(agent)
382 .ok_or_else(|| missing_agent_error(agent))?;
383 if !allow_disabled && !agent_config.enabled.can_be_started() {
384 return Err(disabled_agent_error(agent, agent_config.enabled));
385 }
386
387 let built_tools = build_tools(config, agent_config)?;
388 let mut chat_config = merged_chat_config(agent_config, fallback);
389 let skills = resolve_agent_skills(config, agent_config)?;
390 let agent_skills = skills.iter().map(|skill| (*skill).clone()).collect();
391 let filesystem = build_agent_filesystem(&workspace_root, config, agent_config)?;
392 let user_instructions = resolve_user_instruction_settings(config, agent_config)?;
393 let writable_roots = default_writable_roots(&workspace_root);
394 append_system_description(&mut chat_config, &workspace_root);
395 if !skills.is_empty() {
396 append_skill_index_to_system_prompt(&mut chat_config, &skills);
397 }
398 append_agents_md_to_system_prompt(&mut chat_config, &user_instructions, &workspace_root)?;
399 Ok(Self::with_parts(
400 agent.to_string(),
401 agent_config.enabled,
402 chat_config,
403 built_tools.tools,
404 built_tools.builtin_bindings,
405 agent_skills,
406 config.root.clone(),
407 workspace_root,
408 user_instructions,
409 writable_roots,
410 filesystem,
411 None,
412 None,
413 SidToolScope::Normal,
414 0,
415 ))
416 }
417
418 #[allow(clippy::too_many_arguments)]
419 fn with_parts(
420 id: String,
421 enabled: SwitchPosition,
422 config: ChatConfig,
423 tools: Vec<Arc<dyn Tool<Self>>>,
424 builtin_bindings: BuiltinToolBindings,
425 skills: Vec<SkillConfig>,
426 config_root: Path<'static>,
427 workspace_root: Path<'static>,
428 user_instructions: UserInstructionSettings,
429 writable_roots: WritableRoots,
430 filesystem: MountHierarchy,
431 session: Option<Arc<SidSession>>,
432 memory_source: Option<CompactionProvenance>,
433 tool_scope: SidToolScope,
434 memory_depth: usize,
435 ) -> Self {
436 Self {
437 id,
438 enabled,
439 config,
440 tools,
441 builtin_bindings,
442 skills,
443 config_root,
444 workspace_root,
445 user_instructions,
446 writable_roots,
447 filesystem,
448 session,
449 memory_source,
450 tool_scope,
451 memory_depth,
452 bash_session: Mutex::new(None),
453 tool_cancellation_pending: AtomicBool::new(false),
454 token_usage_totals: StdMutex::new(TokenUsageTotals::default()),
455 }
456 }
457
458 pub fn with_session(mut self, session: Arc<SidSession>) -> Self {
460 append_writable_root(&mut self.writable_roots, session.root());
461 if self.memory_source.is_none() {
462 self.memory_source = session.compaction_provenance().cloned();
463 }
464 self.session = Some(session);
465 self
466 }
467
468 pub fn with_memory_source(mut self, memory_source: Option<CompactionProvenance>) -> Self {
470 self.memory_source = memory_source;
471 self
472 }
473
474 fn with_tool_scope(mut self, tool_scope: SidToolScope) -> Self {
475 self.tool_scope = tool_scope;
476 if !tool_scope.exposes_standard_tools() {
477 self.tools.clear();
478 self.builtin_bindings = BuiltinToolBindings::default();
479 }
480 self
481 }
482
483 fn with_memory_depth(mut self, memory_depth: usize) -> Self {
484 self.memory_depth = memory_depth;
485 self
486 }
487
488 pub fn compaction_snapshot(&self) -> CompactionExpertConfig {
490 CompactionExpertConfig {
491 agent_id: Some(self.id.clone()),
492 model: self.config.model().to_string(),
493 system_prompt: self.config.system_prompt_text().map(str::to_string),
494 }
495 }
496
497 async fn run_bash_command(
498 &self,
499 command: &str,
500 restart: bool,
501 ) -> Result<String, std::io::Error> {
502 self.run_bash_command_with_renderer(command, restart, None)
503 .await
504 }
505
506 pub async fn bash_with_renderer(
516 &self,
517 command: &str,
518 restart: bool,
519 renderer: &mut dyn Renderer,
520 ) -> Result<String, std::io::Error> {
521 self.run_bash_command_with_renderer(command, restart, Some(renderer))
522 .await
523 }
524
525 async fn run_bash_command_with_renderer(
526 &self,
527 command: &str,
528 restart: bool,
529 renderer: Option<&mut dyn Renderer>,
530 ) -> Result<String, std::io::Error> {
531 let Some(binding) = self.builtin_bindings.bash.as_ref() else {
532 return Err(std::io::Error::new(
533 std::io::ErrorKind::Unsupported,
534 "bash is not supported",
535 ));
536 };
537
538 match binding.enabled {
539 SwitchPosition::Yes => {}
540 SwitchPosition::No => {
541 return Err(std::io::Error::new(
542 std::io::ErrorKind::PermissionDenied,
543 "bash is disabled",
544 ));
545 }
546 SwitchPosition::Manual => {
547 let input_value = serde_json::json!({
548 "command": command,
549 "restart": restart,
550 "cwd": self.workspace_root.as_str(),
551 "writable_roots": self.writable_roots.as_slice(),
552 });
553 match confirm_manual_tool_call("bash", &input_value, None, renderer) {
554 Ok(ManualToolConfirmation::Allow) => {}
555 Ok(ManualToolConfirmation::Deny) => {
556 return Err(std::io::Error::new(
557 std::io::ErrorKind::PermissionDenied,
558 "bash call denied by operator",
559 ));
560 }
561 Ok(ManualToolConfirmation::Cancel) => {
562 return Err(std::io::Error::new(
563 std::io::ErrorKind::PermissionDenied,
564 USER_CANCELLED_ACTION,
565 ));
566 }
567 Err(err) => {
568 return Err(std::io::Error::other(err));
569 }
570 }
571 }
572 }
573
574 if restart {
575 self.clear_persisted_bash_state()?;
576 }
577
578 let mut session = self.bash_session.lock().await;
579 if session.is_none() {
580 *session = Some(self.spawn_bash_session(!restart).await?);
581 }
582
583 let result = {
584 let session = session
585 .as_mut()
586 .expect("bash PTY session should be initialized");
587 session.run(command, restart).await
588 };
589 match result {
590 Ok(result) => {
591 let bash_session = session
592 .as_mut()
593 .expect("bash PTY session should still be initialized");
594 self.persist_bash_state(bash_session).await?;
595 render_bash_pty_result(result)
596 }
597 Err(err) => {
598 let _ = self.clear_persisted_bash_state();
599 *session = None;
600 Err(err)
601 }
602 }
603 }
604
605 async fn spawn_bash_session(
606 &self,
607 restore_state: bool,
608 ) -> Result<BashPtySession, std::io::Error> {
609 let mut session = BashPtySession::new(self.bash_pty_config()).await?;
610 if restore_state {
611 self.restore_bash_state(&mut session).await?;
612 }
613 Ok(session)
614 }
615
616 fn bash_pty_config(&self) -> BashPtyConfig {
617 let mut env: BTreeMap<String, String> = std::env::vars().collect();
618 env.insert(
619 "SID_WORKSPACE_ROOT".to_string(),
620 self.workspace_root.as_str().to_string(),
621 );
622 if let Some(session) = self.session.as_ref() {
623 env.insert(
624 session::SID_SESSION_ID_ENV.to_string(),
625 session.id().to_string(),
626 );
627 env.insert(
628 session::SID_SESSION_DIR_ENV.to_string(),
629 session.root().to_string_lossy().into_owned(),
630 );
631 env.insert(
632 session::SID_SESSIONS_ENV.to_string(),
633 session.sessions_root().to_string_lossy().into_owned(),
634 );
635 env.insert(
636 "TMPDIR".to_string(),
637 session.bash_tmp_dir().to_string_lossy().into_owned(),
638 );
639 }
640 env.insert("PAGER".to_string(), "cat".to_string());
641 env.insert("HISTFILE".to_string(), "/dev/null".to_string());
642 env.insert("INPUTRC".to_string(), "/dev/null".to_string());
643 BashPtyConfig {
644 cwd: PathBuf::from(self.workspace_root.as_str()),
645 env,
646 shell_wrapper: seatbelt::shell_wrapper(&self.writable_roots),
647 ..BashPtyConfig::default()
648 }
649 }
650
651 fn clear_persisted_bash_state(&self) -> Result<(), std::io::Error> {
652 if let Some(session) = self.session.as_ref() {
653 session
654 .clear_bash_state()
655 .map_err(|err| std::io::Error::other(err.to_string()))?;
656 }
657 Ok(())
658 }
659
660 async fn restore_bash_state(
661 &self,
662 bash_session: &mut BashPtySession,
663 ) -> Result<(), std::io::Error> {
664 let Some(sid_session) = self.session.as_ref() else {
665 return Ok(());
666 };
667 let Some(_) = sid_session
668 .read_bash_state()
669 .map_err(|err| std::io::Error::other(err.to_string()))?
670 else {
671 return Ok(());
672 };
673
674 let restore_command = format!(
675 "builtin source {}",
676 shvar::quote_string(sid_session.bash_state_path().to_string_lossy().as_ref())
677 );
678 let result = bash_session.run(&restore_command, false).await?;
679 if result.status.success() {
680 Ok(())
681 } else {
682 Err(bash_state_io_error("failed to restore bash state", &result))
683 }
684 }
685
686 async fn persist_bash_state(
687 &self,
688 bash_session: &mut BashPtySession,
689 ) -> Result<(), std::io::Error> {
690 let Some(sid_session) = self.session.as_ref() else {
691 return Ok(());
692 };
693 if !bash_session.is_alive()? {
694 sid_session
695 .clear_bash_state()
696 .map_err(|err| std::io::Error::other(err.to_string()))?;
697 return Ok(());
698 }
699
700 let snapshot = bash_session.run(BASH_STATE_CAPTURE_COMMAND, false).await?;
701 if !snapshot.status.success() {
702 return Err(bash_state_io_error(
703 "failed to capture bash state",
704 &snapshot,
705 ));
706 }
707 sid_session
708 .write_bash_state(&snapshot.output)
709 .map_err(|err| std::io::Error::other(err.to_string()))
710 }
711
712 fn memory_source(&self) -> Option<&CompactionProvenance> {
713 self.memory_source.as_ref()
714 }
715
716 async fn ask_an_expert(
717 &self,
718 client: &Anthropic,
719 question: &str,
720 ) -> Result<String, std::io::Error> {
721 self.ask_an_expert_with_renderer(client, question, None)
722 .await
723 }
724
725 async fn ask_an_expert_with_renderer(
726 &self,
727 client: &Anthropic,
728 question: &str,
729 renderer: Option<&mut dyn Renderer>,
730 ) -> Result<String, std::io::Error> {
731 let Some(source) = self.memory_source() else {
732 return Err(std::io::Error::new(
733 std::io::ErrorKind::Unsupported,
734 "ask_an_expert is not available in this session",
735 ));
736 };
737 if self.memory_depth >= MAX_ASK_AN_EXPERT_DEPTH {
738 return Ok("I don't know".to_string());
739 }
740
741 let parent_root = std::path::Path::new(&source.session_dir);
742 let parent_messages = load_transcript_messages(
743 session::transcript_path_for_session_dir(parent_root).as_path(),
744 )
745 .map_err(|err| std::io::Error::other(err.to_string()))?;
746 let parent_memory_source = session::read_compaction_provenance_from_dir(parent_root)
747 .map_err(|err| std::io::Error::other(err.to_string()))?;
748
749 let expert = Self::memory_expert_from_snapshot(
750 &source.expert,
751 &self.config_root,
752 &self.workspace_root,
753 parent_memory_source,
754 self.memory_depth + 1,
755 );
756 let mut chat = claudius::chat::ChatSession::with_agent(client.clone(), expert);
757 chat.replace_messages(parent_messages);
758
759 match renderer {
760 Some(renderer) => chat
761 .send_message(MessageParam::user(question), renderer)
762 .await
763 .map_err(|err| std::io::Error::other(err.to_string()))?,
764 None => {
765 let mut renderer = NullRenderer;
766 chat.send_message(MessageParam::user(question), &mut renderer)
767 .await
768 .map_err(|err| std::io::Error::other(err.to_string()))?;
769 }
770 }
771
772 extract_last_assistant_text(&chat.clone_messages()).ok_or_else(|| {
773 std::io::Error::other("expert conversation produced no assistant response")
774 })
775 }
776
777 fn memory_expert_from_snapshot(
778 snapshot: &CompactionExpertConfig,
779 config_root: &Path,
780 workspace_root: &Path,
781 memory_source: Option<CompactionProvenance>,
782 memory_depth: usize,
783 ) -> Self {
784 let mut config = ChatConfig::new();
785 let model = snapshot
786 .model
787 .parse()
788 .unwrap_or_else(|_| Model::Custom(snapshot.model.clone()));
789 config.set_model(model);
790 config.set_system_prompt(Some(format!(
791 "{}{}",
792 snapshot.system_prompt.as_deref().unwrap_or_default(),
793 MEMORY_EXPERT_PROMPT_ADDENDUM
794 )));
795
796 Self::new_custom(
797 snapshot
798 .agent_id
799 .clone()
800 .unwrap_or_else(|| DEFAULT_COMPACTOR_AGENT_ID.to_string()),
801 config,
802 config_root.clone().into_owned(),
803 workspace_root.clone().into_owned(),
804 disabled_user_instruction_settings(),
805 )
806 .with_tool_scope(SidToolScope::MemoryOnly)
807 .with_memory_source(memory_source)
808 .with_memory_depth(memory_depth)
809 }
810
811 fn tool_runtime_context(&self) -> ToolRuntimeContext<'_> {
812 ToolRuntimeContext {
813 agent_id: &self.id,
814 config_root: &self.config_root,
815 workspace_root: &self.workspace_root,
816 writable_roots: &self.writable_roots,
817 session: self.session.as_deref(),
818 }
819 }
820
821 async fn inject_user_instructions_for_turn(
822 &self,
823 messages: &mut [MessageParam],
824 ) -> Result<(), Error> {
825 let latest_user_message = latest_user_message_text(messages);
826 let context = UserInstructionRuntimeContext {
827 agent_id: &self.id,
828 config_root: &self.config_root,
829 workspace_root: &self.workspace_root,
830 session: self.session.as_deref(),
831 latest_user_message: latest_user_message.as_deref(),
832 skills: &self.skills,
833 };
834 let Some(instructions) = build_user_instruction_block(&self.user_instructions, &context)
835 .await
836 .map_err(|err| Error::unknown(format!("failed to build user instructions: {err}")))?
837 else {
838 return Ok(());
839 };
840 append_user_instruction_block(messages, instructions);
841 Ok(())
842 }
843
844 fn prepare_rc_tool(
845 &self,
846 display_name: &str,
847 binding: &RcToolBinding,
848 tool_use_id: &str,
849 input: serde_json::Map<String, serde_json::Value>,
850 ) -> Result<tool_runtime::PreparedRcToolInvocation, String> {
851 let context = self.tool_runtime_context();
852 tool_runtime::prepare_rc_tool_invocation(
853 display_name,
854 &binding.service_name,
855 &binding.canonical_id,
856 &binding.executable_path,
857 &context,
858 tool_use_id,
859 input,
860 )
861 }
862
863 async fn invoke_rc_tool(
864 &self,
865 binding: &RcToolBinding,
866 tool_use_id: &str,
867 input: serde_json::Map<String, serde_json::Value>,
868 ) -> Result<String, std::io::Error> {
869 let context = self.tool_runtime_context();
870 tool_runtime::invoke_rc_tool_text(
871 &binding.service_name,
872 &binding.service_name,
873 &binding.canonical_id,
874 &binding.executable_path,
875 &context,
876 tool_use_id,
877 input,
878 )
879 .await
880 .map_err(std::io::Error::other)
881 }
882
883 async fn run_text_editor_tool(
884 &self,
885 tool_use: ToolUseBlock,
886 renderer: Option<&mut dyn Renderer>,
887 ) -> Result<String, std::io::Error> {
888 #[derive(serde::Deserialize)]
889 struct Command {
890 command: String,
891 }
892 let cmd: Command = serde_json::from_value(tool_use.input.clone())?;
893 let Some(binding) = self.builtin_bindings.edit.as_ref() else {
894 return self
895 .default_text_editor_command(cmd.command.as_str(), tool_use)
896 .await;
897 };
898 match binding.enabled {
899 SwitchPosition::Yes => {
900 let input = tool_use.input.as_object().cloned().ok_or_else(|| {
901 std::io::Error::new(
902 std::io::ErrorKind::InvalidInput,
903 "text editor input must be a JSON object",
904 )
905 })?;
906 return self.invoke_rc_tool(binding, &tool_use.id, input).await;
907 }
908 SwitchPosition::No => {
909 return Err(std::io::Error::new(
910 std::io::ErrorKind::PermissionDenied,
911 "edit is disabled",
912 ));
913 }
914 SwitchPosition::Manual => {}
915 }
916 let input = tool_use.input.as_object().cloned().ok_or_else(|| {
917 std::io::Error::new(
918 std::io::ErrorKind::InvalidInput,
919 "text editor input must be a JSON object",
920 )
921 })?;
922 let prepared = self
923 .prepare_rc_tool("edit", binding, &tool_use.id, input)
924 .map_err(std::io::Error::other)?;
925 match confirm_manual_prepared_tool_call(
926 "edit",
927 &tool_use.input,
928 binding.confirm_preview,
929 &prepared,
930 self.session.as_deref(),
931 renderer,
932 )
933 .await
934 {
935 Ok(ManualToolConfirmation::Allow) => {}
936 Ok(ManualToolConfirmation::Deny) => {
937 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
938 return Err(std::io::Error::new(
939 std::io::ErrorKind::PermissionDenied,
940 "edit call denied by operator",
941 ));
942 }
943 Ok(ManualToolConfirmation::Cancel) => {
944 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
945 return Err(std::io::Error::new(
946 std::io::ErrorKind::PermissionDenied,
947 USER_CANCELLED_ACTION,
948 ));
949 }
950 Err(err) => {
951 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, true);
952 return Err(std::io::Error::other(err));
953 }
954 }
955 tool_runtime::run_prepared_rc_tool_text(
956 &prepared,
957 &self.writable_roots,
958 self.session.as_deref(),
959 )
960 .await
961 .map_err(std::io::Error::other)
962 }
963
964 async fn default_text_editor_command(
965 &self,
966 command: &str,
967 tool_use: ToolUseBlock,
968 ) -> Result<String, std::io::Error> {
969 match command {
970 "view" => {
971 #[derive(serde::Deserialize)]
972 struct ViewTool {
973 path: String,
974 view_range: Option<(u32, u32)>,
975 }
976 let args: ViewTool = serde_json::from_value(tool_use.input)?;
977 self.view(&args.path, args.view_range).await
978 }
979 "str_replace" => {
980 #[derive(serde::Deserialize)]
981 struct StrReplaceTool {
982 path: String,
983 old_str: String,
984 new_str: Option<String>,
985 }
986 let args: StrReplaceTool = serde_json::from_value(tool_use.input)?;
987 let new_str = args.new_str.as_deref().unwrap_or("");
988 self.str_replace(&args.path, &args.old_str, new_str).await
989 }
990 "insert" => {
991 #[derive(serde::Deserialize)]
992 struct InsertTool {
993 path: String,
994 insert_line: u32,
995 insert_text: Option<String>,
996 new_str: Option<String>,
997 }
998 let args: InsertTool = serde_json::from_value(tool_use.input)?;
999 let text = args.insert_text.or(args.new_str).ok_or_else(|| {
1000 std::io::Error::new(
1001 std::io::ErrorKind::InvalidInput,
1002 "missing insert_text field",
1003 )
1004 })?;
1005 self.insert(&args.path, args.insert_line, &text).await
1006 }
1007 "create" => {
1008 #[derive(serde::Deserialize)]
1009 struct CreateTool {
1010 path: String,
1011 file_text: String,
1012 }
1013 let args: CreateTool = serde_json::from_value(tool_use.input)?;
1014 self.create(&args.path, &args.file_text).await
1015 }
1016 _ => Err(std::io::Error::new(
1017 std::io::ErrorKind::Unsupported,
1018 format!("{} is not a supported tool", tool_use.name),
1019 )),
1020 }
1021 }
1022}
1023
1024#[async_trait::async_trait]
1025impl Agent for SidAgent {
1026 fn stream_label(&self) -> String {
1027 self.id.clone()
1028 }
1029
1030 async fn take_turn(
1031 &mut self,
1032 client: &Anthropic,
1033 messages: &mut Vec<MessageParam>,
1034 budget: &Arc<Budget>,
1035 ) -> Result<TurnOutcome, Error> {
1036 self.tool_cancellation_pending
1037 .store(false, Ordering::Relaxed);
1038 top_up_cancelled_tool_results(messages);
1039 self.inject_user_instructions_for_turn(messages).await?;
1040 let Some(mut tokens_rem) = budget.allocate(self.max_tokens().await) else {
1041 let stop_reason = self.handle_max_tokens().await?;
1042 top_up_cancelled_tool_results(messages);
1043 return Ok(TurnOutcome {
1044 stop_reason,
1045 usage: Usage::new(0, 0),
1046 request_count: 0,
1047 });
1048 };
1049
1050 let mut usage_total = Usage::new(0, 0);
1051 let mut request_count: u64 = 0;
1052 while tokens_rem.remaining_tokens()
1053 > self
1054 .thinking()
1055 .await
1056 .map(|thinking| thinking.num_tokens())
1057 .unwrap_or(0)
1058 {
1059 let retry_policy = retry::ApiRetryPolicy::default();
1060 let retry_backoff = retry_policy.backoff();
1061 let mut retry_count = 0usize;
1062 let step = loop {
1063 let step = self
1064 .step_default_turn(client, messages, &mut tokens_rem)
1065 .await;
1066 let Some(delay) = (match &step {
1067 ControlFlow::Break(Err(err)) => {
1068 retry_policy.retry_delay(&retry_backoff, retry_count, err)
1069 }
1070 _ => None,
1071 }) else {
1072 break step;
1073 };
1074 retry_count += 1;
1075 tokio::time::sleep(delay).await;
1076 };
1077 match step {
1078 ControlFlow::Continue(step) => {
1079 usage_total = usage_total + step.usage;
1080 request_count = request_count.saturating_add(step.request_count);
1081 if self
1082 .tool_cancellation_pending
1083 .swap(false, Ordering::Relaxed)
1084 {
1085 top_up_cancelled_tool_results(messages);
1086 return Ok(TurnOutcome {
1087 stop_reason: StopReason::EndTurn,
1088 usage: usage_total,
1089 request_count,
1090 });
1091 }
1092 }
1093 ControlFlow::Break(res) => {
1094 return match res {
1095 Ok(mut outcome) => {
1096 outcome.usage = outcome.usage + usage_total;
1097 outcome.request_count =
1098 outcome.request_count.saturating_add(request_count);
1099 top_up_cancelled_tool_results(messages);
1100 Ok(outcome)
1101 }
1102 Err(err) => {
1103 top_up_cancelled_tool_results(messages);
1104 Err(err)
1105 }
1106 };
1107 }
1108 }
1109 }
1110
1111 let stop_reason = self.handle_max_tokens().await?;
1112 top_up_cancelled_tool_results(messages);
1113 Ok(TurnOutcome {
1114 stop_reason,
1115 usage: usage_total,
1116 request_count,
1117 })
1118 }
1119
1120 async fn take_turn_streaming(
1121 &mut self,
1122 client: &Anthropic,
1123 messages: &mut Vec<MessageParam>,
1124 budget: &Arc<Budget>,
1125 renderer: &mut dyn Renderer,
1126 context: AgentStreamContext,
1127 ) -> Result<TurnOutcome, Error> {
1128 self.tool_cancellation_pending
1129 .store(false, Ordering::Relaxed);
1130 top_up_cancelled_tool_results(messages);
1131 self.inject_user_instructions_for_turn(messages).await?;
1132 renderer.start_agent(&context);
1133 let Some(mut tokens_rem) = budget.allocate(self.max_tokens().await) else {
1134 let stop_reason = self.handle_max_tokens().await?;
1135 top_up_cancelled_tool_results(messages);
1136 renderer.finish_agent(&context, Some(&stop_reason));
1137 return Ok(TurnOutcome {
1138 stop_reason,
1139 usage: Usage::new(0, 0),
1140 request_count: 0,
1141 });
1142 };
1143
1144 let mut usage_total = Usage::new(0, 0);
1145 let mut request_count: u64 = 0;
1146 while tokens_rem.remaining_tokens()
1147 > self
1148 .thinking()
1149 .await
1150 .map(|thinking| thinking.num_tokens())
1151 .unwrap_or(0)
1152 {
1153 let retry_policy = retry::ApiRetryPolicy::default();
1154 let retry_backoff = retry_policy.backoff();
1155 let mut retry_count = 0usize;
1156 let step = loop {
1157 let step = self
1158 .step_default_turn_streaming(
1159 client,
1160 messages,
1161 &mut tokens_rem,
1162 renderer,
1163 &context,
1164 )
1165 .await;
1166 let Some(delay) = (match &step {
1167 ControlFlow::Break(Err(err)) => {
1168 retry_policy.retry_delay(&retry_backoff, retry_count, err)
1169 }
1170 _ => None,
1171 }) else {
1172 break step;
1173 };
1174 retry_count += 1;
1175 renderer.print_info(
1176 &context,
1177 &format!(
1178 "Transient API failure; retrying in {} (retry {}/{})",
1179 retry::format_delay(delay),
1180 retry_count,
1181 retry_policy.max_retries(),
1182 ),
1183 );
1184 tokio::time::sleep(delay).await;
1185 };
1186 match step {
1187 ControlFlow::Continue(step) => {
1188 usage_total = usage_total + step.usage;
1189 request_count = request_count.saturating_add(step.request_count);
1190 if self
1191 .tool_cancellation_pending
1192 .swap(false, Ordering::Relaxed)
1193 {
1194 let stop_reason = StopReason::EndTurn;
1195 top_up_cancelled_tool_results(messages);
1196 renderer.finish_agent(&context, Some(&stop_reason));
1197 return Ok(TurnOutcome {
1198 stop_reason,
1199 usage: usage_total,
1200 request_count,
1201 });
1202 }
1203 }
1204 ControlFlow::Break(res) => match res {
1205 Ok(mut outcome) => {
1206 outcome.usage = outcome.usage + usage_total;
1207 outcome.request_count = outcome.request_count.saturating_add(request_count);
1208 top_up_cancelled_tool_results(messages);
1209 renderer.finish_agent(&context, Some(&outcome.stop_reason));
1210 return Ok(outcome);
1211 }
1212 Err(err) => {
1213 top_up_cancelled_tool_results(messages);
1214 renderer.finish_agent(&context, None);
1215 return Err(err);
1216 }
1217 },
1218 }
1219 }
1220
1221 let stop_reason = self.handle_max_tokens().await?;
1222 top_up_cancelled_tool_results(messages);
1223 renderer.finish_agent(&context, Some(&stop_reason));
1224 Ok(TurnOutcome {
1225 stop_reason,
1226 usage: usage_total,
1227 request_count,
1228 })
1229 }
1230
1231 async fn max_tokens(&self) -> u32 {
1232 self.config.max_tokens()
1233 }
1234
1235 async fn model(&self) -> Model {
1236 self.config.model()
1237 }
1238
1239 async fn metadata(&self) -> Option<Metadata> {
1240 self.config.template.metadata.clone()
1241 }
1242
1243 async fn stop_sequences(&self) -> Option<Vec<String>> {
1244 let sequences = self.config.stop_sequences();
1245 if sequences.is_empty() {
1246 None
1247 } else {
1248 Some(sequences.to_vec())
1249 }
1250 }
1251
1252 async fn system(&self) -> Option<SystemPrompt> {
1253 let prompt = self.config.template.system.as_ref()?;
1254 Some(prompt.clone())
1255 }
1256
1257 fn caching_enabled(&self) -> bool {
1258 self.config.caching_enabled
1259 }
1260
1261 async fn temperature(&self) -> Option<f32> {
1262 self.config.template.temperature
1263 }
1264
1265 async fn thinking(&self) -> Option<ThinkingConfig> {
1266 self.config.template.thinking
1267 }
1268
1269 async fn tool_choice(&self) -> Option<ToolChoice> {
1270 self.config.template.tool_choice.clone()
1271 }
1272
1273 async fn tools(&self) -> Vec<Arc<dyn Tool<Self>>> {
1274 let mut tools = if self.tool_scope.exposes_standard_tools() {
1275 self.tools.clone()
1276 } else {
1277 Vec::new()
1278 };
1279 if self.memory_source().is_some() {
1280 tools.push(Arc::new(AskAnExpertTool) as Arc<dyn Tool<Self>>);
1281 }
1282 tools
1283 }
1284
1285 async fn top_k(&self) -> Option<u32> {
1286 self.config.template.top_k
1287 }
1288
1289 async fn top_p(&self) -> Option<f32> {
1290 self.config.template.top_p
1291 }
1292
1293 async fn filesystem(&self) -> Option<&dyn FileSystem> {
1294 Some(&self.filesystem)
1295 }
1296
1297 async fn hook_message(&self, resp: &Message) -> Result<(), Error> {
1298 if let Some(session) = self.session.as_ref() {
1299 session
1300 .log_api_response(resp)
1301 .map_err(|err| Error::unknown(format!("failed to log API response: {err}")))?;
1302 }
1303 let totals = self.record_token_usage(resp.usage);
1304 println!(
1305 "[tokens: input={} cached_input={} output={}]",
1306 totals.input, totals.cached_input, totals.output
1307 );
1308 println!("[usage: {:?}]", resp.usage);
1309 Ok(())
1310 }
1311
1312 async fn hook_message_create_params(
1313 &self,
1314 req: &claudius::MessageCreateParams,
1315 ) -> Result<(), Error> {
1316 if let Some(session) = self.session.as_ref() {
1317 session
1318 .log_api_request(req)
1319 .map_err(|err| Error::unknown(format!("failed to log API request: {err}")))?;
1320 }
1321 Ok(())
1322 }
1323
1324 async fn text_editor(&self, tool_use: ToolUseBlock) -> Result<String, std::io::Error> {
1325 self.run_text_editor_tool(tool_use, None).await
1326 }
1327
1328 async fn bash(&self, command: &str, restart: bool) -> Result<String, std::io::Error> {
1329 self.run_bash_command(command, restart).await
1330 }
1331
1332 async fn handle_tool_use(
1333 &mut self,
1334 client: &Anthropic,
1335 resp: &Message,
1336 ) -> ControlFlow<Result<StopReason, Error>, Vec<ContentBlock>> {
1337 let requested_tools = collect_requested_tool_calls(self, resp).await;
1338 let mut tool_results = Vec::new();
1339 for (index, requested) in requested_tools.iter().enumerate() {
1340 let result = match requested.tool.as_ref() {
1341 Some(tool) => {
1342 let callback = tool.callback();
1343 let tool_use = requested.tool_use.clone();
1344 let this = &*self;
1345 let intermediate = callback.compute_tool_result(client, this, &tool_use).await;
1346 match callback
1347 .apply_tool_result(client, self, &tool_use, intermediate)
1348 .await
1349 {
1350 ControlFlow::Continue(result) => result,
1351 ControlFlow::Break(err) => return ControlFlow::Break(Err(err)),
1352 }
1353 }
1354 None => missing_tool_result(&requested.tool_use),
1355 };
1356 let cancelled = tool_result_is_user_cancelled(&result);
1357 push_tool_result(&mut tool_results, None, result);
1358 if cancelled {
1359 self.tool_cancellation_pending
1360 .store(true, Ordering::Relaxed);
1361 cancel_remaining_tool_calls(
1362 &mut tool_results,
1363 requested_tools.iter().skip(index + 1),
1364 );
1365 break;
1366 }
1367 }
1368 ControlFlow::Continue(tool_results)
1369 }
1370
1371 async fn handle_tool_use_streaming(
1372 &mut self,
1373 client: &Anthropic,
1374 resp: &Message,
1375 renderer: &mut dyn Renderer,
1376 context: &AgentStreamContext,
1377 ) -> ControlFlow<Result<StopReason, Error>, Vec<ContentBlock>> {
1378 let requested_tools = collect_requested_tool_calls(self, resp).await;
1379 let mut tool_results = Vec::new();
1380 for (index, requested) in requested_tools.iter().enumerate() {
1381 let tool_context = context.child(format!("tool:{}", requested.tool_use.name));
1382 let result = match requested.tool.as_ref() {
1383 Some(tool) => {
1384 let callback = tool.callback();
1385 let this = &*self;
1386 let intermediate = callback
1387 .compute_tool_result_streaming(
1388 client,
1389 this,
1390 &requested.tool_use,
1391 renderer,
1392 &tool_context,
1393 )
1394 .await;
1395 let render_result = should_render_tool_result(intermediate.as_ref());
1396 match callback
1397 .apply_tool_result(client, self, &requested.tool_use, intermediate)
1398 .await
1399 {
1400 ControlFlow::Continue(result) => {
1401 let cancelled = tool_result_is_user_cancelled(&result);
1402 if render_result {
1403 push_tool_result(
1404 &mut tool_results,
1405 Some((renderer, &tool_context as &dyn StreamContext)),
1406 result,
1407 );
1408 } else {
1409 push_tool_result(&mut tool_results, None, result);
1410 }
1411 if cancelled {
1412 self.tool_cancellation_pending
1413 .store(true, Ordering::Relaxed);
1414 cancel_remaining_tool_calls_streaming(
1415 &mut tool_results,
1416 renderer,
1417 context,
1418 requested_tools.iter().skip(index + 1),
1419 );
1420 break;
1421 }
1422 continue;
1423 }
1424 ControlFlow::Break(err) => return ControlFlow::Break(Err(err)),
1425 }
1426 }
1427 None => missing_tool_result(&requested.tool_use),
1428 };
1429 let cancelled = tool_result_is_user_cancelled(&result);
1430 push_tool_result(&mut tool_results, Some((renderer, &tool_context)), result);
1431 if cancelled {
1432 self.tool_cancellation_pending
1433 .store(true, Ordering::Relaxed);
1434 cancel_remaining_tool_calls_streaming(
1435 &mut tool_results,
1436 renderer,
1437 context,
1438 requested_tools.iter().skip(index + 1),
1439 );
1440 break;
1441 }
1442 }
1443 ControlFlow::Continue(tool_results)
1444 }
1445}
1446
1447impl ChatAgent for SidAgent {
1448 fn config(&self) -> &ChatConfig {
1449 &self.config
1450 }
1451
1452 fn config_mut(&mut self) -> &mut ChatConfig {
1453 &mut self.config
1454 }
1455}
1456
1457impl SidAgent {
1458 fn record_token_usage(&self, usage: Usage) -> TokenUsageTotals {
1459 let mut totals = self
1460 .token_usage_totals
1461 .lock()
1462 .expect("token usage totals lock poisoned");
1463 totals.add(usage);
1464 *totals
1465 }
1466}
1467
1468struct RequestedToolCall {
1469 tool_use: ToolUseBlock,
1470 tool: Option<Arc<dyn Tool<SidAgent>>>,
1471}
1472
1473async fn collect_requested_tool_calls(agent: &SidAgent, resp: &Message) -> Vec<RequestedToolCall> {
1474 let tools = agent.tools().await;
1475 resp.content
1476 .iter()
1477 .filter_map(|block| {
1478 let ContentBlock::ToolUse(tool_use) = block else {
1479 return None;
1480 };
1481 let tool = tools
1482 .iter()
1483 .find(|tool| tool.name() == tool_use.name)
1484 .cloned();
1485 Some(RequestedToolCall {
1486 tool_use: tool_use.clone(),
1487 tool,
1488 })
1489 })
1490 .collect()
1491}
1492
1493fn top_up_cancelled_tool_results(messages: &mut Vec<MessageParam>) -> usize {
1494 let mut cancelled = 0;
1495 let mut index = 0;
1496 while index < messages.len() {
1497 let tool_use_ids = assistant_tool_use_ids(&messages[index]);
1498 if tool_use_ids.is_empty() {
1499 index += 1;
1500 continue;
1501 }
1502
1503 let next_index = index + 1;
1504 if next_index < messages.len()
1505 && messages[next_index].role == MessageRole::User
1506 && user_message_has_required_tool_result(&messages[next_index], &tool_use_ids)
1507 {
1508 cancelled +=
1509 top_up_user_tool_results(&mut messages[next_index], tool_use_ids.as_slice());
1510 } else {
1511 let blocks = tool_use_ids
1512 .iter()
1513 .map(|tool_use_id| user_cancelled_tool_result_block(tool_use_id).into())
1514 .collect::<Vec<ContentBlock>>();
1515 cancelled += blocks.len();
1516 messages.insert(
1517 next_index,
1518 MessageParam::new(MessageParamContent::Array(blocks), MessageRole::User),
1519 );
1520 }
1521 index += 2;
1522 }
1523 cancelled
1524}
1525
1526fn assistant_tool_use_ids(message: &MessageParam) -> Vec<String> {
1527 if message.role != MessageRole::Assistant {
1528 return Vec::new();
1529 }
1530 let MessageParamContent::Array(blocks) = &message.content else {
1531 return Vec::new();
1532 };
1533
1534 let mut seen = BTreeSet::new();
1535 let mut ids = Vec::new();
1536 for block in blocks {
1537 let Some(tool_use) = block.as_tool_use() else {
1538 continue;
1539 };
1540 if seen.insert(tool_use.id.clone()) {
1541 ids.push(tool_use.id.clone());
1542 }
1543 }
1544 ids
1545}
1546
1547fn user_message_has_required_tool_result(message: &MessageParam, tool_use_ids: &[String]) -> bool {
1548 let required = tool_use_ids.iter().collect::<BTreeSet<_>>();
1549 let MessageParamContent::Array(blocks) = &message.content else {
1550 return false;
1551 };
1552 blocks.iter().any(|block| {
1553 block
1554 .as_tool_result()
1555 .is_some_and(|tool_result| required.contains(&tool_result.tool_use_id))
1556 })
1557}
1558
1559fn top_up_user_tool_results(message: &mut MessageParam, tool_use_ids: &[String]) -> usize {
1560 let required = tool_use_ids.iter().cloned().collect::<BTreeSet<_>>();
1561 let content = std::mem::replace(&mut message.content, MessageParamContent::Array(Vec::new()));
1562 let mut existing_results = BTreeMap::new();
1563 let mut tail = Vec::new();
1564
1565 match content {
1566 MessageParamContent::String(text) => {
1567 tail.push(ContentBlock::Text(TextBlock::new(text)));
1568 }
1569 MessageParamContent::Array(blocks) => {
1570 for block in blocks {
1571 if let ContentBlock::ToolResult(tool_result) = &block
1572 && required.contains(&tool_result.tool_use_id)
1573 && !existing_results.contains_key(&tool_result.tool_use_id)
1574 {
1575 existing_results.insert(tool_result.tool_use_id.clone(), block);
1576 continue;
1577 }
1578 tail.push(block);
1579 }
1580 }
1581 }
1582
1583 let mut cancelled = 0;
1584 let mut blocks = Vec::with_capacity(tool_use_ids.len() + tail.len());
1585 for tool_use_id in tool_use_ids {
1586 if let Some(block) = existing_results.remove(tool_use_id) {
1587 blocks.push(block);
1588 } else {
1589 blocks.push(user_cancelled_tool_result_block(tool_use_id).into());
1590 cancelled += 1;
1591 }
1592 }
1593 blocks.extend(tail);
1594 message.content = MessageParamContent::Array(blocks);
1595 cancelled
1596}
1597
1598fn missing_tool_result(tool_use: &ToolUseBlock) -> Result<ToolResultBlock, ToolResultBlock> {
1599 Err(ToolResultBlock::new(tool_use.id.clone())
1600 .with_string_content(format!("{} not found", tool_use.name))
1601 .with_error(true))
1602}
1603
1604fn cancel_remaining_tool_calls<'a>(
1605 tool_results: &mut Vec<ContentBlock>,
1606 remaining: impl Iterator<Item = &'a RequestedToolCall>,
1607) {
1608 for requested in remaining {
1609 let result = user_cancelled_tool_result(&requested.tool_use.id);
1610 push_tool_result(tool_results, None, result);
1611 }
1612}
1613
1614fn cancel_remaining_tool_calls_streaming<'a>(
1615 tool_results: &mut Vec<ContentBlock>,
1616 renderer: &mut dyn Renderer,
1617 context: &AgentStreamContext,
1618 remaining: impl Iterator<Item = &'a RequestedToolCall>,
1619) {
1620 for requested in remaining {
1621 let result = user_cancelled_tool_result(&requested.tool_use.id);
1622 let tool_context = context.child(format!("tool:{}", requested.tool_use.name));
1623 push_tool_result(tool_results, Some((renderer, &tool_context)), result);
1624 }
1625}
1626
1627fn user_cancelled_tool_result(tool_use_id: &str) -> Result<ToolResultBlock, ToolResultBlock> {
1628 Err(user_cancelled_tool_result_block(tool_use_id))
1629}
1630
1631fn user_cancelled_tool_result_block(tool_use_id: &str) -> ToolResultBlock {
1632 ToolResultBlock::new(tool_use_id.to_string())
1633 .with_string_content(USER_CANCELLED_ACTION.to_string())
1634 .with_error(true)
1635}
1636
1637fn tool_result_is_user_cancelled(result: &Result<ToolResultBlock, ToolResultBlock>) -> bool {
1638 let block = match result {
1639 Ok(block) | Err(block) => block,
1640 };
1641 block.is_error.unwrap_or(false)
1642 && matches!(
1643 block.content.as_ref(),
1644 Some(ToolResultBlockContent::String(text)) if text == USER_CANCELLED_ACTION
1645 )
1646}
1647
1648fn push_tool_result(
1649 tool_results: &mut Vec<ContentBlock>,
1650 renderer: Option<(&mut dyn Renderer, &dyn StreamContext)>,
1651 result: Result<ToolResultBlock, ToolResultBlock>,
1652) {
1653 let block = match result {
1654 Ok(block) => block,
1655 Err(block) => block.with_error(true),
1656 };
1657 if let Some((renderer, context)) = renderer {
1658 render_tool_result_block(renderer, context, &block);
1659 }
1660 tool_results.push(block.into());
1661}
1662
1663fn render_tool_result_block(
1664 renderer: &mut dyn Renderer,
1665 context: &dyn StreamContext,
1666 block: &ToolResultBlock,
1667) {
1668 renderer.start_tool_result(context, &block.tool_use_id, block.is_error.unwrap_or(false));
1669 if let Some(content) = &block.content {
1670 render_tool_result_content(renderer, context, content);
1671 }
1672 renderer.finish_tool_result(context);
1673}
1674
1675fn render_tool_result_content(
1676 renderer: &mut dyn Renderer,
1677 context: &dyn StreamContext,
1678 content: &ToolResultBlockContent,
1679) {
1680 match content {
1681 ToolResultBlockContent::String(text) => renderer.print_tool_result_text(context, text),
1682 ToolResultBlockContent::Array(items) => {
1683 for (idx, item) in items.iter().enumerate() {
1684 if idx > 0 {
1685 renderer.print_tool_result_text(context, "\n");
1686 }
1687 match item {
1688 Content::Text(text) => renderer.print_tool_result_text(context, &text.text),
1689 Content::Image(_) => renderer.print_tool_result_text(context, "[image]"),
1690 }
1691 }
1692 }
1693 }
1694}
1695
1696#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1697struct TokenUsageTotals {
1698 input: u64,
1699 cached_input: u64,
1700 output: u64,
1701}
1702
1703impl TokenUsageTotals {
1704 fn add(&mut self, usage: Usage) {
1705 self.input = self.input.saturating_add(tokens_to_u64(usage.input_tokens));
1706 self.cached_input = self
1707 .cached_input
1708 .saturating_add(optional_tokens_to_u64(usage.cache_read_input_tokens));
1709 self.output = self
1710 .output
1711 .saturating_add(tokens_to_u64(usage.output_tokens));
1712 }
1713}
1714
1715fn tokens_to_u64(tokens: i32) -> u64 {
1716 tokens.max(0) as u64
1717}
1718
1719fn optional_tokens_to_u64(tokens: Option<i32>) -> u64 {
1720 tokens.map(tokens_to_u64).unwrap_or(0)
1721}
1722
1723#[derive(Clone, Debug, Default)]
1724struct BuiltinToolBindings {
1725 bash: Option<BuiltinBashBinding>,
1726 edit: Option<RcToolBinding>,
1727}
1728
1729#[derive(Clone, Debug)]
1730struct BuiltinBashBinding {
1731 enabled: SwitchPosition,
1732}
1733
1734#[derive(Clone, Debug)]
1735struct RcToolBinding {
1736 service_name: String,
1737 canonical_id: String,
1738 enabled: SwitchPosition,
1739 confirm_preview: bool,
1740 executable_path: Path<'static>,
1741}
1742
1743struct BuiltTools {
1744 tools: Vec<Arc<dyn Tool<SidAgent>>>,
1745 builtin_bindings: BuiltinToolBindings,
1746}
1747
1748#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1749enum BuiltinToolKind {
1750 Bash,
1751 Edit,
1752}
1753
1754impl BuiltinToolKind {
1755 fn from_canonical_id(canonical_id: &str) -> Option<Self> {
1756 match canonical_id {
1757 "bash" => Some(Self::Bash),
1758 "edit" => Some(Self::Edit),
1759 _ => None,
1760 }
1761 }
1762
1763 fn tool(self) -> Arc<dyn Tool<SidAgent>> {
1764 match self {
1765 Self::Bash => Arc::new(SidBashTool::new()) as Arc<dyn Tool<SidAgent>>,
1766 Self::Edit => Arc::new(SidTextEditorTool::new()) as Arc<dyn Tool<SidAgent>>,
1767 }
1768 }
1769}
1770
1771#[derive(Clone, Debug)]
1772struct SidBashTool {
1773 param: ToolBash20250124,
1774}
1775
1776impl SidBashTool {
1777 fn new() -> Self {
1778 Self {
1779 param: ToolBash20250124::new(),
1780 }
1781 }
1782}
1783
1784impl Tool<SidAgent> for SidBashTool {
1785 fn name(&self) -> String {
1786 self.param.name.clone()
1787 }
1788
1789 fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
1790 Box::new(SidBashCallback)
1791 }
1792
1793 fn to_param(&self) -> ToolUnionParam {
1794 ToolUnionParam::Bash20250124(self.param.clone())
1795 }
1796}
1797
1798struct SidBashCallback;
1799
1800impl SidBashCallback {
1801 async fn compute(
1802 agent: &SidAgent,
1803 tool_use: &ToolUseBlock,
1804 renderer: Option<&mut dyn Renderer>,
1805 ) -> ToolResult {
1806 #[derive(serde::Deserialize)]
1807 struct BashInput {
1808 command: String,
1809 #[serde(default)]
1810 restart: bool,
1811 }
1812
1813 let bash: BashInput = match serde_json::from_value(tool_use.input.clone()) {
1814 Ok(input) => input,
1815 Err(err) => return tool_error_result(&tool_use.id, err.to_string()),
1816 };
1817
1818 match agent
1819 .run_bash_command_with_renderer(&bash.command, bash.restart, renderer)
1820 .await
1821 {
1822 Ok(output) => tool_success_result(&tool_use.id, output),
1823 Err(err) => tool_error_result(&tool_use.id, err.to_string()),
1824 }
1825 }
1826}
1827
1828#[async_trait::async_trait]
1829impl ToolCallback<SidAgent> for SidBashCallback {
1830 async fn compute_tool_result(
1831 &self,
1832 _client: &Anthropic,
1833 agent: &SidAgent,
1834 tool_use: &ToolUseBlock,
1835 ) -> Box<dyn IntermediateToolResult> {
1836 Box::new(Self::compute(agent, tool_use, None).await)
1837 }
1838
1839 async fn compute_tool_result_streaming(
1840 &self,
1841 _client: &Anthropic,
1842 agent: &SidAgent,
1843 tool_use: &ToolUseBlock,
1844 renderer: &mut dyn Renderer,
1845 _context: &AgentStreamContext,
1846 ) -> Box<dyn IntermediateToolResult> {
1847 Box::new(Self::compute(agent, tool_use, Some(renderer)).await)
1848 }
1849
1850 async fn apply_tool_result(
1851 &self,
1852 _client: &Anthropic,
1853 _agent: &mut SidAgent,
1854 _tool_use: &ToolUseBlock,
1855 intermediate: Box<dyn IntermediateToolResult>,
1856 ) -> ToolResult {
1857 apply_computed_tool_result(intermediate)
1858 }
1859}
1860
1861#[derive(Clone, Debug)]
1862struct SidTextEditorTool {
1863 param: ToolTextEditor20250728,
1864}
1865
1866impl SidTextEditorTool {
1867 fn new() -> Self {
1868 Self {
1869 param: ToolTextEditor20250728::new(),
1870 }
1871 }
1872}
1873
1874impl Tool<SidAgent> for SidTextEditorTool {
1875 fn name(&self) -> String {
1876 self.param.name.clone()
1877 }
1878
1879 fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
1880 Box::new(SidTextEditorCallback)
1881 }
1882
1883 fn to_param(&self) -> ToolUnionParam {
1884 ToolUnionParam::TextEditor20250728(self.param.clone())
1885 }
1886}
1887
1888struct SidTextEditorCallback;
1889
1890impl SidTextEditorCallback {
1891 async fn compute(
1892 agent: &SidAgent,
1893 tool_use: &ToolUseBlock,
1894 renderer: Option<&mut dyn Renderer>,
1895 ) -> ToolResult {
1896 match agent.run_text_editor_tool(tool_use.clone(), renderer).await {
1897 Ok(output) => tool_success_result(&tool_use.id, output),
1898 Err(err) => tool_error_result(&tool_use.id, err.to_string()),
1899 }
1900 }
1901}
1902
1903#[async_trait::async_trait]
1904impl ToolCallback<SidAgent> for SidTextEditorCallback {
1905 async fn compute_tool_result(
1906 &self,
1907 _client: &Anthropic,
1908 agent: &SidAgent,
1909 tool_use: &ToolUseBlock,
1910 ) -> Box<dyn IntermediateToolResult> {
1911 Box::new(Self::compute(agent, tool_use, None).await)
1912 }
1913
1914 async fn compute_tool_result_streaming(
1915 &self,
1916 _client: &Anthropic,
1917 agent: &SidAgent,
1918 tool_use: &ToolUseBlock,
1919 renderer: &mut dyn Renderer,
1920 _context: &AgentStreamContext,
1921 ) -> Box<dyn IntermediateToolResult> {
1922 Box::new(Self::compute(agent, tool_use, Some(renderer)).await)
1923 }
1924
1925 async fn apply_tool_result(
1926 &self,
1927 _client: &Anthropic,
1928 _agent: &mut SidAgent,
1929 _tool_use: &ToolUseBlock,
1930 intermediate: Box<dyn IntermediateToolResult>,
1931 ) -> ToolResult {
1932 apply_computed_tool_result(intermediate)
1933 }
1934}
1935
1936fn apply_computed_tool_result(intermediate: Box<dyn IntermediateToolResult>) -> ToolResult {
1937 if let Some(intermediate) = intermediate.as_any().downcast_ref::<ToolResult>() {
1938 return intermediate.clone();
1939 }
1940 if let Some(intermediate) = intermediate.as_any().downcast_ref::<ComputedToolResult>() {
1941 return intermediate.result.clone();
1942 }
1943 ControlFlow::Break(Error::unknown(
1944 "intermediate tool result fails to deserialize",
1945 ))
1946}
1947
1948#[derive(Clone, Debug)]
1949struct ComputedToolResult {
1950 result: ToolResult,
1951 render_result: bool,
1952}
1953
1954impl ComputedToolResult {
1955 fn new(result: ToolResult) -> Self {
1956 Self {
1957 result,
1958 render_result: true,
1959 }
1960 }
1961
1962 fn with_render_result(result: ToolResult, render_result: bool) -> Self {
1963 Self {
1964 result,
1965 render_result,
1966 }
1967 }
1968}
1969
1970impl IntermediateToolResult for ComputedToolResult {
1971 fn as_any(&self) -> &dyn std::any::Any {
1972 self
1973 }
1974}
1975
1976fn should_render_tool_result(intermediate: &dyn IntermediateToolResult) -> bool {
1977 intermediate
1978 .as_any()
1979 .downcast_ref::<ComputedToolResult>()
1980 .map(|intermediate| intermediate.render_result)
1981 .unwrap_or(true)
1982}
1983
1984#[derive(Clone, Debug)]
1985struct AskAnExpertTool;
1986
1987impl Tool<SidAgent> for AskAnExpertTool {
1988 fn name(&self) -> String {
1989 ASK_AN_EXPERT_TOOL_NAME.to_string()
1990 }
1991
1992 fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
1993 Box::new(AskAnExpertCallback)
1994 }
1995
1996 fn to_param(&self) -> ToolUnionParam {
1997 ToolUnionParam::CustomTool(
1998 ToolParam::new(
1999 ASK_AN_EXPERT_TOOL_NAME.to_string(),
2000 serde_json::json!({
2001 "type": "object",
2002 "properties": {
2003 "question": {
2004 "type": "string",
2005 "description": "The direct question to ask the previous summary writer."
2006 }
2007 },
2008 "required": ["question"],
2009 "additionalProperties": false
2010 }),
2011 )
2012 .with_description("Ask a direct question of the person who wrote the summary for this compacted session. They are the previous owner of the project and only available for consultation by contextual memory.".to_string()),
2013 )
2014 }
2015}
2016
2017struct AskAnExpertCallback;
2018
2019#[async_trait::async_trait]
2020impl ToolCallback<SidAgent> for AskAnExpertCallback {
2021 async fn compute_tool_result(
2022 &self,
2023 client: &Anthropic,
2024 agent: &SidAgent,
2025 tool_use: &ToolUseBlock,
2026 ) -> Box<dyn IntermediateToolResult> {
2027 Box::new(ask_an_expert_result(client, agent, tool_use).await)
2028 }
2029
2030 async fn compute_tool_result_streaming(
2031 &self,
2032 client: &Anthropic,
2033 agent: &SidAgent,
2034 tool_use: &ToolUseBlock,
2035 renderer: &mut dyn Renderer,
2036 context: &AgentStreamContext,
2037 ) -> Box<dyn IntermediateToolResult> {
2038 Box::new(ask_an_expert_result_streaming(client, agent, tool_use, renderer, context).await)
2039 }
2040
2041 async fn apply_tool_result(
2042 &self,
2043 _client: &Anthropic,
2044 _agent: &mut SidAgent,
2045 _tool_use: &ToolUseBlock,
2046 intermediate: Box<dyn IntermediateToolResult>,
2047 ) -> ToolResult {
2048 apply_computed_tool_result(intermediate)
2049 }
2050}
2051
2052async fn ask_an_expert_result(
2053 client: &Anthropic,
2054 agent: &SidAgent,
2055 tool_use: &ToolUseBlock,
2056) -> ToolResult {
2057 let question = match ask_an_expert_question(tool_use) {
2058 Ok(question) => question,
2059 Err(result) => return result,
2060 };
2061
2062 match agent.ask_an_expert(client, &question).await {
2063 Ok(output) => tool_success_result(&tool_use.id, output),
2064 Err(err) => tool_error_result(&tool_use.id, err.to_string()),
2065 }
2066}
2067
2068fn ask_an_expert_question(tool_use: &ToolUseBlock) -> Result<String, ToolResult> {
2069 #[derive(serde::Deserialize)]
2070 struct AskAnExpertInput {
2071 question: String,
2072 }
2073
2074 let input: AskAnExpertInput = match serde_json::from_value(tool_use.input.clone()) {
2075 Ok(input) => input,
2076 Err(err) => return Err(tool_error_result(&tool_use.id, err.to_string())),
2077 };
2078 let question = input.question.trim();
2079 if question.is_empty() {
2080 return Err(tool_error_result(
2081 &tool_use.id,
2082 "question must not be empty".to_string(),
2083 ));
2084 }
2085
2086 Ok(question.to_string())
2087}
2088
2089async fn ask_an_expert_result_streaming(
2090 client: &Anthropic,
2091 agent: &SidAgent,
2092 tool_use: &ToolUseBlock,
2093 renderer: &mut dyn Renderer,
2094 context: &AgentStreamContext,
2095) -> ComputedToolResult {
2096 let question = match ask_an_expert_question(tool_use) {
2097 Ok(question) => question,
2098 Err(result) => return ComputedToolResult::new(result),
2099 };
2100 let mut renderer = AskAnExpertStreamRenderer::new(renderer, context, &question);
2101 let result = match agent
2102 .ask_an_expert_with_renderer(client, &question, Some(&mut renderer))
2103 .await
2104 {
2105 Ok(output) => ComputedToolResult::with_render_result(
2106 tool_success_result(&tool_use.id, output),
2107 !renderer.started(),
2108 ),
2109 Err(err) => ComputedToolResult::new(tool_error_result(&tool_use.id, err.to_string())),
2110 };
2111 renderer.finish_if_open(None);
2112 result
2113}
2114
2115struct AskAnExpertStreamRenderer<'a> {
2116 parent: &'a mut dyn Renderer,
2117 context: AgentStreamContext,
2118 question: &'a str,
2119 started: bool,
2120 finished: bool,
2121}
2122
2123impl<'a> AskAnExpertStreamRenderer<'a> {
2124 fn new(
2125 parent: &'a mut dyn Renderer,
2126 parent_context: &AgentStreamContext,
2127 question: &'a str,
2128 ) -> Self {
2129 Self {
2130 parent,
2131 context: parent_context.child("expert"),
2132 question,
2133 started: false,
2134 finished: false,
2135 }
2136 }
2137
2138 fn ensure_started(&mut self) {
2139 if self.started {
2140 return;
2141 }
2142 self.parent.start_agent(&self.context);
2143 self.parent.print_info(&self.context, "question:");
2144 self.parent.print_text(&self.context, self.question);
2145 if !self.question.ends_with('\n') {
2146 self.parent.print_text(&self.context, "\n");
2147 }
2148 self.parent.print_text(&self.context, "\n");
2149 self.started = true;
2150 }
2151
2152 fn finish_if_open(&mut self, stop_reason: Option<&StopReason>) {
2153 if self.started && !self.finished {
2154 self.parent.finish_agent(&self.context, stop_reason);
2155 self.finished = true;
2156 }
2157 }
2158
2159 fn started(&self) -> bool {
2160 self.started
2161 }
2162}
2163
2164impl Renderer for AskAnExpertStreamRenderer<'_> {
2165 fn start_agent(&mut self, _context: &dyn StreamContext) {
2166 self.ensure_started();
2167 }
2168
2169 fn finish_agent(&mut self, _context: &dyn StreamContext, stop_reason: Option<&StopReason>) {
2170 self.finish_if_open(stop_reason);
2171 }
2172
2173 fn print_text(&mut self, _context: &dyn StreamContext, text: &str) {
2174 self.ensure_started();
2175 self.parent.print_text(&self.context, text);
2176 }
2177
2178 fn print_thinking(&mut self, _context: &dyn StreamContext, text: &str) {
2179 self.ensure_started();
2180 self.parent.print_thinking(&self.context, text);
2181 }
2182
2183 fn print_error(&mut self, _context: &dyn StreamContext, error: &str) {
2184 self.ensure_started();
2185 self.parent.print_error(&self.context, error);
2186 }
2187
2188 fn print_info(&mut self, _context: &dyn StreamContext, info: &str) {
2189 self.ensure_started();
2190 self.parent.print_info(&self.context, info);
2191 }
2192
2193 fn start_tool_use(&mut self, _context: &dyn StreamContext, name: &str, id: &str) {
2194 self.ensure_started();
2195 self.parent.start_tool_use(&self.context, name, id);
2196 }
2197
2198 fn print_tool_input(&mut self, _context: &dyn StreamContext, partial_json: &str) {
2199 self.ensure_started();
2200 self.parent.print_tool_input(&self.context, partial_json);
2201 }
2202
2203 fn finish_tool_use(&mut self, _context: &dyn StreamContext) {
2204 self.ensure_started();
2205 self.parent.finish_tool_use(&self.context);
2206 }
2207
2208 fn start_tool_result(
2209 &mut self,
2210 _context: &dyn StreamContext,
2211 tool_use_id: &str,
2212 is_error: bool,
2213 ) {
2214 self.ensure_started();
2215 self.parent
2216 .start_tool_result(&self.context, tool_use_id, is_error);
2217 }
2218
2219 fn print_tool_result_text(&mut self, _context: &dyn StreamContext, text: &str) {
2220 self.ensure_started();
2221 self.parent.print_tool_result_text(&self.context, text);
2222 }
2223
2224 fn finish_tool_result(&mut self, _context: &dyn StreamContext) {
2225 self.ensure_started();
2226 self.parent.finish_tool_result(&self.context);
2227 }
2228
2229 fn finish_response(&mut self, _context: &dyn StreamContext) {
2230 self.ensure_started();
2231 self.parent.finish_response(&self.context);
2232 }
2233
2234 fn print_interrupted(&mut self, _context: &dyn StreamContext) {
2235 self.ensure_started();
2236 self.parent.print_interrupted(&self.context);
2237 }
2238
2239 fn should_interrupt(&self) -> bool {
2240 self.parent.should_interrupt()
2241 }
2242
2243 fn read_operator_line(&mut self, prompt: &str) -> std::io::Result<Option<OperatorLine>> {
2244 self.parent.read_operator_line(prompt)
2245 }
2246}
2247
2248#[derive(Clone, Debug)]
2249struct ExternalTool {
2250 name: String,
2251 canonical_id: String,
2252 enabled: SwitchPosition,
2253 confirm_preview: bool,
2254 executable_path: Path<'static>,
2255 description: String,
2256 input_schema: serde_json::Value,
2257}
2258
2259impl ExternalTool {
2260 fn from_config(name: String, canonical_id: String, tool: &ToolConfig) -> Self {
2261 let manifest = tool
2262 .manifest
2263 .as_ref()
2264 .expect("external tools require a manifest");
2265 let executable_path = tool
2266 .executable_path
2267 .clone()
2268 .expect("external tools require an executable path");
2269 Self {
2270 name,
2271 canonical_id,
2272 enabled: tool.enabled,
2273 confirm_preview: tool.confirm_preview,
2274 executable_path,
2275 description: manifest.description.clone(),
2276 input_schema: manifest.input_schema.clone(),
2277 }
2278 }
2279}
2280
2281impl Tool<SidAgent> for ExternalTool {
2282 fn name(&self) -> String {
2283 self.name.clone()
2284 }
2285
2286 fn callback(&self) -> Box<dyn ToolCallback<SidAgent> + '_> {
2287 Box::new(ExternalToolCallback { tool: self.clone() })
2288 }
2289
2290 fn to_param(&self) -> ToolUnionParam {
2291 ToolUnionParam::CustomTool(
2292 ToolParam::new(self.name.clone(), self.input_schema.clone())
2293 .with_description(self.description.clone()),
2294 )
2295 }
2296}
2297
2298#[derive(Clone, Debug)]
2299struct ExternalToolCallback {
2300 tool: ExternalTool,
2301}
2302
2303#[async_trait::async_trait]
2304impl ToolCallback<SidAgent> for ExternalToolCallback {
2305 async fn compute_tool_result(
2306 &self,
2307 _client: &Anthropic,
2308 agent: &SidAgent,
2309 tool_use: &ToolUseBlock,
2310 ) -> Box<dyn IntermediateToolResult> {
2311 Box::new(invoke_external_tool(&self.tool, agent, tool_use, None).await)
2312 }
2313
2314 async fn compute_tool_result_streaming(
2315 &self,
2316 _client: &Anthropic,
2317 agent: &SidAgent,
2318 tool_use: &ToolUseBlock,
2319 renderer: &mut dyn Renderer,
2320 _context: &AgentStreamContext,
2321 ) -> Box<dyn IntermediateToolResult> {
2322 Box::new(invoke_external_tool(&self.tool, agent, tool_use, Some(renderer)).await)
2323 }
2324
2325 async fn apply_tool_result(
2326 &self,
2327 _client: &Anthropic,
2328 _agent: &mut SidAgent,
2329 _tool_use: &ToolUseBlock,
2330 intermediate: Box<dyn IntermediateToolResult>,
2331 ) -> ToolResult {
2332 apply_computed_tool_result(intermediate)
2333 }
2334}
2335
2336async fn invoke_external_tool(
2337 tool: &ExternalTool,
2338 agent: &SidAgent,
2339 tool_use: &ToolUseBlock,
2340 renderer: Option<&mut dyn Renderer>,
2341) -> ToolResult {
2342 if tool.enabled == SwitchPosition::No {
2343 return tool_error_result(&tool_use.id, format!("tool '{}' is disabled", tool.name));
2344 }
2345
2346 let input = match tool_use.input.as_object() {
2347 Some(input) => input.clone(),
2348 None => {
2349 return tool_error_result(
2350 &tool_use.id,
2351 format!(
2352 "tool '{}' protocol error: tool input must be a JSON object",
2353 tool.name
2354 ),
2355 );
2356 }
2357 };
2358
2359 match tool.enabled {
2360 SwitchPosition::Yes => {}
2361 SwitchPosition::No => unreachable!("disabled tools return before input validation"),
2362 SwitchPosition::Manual => {
2363 let context = agent.tool_runtime_context();
2364 let prepared = match tool_runtime::prepare_rc_tool_invocation(
2365 &tool.name,
2366 &tool.name,
2367 &tool.canonical_id,
2368 &tool.executable_path,
2369 &context,
2370 &tool_use.id,
2371 input,
2372 ) {
2373 Ok(prepared) => prepared,
2374 Err(message) => return tool_error_result(&tool_use.id, message),
2375 };
2376 match confirm_manual_prepared_tool_call(
2377 &tool.name,
2378 &tool_use.input,
2379 tool.confirm_preview,
2380 &prepared,
2381 agent.session.as_deref(),
2382 renderer,
2383 )
2384 .await
2385 {
2386 Ok(ManualToolConfirmation::Allow) => {}
2387 Ok(ManualToolConfirmation::Deny) => {
2388 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
2389 return tool_error_result(
2390 &tool_use.id,
2391 format!("tool '{}' call denied by operator", tool.name),
2392 );
2393 }
2394 Ok(ManualToolConfirmation::Cancel) => {
2395 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, false);
2396 return tool_user_cancelled_result(&tool_use.id);
2397 }
2398 Err(err) => {
2399 let _ = tool_runtime::cleanup_prepared_rc_tool(&prepared, true);
2400 return tool_error_result(&tool_use.id, err);
2401 }
2402 }
2403 return match tool_runtime::run_prepared_rc_tool_text(
2404 &prepared,
2405 &agent.writable_roots,
2406 agent.session.as_deref(),
2407 )
2408 .await
2409 {
2410 Ok(text) => tool_success_result(&tool_use.id, text),
2411 Err(message) => tool_error_result(&tool_use.id, message),
2412 };
2413 }
2414 }
2415
2416 let context = ToolRuntimeContext {
2417 agent_id: &agent.id,
2418 config_root: &agent.config_root,
2419 workspace_root: &agent.workspace_root,
2420 writable_roots: &agent.writable_roots,
2421 session: agent.session.as_deref(),
2422 };
2423 match tool_runtime::invoke_rc_tool_text(
2424 &tool.name,
2425 &tool.name,
2426 &tool.canonical_id,
2427 &tool.executable_path,
2428 &context,
2429 &tool_use.id,
2430 input,
2431 )
2432 .await
2433 {
2434 Ok(text) => tool_success_result(&tool_use.id, text),
2435 Err(message) => tool_error_result(&tool_use.id, message),
2436 }
2437}
2438
2439#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2440enum ManualToolConfirmation {
2441 Allow,
2442 Deny,
2443 Cancel,
2444}
2445
2446#[derive(Debug, Eq, PartialEq)]
2447enum ManualToolInput {
2448 Line(String),
2449 Cancel,
2450}
2451
2452async fn confirm_manual_prepared_tool_call(
2458 tool_name: &str,
2459 input: &serde_json::Value,
2460 confirm_preview: bool,
2461 prepared: &tool_runtime::PreparedRcToolInvocation,
2462 session: Option<&SidSession>,
2463 renderer: Option<&mut dyn Renderer>,
2464) -> Result<ManualToolConfirmation, String> {
2465 let preview = if confirm_preview {
2466 tool_runtime::render_rc_tool_confirmation_preview(prepared, session)
2467 .await
2468 .ok()
2469 } else {
2470 None
2471 };
2472 confirm_manual_tool_call(tool_name, input, preview.as_deref(), renderer)
2473}
2474
2475fn confirm_manual_tool_call(
2476 tool_name: &str,
2477 input: &serde_json::Value,
2478 preview: Option<&str>,
2479 mut renderer: Option<&mut dyn Renderer>,
2480) -> Result<ManualToolConfirmation, String> {
2481 let input_display = preview.map(str::to_string).unwrap_or_else(|| {
2482 serde_json::to_string_pretty(input).unwrap_or_else(|_| format!("{input:?}"))
2483 });
2484
2485 loop {
2486 let prompt =
2487 format!("Tool '{tool_name}' is MANUAL.\n{input_display}\nAllow this call? [yes/no]: ");
2488 let input = read_operator_line(&mut renderer, &prompt)?;
2489 let ManualToolInput::Line(buf) = input else {
2490 return Ok(ManualToolConfirmation::Cancel);
2491 };
2492
2493 match parse_tool_confirmation(&buf) {
2494 Some(true) => return Ok(ManualToolConfirmation::Allow),
2495 Some(false) => return Ok(ManualToolConfirmation::Deny),
2496 None => println!("Please answer yes or no."),
2497 }
2498 }
2499}
2500
2501fn read_operator_line(
2502 renderer: &mut Option<&mut dyn Renderer>,
2503 prompt: &str,
2504) -> Result<ManualToolInput, String> {
2505 use std::io::{self, Write};
2506
2507 if let Some(renderer) = renderer.as_mut()
2508 && let Some(line) = (*renderer)
2509 .read_operator_line(prompt)
2510 .map_err(|err| format!("failed to read manual-tool confirmation input: {err}"))?
2511 {
2512 return match line {
2513 OperatorLine::Line(line) => Ok(ManualToolInput::Line(line)),
2514 OperatorLine::Eof | OperatorLine::Interrupted => {
2515 println!();
2516 Ok(ManualToolInput::Cancel)
2517 }
2518 };
2519 }
2520
2521 print!("{prompt}");
2522 io::stdout()
2523 .flush()
2524 .map_err(|err| format!("failed to flush manual-tool confirmation prompt: {err}"))?;
2525
2526 let mut buf = String::new();
2527 match io::stdin().read_line(&mut buf) {
2528 Ok(0) => {
2529 println!();
2530 Ok(ManualToolInput::Cancel)
2531 }
2532 Ok(_) => Ok(ManualToolInput::Line(buf)),
2533 Err(err) if err.kind() == io::ErrorKind::Interrupted => {
2534 println!();
2535 Ok(ManualToolInput::Cancel)
2536 }
2537 Err(err) => Err(format!(
2538 "failed to read manual-tool confirmation input: {err}"
2539 )),
2540 }
2541}
2542
2543fn parse_tool_confirmation(input: &str) -> Option<bool> {
2544 match input.trim().to_ascii_lowercase().as_str() {
2545 "y" | "yes" => Some(true),
2546 "n" | "no" => Some(false),
2547 _ => None,
2548 }
2549}
2550
2551fn render_bash_pty_result(result: BashPtyResult) -> Result<String, std::io::Error> {
2552 let mut rendered = strip_ansi_escapes(&result.output);
2553 if result.status.success() {
2554 if rendered.is_empty() {
2555 rendered.push_str("success\n");
2556 }
2557 Ok(rendered)
2558 } else {
2559 if !rendered.is_empty() && !rendered.ends_with('\n') {
2560 rendered.push('\n');
2561 }
2562 rendered.push_str(&format!("{}\n", result.status));
2563 Err(std::io::Error::other(rendered))
2564 }
2565}
2566
2567fn bash_state_io_error(context: &str, result: &BashPtyResult) -> std::io::Error {
2568 let mut rendered = strip_ansi_escapes(&result.output);
2569 if !rendered.is_empty() && !rendered.ends_with('\n') {
2570 rendered.push('\n');
2571 }
2572 rendered.push_str(&format!("{}", result.status));
2573 std::io::Error::other(format!("{context}: {rendered}"))
2574}
2575
2576fn strip_ansi_escapes(input: &str) -> String {
2582 let mut out = String::with_capacity(input.len());
2583 let mut chars = input.chars();
2584 while let Some(ch) = chars.next() {
2585 if ch != '\x1b' {
2586 out.push(ch);
2587 continue;
2588 }
2589 match chars.next() {
2591 Some('[') => {
2593 for c in chars.by_ref() {
2594 if c.is_ascii_alphabetic() || c == '@' || c == '~' {
2595 break;
2596 }
2597 }
2598 }
2599 Some(']') => {
2601 let mut prev = '\0';
2602 for c in chars.by_ref() {
2603 if c == '\x07' {
2604 break;
2605 }
2606 if prev == '\x1b' && c == '\\' {
2607 break;
2608 }
2609 prev = c;
2610 }
2611 }
2612 Some(_) => {}
2615 None => {}
2617 }
2618 }
2619 out
2620}
2621
2622#[derive(serde::Deserialize)]
2623struct TranscriptSnapshot {
2624 version: u8,
2625 messages: Vec<MessageParam>,
2626}
2627
2628struct NullRenderer;
2629
2630impl Renderer for NullRenderer {
2631 fn print_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
2632
2633 fn print_thinking(&mut self, _context: &dyn StreamContext, _text: &str) {}
2634
2635 fn print_error(&mut self, _context: &dyn StreamContext, _error: &str) {}
2636
2637 fn print_info(&mut self, _context: &dyn StreamContext, _info: &str) {}
2638
2639 fn start_tool_use(&mut self, _context: &dyn StreamContext, _name: &str, _id: &str) {}
2640
2641 fn print_tool_input(&mut self, _context: &dyn StreamContext, _partial_json: &str) {}
2642
2643 fn finish_tool_use(&mut self, _context: &dyn StreamContext) {}
2644
2645 fn start_tool_result(
2646 &mut self,
2647 _context: &dyn StreamContext,
2648 _tool_use_id: &str,
2649 _is_error: bool,
2650 ) {
2651 }
2652
2653 fn print_tool_result_text(&mut self, _context: &dyn StreamContext, _text: &str) {}
2654
2655 fn finish_tool_result(&mut self, _context: &dyn StreamContext) {}
2656
2657 fn finish_response(&mut self, _context: &dyn StreamContext) {}
2658}
2659
2660pub fn load_transcript_messages(path: &std::path::Path) -> Result<Vec<MessageParam>, SError> {
2670 let payload = std::fs::read(path).map_err(|err| {
2671 SError::new("sid-transcript")
2672 .with_code("transcript_read_failed")
2673 .with_message("failed to read transcript")
2674 .with_string_field("path", path.to_string_lossy().as_ref())
2675 .with_string_field("cause", &err.to_string())
2676 })?;
2677 let snapshot: TranscriptSnapshot = serde_json::from_slice(&payload).map_err(|err| {
2678 SError::new("sid-transcript")
2679 .with_code("transcript_parse_failed")
2680 .with_message("failed to parse transcript")
2681 .with_string_field("path", path.to_string_lossy().as_ref())
2682 .with_string_field("cause", &err.to_string())
2683 })?;
2684 if snapshot.version != 1 {
2685 return Err(SError::new("sid-transcript")
2686 .with_code("unsupported_transcript_version")
2687 .with_message("unsupported transcript version")
2688 .with_string_field("path", path.to_string_lossy().as_ref())
2689 .with_string_field("version", &snapshot.version.to_string()));
2690 }
2691 Ok(snapshot.messages)
2692}
2693
2694pub fn compacted_transcript(parent_session_id: &str, summary: &str) -> Vec<MessageParam> {
2700 vec![
2701 MessageParam::user(
2702 COMPACTED_SESSION_CONTEXT_TEMPLATE.replace("{session_id}", parent_session_id),
2703 ),
2704 MessageParam::assistant(summary),
2705 ]
2706}
2707
2708fn tool_success_result(tool_use_id: &str, message: String) -> ToolResult {
2709 ControlFlow::Continue(Ok(
2710 ToolResultBlock::new(tool_use_id.to_string()).with_string_content(message)
2711 ))
2712}
2713
2714fn tool_error_result(tool_use_id: &str, message: String) -> ToolResult {
2715 ControlFlow::Continue(Err(ToolResultBlock::new(tool_use_id.to_string())
2716 .with_string_content(message)
2717 .with_error(true)))
2718}
2719
2720fn tool_user_cancelled_result(tool_use_id: &str) -> ToolResult {
2721 ControlFlow::Continue(user_cancelled_tool_result(tool_use_id))
2722}
2723
2724fn default_writable_roots(workspace_root: &Path) -> WritableRoots {
2730 let mut roots = WritableRoots::default();
2731 if let Ok(canonical) = std::fs::canonicalize(workspace_root.as_str()) {
2732 if let Some(s) = canonical.to_str() {
2733 roots.push(s.to_string());
2734 }
2735 } else {
2736 roots.push(workspace_root.as_str().to_string());
2737 }
2738 let temp_dir = std::env::temp_dir();
2739 if let Ok(canonical) = std::fs::canonicalize(&temp_dir) {
2740 if let Some(s) = canonical.to_str() {
2741 roots.push(s.to_string());
2742 }
2743 } else if let Some(s) = temp_dir.to_str() {
2744 roots.push(s.to_string());
2745 }
2746 roots
2747}
2748
2749fn append_writable_root(roots: &mut WritableRoots, root: &std::path::Path) {
2750 if let Ok(canonical) = std::fs::canonicalize(root)
2751 && let Some(s) = canonical.to_str()
2752 {
2753 roots.push(s.to_string());
2754 return;
2755 }
2756 if let Some(s) = root.to_str() {
2757 roots.push(s.to_string());
2758 }
2759}
2760
2761fn workspace_has_config(root: &Path) -> bool {
2762 root.join(AGENTS_CONF_FILE).is_file() || root.join(TOOLS_CONF_FILE).is_file()
2763}
2764
2765fn default_agent_id(config: &Config) -> Result<String, SError> {
2770 if let Some(default) = config.default_agent.as_ref() {
2771 let agent_config = config.agents.get(default).ok_or_else(|| {
2772 SError::new("sid-agent")
2773 .with_code("invalid_default_agent")
2774 .with_message("DEFAULT_AGENT names an undefined agent")
2775 .with_string_field("default_agent", default)
2776 })?;
2777 if !agent_config.enabled.can_be_started() {
2778 return Err(SError::new("sid-agent")
2779 .with_code("disabled_default_agent")
2780 .with_message("DEFAULT_AGENT names a disabled agent")
2781 .with_string_field("default_agent", default)
2782 .with_string_field("enabled", &format!("{:?}", agent_config.enabled)));
2783 }
2784 return Ok(default.clone());
2785 }
2786
2787 if let Some(agent) = config
2788 .agents
2789 .values()
2790 .find(|agent| agent.enabled == SwitchPosition::Yes)
2791 {
2792 return Ok(agent.id.clone());
2793 }
2794 if let Some(agent) = config
2795 .agents
2796 .values()
2797 .find(|agent| agent.enabled == SwitchPosition::Manual)
2798 {
2799 return Ok(agent.id.clone());
2800 }
2801
2802 Err(SError::new("sid-agent")
2803 .with_code("no_startable_agents")
2804 .with_message("workspace config defines no runnable agents"))
2805}
2806
2807fn build_tools(config: &Config, agent_config: &AgentConfig) -> Result<BuiltTools, SError> {
2808 let mut tools = Vec::new();
2809 let mut seen = BTreeSet::new();
2810 let mut builtin_bindings = BuiltinToolBindings::default();
2811 for tool_name in &agent_config.tools {
2812 let tool_config = config.tools.get(tool_name).ok_or_else(|| {
2813 SError::new("sid-agent")
2814 .with_code("unknown_tool")
2815 .with_message("agent references an undefined tool")
2816 .with_string_field("agent", &agent_config.id)
2817 .with_string_field("tool", tool_name)
2818 })?;
2819 if !tool_config.enabled.can_be_started() {
2820 return Err(disabled_tool_error(
2821 &agent_config.id,
2822 tool_name,
2823 tool_config.enabled,
2824 ));
2825 }
2826 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, tool_name)?;
2827 let Some(builtin_kind) = BuiltinToolKind::from_canonical_id(&canonical_id) else {
2828 let tool = ExternalTool::from_config(
2829 exposed_tool_name(&agent_config.id, tool_name)?,
2830 canonical_id,
2831 tool_config,
2832 );
2833 if seen.insert(tool.name.clone()) {
2834 tools.push(Arc::new(tool) as Arc<dyn Tool<SidAgent>>);
2835 }
2836 continue;
2837 };
2838 let tool = builtin_kind.tool();
2839 let exposed_name = tool.name();
2840 if seen.insert(exposed_name) {
2841 match builtin_kind {
2842 BuiltinToolKind::Bash if builtin_bindings.bash.is_none() => {
2843 builtin_bindings.bash = Some(BuiltinBashBinding {
2844 enabled: tool_config.enabled,
2845 });
2846 }
2847 BuiltinToolKind::Edit if builtin_bindings.edit.is_none() => {
2848 builtin_bindings.edit = Some(RcToolBinding {
2849 service_name: tool_name.clone(),
2850 canonical_id,
2851 enabled: tool_config.enabled,
2852 confirm_preview: tool_config.confirm_preview,
2853 executable_path: tool_config
2854 .executable_path
2855 .clone()
2856 .expect("built-in edit tool requires an executable path"),
2857 });
2858 }
2859 _ => {}
2860 }
2861 tools.push(tool);
2862 }
2863 }
2864 Ok(BuiltTools {
2865 tools,
2866 builtin_bindings,
2867 })
2868}
2869
2870fn exposed_tool_name(agent: &str, tool_name: &str) -> Result<String, SError> {
2871 if is_valid_anthropic_tool_name(tool_name) {
2872 Ok(tool_name.to_string())
2873 } else {
2874 Err(SError::new("sid-agent")
2875 .with_code("invalid_tool_name")
2876 .with_message("tool name exposed to the model is not legal")
2877 .with_string_field("agent", agent)
2878 .with_string_field("tool", tool_name)
2879 .with_string_field("name", tool_name))
2880 }
2881}
2882
2883fn merged_chat_config(agent_config: &AgentConfig, fallback: Option<&ChatConfig>) -> ChatConfig {
2884 let mut merged = fallback.cloned().unwrap_or_else(ChatConfig::new);
2885 let defaults = ChatConfig::new();
2886 let agent = &agent_config.chat_config;
2887
2888 if agent.template.model != defaults.template.model {
2889 merged.template.model = agent.template.model.clone();
2890 }
2891 if agent.template.system.is_some() {
2892 merged.template.system = agent.template.system.clone();
2893 }
2894 if agent.template.max_tokens != defaults.template.max_tokens {
2895 merged.template.max_tokens = agent.template.max_tokens;
2896 }
2897 if agent.template.temperature != defaults.template.temperature {
2898 merged.template.temperature = agent.template.temperature;
2899 }
2900 if agent.template.top_p != defaults.template.top_p {
2901 merged.template.top_p = agent.template.top_p;
2902 }
2903 if agent.template.top_k != defaults.template.top_k {
2904 merged.template.top_k = agent.template.top_k;
2905 }
2906 if agent.template.stop_sequences != defaults.template.stop_sequences {
2907 merged.template.stop_sequences = agent.template.stop_sequences.clone();
2908 }
2909 if agent.template.thinking != defaults.template.thinking {
2910 merged.template.thinking = agent.template.thinking;
2911 }
2912 if agent.use_color != defaults.use_color {
2913 merged.use_color = agent.use_color;
2914 }
2915 if agent.session_budget.is_some() {
2916 merged.session_budget = agent.session_budget.clone();
2917 }
2918 if agent.caching_enabled != defaults.caching_enabled {
2919 merged.caching_enabled = agent.caching_enabled;
2920 }
2921
2922 merged
2923}
2924
2925fn append_system_description(chat_config: &mut ChatConfig, workspace_root: &Path) {
2926 let existing = chat_config.system_prompt_text().unwrap_or("").to_string();
2927 let addendum = format!(
2928 r#"# Environment
2929
2930You are operating in the sid-isn't-done environment. Tools:
2931- edit: The Anthropic Text Editor tool:
2932 - Uses sid's virtual filesystem.
2933 - The virtual filesystem maps / to the workspace root.
2934 - It is not an operating-system chroot.
2935 - Absolute editor paths are workspace-rooted; /foo means {workspace_root}/foo.
2936- bash: A genuine bash shell:
2937 - Connected via PTY.
2938 - Without support for cursor positioning.
2939 - With state persistence between invocations.
2940 - With PS0, PS1, PS2 and PROMPT_COMMAND set to readonly.
2941 - `restart: true` throws the session away and starts fresh.
2942 - Initial CWD is {workspace_root}.
2943 - Runs in the host filesystem namespace, not a chroot.
2944 - Host / remains visible subject to OS permissions and sandbox policy.
2945 - Bash cannot see sid's virtual /skills mount.
2946 - Use the index to browse skills if you need specialized knowledge.
2947
2948CRITICAL — the edit tool and bash tool use different path namespaces:
2949- The edit tool's / is the workspace root ({workspace_root}).
2950 To edit a file at the workspace root, use /filename (e.g., /src/lib.rs).
2951 NEVER pass a full host path to the edit tool.
2952- Bash sees the real host filesystem. `pwd` prints {workspace_root}, not /.
2953 To convert a bash path to an edit path, strip the {workspace_root} prefix.
2954 To convert an edit path to a bash path, prepend {workspace_root}.
2955 If bash `pwd` is a subdirectory, a relative path like ./foo.rs in bash
2956 corresponds to stripping the workspace prefix from the absolute bash path.
2957- Example: the bash path {workspace_root}/src/lib.rs is the edit path /src/lib.rs.
2958"#,
2959 );
2960
2961 chat_config.set_system_prompt(Some(format!("{existing}{addendum}")));
2962}
2963
2964fn append_skill_index_to_system_prompt(chat_config: &mut ChatConfig, skills: &[&SkillConfig]) {
2967 let mut index = String::from("\n\n# Available skills (mounted read-only under /skills/):\n");
2968 for skill in skills {
2969 index.push_str(&format!(" - /skills/{}/SKILL.md\n", skill.id));
2970 }
2971 let existing = chat_config.system_prompt_text().unwrap_or("").to_string();
2972 chat_config.set_system_prompt(Some(format!("{existing}{index}")));
2973}
2974
2975fn latest_user_message_text(messages: &[MessageParam]) -> Option<String> {
2976 let message = messages.last()?;
2977 if message.role != MessageRole::User {
2978 return None;
2979 }
2980 match &message.content {
2981 claudius::MessageParamContent::String(text) => Some(text.clone()),
2982 claudius::MessageParamContent::Array(blocks) => {
2983 if blocks.iter().any(ContentBlock::is_tool_result) {
2984 return None;
2985 }
2986 let text = blocks
2987 .iter()
2988 .filter_map(ContentBlock::as_text)
2989 .map(|block| block.text.as_str())
2990 .collect::<Vec<_>>()
2991 .join("\n\n");
2992 Some(text)
2993 }
2994 }
2995}
2996
2997pub fn extract_last_assistant_text(messages: &[MessageParam]) -> Option<String> {
3002 let message = messages
3003 .iter()
3004 .rev()
3005 .find(|message| message.role == MessageRole::Assistant)?;
3006 assistant_message_text(message)
3007}
3008
3009fn assistant_message_text(message: &MessageParam) -> Option<String> {
3010 if message.role != MessageRole::Assistant {
3011 return None;
3012 }
3013 match &message.content {
3014 MessageParamContent::String(text) => Some(text.clone()),
3015 MessageParamContent::Array(blocks) => {
3016 let text = blocks
3017 .iter()
3018 .filter_map(ContentBlock::as_text)
3019 .map(|block| block.text.as_str())
3020 .collect::<Vec<_>>()
3021 .join("\n\n");
3022 if text.is_empty() { None } else { Some(text) }
3023 }
3024 }
3025}
3026
3027fn missing_agent_error(agent: &str) -> SError {
3028 SError::new("sid-agent")
3029 .with_code("unknown_agent")
3030 .with_message("requested agent is not defined")
3031 .with_string_field("agent", agent)
3032}
3033
3034fn disabled_agent_error(agent: &str, enabled: SwitchPosition) -> SError {
3035 SError::new("sid-agent")
3036 .with_code("disabled_agent")
3037 .with_message("requested agent is disabled")
3038 .with_string_field("agent", agent)
3039 .with_string_field("enabled", &format!("{enabled:?}"))
3040}
3041
3042fn disabled_tool_error(agent: &str, tool: &str, enabled: SwitchPosition) -> SError {
3043 SError::new("sid-agent")
3044 .with_code("disabled_tool")
3045 .with_message("agent references a disabled tool")
3046 .with_string_field("agent", agent)
3047 .with_string_field("tool", tool)
3048 .with_string_field("enabled", &format!("{enabled:?}"))
3049}
3050
3051#[cfg(test)]
3052mod tests {
3053 use std::collections::VecDeque;
3054 use std::fs;
3055
3056 use claudius::{
3057 KnownModel, MessageParamContent, ToolBash20250124, ToolTextEditor20250728, ToolUnionParam,
3058 };
3059 use serde_json::json;
3060
3061 use super::*;
3062 use crate::config::{TOOL_PROTOCOL_VERSION, TOOLS_DIR};
3063 use crate::test_support::{
3064 make_executable, temp_config_root, unique_temp_dir, write_default_tool_manifest,
3065 };
3066
3067 struct ScriptedRenderer {
3068 lines: VecDeque<OperatorLine>,
3069 prompts: Vec<String>,
3070 }
3071
3072 impl ScriptedRenderer {
3073 fn new(lines: impl IntoIterator<Item = OperatorLine>) -> Self {
3074 Self {
3075 lines: lines.into_iter().collect(),
3076 prompts: Vec::new(),
3077 }
3078 }
3079 }
3080
3081 impl Renderer for ScriptedRenderer {
3082 fn print_text(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3083
3084 fn print_thinking(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3085
3086 fn print_error(&mut self, _context: &dyn claudius::StreamContext, _error: &str) {}
3087
3088 fn print_info(&mut self, _context: &dyn claudius::StreamContext, _info: &str) {}
3089
3090 fn start_tool_use(
3091 &mut self,
3092 _context: &dyn claudius::StreamContext,
3093 _name: &str,
3094 _id: &str,
3095 ) {
3096 }
3097
3098 fn print_tool_input(
3099 &mut self,
3100 _context: &dyn claudius::StreamContext,
3101 _partial_json: &str,
3102 ) {
3103 }
3104
3105 fn finish_tool_use(&mut self, _context: &dyn claudius::StreamContext) {}
3106
3107 fn start_tool_result(
3108 &mut self,
3109 _context: &dyn claudius::StreamContext,
3110 _tool_use_id: &str,
3111 _is_error: bool,
3112 ) {
3113 }
3114
3115 fn print_tool_result_text(&mut self, _context: &dyn claudius::StreamContext, _text: &str) {}
3116
3117 fn finish_tool_result(&mut self, _context: &dyn claudius::StreamContext) {}
3118
3119 fn finish_response(&mut self, _context: &dyn claudius::StreamContext) {}
3120
3121 fn read_operator_line(&mut self, prompt: &str) -> std::io::Result<Option<OperatorLine>> {
3122 self.prompts.push(prompt.to_string());
3123 Ok(self.lines.pop_front())
3124 }
3125 }
3126
3127 #[derive(Default)]
3128 struct RecordingRenderer {
3129 output: String,
3130 }
3131
3132 impl Renderer for RecordingRenderer {
3133 fn start_agent(&mut self, context: &dyn claudius::StreamContext) {
3134 self.output.push_str(&format!(
3135 "[start:{}:{}]\n",
3136 context.label().unwrap_or_default(),
3137 context.depth()
3138 ));
3139 }
3140
3141 fn finish_agent(
3142 &mut self,
3143 context: &dyn claudius::StreamContext,
3144 stop_reason: Option<&StopReason>,
3145 ) {
3146 self.output.push_str(&format!(
3147 "[finish:{}:{}:{stop_reason:?}]\n",
3148 context.label().unwrap_or_default(),
3149 context.depth(),
3150 ));
3151 }
3152
3153 fn print_text(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3154 self.output.push_str(text);
3155 }
3156
3157 fn print_thinking(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3158 self.output.push_str(text);
3159 }
3160
3161 fn print_error(&mut self, _context: &dyn claudius::StreamContext, error: &str) {
3162 self.output.push_str(error);
3163 self.output.push('\n');
3164 }
3165
3166 fn print_info(&mut self, _context: &dyn claudius::StreamContext, info: &str) {
3167 self.output.push_str(info);
3168 self.output.push('\n');
3169 }
3170
3171 fn start_tool_use(
3172 &mut self,
3173 _context: &dyn claudius::StreamContext,
3174 _name: &str,
3175 _id: &str,
3176 ) {
3177 }
3178
3179 fn print_tool_input(&mut self, _context: &dyn claudius::StreamContext, partial_json: &str) {
3180 self.output.push_str(partial_json);
3181 }
3182
3183 fn finish_tool_use(&mut self, _context: &dyn claudius::StreamContext) {}
3184
3185 fn start_tool_result(
3186 &mut self,
3187 _context: &dyn claudius::StreamContext,
3188 _tool_use_id: &str,
3189 _is_error: bool,
3190 ) {
3191 }
3192
3193 fn print_tool_result_text(&mut self, _context: &dyn claudius::StreamContext, text: &str) {
3194 self.output.push_str(text);
3195 }
3196
3197 fn finish_tool_result(&mut self, _context: &dyn claudius::StreamContext) {}
3198
3199 fn finish_response(&mut self, _context: &dyn claudius::StreamContext) {}
3200 }
3201
3202 #[test]
3203 fn ask_an_expert_stream_renderer_streams_question_and_answer() {
3204 let parent_context = AgentStreamContext::root("build").child("tool:ask_an_expert");
3205 let mut parent = RecordingRenderer::default();
3206 {
3207 let mut renderer =
3208 AskAnExpertStreamRenderer::new(&mut parent, &parent_context, "Where is it?");
3209 renderer.start_agent(&());
3210 renderer.print_text(&(), "In src/lib.rs.");
3211 renderer.finish_agent(&(), Some(&StopReason::EndTurn));
3212 }
3213
3214 assert!(parent.output.contains("[start:expert:2]"));
3215 assert!(parent.output.contains("question:\nWhere is it?\n\n"));
3216 assert!(parent.output.contains("In src/lib.rs."));
3217 assert!(parent.output.contains("[finish:expert:2:Some(EndTurn)]"));
3218 }
3219
3220 #[test]
3221 fn computed_tool_result_can_suppress_terminal_rendering() {
3222 let result = ComputedToolResult::with_render_result(
3223 tool_success_result("toolu_test", "ok".to_string()),
3224 false,
3225 );
3226
3227 assert!(!should_render_tool_result(&result));
3228 assert_eq!(
3229 unwrap_success_text(apply_computed_tool_result(Box::new(result))),
3230 "ok"
3231 );
3232 }
3233
3234 #[test]
3235 fn from_config_uses_agent_prompt_and_tools() {
3236 let root = temp_config_root("agent");
3237 write_sample_config(&root);
3238
3239 let config = Config::load(&root).unwrap();
3240 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3241
3242 assert_eq!(agent.id(), "build");
3243 assert_eq!(agent.stream_label(), "build".to_string());
3244 assert_eq!(
3245 agent.config.system_prompt_text(),
3246 Some(expected_build_system_prompt(&root).as_str())
3247 );
3248
3249 let runtime = tokio::runtime::Runtime::new().unwrap();
3250 let tool_names = runtime
3251 .block_on(agent.tools())
3252 .iter()
3253 .map(|tool| tool.name())
3254 .collect::<Vec<_>>();
3255 assert_eq!(tool_names, vec!["format".to_string(), "shell".to_string()]);
3256 assert!(!agent.requires_confirmation());
3257
3258 fs::remove_dir_all(root.as_str()).unwrap();
3259 }
3260
3261 #[test]
3262 fn from_workspace_prefers_workspace_agent_and_fills_from_fallback() {
3263 let root = temp_config_root("agent");
3264 write_sample_config(&root);
3265
3266 let fallback = ChatConfig::new()
3267 .with_model(Model::Known(KnownModel::ClaudeHaiku45))
3268 .with_system_prompt("fallback system".to_string())
3269 .with_max_tokens(2048);
3270 let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3271
3272 assert_eq!(agent.id(), "build");
3273 assert_eq!(
3274 agent.config.system_prompt_text(),
3275 Some(expected_build_system_prompt(&root).as_str())
3276 );
3277 assert_eq!(
3278 agent.config.model(),
3279 Model::Known(KnownModel::ClaudeHaiku45)
3280 );
3281 assert_eq!(agent.config.max_tokens(), 2048);
3282
3283 let runtime = tokio::runtime::Runtime::new().unwrap();
3284 let tool_names = runtime
3285 .block_on(agent.tools())
3286 .iter()
3287 .map(|tool| tool.name())
3288 .collect::<Vec<_>>();
3289 assert_eq!(tool_names, vec!["format".to_string(), "shell".to_string()]);
3290 assert!(!agent.requires_confirmation());
3291
3292 fs::remove_dir_all(root.as_str()).unwrap();
3293 }
3294
3295 #[test]
3296 fn from_workspace_with_config_root_loads_config_from_sid_home() {
3297 let config_root = temp_config_root("config-root");
3298 write_sample_config_with_fmt_script(
3299 &config_root,
3300 &capturing_tool_script(
3301 "format via sid_home",
3302 "sid-home-request-capture.json",
3303 "sid-home-env-capture.json",
3304 ),
3305 );
3306 let workspace_root = unique_temp_dir("workspace-root");
3307 fs::create_dir_all(workspace_root.as_str()).unwrap();
3308
3309 let fallback = ChatConfig::new()
3310 .with_system_prompt("fallback system".to_string())
3311 .with_max_tokens(2048);
3312 let agent =
3313 SidAgent::from_workspace_with_config_root(&workspace_root, &config_root, fallback)
3314 .unwrap();
3315 assert_eq!(agent.id(), "build");
3316 assert_eq!(
3317 agent.config.system_prompt_text(),
3318 Some(expected_build_system_prompt(&workspace_root).as_str())
3319 );
3320 assert_eq!(agent.config.max_tokens(), 2048);
3321
3322 let config = Config::load(&config_root).unwrap();
3323 let tool_config = config.tools.get("format").unwrap();
3324 let exposed_name = exposed_tool_name("build", "format").unwrap();
3325 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
3326 let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
3327 let tool_use = ToolUseBlock::new(
3328 "toolu_sid_home_123",
3329 exposed_name,
3330 json!({ "paths": ["src/lib.rs"] }),
3331 );
3332 let runtime = tokio::runtime::Runtime::new().unwrap();
3333 let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
3334 assert_eq!(unwrap_success_text(result), "format via sid_home");
3335
3336 let request: serde_json::Value = serde_json::from_str(
3337 &fs::read_to_string(
3338 workspace_root
3339 .join("sid-home-request-capture.json")
3340 .as_str(),
3341 )
3342 .unwrap(),
3343 )
3344 .unwrap();
3345 assert_eq!(request["workspace"]["root"], json!(workspace_root.as_str()));
3346 assert_eq!(request["workspace"]["cwd"], json!(workspace_root.as_str()));
3347
3348 let env: serde_json::Value = serde_json::from_str(
3349 &fs::read_to_string(workspace_root.join("sid-home-env-capture.json").as_str()).unwrap(),
3350 )
3351 .unwrap();
3352 assert_eq!(env["workspace_root"], json!(workspace_root.as_str()));
3353 assert_eq!(env["rc_d_path"], json!(config_root.join("tools").as_str()));
3354 assert!(
3355 env["rc_conf_path"]
3356 .as_str()
3357 .unwrap()
3358 .starts_with(config_root.join("tools.conf").as_str())
3359 );
3360
3361 fs::remove_dir_all(config_root.as_str()).unwrap();
3362 fs::remove_dir_all(workspace_root.as_str()).unwrap();
3363 }
3364
3365 #[test]
3366 fn from_workspace_with_home_config_root_runs_external_tools() {
3367 if !seatbelt::sandbox_available() {
3368 return;
3369 }
3370
3371 let home = match std::env::var("HOME") {
3372 Ok(home) if !home.is_empty() => home,
3373 _ => return,
3374 };
3375 let config_leaf = std::path::Path::new(unique_temp_dir("home-tool-config").as_str())
3376 .file_name()
3377 .and_then(|name| name.to_str())
3378 .expect("temp dir should have a utf-8 leaf")
3379 .to_string();
3380 let config_root = Path::new(&home)
3381 .join(".sid-isnt-done-tests")
3382 .join(config_leaf)
3383 .into_owned();
3384 write_sample_config_with_fmt_script(
3385 &config_root,
3386 &capturing_tool_script(
3387 "format via home sid_root",
3388 "home-sid-root-request-capture.json",
3389 "home-sid-root-env-capture.json",
3390 ),
3391 );
3392 let workspace_root = unique_temp_dir("workspace-root");
3393 fs::create_dir_all(workspace_root.as_str()).unwrap();
3394
3395 let fallback = ChatConfig::new()
3396 .with_system_prompt("fallback system".to_string())
3397 .with_max_tokens(2048);
3398 let agent =
3399 SidAgent::from_workspace_with_config_root(&workspace_root, &config_root, fallback)
3400 .unwrap();
3401
3402 let config = Config::load(&config_root).unwrap();
3403 let tool_config = config.tools.get("format").unwrap();
3404 let exposed_name = exposed_tool_name("build", "format").unwrap();
3405 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
3406 let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
3407 let tool_use = ToolUseBlock::new(
3408 "toolu_home_sid_root_123",
3409 exposed_name,
3410 json!({ "paths": ["src/lib.rs"] }),
3411 );
3412 let runtime = tokio::runtime::Runtime::new().unwrap();
3413 let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
3414 assert_eq!(unwrap_success_text(result), "format via home sid_root");
3415
3416 fs::remove_dir_all(config_root.as_str()).unwrap();
3417 fs::remove_dir_all(workspace_root.as_str()).unwrap();
3418 }
3419
3420 #[test]
3421 fn from_config_exposes_builtin_bash_and_edit_tools() {
3422 let root = temp_config_root("agent");
3423 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3424
3425 let config = Config::load(&root).unwrap();
3426 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3427
3428 let runtime = tokio::runtime::Runtime::new().unwrap();
3429 let tool_names = runtime
3430 .block_on(agent.tools())
3431 .iter()
3432 .map(|tool| tool.name())
3433 .collect::<Vec<_>>();
3434 assert_eq!(
3435 tool_names,
3436 vec![
3437 "bash".to_string(),
3438 "str_replace_based_edit_tool".to_string(),
3439 ]
3440 );
3441
3442 fs::remove_dir_all(root.as_str()).unwrap();
3443 }
3444
3445 #[test]
3446 fn builtin_tools_without_manifests_use_claudius_union_params() {
3447 let root = temp_config_root("agent");
3448 write_builtin_config_without_manifests(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3449
3450 let config = Config::load(&root).unwrap();
3451 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3452
3453 let runtime = tokio::runtime::Runtime::new().unwrap();
3454 let tools = runtime.block_on(agent.tools());
3455 let params = tools
3456 .iter()
3457 .map(|tool| (tool.name(), tool.to_param()))
3458 .collect::<Vec<_>>();
3459 assert_eq!(
3460 params,
3461 vec![
3462 (
3463 "bash".to_string(),
3464 ToolUnionParam::Bash20250124(ToolBash20250124::new()),
3465 ),
3466 (
3467 "str_replace_based_edit_tool".to_string(),
3468 ToolUnionParam::TextEditor20250728(ToolTextEditor20250728::new()),
3469 ),
3470 ]
3471 );
3472
3473 fs::remove_dir_all(root.as_str()).unwrap();
3474 }
3475
3476 #[test]
3477 fn from_workspace_falls_back_without_config_files() {
3478 let root = unique_temp_dir("agent");
3479 fs::create_dir_all(root.as_str()).unwrap();
3480
3481 let fallback = ChatConfig::new()
3482 .with_system_prompt("fallback system".to_string())
3483 .with_max_tokens(2048);
3484 let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3485
3486 assert_eq!(agent.id(), DEFAULT_AGENT_ID);
3487 assert_eq!(agent.config.system_prompt_text(), Some("fallback system"));
3488 assert_eq!(agent.config.max_tokens(), 2048);
3489 let runtime = tokio::runtime::Runtime::new().unwrap();
3490 assert!(runtime.block_on(agent.tools()).is_empty());
3491 assert!(!agent.requires_confirmation());
3492
3493 fs::remove_dir_all(root.as_str()).unwrap();
3494 }
3495
3496 #[test]
3497 fn from_workspace_compactor_falls_back_to_builtin_prompt() {
3498 let root = unique_temp_dir("compactor");
3499 fs::create_dir_all(root.as_str()).unwrap();
3500
3501 let agent = SidAgent::from_workspace_compactor_with_config_root(
3502 &root,
3503 &root,
3504 ChatConfig::new().with_system_prompt("ignored".to_string()),
3505 )
3506 .unwrap();
3507
3508 assert_eq!(agent.id(), DEFAULT_COMPACTOR_AGENT_ID);
3509 assert_eq!(
3510 agent.config.system_prompt_text(),
3511 Some(DEFAULT_COMPACTOR_SYSTEM_PROMPT)
3512 );
3513 let runtime = tokio::runtime::Runtime::new().unwrap();
3514 assert!(runtime.block_on(agent.tools()).is_empty());
3515
3516 fs::remove_dir_all(root.as_str()).unwrap();
3517 }
3518
3519 #[test]
3520 fn configured_compactor_uses_reserved_agent_even_if_disabled() {
3521 let root = temp_config_root("compact-agent");
3522 fs::create_dir_all(root.join("agents").as_str()).unwrap();
3523 fs::write(
3524 root.join("agents.conf").as_str(),
3525 r#"
3526build_ENABLED="YES"
3527build_TOOLS='bash'
3528compact_ENABLED="NO"
3529compact_TOOLS='bash'
3530"#,
3531 )
3532 .unwrap();
3533 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
3534 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3535 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3536 fs::write(
3537 root.join("agents/compact.md").as_str(),
3538 "# Compact\n\nCustom compact prompt.\n",
3539 )
3540 .unwrap();
3541
3542 let agent = SidAgent::from_workspace_compactor_with_config_root(
3543 &root,
3544 &root,
3545 ChatConfig::new().with_system_prompt("ignored".to_string()),
3546 )
3547 .unwrap();
3548 assert_eq!(agent.id(), DEFAULT_COMPACTOR_AGENT_ID);
3549 assert!(
3550 agent
3551 .config
3552 .system_prompt_text()
3553 .unwrap()
3554 .contains("Custom compact prompt.")
3555 );
3556 let runtime = tokio::runtime::Runtime::new().unwrap();
3557 assert!(runtime.block_on(agent.tools()).is_empty());
3558
3559 fs::remove_dir_all(root.as_str()).unwrap();
3560 }
3561
3562 #[test]
3563 fn compacted_session_exposes_ask_an_expert_tool() {
3564 let root = temp_config_root("compact-memory");
3565 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
3566 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
3567 let parent = SidSession::create_in(sessions_root.clone()).unwrap();
3568 let child = SidSession::create_compacted_in(
3569 sessions_root.clone(),
3570 CompactionProvenance {
3571 session_id: parent.id().to_string(),
3572 session_dir: parent.root().to_string_lossy().into_owned(),
3573 expert: CompactionExpertConfig {
3574 agent_id: Some(DEFAULT_COMPACTOR_AGENT_ID.to_string()),
3575 model: "claude-sonnet-4-5".to_string(),
3576 system_prompt: Some("Summarize carefully.".to_string()),
3577 },
3578 },
3579 )
3580 .unwrap();
3581
3582 let config = Config::load(&root).unwrap();
3583 let agent = SidAgent::from_config(&config, "build", root.clone())
3584 .unwrap()
3585 .with_session(Arc::new(child));
3586
3587 let runtime = tokio::runtime::Runtime::new().unwrap();
3588 let tool_names = runtime
3589 .block_on(agent.tools())
3590 .iter()
3591 .map(|tool| tool.name())
3592 .collect::<Vec<_>>();
3593 assert_eq!(
3594 tool_names,
3595 vec![
3596 "bash".to_string(),
3597 "str_replace_based_edit_tool".to_string(),
3598 ASK_AN_EXPERT_TOOL_NAME.to_string(),
3599 ]
3600 );
3601
3602 fs::remove_dir_all(root.as_str()).unwrap();
3603 fs::remove_dir_all(sessions_root).unwrap();
3604 }
3605
3606 #[test]
3607 fn from_workspace_without_config_appends_agents_md_to_system_prompt() {
3608 let root = unique_temp_dir("agent");
3609 fs::create_dir_all(root.as_str()).unwrap();
3610 fs::write(root.join("AGENTS.md").as_str(), "Use workspace rules.\n").unwrap();
3611
3612 let fallback = ChatConfig::new()
3613 .with_system_prompt("fallback system".to_string())
3614 .with_max_tokens(2048);
3615 let agent = SidAgent::from_workspace(&root, fallback).unwrap();
3616 let prompt = agent.config.system_prompt_text().unwrap();
3617
3618 assert!(prompt.starts_with("fallback system"));
3619 assert!(prompt.contains("# User instructions from AGENTS.md"));
3620 assert!(prompt.contains("Use workspace rules."));
3621
3622 fs::remove_dir_all(root.as_str()).unwrap();
3623 }
3624
3625 #[test]
3626 fn agent_create_request_forwards_chat_config_request_fields() {
3627 let root = unique_temp_dir("agent-request");
3628 fs::create_dir_all(root.as_str()).unwrap();
3629
3630 let mut config = ChatConfig::new();
3631 config.template.metadata = Some(Metadata::with_user_id("opaque-user"));
3632 config.template.tool_choice = Some(ToolChoice::none());
3633 let agent = SidAgent::new(config, root.clone());
3634
3635 let runtime = tokio::runtime::Runtime::new().unwrap();
3636 let req = runtime.block_on(agent.create_request(123, vec![], false));
3637
3638 assert!(req.cache_control.is_some());
3639 assert_eq!(req.metadata, Some(Metadata::with_user_id("opaque-user")));
3640 assert_eq!(req.tool_choice, Some(ToolChoice::none()));
3641
3642 fs::remove_dir_all(root.as_str()).unwrap();
3643 }
3644
3645 #[test]
3646 fn manual_agents_require_confirmation() {
3647 let root = temp_config_root("agent");
3648 write_sample_config(&root);
3649
3650 let config = Config::load(&root).unwrap();
3651 let agent = SidAgent::from_config(&config, "plan", root.clone()).unwrap();
3652
3653 assert_eq!(agent.id(), "plan");
3654 assert!(agent.requires_confirmation());
3655
3656 fs::remove_dir_all(root.as_str()).unwrap();
3657 }
3658
3659 #[test]
3660 fn parse_tool_confirmation_accepts_yes() {
3661 assert_eq!(parse_tool_confirmation("yes"), Some(true));
3662 assert_eq!(parse_tool_confirmation("YES"), Some(true));
3663 assert_eq!(parse_tool_confirmation("y"), Some(true));
3664 assert_eq!(parse_tool_confirmation("Y"), Some(true));
3665 assert_eq!(parse_tool_confirmation(" yes \n"), Some(true));
3666 }
3667
3668 #[test]
3669 fn parse_tool_confirmation_accepts_no() {
3670 assert_eq!(parse_tool_confirmation("no"), Some(false));
3671 assert_eq!(parse_tool_confirmation("NO"), Some(false));
3672 assert_eq!(parse_tool_confirmation("n"), Some(false));
3673 assert_eq!(parse_tool_confirmation("N"), Some(false));
3674 assert_eq!(parse_tool_confirmation(" no \n"), Some(false));
3675 }
3676
3677 #[test]
3678 fn parse_tool_confirmation_rejects_other() {
3679 assert_eq!(parse_tool_confirmation(""), None);
3680 assert_eq!(parse_tool_confirmation("maybe"), None);
3681 assert_eq!(parse_tool_confirmation("yep"), None);
3682 assert_eq!(parse_tool_confirmation("nope"), None);
3683 }
3684
3685 #[test]
3686 fn manual_tool_confirmation_reads_from_renderer() {
3687 let input = json!({ "command": "pwd" });
3688 let mut renderer = ScriptedRenderer::new([
3689 OperatorLine::Line("maybe".to_string()),
3690 OperatorLine::Line("yes".to_string()),
3691 ]);
3692
3693 assert_eq!(
3694 confirm_manual_tool_call("bash", &input, None, Some(&mut renderer)).unwrap(),
3695 ManualToolConfirmation::Allow
3696 );
3697 assert_eq!(renderer.prompts.len(), 2);
3698 assert!(renderer.prompts[0].contains("Tool 'bash' is MANUAL."));
3699 }
3700
3701 #[test]
3702 fn manual_tool_confirmation_interrupted_renderer_cancels() {
3703 let input = json!({ "command": "pwd" });
3704 let mut renderer = ScriptedRenderer::new([OperatorLine::Interrupted]);
3705
3706 assert_eq!(
3707 confirm_manual_tool_call("bash", &input, None, Some(&mut renderer)).unwrap(),
3708 ManualToolConfirmation::Cancel
3709 );
3710 assert_eq!(renderer.prompts.len(), 1);
3711 }
3712
3713 #[test]
3714 fn interrupted_manual_tool_cancels_remaining_tool_calls() {
3715 let root = temp_config_root("agent");
3716 fs::create_dir_all(root.join("agents").as_str()).unwrap();
3717 fs::write(
3718 root.join("agents.conf").as_str(),
3719 "build_ENABLED=YES\nbuild_TOOLS='bash edit'\n",
3720 )
3721 .unwrap();
3722 fs::write(
3723 root.join("tools.conf").as_str(),
3724 "bash_ENABLED=MANUAL\nedit_ENABLED=MANUAL\n",
3725 )
3726 .unwrap();
3727 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3728 write_tool_runtime(&root, "edit", "#!/bin/sh\nexit 0\n");
3729 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3730
3731 let config = Config::load(&root).unwrap();
3732 let mut agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3733 let client = Anthropic::new(Some("test-api-key".to_string())).unwrap();
3734 let response = Message::new(
3735 "msg_test".to_string(),
3736 vec![
3737 ToolUseBlock::new(
3738 "toolu_bash",
3739 "bash",
3740 json!({
3741 "command": "pwd"
3742 }),
3743 )
3744 .into(),
3745 ToolUseBlock::new(
3746 "toolu_edit",
3747 "str_replace_based_edit_tool",
3748 json!({
3749 "command": "view",
3750 "path": "/src/lib.rs"
3751 }),
3752 )
3753 .into(),
3754 ],
3755 Model::Known(KnownModel::ClaudeHaiku45),
3756 Usage::new(0, 0),
3757 )
3758 .with_stop_reason(StopReason::ToolUse);
3759 let runtime = tokio::runtime::Runtime::new().unwrap();
3760 let mut renderer = ScriptedRenderer::new([OperatorLine::Interrupted]);
3761 let context = AgentStreamContext::root("build");
3762
3763 let result = runtime.block_on(agent.handle_tool_use_streaming(
3764 &client,
3765 &response,
3766 &mut renderer,
3767 &context,
3768 ));
3769 let ControlFlow::Continue(blocks) = result else {
3770 panic!("expected tool result blocks, got {result:?}");
3771 };
3772
3773 assert_eq!(renderer.prompts.len(), 1);
3774 assert_eq!(blocks.len(), 2);
3775 let first = blocks[0].as_tool_result().unwrap().clone();
3776 let second = blocks[1].as_tool_result().unwrap().clone();
3777 assert_eq!(first.tool_use_id, "toolu_bash");
3778 assert_eq!(second.tool_use_id, "toolu_edit");
3779 assert_eq!(first.is_error, Some(true));
3780 assert_eq!(second.is_error, Some(true));
3781 assert_eq!(tool_block_text(first), USER_CANCELLED_ACTION);
3782 assert_eq!(tool_block_text(second), USER_CANCELLED_ACTION);
3783
3784 fs::remove_dir_all(root.as_str()).unwrap();
3785 }
3786
3787 #[test]
3788 fn top_up_cancelled_tool_results_appends_after_partial_assistant_tool_use() {
3789 let mut messages = vec![
3790 MessageParam::user("Do the work."),
3791 MessageParam::new(
3792 MessageParamContent::Array(vec![
3793 ContentBlock::Text(TextBlock::new("I will check.".to_string())),
3794 ToolUseBlock::new("toolu_partial", "bash", json!({"command": "pwd"})).into(),
3795 ]),
3796 MessageRole::Assistant,
3797 ),
3798 ];
3799
3800 assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
3801
3802 assert_eq!(messages.len(), 3);
3803 assert_eq!(messages[2].role, MessageRole::User);
3804 let MessageParamContent::Array(blocks) = &messages[2].content else {
3805 panic!("expected synthesized tool result blocks");
3806 };
3807 assert_eq!(blocks.len(), 1);
3808 let result = blocks[0].as_tool_result().unwrap().clone();
3809 assert_eq!(result.tool_use_id, "toolu_partial");
3810 assert_eq!(result.is_error, Some(true));
3811 assert_eq!(tool_block_text(result), USER_CANCELLED_ACTION);
3812 }
3813
3814 #[test]
3815 fn top_up_cancelled_tool_results_inserts_before_stale_user_message() {
3816 let mut messages = vec![
3817 MessageParam::new(
3818 MessageParamContent::Array(vec![
3819 ToolUseBlock::new("toolu_stale", "bash", json!({"command": "pwd"})).into(),
3820 ]),
3821 MessageRole::Assistant,
3822 ),
3823 MessageParam::user("Try again."),
3824 ];
3825
3826 assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
3827
3828 assert_eq!(messages.len(), 3);
3829 assert_eq!(messages[1].role, MessageRole::User);
3830 assert_eq!(messages[2], MessageParam::user("Try again."));
3831 let MessageParamContent::Array(blocks) = &messages[1].content else {
3832 panic!("expected synthesized tool result blocks");
3833 };
3834 let result = blocks[0].as_tool_result().unwrap().clone();
3835 assert_eq!(result.tool_use_id, "toolu_stale");
3836 assert_eq!(result.is_error, Some(true));
3837 assert_eq!(tool_block_text(result), USER_CANCELLED_ACTION);
3838 }
3839
3840 #[test]
3841 fn top_up_cancelled_tool_results_preserves_existing_results_and_cancels_missing() {
3842 let existing =
3843 ToolResultBlock::new("toolu_done".to_string()).with_string_content("done".to_string());
3844 let mut messages = vec![
3845 MessageParam::new(
3846 MessageParamContent::Array(vec![
3847 ToolUseBlock::new("toolu_missing", "bash", json!({"command": "pwd"})).into(),
3848 ToolUseBlock::new("toolu_done", "bash", json!({"command": "date"})).into(),
3849 ]),
3850 MessageRole::Assistant,
3851 ),
3852 MessageParam::new(
3853 MessageParamContent::Array(vec![
3854 ContentBlock::Text(TextBlock::new("next request".to_string())),
3855 existing.into(),
3856 ]),
3857 MessageRole::User,
3858 ),
3859 ];
3860
3861 assert_eq!(top_up_cancelled_tool_results(&mut messages), 1);
3862
3863 assert_eq!(messages.len(), 2);
3864 let MessageParamContent::Array(blocks) = &messages[1].content else {
3865 panic!("expected reordered user content blocks");
3866 };
3867 assert_eq!(blocks.len(), 3);
3868 let first = blocks[0].as_tool_result().unwrap().clone();
3869 let second = blocks[1].as_tool_result().unwrap().clone();
3870 assert_eq!(first.tool_use_id, "toolu_missing");
3871 assert_eq!(first.is_error, Some(true));
3872 assert_eq!(tool_block_text(first), USER_CANCELLED_ACTION);
3873 assert_eq!(second.tool_use_id, "toolu_done");
3874 assert_eq!(tool_block_text(second), "done");
3875 assert_eq!(
3876 blocks[2].as_text().map(|block| block.text.as_str()),
3877 Some("next request")
3878 );
3879 }
3880
3881 #[test]
3882 fn token_usage_totals_accumulate_input_cached_input_and_output() {
3883 let mut totals = TokenUsageTotals::default();
3884 totals.add(Usage::new(10, 4).with_cache_read_input_tokens(6));
3885 totals.add(Usage::new(20, 8).with_cache_read_input_tokens(7));
3886
3887 assert_eq!(
3888 totals,
3889 TokenUsageTotals {
3890 input: 30,
3891 cached_input: 13,
3892 output: 12,
3893 }
3894 );
3895 }
3896
3897 #[test]
3898 fn token_usage_totals_treat_negative_counts_as_zero() {
3899 let mut totals = TokenUsageTotals::default();
3900 totals.add(Usage::new(-10, -4).with_cache_read_input_tokens(-6));
3901
3902 assert_eq!(totals, TokenUsageTotals::default());
3903 }
3904
3905 #[test]
3906 fn agents_md_instructions_are_appended_to_system_prompt() {
3907 let root = temp_config_root("agent");
3908 fs::create_dir_all(root.join("agents").as_str()).unwrap();
3909 fs::write(
3910 root.join("agents.conf").as_str(),
3911 "build_ENABLED=YES\nbuild_TOOLS='bash'\n",
3912 )
3913 .unwrap();
3914 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
3915 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3916 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3917 fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
3918
3919 let config = Config::load(&root).unwrap();
3920 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3921 let mut messages = vec![MessageParam::user("Do the work.")];
3922 let runtime = tokio::runtime::Runtime::new().unwrap();
3923 runtime
3924 .block_on(agent.inject_user_instructions_for_turn(&mut messages))
3925 .unwrap();
3926
3927 assert_eq!(messages[0], MessageParam::user("Do the work."));
3928 let prompt = agent.config.system_prompt_text().unwrap();
3929 assert!(prompt.contains("# User instructions from AGENTS.md"));
3930 assert!(prompt.contains("Use local rules."));
3931
3932 fs::remove_dir_all(root.as_str()).unwrap();
3933 }
3934
3935 #[test]
3936 fn agents_md_path_concatenates_existing_files_in_order() {
3937 let root = temp_config_root("agent");
3938 fs::create_dir_all(root.join("agents").as_str()).unwrap();
3939 fs::create_dir_all(root.join("local").as_str()).unwrap();
3940 fs::write(
3941 root.join("agents.conf").as_str(),
3942 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_AGENTS_MD_PATH='global.md:missing.md:local/AGENTS.md'\n",
3943 )
3944 .unwrap();
3945 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
3946 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3947 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3948 fs::write(root.join("global.md").as_str(), "Global rules.\n").unwrap();
3949 fs::write(root.join("local/AGENTS.md").as_str(), "Local rules.\n").unwrap();
3950
3951 let config = Config::load(&root).unwrap();
3952 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3953 let prompt = agent.config.system_prompt_text().unwrap();
3954 let global = prompt.find("Global rules.").unwrap();
3955 let local = prompt.find("Local rules.").unwrap();
3956 assert!(global < local);
3957 assert!(!prompt.contains("missing.md"));
3958
3959 fs::remove_dir_all(root.as_str()).unwrap();
3960 }
3961
3962 #[test]
3963 fn agents_md_injection_can_be_disabled() {
3964 let root = temp_config_root("agent");
3965 fs::create_dir_all(root.join("agents").as_str()).unwrap();
3966 fs::write(
3967 root.join("agents.conf").as_str(),
3968 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_AGENTS_MD=NO\n",
3969 )
3970 .unwrap();
3971 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
3972 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
3973 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
3974 fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
3975
3976 let config = Config::load(&root).unwrap();
3977 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
3978 let mut messages = vec![MessageParam::user("Do the work.")];
3979 let runtime = tokio::runtime::Runtime::new().unwrap();
3980 runtime
3981 .block_on(agent.inject_user_instructions_for_turn(&mut messages))
3982 .unwrap();
3983
3984 assert_eq!(messages[0], MessageParam::user("Do the work."));
3985 assert!(
3986 !agent
3987 .config
3988 .system_prompt_text()
3989 .unwrap()
3990 .contains("Use local rules.")
3991 );
3992
3993 fs::remove_dir_all(root.as_str()).unwrap();
3994 }
3995
3996 #[test]
3997 fn user_instruction_hook_runs_with_rc_overlay() {
3998 let root = temp_config_root("agent");
3999 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4000 fs::write(
4001 root.join("agents.conf").as_str(),
4002 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_USER_INSTRUCTIONS_HOOK=context\n",
4003 )
4004 .unwrap();
4005 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4006 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4007 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4008 fs::write(root.join("AGENTS.md").as_str(), "Use local rules.\n").unwrap();
4009 write_agent_hook(
4010 &root,
4011 "context",
4012 r#"#!/bin/sh
4013set -eu
4014PREFIX=${RCVAR_ARGV0:?missing RCVAR_ARGV0}
4015case "${1:-}" in
4016rcvar)
4017 printf '%s\n' \
4018 "${PREFIX}_WORKSPACE_ROOT" \
4019 "${PREFIX}_CONFIG_ROOT" \
4020 "${PREFIX}_AGENT_ID" \
4021 "${PREFIX}_HOOK_NAME" \
4022 "${PREFIX}_AGENTS_MD_PATH" \
4023 "${PREFIX}_SCRATCH_DIR" \
4024 "${PREFIX}_TEMP_DIR" \
4025 "${PREFIX}_TMPDIR" \
4026 "${PREFIX}_RC_CONF_PATH" \
4027 "${PREFIX}_RC_D_PATH"
4028 ;;
4029run)
4030 printf 'workspace=%s\n' "$(printenv "${PREFIX}_WORKSPACE_ROOT")"
4031 printf 'config=%s\n' "$(printenv "${PREFIX}_CONFIG_ROOT")"
4032 printf 'agent=%s\n' "$(printenv "${PREFIX}_AGENT_ID")"
4033 printf 'hook=%s\n' "$(printenv "${PREFIX}_HOOK_NAME")"
4034 printf 'agents_md=%s\n' "$(printenv "${PREFIX}_AGENTS_MD_PATH")"
4035 printf 'scratch=%s\n' "$(printenv "${PREFIX}_SCRATCH_DIR")"
4036 printf 'temp=%s\n' "$(printenv "${PREFIX}_TEMP_DIR")"
4037 printf 'tmpdir=%s\n' "$(printenv "${PREFIX}_TMPDIR")"
4038 printf 'rc=%s\n' "$(printenv "${PREFIX}_RC_CONF_PATH")"
4039 printf 'rcd=%s\n' "$(printenv "${PREFIX}_RC_D_PATH")"
4040 ;;
4041*)
4042 exit 129
4043 ;;
4044esac
4045"#,
4046 );
4047
4048 let config = Config::load(&root).unwrap();
4049 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4050 let mut messages = vec![MessageParam::user("Do the work.")];
4051 let runtime = tokio::runtime::Runtime::new().unwrap();
4052 runtime
4053 .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4054 .unwrap();
4055 let injected = injected_user_instruction_text(&messages[0]);
4056
4057 assert!(injected.contains("# User instructions from hook context"));
4058 assert!(!injected.contains("Use local rules."));
4059 assert!(injected.contains(&format!("workspace={}", root.as_str())));
4060 assert!(injected.contains(&format!("config={}", root.as_str())));
4061 assert!(injected.contains("agent=build"));
4062 assert!(injected.contains("hook=context"));
4063 assert!(injected.contains(&format!("agents_md={}", root.join("AGENTS.md").as_str())));
4064 assert!(injected.contains("scratch=/"));
4065 assert!(injected.contains("temp=/"));
4066 assert!(injected.contains("tmpdir=/"));
4067 assert!(injected.contains(root.join("agents.conf").as_str()));
4068 assert!(injected.contains(&format!("rcd={}", root.join("agents").as_str())));
4069 assert!(
4070 agent
4071 .config
4072 .system_prompt_text()
4073 .unwrap()
4074 .contains("Use local rules.")
4075 );
4076
4077 fs::remove_dir_all(root.as_str()).unwrap();
4078 }
4079
4080 #[test]
4081 fn user_instruction_hook_runs_from_home_config_root() {
4082 if !seatbelt::sandbox_available() {
4083 return;
4084 }
4085
4086 let home = match std::env::var("HOME") {
4087 Ok(home) if !home.is_empty() => home,
4088 _ => return,
4089 };
4090 let config_leaf = std::path::Path::new(unique_temp_dir("home-config-root").as_str())
4091 .file_name()
4092 .and_then(|name| name.to_str())
4093 .expect("temp dir should have a utf-8 leaf")
4094 .to_string();
4095 let config_root = Path::new(&home)
4096 .join(".sid-isnt-done-tests")
4097 .join(config_leaf)
4098 .into_owned();
4099 let workspace_root = unique_temp_dir("workspace-root");
4100
4101 fs::create_dir_all(config_root.join("agents").as_str()).unwrap();
4102 fs::write(
4103 config_root.join("agents.conf").as_str(),
4104 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_USER_INSTRUCTIONS_HOOK=context\n",
4105 )
4106 .unwrap();
4107 fs::write(
4108 config_root.join("tools.conf").as_str(),
4109 "bash_ENABLED=YES\n",
4110 )
4111 .unwrap();
4112 write_tool_runtime(&config_root, "bash", "#!/bin/sh\nexit 0\n");
4113 fs::write(config_root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4114 fs::write(
4115 config_root.join("agents/context").as_str(),
4116 "#!/bin/sh\nexec \"$(dirname \"$0\")/context.impl\" \"$@\"\n",
4117 )
4118 .unwrap();
4119 make_executable(&config_root.join("agents/context"));
4120 fs::write(
4121 config_root.join("agents/context.impl").as_str(),
4122 r#"#!/bin/sh
4123case "${1:-}" in
4124rcvar)
4125 ;;
4126run)
4127 printf 'home hook ok\n'
4128 ;;
4129*)
4130 exit 129
4131 ;;
4132esac
4133"#,
4134 )
4135 .unwrap();
4136 make_executable(&config_root.join("agents/context.impl"));
4137 fs::create_dir_all(workspace_root.as_str()).unwrap();
4138
4139 let config = Config::load(&config_root).unwrap();
4140 let agent = SidAgent::from_config(&config, "build", workspace_root.clone()).unwrap();
4141 let mut messages = vec![MessageParam::user("Do the work.")];
4142 let runtime = tokio::runtime::Runtime::new().unwrap();
4143 runtime
4144 .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4145 .unwrap();
4146
4147 let injected = injected_user_instruction_text(&messages[0]);
4148 assert!(injected.contains("# User instructions from hook context"));
4149 assert!(injected.contains("home hook ok"));
4150
4151 fs::remove_dir_all(config_root.as_str()).unwrap();
4152 fs::remove_dir_all(workspace_root.as_str()).unwrap();
4153 }
4154
4155 #[test]
4156 fn user_instruction_hook_receives_user_message_and_skill_manifest() {
4157 let root = temp_config_root("agent");
4158 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4159 fs::write(
4160 root.join("agents.conf").as_str(),
4161 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python PATH'\nbuild_USER_INSTRUCTIONS_HOOK='context'\n",
4162 )
4163 .unwrap();
4164 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4165 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4166 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4167 write_agent_hook(
4168 &root,
4169 "context",
4170 r#"#!/bin/sh
4171set -eu
4172PREFIX=${RCVAR_ARGV0:?missing RCVAR_ARGV0}
4173case "${1:-}" in
4174rcvar)
4175 printf '%s\n' \
4176 "${PREFIX}_USER_MESSAGE_FILE" \
4177 "${PREFIX}_SKILLS_MANIFEST_FILE"
4178 ;;
4179run)
4180 USER_MESSAGE_FILE=$(printenv "${PREFIX}_USER_MESSAGE_FILE")
4181 SKILLS_MANIFEST_FILE=$(printenv "${PREFIX}_SKILLS_MANIFEST_FILE")
4182 printf 'message=%s\n' "$(cat "$USER_MESSAGE_FILE")"
4183 printf 'manifest='
4184 cat "$SKILLS_MANIFEST_FILE"
4185 ;;
4186*)
4187 exit 129
4188 ;;
4189esac
4190"#,
4191 );
4192 write_skill(&root, "rust", "# Rust Skill\n\nUse Rust idioms.\n");
4193 write_skill(&root, "python", "# Python Skill\n\nUse Python idioms.\n");
4194 write_skill(&root, "PATH", "# Path Skill\n\nShould not be injected.\n");
4195
4196 let config = Config::load(&root).unwrap();
4197 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4198 let original = "Use $rust and $rust; ignore $python-extra and $PATH.";
4199 let mut messages = vec![MessageParam::user(original)];
4200 let runtime = tokio::runtime::Runtime::new().unwrap();
4201 runtime
4202 .block_on(agent.inject_user_instructions_for_turn(&mut messages))
4203 .unwrap();
4204
4205 let injected = injected_user_instruction_text(&messages[0]);
4206 assert!(injected.contains("message=Use $rust and $rust; ignore $python-extra and $PATH."));
4207 assert!(injected.contains("rust\t/skills/rust/SKILL.md\t"));
4208 assert!(injected.contains("python\t/skills/python/SKILL.md\t"));
4209 assert!(!injected.contains("PATH\t/skills/PATH/SKILL.md\t"));
4210
4211 fs::remove_dir_all(root.as_str()).unwrap();
4212 }
4213
4214 #[test]
4215 fn manual_external_tool_stores_enabled_state() {
4216 let root = temp_config_root("agent");
4217 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4218 fs::write(
4219 root.join("agents.conf").as_str(),
4220 "build_ENABLED=YES\nbuild_TOOLS='fmt'\n",
4221 )
4222 .unwrap();
4223 fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=MANUAL\n").unwrap();
4224 write_tool_contract(&root, "fmt", "Format files.", "#!/bin/sh\nexit 0\n");
4225 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4226
4227 let config = Config::load(&root).unwrap();
4228 let tool_config = config.tools.get("fmt").unwrap();
4229 assert_eq!(tool_config.enabled, SwitchPosition::Manual);
4230
4231 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "fmt").unwrap();
4232 let tool = ExternalTool::from_config("fmt".to_string(), canonical_id, tool_config);
4233 assert_eq!(tool.enabled, SwitchPosition::Manual);
4234
4235 fs::remove_dir_all(root.as_str()).unwrap();
4236 }
4237
4238 #[test]
4239 fn manual_builtin_binding_stores_enabled_state() {
4240 let root = temp_config_root("agent");
4241 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4242 fs::write(
4243 root.join("agents.conf").as_str(),
4244 "build_ENABLED=YES\nbuild_TOOLS='bash edit'\n",
4245 )
4246 .unwrap();
4247 fs::write(
4248 root.join("tools.conf").as_str(),
4249 "bash_ENABLED=MANUAL\nedit_ENABLED=MANUAL\n",
4250 )
4251 .unwrap();
4252 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4253 write_tool_runtime(&root, "edit", "#!/bin/sh\nexit 0\n");
4254 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4255
4256 let config = Config::load(&root).unwrap();
4257 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4258 assert_eq!(
4259 agent.builtin_bindings.bash.as_ref().unwrap().enabled,
4260 SwitchPosition::Manual,
4261 );
4262 assert_eq!(
4263 agent.builtin_bindings.edit.as_ref().unwrap().enabled,
4264 SwitchPosition::Manual,
4265 );
4266
4267 fs::remove_dir_all(root.as_str()).unwrap();
4268 }
4269
4270 #[test]
4271 fn from_config_mounts_skills_as_read_only_files() {
4272 let root = temp_config_root("agent");
4273 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4274 fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4275 fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
4276
4277 fs::write(
4278 root.join("agents.conf").as_str(),
4279 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python'\n",
4280 )
4281 .unwrap();
4282 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4283 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4284 fs::write(
4285 root.join("agents/build.md").as_str(),
4286 "# Build\n\nYou are a builder.\n",
4287 )
4288 .unwrap();
4289 fs::write(
4290 root.join("skills/rust/SKILL.md").as_str(),
4291 "# Rust Skill\n\nWrite safe Rust.\n",
4292 )
4293 .unwrap();
4294 fs::write(
4295 root.join("skills/python/SKILL.md").as_str(),
4296 "# Python Skill\n\nWrite clean Python.\n",
4297 )
4298 .unwrap();
4299
4300 let config = Config::load(&root).unwrap();
4301 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4302
4303 let runtime = tokio::runtime::Runtime::new().unwrap();
4304 let rust_content = runtime
4305 .block_on(agent.filesystem.view("/skills/rust/SKILL.md", None))
4306 .unwrap();
4307 assert!(
4308 rust_content.contains("Write safe Rust."),
4309 "skill content should be viewable: {rust_content}"
4310 );
4311 let python_content = runtime
4312 .block_on(agent.filesystem.view("/skills/python/SKILL.md", None))
4313 .unwrap();
4314 assert!(
4315 python_content.contains("Write clean Python."),
4316 "skill content should be viewable: {python_content}"
4317 );
4318
4319 let replace_err = runtime
4320 .block_on(agent.filesystem.str_replace(
4321 "/skills/rust/SKILL.md",
4322 "Write safe Rust.",
4323 "Replaced.",
4324 ))
4325 .unwrap_err();
4326 assert_eq!(
4327 replace_err.kind(),
4328 std::io::ErrorKind::PermissionDenied,
4329 "skill files should be read-only: {replace_err}"
4330 );
4331
4332 fs::remove_dir_all(root.as_str()).unwrap();
4333 }
4334
4335 #[test]
4336 fn from_config_mounts_all_skills_with_wildcard() {
4337 let root = temp_config_root("agent");
4338 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4339 fs::create_dir_all(root.join("skills/docs").as_str()).unwrap();
4340
4341 fs::write(
4342 root.join("agents.conf").as_str(),
4343 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='*'\n",
4344 )
4345 .unwrap();
4346 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4347 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4348 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4349 fs::write(root.join("skills/docs/SKILL.md").as_str(), "# Docs Skill\n").unwrap();
4350
4351 let config = Config::load(&root).unwrap();
4352 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4353
4354 let runtime = tokio::runtime::Runtime::new().unwrap();
4355 let docs_content = runtime
4356 .block_on(agent.filesystem.view("/skills/docs/SKILL.md", None))
4357 .unwrap();
4358 assert!(
4359 docs_content.contains("# Docs Skill"),
4360 "wildcard should mount all skills: {docs_content}"
4361 );
4362
4363 fs::remove_dir_all(root.as_str()).unwrap();
4364 }
4365
4366 #[test]
4367 fn from_config_no_skills_mounts_workspace_only() {
4368 let root = temp_config_root("agent");
4369 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4370
4371 fs::write(
4372 root.join("agents.conf").as_str(),
4373 "build_ENABLED=YES\nbuild_TOOLS='bash'\n",
4374 )
4375 .unwrap();
4376 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4377 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4378 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4379
4380 let config = Config::load(&root).unwrap();
4381 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4382
4383 let runtime = tokio::runtime::Runtime::new().unwrap();
4384 let content = runtime
4385 .block_on(agent.filesystem.view("/agents/build.md", None))
4386 .unwrap();
4387 assert!(
4388 content.contains("# Build"),
4389 "workspace files should be viewable: {content}"
4390 );
4391
4392 fs::remove_dir_all(root.as_str()).unwrap();
4393 }
4394
4395 #[test]
4396 fn from_config_rejects_unknown_skill_references() {
4397 let root = temp_config_root("agent");
4398 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4399 fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4400
4401 fs::write(
4402 root.join("agents.conf").as_str(),
4403 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust missing'\n",
4404 )
4405 .unwrap();
4406 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4407 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4408 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4409 fs::write(root.join("skills/rust/SKILL.md").as_str(), "# Rust Skill\n").unwrap();
4410
4411 let config = Config::load(&root).unwrap();
4412 let err = SidAgent::from_config(&config, "build", root.clone())
4413 .err()
4414 .expect("unknown skills should fail")
4415 .to_string();
4416 assert!(err.contains("unknown_skill"), "error: {err}");
4417 assert!(err.contains("missing"), "error: {err}");
4418
4419 fs::remove_dir_all(root.as_str()).unwrap();
4420 }
4421
4422 #[test]
4423 fn explicit_default_agent_is_preferred() {
4424 let root = temp_config_root("agent");
4425 write_sample_config(&root);
4426 fs::write(
4427 root.join("agents.conf").as_str(),
4428 concat!(
4429 "DEFAULT_AGENT=plan\n",
4430 "ROLE='principal engineer'\n",
4431 "build_ENABLED=\"YES\"\n",
4432 "plan_ENABLED=\"MANUAL\"\n",
4433 "evil_ENABLED=\"NO\"\n",
4434 "build_TOOLS='format shell'\n",
4435 "plan_MODEL=claude-sonnet-4-5\n",
4436 "plan_SYSTEM=\"You are ${ROLE}\"\n",
4437 "plan_MAX_TOKENS=8192\n",
4438 ),
4439 )
4440 .unwrap();
4441
4442 let config = Config::load(&root).unwrap();
4443 let agent_id = default_agent_id(&config).unwrap();
4444 assert_eq!(agent_id, "plan");
4445
4446 fs::remove_dir_all(root.as_str()).unwrap();
4447 }
4448
4449 #[test]
4450 fn invalid_default_agent_is_rejected_at_config_load() {
4451 let root = temp_config_root("agent");
4452 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4453 fs::write(
4454 root.join("agents.conf").as_str(),
4455 "DEFAULT_AGENT=nonexistent\nbuild_ENABLED=YES\n",
4456 )
4457 .unwrap();
4458 fs::write(root.join("tools.conf").as_str(), "").unwrap();
4459 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4460
4461 let err = Config::load(&root)
4462 .expect_err("invalid DEFAULT_AGENT should fail")
4463 .to_string();
4464 assert!(err.contains("invalid_default_agent"), "error: {err}");
4465 assert!(err.contains("nonexistent"), "error: {err}");
4466
4467 fs::remove_dir_all(root.as_str()).unwrap();
4468 }
4469
4470 #[test]
4471 fn skills_are_advertised_in_system_prompt() {
4472 let root = temp_config_root("agent");
4473 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4474 fs::create_dir_all(root.join("skills/rust").as_str()).unwrap();
4475 fs::create_dir_all(root.join("skills/python").as_str()).unwrap();
4476
4477 fs::write(
4478 root.join("agents.conf").as_str(),
4479 "build_ENABLED=YES\nbuild_TOOLS='bash'\nbuild_SKILLS='rust python'\n",
4480 )
4481 .unwrap();
4482 fs::write(root.join("tools.conf").as_str(), "bash_ENABLED=YES\n").unwrap();
4483 write_tool_runtime(&root, "bash", "#!/bin/sh\nexit 0\n");
4484 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4485 fs::write(root.join("skills/rust/SKILL.md").as_str(), "# Rust Skill\n").unwrap();
4486 fs::write(
4487 root.join("skills/python/SKILL.md").as_str(),
4488 "# Python Skill\n",
4489 )
4490 .unwrap();
4491
4492 let config = Config::load(&root).unwrap();
4493 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4494
4495 let prompt = agent.config.system_prompt_text().unwrap();
4496 assert!(
4497 prompt.contains("/skills/rust/SKILL.md"),
4498 "system prompt should list rust skill: {prompt}"
4499 );
4500 assert!(
4501 prompt.contains("/skills/python/SKILL.md"),
4502 "system prompt should list python skill: {prompt}"
4503 );
4504
4505 fs::remove_dir_all(root.as_str()).unwrap();
4506 }
4507
4508 #[test]
4509 fn from_config_rejects_unknown_tools() {
4510 let root = unique_temp_dir("agent");
4511 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4512 fs::write(
4513 root.join("agents.conf").as_str(),
4514 "build_ENABLED=YES\nbuild_TOOLS='missing'\n",
4515 )
4516 .unwrap();
4517 fs::write(root.join("tools.conf").as_str(), "fmt_ENABLED=YES\n").unwrap();
4518 write_tool_contract(
4519 &root,
4520 "fmt",
4521 "Format files in the workspace.",
4522 "#!/bin/sh\nexit 0\n",
4523 );
4524 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4525
4526 let config = Config::load(&root).unwrap();
4527 let err = SidAgent::from_config(&config, "build", root.clone())
4528 .err()
4529 .expect("unknown tools should fail")
4530 .to_string();
4531 assert!(err.contains("unknown_tool"));
4532 assert!(err.contains("missing"));
4533
4534 fs::remove_dir_all(root.as_str()).unwrap();
4535 }
4536
4537 #[test]
4538 fn from_config_rejects_invalid_tool_names() {
4539 let root = unique_temp_dir("agent");
4540 let invalid_name = "a".repeat(65);
4541 fs::create_dir_all(root.join("agents").as_str()).unwrap();
4542 fs::write(
4543 root.join("agents.conf").as_str(),
4544 format!("build_ENABLED=YES\nbuild_TOOLS='{invalid_name}'\n"),
4545 )
4546 .unwrap();
4547 fs::write(
4548 root.join("tools.conf").as_str(),
4549 format!("fmt_ENABLED=YES\n{invalid_name}_ALIASES=fmt\n"),
4550 )
4551 .unwrap();
4552 write_tool_contract(
4553 &root,
4554 "fmt",
4555 "Format files in the workspace.",
4556 "#!/bin/sh\nexit 0\n",
4557 );
4558 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
4559
4560 let config = Config::load(&root).unwrap();
4561 let err = SidAgent::from_config(&config, "build", root.clone())
4562 .err()
4563 .expect("invalid tool names should fail")
4564 .to_string();
4565 assert!(err.contains("invalid_tool_name"));
4566 assert!(err.contains(&invalid_name));
4567
4568 fs::remove_dir_all(root.as_str()).unwrap();
4569 }
4570
4571 #[test]
4572 fn builtin_bash_persists_shell_state_across_calls() {
4573 let root = temp_config_root("agent");
4574 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4575 let canonical_agents = Path::try_from(
4576 fs::canonicalize(root.join("agents").as_str())
4577 .expect("canonicalize agents directory should succeed"),
4578 )
4579 .expect("canonical agents path should be valid UTF-8")
4580 .into_owned();
4581
4582 let config = Config::load(&root).unwrap();
4583 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4584 let runtime = tokio::runtime::Runtime::new().unwrap();
4585 runtime
4586 .block_on(agent.bash("export FOO=bar\nf() { printf hi; }\ncd agents", true))
4587 .unwrap();
4588 let result = runtime
4589 .block_on(agent.bash("printf '%s:%s:%s' \"$FOO\" \"$(f)\" \"$PWD\"", false))
4590 .unwrap();
4591 assert_eq!(
4592 result.trim_end(),
4593 format!("bar:hi:{}", canonical_agents.as_str()),
4594 );
4595
4596 fs::remove_dir_all(root.as_str()).unwrap();
4597 }
4598
4599 #[test]
4600 fn builtin_bash_restart_resets_shell_state() {
4601 let root = temp_config_root("agent");
4602 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4603 let canonical_root = Path::try_from(
4604 fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4605 )
4606 .expect("canonical root path should be valid UTF-8")
4607 .into_owned();
4608 let canonical_agents = Path::try_from(
4609 fs::canonicalize(root.join("agents").as_str())
4610 .expect("canonicalize agents directory should succeed"),
4611 )
4612 .expect("canonical agents path should be valid UTF-8")
4613 .into_owned();
4614
4615 let config = Config::load(&root).unwrap();
4616 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4617 let runtime = tokio::runtime::Runtime::new().unwrap();
4618 runtime
4619 .block_on(agent.bash("export FOO=bar\ncd agents", true))
4620 .unwrap();
4621 let persisted = runtime
4622 .block_on(agent.bash("printf '%s:%s' \"$FOO\" \"$PWD\"", false))
4623 .unwrap();
4624 assert_eq!(
4625 persisted.trim_end(),
4626 format!("bar:{}", canonical_agents.as_str()),
4627 );
4628
4629 let restarted = runtime
4630 .block_on(agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", true))
4631 .unwrap();
4632 assert_eq!(
4633 restarted.trim_end(),
4634 format!("unset:{}", canonical_root.as_str())
4635 );
4636
4637 fs::remove_dir_all(root.as_str()).unwrap();
4638 }
4639
4640 #[test]
4641 fn builtin_bash_tool_restart_resets_shell_state() {
4642 let root = temp_config_root("agent");
4643 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4644 let canonical_root = Path::try_from(
4645 fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4646 )
4647 .expect("canonical root path should be valid UTF-8")
4648 .into_owned();
4649 let canonical_agents = Path::try_from(
4650 fs::canonicalize(root.join("agents").as_str())
4651 .expect("canonicalize agents directory should succeed"),
4652 )
4653 .expect("canonical agents path should be valid UTF-8")
4654 .into_owned();
4655
4656 let config = Config::load(&root).unwrap();
4657 let mut agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4658 let client = Anthropic::new(Some("test-api-key".to_string())).unwrap();
4659 let runtime = tokio::runtime::Runtime::new().unwrap();
4660
4661 let initialized = invoke_bash_tool(
4662 &runtime,
4663 &client,
4664 &mut agent,
4665 "toolu_bash_1",
4666 json!({
4667 "command": "export FOO=bar\ncd agents",
4668 "restart": true
4669 }),
4670 );
4671 assert_eq!(
4672 unwrap_success_block(initialized),
4673 ToolResultBlock {
4674 tool_use_id: "toolu_bash_1".to_string(),
4675 cache_control: None,
4676 content: Some(ToolResultBlockContent::String("success\n".to_string())),
4677 is_error: None,
4678 }
4679 );
4680
4681 let persisted = invoke_bash_tool(
4682 &runtime,
4683 &client,
4684 &mut agent,
4685 "toolu_bash_2",
4686 json!({
4687 "command": "printf '%s:%s' \"$FOO\" \"$PWD\"",
4688 "restart": false
4689 }),
4690 );
4691 assert_eq!(
4692 unwrap_success_block(persisted),
4693 ToolResultBlock {
4694 tool_use_id: "toolu_bash_2".to_string(),
4695 cache_control: None,
4696 content: Some(ToolResultBlockContent::String(format!(
4697 "bar:{}",
4698 canonical_agents.as_str()
4699 ))),
4700 is_error: None,
4701 }
4702 );
4703
4704 let restarted = invoke_bash_tool(
4705 &runtime,
4706 &client,
4707 &mut agent,
4708 "toolu_bash_3",
4709 json!({
4710 "command": "printf '%s:%s' \"${FOO-unset}\" \"$PWD\"",
4711 "restart": true
4712 }),
4713 );
4714 assert_eq!(
4715 unwrap_success_block(restarted),
4716 ToolResultBlock {
4717 tool_use_id: "toolu_bash_3".to_string(),
4718 cache_control: None,
4719 content: Some(ToolResultBlockContent::String(format!(
4720 "unset:{}",
4721 canonical_root.as_str()
4722 ))),
4723 is_error: None,
4724 }
4725 );
4726
4727 fs::remove_dir_all(root.as_str()).unwrap();
4728 }
4729
4730 #[test]
4731 fn resumed_session_restores_bash_state_snapshot() {
4732 let root = temp_config_root("agent");
4733 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4734 let canonical_agents = Path::try_from(
4735 fs::canonicalize(root.join("agents").as_str())
4736 .expect("canonicalize agents directory should succeed"),
4737 )
4738 .expect("canonical agents path should be valid UTF-8")
4739 .into_owned();
4740 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
4741
4742 let config = Config::load(&root).unwrap();
4743 let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
4744 let agent = SidAgent::from_config(&config, "build", root.clone())
4745 .unwrap()
4746 .with_session(sid_session.clone());
4747 let runtime = tokio::runtime::Runtime::new().unwrap();
4748
4749 runtime
4750 .block_on(agent.bash(
4751 "export FOO=bar\nf() { printf hi; }\nalias ll='printf alias'\nset -o nounset\ncd agents",
4752 true,
4753 ))
4754 .unwrap();
4755
4756 let resumed =
4757 Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
4758 let resumed_agent = SidAgent::from_config(&config, "build", root.clone())
4759 .unwrap()
4760 .with_session(resumed);
4761 let restored = runtime
4762 .block_on(resumed_agent.bash(
4763 "if shopt -qo nounset; then nounset=on; else nounset=off; fi\nprintf '%s:%s:%s:%s:%s' \"$FOO\" \"$(f)\" \"$(ll)\" \"$PWD\" \"$nounset\"",
4764 false,
4765 ))
4766 .unwrap();
4767
4768 assert_eq!(
4769 restored.trim_end(),
4770 format!("bar:hi:alias:{}:on", canonical_agents.as_str()),
4771 );
4772
4773 fs::remove_dir_all(root.as_str()).unwrap();
4774 fs::remove_dir_all(sessions_root).unwrap();
4775 }
4776
4777 #[test]
4778 fn resumed_session_restart_discards_saved_bash_state() {
4779 let root = temp_config_root("agent");
4780 write_builtin_config(&root, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4781 let canonical_root = Path::try_from(
4782 fs::canonicalize(root.as_str()).expect("canonicalize root directory should succeed"),
4783 )
4784 .expect("canonical root path should be valid UTF-8")
4785 .into_owned();
4786 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
4787
4788 let config = Config::load(&root).unwrap();
4789 let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
4790 let agent = SidAgent::from_config(&config, "build", root.clone())
4791 .unwrap()
4792 .with_session(sid_session.clone());
4793 let runtime = tokio::runtime::Runtime::new().unwrap();
4794
4795 runtime
4796 .block_on(agent.bash("export FOO=bar\ncd agents", true))
4797 .unwrap();
4798
4799 let resumed =
4800 Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
4801 let resumed_agent = SidAgent::from_config(&config, "build", root.clone())
4802 .unwrap()
4803 .with_session(resumed.clone());
4804 let restarted = runtime
4805 .block_on(resumed_agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", true))
4806 .unwrap();
4807 assert_eq!(
4808 restarted.trim_end(),
4809 format!("unset:{}", canonical_root.as_str())
4810 );
4811
4812 let resumed_again =
4813 Arc::new(SidSession::resume_in(sessions_root.clone(), sid_session.id()).unwrap());
4814 let resumed_again_agent = SidAgent::from_config(&config, "build", root.clone())
4815 .unwrap()
4816 .with_session(resumed_again);
4817 let restored = runtime
4818 .block_on(resumed_again_agent.bash("printf '%s:%s' \"${FOO-unset}\" \"$PWD\"", false))
4819 .unwrap();
4820 assert_eq!(
4821 restored.trim_end(),
4822 format!("unset:{}", canonical_root.as_str())
4823 );
4824
4825 fs::remove_dir_all(root.as_str()).unwrap();
4826 fs::remove_dir_all(sessions_root).unwrap();
4827 }
4828
4829 #[test]
4830 fn two_agents_with_different_workspace_roots_have_no_crosstalk() {
4831 let root_a = temp_config_root("agent-a");
4832 let root_b = temp_config_root("agent-b");
4833 write_builtin_config(&root_a, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4834 write_builtin_config(&root_b, "#!/bin/sh\nexit 0\n", "#!/bin/sh\nexit 0\n");
4835 let canonical_a = Path::try_from(
4836 fs::canonicalize(root_a.as_str()).expect("canonicalize root_a should succeed"),
4837 )
4838 .expect("canonical root_a path should be valid UTF-8")
4839 .into_owned();
4840 let canonical_b = Path::try_from(
4841 fs::canonicalize(root_b.as_str()).expect("canonicalize root_b should succeed"),
4842 )
4843 .expect("canonical root_b path should be valid UTF-8")
4844 .into_owned();
4845
4846 let config_a = Config::load(&root_a).unwrap();
4847 let agent_a = SidAgent::from_config(&config_a, "build", root_a.clone()).unwrap();
4848 let config_b = Config::load(&root_b).unwrap();
4849 let agent_b = SidAgent::from_config(&config_b, "build", root_b.clone()).unwrap();
4850
4851 let runtime = tokio::runtime::Runtime::new().unwrap();
4852 let pwd_a = runtime.block_on(agent_a.bash("pwd", true)).unwrap();
4853 let pwd_b = runtime.block_on(agent_b.bash("pwd", true)).unwrap();
4854
4855 assert_eq!(pwd_a.trim_end(), canonical_a.as_str());
4856 assert_eq!(pwd_b.trim_end(), canonical_b.as_str());
4857 assert_ne!(
4858 canonical_a.as_str(),
4859 canonical_b.as_str(),
4860 "the two workspace roots must be distinct"
4861 );
4862
4863 fs::remove_dir_all(root_a.as_str()).unwrap();
4864 fs::remove_dir_all(root_b.as_str()).unwrap();
4865 }
4866
4867 #[test]
4868 fn builtin_text_editor_uses_rc_tool_runtime() {
4869 let root = temp_config_root("agent");
4870 write_builtin_config(
4871 &root,
4872 "#!/bin/sh\nexit 0\n",
4873 &capturing_tool_script(
4874 "edit via rc",
4875 "edit-request-capture.json",
4876 "edit-env-capture.json",
4877 ),
4878 );
4879
4880 let config = Config::load(&root).unwrap();
4881 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
4882 let runtime = tokio::runtime::Runtime::new().unwrap();
4883 let tool_use = ToolUseBlock::new(
4884 "toolu_edit_123",
4885 "str_replace_based_edit_tool",
4886 json!({
4887 "command": "str_replace",
4888 "path": "src/lib.rs",
4889 "old_str": "old",
4890 "new_str": "new"
4891 }),
4892 );
4893 let result = runtime.block_on(agent.text_editor(tool_use)).unwrap();
4894 assert_eq!(result, "edit via rc");
4895
4896 let request: serde_json::Value = serde_json::from_str(
4897 &fs::read_to_string(root.join("edit-request-capture.json").as_str()).unwrap(),
4898 )
4899 .unwrap();
4900 assert_eq!(request["tool"]["id"], json!("edit"));
4901 assert_eq!(
4902 request["invocation"]["tool_use_id"],
4903 json!("toolu_edit_123")
4904 );
4905 assert_eq!(
4906 request["invocation"]["input"],
4907 json!({
4908 "command": "str_replace",
4909 "path": "src/lib.rs",
4910 "old_str": "old",
4911 "new_str": "new"
4912 })
4913 );
4914
4915 let env: serde_json::Value = serde_json::from_str(
4916 &fs::read_to_string(root.join("edit-env-capture.json").as_str()).unwrap(),
4917 )
4918 .unwrap();
4919 assert_eq!(env["tool_id"], json!("edit"));
4920 assert_eq!(env["tool_name"], json!("edit"));
4921 assert_eq!(env["workspace_root"], json!(root.as_str()));
4922
4923 fs::remove_dir_all(root.as_str()).unwrap();
4924 }
4925
4926 #[test]
4927 fn tool_invocation_writes_request_and_returns_success() {
4928 let root = temp_config_root("agent");
4929 write_sample_config_with_fmt_script(
4930 &root,
4931 &success_tool_script("Formatted 3 files.", true),
4932 );
4933
4934 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
4935 assert_eq!(unwrap_success_text(result), "Formatted 3 files.");
4936
4937 let request: serde_json::Value = serde_json::from_str(
4938 &fs::read_to_string(root.join("request-capture.json").as_str()).unwrap(),
4939 )
4940 .unwrap();
4941 assert_eq!(request["protocol_version"], json!(TOOL_PROTOCOL_VERSION));
4942 assert_eq!(request["tool"]["id"], json!("fmt"));
4943 assert_eq!(request["invocation"]["tool_use_id"], json!("toolu_123"));
4944 assert_eq!(
4945 request["invocation"]["input"],
4946 json!({ "paths": ["src/lib.rs"] })
4947 );
4948 assert_eq!(request["agent"]["id"], json!("build"));
4949 assert_eq!(request["workspace"]["root"], json!(root.as_str()));
4950 assert_eq!(request["workspace"]["cwd"], json!(root.as_str()));
4951 let scratch_dir = request["files"]["scratch_dir"].as_str().unwrap();
4952 let result_file = request["files"]["result_file"].as_str().unwrap();
4953 assert!(scratch_dir.starts_with('/'));
4954 assert!(result_file.starts_with(scratch_dir));
4955 assert!(result_file.ends_with("/result.json"));
4956
4957 fs::remove_dir_all(root.as_str()).unwrap();
4958 }
4959
4960 #[test]
4961 fn tool_invocation_exposes_execution_env_and_ignores_terminal_output() {
4962 let root = temp_config_root("agent");
4963 write_sample_config_with_fmt_script(
4964 &root,
4965 &environment_capturing_tool_script("tool result only"),
4966 );
4967
4968 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
4969 assert_eq!(unwrap_success_text(result), "tool result only");
4970
4971 let env: serde_json::Value = serde_json::from_str(
4972 &fs::read_to_string(root.join("env-capture.json").as_str()).unwrap(),
4973 )
4974 .unwrap();
4975 assert_eq!(env["protocol"], json!(TOOL_PROTOCOL_VERSION.to_string()));
4976 assert_eq!(env["workspace_root"], json!(root.as_str()));
4977 assert_eq!(env["agent_id"], json!("build"));
4978 assert_eq!(env["tool_id"], json!("fmt"));
4979 assert_eq!(env["tool_name"], json!("format"));
4980 assert_eq!(env["rc_d_path"], json!(root.join("tools").as_str()));
4981 let scratch_dir = env["scratch_dir"].as_str().unwrap();
4982 assert!(scratch_dir.starts_with('/'));
4983 assert!(
4984 !PathBuf::from(scratch_dir).exists(),
4985 "tool scratch should be cleaned by default"
4986 );
4987 assert!(
4988 env["request_file"]
4989 .as_str()
4990 .unwrap()
4991 .ends_with("/request.json")
4992 );
4993 assert!(
4994 env["result_file"]
4995 .as_str()
4996 .unwrap()
4997 .ends_with("/result.json")
4998 );
4999
5000 fs::remove_dir_all(root.as_str()).unwrap();
5001 }
5002
5003 #[test]
5004 fn tool_invocation_uses_ordered_session_tmp_and_journals() {
5005 let root = temp_config_root("agent");
5006 write_sample_config_with_fmt_script(
5007 &root,
5008 &capturing_tool_script(
5009 "tool result only",
5010 "request-capture.json",
5011 "env-capture.json",
5012 ),
5013 );
5014 let sessions_root = PathBuf::from(unique_temp_dir("sessions").as_str());
5015 let sid_session = Arc::new(SidSession::create_in(sessions_root.clone()).unwrap());
5016
5017 let config = Config::load(&root).unwrap();
5018 let agent = SidAgent::from_config(&config, "build", root.clone())
5019 .unwrap()
5020 .with_session(sid_session.clone());
5021 let tool_config = config.tools.get("format").unwrap();
5022 let exposed_name = exposed_tool_name("build", "format").unwrap();
5023 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
5024 let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5025 let tool_use = ToolUseBlock::new(
5026 "toolu_123",
5027 exposed_name,
5028 json!({ "paths": ["src/lib.rs"] }),
5029 );
5030 let runtime = tokio::runtime::Runtime::new().unwrap();
5031
5032 let result = runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None));
5033 assert_eq!(unwrap_success_text(result), "tool result only");
5034
5035 let request: serde_json::Value = serde_json::from_str(
5036 &fs::read_to_string(root.join("request-capture.json").as_str()).unwrap(),
5037 )
5038 .unwrap();
5039 let env: serde_json::Value = serde_json::from_str(
5040 &fs::read_to_string(root.join("env-capture.json").as_str()).unwrap(),
5041 )
5042 .unwrap();
5043 let scratch_dir = PathBuf::from(request["files"]["scratch_dir"].as_str().unwrap());
5044 let temp_dir = PathBuf::from(request["files"]["temp_dir"].as_str().unwrap());
5045 let tool_root = sid_session.root().join("tmp").join("tool-000001");
5046
5047 assert_eq!(scratch_dir, tool_root);
5048 assert_eq!(temp_dir, tool_root.join("tmp"));
5049 assert_eq!(
5050 request["files"]["result_file"],
5051 json!(scratch_dir.join("result.json").to_string_lossy())
5052 );
5053 assert_eq!(env["scratch_dir"], json!(scratch_dir.to_string_lossy()));
5054 assert_eq!(env["temp_dir"], json!(temp_dir.to_string_lossy()));
5055 assert_eq!(env["tmpdir"], json!(temp_dir.to_string_lossy()));
5056 assert_eq!(env["session_id"], json!(sid_session.id()));
5057 assert_eq!(
5058 env["session_dir"],
5059 json!(sid_session.root().to_string_lossy())
5060 );
5061 assert!(
5062 !tool_root.exists(),
5063 "session tool scratch should be cleaned by default"
5064 );
5065
5066 let events = fs::read_to_string(sid_session.root().join("events.jsonl")).unwrap();
5067 let events = events
5068 .lines()
5069 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
5070 .collect::<Vec<_>>();
5071 assert_eq!(events[0]["kind"], json!("session_start"));
5072 assert_eq!(events[1]["kind"], json!("tool_start"));
5073 assert_eq!(events[1]["tool_seq"], json!(1));
5074 assert_eq!(events[1]["request_id"], request["request_id"]);
5075 assert_eq!(events[2]["kind"], json!("tool_finish"));
5076 assert_eq!(events[2]["tool_seq"], json!(1));
5077 assert_eq!(events[2]["success"], json!(true));
5078 assert_eq!(events[2]["scratch_preserved"], json!(false));
5079
5080 fs::remove_dir_all(root.as_str()).unwrap();
5081 fs::remove_dir_all(sessions_root).unwrap();
5082 }
5083
5084 #[test]
5085 fn tool_confirmation_preview_uses_prepared_invocation_env() {
5086 if sandbox_exec_refuses_children() {
5087 return;
5088 }
5089
5090 let root = temp_config_root("agent");
5091 write_sample_config_with_fmt_script(&root, &confirmation_preview_tool_script());
5092
5093 let config = Config::load(&root).unwrap();
5094 let agent = SidAgent::from_config(&config, "build", root.clone()).unwrap();
5095 let tool_config = config.tools.get("format").unwrap();
5096 let exposed_name = exposed_tool_name("build", "format").unwrap();
5097 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, "format").unwrap();
5098 let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5099 let context = agent.tool_runtime_context();
5100 let prepared = tool_runtime::prepare_rc_tool_invocation(
5101 &tool.name,
5102 &tool.name,
5103 &tool.canonical_id,
5104 &tool.executable_path,
5105 &context,
5106 "toolu_confirm_123",
5107 serde_json::Map::from_iter([("paths".to_string(), json!(["src/lib.rs"]))]),
5108 )
5109 .unwrap();
5110
5111 let runtime = tokio::runtime::Runtime::new().unwrap();
5112 let preview = runtime
5113 .block_on(tool_runtime::render_rc_tool_confirmation_preview(
5114 &prepared, None,
5115 ))
5116 .unwrap();
5117 assert!(preview.contains("mode=confirm"));
5118 assert!(preview.contains("tool=format"));
5119 assert!(preview.contains("id=fmt"));
5120 assert!(preview.contains("request=request.json"));
5121
5122 let text = runtime
5123 .block_on(tool_runtime::run_prepared_rc_tool_text(
5124 &prepared,
5125 &agent.writable_roots,
5126 None,
5127 ))
5128 .unwrap();
5129 assert_eq!(text, "format ran");
5130
5131 fs::remove_dir_all(root.as_str()).unwrap();
5132 }
5133
5134 #[test]
5135 fn tool_invocation_returns_handled_model_visible_error() {
5136 let root = temp_config_root("agent");
5137 write_sample_config_with_fmt_script(
5138 &root,
5139 &handled_error_tool_script("paths must not be empty"),
5140 );
5141
5142 let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5143 assert_eq!(unwrap_error_text(result), "paths must not be empty");
5144
5145 fs::remove_dir_all(root.as_str()).unwrap();
5146 }
5147
5148 #[test]
5149 fn tool_invocation_returns_process_error_when_tool_exits_nonzero() {
5150 let root = temp_config_root("agent");
5151 write_sample_config_with_fmt_script(&root, &nonzero_exit_tool_script());
5152
5153 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5154 let error = unwrap_error_text(result);
5155 assert!(error.contains("exited with status"));
5156
5157 fs::remove_dir_all(root.as_str()).unwrap();
5158 }
5159
5160 #[test]
5161 fn tool_invocation_returns_protocol_error_when_result_protocol_version_is_unsupported() {
5162 let root = temp_config_root("agent");
5163 write_sample_config_with_fmt_script(&root, &unsupported_protocol_version_tool_script());
5164
5165 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5166 let error = unwrap_error_text(result);
5167 assert!(error.contains("unsupported result protocol version"));
5168
5169 fs::remove_dir_all(root.as_str()).unwrap();
5170 }
5171
5172 #[test]
5173 fn tool_invocation_returns_protocol_error_when_output_kind_is_unsupported() {
5174 let root = temp_config_root("agent");
5175 write_sample_config_with_fmt_script(&root, &unsupported_output_kind_tool_script());
5176
5177 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5178 let error = unwrap_error_text(result);
5179 assert!(error.contains("unsupported output kind"));
5180
5181 fs::remove_dir_all(root.as_str()).unwrap();
5182 }
5183
5184 #[test]
5185 fn tool_invocation_returns_protocol_error_when_success_output_is_missing() {
5186 let root = temp_config_root("agent");
5187 write_sample_config_with_fmt_script(&root, &missing_success_output_tool_script());
5188
5189 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5190 let error = unwrap_error_text(result);
5191 assert!(error.contains("missing success output"));
5192
5193 fs::remove_dir_all(root.as_str()).unwrap();
5194 }
5195
5196 #[test]
5197 fn tool_invocation_returns_protocol_error_when_output_text_is_missing() {
5198 let root = temp_config_root("agent");
5199 write_sample_config_with_fmt_script(&root, &missing_output_text_tool_script());
5200
5201 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5202 let error = unwrap_error_text(result);
5203 assert!(error.contains("missing output.text"));
5204
5205 fs::remove_dir_all(root.as_str()).unwrap();
5206 }
5207
5208 #[test]
5209 fn tool_invocation_returns_protocol_error_when_result_is_missing() {
5210 let root = temp_config_root("agent");
5211 write_sample_config_with_fmt_script(&root, "#!/bin/sh\nexit 0\n");
5212
5213 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5214 let error = unwrap_error_text(result);
5215 assert!(error.contains("protocol error"));
5216 assert!(error.contains("failed to read tool result file"));
5217
5218 fs::remove_dir_all(root.as_str()).unwrap();
5219 }
5220
5221 #[test]
5222 fn tool_invocation_returns_protocol_error_when_result_is_malformed() {
5223 let root = temp_config_root("agent");
5224 write_sample_config_with_fmt_script(
5225 &root,
5226 "#!/bin/sh\nprintf 'not json' >\"$RESULT_FILE\"\n",
5227 );
5228
5229 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5230 let error = unwrap_error_text(result);
5231 assert!(error.contains("protocol error"));
5232 assert!(error.contains("failed to parse tool result file"));
5233
5234 fs::remove_dir_all(root.as_str()).unwrap();
5235 }
5236
5237 #[test]
5238 fn tool_invocation_returns_protocol_error_when_error_object_is_missing() {
5239 let root = temp_config_root("agent");
5240 write_sample_config_with_fmt_script(&root, &missing_error_object_tool_script());
5241
5242 let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5243 let error = unwrap_error_text(result);
5244 assert!(error.contains("missing error object"));
5245
5246 fs::remove_dir_all(root.as_str()).unwrap();
5247 }
5248
5249 #[test]
5250 fn tool_invocation_returns_protocol_error_when_error_message_is_missing() {
5251 let root = temp_config_root("agent");
5252 write_sample_config_with_fmt_script(&root, &missing_error_message_tool_script());
5253
5254 let result = invoke_configured_tool(&root, "format", json!({ "paths": [] }));
5255 let error = unwrap_error_text(result);
5256 assert!(error.contains("missing error.message"));
5257
5258 fs::remove_dir_all(root.as_str()).unwrap();
5259 }
5260
5261 #[test]
5262 fn tool_invocation_returns_protocol_error_when_request_id_mismatches() {
5263 let root = temp_config_root("agent");
5264 write_sample_config_with_fmt_script(&root, &mismatched_request_id_tool_script());
5265
5266 let result = invoke_configured_tool(&root, "format", json!({ "paths": ["src/lib.rs"] }));
5267 let error = unwrap_error_text(result);
5268 assert!(error.contains("request_id mismatch"));
5269
5270 fs::remove_dir_all(root.as_str()).unwrap();
5271 }
5272
5273 #[test]
5274 fn tool_invocation_returns_protocol_error_when_input_is_not_an_object() {
5275 let root = temp_config_root("agent");
5276 write_sample_config_with_fmt_script(
5277 &root,
5278 &success_tool_script("Formatted 3 files.", false),
5279 );
5280
5281 let result = invoke_configured_tool(&root, "format", json!(["src/lib.rs"]));
5282 let error = unwrap_error_text(result);
5283 assert!(error.contains("tool input must be a JSON object"));
5284
5285 fs::remove_dir_all(root.as_str()).unwrap();
5286 }
5287
5288 fn expected_build_system_prompt(workspace_root: &Path) -> String {
5289 format!(
5290 r#"# Build
5291
5292You are an expert builder.
5293# Environment
5294
5295You are operating in the sid-isn't-done environment. Tools:
5296- edit: The Anthropic Text Editor tool:
5297 - Uses sid's virtual filesystem.
5298 - The virtual filesystem maps / to the workspace root.
5299 - It is not an operating-system chroot.
5300 - Absolute editor paths are workspace-rooted; /foo means {}/foo.
5301- bash: A genuine bash shell:
5302 - Connected via PTY.
5303 - Without support for cursor positioning.
5304 - With state persistence between invocations.
5305 - With PS0, PS1, PS2 and PROMPT_COMMAND set to readonly.
5306 - `restart: true` throws the session away and starts fresh.
5307 - Initial CWD is {}.
5308 - Runs in the host filesystem namespace, not a chroot.
5309 - Host / remains visible subject to OS permissions and sandbox policy.
5310 - Bash cannot see sid's virtual /skills mount.
5311 - Use the index to browse skills if you need specialized knowledge.
5312
5313CRITICAL — the edit tool and bash tool use different path namespaces:
5314- The edit tool's / is the workspace root ({}).
5315 To edit a file at the workspace root, use /filename (e.g., /src/lib.rs).
5316 NEVER pass a full host path to the edit tool.
5317- Bash sees the real host filesystem. `pwd` prints {}, not /.
5318 To convert a bash path to an edit path, strip the {} prefix.
5319 To convert an edit path to a bash path, prepend {}.
5320 If bash `pwd` is a subdirectory, a relative path like ./foo.rs in bash
5321 corresponds to stripping the workspace prefix from the absolute bash path.
5322- Example: the bash path {}/src/lib.rs is the edit path /src/lib.rs.
5323"#,
5324 workspace_root,
5325 workspace_root,
5326 workspace_root,
5327 workspace_root,
5328 workspace_root,
5329 workspace_root,
5330 workspace_root
5331 )
5332 }
5333
5334 fn write_sample_config(root: &Path) {
5335 write_sample_config_with_fmt_script(root, "#!/bin/sh\nexit 0\n");
5336 }
5337
5338 fn write_builtin_config(root: &Path, bash_script: &str, edit_script: &str) {
5339 fs::create_dir_all(root.join("agents").as_str()).unwrap();
5340 fs::write(
5341 root.join("agents.conf").as_str(),
5342 r#"
5343build_ENABLED="YES"
5344build_TOOLS='bash edit'
5345"#,
5346 )
5347 .unwrap();
5348 fs::write(
5349 root.join("tools.conf").as_str(),
5350 r#"
5351bash_ENABLED="YES"
5352edit_ENABLED="YES"
5353"#,
5354 )
5355 .unwrap();
5356 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
5357 write_tool_contract(root, "bash", "Run a shell command.", bash_script);
5358 write_tool_contract(root, "edit", "Edit workspace files.", edit_script);
5359 }
5360
5361 fn write_builtin_config_without_manifests(root: &Path, bash_script: &str, edit_script: &str) {
5362 fs::create_dir_all(root.join("agents").as_str()).unwrap();
5363 fs::write(
5364 root.join("agents.conf").as_str(),
5365 r#"
5366build_ENABLED="YES"
5367build_TOOLS='bash edit'
5368"#,
5369 )
5370 .unwrap();
5371 fs::write(
5372 root.join("tools.conf").as_str(),
5373 r#"
5374bash_ENABLED="YES"
5375edit_ENABLED="YES"
5376"#,
5377 )
5378 .unwrap();
5379 fs::write(root.join("agents/build.md").as_str(), "# Build\n").unwrap();
5380 write_tool_runtime(root, "bash", bash_script);
5381 write_tool_runtime(root, "edit", edit_script);
5382 }
5383
5384 fn write_sample_config_with_fmt_script(root: &Path, fmt_script: &str) {
5385 fs::create_dir_all(root.join("agents").as_str()).unwrap();
5386 fs::write(
5387 root.join("agents.conf").as_str(),
5388 r#"
5389ROLE='principal engineer'
5390build_ENABLED="YES"
5391plan_ENABLED="MANUAL"
5392evil_ENABLED="NO"
5393
5394build_NAME="Let's go ${ROLE}"
5395build_DESC="buildit"
5396build_TOOLS='format shell'
5397
5398plan_MODEL=claude-sonnet-4-5
5399plan_SYSTEM="You are ${ROLE}"
5400plan_MAX_TOKENS=8192
5401"#,
5402 )
5403 .unwrap();
5404 fs::write(
5405 root.join("tools.conf").as_str(),
5406 r#"
5407fmt_ENABLED="YES"
5408shell_ENABLED="YES"
5409
5410format_INHERIT="YES"
5411format_ALIASES="fmt"
5412"#,
5413 )
5414 .unwrap();
5415 fs::write(
5416 root.join("agents/build.md").as_str(),
5417 "# Build\n\nYou are an expert builder.\n",
5418 )
5419 .unwrap();
5420 write_tool_contract(root, "fmt", "Format files in the workspace.", fmt_script);
5421 write_tool_contract(root, "shell", "Run a shell command.", "#!/bin/sh\nexit 0\n");
5422 }
5423
5424 fn invoke_configured_tool(
5425 root: &Path,
5426 tool_name: &str,
5427 input: serde_json::Value,
5428 ) -> ToolResult {
5429 let config = Config::load(root).unwrap();
5430 let agent = SidAgent::from_config(&config, "build", root.clone().into_owned()).unwrap();
5431 let tool_config = config.tools.get(tool_name).unwrap();
5432 let exposed_name = exposed_tool_name("build", tool_name).unwrap();
5433 let canonical_id = resolve_canonical_tool_id(&config.tools_rc_conf, tool_name).unwrap();
5434 let tool = ExternalTool::from_config(exposed_name.clone(), canonical_id, tool_config);
5435 let tool_use = ToolUseBlock::new("toolu_123", exposed_name, input);
5436 let runtime = tokio::runtime::Runtime::new().unwrap();
5437 runtime.block_on(invoke_external_tool(&tool, &agent, &tool_use, None))
5438 }
5439
5440 fn invoke_bash_tool(
5441 runtime: &tokio::runtime::Runtime,
5442 client: &Anthropic,
5443 agent: &mut SidAgent,
5444 tool_use_id: &str,
5445 input: serde_json::Value,
5446 ) -> ToolResult {
5447 let tool = runtime
5448 .block_on(agent.tools())
5449 .into_iter()
5450 .find(|tool| tool.name() == "bash")
5451 .expect("agent should expose the builtin bash tool");
5452 let tool_use = ToolUseBlock::new(tool_use_id, "bash", input);
5453 let callback = tool.callback();
5454 let intermediate = runtime.block_on(callback.compute_tool_result(client, agent, &tool_use));
5455 runtime.block_on(callback.apply_tool_result(client, agent, &tool_use, intermediate))
5456 }
5457
5458 fn unwrap_success_text(result: ToolResult) -> String {
5459 match result {
5460 ControlFlow::Continue(Ok(block)) => tool_block_text(block),
5461 other => panic!("expected successful tool result, got {other:?}"),
5462 }
5463 }
5464
5465 fn unwrap_success_block(result: ToolResult) -> ToolResultBlock {
5466 match result {
5467 ControlFlow::Continue(Ok(block)) => block,
5468 other => panic!("expected successful tool result, got {other:?}"),
5469 }
5470 }
5471
5472 fn unwrap_error_text(result: ToolResult) -> String {
5473 match result {
5474 ControlFlow::Continue(Err(block)) => {
5475 assert_eq!(block.is_error, Some(true));
5476 tool_block_text(block)
5477 }
5478 other => panic!("expected errored tool result, got {other:?}"),
5479 }
5480 }
5481
5482 fn tool_block_text(block: ToolResultBlock) -> String {
5483 match block.content.unwrap() {
5484 ToolResultBlockContent::String(text) => text,
5485 other => panic!("expected string tool result content, got {other:?}"),
5486 }
5487 }
5488
5489 fn injected_user_instruction_text(message: &MessageParam) -> &str {
5490 let MessageParamContent::Array(blocks) = &message.content else {
5491 panic!("expected content blocks, got {:?}", message.content);
5492 };
5493 blocks
5494 .last()
5495 .and_then(ContentBlock::as_text)
5496 .map(|block| block.text.as_str())
5497 .expect("last block should be injected text")
5498 }
5499
5500 fn success_tool_script(text: &str, capture_request: bool) -> String {
5501 let capture = if capture_request {
5502 "cp \"$REQUEST_FILE\" \"$WORKSPACE_ROOT/request-capture.json\"\n"
5503 } else {
5504 ""
5505 };
5506 format!(
5507 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\n{capture}cat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5508 )
5509 }
5510
5511 fn handled_error_tool_script(message: &str) -> String {
5512 format!(
5513 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false,\"error\":{{\"code\":\"invalid_input\",\"message\":\"{message}\"}}}}\nEOF\n"
5514 )
5515 }
5516
5517 fn environment_capturing_tool_script(text: &str) -> String {
5518 format!(
5519 "#!/bin/sh\nprintf 'stdout from tool\\n'\nprintf 'stderr from tool\\n' >&2\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$WORKSPACE_ROOT/env-capture.json\" <<EOF\n{{\"protocol\":\"$TOOL_PROTOCOL\",\"request_file\":\"$REQUEST_FILE\",\"result_file\":\"$RESULT_FILE\",\"scratch_dir\":\"$SCRATCH_DIR\",\"workspace_root\":\"$WORKSPACE_ROOT\",\"agent_id\":\"$AGENT_ID\",\"tool_id\":\"$TOOL_ID\",\"tool_name\":\"$TOOL_NAME\",\"rc_conf_path\":\"$RC_CONF_PATH\",\"rc_d_path\":\"$RC_D_PATH\"}}\nEOF\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5520 )
5521 }
5522
5523 fn capturing_tool_script(text: &str, request_capture: &str, env_capture: &str) -> String {
5524 format!(
5525 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncp \"$REQUEST_FILE\" \"$WORKSPACE_ROOT/{request_capture}\"\ncat >\"$WORKSPACE_ROOT/{env_capture}\" <<EOF\n{{\"protocol\":\"$TOOL_PROTOCOL\",\"request_file\":\"$REQUEST_FILE\",\"result_file\":\"$RESULT_FILE\",\"scratch_dir\":\"$SCRATCH_DIR\",\"temp_dir\":\"$TEMP_DIR\",\"tmpdir\":\"$TMPDIR\",\"workspace_root\":\"$WORKSPACE_ROOT\",\"agent_id\":\"$AGENT_ID\",\"session_id\":\"$SESSION_ID\",\"session_dir\":\"$SESSION_DIR\",\"tool_id\":\"$TOOL_ID\",\"tool_name\":\"$TOOL_NAME\",\"rc_conf_path\":\"$RC_CONF_PATH\",\"rc_d_path\":\"$RC_D_PATH\"}}\nEOF\ncat >\"$RESULT_FILE\" <<EOF\n{{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{{\"kind\":\"text\",\"text\":\"{text}\"}}}}\nEOF\n"
5526 )
5527 }
5528
5529 fn confirmation_preview_tool_script() -> String {
5530 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\nif [ \"$TOOL_MODE\" = confirm ]; then\n printf 'mode=%s tool=%s id=%s request=%s\\n' \"$TOOL_MODE\" \"$TOOL_NAME\" \"$TOOL_ID\" \"$(basename \"$REQUEST_FILE\")\"\n exit 0\nfi\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"format ran\"}}\nEOF\n".to_string()
5531 }
5532
5533 fn sandbox_exec_refuses_children() -> bool {
5534 if !seatbelt::sandbox_available() {
5535 return false;
5536 }
5537 match std::process::Command::new("/usr/bin/sandbox-exec")
5538 .arg("-p")
5539 .arg("(version 1)\n(allow default)\n")
5540 .arg("/usr/bin/true")
5541 .stdin(std::process::Stdio::null())
5542 .stdout(std::process::Stdio::null())
5543 .stderr(std::process::Stdio::null())
5544 .status()
5545 {
5546 Ok(status) => !status.success(),
5547 Err(_) => true,
5548 }
5549 }
5550
5551 fn nonzero_exit_tool_script() -> String {
5552 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\nexit 9\n".to_string()
5553 }
5554
5555 fn mismatched_request_id_tool_script() -> String {
5556 "#!/bin/sh\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"wrong-request\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5557 }
5558
5559 fn unsupported_protocol_version_tool_script() -> String {
5560 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":2,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5561 }
5562
5563 fn unsupported_output_kind_tool_script() -> String {
5564 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"json\",\"text\":\"ignored\"}}\nEOF\n".to_string()
5565 }
5566
5567 fn missing_success_output_tool_script() -> String {
5568 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true}\nEOF\n".to_string()
5569 }
5570
5571 fn missing_output_text_tool_script() -> String {
5572 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":true,\"output\":{\"kind\":\"text\"}}\nEOF\n".to_string()
5573 }
5574
5575 fn missing_error_object_tool_script() -> String {
5576 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false}\nEOF\n".to_string()
5577 }
5578
5579 fn missing_error_message_tool_script() -> String {
5580 "#!/bin/sh\nREQUEST_ID=$(sed -n 's/.*\"request_id\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \"$REQUEST_FILE\")\ncat >\"$RESULT_FILE\" <<EOF\n{\"protocol_version\":1,\"request_id\":\"$REQUEST_ID\",\"ok\":false,\"error\":{\"code\":\"invalid_input\"}}\nEOF\n".to_string()
5581 }
5582
5583 fn write_tool_contract(root: &Path, tool: &str, description: &str, body: &str) {
5584 write_tool_runtime(root, tool, body);
5585 write_default_tool_manifest(root, tool, description);
5586 }
5587
5588 fn write_tool_runtime(root: &Path, tool: &str, body: &str) {
5589 fs::create_dir_all(root.join(TOOLS_DIR).as_str()).unwrap();
5590 let executable = root.join(format!("{TOOLS_DIR}/{tool}")).into_owned();
5591 let implementation = root.join(format!("{TOOLS_DIR}/{tool}.impl")).into_owned();
5592 fs::write(implementation.as_str(), body).unwrap();
5593 make_executable(&implementation);
5594 fs::write(
5595 executable.as_str(),
5596 format!(
5597 "#!/bin/sh\nset -eu\n\nlookup() {{\n printenv \"$1\"\n}}\n\nPREFIX=${{RCVAR_ARGV0:?missing RCVAR_ARGV0}}\n\ncase \"${{1:-}}\" in\nrcvar)\n printf '%s\\n' \\\n \"${{PREFIX}}_REQUEST_FILE\" \\\n \"${{PREFIX}}_RESULT_FILE\" \\\n \"${{PREFIX}}_SCRATCH_DIR\" \\\n \"${{PREFIX}}_TEMP_DIR\" \\\n \"${{PREFIX}}_TMPDIR\" \\\n \"${{PREFIX}}_WORKSPACE_ROOT\" \\\n \"${{PREFIX}}_SESSION_ID\" \\\n \"${{PREFIX}}_SESSION_DIR\" \\\n \"${{PREFIX}}_AGENT_ID\" \\\n \"${{PREFIX}}_TOOL_ID\" \\\n \"${{PREFIX}}_TOOL_NAME\" \\\n \"${{PREFIX}}_TOOL_PROTOCOL\" \\\n \"${{PREFIX}}_RC_CONF_PATH\" \\\n \"${{PREFIX}}_RC_D_PATH\"\n ;;\nconfirm|run)\n export TOOL_MODE=\"$1\"\n shift\n export REQUEST_FILE=\"$(lookup \"${{PREFIX}}_REQUEST_FILE\")\"\n export RESULT_FILE=\"$(lookup \"${{PREFIX}}_RESULT_FILE\")\"\n export SCRATCH_DIR=\"$(lookup \"${{PREFIX}}_SCRATCH_DIR\")\"\n export TEMP_DIR=\"$(lookup \"${{PREFIX}}_TEMP_DIR\")\"\n export TMPDIR=\"$(lookup \"${{PREFIX}}_TMPDIR\")\"\n export WORKSPACE_ROOT=\"$(lookup \"${{PREFIX}}_WORKSPACE_ROOT\")\"\n export SESSION_ID=\"$(lookup \"${{PREFIX}}_SESSION_ID\")\"\n export SESSION_DIR=\"$(lookup \"${{PREFIX}}_SESSION_DIR\")\"\n export AGENT_ID=\"$(lookup \"${{PREFIX}}_AGENT_ID\")\"\n export TOOL_ID=\"$(lookup \"${{PREFIX}}_TOOL_ID\")\"\n export TOOL_NAME=\"$(lookup \"${{PREFIX}}_TOOL_NAME\")\"\n export TOOL_PROTOCOL=\"$(lookup \"${{PREFIX}}_TOOL_PROTOCOL\")\"\n export RC_CONF_PATH=\"$(lookup \"${{PREFIX}}_RC_CONF_PATH\")\"\n export RC_D_PATH=\"$(lookup \"${{PREFIX}}_RC_D_PATH\")\"\n exec {} \"$@\"\n ;;\n*)\n echo \"usage: $0 [rcvar|confirm|run]\" >&2\n exit 129\n ;;\nesac\n",
5598 shvar::quote_string(implementation.as_str())
5599 ),
5600 )
5601 .unwrap();
5602 make_executable(&executable);
5603 }
5604
5605 fn write_agent_hook(root: &Path, hook: &str, body: &str) {
5606 fs::create_dir_all(root.join("agents").as_str()).unwrap();
5607 let executable = root.join(format!("agents/{hook}")).into_owned();
5608 fs::write(executable.as_str(), body).unwrap();
5609 make_executable(&executable);
5610 }
5611
5612 fn write_skill(root: &Path, skill: &str, body: &str) {
5613 let skill_dir = root.join(format!("skills/{skill}")).into_owned();
5614 fs::create_dir_all(skill_dir.as_str()).unwrap();
5615 fs::write(skill_dir.join("SKILL.md").as_str(), body).unwrap();
5616 }
5617
5618 #[test]
5619 fn strip_ansi_escapes_plain_text() {
5620 assert_eq!(strip_ansi_escapes("hello world"), "hello world");
5621 }
5622
5623 #[test]
5624 fn strip_ansi_escapes_empty() {
5625 assert_eq!(strip_ansi_escapes(""), "");
5626 }
5627
5628 #[test]
5629 fn strip_ansi_escapes_sgr_color() {
5630 assert_eq!(strip_ansi_escapes("\x1b[1;31merror\x1b[0m"), "error");
5632 }
5633
5634 #[test]
5635 fn strip_ansi_escapes_multiple_csi() {
5636 assert_eq!(
5637 strip_ansi_escapes("\x1b[32mok\x1b[0m \x1b[33mwarn\x1b[0m"),
5638 "ok warn"
5639 );
5640 }
5641
5642 #[test]
5643 fn strip_ansi_escapes_256_color() {
5644 assert_eq!(strip_ansi_escapes("\x1b[38;5;196mred\x1b[0m"), "red");
5645 }
5646
5647 #[test]
5648 fn strip_ansi_escapes_truecolor() {
5649 assert_eq!(strip_ansi_escapes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
5650 }
5651
5652 #[test]
5653 fn strip_ansi_escapes_osc_bel() {
5654 assert_eq!(strip_ansi_escapes("\x1b]0;my title\x07rest"), "rest");
5656 }
5657
5658 #[test]
5659 fn strip_ansi_escapes_osc_st() {
5660 assert_eq!(strip_ansi_escapes("\x1b]0;my title\x1b\\rest"), "rest");
5662 }
5663
5664 #[test]
5665 fn strip_ansi_escapes_two_char_escape() {
5666 assert_eq!(strip_ansi_escapes("a\x1bMb"), "ab");
5668 }
5669
5670 #[test]
5671 fn strip_ansi_escapes_trailing_esc() {
5672 assert_eq!(strip_ansi_escapes("text\x1b"), "text");
5673 }
5674
5675 #[test]
5676 fn strip_ansi_escapes_cursor_csi() {
5677 assert_eq!(strip_ansi_escapes("\x1b[2~x\x1b[1@y"), "xy");
5679 }
5680
5681 #[test]
5682 fn strip_ansi_escapes_preserves_newlines() {
5683 assert_eq!(
5684 strip_ansi_escapes("\x1b[32mline1\x1b[0m\nline2\n"),
5685 "line1\nline2\n"
5686 );
5687 }
5688}