diff --git a/poc-agent/src/tools/bash.rs b/poc-agent/src/tools/bash.rs index d108f49..cf5bcac 100644 --- a/poc-agent/src/tools/bash.rs +++ b/poc-agent/src/tools/bash.rs @@ -168,13 +168,7 @@ pub async fn run_bash(args: &serde_json::Value, tracker: &ProcessTracker) -> Res result = "(no output)".to_string(); } - const MAX_OUTPUT: usize = 30000; - if result.len() > MAX_OUTPUT { - result.truncate(MAX_OUTPUT); - result.push_str("\n... (output truncated)"); - } - - Ok(result) + Ok(super::truncate_output(result, 30000)) } Ok(Err(e)) => { Err(anyhow::anyhow!("Command failed: {}", e)) diff --git a/poc-agent/src/tools/control.rs b/poc-agent/src/tools/control.rs new file mode 100644 index 0000000..3559b06 --- /dev/null +++ b/poc-agent/src/tools/control.rs @@ -0,0 +1,103 @@ +// tools/control.rs — Agent control tools +// +// Tools that affect agent control flow rather than performing work. +// These return Result to maintain consistency with other +// tools that can fail. The dispatch function handles error wrapping. + +use anyhow::{Context, Result}; + +use super::ToolOutput; +use crate::types::ToolDef; + +pub fn pause(_args: &serde_json::Value) -> Result { + Ok(ToolOutput { + text: "Pausing autonomous behavior. Only user input will wake you.".to_string(), + is_yield: true, + images: Vec::new(), + model_switch: None, + dmn_pause: true, + }) +} + +pub fn switch_model(args: &serde_json::Value) -> Result { + let model = args + .get("model") + .and_then(|v| v.as_str()) + .context("'model' parameter is required")?; + if model.is_empty() { + anyhow::bail!("'model' parameter cannot be empty"); + } + Ok(ToolOutput { + text: format!("Switching to model '{}' after this turn.", model), + is_yield: false, + images: Vec::new(), + model_switch: Some(model.to_string()), + dmn_pause: false, + }) +} + +pub fn yield_to_user(args: &serde_json::Value) -> Result { + let msg = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Waiting for input."); + Ok(ToolOutput { + text: format!("Yielding. {}", msg), + is_yield: true, + images: Vec::new(), + model_switch: None, + dmn_pause: false, + }) +} + +pub fn definitions() -> Vec { + vec![ + ToolDef::new( + "switch_model", + "Switch to a different LLM model mid-conversation. The switch \ + takes effect after the current turn completes. Use this when \ + a task would benefit from a different model's strengths. \ + Your memories and conversation history carry over.", + serde_json::json!({ + "type": "object", + "properties": { + "model": { + "type": "string", + "description": "Name of the model to switch to (configured in config.json5)" + } + }, + "required": ["model"] + }), + ), + ToolDef::new( + "pause", + "Pause all autonomous behavior (DMN). You will only run when \ + the user types something. Use this as a safety valve when \ + you're stuck in a loop, confused, or want to fully stop. \ + NOTE: only the user can unpause (Ctrl+P or /wake) — you \ + cannot undo this yourself.", + serde_json::json!({ + "type": "object", + "properties": {} + }), + ), + ToolDef::new( + "yield_to_user", + "Signal that you want to wait for user input before continuing. \ + Call this when you have a question for the user, when you've \ + completed their request and want feedback, or when you genuinely \ + want to pause. This is the ONLY way to enter a waiting state — \ + without calling this tool, the agent loop will keep prompting you \ + after a brief interval.", + serde_json::json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Optional status message (e.g., 'Waiting for your thoughts on the design')" + } + } + }), + ), + ] +} diff --git a/poc-agent/src/tools/glob_tool.rs b/poc-agent/src/tools/glob_tool.rs index df8f362..32ccb6f 100644 --- a/poc-agent/src/tools/glob_tool.rs +++ b/poc-agent/src/tools/glob_tool.rs @@ -73,13 +73,6 @@ pub fn glob_search(args: &serde_json::Value) -> Result { output.push('\n'); } - // Truncate if too many - const MAX_OUTPUT: usize = 30000; - if output.len() > MAX_OUTPUT { - output.truncate(MAX_OUTPUT); - output.push_str("\n... (output truncated)"); - } - output.push_str(&format!("\n({} files matched)", entries.len())); - Ok(output) + Ok(super::truncate_output(output, 30000)) } diff --git a/poc-agent/src/tools/grep.rs b/poc-agent/src/tools/grep.rs index 84278db..64e0bde 100644 --- a/poc-agent/src/tools/grep.rs +++ b/poc-agent/src/tools/grep.rs @@ -51,84 +51,75 @@ fn has_rg() -> bool { } pub fn grep(args: &serde_json::Value) -> Result { - let pattern = args["pattern"].as_str().context("pattern is required")?; + let pattern = get_str(args, "pattern")?; let path = args["path"].as_str().unwrap_or("."); let file_glob = args["glob"].as_str(); let show_content = args["show_content"].as_bool().unwrap_or(false); let context = args["context_lines"].as_u64(); let output = if has_rg() { - run_rg(pattern, path, file_glob, show_content, context)? + run_search("rg", pattern, path, file_glob, show_content, context, true)? } else { - run_grep(pattern, path, file_glob, show_content, context)? + run_search("grep", pattern, path, file_glob, show_content, context, false)? }; if output.is_empty() { return Ok("No matches found.".to_string()); } - let mut result = output; - const MAX_OUTPUT: usize = 30000; - if result.len() > MAX_OUTPUT { - result.truncate(MAX_OUTPUT); - result.push_str("\n... (output truncated)"); - } - - Ok(result) + Ok(super::truncate_output(output, 30000)) } -fn run_rg( +/// Run a grep/rg search. Unified implementation for both tools. +fn run_search( + tool: &str, pattern: &str, path: &str, file_glob: Option<&str>, show_content: bool, context: Option, + use_rg: bool, ) -> Result { - let mut cmd = Command::new("rg"); + let mut cmd = Command::new(tool); - if show_content { - cmd.arg("-n"); - if let Some(c) = context { - cmd.arg("-C").arg(c.to_string()); + if use_rg { + // ripgrep args + if show_content { + cmd.arg("-n"); + if let Some(c) = context { + cmd.arg("-C").arg(c.to_string()); + } + } else { + cmd.arg("--files-with-matches"); + } + if let Some(g) = file_glob { + cmd.arg("--glob").arg(g); } } else { - cmd.arg("--files-with-matches"); - } - - if let Some(g) = file_glob { - cmd.arg("--glob").arg(g); + // grep args + cmd.arg("-r"); // recursive + if show_content { + cmd.arg("-n"); // line numbers + if let Some(c) = context { + cmd.arg("-C").arg(c.to_string()); + } + } else { + cmd.arg("-l"); // files-with-matches + } + if let Some(g) = file_glob { + cmd.arg("--include").arg(g); + } + cmd.arg("-E"); // extended regex } cmd.arg(pattern).arg(path); - let output = cmd.output().context("Failed to run rg")?; + let output = cmd.output().with_context(|| format!("Failed to run {}", tool))?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } -fn run_grep( - pattern: &str, - path: &str, - file_glob: Option<&str>, - show_content: bool, - context: Option, -) -> Result { - let mut cmd = Command::new("grep"); - cmd.arg("-r"); // recursive - - if show_content { - cmd.arg("-n"); // line numbers - if let Some(c) = context { - cmd.arg("-C").arg(c.to_string()); - } - } else { - cmd.arg("-l"); // files-with-matches - } - - if let Some(g) = file_glob { - cmd.arg("--include").arg(g); - } - - cmd.arg("-E"); // extended regex - cmd.arg(pattern).arg(path); - let output = cmd.output().context("Failed to run grep")?; - Ok(String::from_utf8_lossy(&output.stdout).to_string()) +/// Helper: get required string argument. +fn get_str<'a>(args: &'a serde_json::Value, name: &'a str) -> Result<&'a str> { + args.get(name) + .and_then(|v| v.as_str()) + .context(format!("{} is required", name)) } diff --git a/poc-agent/src/tools/memory.rs b/poc-agent/src/tools/memory.rs index ce50372..cfa7ffc 100644 --- a/poc-agent/src/tools/memory.rs +++ b/poc-agent/src/tools/memory.rs @@ -6,7 +6,8 @@ use anyhow::{Context, Result}; use serde_json::json; -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; use crate::types::ToolDef; @@ -177,106 +178,58 @@ pub fn definitions() -> Vec { /// Dispatch a memory tool call. Shells out to poc-memory CLI. pub fn dispatch(name: &str, args: &serde_json::Value, provenance: Option<&str>) -> Result { - match name { + let result = match name { "memory_render" => { - let key = args["key"].as_str().context("key is required")?; - run_poc_memory(&["render", key], provenance) + let key = get_str(args, "key")?; + cmd(&["render", key], provenance)? } "memory_write" => { - let key = args["key"].as_str().context("key is required")?; - let content = args["content"].as_str().context("content is required")?; - let mut cmd = Command::new("poc-memory"); - cmd.args(["write", key]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - if let Some(prov) = provenance { - cmd.env("POC_PROVENANCE", prov); - } - let mut child = cmd.spawn() - .context("spawn poc-memory write")?; - use std::io::Write; - child.stdin.take().unwrap().write_all(content.as_bytes()) - .context("write content to stdin")?; - let output = child.wait_with_output().context("wait poc-memory write")?; - Ok(String::from_utf8_lossy(&output.stdout).to_string() - + &String::from_utf8_lossy(&output.stderr)) + let key = get_str(args, "key")?; + let content = get_str(args, "content")?; + write_node(key, content, provenance)? } "memory_search" => { - let query = args["query"].as_str().context("query is required")?; - run_poc_memory(&["search", query], provenance) + let query = get_str(args, "query")?; + cmd(&["search", query], provenance)? } "memory_links" => { - let key = args["key"].as_str().context("key is required")?; - run_poc_memory(&["graph", "link", key], provenance) + let key = get_str(args, "key")?; + cmd(&["graph", "link", key], provenance)? } "memory_link_set" => { - let source = args["source"].as_str().context("source is required")?; - let target = args["target"].as_str().context("target is required")?; - let strength = args["strength"].as_f64().context("strength is required")?; - run_poc_memory(&["graph", "link-set", source, target, &format!("{:.2}", strength)], provenance) + let source = get_str(args, "source")?; + let target = get_str(args, "target")?; + let strength = get_f64(args, "strength")?; + cmd(&["graph", "link-set", source, target, &format!("{:.2}", strength)], provenance)? } "memory_link_add" => { - let source = args["source"].as_str().context("source is required")?; - let target = args["target"].as_str().context("target is required")?; - run_poc_memory(&["graph", "link-add", source, target], provenance) + let source = get_str(args, "source")?; + let target = get_str(args, "target")?; + cmd(&["graph", "link-add", source, target], provenance)? } "memory_used" => { - let key = args["key"].as_str().context("key is required")?; - run_poc_memory(&["used", key], provenance) + let key = get_str(args, "key")?; + cmd(&["used", key], provenance)? } "memory_weight_set" => { - let key = args["key"].as_str().context("key is required")?; - let weight = args["weight"].as_f64().context("weight is required")?; - run_poc_memory(&["weight-set", key, &format!("{:.2}", weight)], provenance) + let key = get_str(args, "key")?; + let weight = get_f64(args, "weight")?; + cmd(&["weight-set", key, &format!("{:.2}", weight)], provenance)? } - "memory_supersede" => { - let old_key = args["old_key"].as_str().context("old_key is required")?; - let new_key = args["new_key"].as_str().context("new_key is required")?; - let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("superseded"); - - // Read old node, prepend superseded notice, write back, set weight to 0.01 - let old_content = run_poc_memory(&["render", old_key], provenance).unwrap_or_default(); - // Strip the links section from render output - let content_only = old_content.split("\n\n---\nLinks:").next().unwrap_or(&old_content); - let notice = format!( - "**SUPERSEDED** by `{}` — {}\n\nOriginal content preserved below for reference.\n\n---\n\n{}", - new_key, reason, content_only.trim() - ); - let mut cmd = Command::new("poc-memory"); - cmd.args(["write", old_key]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - if let Some(prov) = provenance { - cmd.env("POC_PROVENANCE", prov); - } - let mut child = cmd.spawn() - .context("spawn poc-memory write")?; - use std::io::Write; - child.stdin.take().unwrap().write_all(notice.as_bytes()) - .context("write supersede notice")?; - let output = child.wait_with_output().context("wait poc-memory write")?; - let write_result = String::from_utf8_lossy(&output.stdout).to_string(); - - // Set weight to 0.01 - let weight_result = run_poc_memory(&["weight-set", old_key, "0.01"], provenance) - .unwrap_or_else(|e| format!("weight-set failed: {}", e)); - - Ok(format!("{}\n{}", write_result.trim(), weight_result.trim())) - } - _ => Err(anyhow::anyhow!("Unknown memory tool: {}", name)), - } + "memory_supersede" => supersede(args, provenance)?, + _ => anyhow::bail!("Unknown memory tool: {}", name), + }; + Ok(result) } -fn run_poc_memory(args: &[&str], provenance: Option<&str>) -> Result { +/// Run poc-memory command and return stdout. +fn cmd(args: &[&str], provenance: Option<&str>) -> Result { let mut cmd = Command::new("poc-memory"); cmd.args(args); if let Some(prov) = provenance { cmd.env("POC_PROVENANCE", prov); } - let output = cmd.output() - .context("run poc-memory")?; + let output = cmd.output().context("run poc-memory")?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if output.status.success() { @@ -285,3 +238,60 @@ fn run_poc_memory(args: &[&str], provenance: Option<&str>) -> Result { Ok(format!("{}{}", stdout, stderr)) } } + +/// Write content to a node via stdin. +fn write_node(key: &str, content: &str, provenance: Option<&str>) -> Result { + let mut cmd = Command::new("poc-memory"); + cmd.args(["write", key]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(prov) = provenance { + cmd.env("POC_PROVENANCE", prov); + } + let mut child = cmd.spawn().context("spawn poc-memory write")?; + child.stdin.take().unwrap().write_all(content.as_bytes()) + .context("write content to stdin")?; + let output = child.wait_with_output().context("wait poc-memory write")?; + Ok(String::from_utf8_lossy(&output.stdout).to_string() + + &String::from_utf8_lossy(&output.stderr)) +} + +/// Handle memory_supersede - reads old node, prepends notice, writes back, sets weight. +fn supersede(args: &serde_json::Value, provenance: Option<&str>) -> Result { + let old_key = get_str(args, "old_key")?; + let new_key = get_str(args, "new_key")?; + let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("superseded"); + + // Read old node + let old_content = cmd(&["render", old_key], provenance)?; + let content_only = old_content.split("\n\n---\nLinks:").next().unwrap_or(&old_content); + + // Prepend superseded notice + let notice = format!( + "**SUPERSEDED** by `{}` — {}\n\nOriginal content preserved below for reference.\n\n---\n\n{}", + new_key, reason, content_only.trim() + ); + + // Write back + let write_result = write_node(old_key, ¬ice, provenance)?; + + // Set weight to 0.01 + let weight_result = cmd(&["weight-set", old_key, "0.01"], provenance)?; + + Ok(format!("{}\n{}", write_result.trim(), weight_result.trim())) +} + +/// Helper: get required string argument. +fn get_str<'a>(args: &'a serde_json::Value, name: &'a str) -> Result<&'a str> { + args.get(name) + .and_then(|v| v.as_str()) + .context(format!("{} is required", name)) +} + +/// Helper: get required f64 argument. +fn get_f64(args: &serde_json::Value, name: &str) -> Result { + args.get(name) + .and_then(|v| v.as_f64()) + .context(format!("{} is required", name)) +} diff --git a/poc-agent/src/tools/mod.rs b/poc-agent/src/tools/mod.rs index c8a1c0b..750cdb7 100644 --- a/poc-agent/src/tools/mod.rs +++ b/poc-agent/src/tools/mod.rs @@ -10,6 +10,7 @@ // immediately from an async fn. mod bash; +mod control; mod edit; mod glob_tool; mod grep; @@ -35,60 +36,64 @@ pub struct ToolOutput { pub dmn_pause: bool, } -/// Dispatch a tool call by name, returning the result as a string. -/// Returns (output, is_yield) — is_yield is true only for yield_to_user. +impl ToolOutput { + fn error(e: impl std::fmt::Display) -> Self { + Self { + text: format!("Error: {}", e), + is_yield: false, + images: Vec::new(), + model_switch: None, + dmn_pause: false, + } + } + + fn text(s: String) -> Self { + Self { + text: s, + is_yield: false, + images: Vec::new(), + model_switch: None, + dmn_pause: false, + } + } +} + +/// Truncate output if it exceeds max length, appending a truncation notice. +/// Used by tools that can produce large amounts of output (bash, grep, glob, etc). +pub fn truncate_output(mut s: String, max: usize) -> String { + if s.len() > max { + s.truncate(max); + s.push_str("\n... (output truncated)"); + } + s +} + +/// Dispatch a tool call by name. +/// +/// Control tools (pause, switch_model, yield_to_user) and view_image +/// return Result. Regular tools return Result and +/// get wrapped in a text-only ToolOutput. +/// +/// Note: working_stack is handled in agent.rs before reaching this +/// function (it needs mutable context access). pub async fn dispatch( name: &str, args: &serde_json::Value, tracker: &ProcessTracker, ) -> ToolOutput { - if name == "pause" { - return ToolOutput { - text: "Pausing autonomous behavior. Only user input will wake you.".to_string(), - is_yield: true, - images: Vec::new(), - model_switch: None, - dmn_pause: true, - }; - } - - if name == "switch_model" { - let model = args - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if model.is_empty() { - return ToolOutput { - text: "Error: 'model' parameter is required".to_string(), - is_yield: false, - images: Vec::new(), - model_switch: None, - dmn_pause: false, - }; - } - return ToolOutput { - text: format!("Switching to model '{}' after this turn.", model), - is_yield: false, - images: Vec::new(), - model_switch: Some(model.to_string()), - dmn_pause: false, - }; - } - - if name == "yield_to_user" { - let msg = args - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("Waiting for input."); - return ToolOutput { - text: format!("Yielding. {}", msg), - is_yield: true, - images: Vec::new(), - model_switch: None, - dmn_pause: false, - }; + // Tools that return Result directly + let rich_result = match name { + "pause" => Some(control::pause(args)), + "switch_model" => Some(control::switch_model(args)), + "yield_to_user" => Some(control::yield_to_user(args)), + "view_image" => Some(vision::view_image(args)), + _ => None, + }; + if let Some(result) = rich_result { + return result.unwrap_or_else(ToolOutput::error); } + // Regular tools — return Result let result = match name { "read_file" => read::read_file(args), "write_file" => write::write_file(args), @@ -97,37 +102,13 @@ pub async fn dispatch( "grep" => grep::grep(args), "glob" => glob_tool::glob_search(args), "journal" => journal::write_entry(args), - "working_stack" => { - // working_stack needs mutable access to agent's context state - // This is handled specially in agent.rs - Err(anyhow::anyhow!("working_stack handled by agent")) - } n if n.starts_with("memory_") => memory::dispatch(n, args, None), - "view_image" => { - return match vision::view_image(args) { - Ok(output) => output, - Err(e) => ToolOutput { - text: format!("Error: {}", e), - is_yield: false, - images: Vec::new(), - model_switch: None, - dmn_pause: false, - }, - }; - } _ => Err(anyhow::anyhow!("Unknown tool: {}", name)), }; - let text = match result { - Ok(output) => output, - Err(e) => format!("Error: {}", e), - }; - ToolOutput { - text, - is_yield: false, - images: Vec::new(), - model_switch: None, - dmn_pause: false, + match result { + Ok(s) => ToolOutput::text(s), + Err(e) => ToolOutput::error(e), } } @@ -143,54 +124,8 @@ pub fn definitions() -> Vec { vision::definition(), journal::definition(), working_stack::definition(), - ToolDef::new( - "switch_model", - "Switch to a different LLM model mid-conversation. The switch \ - takes effect after the current turn completes. Use this when \ - a task would benefit from a different model's strengths. \ - Your memories and conversation history carry over.", - serde_json::json!({ - "type": "object", - "properties": { - "model": { - "type": "string", - "description": "Name of the model to switch to (configured in config.json5)" - } - }, - "required": ["model"] - }), - ), - ToolDef::new( - "pause", - "Pause all autonomous behavior (DMN). You will only run when \ - the user types something. Use this as a safety valve when \ - you're stuck in a loop, confused, or want to fully stop. \ - NOTE: only the user can unpause (Ctrl+P or /wake) — you \ - cannot undo this yourself.", - serde_json::json!({ - "type": "object", - "properties": {} - }), - ), - ToolDef::new( - "yield_to_user", - "Signal that you want to wait for user input before continuing. \ - Call this when you have a question for the user, when you've \ - completed their request and want feedback, or when you genuinely \ - want to pause. This is the ONLY way to enter a waiting state — \ - without calling this tool, the agent loop will keep prompting you \ - after a brief interval.", - serde_json::json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Optional status message (e.g., 'Waiting for your thoughts on the design')" - } - } - }), - ), ].into_iter() + .chain(control::definitions()) .chain(memory::definitions()) .collect() }