World: normalize real run_events into the live taxonomy (the data unlock)
/api/world/live now tails each active run's journal (run_events) and normalizes
the runner's existing events into taxonomy events — no runner change needed:
- text_delta -> agent.reasoning.delta (the live reasoning stream)
- step_started -> agent.tool.call + node.activity + world.touch on the tool
node (agents visibly converge on the tool they're using)
- approval_required -> door.request
Per-run seq cursor streams forward only (skips backlog on first sight). This
lights up REAL data for both the World view and the upcoming Observe surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
53279e6339
commit
380b95da2a
@@ -61,6 +61,50 @@ async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A short human label for a tool's input (for the tool-call target).
|
||||
fn summarize_input(input: &Value) -> String {
|
||||
for k in ["target", "path", "url", "query", "name", "file", "command"] {
|
||||
if let Some(s) = input.get(k).and_then(|v| v.as_str()) {
|
||||
return s.chars().take(48).collect();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Normalize one durable `run_events` row into taxonomy events — the Rust twin of
|
||||
/// the handoff bridge's normalize(). The runner already journals these, so the
|
||||
/// live view shows REAL reasoning, tool-convergence and doors with no runner edit.
|
||||
fn normalize_run_event(agent_id: &str, event_type: &str, payload: &Value) -> Vec<(&'static str, Value)> {
|
||||
let mut out = Vec::new();
|
||||
match event_type {
|
||||
"text_delta" => {
|
||||
if let Some(delta) = payload.get("delta").and_then(|v| v.as_str()) {
|
||||
out.push(("agent.reasoning.delta", json!({ "agentId": agent_id, "text": delta })));
|
||||
}
|
||||
}
|
||||
"step_started" => {
|
||||
let tool = payload.get("tool").and_then(|v| v.as_str()).unwrap_or("tool");
|
||||
let node_id = format!("tool:{tool}");
|
||||
let target = payload.get("input").map(summarize_input).unwrap_or_default();
|
||||
out.push(("agent.tool.call", json!({ "agentId": agent_id, "tool": tool, "target": target })));
|
||||
out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": 0.9 })));
|
||||
// the agent converges on the tool it's using (the Gource beam)
|
||||
out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service" })));
|
||||
}
|
||||
"approval_required" => {
|
||||
let action = payload.get("action_type").and_then(|v| v.as_str()).unwrap_or("action");
|
||||
let category = payload.get("category").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let door_id = payload.get("approval_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
out.push((
|
||||
"door.request",
|
||||
json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Count of doors (approvals) awaiting a decision in the workspace.
|
||||
async fn doors_pending(pool: &PgPool, ws: WorkspaceId) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
@@ -81,6 +125,8 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
|
||||
let mut first = true;
|
||||
// Remember last status per agent so we only push deltas after the seed.
|
||||
let mut last: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
// Per-run journal cursor so we stream only NEW run_events each poll.
|
||||
let mut cursors: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
|
||||
loop {
|
||||
let roster = match cm_db::repo::agents::roster(&pool, ws).await {
|
||||
Ok(r) => r,
|
||||
@@ -121,6 +167,43 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
|
||||
json!({ "nodeId": node_id, "label": "active run", "kind": "event", "heat": 0.85 }),
|
||||
);
|
||||
yield sse("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "event" }));
|
||||
|
||||
// Tail the run's journal for richer real events (reasoning, tool
|
||||
// convergence, doors). On first sight, jump the cursor to the
|
||||
// current max so we stream forward without replaying the backlog.
|
||||
if let Some(&after) = cursors.get(run_id) {
|
||||
let rows = sqlx::query(
|
||||
"SELECT seq, event_type, payload FROM run_events
|
||||
WHERE run_id = $1::uuid AND seq > $2 ORDER BY seq ASC LIMIT 200",
|
||||
)
|
||||
.bind(run_id)
|
||||
.bind(after)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut maxseq = after;
|
||||
for row in &rows {
|
||||
let seq: i64 = row.get("seq");
|
||||
let et: String = row.get("event_type");
|
||||
let payload: Value = row.get("payload");
|
||||
for (t, d) in normalize_run_event(agent_id, &et, &payload) {
|
||||
yield sse(t, d);
|
||||
}
|
||||
if seq > maxseq {
|
||||
maxseq = seq;
|
||||
}
|
||||
}
|
||||
cursors.insert(run_id.clone(), maxseq);
|
||||
} else {
|
||||
let maxseq: i64 = sqlx::query_scalar(
|
||||
"SELECT coalesce(max(seq), -1) FROM run_events WHERE run_id = $1::uuid",
|
||||
)
|
||||
.bind(run_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
cursors.insert(run_id.clone(), maxseq);
|
||||
}
|
||||
}
|
||||
|
||||
yield sse(
|
||||
|
||||
Reference in New Issue
Block a user