From 7c011e312c2558aa7ec91920fe4e376ef2d7960b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 11:58:48 -0400 Subject: [PATCH] feat(agents): carry agent types and their handles over the agents port The port client now answers types() and takes present(key, ...), as the service does in one page. The service pushes each type as data: its identity, title, defaults, which calls it answers (models, usage, provider check and attention action), whether it is hosted, the shape of what it exposes, each exposed store's snapshot, and each keyed read a screen asked for. Its session scope for each shown agent is pushed too. On the screens' side each type is built from that and from what the page presents, through the same merge the service uses. Exposed calls go over the port by path; an AbortSignal argument becomes a stand-in whose abort is sent, so the service's call sees its own signal abort. The service subscribes to the types' own stores, so their changes are pushed, and still pushes before each answer. A type whose handles are not plain is listed but not shown, as in the service's page. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/port.test.ts | 211 ++++++++++++ src/features/agents/port.ts | 111 ++++++- src/features/agents/service.test.ts | 6 + src/features/agents/type-port.ts | 493 ++++++++++++++++++++++++++++ 4 files changed, 804 insertions(+), 17 deletions(-) create mode 100644 src/features/agents/type-port.ts diff --git a/src/features/agents/port.test.ts b/src/features/agents/port.test.ts index e98f016120..5e9e32c6d2 100644 --- a/src/features/agents/port.test.ts +++ b/src/features/agents/port.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; +import { keyedRead } from "./handles"; import { connectAgents, serveAgents, shared } from "./port"; import type { Agents, AgentsSnapshot } from "./service"; @@ -47,6 +48,7 @@ function fakeAgents() { listeners.add(listener); return () => listeners.delete(listener); }, + types: () => [] as unknown[], memberships: () => [], joinable: () => [], interruptible: () => false, @@ -123,3 +125,212 @@ it("refuses calls once closed, and fails those still waiting", async () => { await expect(waiting).rejects.toThrow("closed"); await expect(client.removeTeam("t")).rejects.toThrow("closed"); }); + +/** A type as the service has it, with stores that change on their own. */ +function fakeType() { + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + let readiness: "checking" | "ready" | "attention" = "checking"; + let installed = false; + const sessions: Record = {}; + const aborted = vi.fn(); + const type = { + key: "example/shown", + pluginId: "example", + revision: "1", + id: "shown", + title: "Shown", + defaults: () => ({ config: { model: "" } }), + models: vi.fn(async (agent: { key: string }) => ({ + current: `${agent.key}-model`, + models: [], + })), + provider: { + snapshot: () => ({ readiness }), + subscribe, + check: vi.fn(async () => { + readiness = "ready"; + notify(); + }), + get attentionAction() { + return readiness === "attention" ? () => {} : undefined; + }, + }, + expose: { + setup: { + snapshot: () => ({ installed }), + subscribe, + install: vi.fn(async () => { + installed = true; + notify(); + return "installed"; + }), + }, + runtime: { + sessions: keyedRead((key) => sessions[key] ?? [], subscribe, []), + transcript: (_key: string, signal: AbortSignal) => + new Promise((_, reject) => { + signal.addEventListener("abort", () => { + aborted(); + reject(new Error("stopped")); + }); + }), + }, + }, + }; + return { + type, + aborted, + notify, + setSessions(key: string, value: { key: string; busy: boolean }[]) { + sessions[key] = value; + notify(); + }, + setReadiness(next: typeof readiness) { + readiness = next; + notify(); + }, + }; +} + +type Shown = { + setup: { + snapshot(): { installed: boolean }; + subscribe(listener: () => void): () => void; + install(): Promise; + }; + runtime: { + sessions: { + read(key: string): readonly { key: string; busy: boolean }[]; + }; + transcript(key: string, signal: AbortSignal): Promise; + }; +}; + +it("carries a type and what it exposes to the screens' page, which presents it", async () => { + const fake = fakeType(); + const agents = fakeAgents(); + agents.types = () => [fake.type]; + const client = connected(agents); + const Tab = () => null; + let exposed: Shown | undefined; + client.present("example/shown", (handles) => { + exposed = handles as Shown; + return { tabs: [{ id: "t", title: "T", component: Tab }] }; + }); + await vi.waitFor(() => expect(client.types()).toHaveLength(1)); + const [type] = client.types(); + expect(type).toMatchObject({ + key: "example/shown", + title: "Shown", + tabs: [{ id: "t", component: Tab }], + }); + expect(type?.defaults()).toEqual({ config: { model: "" } }); + expect(type?.remote).toBeUndefined(); + // A store's snapshot is read synchronously, and kept while it is the same. + expect(exposed?.setup.snapshot()).toEqual({ installed: false }); + expect(type?.provider?.snapshot()).toEqual({ readiness: "checking" }); + const snapshot = exposed?.setup.snapshot(); + fake.notify(); + await Promise.resolve(); + expect(exposed?.setup.snapshot()).toBe(snapshot); + // A call answers after the change it made. + await expect(exposed?.setup.install()).resolves.toBe("installed"); + expect(exposed?.setup.snapshot()).toEqual({ installed: true }); + await type?.provider?.check(); + expect(type?.provider?.snapshot()).toEqual({ readiness: "ready" }); + expect(type?.provider?.attentionAction).toBeUndefined(); + fake.setReadiness("attention"); + await vi.waitFor(() => + expect(type?.provider?.attentionAction).toBeTypeOf("function"), + ); + await expect( + type?.models?.({ key: "a" } as Parameters< + NonNullable["models"]> + >[0]), + ).resolves.toEqual({ current: "a-model", models: [] }); + // The screens see one stable type while its stores change. + expect(client.types()[0]).toBe(type); +}); + +it("reads a key as empty until the service pushes it, then follows it", async () => { + const fake = fakeType(); + fake.setSessions("a", [{ key: "one", busy: true }]); + const agents = fakeAgents(); + agents.types = () => [fake.type]; + const client = connected(agents); + let exposed: Shown | undefined; + client.present("example/shown", (handles) => { + exposed = handles as Shown; + return {}; + }); + await vi.waitFor(() => expect(client.types()).toHaveLength(1)); + client.types(); + expect(exposed?.runtime.sessions.read("a")).toEqual([]); + await vi.waitFor(() => + expect(exposed?.runtime.sessions.read("a")).toEqual([ + { key: "one", busy: true }, + ]), + ); + const first = exposed?.runtime.sessions.read("a"); + fake.setSessions("a", [{ key: "one", busy: false }]); + await vi.waitFor(() => + expect(exposed?.runtime.sessions.read("a")).toEqual([ + { key: "one", busy: false }, + ]), + ); + expect(exposed?.runtime.sessions.read("a")).not.toBe(first); +}); + +it("stops an exposed call in the service when the screen aborts it", async () => { + const fake = fakeType(); + const agents = fakeAgents(); + agents.types = () => [fake.type]; + const client = connected(agents); + let exposed: Shown | undefined; + client.present("example/shown", (handles) => { + exposed = handles as Shown; + return {}; + }); + await vi.waitFor(() => expect(client.types()).toHaveLength(1)); + client.types(); + const abort = new AbortController(); + const reading = exposed?.runtime.transcript("a", abort.signal); + abort.abort(); + await expect(reading).rejects.toThrow("stopped"); + expect(fake.aborted).toHaveBeenCalledOnce(); +}); + +it("does not show a type whose handles cannot cross", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + class Setup { + check() {} + } + const agents = fakeAgents(); + agents.types = () => [ + { + key: "example/leaky", + pluginId: "example", + revision: "1", + id: "leaky", + title: "Leaky", + defaults: () => ({ config: {} }), + expose: { setup: new Setup() }, + }, + ]; + const client = connected(agents); + const shown = vi.fn(() => ({ tabs: [] })); + client.present("example/leaky", shown); + await vi.waitFor(() => expect(client.types()).toHaveLength(1)); + expect(client.types()[0]).toMatchObject({ title: "Leaky" }); + expect(client.types()[0]?.tabs).toBeUndefined(); + expect(shown).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalled(); + error.mockRestore(); +}); diff --git a/src/features/agents/port.ts b/src/features/agents/port.ts index 27c4a53a2c..33523ec5d4 100644 --- a/src/features/agents/port.ts +++ b/src/features/agents/port.ts @@ -9,6 +9,13 @@ import type { AgentsSnapshot, AgentUpdateRequest, } from "./service"; +import { + serveTypes, + type TypeCall, + type TypesState, + type TypeWatch, + typesClient, +} from "./type-port"; /** Calls a screen waits for. Each takes and returns structured-cloneable data. */ const CALLS = [ @@ -51,6 +58,7 @@ type State = Readonly<{ interruptible: readonly string[]; creationRequests: readonly AgentCreationRequest[]; updateRequests: readonly AgentUpdateRequest[]; + types: TypesState; }>; type ToClient = | Readonly<{ kind: "state"; state: State }> @@ -58,17 +66,21 @@ type ToClient = | Readonly<{ kind: "error"; id: number; message: string }>; type ToService = | Readonly<{ kind: "call"; id: number; method: Call; args: unknown[] }> - | Readonly<{ kind: "send"; method: Send; args: unknown[] }>; + | Readonly<{ kind: "send"; method: Send; args: unknown[] }> + | Readonly<{ kind: "type"; id: number; call: TypeCall; args: unknown[] }> + | Readonly<{ kind: "watch"; watch: TypeWatch }> + | Readonly<{ kind: "abort"; signal: number }>; -/** What the client carries of `AgentsClient` so far. Types, session state and - * the retype preview call into plugin code, which a later change gives the - * screens' page. */ +/** What the client carries of `AgentsClient` so far, and `present` for what + * this page shows of each type (by type key). Session state and the retype + * preview read service state that a later change pushes. */ export type AgentsPortClient = Omit< AgentsClient, - "types" | "sessionState" | "previewRetype" ->; + "sessionState" | "previewRetype" +> & + Readonly<{ present: ReturnType["present"] }>; -function stateOf(agents: Agents): State { +function stateOf(agents: Agents, types: TypesState): State { const snapshot = agents.snapshot(); const memberships: Record> = {}; const joinable = agents.joinable(); @@ -94,19 +106,31 @@ function stateOf(agents: Agents): State { ], creationRequests: agents.creationRequests(), updateRequests: agents.updateRequests(), + types, }; } /** Serves `agents` on `port` until the returned stop. It pushes state before * each answer, so a call's result never arrives before what it changed. */ export function serveAgents(agents: Agents, port: MessagePort) { + const types = serveTypes(agents); + // The types' own stores, which change without the service's notice. + const stores = new Map<(listener: () => void) => () => void, () => void>(); let pending = true; const flush = () => { if (!pending) return; pending = false; + const wanted = new Set(types.stores()); + for (const [subscribe, stop] of stores) + if (!wanted.has(subscribe)) { + stop(); + stores.delete(subscribe); + } + for (const subscribe of wanted) + if (!stores.has(subscribe)) stores.set(subscribe, subscribe(changed)); port.postMessage({ kind: "state", - state: stateOf(agents), + state: stateOf(agents, types.state()), } satisfies ToClient); }; // Several changes in one task are one push. @@ -123,10 +147,27 @@ export function serveAgents(agents: Agents, port: MessagePort) { (agents[data.method] as (...args: unknown[]) => void)(...data.args); return; } + if (data.kind === "watch") { + types.watch(data.watch); + changed(); + return; + } + if (data.kind === "abort") { + types.abort(data.signal); + return; + } + if (data.kind === "type") { + answer(data.id, () => types.call(data.call, data.args)); + return; + } if (data.kind !== "call" || !calls.has(data.method)) return; const { id, method, args } = data; - void (async () => - (agents[method] as (...args: unknown[]) => Promise)(...args))() + answer(id, () => + (agents[method] as (...args: unknown[]) => Promise)(...args), + ); + }; + function answer(id: number, run: () => unknown) { + void (async () => run())() .then( (value): ToClient => ({ kind: "result", id, value }), (error: unknown): ToClient => ({ @@ -139,10 +180,12 @@ export function serveAgents(agents: Agents, port: MessagePort) { flush(); port.postMessage(answer); }); - }; + } flush(); return () => { unsubscribe(); + for (const stop of stores.values()) stop(); + stores.clear(); port.onmessage = null; port.close(); }; @@ -164,6 +207,11 @@ const STARTING: State = Object.freeze({ interruptible: EMPTY, creationRequests: EMPTY, updateRequests: EMPTY, + types: Object.freeze({ + types: EMPTY, + values: Object.freeze({}), + scopes: Object.freeze({}), + }), }); /** `next`, keeping each part of `prior` it equals, so screens that compare by @@ -265,6 +313,38 @@ export function connectAgents(port: MessagePort) { const calls = Object.fromEntries( CALLS.map((method) => [method, call(method)]), ); + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + const types = typesClient( + () => state.types, + subscribe, + { + call: (call, args) => + new Promise((resolve, reject) => { + const id = ++next; + waiting.set(id, { resolve, reject }); + try { + post({ kind: "type", id, call, args }); + } catch (error) { + waiting.delete(id); + reject(error); + } + }), + watch: (watch) => { + if (!closed) post({ kind: "watch", watch }); + }, + abort: (signal) => { + if (!closed) post({ kind: "abort", signal }); + }, + }, + () => { + for (const listener of listeners) listener(); + }, + ); const sends = Object.fromEntries( SENDS.map((method) => [method, send(method)]), ); @@ -272,12 +352,9 @@ export function connectAgents(port: MessagePort) { ...calls, ...sends, snapshot: () => state.snapshot, - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, + subscribe, + types: types.types, + present: types.present, find: (pubkey: string) => state.snapshot.agents.find((agent) => agent.pubkey === pubkey), classifier: () => state.snapshot.classifier, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index f395341f77..f85d8eb0c6 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -2235,6 +2235,12 @@ describe("an agent in several communities", () => { try { const made = await client.create({ type: "example/echo", name: "Echo" }); expect(made).toMatchObject({ pubkey: bot, name: "Echo" }); + // The screens' page lists the same types, each with its defaults. + expect( + client.types().map((type) => [type.key, type.title, type.defaults()]), + ).toEqual( + service.types().map((type) => [type.key, type.title, type.defaults()]), + ); expect(client.find(bot)).toEqual(service.find(bot)); // An agent nothing changed keeps its identity across pushes. const shown = client.find(bot); diff --git a/src/features/agents/type-port.ts b/src/features/agents/type-port.ts new file mode 100644 index 0000000000..2b00ebbd58 --- /dev/null +++ b/src/features/agents/type-port.ts @@ -0,0 +1,493 @@ +// Agent types across the agents port. The service describes each type as +// data: what it is, which calls it answers, and the shape of what it exposes +// to its screens. The screens' side builds the type from that and from what +// its own page presents, as the service merges them in one page. +import { local } from "./handles"; +import { + type AgentPresentation, + type AgentProviderSnapshot, + type AgentType, + type AgentTypeView, + type Agents, + presented, +} from "./service"; + +/** The shape of a type's `expose`; functions become calls by path. */ +type Shape = + | Readonly<{ kind: "value"; value: unknown }> + | Readonly<{ kind: "call" }> + | Readonly<{ kind: "snapshot" }> + | Readonly<{ kind: "subscribe" }> + | Readonly<{ kind: "read"; empty: unknown }> + | Readonly<{ kind: "array"; items: readonly Shape[] }> + | Readonly<{ kind: "object"; members: Readonly> }>; + +/** What of a type does not change while it is registered. */ +export type TypeDescriptor = Readonly<{ + key: string; + pluginId: string; + revision: string; + id: string; + title: string; + description?: string; + /** Its `defaults()`, for screens whose page presents none. */ + defaults: ReturnType; + migrationWarning?: string; + remote: boolean; + models: boolean; + usage: boolean; + sessionControls: boolean; + provider: boolean; + /** Absent when it exposes nothing; `invalid` when what it exposes is not + * plain handles, so it is not shown. */ + exposed?: Shape | "invalid"; +}>; +/** What of a type screens read synchronously, as it is now. */ +export type TypeValues = Readonly<{ + provider?: Readonly<{ snapshot: AgentProviderSnapshot; attention: boolean }>; + /** Each exposed store's snapshot, by path. */ + snapshots: Readonly>; + /** Each keyed read a screen asked for, by path and key. */ + reads: Readonly>>>; +}>; +export type TypesState = Readonly<{ + types: readonly TypeDescriptor[]; + values: Readonly>; + /** Each shown agent's session scope, for types with session controls. */ + scopes: Readonly>; +}>; +/** A call on a type: one of its own, or an exposed one by path. */ +export type TypeCall = Readonly<{ + type: string; + target: + | "models" + | "usage" + | "check" + | "attention" + | Readonly<{ path: string }>; +}>; +export type TypeWatch = Readonly<{ type: string; path: string; key: string }>; + +/** Stands in for an AbortSignal argument; `abort` messages name it. */ +const SIGNAL = "$abortSignal"; + +type Described = Readonly<{ + shape: Shape | "invalid"; + calls: ReadonlyMap unknown>; + snapshots: ReadonlyMap unknown>; + subscribes: readonly ((listener: () => void) => () => void)[]; + reads: ReadonlyMap unknown>; +}>; + +function describe(exposed: unknown): Described { + const calls = new Map unknown>(); + const snapshots = new Map unknown>(); + const subscribes: ((listener: () => void) => () => void)[] = []; + const reads = new Map unknown>(); + const walk = (value: unknown, path: string, name: string): Shape => { + if (typeof value === "function") { + const fn = value as (...args: unknown[]) => unknown; + if (name === "snapshot") { + snapshots.set(path, fn as () => unknown); + return { kind: "snapshot" }; + } + if (name === "subscribe") { + subscribes.push(fn as (listener: () => void) => () => void); + return { kind: "subscribe" }; + } + if (name === "read") { + reads.set(path, fn as (key: string) => unknown); + return { kind: "read", empty: undefined }; + } + calls.set(path, fn); + return { kind: "call" }; + } + if (typeof value !== "object" || value === null) + return { kind: "value", value }; + if (Array.isArray(value)) + return { + kind: "array", + items: value.map((item, index) => walk(item, `${path}.${index}`, "")), + }; + const members = Object.fromEntries( + Object.entries(value).map(([key, member]) => [ + key, + walk(member, `${path}.${key}`, key), + ]), + ); + if (members.read?.kind === "read") + members.read = { + kind: "read", + empty: (value as { empty?: unknown }).empty, + }; + return { kind: "object", members }; + }; + try { + // The same check screens in the service's page get. + local(exposed); + } catch (error) { + console.error("An agent type's handles cannot reach its screens", error); + return { shape: "invalid", calls, snapshots, subscribes, reads }; + } + return { shape: walk(exposed, "", ""), calls, snapshots, subscribes, reads }; +} + +/** Serves `agents`' types: their state for each push, the stores a push + * waits on, and their calls. */ +export function serveTypes(agents: Agents) { + const described = new WeakMap(); + const describedOf = (exposed: unknown) => { + if (typeof exposed !== "object" || exposed === null) + return describe(exposed); + let known = described.get(exposed); + if (!known) { + known = describe(exposed); + described.set(exposed, known); + } + return known; + }; + const watched = new Map>>(); + const signals = new Map(); + const typeOf = (key: string) => + agents.types().find((type) => type.key === key); + + function state(): TypesState { + const types = agents.types(); + const values: Record = {}; + const scopes: Record = {}; + const descriptors = types.map((type): TypeDescriptor => { + const exposed = + type.expose === undefined ? undefined : describedOf(type.expose); + const reads: Record> = {}; + for (const [path, keys] of watched.get(type.key) ?? []) { + const read = exposed?.reads.get(path); + if (read) + reads[path] = Object.fromEntries( + [...keys].map((key) => [key, read(key)]), + ); + } + values[type.key] = { + ...(type.provider && { + provider: { + snapshot: type.provider.snapshot(), + attention: Boolean(type.provider.attentionAction), + }, + }), + snapshots: Object.fromEntries( + [...(exposed?.snapshots ?? [])].map(([path, read]) => [path, read()]), + ), + reads, + }; + return { + key: type.key, + pluginId: type.pluginId, + revision: type.revision, + id: type.id, + title: type.title, + ...(type.description !== undefined && { + description: type.description, + }), + defaults: type.defaults(), + ...(type.migrationWarning !== undefined && { + migrationWarning: type.migrationWarning, + }), + remote: Boolean(type.remote), + models: Boolean(type.models), + usage: Boolean(type.usage), + sessionControls: Boolean(type.sessionControls), + provider: Boolean(type.provider), + ...(exposed && { exposed: exposed.shape }), + }; + }); + for (const agent of agents.snapshot().agents) { + const controls = types.find( + (type) => type.key === agent.type, + )?.sessionControls; + if (controls) scopes[agent.key] = controls.scope(agent); + } + return { types: descriptors, values, scopes }; + } + + /** Every store a push reads, so a change to any of them is pushed. */ + function stores() { + const subscribes: ((listener: () => void) => () => void)[] = []; + for (const type of agents.types()) { + if (type.provider) subscribes.push(type.provider.subscribe); + if (type.sessionControls) subscribes.push(type.sessionControls.subscribe); + if (type.expose !== undefined) + subscribes.push(...describedOf(type.expose).subscribes); + } + return subscribes; + } + + function watch({ type, path, key }: TypeWatch) { + const paths = watched.get(type) ?? new Map>(); + watched.set(type, paths); + const keys = paths.get(path) ?? new Set(); + paths.set(path, keys); + keys.add(key); + } + + const withSignals = (value: unknown, controllers: number[]): unknown => { + if (typeof value !== "object" || value === null) return value; + if (Array.isArray(value)) + return value.map((item) => withSignals(item, controllers)); + if (Object.keys(value).length === 1 && SIGNAL in value) { + const id = (value as Record)[SIGNAL]; + const controller = new AbortController(); + signals.set(id, controller); + controllers.push(id); + return controller.signal; + } + return Object.fromEntries( + Object.entries(value).map(([key, member]) => [ + key, + withSignals(member, controllers), + ]), + ); + }; + + async function call({ type: key, target }: TypeCall, raw: unknown[]) { + const type = typeOf(key); + if (!type) throw new Error("That agent type is not available"); + const controllers: number[] = []; + const args = raw.map((arg) => withSignals(arg, controllers)); + try { + const run = (): unknown => { + if (target === "models") + return type.models?.( + ...(args as Parameters>), + ); + if (target === "usage") + return type.usage?.( + ...(args as Parameters>), + ); + if (target === "check") return type.provider?.check(); + if (target === "attention") return type.provider?.attentionAction?.(); + const fn = + type.expose === undefined + ? undefined + : describedOf(type.expose).calls.get(target.path); + if (!fn) throw new Error("That agent type has no such call"); + return fn(...args); + }; + return await run(); + } finally { + for (const id of controllers) signals.delete(id); + } + } + + return { + state, + stores, + watch, + call, + abort: (id: number) => signals.get(id)?.abort(), + }; +} + +type Presentation = (exposed: unknown) => AgentPresentation; +const CHECKING: AgentProviderSnapshot = Object.freeze({ + readiness: "checking", +}); +const NONE: TypeValues = Object.freeze({ + snapshots: Object.freeze({}), + reads: Object.freeze({}), +}); + +/** The types on the screens' side, from what the service pushes and what this + * page presents. `call` sends a type call, `watch` asks for a keyed read, and + * `subscribe` is the client's own change notice. */ +export function typesClient( + current: () => TypesState, + subscribe: (listener: () => void) => () => void, + send: Readonly<{ + call(call: TypeCall, args: unknown[]): Promise; + watch(watch: TypeWatch): void; + abort(id: number): void; + }>, + changed: () => void, +) { + const presentations = new Map(); + const built = new Map< + string, + Readonly<{ + descriptor: TypeDescriptor; + presentation: Presentation | undefined; + view: AgentTypeView; + }> + >(); + let last: + | Readonly<{ + types: readonly TypeDescriptor[]; + version: number; + result: readonly AgentTypeView[]; + }> + | undefined; + let version = 0; + let signals = 0; + const watching = new Set(); + + const values = (key: string) => current().values[key] ?? NONE; + /** Arguments as they cross: an AbortSignal becomes a stand-in, and its + * abort is sent. */ + const crossing = (value: unknown): unknown => { + if (value instanceof AbortSignal) { + const id = ++signals; + if (value.aborted) queueMicrotask(() => send.abort(id)); + else + value.addEventListener("abort", () => send.abort(id), { once: true }); + return { [SIGNAL]: id }; + } + if (typeof value !== "object" || value === null) return value; + if (Array.isArray(value)) return value.map(crossing); + return Object.fromEntries( + Object.entries(value).map(([key, member]) => [key, crossing(member)]), + ); + }; + const call = + (type: string, target: TypeCall["target"]) => + (...args: unknown[]) => + send.call({ type, target }, args.map(crossing)); + + function proxy(type: string, shape: Shape, path: string): unknown { + switch (shape.kind) { + case "value": + return shape.value; + case "call": + return call(type, { path }); + case "snapshot": + return () => values(type).snapshots[path]; + case "subscribe": + return subscribe; + case "read": { + const { empty } = shape; + return (key: string) => { + const id = JSON.stringify([type, path, key]); + if (!watching.has(id)) { + watching.add(id); + send.watch({ type, path, key }); + } + const reads = values(type).reads[path]; + return reads && key in reads ? reads[key] : empty; + }; + } + case "array": + return Object.freeze( + shape.items.map((item, index) => + proxy(type, item, `${path}.${index}`), + ), + ); + case "object": + return Object.freeze( + Object.fromEntries( + Object.entries(shape.members).map(([key, member]) => [ + key, + proxy(type, member, `${path}.${key}`), + ]), + ), + ); + } + } + + function view(descriptor: TypeDescriptor): AgentTypeView { + const { key } = descriptor; + const type = { + key, + pluginId: descriptor.pluginId, + revision: descriptor.revision, + id: descriptor.id, + title: descriptor.title, + ...(descriptor.description !== undefined && { + description: descriptor.description, + }), + defaults: () => descriptor.defaults, + ...(descriptor.migrationWarning !== undefined && { + migrationWarning: descriptor.migrationWarning, + }), + ...(descriptor.remote && { remote: Object.freeze({}) }), + ...(descriptor.models && { models: call(key, "models") }), + ...(descriptor.usage && { usage: call(key, "usage") }), + ...(descriptor.sessionControls && { + sessionControls: Object.freeze({ + scope: (agent: { key: string }) => + current().scopes[agent.key] ?? "thread", + subscribe, + }), + }), + ...(descriptor.provider && { + provider: Object.freeze({ + snapshot: () => values(key).provider?.snapshot ?? CHECKING, + subscribe, + check: call(key, "check"), + get attentionAction() { + return values(key).provider?.attention + ? () => void call(key, "attention")() + : undefined; + }, + }), + }), + }; + return type as unknown as AgentTypeView; + } + + function types(): readonly AgentTypeView[] { + const descriptors = current().types; + if (last?.types === descriptors && last.version === version) + return last.result; + const result = Object.freeze( + descriptors.map((descriptor) => { + const presentation = presentations.get(descriptor.key); + const known = built.get(descriptor.key); + if ( + known?.descriptor === descriptor && + known.presentation === presentation + ) + return known.view; + let shown = view(descriptor); + if (presentation && descriptor.exposed !== "invalid") { + try { + const exposed = + descriptor.exposed === undefined + ? undefined + : proxy(descriptor.key, descriptor.exposed, ""); + shown = presented( + shown as unknown as AgentType, + presentation(exposed), + ) as unknown as AgentTypeView; + } catch (error) { + // Its agents still run; only what the type shows is missing. + console.error( + `Agent type ${descriptor.key} could not be shown`, + error, + ); + } + } + const frozen = Object.freeze(shown); + built.set(descriptor.key, { descriptor, presentation, view: frozen }); + return frozen; + }), + ); + last = { types: descriptors, version, result }; + return result; + } + + return { + types, + /** Supplies what type `key` shows, from this page, until the returned + * stop; as `Agents.present` does where the service runs. */ + present(key: string, present: Presentation) { + if (typeof present !== "function") + throw new Error("present needs a function"); + presentations.set(key, present); + version++; + changed(); + return () => { + if (presentations.get(key) !== present) return; + presentations.delete(key); + version++; + changed(); + }; + }, + }; +}