feat: support setting the tool_timeout in the main config file instead of only via an environment variable
This commit is contained in:
8 files changed
+125
-23
No files matched your search
+14
-5
@@ -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<u64>,
|
||||
) -> 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<String>) -> Result<()> {
|
||||
pub fn update_session_dynamic_instructions(
|
||||
&mut self,
|
||||
value: Option<String>,
|
||||
tool_timeout: Option<u64>,
|
||||
) -> 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<String> {
|
||||
fn run_instructions_fn(&self, tool_timeout: Option<u64>) -> Result<String> {
|
||||
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 {
|
||||
|
||||
@@ -70,6 +70,7 @@ pub struct AppConfig {
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
pub tool_timeout: Option<u64>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
@@ -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::<usize>(&get_env_name("max_concurrent_jobs")) {
|
||||
self.max_concurrent_jobs = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<u64>(&get_env_name("tool_timeout")) {
|
||||
self.tool_timeout = v;
|
||||
}
|
||||
if let Some(v) = super::read_env_value::<String>(&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 =
|
||||
|
||||
@@ -256,6 +256,7 @@ pub struct Config {
|
||||
pub summary_context_prompt: Option<String>,
|
||||
pub max_tool_result_chars: Option<usize>,
|
||||
pub max_concurrent_jobs: Option<usize>,
|
||||
pub tool_timeout: Option<u64>,
|
||||
|
||||
pub memory: Option<bool>,
|
||||
pub memory_cap_with_tools: Option<usize>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -143,8 +143,9 @@ pub fn job_function_declarations() -> Vec<FunctionDeclaration> {
|
||||
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::<u64>().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
|
||||
);
|
||||
|
||||
|
||||
+47
-6
@@ -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>) -> u64 {
|
||||
env::var(get_env_name("tool_timeout"))
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.or(config_value)
|
||||
.unwrap_or(1800)
|
||||
}
|
||||
|
||||
pub fn run_llm_function(
|
||||
cmd_name: String,
|
||||
cmd_args: Vec<String>,
|
||||
mut envs: HashMap<String, String>,
|
||||
agent_name: Option<String>,
|
||||
tool_timeout: Option<u64>,
|
||||
quiet: bool,
|
||||
) -> Result<Option<String>> {
|
||||
let mut bin_dirs: Vec<PathBuf> = 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::<u64>().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");
|
||||
|
||||
Reference in new issue
Block a user