fabro/lib/components/fabro-agent
Bryan Helmkamp 6659ae768a
feat(events): make inference in-flight state observable
During a long LLM turn the durable event stream was silent: between
`agent.tool.completed` and the next `agent.message` nothing was emitted,
so "the model is generating" and "the worker is wedged" were
indistinguishable from the run store, SSE, or the UI.

The signal already existed. `AssistantTextStart` fired at exactly the
right point — after `build_request()`, after compaction, immediately
before the stream opens — then was classified as streaming noise and
thrown away. This promotes it rather than inventing a new one.

Two events, each asserting only what is provable when it is emitted:

- `agent.llm.started` carries the *requested* provider/model. No usage,
  no cost, no context window: none of it exists yet, and failover can
  re-target, so `agent.message` stays authoritative for what answered.
- `agent.llm.first_output` is edge-triggered on the first output of an
  attempt and names what arrived. `ToolCall` is required, not optional:
  a turn that opens with a tool call produces no text or reasoning
  delta, so a latch keyed on those two would stay silent for exactly
  the tool-heavy rounds where liveness matters most.

`agent.llm.retry` now also fires on the one previously invisible
mid-turn path — a stream that ends without a finish event, which
replays the turn and discards its output with nothing to show for it.
Its `attempt` field was already fed by two independent counters, so an
optional `phase` (open | consume) names which loop it counts.

`StageProjection.inference` projects the open bracket. `Some` means
"the event log contains an unclosed inference bracket", not "the model
is computing now" — a SIGKILLed worker leaves it open, which is the
truthful statement of what we know, and `watchdog.timeout` remains the
authority on actually-stuck.

The close is the subtle part. Terminal cancel and wall-clock timeout
tear the session down through `discard_session` without emitting a
message, error, or interrupt, so a session-lifecycle backstop is
required. It has to be `agent.session.ended`, not
`agent.session.deactivated`: deactivation is emitted by `lease.release()`
*before* the forwarder drains queued agent events, so a queued
`agent.llm.started` can arrive after it and re-open the bracket. But
`agent.session.ended` carries no stage identity, so the close takes
ordering from the event and identity from the projection, scanning for
brackets the ending session opened. A normal stage lookup there finds
no target and silently no-ops.

Presentation states what the log proves and nothing more: no progress
bar or ETA (no completion estimate exists), "reasoning" only when the
provider sent reasoning output, elapsed counted since the request
opened, and no live animation once the run is terminal.

Scope is session-backed agent stages. One-shot completions call
`client.complete` directly and never build a session; covering them
means moving the emit point into `fabro-llm`, filed as a follow-up.

`agent.output.start` was never persisted — it existed in a name map,
an `unreachable!` arm, and docs — so the rename carries no migration
risk. Corrects `events.md`, which documented it as a real emitted
event, and the v2 proposal, which mapped it to `message.part.started`
despite it firing before the request opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 21:47:51 -04:00
..
src feat(events): make inference in-flight state observable 2026-07-24 21:47:51 -04:00
tests/it refactor: simplify profile builder and drop dead tool plumbing 2026-07-24 17:32:33 -04:00
Cargo.toml refactor(agent): simplify prompt template rendering 2026-07-24 19:10:48 -04:00
README.md refactor: centralize agent profile tool configuration 2026-07-24 14:06:29 -04:00

agent

A programmable agentic loop for building coding agents. This crate provides the core session management, tool execution, and LLM interaction loop used to power interactive coding assistants.

Architecture

The crate is organized around a central Session that drives an agentic loop:

  1. User input is appended to a conversation History
  2. The session builds a Request with system prompt, history, and tools
  3. An LLM generates a response (text and/or tool calls) via unified-llm
  4. Tool calls are executed through a ToolRegistry against a Sandbox
  5. Results are recorded and the loop continues until the LLM responds with text only (natural completion), a turn limit is reached, or the session is interrupted
User Input
    |
    v
[Session::process_input]
    |
    v
+-------------------+
| Build Request     |  <-- system prompt + history + tools
+-------------------+
    |
    v
+-------------------+
| LLM Call          |  <-- via unified-llm Client
+-------------------+
    |
    v
+-------------------+     +-------------------+
| Tool Calls?  -----+-yes-| Execute Tools     |
+-------------------+     | (parallel or seq)  |
    | no                  +-------------------+
    v                         |
  [Done]                      +---> loop back to Build Request

Key Components

  • Session -- Manages the full agentic loop: LLM calls, tool execution, steering, follow-ups, interrupt handling, and event emission.
  • AgentProfile (trait) -- Defines how to build system prompts, which tools to register, and what capabilities a provider supports. Ships with AnthropicProfile, OpenAiProfile, and GeminiProfile.
  • Sandbox (trait) -- Abstracts filesystem, shell, grep, and glob operations. LocalSandbox provides a real implementation; the trait enables sandboxing and testing.
  • ToolRegistry -- Maps tool names to definitions and async executor functions. Tools are registered per-profile.
  • History -- Ordered list of Turn variants (User, Assistant, ToolResults, System, Steering) that converts to LLM messages.
  • Emitter -- Broadcasts SessionEvents (tool calls, text, errors, warnings) over a tokio::sync::broadcast channel for UI or logging.
  • SubAgentManager -- Spawns child Sessions on background tasks for delegated work, with depth limits.
  • SessionConfig -- Tunable parameters: max turns, tool round limits, command timeouts, loop detection, output truncation limits, and user instructions.

Key Types and Traits

Session

The main entry point. Created with an LLM client, a provider profile, a sandbox, and a config.

AgentProfile

