Skip to content

Commit db7691b

Browse files
authored
feat(managed-agents): clarify online chat status and admission (#310)
1 parent 881cd5c commit db7691b

6 files changed

Lines changed: 278 additions & 60 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ readiness for the signed-in account. `aether doctor --live` checks the same
141141
read-only Cloud contract; plain `aether doctor` leaves account readiness
142142
unverified. A saved DM is reported as admitted only when Cloud confirms
143143
message admission.
144+
In `aether agent chat`, `/help` lists commands for the selected agent and
145+
`/refresh` checks the shared conversation again. The header shows Cloud DM sync
146+
separately from ATS local setup. Transcript messages show their time and any
147+
reported admission state; a saved message is not an admitted run.
144148

145149
ATS is the trading adapter for account agents. Create a workspace with `aether agent create ATS Atlas`; guided setup covers memory, strategies, and data settings.
146150

‎src/commands/ats_agent.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ export function createAtsHooks(deps: AtsHookDeps = {}): ManagedAgentHooks {
744744
throw new Error("ATS memory writer lease did not establish runtime exclusivity.");
745745
}
746746
const settings = await pack.loadSettings(join(dirname(path), "settings.json"));
747-
surface?.setContext?.({ memory: `writer leased ${binding.memory_gb} GiB`, mode: `${settings.permission_mode} requested`, strategies: "configured", data: String(pack.dataStreamStatus(settings)["state"] ?? "unverified") });
747+
surface?.setContext?.({ memory: `writer leased ${binding.memory_gb} GiB`, mode: `${settings.permission_mode} requested`, strategies: "directory configured; readiness unverified", data: String(pack.dataStreamStatus(settings)["state"] ?? "unverified"), profileCheckedAt: Date.now() });
748748
await appendJournal(pack, path, agent.agent_id, "chat.opened", "ATS chat acquired the memory writer lease.", { memory_gb: binding.memory_gb, runtime_exclusive: true });
749749
output(theme.dim("ATS · local memory writer leased · Cloud DM · local execution is not connected to this conversation\n"));
750750
// Strict local loopback runtimes intentionally support unauthenticated setup.

‎src/commands/managed_agents.ts‎

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { AppContext } from "../core/context.js";
55
import {
66
ManagedAgentsClient, MANAGED_AGENT_ID, managedAgentError, probeManagedReadiness,
77
type ManagedReadiness, type ReadinessGate,
8-
type ManagedAgent, type ManagedAgentConfig, type AgentMessage,
8+
type ManagedAgent, type ManagedAgentConfig, type AgentMessage, type MessageAdmission,
99
} from "../core/managed_agents.js";
1010
import { theme } from "../ui/theme.js";
1111
import { sanitizeTerm, sliceVisible, visibleWidth, wrapVisible } from "../ui/text.js";
@@ -17,6 +17,7 @@ import { managedChatInput } from "../ui/managed_chat_input.js";
1717
export interface ManagedChatContext {
1818
chat: "connecting" | "synced" | "paused";
1919
checkedAt?: number;
20+
profileCheckedAt?: number;
2021
mode?: string;
2122
memory?: string;
2223
strategies?: string;
@@ -99,15 +100,30 @@ export function renderManagedAgents(agents: ManagedAgent[], columns = process.st
99100
return rows.flatMap(row => wrapVisible(row, width)).join("\n") + "\n";
100101
}
101102

102-
/** A compact status strip above the composer; no sidebar steals narrow widths. */
103+
function checkedTime(value: number | undefined): string {
104+
return value !== undefined && Number.isFinite(value) && !Number.isNaN(new Date(value).getTime())
105+
? new Date(value).toISOString() : "not checked";
106+
}
107+
108+
function isAts(agent: ManagedAgent): boolean {
109+
return agent.config.profile?.schema_version === "aether.managed-agent.profile/1" && agent.config.profile.kind === "ats";
110+
}
111+
112+
/** Read-only status from Cloud and, for typed ATS agents, checked local setup. */
103113
export function renderManagedContext(agent: ManagedAgent, state: ManagedChatContext, columns: number): string {
104114
const width = Math.max(20, Math.floor(columns));
105115
const clean = (value: string): string => sanitizeTerm(value).replace(/[\r\n\t]/g, " ");
106-
const lines = [
107-
`${clean(agent.config.identity.display_name)} · ${clean(agent.runtime.tile_state)} · chat ${state.chat}`,
108-
`mode ${state.mode ?? "unconfirmed"} · memory ${state.memory ?? "unverified"} · strategies ${state.strategies ?? "unverified"}`,
109-
`browser ${state.browser ?? "unverified"} · data ${state.data ?? "unverified"}`,
110-
];
116+
const name = sliceVisible(clean(agent.config.identity.display_name), Math.min(24, width - 16));
117+
const dm = `DM ${state.chat}`;
118+
const dmChecked = `checked ${checkedTime(state.checkedAt)}`;
119+
const lines = [`${name} · ${sliceVisible(clean(agent.runtime.tile_state), 16)}`];
120+
lines.push(...(visibleWidth(`${dm} · ${dmChecked}`) <= width ? [`${dm} · ${dmChecked}`] : [dm, dmChecked]));
121+
if (isAts(agent)) {
122+
const details = checkedTime(state.profileCheckedAt) === "not checked" ? [] : (["mode", "memory", "strategies", "browser", "data"] as const)
123+
.flatMap(key => state[key] && !/^(unverified|unconfirmed)$/i.test(state[key]) ? [`${key} ${clean(state[key])}`] : []);
124+
if (details.length) lines.push("ATS local", `checked ${checkedTime(state.profileCheckedAt)}`, ...details);
125+
else lines.push("ATS local · not checked; local setup does not verify live orders");
126+
}
111127
return lines.flatMap(line => wrapVisible(theme.dim(clean(line)), width)).join("\n");
112128
}
113129

@@ -220,10 +236,45 @@ async function pickManagedAgent(agents: ManagedAgent[], out: Writable, signal?:
220236
});
221237
}
222238

