agents: phase tracking, pid files, pipelining, unified cycle

- AgentStep with phase labels (=== PROMPT phase:name ===)
- PID files in state dir (pid-{PID} with JSON phase/timestamp)
- Built-in bail check: between steps, bail if other pid files exist
- surface_observe_cycle replaces surface_agent_cycle + journal_agent_cycle
- Reads surface output from state dir instead of parsing stdout
- Pipelining: starts new agent if running one is past surface phase
- link_set upserts (creates link if missing)
- Better error message for context window overflow

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
ProofOfConcept 2026-03-26 14:48:42 -04:00
parent 11289667f5
commit e20aeeeabe
8 changed files with 256 additions and 178 deletions

View file

@ -26,12 +26,20 @@ use std::path::PathBuf;
/// Agent definition: config (from JSON header) + prompt (raw markdown body).
#[derive(Clone, Debug)]
/// A single step in a multi-step agent.
pub struct AgentStep {
pub prompt: String,
/// Phase label for PID file tracking (e.g. "surface", "observe").
/// Parsed from `=== PROMPT phase:name ===` or auto-generated as "step-N".
pub phase: String,
}
pub struct AgentDef {
pub agent: String,
pub query: String,
/// Prompt steps — single-step agents have one entry, multi-step have several.
/// Steps — single-step agents have one entry, multi-step have several.
/// Steps are separated by `=== PROMPT ===` in the .agent file.
pub prompts: Vec<String>,
pub steps: Vec<AgentStep>,
pub model: String,
pub schedule: String,
pub tools: Vec<String>,
@ -70,28 +78,64 @@ struct AgentHeader {
fn default_model() -> String { "sonnet".into() }
/// Parse an agent file: first line is JSON config, rest is the prompt(s).
/// Multiple prompts are separated by `=== PROMPT ===` lines.
/// Multiple prompts are separated by `=== PROMPT [phase:name] ===` lines.
fn parse_agent_file(content: &str) -> Option<AgentDef> {
let (first_line, rest) = content.split_once('\n')?;
let header: AgentHeader = serde_json::from_str(first_line.trim()).ok()?;
// Skip optional blank line between header and prompt body
let body = rest.strip_prefix('\n').unwrap_or(rest);
// Split on === PROMPT === delimiter for multi-step agents
let prompts: Vec<String> = body
.split("\n=== PROMPT ===\n")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Split on === PROMPT ... === lines, capturing the delimiter content
let mut steps: Vec<AgentStep> = Vec::new();
let mut current_prompt = String::new();
let mut current_phase: Option<String> = None;
let mut step_num = 0;
if prompts.is_empty() {
for line in body.lines() {
if line.starts_with("=== PROMPT") && line.ends_with("===") {
// Save previous step if any
let trimmed = current_prompt.trim().to_string();
if !trimmed.is_empty() {
steps.push(AgentStep {
prompt: trimmed,
phase: current_phase.take()
.unwrap_or_else(|| format!("step-{}", step_num)),
});
step_num += 1;
}
// Parse delimiter: === PROMPT [phase:name] ===
let inner = line.strip_prefix("=== PROMPT").unwrap()
.strip_suffix("===").unwrap().trim();
current_phase = inner.strip_prefix("phase:")
.map(|s| s.trim().to_string());
current_prompt.clear();
} else {
if !current_prompt.is_empty() {
current_prompt.push('\n');
}
current_prompt.push_str(line);
}
}
// Save final step
let trimmed = current_prompt.trim().to_string();
if !trimmed.is_empty() {
steps.push(AgentStep {
prompt: trimmed,
phase: current_phase.take()
.unwrap_or_else(|| format!("step-{}", step_num)),
});
}
if steps.is_empty() {
return None;
}
Some(AgentDef {
agent: header.agent,
query: header.query,
prompts,
steps,
model: header.model,
schedule: header.schedule,
tools: header.tools,
@ -744,18 +788,21 @@ pub fn run_agent(
vec![]
};
// Resolve placeholders for all prompts. The conversation context
// Resolve placeholders for all steps. The conversation context
// carries forward between steps naturally via the LLM's message history.
let mut all_keys = keys;
let mut prompts = Vec::new();
for prompt_template in &def.prompts {
let template = prompt_template.replace("{agent_name}", &def.agent);
let mut resolved_steps = Vec::new();
for step in &def.steps {
let template = step.prompt.replace("{agent_name}", &def.agent);
let (prompt, extra_keys) = resolve_placeholders(&template, store, &graph, &all_keys, count);
all_keys.extend(extra_keys);
prompts.push(prompt);
resolved_steps.push(super::prompts::ResolvedStep {
prompt,
phase: step.phase.clone(),
});
}
Ok(super::prompts::AgentBatch { prompts, node_keys: all_keys })
Ok(super::prompts::AgentBatch { steps: resolved_steps, node_keys: all_keys })
}
/// Convert a list of keys to ReplayItems with priority and graph metrics.