pub trait AgentProfile: Send + Sync {
    fn id(&self) -> String;
    fn model(&self) -> String;
    fn tool_registry(&self) -> &ToolRegistry;
    fn build_system_prompt(
        &self,
        env: &dyn Sandbox,
        env_context: &EnvContext,
        project_docs: &[String],
        user_instructions: Option<&str>,
    ) -> String;
    // ... default methods for tools(), knowledge_cutoff(), context_window_size()
}

Built-in profiles:

  • AnthropicProfile -- 200K context, extended thinking beta headers, and Anthropic task tools
  • OpenAiProfile -- 128K context, reasoning effort support, and apply_patch (Codex apply_patch format)
  • GeminiProfile -- 1M context, safety settings, plus read_many_files and list_dir

All profiles include the common file, shell, search, and web_fetch tools. web_search is included only when a Brave Search API key is supplied while building the profile.

Sandbox

pub trait Sandbox: Send + Sync {
    async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>, String>;
    async fn read_file_text(&self, path: &str) -> Result<String, String>;
    async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String>; // line-numbered display
    async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
    async fn exec_command(&self, command: &str, timeout_ms: u64, ...) -> Result<ExecResult, String>;
    async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<String>, String>;
    async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
    // ... plus delete_file, file_exists, list_directory, initialize, cleanup, platform info
}

LocalSandbox is the real implementation with env-var filtering (strips secrets), process group management, and ripgrep/grep fallback.

SessionConfig

pub struct SessionConfig {
    pub default_command_timeout_ms: u64,     // default: 10s
    pub max_command_timeout_ms: u64,         // default: 600s
    pub enable_loop_detection: bool,         // default: true
    pub loop_detection_window: usize,        // default: 10
    pub max_subagent_depth: usize,           // default: 1
    pub user_instructions: Option<String>,
    pub reasoning_effort: Option<String>,
    // ... plus tool_output_limits, tool_line_limits, git_root
}

Usage

use agent::{
    AnthropicProfile, LocalSandbox, Session, SessionConfig,
};
use std::path::PathBuf;
use std::sync::Arc;
use unified_llm::client::Client;

// 1. Create an LLM client (via unified-llm)
let client: Client = /* configure unified-llm client */;

// 2. Choose a provider profile
let profile = Arc::new(AnthropicProfile::new("claude-sonnet-4-20250514"));

// 3. Create a sandbox
let env = Arc::new(LocalSandbox::new(
    PathBuf::from("/path/to/project"),
));

// 4. Configure the session
let config = SessionConfig {
    enable_loop_detection: true,
    user_instructions: Some("Always write tests first".into()),
    ..SessionConfig::default()
};

// 5. Create and initialize the session
let mut session = Session::new(client, profile, env, config, None);
session.initialize().await?;

// 6. Subscribe to events (for UI rendering)
let mut rx = session.subscribe();
tokio::spawn(async move {
    while let Ok(event) = rx.recv().await {
        // Handle SessionEvent: tool calls, text, errors, etc.
    }
});

// 7. Process user input
session.process_input("Fix the failing test in src/lib.rs").await?;

Steering and Follow-ups

Inject guidance mid-conversation or queue follow-up messages:

// Inject a steering message before the next LLM call
session.steer("Focus on the root cause, not symptoms".into());

// Queue a follow-up that runs after the current input completes
session.follow_up("Now run the test suite to verify".into());

Interrupt

Cancel a running session from another thread:

let cancel_token = session.cancel_token();
// From another task:
cancel_token.cancel();

Custom Tools

Register additional tools via the profile's ToolRegistry:

use agent::tool_registry::{RegisteredTool, ToolExecutor};
use unified_llm::types::ToolDefinition;
use std::sync::Arc;

let custom_tool = RegisteredTool {
    definition: ToolDefinition {
        name: "my_tool".into(),
        description: "Does something useful".into(),
        parameters: serde_json::json!({
            "type": "object",
            "properties": {
                "input": {"type": "string"}
            },
            "required": ["input"]
        }),
    },
    executor: Arc::new(|args, env| {
        Box::pin(async move {
            let input = args["input"].as_str().unwrap_or("");
            Ok(format!("Processed: {input}"))
        })
    }),
};

// Register on a mutable profile before creating the session
profile.tool_registry_mut().register(custom_tool);

Subagents

Spawn child sessions for delegated tasks:

use agent::subagent::SubAgentManager;

let mut profile = AnthropicProfile::new("claude-sonnet-4-20250514");
let manager = Arc::new(tokio::sync::Mutex::new(SubAgentManager::new(3)));
let factory = Arc::new(|| { /* create a new Session */ });

// Registers spawn_agent, send_input, wait, close_agent tools
profile.register_subagent_tools(manager, factory, 0);

Safety Features

  • Loop detection -- Detects repeating tool call patterns (period 1, 2, or 3) and injects a steering warning
  • Context window monitoring -- Emits Warning events (kind "context_window") when estimated usage exceeds 80%
  • Tool argument validation -- Validates arguments against JSON Schema before execution
  • Tool output truncation -- Per-tool character and line limits with head/tail or tail-only truncation modes
  • Environment variable filtering -- LocalSandbox strips secrets (*_API_KEY, *_SECRET, *_TOKEN, *_PASSWORD, *_CREDENTIAL) from subprocess environments
  • Command timeouts -- Configurable per-command with process group cleanup (SIGTERM then SIGKILL)
  • Project doc discovery -- Automatically discovers AGENTS.md, CLAUDE.md, GEMINI.md, or .codex/instructions.md based on provider, with a 32KB budget