diff --git a/assets/config-template.yaml b/assets/config-template.yaml index cd18c10..0ab0ed8 100644 --- a/assets/config-template.yaml +++ b/assets/config-template.yaml @@ -177,6 +177,7 @@ summarization_prompt: null # The text prompt used for creating a concise s summary_context_prompt: null # The text prompt used for including the summary of the entire session as context to the model. If null, uses built-in default max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap) max_concurrent_jobs: null # Max background jobs (`job__*` tools) running at once per context (null = 5; 0 disables background jobs entirely) +tool_timeout: null # Max seconds a tool call may run before being killed (null = 1800; 0 = unlimited). COYOTE_TOOL_TIMEOUT env var overrides # ---- Memory ---- # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. diff --git a/config.example.yaml b/config.example.yaml index ebb5d8c..f4bd891 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -223,6 +223,7 @@ summary_context_prompt: > # The text prompt used for including the summar compression_keep_last: 0 # Number of most-recent messages to keep visible after compression (0 = compress all messages) max_tool_result_chars: null # Cap on tool result characters forwarded to the model per call (null = no cap) max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once per context (default: 5; 0 disables background jobs entirely) +tool_timeout: 1800 # Max seconds a single tool call may run before being killed (default: 1800; 0 = unlimited). COYOTE_TOOL_TIMEOUT env var overrides # ---- Memory ---- # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. diff --git a/src/config/agent.rs b/src/config/agent.rs index cc1e287..f0de372 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -599,30 +599,39 @@ impl Agent { self.config.dynamic_instructions } - pub fn update_shared_dynamic_instructions(&mut self, force: bool) -> Result<()> { + pub fn update_shared_dynamic_instructions( + &mut self, + force: bool, + tool_timeout: Option, + ) -> Result<()> { if self.is_dynamic_instructions() && (force || self.shared_dynamic_instructions.is_none()) { - self.shared_dynamic_instructions = Some(self.run_instructions_fn()?); + self.shared_dynamic_instructions = Some(self.run_instructions_fn(tool_timeout)?); } Ok(()) } - pub fn update_session_dynamic_instructions(&mut self, value: Option) -> Result<()> { + pub fn update_session_dynamic_instructions( + &mut self, + value: Option, + tool_timeout: Option, + ) -> Result<()> { if self.is_dynamic_instructions() { let value = match value { Some(v) => v, - None => self.run_instructions_fn()?, + None => self.run_instructions_fn(tool_timeout)?, }; self.session_dynamic_instructions = Some(value); } Ok(()) } - fn run_instructions_fn(&self) -> Result { + fn run_instructions_fn(&self, tool_timeout: Option) -> Result { let value = run_llm_function( self.name().to_string(), vec!["_instructions".into(), "{}".into()], self.variable_envs(), Some(self.name().to_string()), + tool_timeout, false, )?; match value { diff --git a/src/config/app_config.rs b/src/config/app_config.rs index 6bf1576..f5e50a0 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -70,6 +70,7 @@ pub struct AppConfig { pub summary_context_prompt: Option, pub max_tool_result_chars: Option, pub max_concurrent_jobs: Option, + pub tool_timeout: Option, pub memory: Option, pub memory_cap_with_tools: Option, @@ -159,6 +160,7 @@ impl Default for AppConfig { summary_context_prompt: None, max_tool_result_chars: None, max_concurrent_jobs: None, + tool_timeout: None, memory: None, memory_cap_with_tools: None, @@ -249,6 +251,7 @@ impl AppConfig { summary_context_prompt: config.summary_context_prompt, max_tool_result_chars: config.max_tool_result_chars, max_concurrent_jobs: config.max_concurrent_jobs, + tool_timeout: config.tool_timeout, memory: config.memory, memory_cap_with_tools: config.memory_cap_with_tools, @@ -587,6 +590,9 @@ impl AppConfig { if let Some(v) = super::read_env_value::(&get_env_name("max_concurrent_jobs")) { self.max_concurrent_jobs = v; } + if let Some(v) = super::read_env_value::(&get_env_name("tool_timeout")) { + self.tool_timeout = v; + } if let Some(v) = super::read_env_value::(&get_env_name("summarization_prompt")) { self.summarization_prompt = v; } @@ -926,6 +932,49 @@ mod tests { } } + #[test] + fn from_config_copies_tool_timeout() { + let cfg = Config { + model_id: "test-model".to_string(), + tool_timeout: Some(600), + clients: vec![ClientConfig::default()], + ..Config::default() + }; + + let app = AppConfig::from_config(cfg).unwrap(); + + assert_eq!(app.tool_timeout, Some(600)); + } + + #[test] + #[serial_test::serial] + fn load_envs_overrides_tool_timeout() { + let env_name = get_env_name("tool_timeout"); + let prev = env::var_os(&env_name); + + let mut app = AppConfig::default(); + + unsafe { env::set_var(&env_name, "90") }; + app.load_envs(); + assert_eq!(app.tool_timeout, Some(90)); + + unsafe { env::set_var(&env_name, "0") }; + app.load_envs(); + assert_eq!(app.tool_timeout, Some(0)); + + unsafe { env::remove_var(&env_name) }; + app.tool_timeout = Some(60); + app.load_envs(); + assert_eq!(app.tool_timeout, Some(60)); + + unsafe { + match prev { + Some(v) => env::set_var(&env_name, v), + None => env::remove_var(&env_name), + } + } + } + #[test] fn editor_returns_configured_value() { let configured = diff --git a/src/config/mod.rs b/src/config/mod.rs index 381b283..10d29ea 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -256,6 +256,7 @@ pub struct Config { pub summary_context_prompt: Option, pub max_tool_result_chars: Option, pub max_concurrent_jobs: Option, + pub tool_timeout: Option, pub memory: Option, pub memory_cap_with_tools: Option, @@ -342,6 +343,7 @@ impl Default for Config { summary_context_prompt: None, max_tool_result_chars: None, max_concurrent_jobs: None, + tool_timeout: None, memory: None, memory_cap_with_tools: None, diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 9b4682d..48a2de4 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -1173,7 +1173,7 @@ impl RequestContext { agent.set_shared_variables(new_variables); } if !self.info_flag { - agent.update_shared_dynamic_instructions(false)?; + agent.update_shared_dynamic_instructions(false, self.app.config.tool_timeout)?; } Ok(()) } @@ -1199,15 +1199,16 @@ impl RequestContext { }; agent.set_session_variables(session_variables); if !self.info_flag { - agent.update_session_dynamic_instructions(None)?; + agent.update_session_dynamic_instructions(None, self.app.config.tool_timeout)?; } session.sync_agent(agent); } else { let variables = session.agent_variables(); agent.set_session_variables(variables.clone()); - agent.update_session_dynamic_instructions(Some( - session.agent_instructions().to_string(), - ))?; + agent.update_session_dynamic_instructions( + Some(session.agent_instructions().to_string()), + self.app.config.tool_timeout, + )?; } Ok(()) } diff --git a/src/function/jobs.rs b/src/function/jobs.rs index b191051..a63263e 100644 --- a/src/function/jobs.rs +++ b/src/function/jobs.rs @@ -143,8 +143,9 @@ pub fn job_function_declarations() -> Vec { directly. Backgroundable tools: external command tools (e.g. execute_command) and \ `mcp_invoke_*` calls; built-in `agent__`/`job__`/`user__`/`todo__`/`memory__`/`skill__` \ tools cannot be backgrounded. The job runs against a snapshot of the current config and \ - environment; later changes do not affect it. Process jobs honor COYOTE_TOOL_TIMEOUT; MCP \ - jobs have NO timeout — cancel a hung one with `job__cancel`. Jobs do not survive coyote \ + environment; later changes do not affect it. Process jobs honor the tool timeout \ + (`tool_timeout` config / COYOTE_TOOL_TIMEOUT env var); MCP jobs have NO timeout — cancel \ + a hung one with `job__cancel`. Jobs do not survive coyote \ exiting. In graph LLM nodes, jobs are node-local: collect or cancel every job you start \ before the node ends — leftovers are cancelled at node exit.".to_string(), parameters: JsonSchema { @@ -963,10 +964,7 @@ fn build_env_snapshot( args }; - let timeout_secs = env::var("COYOTE_TOOL_TIMEOUT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(1800); + let timeout_secs = super::tool_timeout_secs(ctx.app.config.tool_timeout); Ok(JobEnvSnapshot { cmd_name, @@ -1105,7 +1103,7 @@ async fn run_process_job( drain_output_file(&snapshot.output_file, &file_offset, &output_buf); let output_bytes_captured = output_buf.lock().total_written(); let message = format!( - "Tool call '{}' timed out after {}s and was killed (set COYOTE_TOOL_TIMEOUT to adjust; 0 = unlimited)", + "Tool call '{}' timed out after {}s and was killed (set tool_timeout in config or COYOTE_TOOL_TIMEOUT to adjust; 0 = unlimited)", snapshot.display_name, snapshot.timeout_secs ); diff --git a/src/function/mod.rs b/src/function/mod.rs index 362eedc..7708b1f 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -1677,7 +1677,14 @@ impl ToolCall { json!({"tool_call_error": error_msg}) }) } - _ => match run_llm_function(cmd_name, cmd_args, envs, agent_name, quiet) { + _ => match run_llm_function( + cmd_name, + cmd_args, + envs, + agent_name, + ctx.app.config.tool_timeout, + quiet, + ) { Ok(Some(contents)) => serde_json::from_str(&contents) .ok() .unwrap_or_else(|| json!({"output": contents})), @@ -2271,11 +2278,20 @@ fn clamp_metadata_field(object: &mut Value, key: &str) { object[key] = json!(clamped); } +pub(crate) fn tool_timeout_secs(config_value: Option) -> u64 { + env::var(get_env_name("tool_timeout")) + .ok() + .and_then(|v| v.parse::().ok()) + .or(config_value) + .unwrap_or(1800) +} + pub fn run_llm_function( cmd_name: String, cmd_args: Vec, mut envs: HashMap, agent_name: Option, + tool_timeout: Option, quiet: bool, ) -> Result> { let mut bin_dirs: Vec = vec![]; @@ -2395,10 +2411,7 @@ pub fn run_llm_function( buf }); - let timeout_secs = env::var("COYOTE_TOOL_TIMEOUT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(1800); + let timeout_secs = tool_timeout_secs(tool_timeout); let deadline = (timeout_secs > 0).then(|| Instant::now() + Duration::from_secs(timeout_secs)); let status = loop { match child.try_wait() { @@ -2414,7 +2427,7 @@ pub fn run_llm_function( drop(stdout_thread); drop(stderr_thread); let tool_error_message = format!( - "Tool call '{command_name}' timed out after {timeout_secs}s and was killed (set COYOTE_TOOL_TIMEOUT to adjust; 0 = unlimited)" + "Tool call '{command_name}' timed out after {timeout_secs}s and was killed (set tool_timeout in config or COYOTE_TOOL_TIMEOUT to adjust; 0 = unlimited)" ); emit_tool_warning( quiet, @@ -4546,6 +4559,7 @@ mod tests { ], HashMap::new(), None, + None, false, ) .unwrap() @@ -4573,6 +4587,7 @@ mod tests { ], HashMap::new(), None, + None, true, ) .unwrap() @@ -4589,6 +4604,32 @@ mod tests { assert_eq!(json["output"], "partial-output\n"); } + #[test] + #[serial] + fn tool_timeout_secs_prefers_env_then_config_then_default() { + let prev = env::var_os("COYOTE_TOOL_TIMEOUT"); + + unsafe { env::set_var("COYOTE_TOOL_TIMEOUT", "42") }; + assert_eq!(tool_timeout_secs(Some(900)), 42); + + unsafe { env::set_var("COYOTE_TOOL_TIMEOUT", "abc") }; + assert_eq!(tool_timeout_secs(Some(900)), 900); + + unsafe { env::set_var("COYOTE_TOOL_TIMEOUT", "0") }; + assert_eq!(tool_timeout_secs(Some(900)), 0); + + unsafe { env::remove_var("COYOTE_TOOL_TIMEOUT") }; + assert_eq!(tool_timeout_secs(Some(900)), 900); + assert_eq!(tool_timeout_secs(None), 1800); + + unsafe { + match prev { + Some(v) => env::set_var("COYOTE_TOOL_TIMEOUT", v), + None => env::remove_var("COYOTE_TOOL_TIMEOUT"), + } + } + } + #[test] fn bin_entry_stem_strips_run_prefix_and_extension() { assert_eq!(bin_entry_stem("fs_grep"), "fs_grep");