CommonAgent

The base agent every CommonOS agent call runs through. It owns the turn loop and the hook points; storage, prompts and approvals come in through traits.

CommonAgent is the heart of CommonOS. It takes one message, runs the model, calls tools, asks for approval when it must, fires hooks at every step, and returns a reply. Every agent call in CommonOS runs through it: a chat in the dashboard, a terminal chat, and every helper agent that delegate_task starts.

It lives in the common-agent crate and has no database or web code. Where it stores messages, how it asks a person for approval, and which model it calls are all traits you pass in. That makes it easy to test and easy to embed.

What it owns

  • The turn loop. Model call, tool calls, repeat, until the model answers with text. The Turn Loop →
  • Hook points. Twenty-two points with Hermes names and behavior. Hooks →
  • Tools and risk. A registry of tools, each with a declared risk. Tools →
  • Approvals. The built-in policy that turns risk into run, ask or block. Approvals →
  • The system prompt. Three tiers with cache breakpoints. Prompt Tiers →
  • Providers. The Anthropic Messages API and a mock. Providers →

What you plug in

Trait Job In CommonOS For tests
Provider Calls the model AnthropicProvider MockProvider
Journal Stores sessions, turns, messages, tool calls Postgres Store MemoryJournal
Approver Asks a person to approve a tool call Dashboard (ApprovalBroker), y/N in cos chat AutoApprover, DenyApprover
Tool Something the model can call Plugin tools Your own
Hook Runs at a hook point approval_policy, live_feed, plugin hooks Your own
ContextSource Adds a section to a session’s prompt Memory, skills —

Embed it

This program builds an agent with one tool and one hook, and runs a turn against the scripted mock model. It needs no database and no network.

src/main.rs
use std::sync::Arc;

use async_trait::async_trait;
use common_agent::provider::MockProvider;
use common_agent::{
    new_id, parse_tags, ApprovalPolicy, CommonAgent, DenyApprover, Hook, HookBus, HookEvent,
    HookPoint, HookResult, MemoryJournal, PromptParts, Risk, Tool, ToolContext, ToolError,
    ToolRegistry, TurnConfig, TurnInfo, TurnRequest,
};
use serde_json::{json, Value};
use tokio_util::sync::CancellationToken;

struct Weather;

#[async_trait]
impl Tool for Weather {
    fn name(&self) -> &str {
        "weather"
    }
    fn description(&self) -> &str {
        "Today's forecast for a city."
    }
    fn schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": { "city": { "type": "string" } },
            "required": ["city"]
        })
    }
    fn risk(&self) -> Risk {
        Risk::Read
    }
    async fn call(&self, _ctx: &ToolContext, args: Value) -> Result<String, ToolError> {
        let city = args["city"].as_str().ok_or(ToolError::InvalidArgs("city".into()))?;
        Ok(format!("{city}: 18°C, light rain"))
    }
}

struct Usage;

#[async_trait]
impl Hook for Usage {
    fn name(&self) -> &str {
        "usage"
    }
    fn points(&self) -> &[HookPoint] {
        &[HookPoint::PostApiRequest]
    }
    async fn call(&self, event: &HookEvent) -> anyhow::Result<HookResult> {
        println!("model call: {}", event.payload["usage"]);
        Ok(HookResult::Continue)
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let provider = MockProvider::scripted(vec![
        MockProvider::tool_call("c1", "weather", json!({ "city": "Charleston" })),
        MockProvider::text("Bring an umbrella: 18°C and light rain."),
    ]);

    let mut tools = ToolRegistry::new();
    tools.register(Arc::new(Weather));

    let mut hooks = HookBus::default();
    hooks.register(Arc::new(ApprovalPolicy));
    hooks.register(Arc::new(Usage));

    let agent = CommonAgent::new(
        Arc::new(provider),
        tools,
        Arc::new(hooks),
        Arc::new(MemoryJournal::default()),
        Arc::new(DenyApprover),
        TurnConfig::default(),
    );

    let outcome = agent
        .run_turn(
            TurnRequest {
                turn: TurnInfo {
                    profile_id: "Household".into(),
                    session_id: new_id(),
                    turn_id: new_id(),
                    parent_turn_id: None,
                    tags: parse_tags(&["area:household"])?,
                    depth: 0,
                },
                user_text: "Do I need an umbrella?".into(),
                prompt: PromptParts {
                    soul: "You are a helpful household agent.".into(),
                    context: String::new(),
                },
                model: MockProvider::MODEL.into(),
            },
            CancellationToken::new(),
        )
        .await?;

    println!(
        "{} after {} calls: {}",
        outcome.status.as_str(),
        outcome.iterations,
        outcome.text.unwrap_or_default()
    );
    Ok(())
}
Output
model call: {"cache_read_tokens":0,"cache_write_tokens":0,"input_tokens":100,"output_tokens":20}
model call: {"cache_read_tokens":0,"cache_write_tokens":0,"input_tokens":100,"output_tokens":20}
completed after 2 calls: Bring an umbrella: 18°C and light rain.

The model asked for the weather tool, got its result, then answered. That is two model calls in one turn, and the post_api_request hook saw both.

The public API

Rust
impl CommonAgent {
    pub fn new(
        provider: Arc<dyn Provider>,
        tools: ToolRegistry,
        hooks: Arc<HookBus>,
        journal: Arc<dyn Journal>,
        approver: Arc<dyn Approver>,
        config: TurnConfig,
    ) -> Arc<Self>;

    pub async fn run_turn(&self, request: TurnRequest, cancel: CancellationToken)
        -> anyhow::Result<TurnOutcome>;

    /// Run with a different tool set; child agents get a reduced one.
    pub async fn run_turn_with(&self, request: TurnRequest, tools: &ToolRegistry, cancel: CancellationToken)
        -> anyhow::Result<TurnOutcome>;

    pub fn tools(&self) -> &ToolRegistry;
    pub fn journal(&self) -> &Arc<dyn Journal>;
    pub fn hooks(&self) -> &Arc<HookBus>;
}
Type Fields
TurnRequest turn: TurnInfo, user_text, prompt: PromptParts, model
TurnOutcome turn, status: TurnStatus, text: Option<String>, iterations (model calls made)
TurnConfig max_iterations (500), max_tokens (4096), approval_timeout (300 s)

How CommonOS uses it

CommonOS builds one CommonAgent per profile at start-up. All of them share one hook bus and one Postgres journal. Each gets its own provider (from the profile’s config.toml) and its own tool registry (filtered by the profile’s tools list). The Dispatcher picks the right agent for each message and calls run_turn.

Edit this page on GitHub

Esc