Files
clawmates/crates/cm-runtime/src/runtime.rs
T
Omar SobhandClaude Opus 5 8be7b3c9b2
deploy / test (push) Successful in 4m31s
deploy / build (push) Successful in 5m23s
feat(telemetry): record per-agent usage for mission turns
The command centre's SPEND, ACTIVITY and THROUGHPUT cards read `usage_events`,
and nothing on the mission path ever wrote a row: `cm_billing::charge` was
called only from the agent-run path. Measured mid-mission with 14 agents live,
`usage_events` was 0 while a crew had just burned 15k tokens — so an agent that
had done real work reported zero cost and zero activity.

The worker already knew everything needed: it logs node, role and token count
per step, and the node's `attrs.agent` carries the `claw_<uuid>` binding the
runtime dispatches on. This routes that to the ledger.

`charge`'s run_id is now Option. `usage_events.run_id` references `agent_runs`,
and a topology turn has no row there — passing its `topology_runs` id was a
foreign-key violation, which is exactly what the first attempt hit. NULL is the
honest value; the agent-run caller still passes its real id.

The executor reports one total rather than an in/out split, so the cost is right
(credits price the sum) and the columns record it as output rather than
inventing a split.

Verified end to end on a real mission: 4 agents, 1046-8670 tokens each, credits
attributed per agent, and the SPEND/ACTIVITY queries now return real numbers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-15 05:09:56 -07:00

