diff --git a/poc-memory/src/agents/consolidate.rs b/poc-memory/src/agents/consolidate.rs index aae1e85..50b6f22 100644 --- a/poc-memory/src/agents/consolidate.rs +++ b/poc-memory/src/agents/consolidate.rs @@ -233,6 +233,8 @@ pub fn apply_consolidation(store: &mut Store, do_apply: bool, report_key: Option println!(" REFINE {}", key), knowledge::ActionKind::Demote { key } => println!(" DEMOTE {}", key), + knowledge::ActionKind::Delete { key } => + println!(" DELETE {}", key), } } println!("\nTo apply: poc-memory apply-consolidation --apply"); diff --git a/poc-memory/src/agents/knowledge.rs b/poc-memory/src/agents/knowledge.rs index 166b33c..66aeb4b 100644 --- a/poc-memory/src/agents/knowledge.rs +++ b/poc-memory/src/agents/knowledge.rs @@ -51,6 +51,9 @@ pub enum ActionKind { Demote { key: String, }, + Delete { + key: String, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -180,11 +183,28 @@ pub fn parse_demotes(text: &str) -> Vec { .collect() } +pub fn parse_deletes(text: &str) -> Vec { + let re = Regex::new(r"(?m)^DELETE\s+(\S+)").unwrap(); + re.captures_iter(text) + .map(|cap| Action { + kind: ActionKind::Delete { + key: cap[1].to_string(), + }, + confidence: Confidence::High, + weight: 1.0, + depth: 0, + applied: None, + rejected_reason: None, + }) + .collect() +} + pub fn parse_all_actions(text: &str) -> Vec { let mut actions = parse_write_nodes(text); actions.extend(parse_links(text)); actions.extend(parse_refines(text)); actions.extend(parse_demotes(text)); + actions.extend(parse_deletes(text)); actions } @@ -243,7 +263,7 @@ fn agent_base_depth(agent: &str) -> Option { pub fn compute_action_depth(db: &DepthDb, action: &Action, agent: &str) -> i32 { match &action.kind { - ActionKind::Link { .. } | ActionKind::Demote { .. } => -1, + ActionKind::Link { .. } | ActionKind::Demote { .. } | ActionKind::Delete { .. } => -1, ActionKind::Refine { key, .. } => db.get(key), ActionKind::WriteNode { covers, .. } => { if !covers.is_empty() { @@ -336,6 +356,9 @@ pub fn apply_action( false } } + ActionKind::Delete { key } => { + store.delete_node(key).is_ok() + } } } @@ -582,7 +605,7 @@ pub fn run_one_agent( .ok_or_else(|| format!("no .agent file for {}", agent_name))?; let agent_batch = super::defs::run_agent(store, &def, batch_size)?; - let output = llm::call_sonnet(llm_tag, &agent_batch.prompt)?; + let output = llm::call_for_def(&def, &agent_batch.prompt)?; // Store raw output for audit trail let ts = store::compact_timestamp(); @@ -920,6 +943,9 @@ fn run_cycle( ActionKind::Demote { key } => { eprintln!(" DEMOTE {}", key); } + ActionKind::Delete { key } => { + eprintln!(" DELETE {}", key); + } } if apply_action(&mut store, action, agent_name, ×tamp, depth) { diff --git a/poc-memory/src/agents/llm.rs b/poc-memory/src/agents/llm.rs index 81cdfc6..9ae6575 100644 --- a/poc-memory/src/agents/llm.rs +++ b/poc-memory/src/agents/llm.rs @@ -40,6 +40,8 @@ fn log_usage(agent: &str, model: &str, prompt: &str, response: &str, /// Maximum time to wait for a claude subprocess before killing it. const SUBPROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); // 5 minutes +/// Longer timeout for agents with tool access (multi-turn conversations). +const TOOL_AGENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900); // 15 minutes /// Call a model via claude CLI. Returns the response text. /// @@ -47,6 +49,19 @@ const SUBPROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3 /// parent daemon exits — no more orphaned claude processes. /// Times out after 5 minutes to prevent blocking the daemon forever. fn call_model(agent: &str, model: &str, prompt: &str) -> Result { + call_model_with_tools(agent, model, prompt, &[]) +} + +/// Call a model via claude CLI, optionally with allowed tools. +/// +/// When `tools` is empty, all tools are disabled (`--tools ""`). +/// When `tools` has entries, they're passed as `--allowedTools` patterns +/// (e.g. `["Bash(poc-memory:*)"]`), letting the agent call those tools +/// in Claude's native tool loop. +fn call_model_with_tools(agent: &str, model: &str, prompt: &str, + tools: &[String]) -> Result { + let timeout = if tools.is_empty() { SUBPROCESS_TIMEOUT } else { TOOL_AGENT_TIMEOUT }; + // Write prompt to temp file (claude CLI needs file input for large prompts) let tmp = std::env::temp_dir().join(format!("poc-llm-{}-{:?}.txt", std::process::id(), std::thread::current().id())); @@ -54,8 +69,17 @@ fn call_model(agent: &str, model: &str, prompt: &str) -> Result .map_err(|e| format!("write temp prompt: {}", e))?; let mut cmd = Command::new("claude"); - cmd.args(["-p", "--model", model, "--tools", "", "--no-session-persistence", - "--strict-mcp-config"]) + if tools.is_empty() { + cmd.args(["-p", "--model", model, "--tools", "", "--no-session-persistence", + "--strict-mcp-config"]); + } else { + cmd.args(["-p", "--model", model, "--no-session-persistence", + "--strict-mcp-config", "--allowedTools"]); + for tool in tools { + cmd.arg(tool); + } + } + cmd .stdin(fs::File::open(&tmp).map_err(|e| format!("open temp: {}", e))?) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -87,7 +111,7 @@ fn call_model(agent: &str, model: &str, prompt: &str) -> Result let cancel_flag = cancel.clone(); let watchdog = std::thread::spawn(move || { // Sleep in 1s increments so we can check the cancel flag - let deadline = std::time::Instant::now() + SUBPROCESS_TIMEOUT; + let deadline = std::time::Instant::now() + timeout; while std::time::Instant::now() < deadline { if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { return; @@ -119,7 +143,7 @@ fn call_model(agent: &str, model: &str, prompt: &str) -> Result match result { Ok(output) => { let elapsed = start.elapsed().as_millis(); - if elapsed > SUBPROCESS_TIMEOUT.as_millis() - 1000 { + if elapsed > timeout.as_millis() - 1000 { log_usage(agent, model, prompt, "TIMEOUT", elapsed, false); return Err(format!("claude timed out after {:.0}s", elapsed as f64 / 1000.0)); } @@ -148,6 +172,11 @@ pub(crate) fn call_haiku(agent: &str, prompt: &str) -> Result { call_model(agent, "haiku", prompt) } +/// Call a model using an agent definition's model and tool configuration. +pub(crate) fn call_for_def(def: &super::defs::AgentDef, prompt: &str) -> Result { + call_model_with_tools(&def.agent, &def.model, prompt, &def.tools) +} + /// Parse a JSON response, handling markdown fences. pub(crate) fn parse_json_response(response: &str) -> Result { let cleaned = response.trim();