Files
next-tjwater-agent/src/chat/response-inputs.ts
T

100 lines
4.9 KiB
TypeScript

export type InputDelivery = "steer" | "queue";
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" | "cancelled" };
type WaitingInboxRuntime = {
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
agentMessageState: (sessionId: string, id: string) => Promise<"missing" | "enqueued" | "delivered">;
enqueueUserInput: (sessionId: string, input: ResponseInput, options?: { resume?: boolean }) => Promise<void>;
};
/** A question boundary admits input without starting an unowned model loop. */
export class WaitingResponseInputQueue {
private readonly operations = new Map<string, Promise<ResponseInput>>();
constructor(private readonly runtime: WaitingInboxRuntime) {}
async submit(sessionId: string, input: ResponseInput): Promise<ResponseInput> {
const operation = (this.operations.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(async () => {
const pending = await this.runtime.listPendingUserInputs(sessionId);
const previous = pending.find((item) => item.id === input.id);
if (previous) {
if (previous.text !== input.text) throw Object.assign(new Error("Input ID already belongs to another message"), { status: 409 });
return previous;
}
if (await this.runtime.agentMessageState(sessionId, input.id) === "delivered") return { ...input, status: "delivered" as const };
if (pending.length >= 20) throw Object.assign(new Error("At most 20 user messages may wait in this session"), { status: 429 });
await this.runtime.enqueueUserInput(sessionId, input, { resume: false });
return { ...input };
});
this.operations.set(sessionId, operation);
try { return await operation; }
finally { if (this.operations.get(sessionId) === operation) this.operations.delete(sessionId); }
}
}
type InboxRuntime = {
enqueueUserInput: (sessionId: string, input: ResponseInput) => Promise<void>;
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
};
const unavailable = () => Object.assign(new Error("The response is closing; send after it finishes"), { status: 409 });
/** Tracks native inbox receipts, never starts a second host generation loop. */
export class ResponseInputTracker {
private readonly items = new Map<string, ResponseInput>();
private readonly operations = new Set<Promise<unknown>>();
private accepting = true;
private releaseReady!: () => void;
private readonly ready = new Promise<void>((resolve) => { this.releaseReady = resolve; });
constructor(private readonly runtime: InboxRuntime, private readonly sessionId: string,
private readonly changed: (input: ResponseInput) => void) {}
markReady() { this.releaseReady(); }
close() { this.accepting = false; this.releaseReady(); }
get hasAdditionalInput() { return [...this.items.values()].some((input) => input.status !== "cancelled"); }
async restore() {
for (const input of await this.runtime.listPendingUserInputs(this.sessionId)) this.items.set(input.id, input);
}
async submit(input: ResponseInput): Promise<ResponseInput> {
if (!this.accepting) throw unavailable();
const previous = this.items.get(input.id);
if (previous) {
if (previous.text !== input.text) throw Object.assign(new Error("Input ID already belongs to another message"), { status: 409 });
await Promise.all([...this.operations]);
return { ...previous };
}
if ([...this.items.values()].filter((item) => item.status === "pending").length >= 20) {
throw Object.assign(new Error("At most 20 user messages may wait in this session"), { status: 429 });
}
if (this.items.size >= 500) throw Object.assign(new Error("Start a new turn before adding more input"), { status: 429 });
this.items.set(input.id, input);
const operation = (async () => {
await this.ready;
if (!this.accepting) throw unavailable();
await this.runtime.enqueueUserInput(this.sessionId, input);
this.changed({ ...input });
return { ...input };
})();
this.operations.add(operation);
try { return await operation; }
catch (error) { if (input.status === "pending") this.items.delete(input.id); throw error; }
finally { this.operations.delete(operation); }
}
observe(type: string, data: { inboxID?: string; delivery?: InputDelivery }) {
const input = data.inboxID ? this.items.get(data.inboxID) : undefined;
if (!input || input.status !== "pending") return;
if (type === "session.inbox.delivered") input.status = "delivered";
else if (type === "session.inbox.cancelled") input.status = "cancelled";
else if (type === "session.inbox.delivery.changed" && data.delivery) input.delivery = data.delivery;
else return;
this.changed({ ...input });
}
async canClose(): Promise<boolean> {
await Promise.allSettled([...this.operations]);
if ([...this.items.values()].some((input) => input.status === "pending")) return false;
this.accepting = false;
return true;
}
}