223-
function renderMessage(message: AgentMessage, agent: ManagedAgent): string {
239+
function admissionLabel(admission: MessageAdmission | undefined): string {
240+
if (!admission) return "saved · admission unconfirmed";
241+
switch (admission.state) {
242+
case "admitted": return "admitted";
243+
case "replied": return "replied";
244+
case "blocked_budget": return "saved · blocked by budget";
245+
case "blocked_policy": return "saved · blocked by policy";
246+
case "needs_review": return "saved · needs review";
247+
case "saved": return "saved · admission pending";
248+
}
249+
}
250+
251+
export function renderManagedMessage(message: AgentMessage, agent: ManagedAgent, columns = 80): string {
224252
const fromAgent = message.sender_type === "agent" || Boolean(message.sender_agent_id);
225253
const label = fromAgent ? agent.config.identity.display_name : "You";
226-
return `\n${theme.cyan(sanitizeTerm(label))}\n${sanitizeTerm(message.body)}\n`;
254+
const parsed = message.created_at ? Date.parse(message.created_at) : NaN;
255+
const time = Number.isFinite(parsed) ? new Date(parsed).toISOString() : "time unknown";
256+
const admission = !fromAgent ? ` · ${admissionLabel(message.admission)}` : "";
257+
const width = Math.max(20, Math.floor(columns));
258+
const cleanLabel = sliceVisible(sanitizeTerm(label).replace(/[\r\n\t]/g, " "), Math.min(32, width - 16));
259+
const speakerTime = `${cleanLabel} · ${time}`;
260+
const header = [
261+
...(visibleWidth(speakerTime) <= width ? [speakerTime] : [cleanLabel, time]),
262+
...(admission ? wrapVisible(admission.slice(3), width) : []),
263+
].join("\n");
264+
const body = sanitizeTerm(message.body).split("\n").flatMap(line => wrapVisible(line, width)).join("\n");
265+
return `\n${header}\n${body}\n`;
266+
}
267+
268+
export function renderManagedAdmission(admission: MessageAdmission | undefined): string {
269+
const state = admission?.state;
270+
const next: Record<string, string> = {
271+
blocked_budget: "Next: configure this agent's UVT limits, then send a new message.",
272+
blocked_policy: "Next: review this agent's permissions in Online before trying again.",
273+
needs_review: "Next: review this message in Online.",
274+
saved: "Next: use /refresh to check whether Cloud admitted this message.",
275+
unreported: "Next: check the shared conversation before sending again.",
276+
};
277+
return `${admissionLabel(admission)}${next[state ?? "unreported"] ? ` · ${next[state ?? "unreported"]}` : ""}`;
227278
}
228279

229280
/** Scoped managed-chat hotkey. Repeated key events cannot queue mode changes
@@ -274,18 +325,17 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
274325
let timer: ReturnType<typeof setInterval> | undefined;
275326
let closeSession: void | (() => Promise<void>) = undefined;
276327
let reader: Interface | undefined;
328+
let promptReady = false;
277329
let surfaceClosed = false;
278-
let promptDrawn = false;
279330
let contextState: ManagedChatContext = { chat: "connecting" };
280-
let refreshPrompt: (() => void) | undefined;
281331
const surface: ManagedChatSurface = {
282332
signal,
283333
connection: () => ({ chat: contextState.chat, checkedAt: contextState.checkedAt }),
284-
setContext(state) { contextState = { ...contextState, ...state }; refreshPrompt?.(); },
334+
setContext(state) { contextState = { ...contextState, ...state }; },
285335
write(text) {
286336
if (surfaceClosed) return;
287337
if (ctx.flags.json) out.write(JSON.stringify({ type: "agent_status", text: sanitizeTerm(text) }) + "\n");
288-
else if (reader && terminal) { writeManagedChatEvent(out, reader, text); promptDrawn = true; }
338+
else if (reader && terminal && promptReady) writeManagedChatEvent(out, reader, text);
289339
else out.write(text);
290340
},
291341
};
@@ -296,13 +346,14 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
296346
if (readiness.registry.state !== "enabled") { writeGateError(readiness.registry, ctx, deps.err ?? process.stderr); return 1; }
297347
if (readiness.dm.state !== "enabled") { writeGateError(readiness.dm, ctx, deps.err ?? process.stderr); return 1; }
298348
if (ctx.flags.json) out.write(JSON.stringify({ type: "readiness", readiness }) + "\n");
299-
else out.write(readinessLine(readiness));
349+
else if (readiness.model_uvt.state !== "enabled") out.write(`Model admission: ${readiness.model_uvt.code}. ${readiness.model_uvt.remedy}\n`);
300350
let agent = id ? await client.get(id, signal) : await pickManagedAgent(await client.list(signal), out, signal);
301351
if (!agent) return 0;
302352
closeSession = await deps.hooks?.beforeChat?.(ctx, agent, surface);
303353
const thread = await client.thread(agent.agent_id, signal);
304354
if (typeof thread["id"] !== "string") throw new Error("Cloud did not return a conversation ID.");
305355
const conversationId = thread["id"];
356+
const admissionReceipts = new Map<string, MessageAdmission>();
306357
const send = async (body: string): Promise<boolean> => {
307358
const nonce = randomUUID();
308359
let receipt;
@@ -312,41 +363,33 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
312363
throw new Error(`${managedAgentError(error)} Delivery is unconfirmed; check the shared conversation before sending again.`);
313364
}
314365
const state = receipt.admission?.state ?? "unreported";
366+
if (receipt.admission) {
367+
admissionReceipts.set(receipt.id, receipt.admission);
368+
while (admissionReceipts.size > 1000) admissionReceipts.delete(admissionReceipts.keys().next().value!);
369+
}
315370
const accepted = state === "admitted" || state === "replied";
316371
const code = accepted ? "ADMITTED" : "SEND_NOT_ADMITTED";
317372
if (ctx.flags.json) out.write(JSON.stringify({ type: "message_admission", code, state }) + "\n");
318-
else surface.write(theme.dim(`${accepted ? "Message admitted" : "Message saved; execution not accepted"} · ${sanitizeTerm(state)}`) + "\n");
373+
else surface.write(theme.dim(renderManagedAdmission(receipt.admission)) + "\n");
319374
return accepted;
320375
};
321376
if (prompt.trim()) return await send(prompt) ? 0 : 1;
322377
if (!ctx.flags.json) {
323-
out.write(renderAgent(agent) + theme.dim("Shared Online DM · /refresh · /exit") + "\n");
324-
const help = deps.hooks?.help?.(agent);
325-
if (help) out.write(theme.dim(sanitizeTerm(help)) + "\n");
378+
out.write("Shared Online DM · /help · /refresh · /exit\n");
326379
}
327380
const seen = new Map<string, string>();
328381
let polling = false;
329382
let syncFailed = false;
383+
let showedEmpty = false;
330384
let closed = false;
331385
const ownedInput = terminal ? managedChatInput(inputStream) : undefined;
332386
const rl = createInterface({ input: ownedInput?.input ?? inputStream, output: out, terminal });
333387
reader = rl;
334388
const lines = rl[Symbol.asyncIterator]();
335389
let inputClosed = false;
336390
rl.once("close", () => { inputClosed = true; reader = undefined; });
337-
refreshPrompt = (): void => {
338-
const columns = (out as Writable & { columns?: number }).columns ?? 80;
339-
const next = ctx.flags.json ? "" : `${renderManagedContext(agent!, contextState, columns)}\n${theme.cyan("you › ")}`;
340-
if (next === rl.getPrompt()) return;
341-
// Measure the OLD prompt before changing its wrapped height.
342-
if (promptDrawn && terminal && !inputClosed && !closed) {
343-
const { rows } = rl.getCursorPos();
344-
out.write(`\r${rows > 0 ? `\x1b[${rows}A` : ""}\x1b[0J`);
345-
rl.setPrompt(next); rl.prompt(true);
346-
} else rl.setPrompt(next);
347-
};
348-
refreshPrompt();
349-
const onResize = (): void => { refreshPrompt?.(); if (terminal && !inputClosed && !closed) rl.prompt(true); };
391+
rl.setPrompt(ctx.flags.json ? "" : theme.cyan("you › "));
392+
const onResize = (): void => { if (terminal && !inputClosed && !closed) rl.prompt(true); };
350393
out.on("resize", onResize);
351394
const refresh = async (redraw = false): Promise<void> => {
352395
if (polling || closed) return;
@@ -356,28 +399,32 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
356399
if (closed) return;
357400
agent = latest;
358401
contextState = { ...contextState, chat: "synced", checkedAt: Date.now() };
359-
refreshPrompt?.();
360-
for (const message of messages) {
361-
if (seen.get(message.id) === message.body) continue;
362-
const rendered = ctx.flags.json ? JSON.stringify({ type: "message", message }) + "\n" : renderMessage(message, agent!);
363-
if (terminal && reader) { writeManagedChatEvent(out, rl, rendered); promptDrawn = true; }
402+
if (redraw && !ctx.flags.json) surface.write(renderManagedContext(agent!, contextState, (out as Writable & { columns?: number }).columns ?? 80) + "\n");
403+
if (messages.length === 0 && !ctx.flags.json && !showedEmpty) {
404+
surface.write("No messages yet. Write to this agent, or use /help for commands.\n");
405+
showedEmpty = true;
406+
}
407+
for (const rawMessage of messages) {
408+
const message = rawMessage.admission ? rawMessage : { ...rawMessage, admission: admissionReceipts.get(rawMessage.id) };
409+
const fingerprint = JSON.stringify([message.body, message.admission?.state, message.admission?.run_id]);
410+
if (seen.get(message.id) === fingerprint) continue;
411+
const rendered = ctx.flags.json ? JSON.stringify({ type: "message", message }) + "\n" : renderManagedMessage(message, agent!, (out as Writable & { columns?: number }).columns ?? 80);
412+
if (terminal && reader && promptReady) writeManagedChatEvent(out, rl, rendered);
364413
else out.write(rendered);
365-
seen.set(message.id, message.body);
414+
seen.set(message.id, fingerprint);
366415
while (seen.size > 1000) seen.delete(seen.keys().next().value!);
367416
}
368417
if (syncFailed && !ctx.flags.json) surface.write(theme.dim("Conversation sync restored.\n"));
369418
syncFailed = false;
370419
} catch (error) {
371420
contextState = { ...contextState, chat: "paused" };
372-
refreshPrompt?.();
373421
if (!closed && !syncFailed) {
374422
if (ctx.flags.json) out.write(JSON.stringify({ type: "sync_error", message: managedAgentError(error) }) + "\n");
375-
else surface.write(theme.yellow("Conversation sync paused. " + sanitizeTerm(managedAgentError(error))) + "\n");
423+
else surface.write(theme.yellow("DM sync paused. " + sanitizeTerm(managedAgentError(error)) + " Next: /refresh to retry.") + "\n");
376424
}
377425
syncFailed = true;
378426
} finally {
379427
polling = false;
380-
if (redraw && !closed && !inputClosed && terminal) rl.prompt(true);
381428
}
382429
};
383430
let operations = Promise.resolve();
@@ -396,18 +443,24 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
396443
rl.on("SIGINT", stop);
397444
signal.addEventListener("abort", stop, { once: true });
398445
try {
399-
await refresh();
400-
if (terminal) timer = setInterval(() => { void refresh(true); }, 3000);
401-
if (terminal && !inputClosed) { rl.prompt(); promptDrawn = true; }
446+
await refresh(true);
447+
if (terminal) timer = setInterval(() => { void refresh(); }, 3000);
448+
if (terminal && !inputClosed) { rl.prompt(); promptReady = true; }
402449
for await (const line of { [Symbol.asyncIterator]: () => lines }) {
403450
const input = line.trim();
404451
if (input === "/exit" || input === "/quit") break;
405452
try {
406453
await enqueue(async () => {
407-
if (input === "/refresh") await refresh();
454+
if (input === "/refresh") await refresh(true);
455+
else if (input === "/help") {
456+
surface.write("Shared Online DM · /refresh checks messages and status · /exit closes chat\n");
457+
const help = deps.hooks?.help?.(agent!);
458+
if (help) surface.write(sanitizeTerm(help) + "\n");
459+
if (isAts(agent!)) surface.write("ATS local preferences do not authorize live orders. RC observation is separate from this DM.\n");
460+
}
408461
else if (input.startsWith("/")) {
409462
if (!(await deps.hooks?.onChatCommand?.(ctx, agent!, input, surface))) {
410-
surface.write("Use /refresh or /exit. Configure this agent with `aether agent configure`.\n");
463+
surface.write("Unknown command. Use /help for this agent's commands.\n");
411464
}
412465
} else if (input) { await send(input); await refresh(); }
413466
});
@@ -416,17 +469,17 @@ export async function cmdManagedAgentChat(ctx: AppContext, id: string | undefine
416469
if (ctx.flags.json) out.write(JSON.stringify({ type: "command_error", message }) + "\n");
417470
else surface.write(theme.yellow(message) + "\n");
418471
}
419-
if (terminal && !inputClosed) { rl.prompt(); promptDrawn = true; }
472+
if (terminal && !inputClosed) { rl.prompt(); promptReady = true; }
420473
}
421474
} finally {
422475
closed = true;
423476
reader = undefined;
477+
promptReady = false;
424478
removeKeys();
425479
await operations;
426480
signal.removeEventListener("abort", stop);
427481
rl.removeListener("SIGINT", stop);
428482
out.removeListener("resize", onResize);
429-
refreshPrompt = undefined;
430483
rl.close();
431484
ownedInput?.dispose();
432485
}

0 commit comments

Comments
 (0)