Gateway: show subagent requests on the parent's card, and settle threads
This commit is contained in:
1 parent
a8b6375afe
commit
a4754b65b0
7 files changed
+183
-21
No files matched your search
+90
-15
@@ -180,7 +180,56 @@ export function routeKind(url: string): RouteKind {
|
||||
return hostname.includes("vpn") ? "vpn" : "direct";
|
||||
}
|
||||
|
||||
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem {
|
||||
/** A thread on the feed. With `via`, the card shows that subagent's request on this thread. */
|
||||
interface Candidate {
|
||||
host: T3Host;
|
||||
thread: ThreadShell;
|
||||
phase: Phase;
|
||||
via?: { host: T3Host; thread: ThreadShell; phase: Phase };
|
||||
}
|
||||
|
||||
const isWaiting = (phase: Phase | null) => phase === "waiting_for_approval" || phase === "waiting_for_input";
|
||||
|
||||
/** The thread that delegated `child`, if the gateway is connected to its host. */
|
||||
function delegator(hosts: T3Host[], child: ThreadShell): { host: T3Host; thread: ThreadShell } | null {
|
||||
const from = child.delegatedFrom;
|
||||
if (!from) return null;
|
||||
const host = hosts.find((candidate) => candidate.config.environmentId === from.environmentId);
|
||||
const thread = host?.threads.get(from.threadId);
|
||||
return host && thread ? { host, thread } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves each waiting subagent's request onto the card of the thread that delegated it, the oldest
|
||||
* request first. A subagent keeps its own card when its parent is unknown, hidden, archived, or
|
||||
* already showing a request.
|
||||
*/
|
||||
function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] {
|
||||
const result = [...candidates];
|
||||
const delegates = candidates
|
||||
.filter(({ thread, phase }) => thread.delegatedFrom && isWaiting(phase))
|
||||
.sort((a, b) => requestAge(a.thread).localeCompare(requestAge(b.thread)));
|
||||
for (const child of delegates) {
|
||||
const parent = delegator(hosts, child.thread);
|
||||
const index = parent ? result.findIndex(({ host, thread }) => host === parent.host && thread.id === parent.thread.id) : -1;
|
||||
const card = result[index];
|
||||
const own = result.indexOf(child);
|
||||
if (!card || own === -1 || card.via || isWaiting(card.phase)) continue;
|
||||
result[index] = { ...card, phase: child.phase, via: { host: child.host, thread: child.thread, phase: child.phase } };
|
||||
result.splice(own, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? "";
|
||||
|
||||
function feedItem(
|
||||
host: T3Host,
|
||||
thread: ThreadShell,
|
||||
phase: Phase,
|
||||
summary: ThreadSummary | undefined,
|
||||
via?: { host: T3Host; thread: ThreadShell; summary: ThreadSummary | undefined },
|
||||
): FeedItem {
|
||||
return {
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
@@ -191,10 +240,26 @@ function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: Thre
|
||||
phase,
|
||||
updatedAt: thread.updatedAt,
|
||||
lastMessage: summary?.lastAssistantMessage ?? null,
|
||||
request: summary?.request ?? null,
|
||||
request: (via ? via.summary : summary)?.request ?? null,
|
||||
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The longest waiting subagent that `parent` delegated to, if any. */
|
||||
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
|
||||
let oldest: Candidate | undefined;
|
||||
for (const host of hosts) {
|
||||
for (const thread of host.threads.values()) {
|
||||
const from = thread.delegatedFrom;
|
||||
if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue;
|
||||
const phase = threadPhase(thread);
|
||||
if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue;
|
||||
if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! };
|
||||
}
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
/** Counts for the complication, from thread shells alone. */
|
||||
export function buildSummary(hosts: T3Host[]): Summary {
|
||||
let needsYou = 0;
|
||||
@@ -221,37 +286,37 @@ export function buildSummary(hosts: T3Host[]): Summary {
|
||||
|
||||
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
|
||||
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> {
|
||||
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = [];
|
||||
const found: Candidate[] = [];
|
||||
for (const host of hosts) {
|
||||
for (const thread of host.threads.values()) {
|
||||
const phase = threadPhase(thread);
|
||||
if (phase === null || thread.archivedAt || thread.deletedAt) continue;
|
||||
if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue;
|
||||
candidates.push({ host, thread, phase });
|
||||
found.push({ host, thread, phase });
|
||||
}
|
||||
}
|
||||
const candidates = attachDelegates(found, hosts);
|
||||
|
||||
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
|
||||
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
|
||||
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
|
||||
const needsYou = candidates
|
||||
.filter(({ phase }) => phase === "waiting_for_approval" || phase === "waiting_for_input")
|
||||
.sort((a, b) =>
|
||||
(a.thread.pendingRuntimeRequest?.createdAt ?? "").localeCompare(b.thread.pendingRuntimeRequest?.createdAt ?? ""),
|
||||
);
|
||||
.filter(({ phase }) => isWaiting(phase))
|
||||
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b))));
|
||||
const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest);
|
||||
const recent = candidates
|
||||
.filter(({ phase }) => phase === "completed" || phase === "failed")
|
||||
.sort(byNewest)
|
||||
.slice(0, RECENT_LIMIT);
|
||||
|
||||
const shown = [...needsYou, ...working, ...recent];
|
||||
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
|
||||
const missing = shown
|
||||
.filter(({ host, thread }) => !cache.peek(host, thread))
|
||||
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
|
||||
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
|
||||
|
||||
const toItem = ({ host, thread, phase }: (typeof candidates)[number]) =>
|
||||
feedItem(host, thread, phase, cache.peek(host, thread));
|
||||
const toItem = ({ host, thread, phase, via }: Candidate) =>
|
||||
feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
|
||||
return {
|
||||
needsYou: needsYou.map(toItem),
|
||||
working: working.map(toItem),
|
||||
@@ -260,12 +325,22 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date
|
||||
};
|
||||
}
|
||||
|
||||
/** One thread with its recent messages. Fetches the summary if it is not cached. */
|
||||
export async function buildThreadDetail(host: T3Host, thread: ThreadShell, cache: SummaryCache): Promise<ThreadDetail> {
|
||||
/**
|
||||
* One thread with its recent messages. Fetches the summary if it is not cached. When the thread
|
||||
* has no request of its own but a subagent it delegated to is waiting, shows that request.
|
||||
*/
|
||||
export async function buildThreadDetail(
|
||||
host: T3Host,
|
||||
thread: ThreadShell,
|
||||
cache: SummaryCache,
|
||||
hosts: T3Host[],
|
||||
): Promise<ThreadDetail> {
|
||||
const summary = await cache.get(host, thread);
|
||||
const phase = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
|
||||
const own = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
|
||||
const child = isWaiting(own) ? undefined : waitingDelegate(hosts, host, thread);
|
||||
const via = child && { host: child.host, thread: child.thread, summary: await cache.get(child.host, child.thread).catch(() => undefined) };
|
||||
return {
|
||||
item: feedItem(host, thread, phase, summary),
|
||||
item: feedItem(host, thread, child?.phase ?? own, summary, via),
|
||||
messages: summary.messages,
|
||||
canStop: thread.activeRunId !== null,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user