agents: multi-step agent support

Split agent prompts on === PROMPT === delimiter. Each step runs as
a new user message in the same LLM conversation, so context carries
forward naturally between steps. Single-step agents are unchanged.

- AgentDef.prompt -> AgentDef.prompts: Vec<String>
- AgentBatch.prompt -> AgentBatch.prompts: Vec<String>
- API layer injects next prompt after each text response
- {{conversation:N}} parameterized byte budget for conversation context

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-03-26 14:21:43 -04:00
parent baf208281d
commit 77d1d39f3f
6 changed files with 166 additions and 65 deletions

View file

@ -26,11 +26,14 @@ fn get_client() -> Result<&'static ApiClient, String> {
}))
}
/// Run an agent prompt through the direct API with tool support.
/// Returns the final text response after all tool calls are resolved.
/// Run agent prompts through the direct API with tool support.
/// For multi-step agents, each prompt is injected as a new user message
/// after the previous step's tool loop completes. The conversation
/// context carries forward naturally between steps.
/// Returns the final text response after all steps complete.
pub async fn call_api_with_tools(
agent: &str,
prompt: &str,
prompts: &[String],
temperature: Option<f32>,
log: &dyn Fn(&str),
) -> Result<String, String> {
@ -39,15 +42,18 @@ pub async fn call_api_with_tools(
// Set up a UI channel — we drain reasoning tokens into the log
let (ui_tx, mut ui_rx) = crate::agent::ui_channel::channel();
// Build tool definitions — memory tools for graph operations
// Build tool definitions — memory and journal tools for graph operations
let all_defs = tools::definitions();
let tool_defs: Vec<ToolDef> = all_defs.into_iter()
.filter(|d| d.function.name.starts_with("memory_"))
.filter(|d| d.function.name.starts_with("memory_")
|| d.function.name.starts_with("journal_")
|| d.function.name == "output")
.collect();
let tracker = ProcessTracker::new();
// Start with the prompt as a user message
let mut messages = vec![Message::user(prompt)];
// Start with the first prompt as a user message
let mut messages = vec![Message::user(&prompts[0])];
let mut next_prompt_idx = 1; // index of next prompt to inject
let reasoning = crate::config::get().api_reasoning.clone();
let max_turns = 50;
@ -65,8 +71,15 @@ pub async fn call_api_with_tools(
let msg_bytes: usize = messages.iter()
.map(|m| m.content_text().len())
.sum();
format!("API error on turn {} (~{}KB payload, {} messages): {}",
turn, msg_bytes / 1024, messages.len(), e)
let err_str = e.to_string();
let hint = if err_str.contains("IncompleteMessage") || err_str.contains("connection closed") {
format!(" — likely exceeded model context window (~{}KB ≈ {}K tokens)",
msg_bytes / 1024, msg_bytes / 4096)
} else {
String::new()
};
format!("API error on turn {} (~{}KB payload, {} messages): {}{}",
turn, msg_bytes / 1024, messages.len(), e, hint)
})?;
if let Some(u) = &usage {
@ -125,7 +138,9 @@ pub async fn call_api_with_tools(
}
};
let output = if call.function.name.starts_with("memory_") {
let output = if call.function.name.starts_with("memory_")
|| call.function.name.starts_with("journal_")
|| call.function.name == "output" {
let prov = format!("agent:{}", agent);
match crate::agent::tools::memory::dispatch(
&call.function.name, &args, Some(&prov),
@ -151,7 +166,7 @@ pub async fn call_api_with_tools(
continue;
}
// Text-only response — we're done
// Text-only response — step complete
let text = msg.content_text().to_string();
if text.is_empty() && !has_content {
log("empty response, retrying");
@ -162,6 +177,17 @@ pub async fn call_api_with_tools(
}
log(&format!("\n=== RESPONSE ===\n\n{}", text));
// If there are more prompts, inject the next one and continue
if next_prompt_idx < prompts.len() {
messages.push(Message::assistant(&text));
let next = &prompts[next_prompt_idx];
next_prompt_idx += 1;
log(&format!("\n=== STEP {}/{} ===\n", next_prompt_idx, prompts.len()));
messages.push(Message::user(next));
continue;
}
return Ok(text);
}
@ -172,7 +198,7 @@ pub async fn call_api_with_tools(
/// with its own tokio runtime. Safe to call from any context.
pub fn call_api_with_tools_sync(
agent: &str,
prompt: &str,
prompts: &[String],
temperature: Option<f32>,
log: &(dyn Fn(&str) + Sync),
) -> Result<String, String> {
@ -185,7 +211,7 @@ pub fn call_api_with_tools_sync(
let prov = format!("agent:{}", agent);
rt.block_on(
crate::store::TASK_PROVENANCE.scope(prov,
call_api_with_tools(agent, prompt, temperature, log))
call_api_with_tools(agent, prompts, temperature, log))
)
}).join().unwrap()
})