Skip to content

Commit 7d8c758

Browse files
feat(agentos): OTLP gateway identity enrich + live-chat UX for SDK agents
agentos-server: - OTLP trace gateway now enriches spans with computeragent.{group,owner,actor}.id resolved from the validated cak_ key (otel-auth → res.locals.otelIdentity; routes/otlp decodes → stamps each span → re-encodes via an embedded OTLP proto; fail-safe verbatim fallback; gzip-aware). Adds protobufjs + introspectCakBearer. So local SDK runs are group-scoped in the obs read like remote (CAS) runs. - agents/resolve falls back to Mongo _id; group-scoped readable ids; ownership. agentos (SPA): - Inline local-SDK agents are live-chattable; library (git/local) agents show a clear read-only "ran locally — can't be chatted here" state instead of a misleading composer/empty prompt. - Cold sessions can't be resumed (their local sandbox is gone): show a notice + prominent "Start a new session" button (also surfaced on a failed turn). - sse.ts surfaces turn errors instead of a silent "(no reply)". - Registry page hook refactor (useAgentRegistry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8a87d86 commit 7d8c758

17 files changed

Lines changed: 1002 additions & 3932 deletions

File tree

agentos/src/App.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { RegistryPage } from "./components/RegistryPage.tsx";
88
import { AgentDashboard } from "./components/AgentDashboard.tsx";
99
import { SettingsPage } from "./components/SettingsPage.tsx";
1010
import { Separator } from "./components/ui/separator.tsx";
11-
import { useAgents } from "./context/AgentsContext.tsx";
1211
import { cn } from "./lib/cn.ts";
1312

