Plan–Act–Observe Loop

The Plan→Act→Observe loop is the fundamental reasoning pattern for agents in Scarab-Runtime.

State Sequence

Init
 ↓
Plan  ← Reason, declare steps, read memory
 ↓
Act   ← Invoke tools, make changes
 ↓
Observe ← Record what happened, update memory
 ↓
Plan (repeat, or)
 ↓
Terminate

Plan Phase

In the Plan phase, the agent reasons about what to do next:

#![allow(unused)]
fn main() {
agent.transition(LifecycleState::Plan).await?;

// Read prior context from memory
let prior_results = agent.memory_get("prior_results").await?;

// Declare the plan (advisory - helps deviation detection)
agent.plan(vec![
    PlanStep {
        description: "Search for relevant papers".to_string(),
        tool: Some("web.search".to_string()),
    },
    PlanStep {
        description: "Summarize top 3 results".to_string(),
        tool: Some("lm.complete".to_string()),
    },
    PlanStep {
        description: "Write summary to workspace".to_string(),
        tool: Some("fs.write".to_string()),
    },
]).await?;
}

The plan is advisory; the daemon uses it for deviation detection in Strict mode but does not enforce sequential execution.

Act Phase

In the Act phase, the agent executes its plan:

#![allow(unused)]
fn main() {
agent.transition(LifecycleState::Act).await?;

// Step 1: Search
let search_result = agent.invoke_tool("web.search", json!({
    "query": "renewable energy breakthroughs 2026"
})).await?;

// Step 2: Summarize
let summary = agent.invoke_tool("lm.complete", json!({
    "prompt": format!("Summarize these search results: {}", search_result["results"]),
    "model": model
})).await?;

// Step 3: Write
agent.invoke_tool("fs.write", json!({
    "path": "/workspace/summary.md",
    "content": summary["text"]
})).await?;
}

Observe Phase

In the Observe phase, the agent records what happened and updates memory:

#![allow(unused)]
fn main() {
agent.transition(LifecycleState::Observe).await?;

// Record observation
agent.observe(
    "completed_summary",
    &format!("Summary written: {} tokens used", summary["output_tokens"]),
    vec!["success".to_string(), "summary".to_string()],
).await?;

// Update persistent memory
agent.memory_set("last_summary_path", json!("/workspace/summary.md")).await?;
agent.memory_set("run_count", json!(run_count + 1)).await?;
}

Full Example Loop

#![allow(unused)]
fn main() {
let task = std::env::var("SCARAB_TASK").unwrap_or_default();
let model = std::env::var("SCARAB_MODEL").unwrap_or_else(|_| "anthropic/claude-opus-4-6".to_string());

agent.transition(LifecycleState::Plan).await?;
agent.plan(vec![/* ... */]).await?;

agent.transition(LifecycleState::Act).await?;
let result = agent.invoke_tool("lm.complete", json!({
    "prompt": task,
    "model": model
})).await?;

agent.transition(LifecycleState::Observe).await?;
agent.observe("completed", &format!("Done: {}", result["text"]), vec![]).await?;

agent.transition(LifecycleState::Terminate).await?;
}

Plan Revision

If the agent discovers mid-execution that the original plan needs changing:

#![allow(unused)]
fn main() {
// Still in Act phase, but plan needs updating
agent.revise_plan(
    new_steps,
    "Found additional data source that requires an extra step".to_string(),
).await?;
}

Revisions create an audit entry with the reason, enabling full traceability of reasoning changes.

Multi-Iteration Loop

For long-running agents that iterate:

#![allow(unused)]
fn main() {
loop {
    // Check for escalations before each iteration
    let escalations = agent.pending_escalations().await?;
    if !escalations.is_empty() {
        handle_escalations(&mut agent, escalations).await?;
    }

    agent.transition(LifecycleState::Plan).await?;
    // ... plan ...

    agent.transition(LifecycleState::Act).await?;
    // ... act ...

    agent.transition(LifecycleState::Observe).await?;
    // ... observe ...

    if task_complete {
        break;
    }
}

agent.transition(LifecycleState::Terminate).await?;
}