Menu
popagent
publicLatest change f6fedafe4a36ed6468d5b1b819bbcdcbed5a0d53 - Address a peer by display capitalization, and correct the 9Router runbook by AkurAI Build
import type { AgentExecutionSource } from "./api-types";
export type TaskAgentParticipantState = "idle" | "active" | "waiting" | "terminal";
export type TaskAgentPeer = {
id: string;
state: TaskAgentParticipantState;
};
export type TaskAgentCommunicationMessage = {
id: string;
taskId: string;
workspaceId: string;
resourceId: string;
from: string;
to: string;
body: string;
replyTo?: string;
createdAt: string;
};
export type TaskAgentDeliveryReceipt = {
to: string;
outcome: "delivered" | "queued" | "woken" | "failed";
messageId?: string;
error?: string;
};
type Waiter = {
from?: string;
resolve: (message: TaskAgentCommunicationMessage | null) => void;
reject: (error: Error) => void;
};
export type TaskAgentWakeHandler = (
recipientId: string,
message: TaskAgentCommunicationMessage,
) => Promise<void>;
const DEFAULT_MAILBOX_CAP = 100;
export class TaskAgentCommunicationSession {
readonly taskId: string;
readonly workspaceId: string;
readonly resourceId: string;
readonly #mailboxCap: number;
readonly #persist?: (message: TaskAgentCommunicationMessage) => Promise<void>;
readonly #wake?: TaskAgentWakeHandler;
readonly #participants = new Map<string, TaskAgentParticipantState>();
readonly #mailboxes = new Map<string, TaskAgentCommunicationMessage[]>();
readonly #waiters = new Map<string, Waiter[]>();
readonly #messages = new Map<string, TaskAgentCommunicationMessage>();
readonly #wakes = new Set<Promise<void>>();
#executionContext?: {
executionSource: AgentExecutionSource;
selfUpdateWorkspacePath?: string;
};
#closed = false;
#closeReason = new Error("Task communication session closed");
constructor(options: {
taskId: string;
workspaceId: string;
resourceId: string;
participants: readonly string[];
mailboxCap?: number;
signal?: AbortSignal;
persist?: (message: TaskAgentCommunicationMessage) => Promise<void>;
wake?: TaskAgentWakeHandler;
}) {
const mailboxCap = options.mailboxCap ?? DEFAULT_MAILBOX_CAP;
if (!Number.isInteger(mailboxCap) || mailboxCap < 1) {
throw new RangeError("Task communication mailbox cap must be a positive integer");
}
this.taskId = options.taskId;
this.workspaceId = options.workspaceId;
this.resourceId = options.resourceId;
this.#mailboxCap = mailboxCap;
this.#persist = options.persist;
this.#wake = options.wake;
for (const id of options.participants) {
if (id && !this.#participants.has(id)) this.#participants.set(id, "idle");
}
if (options.signal?.aborted) {
this.close(options.signal.reason);
} else {
options.signal?.addEventListener("abort", () => this.close(options.signal!.reason), { once: true });
}
}
list(agentId: string): TaskAgentPeer[] {
this.#activate(agentId);
return [...this.#participants]
.filter(([id, state]) => id !== agentId && state !== "terminal")
.map(([id, state]) => ({ id, state }));
}
configureExecution(context: {
executionSource: AgentExecutionSource;
selfUpdateWorkspacePath?: string;
}): void {
if (this.#closed) return;
this.#executionContext = context;
}
executionContext(): {
executionSource: AgentExecutionSource;
selfUpdateWorkspacePath?: string;
} | undefined {
return this.#executionContext;
}
hasReply(messageId: string, from: string): boolean {
return [...this.#messages.values()].some((message) =>
message.replyTo === messageId && message.from === from);
}
async send(rawInput: {
from: string;
to: string;
body: string;
replyTo?: string;
}): Promise<TaskAgentDeliveryReceipt> {
// Agents read display names ("Orchistrator") from the channel and address
// them verbatim, while participants are keyed by agent id ("orchistrator").
const input = { ...rawInput, from: this.#canonical(rawInput.from), to: this.#canonical(rawInput.to) };
const invalid = this.#validateSend(input.from, input.to, input.replyTo);
if (invalid) return { to: input.to, outcome: "failed", error: invalid };
const body = input.body.trim();
if (!body || body.length > 4_000) {
return { to: input.to, outcome: "failed", error: "Message body must contain 1 to 4000 characters" };
}
this.#activate(input.from);
const message: TaskAgentCommunicationMessage = {
id: crypto.randomUUID(),
taskId: this.taskId,
workspaceId: this.workspaceId,
resourceId: this.resourceId,
from: input.from,
to: input.to,
body,
...(input.replyTo ? { replyTo: input.replyTo } : {}),
createdAt: new Date().toISOString(),
};
try {
await this.#persist?.(message);
} catch (error) {
return {
to: input.to,
outcome: "failed",
error: error instanceof Error ? error.message : String(error),
};
}
if (this.#closed) {
return { to: input.to, outcome: "failed", error: this.#closeReason.message };
}
this.#messages.set(message.id, message);
const waiter = this.#takeWaiter(input.to, input.from);
if (waiter) {
waiter.resolve(message);
return { to: input.to, outcome: "delivered", messageId: message.id };
}
const mailbox = this.#mailboxes.get(input.to) ?? [];
mailbox.push(message);
if (mailbox.length > this.#mailboxCap) mailbox.shift();
this.#mailboxes.set(input.to, mailbox);
if (!input.replyTo && this.#participants.get(input.to) === "idle" && this.#wake) {
this.#scheduleWake(message);
return { to: input.to, outcome: "woken", messageId: message.id };
}
return { to: input.to, outcome: "queued", messageId: message.id };
}
async broadcast(from: string, body: string): Promise<TaskAgentDeliveryReceipt[]> {
const senderError = this.#participantError(from);
if (senderError) return [{ to: "all", outcome: "failed", error: senderError }];
const recipients = [...this.#participants]
.filter(([id, state]) => id !== from && state !== "terminal")
.map(([id]) => id);
const receipts: TaskAgentDeliveryReceipt[] = [];
for (const to of recipients) receipts.push(await this.send({ from, to, body }));
return receipts;
}
inbox(agentId: string, options: { peek?: boolean } = {}): TaskAgentCommunicationMessage[] {
if (this.#participantError(agentId)) return [];
this.#activate(agentId);
const mailbox = this.#mailboxes.get(agentId) ?? [];
if (options.peek) return [...mailbox];
this.#mailboxes.delete(agentId);
return mailbox;
}
takeContext(agentId: string, limit = 8): TaskAgentCommunicationMessage[] {
if (this.#participantError(agentId)) return [];
if (!Number.isInteger(limit) || limit < 1 || limit > 8) {
throw new RangeError("Task communication context limit must be between 1 and 8");
}
this.#activate(agentId);
const mailbox = this.#mailboxes.get(agentId);
if (!mailbox?.length) return [];
const messages = mailbox.splice(0, limit);
if (!mailbox.length) this.#mailboxes.delete(agentId);
return messages;
}
idle(agentId: string): void {
if (this.#closed || this.#participants.get(agentId) === "terminal") return;
this.#participants.set(agentId, "idle");
const pending = this.#mailboxes.get(agentId)?.[0];
if (pending && this.#wake) this.#scheduleWake(pending);
}
async settle(): Promise<void> {
while (this.#wakes.size) {
const wakes = [...this.#wakes];
try {
await Promise.all(wakes);
} finally {
for (const wake of wakes) this.#wakes.delete(wake);
}
}
}
async wait(
agentId: string,
options: { from?: string; timeoutMs?: number } = {},
): Promise<TaskAgentCommunicationMessage | null> {
const participantError = this.#participantError(agentId);
if (participantError) throw new Error(participantError);
if (options.from) {
const fromError = this.#participantError(options.from);
if (fromError) throw new Error(fromError);
}
const pending = this.#takeMailbox(agentId, options.from);
if (pending) {
this.#activate(agentId);
return pending;
}
const timeoutMs = options.timeoutMs ?? 10_000;
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 30_000) {
throw new RangeError("Wait timeout must be between 0 and 30000 milliseconds");
}
this.#participants.set(agentId, "waiting");
const { promise, resolve, reject } = Promise.withResolvers<TaskAgentCommunicationMessage | null>();
let timer: Timer | undefined;
const cleanup = () => {
clearTimeout(timer);
const waiters = this.#waiters.get(agentId);
if (!waiters) return;
const index = waiters.indexOf(waiter);
if (index >= 0) waiters.splice(index, 1);
if (!waiters.length) this.#waiters.delete(agentId);
};
const settleState = () => {
if (!this.#closed && this.#participants.get(agentId) !== "terminal") {
this.#participants.set(agentId, "active");
}
};
const waiter: Waiter = {
from: options.from,
resolve: (message) => { cleanup(); settleState(); resolve(message); },
reject: (error) => { cleanup(); settleState(); reject(error); },
};
const waiters = this.#waiters.get(agentId) ?? [];
waiters.push(waiter);
this.#waiters.set(agentId, waiters);
if (timeoutMs > 0) {
timer = setTimeout(() => waiter.resolve(null), timeoutMs);
timer.unref?.();
}
return promise;
}
terminal(agentId: string): void {
if (!this.#participants.has(agentId)) return;
this.#participants.set(agentId, "terminal");
this.#mailboxes.delete(agentId);
const error = new Error(`Agent "${agentId}" is terminal`);
for (const waiter of [...this.#waiters.get(agentId) ?? []]) waiter.reject(error);
}
close(reason?: unknown): void {
if (this.#closed) return;
this.#closed = true;
this.#closeReason = reason instanceof Error
? reason
: new Error(String(reason ?? "Task communication session closed"));
for (const id of this.#participants.keys()) this.#participants.set(id, "terminal");
this.#mailboxes.clear();
for (const waiters of [...this.#waiters.values()]) {
for (const waiter of [...waiters]) waiter.reject(this.#closeReason);
}
this.#waiters.clear();
}
#activate(agentId: string): void {
if (this.#participants.get(agentId) !== "terminal") this.#participants.set(agentId, "active");
}
/** Registered id for a case variant, so identity checks still run on one key. */
#canonical(agentId: string): string {
if (this.#participants.has(agentId)) return agentId;
const wanted = agentId.trim().toLowerCase();
for (const id of this.#participants.keys()) if (id.toLowerCase() === wanted) return id;
return agentId;
}
#participantError(agentId: string): string | undefined {
if (this.#closed) return `Task communication session is closed: ${this.#closeReason.message}`;
const state = this.#participants.get(agentId);
if (!state) return `Unknown task agent "${agentId}"`;
if (state === "terminal") return `Task agent "${agentId}" is terminal`;
return undefined;
}
#validateSend(from: string, to: string, replyTo?: string): string | undefined {
const senderError = this.#participantError(from);
if (senderError) return senderError;
const recipientError = this.#participantError(to);
if (recipientError) return recipientError;
if (from === to) return "Task agents cannot message themselves";
if (replyTo) {
const original = this.#messages.get(replyTo);
if (!original) return `Unknown reply message "${replyTo}"`;
if (original.from !== to || original.to !== from) {
return `Only the addressed recipient "${original.to}" can reply to message "${replyTo}"`;
}
}
return undefined;
}
#scheduleWake(message: TaskAgentCommunicationMessage): void {
if (!this.#wake || this.#closed) return;
const mailbox = this.#mailboxes.get(message.to);
const index = mailbox?.findIndex((pending) => pending.id === message.id) ?? -1;
if (mailbox && index >= 0) mailbox.splice(index, 1);
if (mailbox && !mailbox.length) this.#mailboxes.delete(message.to);
const wake = this.#wake(message.to, message)
.finally(() => this.idle(message.to));
this.#wakes.add(wake);
void wake.catch(() => undefined);
}
#takeMailbox(agentId: string, from?: string): TaskAgentCommunicationMessage | undefined {
const mailbox = this.#mailboxes.get(agentId);
if (!mailbox?.length) return undefined;
const index = from ? mailbox.findIndex((message) => message.from === from) : 0;
if (index < 0) return undefined;
const [message] = mailbox.splice(index, 1);
if (!mailbox.length) this.#mailboxes.delete(agentId);
return message;
}
#takeWaiter(agentId: string, from: string): Waiter | undefined {
const waiters = this.#waiters.get(agentId);
if (!waiters) return undefined;
const index = waiters.findIndex((waiter) => !waiter.from || waiter.from === from);
if (index < 0) return undefined;
const [waiter] = waiters.splice(index, 1);
if (!waiters.length) this.#waiters.delete(agentId);
return waiter;
}
}