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(); + }; + }, + }; +}