1070 lines
39 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use cm_db::repo::{messages, run_events, runs, sessions, steps};
use cm_db::DbError;
use cm_domain::{
shard_of, AgentId, MessageId, MessageRole, MessageWithSteps, SessionId, SessionKey, Step,
StepStatus, WorkspaceId,
};
use cm_llm::{ChatMessage, ChatRequest, ChatRole, ContentPart, LlmEvent, LlmProvider, StopReason};
use cm_safety::{approvals, checkpoint, grants, NewApproval, ResumeReady, SafetyError};
use cm_tools::{GateDecision, GatePolicy, TaintSet};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::PgPool;
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
use cm_files::{BlobStore, LocalBlobStore};
use crate::events::{RunEventBody, RunEventEnvelope};
use crate::tools::{ToolContext, ToolRegistry};
/// How long a pending approval stays decidable before it expires.
const APPROVAL_TTL: time::Duration = time::Duration::hours(24);
/// Model used for LLM-as-judge roles (the door governor and the topology
/// comparison scorer). Judges use the strongest model — default
/// `claude-opus-4-8` — while everything else runs on the configured default
/// model (`claude-sonnet-4-6`). Override with `CLAWMATES_JUDGE_MODEL`.
pub fn judge_model() -> String {
std::env::var("CLAWMATES_JUDGE_MODEL").unwrap_or_else(|_| "claude-opus-4-8".to_string())
}
/// Extra named LLM providers (GLM, Kimi, …) selectable as `"<name>:<model>"`
/// alongside the default provider. `Arc<dyn LlmProvider>` isn't `Debug`, so this
/// newtype carries a manual `Debug` that lists only the provider names.
#[derive(Clone, Default)]
pub struct ProviderRegistry(pub HashMap<String, Arc<dyn LlmProvider>>);
impl std::fmt::Debug for ProviderRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderRegistry")
.field("names", &self.0.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Debug, Clone)]
pub struct RuntimeConfig {
pub model: String,
pub max_tokens: u32,
/// Secret broker socket; broker-executed tools require it.
pub broker_socket: Option<std::path::PathBuf>,
/// Slack API base (e2e points it at a local sink).
pub slack_base_url: String,
/// Sandbox runtime for shell.exec; None disables environment tools.
pub sandboxes: Option<std::sync::Arc<crate::SandboxManager>>,
/// Egress-enabled browser containers for browser.goto.
pub browser: Option<std::sync::Arc<crate::SandboxManager>>,
/// Themed interactive terminal containers for the Terminal computer app.
pub terminals: Option<std::sync::Arc<crate::TerminalManager>>,
/// Extra named providers (GLM/Kimi/…) for judges and topology nodes.
pub providers: ProviderRegistry,
}
impl RuntimeConfig {
pub fn with_browser(mut self, browser: std::sync::Arc<crate::SandboxManager>) -> RuntimeConfig {
self.browser = Some(browser);
self
}
pub fn with_sandboxes(
mut self,
sandboxes: std::sync::Arc<crate::SandboxManager>,
) -> RuntimeConfig {
self.sandboxes = Some(sandboxes);
self
}
/// Test/dev defaults: no broker, real Slack base.
pub fn basic(model: &str, max_tokens: u32) -> RuntimeConfig {
RuntimeConfig {
model: model.into(),
max_tokens,
broker_socket: None,
slack_base_url: "https://slack.com/api".into(),
sandboxes: None,
browser: None,
terminals: None,
providers: ProviderRegistry::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RuntimeError {
#[error(transparent)]
Db(#[from] DbError),
#[error("safety error: {0}")]
Safety(#[from] SafetyError),
#[error("llm error: {0}")]
Llm(String),
#[error("checkpoint corrupt: {0}")]
Checkpoint(String),
}
/// Handle returned to the caller: the run id plus a live event receiver
/// subscribed before the loop starts (no events can be missed).
pub struct StartedRun {
pub run_id: Uuid,
pub events: broadcast::Receiver<RunEventEnvelope>,
}
/// A tool call the model requested that has not executed yet.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingTool {
id: String,
name: String,
input: Value,
}
/// The complete, serializable state of an in-flight run. This is what
/// `agent_runs.checkpoint` stores while a run awaits approval; resume
/// deserializes it and continues exactly where the loop stopped.
#[derive(Debug, Serialize, Deserialize)]
struct LoopState {
session_id: SessionId,
workspace_id: WorkspaceId,
agent_id: AgentId,
reply_message_id: MessageId,
request: ChatRequest,
full_text: String,
step_seq: i32,
event_seq: i64,
assistant_parts: Vec<ContentPart>,
result_parts: Vec<ContentPart>,
pending_tools: Vec<PendingTool>,
/// Untrusted sources whose content has entered this run (§15). Once
/// tainted, every later gated decision carries these sources.
#[serde(default)]
taint: Vec<String>,
/// Token accounting across every provider leg of this run.
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
enum Outcome {
Completed,
Suspended,
}
/// Owns active run channels; one instance lives in the server state.
/// Cheap to clone (shared inner state).
#[derive(Clone)]
pub struct Runtime {
inner: Arc<RuntimeInner>,
}
struct RuntimeInner {
pool: PgPool,
provider: Arc<dyn LlmProvider>,
tools: Arc<ToolRegistry>,
config: RuntimeConfig,
blob: Arc<dyn BlobStore>,
channels: Mutex<HashMap<Uuid, broadcast::Sender<RunEventEnvelope>>>,
}
impl Runtime {
/// Default blob storage under the system temp dir — fine for dev and
/// tests; deployments pass their data directory via `with_blob_store`.
pub fn new(pool: PgPool, provider: Arc<dyn LlmProvider>, config: RuntimeConfig) -> Runtime {
let blob = Arc::new(LocalBlobStore::new(
std::env::temp_dir().join("clawmates-blobs"),
));
Runtime::with_blob_store(pool, provider, config, blob)
}
pub fn with_blob_store(
pool: PgPool,
provider: Arc<dyn LlmProvider>,
config: RuntimeConfig,
blob: Arc<dyn BlobStore>,
) -> Runtime {
Runtime {
inner: Arc::new(RuntimeInner {
pool,
provider,
tools: Arc::new(ToolRegistry::default()),
config,
blob,
channels: Mutex::new(HashMap::new()),
}),
}
}
pub fn blob(&self) -> Arc<dyn BlobStore> {
self.inner.blob.clone()
}
pub fn pool(&self) -> &PgPool {
&self.inner.pool
}
/// The shared LLM provider (used by the topology comparison endpoint).
pub fn provider(&self) -> Arc<dyn LlmProvider> {
self.inner.provider.clone()
}
/// Resolve a model spec to `(provider, model)`. A spec like `"glm:glm-4.6"`
/// or `"kimi:kimi-k2"` selects a named registry provider; anything else
/// (e.g. `"claude-opus-4-8"`) uses the default provider. This is how judges
/// and topology nodes pick a different LLM.
pub fn resolve_provider(&self, spec: &str) -> (Arc<dyn LlmProvider>, String) {
if let Some((name, model)) = spec.split_once(':') {
if let Some(provider) = self.inner.config.providers.0.get(name) {
return (provider.clone(), model.to_string());
}
}
(self.inner.provider.clone(), spec.to_string())
}
/// The configured default model id.
pub fn model(&self) -> &str {
&self.inner.config.model
}
/// Tear down an agent's sandbox + browser containers (on deletion). Returns
/// whether any container existed and was destroyed.
pub async fn reap_sandbox(&self, agent_id: cm_domain::AgentId) -> bool {
let mut any = false;
if let Some(sb) = &self.inner.config.sandboxes {
any |= sb.release_agent(agent_id).await;
}
if let Some(br) = &self.inner.config.browser {
any |= br.release_agent(agent_id).await;
}
if let Some(tm) = &self.inner.config.terminals {
any |= tm.release_agent(agent_id).await;
}
any
}
/// The themed-terminal manager, if a sandbox engine is configured.
pub fn terminals(&self) -> Option<std::sync::Arc<crate::TerminalManager>> {
self.inner.config.terminals.clone()
}
/// The configured per-call max output tokens.
pub fn max_tokens(&self) -> u32 {
self.inner.config.max_tokens
}
/// MCP-shaped tool definition (`{name, description, inputSchema}`) for one
/// registered tool, or `None` if unknown. Used by the MCP door's
/// `tools/list`.
pub fn tool_descriptor_json(&self, name: &str) -> Option<Value> {
self.inner
.tools
.descriptors()
.into_iter()
.find(|d| d.name == name)
.map(|d| {
json!({
"name": d.name,
"description": d.description,
"inputSchema": d.input_schema,
})
})
}
/// Whether the named tool is broker-executed (the secret broker consumes the
/// execution grant and touches the credential — the agent never holds it).
/// The MCP door must mint an approval+grant for these.
pub fn tool_broker_executed(&self, name: &str) -> bool {
self.inner.tools.broker_executed(name)
}
/// The §10 approval-card preview for a tool input (used by the door when it
/// records a gated action).
pub fn tool_preview(&self, name: &str, input: &Value) -> Value {
self.inner.tools.preview_of(name, input)
}
/// Classify a tool's declared effects into a §15 gated category (assuming
/// no upstream taint). `None` means the action needs no approval.
pub fn tool_gate_category(&self, name: &str) -> Option<cm_domain::GatedCategory> {
let effects = self.inner.tools.effects_of(name);
let taint = TaintSet::from_strings(&[]);
match GatePolicy.classify(effects, &taint) {
GateDecision::RequireApproval(category) => Some(category),
GateDecision::Allow => None,
}
}
/// Governor agent: ask the **judge model** to judge an action. Returns
/// `(allow, reason)`. The verdict is the first token (`ALLOW`/`DENY`) of the
/// model's reply. Best-effort and **fail-open** — if the model is
/// unreachable it returns `(true, …)` so a governor outage doesn't halt
/// autonomous agents (the governor is an extra soft check atop deterministic
/// policy, not the only gate). Judges use the strongest model
/// ([`judge_model`], default `claude-opus-4-8`); everything else runs on the
/// configured default model.
pub async fn judge(&self, system: &str, user: &str) -> (bool, String) {
let (provider, model) = self.resolve_provider(&judge_model());
let request = ChatRequest {
system: system.to_string(),
model,
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user)],
}],
tools: vec![],
max_tokens: 256,
web_search: false,
};
let mut text = String::new();
match provider.stream(request).await {
Ok(mut stream) => {
while let Some(event) = stream.next().await {
if let Ok(LlmEvent::TextDelta(t)) = event {
text.push_str(&t);
}
}
}
Err(e) => return (true, format!("governor unreachable (fail-open): {e}")),
}
let allow = !text.to_uppercase().contains("DENY");
(allow, text.trim().to_string())
}
/// One-shot completion: send `system`+`user` to `model`, collect the full
/// assistant text. Used for non-chat LLM work (e.g. brain enhancement on
/// `claude-opus-4-8`).
pub async fn complete(
&self,
system: &str,
user: &str,
model: &str,
max_tokens: u32,
web_search: bool,
) -> Result<String, String> {
let (provider, resolved) = self.resolve_provider(model);
let request = ChatRequest {
system: system.to_string(),
model: resolved,
messages: vec![ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user)],
}],
tools: vec![],
max_tokens,
web_search,
};
let mut text = String::new();
let mut stream = provider.stream(request).await.map_err(|e| e.to_string())?;
while let Some(event) = stream.next().await {
match event {
Ok(LlmEvent::TextDelta(t)) => text.push_str(&t),
Ok(_) => {}
Err(e) => return Err(e.to_string()),
}
}
Ok(text)
}
/// Execute a tool on behalf of the MCP door — capability is already decided
/// by door policy (the human approver is replaced by an automated policy /
/// governor). Builds the tool context from runtime config; when an
/// `approval_id` is supplied and the tool is runtime-executed, the
/// single-use grant is consumed before the action runs (broker-executed
/// tools consume it inside the broker).
pub async fn execute_door_tool(
&self,
workspace_id: WorkspaceId,
agent_id: AgentId,
name: &str,
input: Value,
approval_id: Option<Uuid>,
) -> Result<Value, String> {
let ctx = ToolContext {
pool: self.inner.pool.clone(),
workspace_id,
agent_id,
blob: self.inner.blob.clone(),
approval_id,
broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(),
sandboxes: self.inner.config.sandboxes.clone(),
browser: self.inner.config.browser.clone(),
};
if let Some(aid) = approval_id {
if !self.inner.tools.broker_executed(name) {
grants::consume(&self.inner.pool, aid)
.await
.map_err(|e| e.to_string())?;
}
}
self.inner.tools.execute(&ctx, name, input).await
}
/// Live-attach to a run that is still streaming.
pub async fn subscribe(&self, run_id: Uuid) -> Option<broadcast::Receiver<RunEventEnvelope>> {
self.inner
.channels
.lock()
.await
.get(&run_id)
.map(|s| s.subscribe())
}
/// Starts a run for a user message and spawns the loop.
pub async fn send_message(
&self,
session_id: SessionId,
user_text: &str,
) -> Result<StartedRun, RuntimeError> {
let inner = &self.inner;
let session = sessions::get(&inner.pool, session_id).await?;
let agent = cm_db::repo::agents::get(&inner.pool, session.agent_id).await?;
let history = messages::history(&inner.pool, session_id).await?;
// Brain-augmented system prompt: inject the claw's installed skills +
// recall relevant memory from its .brain, and record the user turn.
// Best-effort — falls back to the plain system prompt on any error.
let skills: Vec<(String, String, String)> =
cm_db::repo::skills::installed(&inner.pool, agent.id)
.await
.unwrap_or_default()
.into_iter()
.map(|s| (s.title, s.description, s.body))
.collect();
let system_prompt = crate::brain::compose_system(
&agent.id.to_string(),
&agent.system_prompt,
&skills,
user_text,
&session_id.to_string(),
);
let run_id = runs::create(&inner.pool, session_id).await?;
let receiver = self.open_channel(run_id).await;
messages::append(
&inner.pool,
session_id,
MessageRole::User,
json!({"text": user_text}),
)
.await?;
sessions::touch(&inner.pool, session_id).await?;
// The reply row exists up front so steps can attach while streaming.
let reply = messages::append(
&inner.pool,
session_id,
MessageRole::Agent,
json!({"text": ""}),
)
.await?;
let state = LoopState {
session_id,
workspace_id: agent.workspace_id,
agent_id: agent.id,
reply_message_id: reply.id,
request: ChatRequest {
system: system_prompt,
messages: chat_messages(&history, user_text),
tools: inner.tools.descriptors(),
model: inner.config.model.clone(),
max_tokens: inner.config.max_tokens,
web_search: false,
},
full_text: String::new(),
step_seq: 0,
event_seq: 0,
assistant_parts: Vec::new(),
result_parts: Vec::new(),
pending_tools: Vec::new(),
taint: Vec::new(),
input_tokens: 0,
output_tokens: 0,
};
self.spawn_drive(run_id, state, true);
Ok(StartedRun {
run_id,
events: receiver,
})
}
/// Resumes a suspended run after its approval was decided. Exactly one
/// caller wins the claim; everyone else returns quietly.
pub async fn resume_run(&self, ready: ResumeReady) -> Result<(), RuntimeError> {
if !checkpoint::claim_resume(&self.inner.pool, ready.run_id).await? {
return Ok(());
}
let raw = checkpoint::load(&self.inner.pool, ready.run_id).await?;
let mut state: LoopState =
serde_json::from_value(raw).map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
self.open_channel(ready.run_id).await;
let runtime = self.clone();
tokio::spawn(async move {
let result = runtime.resolve_gated_tool(ready, &mut state).await;
match result {
Ok(()) => runtime.spawn_drive_inline(ready.run_id, state).await,
Err(error) => runtime.fail_run(ready.run_id, &error.to_string()).await,
}
});
Ok(())
}
/// The durable resume path: periodically expires overdue approvals and
/// resumes runs whose approvals were decided (survives crashes between
/// decision and resumption).
pub fn spawn_resume_sweeper(&self, interval: Duration) {
let runtime = self.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
let _ = approvals::sweep_expired(&runtime.inner.pool).await;
if let Ok(ready) = approvals::decided_unresumed(&runtime.inner.pool).await {
for item in ready {
let _ = runtime.resume_run(item).await;
}
}
}
});
}
async fn open_channel(&self, run_id: Uuid) -> broadcast::Receiver<RunEventEnvelope> {
let (sender, receiver) = broadcast::channel(1024);
self.inner.channels.lock().await.insert(run_id, sender);
receiver
}
fn spawn_drive(&self, run_id: Uuid, state: LoopState, emit_started: bool) {
let runtime = self.clone();
tokio::spawn(async move {
if emit_started {
let mut seq = state.event_seq;
if runtime
.emit(run_id, &mut seq, RunEventBody::RunStarted { run_id })
.await
.is_err()
{
runtime
.fail_run(run_id, "could not journal run start")
.await;
return;
}
let mut state = state;
state.event_seq = seq;
runtime.spawn_drive_inline(run_id, state).await;
} else {
runtime.spawn_drive_inline(run_id, state).await;
}
});
}
async fn spawn_drive_inline(&self, run_id: Uuid, state: LoopState) {
match self.drive(run_id, state).await {
Ok(Outcome::Completed) | Ok(Outcome::Suspended) => {}
Err(error) => self.fail_run(run_id, &error.to_string()).await,
}
self.inner.channels.lock().await.remove(&run_id);
}
async fn fail_run(&self, run_id: Uuid, message: &str) {
let _ = runs::set_state(
&self.inner.pool,
run_id,
cm_domain::RunState::Failed,
Some(message),
)
.await;
let mut seq = runs::get(&self.inner.pool, run_id)
.await
.map(|r| r.last_event_id)
.unwrap_or(0);
let _ = self
.emit(
run_id,
&mut seq,
RunEventBody::Error {
message: message.to_owned(),
},
)
.await;
self.inner.channels.lock().await.remove(&run_id);
}
/// Journals an event, then broadcasts it. Persist-before-emit is the
/// invariant that makes replay equal to what live observers saw.
async fn emit(
&self,
run_id: Uuid,
seq: &mut i64,
event: RunEventBody,
) -> Result<(), RuntimeError> {
*seq += 1;
let payload = serde_json::to_value(&event).expect("event serializes");
run_events::append(&self.inner.pool, run_id, *seq, event.type_name(), payload).await?;
runs::set_last_event(&self.inner.pool, run_id, *seq).await?;
if let Some(sender) = self.inner.channels.lock().await.get(&run_id) {
let _ = sender.send(RunEventEnvelope { seq: *seq, event });
}
Ok(())
}
/// Applies the human decision to the gated tool at the head of the
/// pending queue: approval consumes the single-use grant and executes;
/// rejection feeds a structured refusal back to the model.
async fn resolve_gated_tool(
&self,
ready: ResumeReady,
state: &mut LoopState,
) -> Result<(), RuntimeError> {
let Some(tool) = state.pending_tools.first().cloned() else {
return Err(RuntimeError::Checkpoint(
"resumed run has no pending tool".into(),
));
};
state.pending_tools.remove(0);
let ctx = ToolContext {
pool: self.inner.pool.clone(),
workspace_id: state.workspace_id,
agent_id: state.agent_id,
blob: self.inner.blob.clone(),
approval_id: Some(ready.approval_id),
broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(),
sandboxes: self.inner.config.sandboxes.clone(),
browser: self.inner.config.browser.clone(),
};
state.step_seq += 1;
self.emit_step_started(ready.run_id, state, &tool).await?;
let (status, output) = if !ready.approved {
(
StepStatus::Error,
json!({"rejected": "The human reviewer rejected this action."}),
)
} else if self.inner.tools.broker_executed(&tool.name) {
// The broker verifies and consumes the grant itself — the
// strongest path: the runtime never touches the credential.
match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
}
} else {
match grants::consume(&self.inner.pool, ready.approval_id).await {
// The grant is consumed BEFORE the action runs: even a
// racing resumer cannot execute a gated call twice.
Ok(()) => match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
},
Err(_) => (
StepStatus::Error,
json!({"error": "execution grant unavailable (already used or never issued)"}),
),
}
};
self.record_step(state, &tool, status, &output).await?;
self.emit_step_finished(ready.run_id, state, status, &output)
.await?;
self.emit_agent_message(ready.run_id, state, &tool, status, &output)
.await?;
state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(),
name: tool.name,
input: tool.input,
});
state.result_parts.push(ContentPart::ToolResult {
tool_use_id: tool.id,
content: output,
});
Ok(())
}
/// The run loop. Processes pending tool calls (suspending at the first
/// gated one), then streams the provider; repeats until the model ends
/// its turn.
async fn drive(&self, run_id: Uuid, mut state: LoopState) -> Result<Outcome, RuntimeError> {
let policy = GatePolicy;
let ctx = ToolContext {
pool: self.inner.pool.clone(),
workspace_id: state.workspace_id,
agent_id: state.agent_id,
blob: self.inner.blob.clone(),
approval_id: None,
broker_socket: self.inner.config.broker_socket.clone(),
slack_base_url: self.inner.config.slack_base_url.clone(),
sandboxes: self.inner.config.sandboxes.clone(),
browser: self.inner.config.browser.clone(),
};
loop {
while let Some(tool) = state.pending_tools.first().cloned() {
let effects = self.inner.tools.effects_of(&tool.name);
let taint = TaintSet::from_strings(&state.taint);
if let GateDecision::RequireApproval(category) = policy.classify(effects, &taint) {
let session = sessions::get(&self.inner.pool, state.session_id).await?;
let session_key = SessionKey {
agent_id: state.agent_id,
shard: shard_of(state.agent_id),
session_id: state.session_id,
message_id: state.reply_message_id,
};
let approval = approvals::create(
&self.inner.pool,
NewApproval {
workspace_id: session.workspace_id,
run_id,
session_key: session_key.to_string(),
action_type: tool.name.clone(),
category,
payload: tool.input.clone(),
preview: self.inner.tools.preview_of(&tool.name, &tool.input),
requested_by_agent: state.agent_id,
taint_sources: taint.as_strings(),
expires_at: Some(time::OffsetDateTime::now_utc() + APPROVAL_TTL),
},
)
.await?;
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::ApprovalRequired {
approval_id: approval.id,
category: category.as_str().to_owned(),
action_type: approval.action_type.clone(),
preview: approval.preview.clone(),
},
)
.await?;
self.emit(
run_id,
&mut seq,
RunEventBody::RunSuspended {
approval_id: approval.id,
},
)
.await?;
state.event_seq = seq;
// Checkpoint AFTER journaling so resume continues from
// the right sequence number.
let snapshot = serde_json::to_value(&state)
.map_err(|e| RuntimeError::Checkpoint(e.to_string()))?;
checkpoint::suspend(&self.inner.pool, run_id, &snapshot).await?;
return Ok(Outcome::Suspended);
}
// Ungated: execute now.
state.pending_tools.remove(0);
state.step_seq += 1;
self.emit_step_started(run_id, &mut state, &tool).await?;
let (status, output) = match self
.inner
.tools
.execute(&ctx, &tool.name, tool.input.clone())
.await
{
Ok(value) => (StepStatus::Ok, value),
Err(message) => (StepStatus::Error, json!({"error": message})),
};
// Taint lands BEFORE the step record: the step that
// produced untrusted output carries its own taint (§15).
if status == StepStatus::Ok {
if let Some(source) = self.inner.tools.output_taint_of(&tool.name) {
let tag = source.as_str().to_owned();
if !state.taint.contains(&tag) {
state.taint.push(tag);
}
}
}
self.record_step(&state, &tool, status, &output).await?;
self.emit_step_finished(run_id, &mut state, status, &output)
.await?;
self.emit_agent_message(run_id, &mut state, &tool, status, &output)
.await?;
state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(),
name: tool.name.clone(),
input: tool.input.clone(),
});
state.result_parts.push(ContentPart::ToolResult {
tool_use_id: tool.id.clone(),
content: output,
});
}
// Feed any finished tool batch back to the model.
if !state.assistant_parts.is_empty() {
state.request.messages.push(ChatMessage {
role: ChatRole::Assistant,
parts: std::mem::take(&mut state.assistant_parts),
});
state.request.messages.push(ChatMessage {
role: ChatRole::User,
parts: std::mem::take(&mut state.result_parts),
});
}
let mut stream = self
.inner
.provider
.stream(state.request.clone())
.await
.map_err(|e| RuntimeError::Llm(e.to_string()))?;
let mut stop = StopReason::EndTurn;
while let Some(event) = stream.next().await {
match event.map_err(|e| RuntimeError::Llm(e.to_string()))? {
LlmEvent::TextDelta(delta) => {
state.full_text.push_str(&delta);
let mut seq = state.event_seq;
self.emit(run_id, &mut seq, RunEventBody::TextDelta { delta })
.await?;
state.event_seq = seq;
}
LlmEvent::ToolUse { id, name, input } => {
state.pending_tools.push(PendingTool { id, name, input });
}
LlmEvent::Usage {
input_tokens,
output_tokens,
} => {
state.input_tokens += u64::from(input_tokens);
state.output_tokens += u64::from(output_tokens);
}
LlmEvent::Stop(reason) => stop = reason,
}
}
if state.pending_tools.is_empty() || stop != StopReason::ToolUse {
break;
}
}
messages::set_content(
&self.inner.pool,
state.reply_message_id,
json!({"text": state.full_text}),
)
.await?;
// Record the assistant's side of the turn in the brain. Only the user's
// turn was ever written, so recall returned half-conversations: the
// question without the answer, which is the less useful half.
// Best-effort, exactly like the user-turn write.
crate::brain::remember_reply(
&state.agent_id.to_string(),
&state.full_text,
&state.session_id.to_string(),
);
// Meter the run (§8.4). Billing failures never fail the run — the
// usage ledger is the recovery path.
if let Err(error) = cm_billing::charge(
&self.inner.pool,
state.workspace_id,
state.agent_id,
Some(run_id),
state.input_tokens,
state.output_tokens,
)
.await
{
eprintln!("billing charge failed for run {run_id}: {error}");
}
runs::set_state(
&self.inner.pool,
run_id,
cm_domain::RunState::Completed,
None,
)
.await?;
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::RunCompleted {
message_id: state.reply_message_id.to_string(),
},
)
.await?;
Ok(Outcome::Completed)
}
async fn emit_step_started(
&self,
run_id: Uuid,
state: &mut LoopState,
tool: &PendingTool,
) -> Result<(), RuntimeError> {
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::StepStarted {
step_seq: state.step_seq,
tool: tool.name.clone(),
input: tool.input.clone(),
},
)
.await?;
state.event_seq = seq;
Ok(())
}
async fn emit_step_finished(
&self,
run_id: Uuid,
state: &mut LoopState,
status: StepStatus,
output: &Value,
) -> Result<(), RuntimeError> {
let mut seq = state.event_seq;
self.emit(
run_id,
&mut seq,
RunEventBody::StepFinished {
step_seq: state.step_seq,
status: status.as_str().to_owned(),
output: output.clone(),
},
)
.await?;
state.event_seq = seq;
Ok(())
}
/// After a successful `chat.send`, journal the inter-agent message so the
/// world feed surfaces agent-to-agent comms live (the A2A observer). No-op
/// for any other tool or a failed send.
async fn emit_agent_message(
&self,
run_id: Uuid,
state: &mut LoopState,
tool: &PendingTool,
status: StepStatus,
output: &Value,
) -> Result<(), RuntimeError> {
if tool.name != "chat.send" || status != StepStatus::Ok {
return Ok(());
}
let str_field = |v: &Value, k: &str| {
v.get(k)
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_owned()
};
let mut seq = state.event_seq;
// Group-room posts fan out to every other active member; 1:1 sends carry
// a single recipient id.
if output.get("room").and_then(|v| v.as_bool()) == Some(true) {
let participant_ids = output
.get("participants")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
self.emit(
run_id,
&mut seq,
RunEventBody::RoomMessage {
thread_id: str_field(output, "thread_id"),
subject: str_field(output, "subject"),
text: str_field(&tool.input, "message"),
participant_ids,
},
)
.await?;
state.event_seq = seq;
return Ok(());
}
let to_agent_id = str_field(output, "to_id");
if to_agent_id.is_empty() {
return Ok(());
}
self.emit(
run_id,
&mut seq,
RunEventBody::AgentMessage {
to_agent_id,
to_name: str_field(output, "to"),
text: str_field(&tool.input, "message"),
thread_id: str_field(output, "thread_id"),
},
)
.await?;
state.event_seq = seq;
Ok(())
}
async fn record_step(
&self,
state: &LoopState,
tool: &PendingTool,
status: StepStatus,
output: &Value,
) -> Result<(), RuntimeError> {
steps::append(
&self.inner.pool,
&Step {
id: Uuid::now_v7(),
message_id: state.reply_message_id,
seq: state.step_seq,
kind: "tool_call".into(),
tool_name: Some(tool.name.clone()),
input: Some(tool.input.clone()),
output: Some(output.clone()),
taint: state.taint.clone(),
status,
},
)
.await?;
Ok(())
}
}
/// Maps persisted history plus the new user message into provider-neutral
/// chat messages. Past step traces are display data; the model sees text.
fn chat_messages(history: &[MessageWithSteps], user_text: &str) -> Vec<ChatMessage> {
let mut out: Vec<ChatMessage> = history
.iter()
.filter_map(|entry| {
let text = entry.message.content["text"].as_str().unwrap_or_default();
if text.is_empty() {
return None;
}
let role = match entry.message.role {
MessageRole::User | MessageRole::System => ChatRole::User,
MessageRole::Agent => ChatRole::Assistant,
};
Some(ChatMessage {
role,
parts: vec![ContentPart::text(text)],
})
})
.collect();
out.push(ChatMessage {
role: ChatRole::User,
parts: vec![ContentPart::text(user_text)],
});
out
}