1413
export default function App() {
@@ -83,16 +82,13 @@ function HomeRoute() {
8382
}
8483

8584
function RegistryRoute() {
86-
const { agents, loaded, err, reload } = useAgents();
8785
const navigate = useNavigate();
86+
// RegistryPage owns its own paginated/infinite-scroll data via useAgentRegistry;
87+
// it only needs to know the open agent (for selection) and how to navigate.
8888
return (
8989
<RegistryPage
90-
agents={agents}
91-
loaded={loaded}
92-
err={err}
9390
selected={null}
9491
onOpenAgent={(agentId) => navigate(`/agents/${encodeURIComponent(agentId)}`)}
95-
onReload={reload}
9692
/>
9793
);
9894
}

agentos/src/api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,20 @@ export interface EvalRun {
446446

447447
export const api = {
448448
agents: () => getJSON<{ agents: Agent[] }>("/agents").then((d) => d.agents),
449+
// Paginated registry read (offset-based) with server-side search + an
450+
// active/archived filter. Used by the registry page's infinite scroll;
451+
// `agents` (above) stays the full-list read for the shared AgentsContext.
452+
agentsPage: (p: { limit: number; offset: number; q?: string; archived?: "false" | "true" | "all" }) => {
453+
const qs = new URLSearchParams({
454+
limit: String(p.limit),
455+
offset: String(p.offset),
456+
archived: p.archived ?? "false",
457+
});
458+
if (p.q && p.q.trim()) qs.set("q", p.q.trim());
459+
return getJSON<{ agents: Agent[]; total: number; limit: number; offset: number; hasMore: boolean }>(
460+
`/agents?${qs.toString()}`,
461+
);
462+
},
449463
registerAgent: (input: RegisterAgentInput) =>
450464
postJSON<{ ok: boolean; id: string; name: string }>("/agents/register", input),
451465
unregisterAgent: (agentId: string) =>

agentos/src/components/ChatTab.tsx

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useRef, useState } from "react";
2-
import { Paperclip, Play, Send, RefreshCw, AlertCircle } from "lucide-react";
2+
import { Paperclip, Play, Send, RefreshCw, AlertCircle, Plus } from "lucide-react";
33
import ReactMarkdown from "react-markdown";
44
import remarkGfm from "remark-gfm";
55
import { api } from "../api.ts";
@@ -28,25 +28,42 @@ export function ChatTab({
2828
agentId,
2929
agentName,
3030
sandboxCapable,
31+
liveChatCapable = true,
32+
resumeWarm = true,
3133
resumeSessionId,
3234
onConsumedResume,
3335
initialMessage,
3436
onConsumedInitial,
3537
onSessionStarted,
38+
onNewSession,
3639
disabled = false,
3740
}: {
3841
/** Registry ObjectId — used to address the agent in API calls. */
3942
agentId: string;
4043
/** Human name — used for display + the `bot` field on logged turns. */
4144
agentName: string;
4245
sandboxCapable: boolean;
46+
/** False for library-mode agents with no server-runnable source: we never
47+
* boot a chat-sandbox (it would 400 LIBRARY_AGENT_NO_LIVE_CHAT). Historical
48+
* transcripts stay viewable read-only; the composer is locked with a notice. */
49+
liveChatCapable?: boolean;
50+
/** Whether the session being RESUMED still has a live (warm) sandbox. A cold
51+
* session can't be resumed — the local sandbox that held its Claude
52+
* conversation is gone (resume → "No conversation found"). We show its
53+
* transcript read-only and direct the user to a New session. Ignored for a
54+
* fresh chat (resumeSessionId == null). */
55+
resumeWarm?: boolean;
4356
resumeSessionId: string | null;
4457
onConsumedResume: () => void;
4558
initialMessage?: string | null;
4659
onConsumedInitial?: () => void;
4760
/** Fired once a sandbox boots and a sessionId is established (new or resumed),
4861
* so the parent can refresh / highlight the session list. */
4962
onSessionStarted?: (sessionId: string) => void;
63+
/** Start a fresh session (parent's "New chat"). When provided, an ended /
64+
* non-resumable view shows a prominent "Start a new session" button. Only
65+
* passed when the agent can actually chat (live-chat-capable + agents:run). */
66+
onNewSession?: () => void;
5067
/** When true the composer is locked (e.g. the agent is archived). Sending is
5168
* refused server-side regardless; this just makes the UI state explicit. */
5269
disabled?: boolean;
@@ -58,8 +75,15 @@ export function ChatTab({
5875
const [input, setInput] = useState("");
5976
const [busy, setBusy] = useState(false);
6077
const [err, setErr] = useState<string | null>(null);
78+
// True when the resumed session is cold (no warm sandbox) and therefore
79+
// can't be continued — history is read-only and the user must start a New session.
80+
const [resumeEnded, setResumeEnded] = useState(false);
6181
const scrollRef = useRef<HTMLDivElement>(null);
6282

83+
// Composer is locked for an archived agent, a library agent (no runnable
84+
// source), or a cold session that can't be resumed.
85+
const composerLocked = disabled || !liveChatCapable || resumeEnded;
86+
6387
useEffect(() => {
6488
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
6589
}, [msgs]);
@@ -69,24 +93,39 @@ export function ChatTab({
6993
setSessionId(null);
7094
setMsgs([]);
7195
setErr(null);
96+
setResumeEnded(false);
7297
}, [agentId]);
7398

7499
useEffect(() => {
75100
if (!resumeSessionId) return;
76101
const sid = resumeSessionId;
77102
onConsumedResume();
78103
(async () => {
104+
let prior: Msg[] = [];
79105
try {
80106
const d = await api.session(sid);
81-
const prior: Msg[] = d.entries.map((e) => ({
82-
role: e.type.includes("user") ? "user" : "assistant",
107+
prior = d.entries.map((e) => ({
108+
role: (e.type.includes("user") ? "user" : "assistant") as Msg["role"],
83109
text: e.text,
84110
}));
85-
setMsgs(prior.length ? prior : [{ role: "status", text: "Resumed — no stored transcript. Continue below.", loading: false }]);
86111
} catch {
87-
setMsgs([{ role: "status", text: "Resumed session — memory is loaded. Continue below.", loading: false }]);
112+
/* no stored transcript — fall through to the notices below */
113+
}
114+
// Resume is only possible when the agent is live-chat-capable AND this
115+
// session still has a warm sandbox. A cold session can't be resumed: the
116+
// local sandbox that held its Claude conversation is gone, so a resume
117+
// would fail with "No conversation found". Show history read-only and
118+
// direct the user to a New session (which boots a fresh sandbox).
119+
if (liveChatCapable && resumeWarm) {
120+
setMsgs(prior.length ? prior : [{ role: "status", text: "Resumed — no stored transcript. Continue below.", loading: false }]);
121+
await boot(sid);
122+
} else {
123+
setResumeEnded(true);
124+
const note = !liveChatCapable
125+
? "Read-only — this agent has no runnable source, so live chat is unavailable."
126+
: "This conversation has ended — it ran in a local session that's no longer live, so it can't be resumed. Click “+ New” to start a fresh chat.";
127+
setMsgs([...prior, { role: "status", text: note, loading: false }]);
88128
}
89-
await boot(sid);
90129
})();
91130
// eslint-disable-next-line react-hooks/exhaustive-deps
92131
}, [resumeSessionId]);
@@ -95,7 +134,9 @@ export function ChatTab({
95134
if (!initialMessage) return;
96135
const m = initialMessage;
97136
onConsumedInitial?.();
98-
void send(m);
137+
// A library agent can't start a live chat; consume the handed-over message
138+
// but don't boot a sandbox (it would 400).
139+
if (liveChatCapable) void send(m);
99140
// eslint-disable-next-line react-hooks/exhaustive-deps
100141
}, [initialMessage]);
101142

@@ -123,7 +164,7 @@ export function ChatTab({
123164
}
124165

125166
async function send(textArg?: string) {
126-
if (disabled) return;
167+
if (composerLocked) return;
127168
const text = (textArg ?? input).trim();
128169
if (!text || busy) return;
129170

@@ -216,7 +257,13 @@ export function ChatTab({
216257
</Button>
217258
</>
218259
) : (
219-
<span className="text-muted-foreground">{booting ? "Booting sandbox…" : "Send a message to start a session"}</span>
260+
<span className="text-muted-foreground">
261+
{!liveChatCapable
262+
? "Read-only — this agent ran locally and can’t be chatted here"
263+
: booting
264+
? "Booting sandbox…"
265+
: "Send a message to start a session"}
266+
</span>
220267
)}
221268
</div>
222269

@@ -225,12 +272,24 @@ export function ChatTab({
225272
{err && (
226273
<div className="flex items-start gap-2 text-sm text-destructive p-3 rounded-md bg-destructive/10 border border-destructive/20">
227274
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
228-
<span>{err}</span>
275+
<div className="flex-1 min-w-0 space-y-2">
276+
<span>{err}</span>
277+
{onNewSession && (
278+
<div>
279+
<Button size="sm" onClick={onNewSession} className="gap-1.5">
280+
<Plus className="h-3.5 w-3.5" />
281+
Start a new session
282+
</Button>
283+
</div>
284+
)}
285+
</div>
229286
</div>
230287
)}
231288
{msgs.length === 0 && !err && (
232-
<div className="h-full grid place-items-center text-muted-foreground text-sm">
233-
Talk to {agentName}. It runs with its configured identity and tools.
289+
<div className="h-full grid place-items-center text-muted-foreground text-sm text-center px-6">
290+
{liveChatCapable
291+
? `Talk to ${agentName}. It runs with its configured identity and tools.`
292+
: `${agentName} ran in a local session and can’t be chatted here. Any past sessions remain viewable (read-only).`}
234293
</div>
235294
)}
236295
{msgs.map((m, i) => {
@@ -249,26 +308,55 @@ export function ChatTab({
249308

250309
{/* Input */}
251310
<div className="px-6 py-4 border-t border-border">
311+
{!liveChatCapable ? (
312+
// Library agent (ran locally / no runnable source): no composer at all,
313+
// just a clear notice so it's obvious why there's nothing to type into.
314+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
315+
<AlertCircle className="h-4 w-4 shrink-0" />
316+
<span>This agent ran in a local session and can’t be chatted here. Past sessions are read-only.</span>
317+
</div>
318+
) : resumeEnded && onNewSession ? (
319+
// Cold session — can't be resumed. Offer the New session action right
320+
// here so the user doesn't have to hunt for the sidebar "+ New".
321+
<div className="flex items-center justify-between gap-3 flex-wrap">
322+
<span className="text-sm text-muted-foreground">
323+
This conversation ran in a local session and can’t be resumed.
324+
</span>
325+
<Button onClick={onNewSession} className="rounded-xl gap-1.5">
326+
<Plus className="h-4 w-4" />
327+
<span>Start a new session</span>
328+
</Button>
329+
</div>
330+
) : (
252331
<div className="flex gap-2">
253332
<Textarea
254333
value={input}
255334
onChange={(e) => setInput(e.target.value)}
256335
onKeyDown={(e) => {
257336
if (e.key === "Enter" && !e.shiftKey) {
258337
e.preventDefault();
259-
if (!disabled) send();
338+
if (!composerLocked) send();
260339
}
261340
}}
262-
disabled={disabled}
263-
placeholder={disabled ? "This agent is archived — unarchive it to chat." : `Message ${agentName}… (Enter to send, Shift+Enter for newline)`}
341+
disabled={composerLocked}
342+
placeholder={
343+
disabled
344+
? "This agent is archived — unarchive it to chat."
345+
: !liveChatCapable
346+
? "Library agent — no runnable source, so live chat is unavailable. Past sessions are read-only."
347+
: resumeEnded
348+
? "This chat has ended — click “+ New” to start a fresh session."
349+
: `Message ${agentName}… (Enter to send, Shift+Enter for newline)`
350+
}
264351
rows={2}
265352
className="flex-1 resize-none rounded-xl bg-card px-3.5 py-2.5"
266353
/>
267-
<Button onClick={() => send()} disabled={disabled || busy || !input.trim()} className="rounded-xl px-4">
354+
<Button onClick={() => send()} disabled={composerLocked || busy || !input.trim()} className="rounded-xl px-4">
268355
{busy ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
269356
<span>Send</span>
270357
</Button>
271358
</div>
359+
)}
272360
</div>
273361
</div>
274362
);

0 commit comments

Comments
 (0)