feat(telemetry): the reasoning stream actually streams
`agent.reasoning.delta` and `agent.tool.call` have been declared in the taxonomy and listened for by the command centre since it shipped — and NOTHING ever emitted them. The world feed emitted five types; neither was among them, so REASONING STREAM could not populate no matter what an agent did. The feed is a database poll, not a push bus, so a live card can only show what was persisted. The worker already holds each step's output text and the claw that produced it, so it records a `reasoning` mission_event (truncated — the card renders a tail, not a transcript, and mission_events is capped per phase), and the feed emits it forward from a cursor that starts at the current max so a page load streams rather than replaying history. `tool.call` is emitted from the same place. On the container tier it will stay empty, and that is correct rather than broken: those agents are tool-free behind the §15 door. Tool lines appear where agents actually hold tools. Verified on a live mission: agent.reasoning.delta observed on /api/world/live carrying the agent's own text, keyed by agentId. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8be7b3c9b2
commit
bf40d10064
@@ -77,11 +77,7 @@ struct MissionRow {
|
|||||||
agent_id: Option<String>,
|
agent_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn world_missions(
|
async fn world_missions(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec<MissionRow> {
|
||||||
pool: &PgPool,
|
|
||||||
ws: WorkspaceId,
|
|
||||||
only: Option<Uuid>,
|
|
||||||
) -> Vec<MissionRow> {
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT m.id::text AS mission_id,
|
"SELECT m.id::text AS mission_id,
|
||||||
m.title AS title,
|
m.title AS title,
|
||||||
@@ -416,11 +412,7 @@ fn benchmark_note(delta: &serde_json::Value) -> Option<String> {
|
|||||||
|
|
||||||
/// Agents that execute a phase of this kind, via the purposes the phase runner
|
/// Agents that execute a phase of this kind, via the purposes the phase runner
|
||||||
/// itself uses. Returns empty for a teamless (microVM) mission.
|
/// itself uses. Returns empty for a teamless (microVM) mission.
|
||||||
async fn phase_agents(
|
async fn phase_agents(pool: &PgPool, mission_id: &str, kind: &str) -> Vec<String> {
|
||||||
pool: &PgPool,
|
|
||||||
mission_id: &str,
|
|
||||||
kind: &str,
|
|
||||||
) -> Vec<String> {
|
|
||||||
let Ok(mid) = Uuid::parse_str(mission_id) else {
|
let Ok(mid) = Uuid::parse_str(mission_id) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -751,6 +743,10 @@ pub async fn world_live(
|
|||||||
// Audit-log cursor for edge-initiated inter-agent events (delegation,
|
// Audit-log cursor for edge-initiated inter-agent events (delegation,
|
||||||
// A2A) that bypass the run loop. -1 until seeded on the first pass.
|
// A2A) that bypass the run loop. -1 until seeded on the first pass.
|
||||||
let mut audit_cursor: i64 = -1;
|
let mut audit_cursor: i64 = -1;
|
||||||
|
// Cursor over mission_events for the per-agent LIVE column. Starts at
|
||||||
|
// -1 and jumps to the current max on first sight, so a page load
|
||||||
|
// streams forward instead of replaying every past turn.
|
||||||
|
let mut agent_ev_cursor: i64 = -1;
|
||||||
// Track the brain-file size we last announced per agent so we only
|
// Track the brain-file size we last announced per agent so we only
|
||||||
// emit `agent.memory` when the file has actually grown (or shrunk).
|
// emit `agent.memory` when the file has actually grown (or shrunk).
|
||||||
// On first seed we still emit — the World engine needs the initial
|
// On first seed we still emit — the World engine needs the initial
|
||||||
@@ -1106,6 +1102,68 @@ pub async fn world_live(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-agent LIVE column: REASONING STREAM + tool lines.
|
||||||
|
//
|
||||||
|
// These two taxonomy types were declared and listened for since the
|
||||||
|
// command centre shipped, and NOTHING ever emitted them — the cards
|
||||||
|
// could not populate no matter what an agent did. mission_events is
|
||||||
|
// the durable source: `reasoning` rows carry the agent's own step
|
||||||
|
// output, `tool.call` rows its actions.
|
||||||
|
//
|
||||||
|
// NOTE: on the container tier `tool.call` legitimately stays empty —
|
||||||
|
// those agents are tool-free behind the §15 door. Reasoning flows on
|
||||||
|
// every tier; tool lines appear where agents actually hold tools.
|
||||||
|
if agent_ev_cursor < 0 {
|
||||||
|
agent_ev_cursor = sqlx::query_scalar(
|
||||||
|
"SELECT coalesce(max(id), 0) FROM mission_events",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
} else {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT e.id, e.agent_id, e.kind, e.target, e.detail
|
||||||
|
FROM mission_events e
|
||||||
|
JOIN missions m ON m.id = e.mission_id
|
||||||
|
WHERE m.workspace_id = $1
|
||||||
|
AND e.id > $2
|
||||||
|
AND e.agent_id IS NOT NULL
|
||||||
|
AND e.kind IN ('reasoning', 'tool.call')
|
||||||
|
ORDER BY e.id
|
||||||
|
LIMIT 200",
|
||||||
|
)
|
||||||
|
.bind(ws.as_uuid())
|
||||||
|
.bind(agent_ev_cursor)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
for r in &rows {
|
||||||
|
let eid: i64 = r.get("id");
|
||||||
|
agent_ev_cursor = agent_ev_cursor.max(eid);
|
||||||
|
let agent_id: Option<uuid::Uuid> = r.get("agent_id");
|
||||||
|
let Some(agent_id) = agent_id else { continue };
|
||||||
|
let kind: String = r.get("kind");
|
||||||
|
let detail: serde_json::Value = r.get("detail");
|
||||||
|
let target: Option<String> = r.get("target");
|
||||||
|
if kind == "tool.call" {
|
||||||
|
yield sse("agent.tool.call", json!({
|
||||||
|
"agentId": agent_id.to_string(),
|
||||||
|
"tool": target.clone().unwrap_or_default(),
|
||||||
|
"target": detail.get("path").and_then(|v| v.as_str()),
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
let text = detail.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if !text.is_empty() {
|
||||||
|
yield sse("agent.reasoning.delta", json!({
|
||||||
|
"agentId": agent_id.to_string(),
|
||||||
|
"text": text,
|
||||||
|
"channel": "say",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Workspace-wide telemetry (top-bar pills / Observe system strip).
|
// Workspace-wide telemetry (top-bar pills / Observe system strip).
|
||||||
yield sse(
|
yield sse(
|
||||||
"telemetry",
|
"telemetry",
|
||||||
@@ -1274,7 +1332,10 @@ mod mission_feed_tests {
|
|||||||
// finds. Written whole, the test fails on itself — which it did, and
|
// finds. Written whole, the test fails on itself — which it did, and
|
||||||
// which is the same self-match that makes `pkill -f <pattern>` kill the
|
// which is the same self-match that makes `pkill -f <pattern>` kill the
|
||||||
// shell carrying the pattern.
|
// shell carrying the pattern.
|
||||||
let needle = concat!("SELECT checkpoint FROM topology_", "runs WHERE id = $1::uuid");
|
let needle = concat!(
|
||||||
|
"SELECT checkpoint FROM topology_",
|
||||||
|
"runs WHERE id = $1::uuid"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!src.contains(needle),
|
!src.contains(needle),
|
||||||
"the checkpoint tail is keyed on an agent_runs id and cannot match a \
|
"the checkpoint tail is keyed on an agent_runs id and cannot match a \
|
||||||
|
|||||||
@@ -494,6 +494,37 @@ async fn drive<E: TurnExecutor>(
|
|||||||
// mission turn costs what it costs. It clamps at the available
|
// mission turn costs what it costs. It clamps at the available
|
||||||
// balance and still records the full obligation, so an empty
|
// balance and still records the full obligation, so an empty
|
||||||
// wallet cannot fail a turn.
|
// wallet cannot fail a turn.
|
||||||
|
// The agent's own words, for the REASONING STREAM card. The
|
||||||
|
// world feed is a DB poll, not a push bus, so a live card can
|
||||||
|
// only show what was persisted — this is the step output the
|
||||||
|
// worker already has in hand, attributed to the claw that
|
||||||
|
// produced it. Truncated because the card renders a tail, not a
|
||||||
|
// transcript, and mission_events is capped per phase.
|
||||||
|
if let Some(agent_id) = agent_of.get(&last.node_id).copied() {
|
||||||
|
let text: String = last.output.chars().take(600).collect();
|
||||||
|
if !text.trim().is_empty() {
|
||||||
|
if let Some(mission_id) = sqlx::query_scalar::<_, Option<Uuid>>(
|
||||||
|
"SELECT mission_id FROM topology_runs WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let mut ev = crate::mission_events::MissionEvent::new(
|
||||||
|
mission_id,
|
||||||
|
"reasoning",
|
||||||
|
);
|
||||||
|
ev.agent_id = Some(agent_id);
|
||||||
|
ev.run_id = Some(id);
|
||||||
|
ev.target = Some(last.role.clone());
|
||||||
|
ev.detail = serde_json::json!({ "text": text });
|
||||||
|
crate::mission_events::record(&pool, ev).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(agent_id) = agent_of.get(&last.node_id).copied() {
|
if let Some(agent_id) = agent_of.get(&last.node_id).copied() {
|
||||||
if last.tokens > 0 {
|
if last.tokens > 0 {
|
||||||
// The executor reports ONE total, not an in/out split.
|
// The executor reports ONE total, not an in/out split.
|
||||||
|
|||||||
Reference in New Issue
Block a user