Agents: read-only agent-to-agent observer in the chat card + live agent.message
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:
co-authored by
Claude Opus 4.8
parent
f1f1f4f862
commit
66c4a80985
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()}’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>
|
||||||
|
|||||||
Reference in New Issue
Block a user