Agents: read-only agent-to-agent observer in the chat card + live agent.message
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 12s
ci / frontend (push) Successful in 26s
ci / e2e (push) Has been skipped

A new "Observe" button (Eye) sits next to "+ New" in the agent chat header; clicking
flips the card into a read-only observer of that agent's conversations with other
agents, updating live as messages happen.

- frontend: AgentObserver (history from /api/claw-chat/* + live agent.message overlay
  filtered to the agent, read-only banner, no composer); ClawChatSection toggle + flip.
- backend: emit a live agent.message run-event when chat.send succeeds — events.rs
  AgentMessage variant, chat.send returns to_id, runtime emits in both tool paths,
  world.rs normalizes agent_message → agent.message SSE. No migration, no new table.

Roadmap (not built): group/multi-party rooms; A2A protocol (a2a-rs) adoption.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-27 09:57:29 -07:00
co-authored by Claude Opus 4.8
parent f1f1f4f862
commit 66c4a80985
6 changed files with 228 additions and 3 deletions
+19
View File
@@ -133,6 +133,25 @@ fn normalize_run_event(
json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }), json!({ "doorId": door_id, "agentId": agent_id, "action": action, "target": category, "summary": action }),
)); ));
} }
"agent_message" => {
let s = |k: &str| {
payload
.get(k)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned()
};
out.push((
"agent.message",
json!({
"fromAgentId": agent_id,
"toAgentId": s("to_agent_id"),
"toName": s("to_name"),
"text": s("text"),
"threadId": s("thread_id"),
}),
));
}
_ => {} _ => {}
} }
out out
+9
View File
@@ -30,6 +30,14 @@ pub enum RunEventBody {
status: String, status: String,
output: Value, output: Value,
}, },
/// An inter-agent message this run sent via `chat.send` (§7.2). Journaled so
/// the world feed can surface agent-to-agent comms live (the A2A observer).
AgentMessage {
to_agent_id: String,
to_name: String,
text: String,
thread_id: String,
},
/// A gated action awaits human review (§15): carries the exact preview /// A gated action awaits human review (§15): carries the exact preview
/// of what will execute, surfaced as the approval card (§10). /// of what will execute, surfaced as the approval card (§10).
ApprovalRequired { ApprovalRequired {
@@ -58,6 +66,7 @@ impl RunEventBody {
RunEventBody::TextDelta { .. } => "text_delta", RunEventBody::TextDelta { .. } => "text_delta",
RunEventBody::StepStarted { .. } => "step_started", RunEventBody::StepStarted { .. } => "step_started",
RunEventBody::StepFinished { .. } => "step_finished", RunEventBody::StepFinished { .. } => "step_finished",
RunEventBody::AgentMessage { .. } => "agent_message",
RunEventBody::ApprovalRequired { .. } => "approval_required", RunEventBody::ApprovalRequired { .. } => "approval_required",
RunEventBody::RunSuspended { .. } => "run_suspended", RunEventBody::RunSuspended { .. } => "run_suspended",
RunEventBody::RunCompleted { .. } => "run_completed", RunEventBody::RunCompleted { .. } => "run_completed",
+44
View File
@@ -682,6 +682,8 @@ impl Runtime {
self.record_step(state, &tool, status, &output).await?; self.record_step(state, &tool, status, &output).await?;
self.emit_step_finished(ready.run_id, state, status, &output) self.emit_step_finished(ready.run_id, state, status, &output)
.await?; .await?;
self.emit_agent_message(ready.run_id, state, &tool, status, &output)
.await?;
state.assistant_parts.push(ContentPart::ToolUse { state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(), id: tool.id.clone(),
name: tool.name, name: tool.name,
@@ -795,6 +797,8 @@ impl Runtime {
self.record_step(&state, &tool, status, &output).await?; self.record_step(&state, &tool, status, &output).await?;
self.emit_step_finished(run_id, &mut state, status, &output) self.emit_step_finished(run_id, &mut state, status, &output)
.await?; .await?;
self.emit_agent_message(run_id, &mut state, &tool, status, &output)
.await?;
state.assistant_parts.push(ContentPart::ToolUse { state.assistant_parts.push(ContentPart::ToolUse {
id: tool.id.clone(), id: tool.id.clone(),
name: tool.name.clone(), name: tool.name.clone(),
@@ -935,6 +939,46 @@ impl Runtime {
Ok(()) 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 to_agent_id = str_field(output, "to_id");
if to_agent_id.is_empty() {
return Ok(());
}
let mut seq = state.event_seq;
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( async fn record_step(
&self, &self,
state: &LoopState, state: &LoopState,
+6 -1
View File
@@ -91,7 +91,12 @@ impl Tool for ChatSend {
) )
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(json!({ "sent": true, "to": target.name, "thread_id": thread_id })) Ok(json!({
"sent": true,
"to": target.name,
"to_id": target.id.to_string(),
"thread_id": thread_id,
}))
} }
} }
@@ -0,0 +1,133 @@
"use client";
// Read-only observer of a claw's agent-to-agent conversations. Backfills history
// from the same inter-agent inbox API that ClawChatApp uses (/api/claw-chat/*) and
// overlays live `agent.message` events (emitted when any claw uses chat.send), so a
// human can watch this agent talk to other agents as it happens. No composer — the
// human is an observer, not a participant.
import { useState } from "react";
import { Eye, MessageCircle } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch";
import { useLiveEvent } from "@/lib/live/useClawmatesLive";
import { relativeTime } from "@/lib/format/relative-time";
interface Thread {
id: string;
subject: string;
sensitivity: string;
last_preview: string | null;
created_at: string;
}
interface ThreadMessage {
id: string;
from_agent: string;
content: { text?: string };
created_at: string;
}
export function AgentObserver({ agent }: { agent: Agent }) {
const [openThread, setOpenThread] = useState<Thread | null>(null);
// Re-scope when the selected claw changes (render-phase, not in an effect).
const [seen, setSeen] = useState(agent.id);
if (seen !== agent.id) {
setSeen(agent.id);
setOpenThread(null);
}
const threads = useFetchJson<Thread[]>(`/api/claw-chat/threads?clawId=${agent.id}`);
const messages = useFetchJson<ThreadMessage[]>(
openThread ? `/api/claw-chat/messages?clawId=${agent.id}&threadId=${openThread.id}` : null,
);
// Live overlay: any inter-agent message touching this agent refreshes the view.
useLiveEvent("agent.message", (d) => {
if (d.fromAgentId === agent.id || d.toAgentId === agent.id) {
threads.refresh();
messages.refresh();
}
});
const banner = (
<div
className="flex shrink-0 items-center gap-2 border-b border-white/[0.06] px-4 py-2 text-xxs text-muted-foreground"
style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", letterSpacing: ".06em" }}
>
<Eye aria-hidden size={12} className="text-coral" />
OBSERVING {agent.name.toUpperCase()}&rsquo;S AGENT-TO-AGENT CONVERSATIONS · READ-ONLY
</div>
);
if (openThread) {
return (
<div className="flex h-full flex-col">
{banner}
<div className="min-h-0 flex-1 overflow-auto p-3">
<button
type="button"
onClick={() => setOpenThread(null)}
className="pb-2 text-xs text-muted-foreground hover:text-foreground"
>
‹ {openThread.subject}
</button>
<ul aria-label="Thread messages" className="flex flex-col gap-2">
{(messages.data ?? []).map((message) => (
<li
key={message.id}
className={`max-w-[85%] rounded-(--radius) px-2 py-1.5 text-xs ${
message.from_agent === agent.id ? "self-end bg-surface-warm-muted" : "bg-subtle"
}`}
>
{message.content.text}
<span className="block pt-0.5 text-xxs text-muted-foreground">
{relativeTime(message.created_at)}
</span>
</li>
))}
</ul>
</div>
</div>
);
}
const list = threads.data ?? [];
return (
<div className="flex h-full flex-col">
{banner}
<div className="min-h-0 flex-1 overflow-auto p-3">
{list.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<MessageCircle aria-hidden size={22} />
<p className="text-sm">No agent-to-agent conversations yet.</p>
<p className="text-xs">Messages appear here live as {agent.name} talks to other agents.</p>
</div>
) : (
<ul aria-label="Threads">
{list.map((thread) => (
<li key={thread.id}>
<button
type="button"
onClick={() => setOpenThread(thread)}
className="w-full rounded-xl px-2 py-2 text-left transition-colors duration-(--duration-normal) ease-app hover:bg-neutral-800/40"
>
<p className="flex items-center gap-2 text-sm">
{thread.subject}
{thread.sensitivity === "sensitive" && (
<span className="rounded-(--radius-button) bg-destructive/40 px-1.5 text-xxs">sensitive</span>
)}
<span className="ml-auto text-xxs text-muted-foreground">{relativeTime(thread.created_at)}</span>
</p>
<p className="truncate text-xs text-muted-foreground">{thread.last_preview ?? ""}</p>
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -8,7 +8,7 @@
// the hook cleanly (useChat takes initialMessages only at mount). // the hook cleanly (useChat takes initialMessages only at mount).
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MessageSquare, Minus, Plus } from "lucide-react"; import { Eye, MessageSquare, Minus, Plus } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import type { HistoryMessage, Session } from "@/lib/api/sessions"; import type { HistoryMessage, Session } from "@/lib/api/sessions";
@@ -17,6 +17,7 @@ import { useChat } from "@/lib/gateway/use-chat";
import { MessageList } from "@/components/chat/MessageList"; import { MessageList } from "@/components/chat/MessageList";
import { Composer } from "@/components/chat/Composer"; import { Composer } from "@/components/chat/Composer";
import { WelcomeState } from "@/components/chat/WelcomeState"; import { WelcomeState } from "@/components/chat/WelcomeState";
import { AgentObserver } from "./AgentObserver";
function toUiMessage(entry: HistoryMessage): UiMessage | null { function toUiMessage(entry: HistoryMessage): UiMessage | null {
if (entry.role === "system") return null; if (entry.role === "system") return null;
@@ -44,6 +45,7 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
const [sessionKey, setSessionKey] = useState<string | null>(null); const [sessionKey, setSessionKey] = useState<string | null>(null);
const [initial, setInitial] = useState<UiMessage[]>([]); const [initial, setInitial] = useState<UiMessage[]>([]);
const [phase, setPhase] = useState<"loading" | "ready" | "error">("loading"); const [phase, setPhase] = useState<"loading" | "ready" | "error">("loading");
const [observerMode, setObserverMode] = useState(false);
// Reset to loading when the claw changes (render-phase, not in the effect). // Reset to loading when the claw changes (render-phase, not in the effect).
const [seenAgent, setSeenAgent] = useState(agent.id); const [seenAgent, setSeenAgent] = useState(agent.id);
@@ -123,6 +125,17 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
<button type="button" onClick={() => onOpenComputerApp("slack")} className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground">Slack</button> <button type="button" onClick={() => onOpenComputerApp("slack")} className="rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground">Slack</button>
) : null} ) : null}
<button type="button" onClick={newSession} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground"><Plus aria-hidden size={13} /> New</button> <button type="button" onClick={newSession} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-hover-bg hover:text-foreground"><Plus aria-hidden size={13} /> New</button>
{/* Flip the card into the read-only agent-to-agent observer (and back). */}
<button
type="button"
aria-label="Agent-to-agent observer"
aria-pressed={observerMode}
title={observerMode ? "Back to chat" : "Observe agent-to-agent chat"}
onClick={() => setObserverMode((v) => !v)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${observerMode ? "text-coral" : "text-muted-foreground hover:bg-hover-bg hover:text-foreground"}`}
>
<Eye aria-hidden size={13} /> Observe
</button>
<span className="flex-1" /> <span className="flex-1" />
{/* Minimize the chat into the top-right launcher (mirrors the computer). */} {/* Minimize the chat into the top-right launcher (mirrors the computer). */}
{onMinimize ? ( {onMinimize ? (
@@ -130,7 +143,9 @@ export function ClawChatSection({ agent, onOpenComputerApp, onMinimize }: { agen
) : null} ) : null}
</div> </div>
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
{phase === "loading" ? ( {observerMode ? (
<AgentObserver agent={agent} />
) : phase === "loading" ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Loading chat…</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">Loading chat…</div>
) : phase === "error" ? ( ) : phase === "error" ? (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">Couldn’t load chat.</div> <div className="flex h-full items-center justify-center text-xs text-muted-foreground">Couldn’t load chat.</div>