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