From 5aa037b49953723bf419f1556227c50d581c2e3e Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sun, 11 Oct 2026 16:06:18 -0700 Subject: [PATCH 1/3] feat(agents): hosted agent types and the owner's agent list A type with `remote` steps (provision, activate, profile, remove) is hosted: its provider holds the key and runs each agent. The app makes one by asking the provider for its key, signing the owner's NIP-OA attestation, handing that over, then listing it. A setup that fails partway is kept as unfinished, across restarts, and Finish setup continues it without making another. Rename and picture go through the provider; a hosted agent's settings are its type's tabs calling its provider. Every agent is listed in the owner's kind 30177 records with its type as a `runtime` tag, so each of the owner's devices shows it here, hosted, or on another device. A record keeps the settings and tags of the one it replaces and defaults parallelism and respond_to, as other desktops parse it whole. Records without a runtime are earlier systems' and stay hidden. Agents on another device are read-only and never run here. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/agents/AgentsPage.test.tsx | 42 ++ src/bundled/agents/AgentsPage.tsx | 128 ++++- src/bundled/profiles/ProfilePanel.tsx | 2 +- src/features/agents/AgentManage.test.tsx | 1 + src/features/agents/AgentManage.tsx | 6 +- src/features/agents/AttentionPanel.test.tsx | 1 + src/features/agents/relay-removal.test.ts | 80 ++- src/features/agents/relay-removal.ts | 62 ++- src/features/agents/service.test.ts | 396 +++++++++++++- src/features/agents/service.ts | 546 +++++++++++++++++++- src/features/agents/store.ts | 52 ++ src/features/agents/tabs.tsx | 14 +- src/features/host/service.ts | 122 ++--- src/features/relay/testing.ts | 4 +- 14 files changed, 1343 insertions(+), 113 deletions(-) diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index cc8924d0d..ef4c64e9e 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -24,6 +24,7 @@ const agent = (name: string, n: number): Agent => ({ type: "test/echo", owner: "f".repeat(64), relay: "wss://relay.example", + place: "here", config: { reply: `${name} says hi` }, skipped: {}, timers: {}, @@ -105,6 +106,7 @@ function setup(saved: Agent[], { shown = true } = {}) { for (const listener of listeners) listener(); return created; }), + finish: vi.fn(async (_: string) => created), portable: vi.fn(async () => ({ agents: portable, warnings: [] })), port: vi.fn(async () => created), save: vi.fn(async () => {}), @@ -321,3 +323,43 @@ it("makes a team of chosen agents and lists it with its members", async () => { expect(agents.removeTeam).toHaveBeenCalledWith("team-1"); expect(screen.getByText("No teams yet.")).toBeInTheDocument(); }); + +it("shows an agent on another device read-only, and offers only Finish setup and Delete for an unfinished hosted one", async () => { + const user = userEvent.setup(); + const far: Agent = { + ...agent("Far", 3), + place: "elsewhere", + attention: {}, + }; + const sky: Agent = { + ...agent("Sky", 4), + place: "hosted", + unfinished: true, + attention: {}, + }; + const { agents } = setup([far, sky]); + const grid = screen.getByRole("listbox", { name: "Agents" }); + expect(within(grid).getByText("On another device")).toBeInTheDocument(); + expect(within(grid).getByText("Setup unfinished")).toBeInTheDocument(); + + await user.click(within(grid).getByText("Far")); + expect(screen.getByText(/another of your devices/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Wakes on")).not.toBeInTheDocument(); + await user.dblClick(within(grid).getByText("Far")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + + await user.click(within(grid).getByText("Sky")); + expect( + screen.queryByRole("button", { name: "Open" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Delete agent" }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Finish setup" })); + expect(agents.finish).toHaveBeenCalledWith(sky.pubkey); + await user.dblClick(within(grid).getByText("Sky")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 020c66b54..49c689011 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -69,6 +69,8 @@ type Frame = /** The type's one-line summary of an agent; its title when it has none. */ function summaryOf(agent: Agent, type: RegisteredAgentType | undefined) { + if (agent.place === "elsewhere") return "On another device"; + if (agent.unfinished) return "Setup unfinished"; if (!type) return "Type unavailable"; try { return type.summary?.(agent).trim() || type.title; @@ -100,7 +102,12 @@ export function AgentsPage({ const creating = side === "creating"; const browse = (selected?: string) => setFrame(selected ? { view: "browse", selected } : { view: "browse" }); - const build = (pubkey: string) => setFrame({ view: "build", pubkey }); + const build = (pubkey: string) => { + // A just-made agent may not be shown yet. + const agent = find(pubkey); + if (agent?.place !== "elsewhere" && !agent?.unfinished) + setFrame({ view: "build", pubkey }); + }; const newAgent = () => setFrame({ view: "browse", side: "creating" }); const bringOver = () => setFrame({ view: "browse", side: "porting" }); const openTeams = () => setFrame({ view: "browse", side: "teams" }); @@ -145,13 +152,14 @@ export function AgentsPage({ ) : side === "teams" ? ( agent.place === "here")} teams={snapshot.teams} onClose={() => browse()} /> ) : selected ? ( { + setFinishing(true); + setError(""); + try { + await agents.finish(agent.pubkey); + } catch (reason) { + setError(message(reason)); + } + setFinishing(false); + }; + if (agent.place === "elsewhere") + return ( +
+ +

+ Its key is on another of your devices, which runs it. Open Agents + there to change or delete it. +

+
+ ); return (
-
- - - -
-

{agent.name}

+ + {agent.unfinished ? ( + <>

- {type?.title ?? "Type unavailable"} - {type && !Peek && summary !== type.title ? ` · ${summary}` : ""} + Its provider made it, but its setup did not finish.

-
-
- - {Peek ? ( + + {error && ( +

+ {error} +

+ )} + + + ) : ( + + )} + {Peek && !agent.unfinished ? (
@@ -423,9 +465,39 @@ function AgentPeek({ Enable the plugin that provides this agent's type to run or change it.

) : null} -
- -
+ {agent.place === "here" && ( +
+ +
+ )} +
+ ); +} + +function PeekIdentity({ + agent, + detail, + onClose, +}: { + agent: Agent; + detail: string; + onClose(): void; +}) { + return ( +
+ + + +
+

{agent.name}

+

{detail}

+
+
); } @@ -492,14 +564,16 @@ function NewAgent({ key={item.key} value={item.key} variant="card" - label={item.title} + label={item.remote ? `${item.title} (hosted)` : item.title} {...(item.description ? { description: item.description } : {})} /> ))}

- It gets its own key on this device and runs while the app is open. + {chosen?.remote + ? "Its provider keeps its key and runs it, whether or not this app is open." + : "It gets its own key on this device and runs while the app is open."}

{error && (

diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 1f5e5e345..6445be4ac 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -313,7 +313,7 @@ function ProfileDetails({ {profile?.about && (

{profile.about}

)} - {plugin && agents && ( + {plugin && agents && plugin.agent.place !== "elsewhere" && (
diff --git a/src/features/agents/AttentionPanel.test.tsx b/src/features/agents/AttentionPanel.test.tsx index ea748afc0..7d869eb08 100644 --- a/src/features/agents/AttentionPanel.test.tsx +++ b/src/features/agents/AttentionPanel.test.tsx @@ -15,6 +15,7 @@ const agent: Agent = { type: "test/echo", owner: "f".repeat(64), relay: "wss://relay.example", + place: "here", config: {}, skipped: {}, timers: {}, diff --git a/src/features/agents/relay-removal.test.ts b/src/features/agents/relay-removal.test.ts index e82cf661d..0e87740cd 100644 --- a/src/features/agents/relay-removal.test.ts +++ b/src/features/agents/relay-removal.test.ts @@ -5,7 +5,7 @@ import { afterEach, expect, it } from "vitest"; import { PublishRejected } from "../relay/outbox"; import { createRelaySession } from "../relay/session"; import { archiveRelay, keypair, signed, type Key } from "../relay/testing"; -import { removeRelayAgent } from "./relay-removal"; +import { publishAgentRecord, removeRelayAgent } from "./relay-removal"; const viewer = keypair(), relay = keypair(), @@ -320,3 +320,81 @@ it("a cancelled removal signs and publishes nothing", async () => { expect(fixture.published).toEqual([]); expect(fixture.signedBy).toEqual([]); }); + +it("publishes the owner's record of an agent with its runtime", async () => { + const { fixture, instance } = setup(); + await publishAgentRecord( + instance.session, + { + pubkey: agent.pubkey, + name: "Agent", + picture: "https://example.test/a.png", + runtime: "example/echo", + }, + new AbortController().signal, + ); + const [listed] = fixture.published; + expect(listed).toMatchObject({ kind: 30177, pubkey: viewer.pubkey }); + expect(JSON.parse(listed?.content ?? "")).toEqual({ + name: "Agent", + picture: "https://example.test/a.png", + parallelism: 1, + respond_to: "owner-only", + }); + expect(listed?.tags).toEqual([ + ["d", agent.pubkey], + ["runtime", "example/echo"], + ["client-id", expect.any(String)], + ]); + expect(instance.session.outbox?.snapshot()).toEqual([]); +}); + +it("keeps the settings and tags of the record it replaces", async () => { + const { fixture, instance } = setup(); + const written = await publishAgentRecord( + instance.session, + { + pubkey: agent.pubkey, + name: "Renamed", + runtime: "example/echo", + prior: { + content: { + name: "Agent", + picture: "https://example.test/old.png", + parallelism: 3, + respond_to: "anyone", + persona_id: "p1", + }, + tags: [ + ["d", agent.pubkey], + ["runtime", "example/old"], + ["a", "30175:owner:p1"], + ["client-id", "old"], + ], + }, + }, + new AbortController().signal, + ); + const [listed] = fixture.published; + const content = { + name: "Renamed", + parallelism: 3, + respond_to: "anyone", + persona_id: "p1", + }; + expect(JSON.parse(listed?.content ?? "")).toEqual(content); + expect(listed?.tags).toEqual([ + ["d", agent.pubkey], + ["runtime", "example/echo"], + ["a", "30175:owner:p1"], + ["client-id", expect.any(String)], + ]); + expect(written).toEqual({ + content, + tags: [ + ["d", agent.pubkey], + ["runtime", "example/echo"], + ["a", "30175:owner:p1"], + ], + }); +}); diff --git a/src/features/agents/relay-removal.ts b/src/features/agents/relay-removal.ts index 5711ae7cf..8da3fe828 100644 --- a/src/features/agents/relay-removal.ts +++ b/src/features/agents/relay-removal.ts @@ -50,8 +50,68 @@ export async function removeRelayAgent( } } +/** The content and tags of a kind 30177 record as read. */ +export type AgentRecordBody = Readonly<{ + content: Readonly>; + tags: readonly (readonly string[])[]; +}>; +/** The viewer's own kind 30177 record of an agent, as Agents writes it. */ +export type AgentRecordEntry = Readonly<{ + pubkey: string; + name: string; + /** An HTTPS image URL. */ + picture?: string; + /** The agent type's key, `pluginId/typeId`, as the `runtime` tag. */ + runtime: string; + /** The record this replaces, whose other settings and tags it keeps. */ + prior?: AgentRecordBody | undefined; +}>; + +/** Publishes the viewer's kind 30177 record of an agent, replacing any + * before it, and resolves with what it wrote once the relay accepts it. + * Another desktop applies the record whole to an agent it has with that key, + * and its parser requires `parallelism` and `respond_to`, so the record keeps + * every setting of the one it replaces and defaults those two + * (docs/relay-queries.md, "Managed-agent registration"). */ +export async function publishAgentRecord( + session: Pick, + { pubkey, name, picture, runtime, prior }: AgentRecordEntry, + signal: AbortSignal, +): Promise { + const outbox = session.outbox; + if (!outbox?.supports(30177)) + throw new Error("This community cannot list agents."); + await outbox.ready(); + signal.throwIfAborted(); + const kept = Object.entries(prior?.content ?? {}).filter( + ([field]) => field !== "picture", + ); + const content = { + parallelism: 1, + respond_to: "owner-only", + ...Object.fromEntries(kept), + name, + ...(picture ? { picture } : {}), + }; + const tags = [ + ["d", pubkey], + ["runtime", runtime], + ...(prior?.tags ?? []) + .filter(([tag]) => !["d", "runtime", "client-id"].includes(tag ?? "")) + .map((tag) => [...tag]), + ]; + const id = outbox.send( + { kind: 30177, content: JSON.stringify(content), tags }, + undefined, + () => !signal.aborted, + ); + await delivered(outbox, id, signal, "The agent list was not updated. Retry."); + await outbox.dismiss(id); + return { content, tags }; +} + /** NIP-09 coordinate deletion of the viewer's own kind 30177 agent record. */ -async function deleteAgentRecord( +export async function deleteAgentRecord( session: Pick, viewer: string, pubkey: string, diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index f69282ab0..e352bd508 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -23,8 +23,17 @@ import { memoryStorage } from "./test-fakes"; // place in the order matters. const steps: string[] = []; const leaveChannels = vi.hoisted(() => vi.fn()); +// The viewer's kind 30177 records, as the community's outbox writes them. +const records = vi.hoisted(() => ({ + publish: vi.fn(), + remove: vi.fn(), + removeAgent: vi.fn(), +})); vi.mock("./relay-removal", () => ({ removeAgentFromChannels: leaveChannels, + publishAgentRecord: records.publish, + deleteAgentRecord: records.remove, + removeRelayAgent: records.removeAgent, })); const viewer = "a".repeat(64); @@ -69,8 +78,11 @@ function fakeRelay(origin = "https://relay.example.test") { }; let status = "connected"; const statuses = new Set<() => void>(); + /** The viewer's kind 30177 records the community returns. */ + const listed: RelayEvent[] = []; const session = { archives, + read: vi.fn(async () => [...listed]), // STOPGAP(agent-local-typing): delete with local-typing.ts. typing: { local: vi.fn() }, live: { @@ -131,6 +143,8 @@ function fakeRelay(origin = "https://relay.example.test") { for (const listener of live) listener(batch); }, archives, + listed, + session, origin, }; } @@ -263,6 +277,7 @@ type Config = { reply: string }; async function setup({ storage = memoryStorage(), identities = [] as AgentIdentity[], + listed = [] as RelayEvent[], } = {}) { const ctx = new Context(); ctx.provide("pluginStatus", { @@ -273,7 +288,18 @@ async function setup({ const second = fakeRelay("https://other.example.test"); const client = fakeCommunities(fake, second); const native = fakeNative(identities); - const service = new AgentsService(ctx, client.communities, native, storage); + fake.listed.push(...listed); + const authorize = vi.fn( + async (_: string, __: AbortSignal) => + ["auth", viewer, "", "f".repeat(128)] as const, + ); + const service = new AgentsService( + ctx, + client.communities, + native, + storage, + authorize, + ); const plugin = ctx.extend({ pluginOwner: Object.freeze({ id: "example", revision: "one" }), }); @@ -327,6 +353,9 @@ async function setup({ reconnecting: fake.reconnecting, recover: fake.recover, archives: fake.archives, + session: fake.session, + authorize, + plugin, second, select: client.select, leave: client.leave, @@ -352,6 +381,12 @@ beforeEach(() => { steps.length = 0; leaveChannels.mockReset(); leaveChannels.mockImplementation(async () => void steps.push("channels")); + records.publish.mockReset(); + records.publish.mockImplementation(async () => {}); + records.remove.mockReset(); + records.remove.mockImplementation(async () => void steps.push("record")); + records.removeAgent.mockReset(); + records.removeAgent.mockImplementation(async () => void steps.push("leave")); }); afterEach(() => vi.useRealTimers()); @@ -1343,7 +1378,7 @@ it("deletes the key at once, then leaves its channels and archives it before for expect(service.snapshot().agents).toEqual([]); expect(storage.getItem("buzz.agents.v1")).not.toContain(bot); await vi.waitFor(() => - expect(steps).toEqual(["key", "channels", "archive", "forget"]), + expect(steps).toEqual(["key", "channels", "archive", "record", "forget"]), ); expect(leaveChannels).toHaveBeenCalledWith( expect.objectContaining({ archives }), @@ -1506,3 +1541,360 @@ it("keeps an Interest while a watch still uses it", async () => { }); expect(service.find(bot)?.attention).toEqual({}); }); + +const record = (pubkey: string, patch: Partial = {}) => + event(`r${pubkey.slice(0, 4)}`, { + pubkey: viewer, + kind: 30177, + content: JSON.stringify({ name: "Far" }), + tags: [ + ["d", pubkey], + ["runtime", "example/echo"], + ], + ...patch, + }); +const hostedKey = "e".repeat(64); +function cloud() { + const remote = { + provision: vi.fn(async (_: { name: string; community: string }) => ({ + pubkey: hostedKey, + })), + activate: vi.fn( + async (_: { pubkey: string; auth: readonly string[] }) => {}, + ), + profile: vi.fn(async (_: { name: string; picture?: string }) => {}), + remove: vi.fn(async (_: { pubkey: string }) => void steps.push("provider")), + }; + const type: AgentType = { + id: "cloud", + title: "Cloud", + defaults: () => ({ config: {} }), + remote, + }; + return { type, remote }; +} + +it("lists each agent here in the owner's agent records, with its type as the runtime", async () => { + const { service, session } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => + expect(records.publish).toHaveBeenCalledWith( + session, + { pubkey: bot, name: "Echo", runtime: "example/echo" }, + expect.any(AbortSignal), + ), + ); + // Listed once: later syncs see the record it wrote. + await service.save(bot, { config: { reply: "hi" } }); + await settle(); + expect(records.publish).toHaveBeenCalledTimes(1); + // A rename replaces it. + await service.save(bot, { name: "Echo Two" }); + await vi.waitFor(() => + expect(records.publish).toHaveBeenLastCalledWith( + session, + { pubkey: bot, name: "Echo Two", runtime: "example/echo" }, + expect.any(AbortSignal), + ), + ); +}); + +it("leaves an agent already listed under its name and type as it is", async () => { + const identity = { + pubkey: bot, + relay: "wss://relay.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }; + const { service } = await setup({ + identities: [identity], + listed: [record(bot, { content: JSON.stringify({ name: "Echo" }) })], + }); + await vi.waitFor(() => expect(service.find(bot)?.place).toBe("here")); + await settle(); + expect(records.publish).not.toHaveBeenCalled(); + expect(service.snapshot().agents).toHaveLength(1); +}); + +it("shows the owner's agents on other devices without running them, and leaves earlier systems' records alone", async () => { + const { service, emit, run, native } = await setup({ + listed: [ + record(sibling), + record(other, { tags: [["d", other]], content: '{"name":"Old"}' }), + ], + }); + await vi.waitFor(() => + expect(service.find(sibling)).toMatchObject({ + name: "Far", + type: "example/echo", + place: "elsewhere", + relay: "wss://relay.example.test", + attention: {}, + }), + ); + expect(service.find(other)).toBeUndefined(); + emit({ + events: [ + event("hey", { + pubkey: viewer, + tags: [ + ["h", channel], + ["p", sibling], + ], + }), + ], + }); + await settle(); + expect(run).not.toHaveBeenCalled(); + expect(native.claim).not.toHaveBeenCalledWith(sibling); + await expect(service.remove(sibling)).rejects.toThrow(/device that runs it/); + await expect(service.save(sibling, { name: "Near" })).rejects.toThrow(); +}); + +it("creates a hosted agent through its provider, attesting the key it returns", async () => { + const { service, plugin, session, authorize, native, storage } = + await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + const agent = await service.create({ type: "example/cloud", name: " Sky " }); + expect(remote.provision).toHaveBeenCalledWith({ + name: "Sky", + community: "wss://relay.example.test", + signal: expect.any(AbortSignal), + }); + expect(authorize).toHaveBeenCalledWith(hostedKey, expect.any(AbortSignal)); + expect(remote.activate).toHaveBeenCalledWith({ + pubkey: hostedKey, + name: "Sky", + community: "wss://relay.example.test", + auth: ["auth", viewer, "", "f".repeat(128)], + signal: expect.any(AbortSignal), + }); + expect(records.publish).toHaveBeenCalledWith( + session, + { pubkey: hostedKey, name: "Sky", runtime: "example/cloud" }, + expect.any(AbortSignal), + ); + expect(agent).toMatchObject({ pubkey: hostedKey, place: "hosted" }); + expect(agent.unfinished).toBeUndefined(); + expect(native.create).not.toHaveBeenCalled(); + expect(native.claim).not.toHaveBeenCalledWith(hostedKey); + expect(storage.getItem("buzz.agents.unfinished.v1")).toContain('"agents":[]'); +}); + +it("keeps a hosted agent whose setup failed, and finishes it without making another", async () => { + const storage = memoryStorage(); + const { service, plugin } = await setup({ storage }); + const { type, remote } = cloud(); + plugin.agents.register(type); + remote.activate.mockRejectedValueOnce(new Error("provider down")); + await expect( + service.create({ type: "example/cloud", name: "Sky" }), + ).rejects.toThrow("provider down"); + expect(service.find(hostedKey)).toMatchObject({ + place: "hosted", + unfinished: true, + name: "Sky", + }); + expect(records.publish).not.toHaveBeenCalled(); + await expect(service.save(hostedKey, { name: "Sea" })).rejects.toThrow( + /Finish the agent's setup/, + ); + // A restart keeps it. + const again = await setup({ storage }); + again.plugin.agents.register(cloud().type); + await vi.waitFor(() => + expect(again.service.find(hostedKey)?.unfinished).toBe(true), + ); + const agent = await service.finish(hostedKey); + expect(remote.provision).toHaveBeenCalledTimes(1); + expect(remote.activate).toHaveBeenCalledTimes(2); + expect(agent.unfinished).toBeUndefined(); + expect(records.publish).toHaveBeenCalledTimes(1); + expect(storage.getItem("buzz.agents.unfinished.v1")).not.toContain(hostedKey); +}); + +it("renames a hosted agent through its provider and relists it; its settings are the provider's", async () => { + const { service, plugin, session } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + await service.create({ type: "example/cloud", name: "Sky" }); + await service.save(hostedKey, { + name: "Sea", + picture: "https://example.test/sea.png", + }); + expect(remote.profile).toHaveBeenCalledWith({ + pubkey: hostedKey, + community: "wss://relay.example.test", + name: "Sea", + picture: "https://example.test/sea.png", + signal: expect.any(AbortSignal), + }); + expect(records.publish).toHaveBeenLastCalledWith( + session, + { + pubkey: hostedKey, + name: "Sea", + picture: "https://example.test/sea.png", + runtime: "example/cloud", + }, + expect.any(AbortSignal), + ); + expect(service.find(hostedKey)).toMatchObject({ + name: "Sea", + picture: "https://example.test/sea.png", + }); + await expect(service.save(hostedKey, { config: {} })).rejects.toThrow( + /through its provider/, + ); +}); + +it("deletes a hosted agent from its provider, then from its community", async () => { + const { service, plugin, session } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + await service.create({ type: "example/cloud", name: "Sky" }); + // A provider that fails leaves it listed, to delete again. + remote.remove.mockRejectedValueOnce(new Error("provider down")); + await expect(service.remove(hostedKey)).rejects.toThrow("provider down"); + expect(records.removeAgent).not.toHaveBeenCalled(); + expect(service.find(hostedKey)?.place).toBe("hosted"); + await service.remove(hostedKey); + expect(records.removeAgent).toHaveBeenCalledWith( + session, + viewer, + hostedKey, + expect.any(AbortSignal), + ); + expect(steps).toEqual(["provider", "leave"]); + expect(remote.remove).toHaveBeenCalledWith({ + pubkey: hostedKey, + community: "wss://relay.example.test", + signal: expect.any(AbortSignal), + }); + expect(service.find(hostedKey)).toBeUndefined(); +}); + +it("deletes an unfinished hosted agent from its community too, as its profile may be out", async () => { + const { service, plugin } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + remote.activate.mockRejectedValueOnce(new Error("provider down")); + await expect( + service.create({ type: "example/cloud", name: "Sky" }), + ).rejects.toThrow(); + await service.remove(hostedKey); + expect(steps).toEqual(["provider", "leave"]); + expect(service.find(hostedKey)).toBeUndefined(); +}); + +it("refuses to finish a hosted agent's setup while it is being deleted", async () => { + const { service, plugin } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + remote.activate.mockRejectedValueOnce(new Error("provider down")); + await expect( + service.create({ type: "example/cloud", name: "Sky" }), + ).rejects.toThrow(); + let release = () => {}; + remote.remove.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + const removing = service.remove(hostedKey); + await expect(service.finish(hostedKey)).rejects.toThrow(/Wait for/); + release(); + await removing; + expect(remote.activate).toHaveBeenCalledTimes(1); +}); + +it("keeps an earlier system's settings when it lists an agent here under that record", async () => { + const identity = { + pubkey: bot, + relay: "wss://relay.example.test", + owner: viewer, + type: "example/echo", + name: "Echo", + deleted: false, + }; + const content = { name: "Old", parallelism: 2, respond_to: "anyone" }; + const tags = [ + ["d", bot], + ["a", "30175:x:y"], + ]; + const { service, session } = await setup({ + identities: [identity], + listed: [record(bot, { tags, content: JSON.stringify(content) })], + }); + await vi.waitFor(() => + expect(records.publish).toHaveBeenCalledWith( + session, + { + pubkey: bot, + name: "Echo", + runtime: "example/echo", + prior: { content, tags }, + }, + expect.any(AbortSignal), + ), + ); + expect(service.snapshot().agents).toHaveLength(1); +}); + +it("keeps what it lists while the community's records are read, and reads again after a failure", async () => { + const { service, plugin, session, connect } = await setup(); + const { type } = cloud(); + plugin.agents.register(type); + const read = vi.mocked(session.read); + read.mockRejectedValueOnce(new Error("offline")); + connect(false); + connect(true); + await settle(); + // The failed read lists nothing, and a sync reads again, held this time. + let respond = (_: RelayEvent[]) => {}; + read.mockImplementationOnce( + () => + new Promise((resolve) => { + respond = resolve; + }) as never, + ); + await service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(3)); + // Made meanwhile, it shows at once and stays once the read returns. + await service.create({ type: "example/cloud", name: "Sky" }); + expect(service.find(hostedKey)).toMatchObject({ place: "hosted" }); + respond([]); + await settle(); + expect(service.find(hostedKey)).toMatchObject({ place: "hosted" }); + // Then the agent here is listed too. + await vi.waitFor(() => + expect(records.publish).toHaveBeenCalledWith( + session, + expect.objectContaining({ pubkey: bot }), + expect.any(AbortSignal), + ), + ); +}); + +it("lists only an HTTPS picture of a hosted agent", async () => { + const { service, plugin } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + await service.create({ type: "example/cloud", name: "Sky" }); + await expect( + service.save(hostedKey, { picture: "data:image/png;base64,AAAA" }), + ).rejects.toThrow(/HTTPS image URL/); + expect(remote.profile).not.toHaveBeenCalled(); +}); + +it("refuses a hosted type that also runs here", async () => { + const { plugin } = await setup(); + const { type } = cloud(); + expect(() => plugin.agents.register({ ...type, run: () => {} })).toThrow( + /hosted agent type/, + ); +}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ae3547287..5e960e785 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -4,6 +4,12 @@ // app owns and edits, and a config blob the type owns. The app delivers matching live events to // `run` from the stream the owner already receives in the agent's community, // selected or not, so no plugin opens a socket or a REQ. +// +// A hosted type's provider holds the key and runs the agent instead. The app +// creates it through the type's `remote` steps and signs only the owner's +// attestation. Every agent of either kind is listed in the owner's kind 30177 +// records with its type as the `runtime` tag, so every device of the owner's +// can show it: as here, hosted, or on another device. import { migrateAgentStorage } from "./migrate-storage"; import { Service, type Context } from "@deepseek-ai/cordis"; import { npubEncode } from "nostr-tools/nip19"; @@ -14,10 +20,21 @@ import { } from "../../plugins/contributions"; import type { LocalAgentsSnapshot } from "./local"; import { attestedOwner } from "./owner-attestation"; -import { removeAgentFromChannels } from "./relay-removal"; +import { + deleteAgentRecord, + publishAgentRecord, + type AgentRecordBody, + removeAgentFromChannels, + removeRelayAgent, +} from "./relay-removal"; import { communityDestination, relayOrigin } from "../communities/destination"; import type { Communities } from "../communities/service"; -import type { RelayEvent } from "../relay/events"; +import { + prepareRemoteAgentAuthorization, + type NipOaAuthorization, +} from "../host/service"; +import { avatarSource } from "../../shared/avatar-source"; +import { newer, type EventData, type RelayEvent } from "../relay/events"; import { EFFORT_TAG, MODEL_TAG } from "../relay/messages"; import type { LiveBatch } from "../relay/incoming"; import { relayPartition } from "../relay/partition"; @@ -52,18 +69,25 @@ import { import { readRecords, readTeams, + readUnfinished, setAttention, writeRecords, writeTeams, + writeUnfinished, type AgentRecord, type SkippedObject, type Team, + type Unfinished, } from "./store"; // STOPGAP(agent-local-typing): delete with local-typing.ts. import { typeLocally } from "./local-typing"; // STOPGAP(agent-working-reactions): delete with working-reactions.ts. import { reactWhileWorking } from "./working-reactions"; +/** Where an agent runs: `here`, with its key on this device; `hosted`, by its + * type's provider; or `elsewhere`, on another of the owner's devices. Only an + * agent here has attention, timers and config; the others never run here. */ +export type AgentPlace = "here" | "hosted" | "elsewhere"; /** One of the viewer's agents, in the community at `relay`. */ export type Agent = Readonly<{ pubkey: string; @@ -73,6 +97,10 @@ export type Agent = Readonly<{ type: string; owner: string; relay: string; + place: AgentPlace; + /** A hosted agent its provider made whose setup has not finished; `finish` + * continues it. */ + unfinished?: true; attention: Readonly>; /** Stored attention objects that are invalid or over a limit, by slug. */ skipped: Readonly>; @@ -95,7 +123,8 @@ export type AgentPeekProps = { agent: Agent }; export type AgentViewProps = { agent: Agent; /** Replaces the type's whole config. Spread `agent.config` to change one - * field, or tabs that each save a part will erase each other's fields. */ + * field, or tabs that each save a part will erase each other's fields. A + * hosted type's tabs save to its provider instead; for them this rejects. */ save(config: Config): Promise; }; export type AgentTab = Readonly<{ @@ -199,7 +228,45 @@ export type AgentType = { run?(delivery: Delivery): void | Promise; /** Per-run deadline in milliseconds, up to 30 minutes; defaults to 60 seconds. */ timeoutMs?: number; + /** Makes the type hosted: its provider runs each agent, so it has no `run`, + * `models` or `timeoutMs`, and its agents have no attention. */ + remote?: RemoteAgentType; }; +/** A hosted type's provider: it holds each agent's key and runs it. The app + * calls these in order to create one (`provision`, then `activate`) and owns + * the owner's agent list; every step is asked again after a failure, so each + * must be safe to repeat. `community` is the agent's `wss://` community. */ +export type RemoteAgentType = Readonly<{ + /** Makes the agent and resolves with its pubkey. Not yet its owner's. */ + provision( + input: Readonly<{ name: string; community: string; signal: AbortSignal }>, + ): Promise>; + /** Hands over the owner's attestation (NIP-OA), which the agent attaches to + * what it signs. The provider publishes its profile and starts it. */ + activate( + input: Readonly<{ + pubkey: string; + name: string; + community: string; + auth: NipOaAuthorization; + signal: AbortSignal; + }>, + ): Promise; + /** Publishes its profile with this name and picture (an HTTPS image URL). */ + profile( + input: Readonly<{ + pubkey: string; + community: string; + name: string; + picture?: string; + signal: AbortSignal; + }>, + ): Promise; + /** Stops it and deletes its key. An agent already gone counts as removed. */ + remove( + input: Readonly<{ pubkey: string; community: string; signal: AbortSignal }>, + ): Promise; +}>; export type RegisteredAgentType = Contribution; export type AgentsSnapshot = Readonly<{ status: "unavailable" | "loading" | "ready" | "error"; @@ -224,7 +291,12 @@ export type Agents = { /** The owner's connection to a running agent's community, which need not be * the selected one: read the agent's conversations through this. */ relay(pubkey: string): RelayData | undefined; + /** Makes an agent of `type` in the selected community: here, or with a + * hosted type at its provider. A hosted one whose setup then fails is kept + * as `unfinished`. */ create(input: Readonly<{ type: string; name: string }>): Promise; + /** Continues an unfinished hosted agent's setup. */ + finish(pubkey: string): Promise; /** Identities earlier agent systems saved on this machine that are not * Agents yet. */ portable(): Promise; @@ -236,7 +308,9 @@ export type Agents = { ): Promise; save(pubkey: string, change: AgentChange): Promise; /** Deletes its key at once. Leaving its channels and archiving it follow in - * the background whenever its community is open. */ + * the background whenever its community is open. A hosted agent is archived, + * leaves its channels and is removed from its provider before this resolves. + * One on another device is deleted there. */ remove(pubkey: string): Promise; /** Creates a team, or replaces the one with `id`. Members must be agents on * this device. */ @@ -392,6 +466,59 @@ const originOf = (value: string) => { return undefined; } }; +/** One of the viewer's kind 30177 agent records. Ones without a `runtime` + * are earlier agent systems', not shown; replacing one keeps its settings. */ +type Listing = Readonly<{ + pubkey: string; + name: string; + picture?: string; + runtime?: string; + /** The record as read or written, kept by the next one written. */ + prior?: AgentRecordBody | undefined; +}>; +const RUNTIME = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/; +/** The newest record of each agent, read as listings. */ +function listingsOf(events: readonly EventData[], viewer: string) { + const heads = new Map(); + for (const event of events) { + const d = event.tags.find((tag) => tag[0] === "d")?.[1] ?? ""; + if (event.kind === 30177 && event.pubkey === viewer) + heads.set(d, newer(heads.get(d), event)); + } + const listings: Listing[] = []; + for (const [pubkey, event] of heads) { + if (!/^[0-9a-f]{64}$/.test(pubkey)) continue; + const runtime = event.tags.find((tag) => tag[0] === "runtime")?.[1] ?? ""; + let body: unknown = null; + try { + body = JSON.parse(event.content); + } catch {} + const content = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : {}; + const name = typeof content.name === "string" ? content.name.trim() : ""; + const picture = recordPicture(content.picture); + listings.push( + Object.freeze({ + pubkey, + name, + ...(picture ? { picture } : {}), + ...(RUNTIME.test(runtime) ? { runtime } : {}), + prior: { content, tags: event.tags }, + }), + ); + } + return Object.freeze(listings); +} +/** A picture as a record carries it: an HTTPS image URL, never inline data. */ +const recordPicture = (value: unknown) => { + const source = avatarSource(value); + return source?.startsWith("https:") ? source : undefined; +}; +/** The `wss://` URL of a community, as agents and their providers know it. */ +const wssOf = (origin: string) => + relayOrigin(origin).replace(/^https:/, "wss:"); type Job = { trigger: Trigger; channelId?: string }; type WatchTrigger = Extract; type CompiledWatch = Omit & { @@ -441,17 +568,35 @@ export class AgentsService extends Service implements Agents { /** Deleted agents whose community cleanup is running, and whether it was * asked for again meanwhile, as when the connection recovers mid-attempt. */ private cleaning = new Map(); + private unfinished: readonly Unfinished[]; + /** The viewer's agent records in each community, by origin, as last read + * from its live session and written since. */ + private listings = new Map(); + /** Agents whose record is being written, so a sync writes each once. */ + private listing = new Set(); + /** By origin while its records are read: the listings written or removed + * meanwhile, which the read must not undo. */ + private edits = new Map>(); + /** Hosted agents whose setup or delete is running, so one waits for the other. */ + private busy = new Set(); + /** Hosted and elsewhere agents as last shown, kept while unchanged. */ + private shown = new Map(); constructor( ctx: Context, private readonly communities: AgentCommunities, private readonly native: AgentsNative | undefined = nativeAgents(), private readonly storage: Storage = globalThis.localStorage, + private readonly authorize: ( + pubkey: string, + signal: AbortSignal, + ) => Promise = prepareRemoteAgentAuthorization, ) { super(ctx, "agents"); this.contributions = createContributions(ctx); migrateAgentStorage(storage); this.records = readRecords(storage); + this.unfinished = Object.freeze(readUnfinished(storage)); this.teamList = Object.freeze(readTeams(storage)); this.state = Object.freeze({ status: native ? "loading" : "unavailable", @@ -514,6 +659,19 @@ export class AgentsService extends Service implements Agents { throw new Error( `Agent type timeoutMs must be a whole number from 1 to ${TIMEOUT_LIMIT_MS}`, ); + const { remote } = type; + if ( + remote && + (type.run || + type.models || + type.timeoutMs !== undefined || + (["provision", "activate", "profile", "remove"] as const).some( + (step) => typeof remote[step] !== "function", + )) + ) + throw new Error( + "A hosted agent type has provision, activate, profile and remove, and no run, models or timeoutMs", + ); this.contributions.register(this.ctx, type as unknown as AgentType); } types = () => this.contributions.snapshot(); @@ -565,11 +723,73 @@ export class AgentsService extends Service implements Agents { async create({ type, name }: Readonly<{ type: string; name: string }>) { if (!name.trim()) throw new Error("Name the agent"); + const remote = this.types().find((entry) => entry.key === type)?.remote; + if (remote) return this.provision(type, remote, name.trim()); return this.add(type, async (native, destination, owner) => ({ agent: await native.create(destination, owner, type, name.trim()), })); } + /** Has the provider make the agent, then sets it up. From here on it is + * kept, unfinished until set up, so a failure never makes a second one. */ + private async provision(type: string, remote: RemoteAgentType, name: string) { + const binding = this.selectedBinding(); + const community = wssOf(binding.origin); + const { pubkey } = await remote.provision({ + name, + community, + signal: binding.controller.signal, + }); + if (typeof pubkey !== "string" || !/^[0-9a-f]{64}$/.test(pubkey)) + throw new Error("The provider did not return the agent's key"); + if ( + pubkey === binding.viewer || + this.identities.some((identity) => identity.pubkey === pubkey) + ) + throw new Error("The provider returned a key that is already in use"); + this.writeUnfinished([ + ...this.unfinished.filter((setup) => setup.pubkey !== pubkey), + { pubkey, type, name, relay: community, owner: binding.viewer }, + ]); + return this.finish(pubkey); + } + + finish(pubkey: string) { + return this.exclusive(pubkey, () => this.setUp(pubkey)); + } + private async setUp(pubkey: string) { + const setup = this.unfinished.find((entry) => entry.pubkey === pubkey); + if (!setup) throw new Error("That agent has no setup to finish"); + const remote = this.types().find( + (entry) => entry.key === setup.type, + )?.remote; + if (!remote) throw new Error("That agent type is not available"); + const origin = originOf(setup.relay); + const binding = this.bindingOf(setup); + if (!origin || !binding) throw new Error("Connect to its community first"); + const { signal } = binding.controller; + const auth = await this.authorize(pubkey, signal); + if (auth[1] !== setup.owner) + throw new Error("Sign in as the agent's owner to finish its setup"); + await remote.activate({ + pubkey, + name: setup.name, + community: setup.relay, + auth, + signal, + }); + await this.writeListing(origin, binding, { + pubkey, + name: setup.name, + runtime: setup.type, + prior: this.rowOf(origin, pubkey)?.prior, + }); + this.writeUnfinished(this.unfinished.filter((entry) => entry !== setup)); + const agent = this.find(pubkey); + if (!agent) throw new Error("The agent was set up in another community"); + return agent; + } + async portable() { const native = this.native; if (!native) throw new Error("Agents run only in the desktop app"); @@ -606,13 +826,9 @@ export class AgentsService extends Service implements Agents { ) => Promise>, ) { const native = this.native; - const selected = this.communities.snapshot().selected; - const binding = selected - ? this.bound.get(originOf(selected) ?? "")?.binding - : undefined; - const kind = this.types().find((entry) => entry.key === type); if (!native) throw new Error("Agents run only in the desktop app"); - if (!binding) throw new Error("Connect to a community first"); + const binding = this.selectedBinding(); + const kind = this.types().find((entry) => entry.key === type); if (!kind) throw new Error("That agent type is not available"); const settings = defaults("", kind); const { agent: identity, carry } = await save( @@ -633,10 +849,20 @@ export class AgentsService extends Service implements Agents { return this.view(identity, record); } + /** The selected community's live binding, where agents are made. */ + private selectedBinding() { + const selected = this.communities.snapshot().selected; + const binding = selected + ? this.bound.get(originOf(selected) ?? "")?.binding + : undefined; + if (!binding) throw new Error("Connect to a community first"); + return binding; + } + async save(pubkey: string, change: AgentChange) { const identity = this.live(pubkey); - if (!this.native || !identity) - throw new Error("No such agent on this device"); + if (!identity) return this.saveHosted(pubkey, change); + if (!this.native) throw new Error("No such agent on this device"); const name = change.name?.trim(); if (name === "") throw new Error("Name the agent"); let record = this.recordOf(identity); @@ -670,13 +896,94 @@ export class AgentsService extends Service implements Agents { if (renamed || repictured) await this.refresh(); } + /** A hosted agent's name and picture are its provider's to publish; the app + * then lists it under them. Its settings are the provider's, not saved here. */ + private async saveHosted(pubkey: string, change: AgentChange) { + const { agent, remote, origin, binding } = this.hosted(pubkey); + if (change.config !== undefined || change.attention) + throw new Error("A hosted agent's settings save through its provider"); + const name = change.name === undefined ? agent.name : change.name.trim(); + if (!name) throw new Error("Name the agent"); + const picture = + change.picture === undefined + ? agent.picture + : recordPicture(change.picture ?? undefined); + if (change.picture && !picture) + throw new Error("A hosted agent's picture is an HTTPS image URL"); + if (name === agent.name && picture === agent.picture) return; + await remote.profile({ + pubkey, + community: agent.relay, + name, + ...(picture ? { picture } : {}), + signal: binding.controller.signal, + }); + await this.writeListing(origin, binding, { + pubkey, + name, + ...(picture ? { picture } : {}), + runtime: agent.type, + prior: this.rowOf(origin, pubkey)?.prior, + }); + } + /** A hosted agent shown now, its provider, and its community's live session. */ + private hosted(pubkey: string, unfinished = false) { + const agent = this.state.agents.find( + (entry) => entry.pubkey === pubkey && entry.place === "hosted", + ); + if (agent?.unfinished && !unfinished) + throw new Error("Finish the agent's setup first"); + const remote = + agent && this.types().find((entry) => entry.key === agent.type)?.remote; + const origin = agent && originOf(agent.relay); + const binding = agent && this.bindingOf(agent); + if (!agent || !remote || !origin || !binding) + throw new Error("No such agent on this device"); + return { agent, remote, origin, binding }; + } + async remove(pubkey: string) { + if (!this.identities.some((identity) => identity.pubkey === pubkey)) + return this.removeHosted(pubkey); if (!this.native) throw new Error("Agents run only in the desktop app"); await this.native.remove(pubkey); // Drops its settings, and starts its cleanup if its community is connected. await this.refresh(); } + /** Has a hosted agent's provider remove it, then takes it out of the + * community as Delete does for one here. Its record goes last, so a failure + * leaves it listed for Delete to be tried again. */ + private removeHosted(pubkey: string) { + if ( + this.state.agents.some( + (agent) => agent.pubkey === pubkey && agent.place === "elsewhere", + ) + ) + throw new Error("Delete it on the device that runs it"); + return this.exclusive(pubkey, async () => { + const { agent, remote, origin, binding } = this.hosted(pubkey, true); + const { signal } = binding.controller; + await remote.remove({ pubkey, community: agent.relay, signal }); + // An unfinished one may have published its profile before setup failed. + await removeRelayAgent(binding.session, binding.viewer, pubkey, signal); + this.unlist(origin, pubkey); + this.writeUnfinished( + this.unfinished.filter((setup) => setup.pubkey !== pubkey), + ); + }); + } + private async exclusive(pubkey: string, step: () => Promise) { + if (this.busy.has(pubkey)) + throw new Error("Wait for the agent's setup or delete to finish"); + this.busy.add(pubkey); + try { + return await step(); + } finally { + this.busy.delete(pubkey); + } + } + async saveTeam({ id, name, instructions, members }: TeamChange) { if (!this.native) throw new Error("Agents run only in the desktop app"); if (!name.trim()) throw new Error("Name the team"); @@ -710,6 +1017,197 @@ export class AgentsService extends Service implements Agents { this.notify(); } + /** Saves the unfinished setups; memory changes only once storage has them. */ + private writeUnfinished(unfinished: readonly Unfinished[]) { + writeUnfinished(this.storage, unfinished); + this.unfinished = Object.freeze(unfinished); + this.update(); + } + + /** Reads the viewer's agent records in the community, then lists each of + * its agents here that they lack. A sync asks again until one succeeds. + * Without the desktop app there are no agents to show. */ + private async readListings(origin: string, binding: Binding) { + if (!this.native || this.edits.has(origin)) return; + const edits = new Map(); + this.edits.set(origin, edits); + const { session, viewer, controller } = binding; + try { + const events = await session.read( + [{ kinds: [30177], authors: [viewer], limit: 500 }], + { signal: controller.signal, priority: "background", fresh: true }, + ); + if (this.bound.get(origin)?.binding !== binding) return; + const read = listingsOf(events, viewer).filter( + (row) => !edits.has(row.pubkey), + ); + const written = [...edits.values()].filter((row) => row !== null); + this.listings.set(origin, Object.freeze([...read, ...written])); + } catch (error) { + if (!controller.signal.aborted) + console.warn("The agent list could not be read", error); + return; + } finally { + if (this.edits.get(origin) === edits) this.edits.delete(origin); + } + this.update(); + this.list(origin, binding); + } + /** The community's listings: as read, or until the first read finishes, + * those written since. */ + private rowsOf(origin: string): readonly Listing[] { + return ( + this.listings.get(origin) ?? + [...(this.edits.get(origin)?.values() ?? [])].filter((row) => !!row) + ); + } + private rowOf(origin: string, pubkey: string) { + return this.rowsOf(origin).find((row) => row.pubkey === pubkey); + } + /** Lists each agent here in the community whose record is missing or shows + * another name, picture or type. Waits for the first read, so it never + * replaces a record it has not seen. */ + private list(origin: string, binding: Binding) { + if (!this.listings.has(origin)) return; + for (const identity of this.identities) { + if ( + identity.deleted || + identity.owner !== binding.viewer || + originOf(identity.relay) !== origin || + this.listing.has(identity.pubkey) + ) + continue; + const picture = recordPicture(identity.picture); + const row = this.rowOf(origin, identity.pubkey); + if ( + row?.name === identity.name && + row.picture === picture && + row.runtime === identity.type + ) + continue; + this.listing.add(identity.pubkey); + this.writeListing(origin, binding, { + pubkey: identity.pubkey, + name: identity.name, + ...(picture ? { picture } : {}), + runtime: identity.type, + prior: row?.prior, + }) + .catch((error) => { + if (!binding.controller.signal.aborted) + console.warn(`Agent ${identity.name} was not listed`, error); + }) + .finally(() => this.listing.delete(identity.pubkey)); + } + } + /** Writes the agent's record and lists it so, without reading it back. */ + private async writeListing( + origin: string, + binding: Binding, + entry: Listing & { runtime: string }, + ) { + const prior = await publishAgentRecord( + binding.session, + entry, + binding.controller.signal, + ); + if (this.bound.get(origin)?.binding !== binding) return; + this.setRow(origin, entry.pubkey, Object.freeze({ ...entry, prior })); + this.update(); + } + private unlist(origin: string, pubkey: string) { + this.setRow(origin, pubkey, null); + } + /** Replaces or removes the agent's listing, and keeps the change through a + * read under way. */ + private setRow(origin: string, pubkey: string, row: Listing | null) { + this.edits.get(origin)?.set(pubkey, row); + const listings = this.listings.get(origin); + if (!listings) return; + const rest = listings.filter((entry) => entry.pubkey !== pubkey); + if (row || rest.length !== listings.length) + this.listings.set(origin, Object.freeze(row ? [...rest, row] : rest)); + } + /** The selected community's agents that are not here: hosted ones, set up + * or not, and ones on the viewer's other devices. */ + private listedAgents(origin: string | undefined) { + const binding = origin ? this.bound.get(origin)?.binding : undefined; + if (!origin || !binding) return []; + const here = new Set(this.identities.map((identity) => identity.pubkey)); + const listings = this.rowsOf(origin); + const types = new Map(this.types().map((type) => [type.key, type])); + const relay = wssOf(binding.origin); + const shown: Agent[] = []; + for (const row of listings) + if (row.runtime && row.name && !here.has(row.pubkey)) + shown.push( + this.show({ + pubkey: row.pubkey, + name: row.name, + ...(row.picture ? { picture: row.picture } : {}), + type: row.runtime, + owner: binding.viewer, + relay, + place: types.get(row.runtime)?.remote ? "hosted" : "elsewhere", + }), + ); + for (const setup of this.unfinished) + if ( + originOf(setup.relay) === origin && + setup.owner === binding.viewer && + !here.has(setup.pubkey) && + !listings.some((row) => row.runtime && row.pubkey === setup.pubkey) + ) + shown.push( + this.show({ + pubkey: setup.pubkey, + name: setup.name, + type: setup.type, + owner: setup.owner, + relay: setup.relay, + place: "hosted", + unfinished: true, + }), + ); + return shown; + } + /** The agent as shown, the same object while nothing about it changed. */ + private show( + fields: Pick< + Agent, + | "pubkey" + | "name" + | "picture" + | "type" + | "owner" + | "relay" + | "place" + | "unfinished" + >, + ): Agent { + const prior = this.shown.get(fields.pubkey); + if ( + prior && + prior.name === fields.name && + prior.picture === fields.picture && + prior.type === fields.type && + prior.owner === fields.owner && + prior.relay === fields.relay && + prior.place === fields.place && + prior.unfinished === fields.unfinished + ) + return prior; + const agent: Agent = Object.freeze({ + ...fields, + attention: EMPTY, + skipped: EMPTY, + timers: EMPTY, + config: undefined, + }); + this.shown.set(fields.pubkey, agent); + return agent; + } + /** Saves one record; memory changes only once storage has taken it. */ private write(record: AgentRecord) { const records = { ...this.records, [record.pubkey]: record }; @@ -742,6 +1240,7 @@ export class AgentsService extends Service implements Agents { type: identity.type, owner: identity.owner, relay: identity.relay, + place: "here", attention: record.attention, skipped: record.skipped ?? EMPTY, timers: record.timers ?? EMPTY, @@ -830,6 +1329,11 @@ export class AgentsService extends Service implements Agents { * its community lacks, and finishes Delete where connected. Each step * is safe to repeat; what fails is tried again on the next refresh or connect. */ private sync() { + for (const [origin, { binding }] of this.bound) { + if (!binding) continue; + if (this.listings.has(origin)) this.list(origin, binding); + else void this.readListings(origin, binding); + } const native = this.native; for (const identity of this.identities) if (!identity.deleted) @@ -879,6 +1383,8 @@ export class AgentsService extends Service implements Agents { signal.throwIfAborted(); if (consent) await archives.request("archive", pubkey, signal); } + await deleteAgentRecord(session, binding.viewer, pubkey, signal); + for (const origin of this.listings.keys()) this.unlist(origin, pubkey); await this.native?.forget(pubkey); await this.refresh(); } catch (error) { @@ -936,6 +1442,8 @@ export class AgentsService extends Service implements Agents { private unbind(origin: string) { const entry = this.bound.get(origin); this.bound.delete(origin); + this.listings.delete(origin); + this.edits.delete(origin); entry?.stop(); entry?.binding?.stopLive(); entry?.binding?.controller.abort(); @@ -964,6 +1472,8 @@ export class AgentsService extends Service implements Agents { if (changed) { entry.binding?.stopLive(); entry.binding?.controller.abort(); + this.listings.delete(origin); + this.edits.delete(origin); entry.binding = live && { ...live, stopLive: this.listen(live.session, origin), @@ -971,7 +1481,8 @@ export class AgentsService extends Service implements Agents { } // Disconnecting stops its agents even when no binding changed. this.update(); - if (changed && live) this.sync(); + // Its sync reads the community's agent records too. + if (changed && entry.binding) this.sync(); } /** Delivers the session's live events, and retries what failed whenever its @@ -1018,9 +1529,12 @@ export class AgentsService extends Service implements Agents { ); } const selected = originOf(this.communities.snapshot().selected ?? ""); - const agents = running.filter( - (agent) => originOf(agent.relay) === selected && this.bindingOf(agent), - ); + const agents = [ + ...running.filter( + (agent) => originOf(agent.relay) === selected && this.bindingOf(agent), + ), + ...this.listedAgents(selected), + ]; const types = new Map(this.types().map((type) => [type.key, type])); for (const [pubkey, runner] of this.runners) if (!identities.some((identity) => identity.pubkey === pubkey)) { diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 0b203f1d3..f768ca134 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -216,3 +216,55 @@ export function readTeams(storage: Storage): readonly Team[] { export function writeTeams(storage: Storage, teams: readonly Team[]) { storage.setItem(TEAMS_KEY, JSON.stringify({ version: 1, teams })); } + +/** A hosted agent its provider made whose setup has not finished. Kept so + * Finish setup continues it instead of the provider making another. */ +export type Unfinished = Readonly<{ + pubkey: string; + /** The agent type's key, `pluginId/typeId`. */ + type: string; + name: string; + /** Its `wss://` community. */ + relay: string; + owner: string; +}>; +export const UNFINISHED_KEY = "buzz.agents.unfinished.v1"; + +/** Reads every unfinished setup, skipping any that is not one. */ +export function readUnfinished(storage: Storage): readonly Unfinished[] { + let stored: unknown; + try { + stored = JSON.parse(storage.getItem(UNFINISHED_KEY) ?? "null"); + } catch { + return []; + } + const agents = + (stored as { version?: unknown } | null)?.version === 1 + ? (stored as { agents: unknown }).agents + : undefined; + if (!Array.isArray(agents)) return []; + return agents.flatMap((agent: Partial | null) => + PUBKEY.test(agent?.pubkey ?? "") && + PUBKEY.test(agent?.owner ?? "") && + typeof agent?.type === "string" && + typeof agent.name === "string" && + typeof agent.relay === "string" + ? [ + { + pubkey: agent.pubkey as string, + type: agent.type, + name: agent.name, + relay: agent.relay, + owner: agent.owner as string, + }, + ] + : [], + ); +} + +export function writeUnfinished( + storage: Storage, + agents: readonly Unfinished[], +) { + storage.setItem(UNFINISHED_KEY, JSON.stringify({ version: 1, agents })); +} diff --git a/src/features/agents/tabs.tsx b/src/features/agents/tabs.tsx index c2c0ebd2b..739a46e37 100644 --- a/src/features/agents/tabs.tsx +++ b/src/features/agents/tabs.tsx @@ -1,6 +1,7 @@ // An agent's sections, wherever they are shown (its Build view and its // profile panel): the type's own tabs, then the app's Attention tab, which every -// type shares because the app is what wakes the agent. +// agent here shares because the app is what wakes it. Hosted agents' tabs are +// their type's alone; an agent on another device has none here. import type { ReactNode } from "react"; import type { ChannelChoice } from "./attention"; import { AttentionPanel } from "./AttentionPanel"; @@ -24,8 +25,9 @@ export function agentSections({ type: RegisteredAgentType | undefined; channels: readonly ChannelChoice[]; }): readonly AgentSection[] { - return [ - ...(type?.tabs ?? []).map(({ id, title, component: Component }) => ({ + if (agent.place === "elsewhere") return []; + const tabs = (type?.tabs ?? []).map( + ({ id, title, component: Component }) => ({ value: `agent:type:${id}` as const, label: title, render: () => ( @@ -34,7 +36,11 @@ export function agentSections({ save={(config) => agents.save(agent.pubkey, { config })} /> ), - })), + }), + ); + if (agent.place === "hosted") return tabs; + return [ + ...tabs, { value: "agent:attention", label: "Attention", diff --git a/src/features/host/service.ts b/src/features/host/service.ts index 623887736..864689434 100644 --- a/src/features/host/service.ts +++ b/src/features/host/service.ts @@ -61,69 +61,75 @@ declare module "@deepseek-ai/cordis" { } } +/** The signed-in owner's NIP-OA proof for `agentPubkey`, from native + * identity or the web relay proxy. */ +export async function prepareRemoteAgentAuthorization( + agentPubkey: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + if (!/^[0-9a-f]{64}$/.test(agentPubkey)) + throw new Error("Agent pubkey must be 64 lowercase hex characters"); + const native = nativeIdentityEnabled(); + const owner = native + ? await invoke("identity_restore") + : ( + await ( + await fetch("/api/relay/identity", { signal: signal ?? null }) + ).json() + ).viewer; + signal?.throwIfAborted(); + if (typeof owner !== "string" || !/^[0-9a-f]{64}$/.test(owner)) + throw new Error("Set up your identity first"); + if (owner === agentPubkey) + throw new Error("Owner and agent pubkeys must differ"); + let tag: NipOaAuthorization; + if (native) { + try { + tag = await invoke( + "identity_prepare_remote_agent_authorization", + { owner, agentPubkey }, + ); + } catch (error) { + throw typeof error === "string" ? new Error(error) : error; + } + } else { + const response = await fetch( + "/api/relay/prepare-remote-agent-authorization", + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner, agentPubkey }), + signal: signal ?? null, + }, + ); + const result = await response.json(); + if (!response.ok) + throw new Error(result.error ?? "Owner authorization failed"); + tag = result; + } + signal?.throwIfAborted(); + if ( + !Array.isArray(tag) || + tag.length !== 4 || + tag[0] !== "auth" || + tag[1] !== owner || + tag[2] !== "" || + typeof tag[3] !== "string" || + !/^[0-9a-f]{128}$/.test(tag[3]) + ) + throw new Error("Invalid remote agent authorization"); + return tag; +} + export class HostService extends Service implements Host { constructor(context: Context) { super(context, "host"); } - async prepareRemoteAgentAuthorization( - agentPubkey: string, - signal?: AbortSignal, - ): Promise { - signal?.throwIfAborted(); - if (!/^[0-9a-f]{64}$/.test(agentPubkey)) - throw new Error("Agent pubkey must be 64 lowercase hex characters"); - const native = nativeIdentityEnabled(); - const owner = native - ? await invoke("identity_restore") - : ( - await ( - await fetch("/api/relay/identity", { signal: signal ?? null }) - ).json() - ).viewer; - signal?.throwIfAborted(); - if (typeof owner !== "string" || !/^[0-9a-f]{64}$/.test(owner)) - throw new Error("Set up your identity first"); - if (owner === agentPubkey) - throw new Error("Owner and agent pubkeys must differ"); - let tag: NipOaAuthorization; - if (native) { - try { - tag = await invoke( - "identity_prepare_remote_agent_authorization", - { owner, agentPubkey }, - ); - } catch (error) { - throw typeof error === "string" ? new Error(error) : error; - } - } else { - const response = await fetch( - "/api/relay/prepare-remote-agent-authorization", - { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ owner, agentPubkey }), - signal: signal ?? null, - }, - ); - const result = await response.json(); - if (!response.ok) - throw new Error(result.error ?? "Owner authorization failed"); - tag = result; - } - signal?.throwIfAborted(); - if ( - !Array.isArray(tag) || - tag.length !== 4 || - tag[0] !== "auth" || - tag[1] !== owner || - tag[2] !== "" || - typeof tag[3] !== "string" || - !/^[0-9a-f]{128}$/.test(tag[3]) - ) - throw new Error("Invalid remote agent authorization"); - return tag; + prepareRemoteAgentAuthorization(agentPubkey: string, signal?: AbortSignal) { + return prepareRemoteAgentAuthorization(agentPubkey, signal); } async runCommand(id: string, input?: string): Promise { diff --git a/src/features/relay/testing.ts b/src/features/relay/testing.ts index 3005966d1..799f39db1 100644 --- a/src/features/relay/testing.ts +++ b/src/features/relay/testing.ts @@ -256,7 +256,7 @@ export function archiveRelay( return []; }), writer: { - kinds: [9001, 5], + kinds: [9001, 5, 30177], async sign(template) { return finalizeEvent({ ...template }, viewer.secret); }, @@ -268,7 +268,7 @@ export function archiveRelay( ) throw removal.fail; published.push(event); - if (!removal.apply) return; + if (!removal.apply || event.kind === 30177) return; if (event.kind === 5) { for (const [name, value] of event.tags) if (name === "a" && value) deleted.add(value); From 1796a8f6c8d4360c1e67e12ab41fb30352abb00c Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 12 Oct 2026 11:57:14 -0700 Subject: [PATCH 2/3] fix(agents): address review findings on hosted agents - Bring over offers only types that run here, and `add()` refuses a hosted type, whose key stays with its provider. - A rename made while an agent's record is being written is listed once that write finishes. - A hosted agent's profile save takes the same per-agent turn as setup and delete, so a late save cannot relist a deleted agent. - A key from the provider that the owner already lists is refused. - Records written before the community's records are first read are kept through a failed read and merged into the next one. - Focus moves from Finish setup to Open when the setup finishes. Co-authored-by: claude00 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- src/bundled/agents/AgentsPage.test.tsx | 41 ++++++++- src/bundled/agents/AgentsPage.tsx | 20 ++++- src/features/agents/service.test.ts | 117 +++++++++++++++++++++++++ src/features/agents/service.ts | 64 +++++++++----- 4 files changed, 217 insertions(+), 25 deletions(-) diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index ef4c64e9e..da08e177b 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -134,7 +134,11 @@ function setup(saved: Agent[], { shown = true } = {}) { subscribe: () => () => {}, } as unknown as RelayData; render(); - return { agents }; + const show = (next: Agent[]) => { + snapshot = { ...snapshot, agents: next }; + for (const listener of listeners) listener(); + }; + return { agents, types, show }; } it("peeks at a selected agent beside the grid and follows the arrow keys", async () => { @@ -279,6 +283,23 @@ it("brings over an earlier agent as the chosen type and drops it from the list", expect(within(list).getByText("Ada")).toBeInTheDocument(); }); +it("brings over agents only as a type that runs here", async () => { + const user = userEvent.setup(); + const { types } = setup([]); + types.push({ + ...types[0], + key: "test/cloud", + title: "Cloud", + remote: {}, + } as unknown as RegisteredAgentType); + await user.click(screen.getByRole("button", { name: "Bring over" })); + await screen.findByRole("button", { name: "Bring over Ada" }); + expect(screen.getByRole("radio", { name: "Echo" })).toBeInTheDocument(); + expect( + screen.queryByRole("radio", { name: "Cloud" }), + ).not.toBeInTheDocument(); +}); + it("shows why bringing an agent over failed and keeps it listed", async () => { const user = userEvent.setup(); const { agents } = setup([]); @@ -363,3 +384,21 @@ it("shows an agent on another device read-only, and offers only Finish setup and await user.dblClick(within(grid).getByText("Sky")); expect(screen.getByRole("listbox")).toBeInTheDocument(); }); + +it("moves focus from Finish setup to Open once the setup finishes", async () => { + const user = userEvent.setup(); + const finished: Agent = { + ...agent("Sky", 4), + place: "hosted", + attention: {}, + }; + const { agents, show } = setup([{ ...finished, unfinished: true }]); + agents.finish.mockImplementationOnce(async () => { + show([finished]); + return finished; + }); + await user.click(screen.getByText("Sky")); + screen.getByRole("button", { name: "Finish setup" }).focus(); + await user.keyboard("{Enter}"); + expect(await screen.findByRole("button", { name: "Open" })).toHaveFocus(); +}); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 49c689011..b71570bdd 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -148,7 +148,11 @@ export function AgentsPage({ onCreated={(agent) => build(agent.pubkey)} /> ) : side === "porting" ? ( - browse()} /> + !type.remote)} + onClose={() => browse()} + /> ) : side === "teams" ? ( (null); + const openButton = useRef(null); + // Finish setup is replaced by Open on success; focus moves with it. + const handoff = useRef(false); const finish = async () => { + handoff.current = document.activeElement === finishButton.current; setFinishing(true); setError(""); try { await agents.finish(agent.pubkey); } catch (reason) { + handoff.current = false; setError(message(reason)); } setFinishing(false); }; + useEffect(() => { + if (agent.unfinished || !handoff.current) return; + handoff.current = false; + openButton.current?.focus(); + }, [agent.unfinished]); if (agent.place === "elsewhere") return (
@@ -438,6 +453,7 @@ function AgentPeek({ Its provider made it, but its setup did not finish.

)} diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index e352bd508..35f78d4b3 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -1898,3 +1898,120 @@ it("refuses a hosted type that also runs here", async () => { /hosted agent type/, ); }); + +it("brings over no identity as a hosted type, whose key stays with its provider", async () => { + const { service, plugin, native } = await setup(); + plugin.agents.register(cloud().type); + await expect( + service.port({ + source: "installed", + pubkey: sibling, + type: "example/cloud", + }), + ).rejects.toThrow(/stays with its provider/); + expect(native.port).not.toHaveBeenCalled(); +}); + +it("refuses a key from the provider that the owner already lists", async () => { + const { service, plugin } = await setup({ listed: [record(sibling)] }); + const { type, remote } = cloud(); + plugin.agents.register(type); + await vi.waitFor(() => + expect(service.find(sibling)?.place).toBe("elsewhere"), + ); + remote.provision.mockResolvedValueOnce({ pubkey: sibling }); + await expect( + service.create({ type: "example/cloud", name: "Sky" }), + ).rejects.toThrow(/already in use/); + expect(remote.activate).not.toHaveBeenCalled(); + expect(service.find(sibling)).toMatchObject({ + name: "Far", + place: "elsewhere", + }); +}); + +it("lists a rename made while the agent's earlier record was being written", async () => { + const { service, session } = await setup(); + let release = () => {}; + records.publish.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }) as never, + ); + await service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => expect(records.publish).toHaveBeenCalledTimes(1)); + await service.save(bot, { name: "Echo Two" }); + await settle(); + expect(records.publish).toHaveBeenCalledTimes(1); + release(); + await vi.waitFor(() => + expect(records.publish).toHaveBeenLastCalledWith( + session, + expect.objectContaining({ pubkey: bot, name: "Echo Two" }), + expect.any(AbortSignal), + ), + ); + await settle(); + expect(records.publish).toHaveBeenCalledTimes(2); +}); + +it("refuses to delete a hosted agent while its new name is being saved", async () => { + const { service, plugin } = await setup(); + const { type, remote } = cloud(); + plugin.agents.register(type); + await service.create({ type: "example/cloud", name: "Sky" }); + let release = () => {}; + remote.profile.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(undefined); + }), + ); + const saving = service.save(hostedKey, { name: "Sea" }); + await vi.waitFor(() => expect(remote.profile).toHaveBeenCalled()); + await expect(service.remove(hostedKey)).rejects.toThrow(/Wait for/); + release(); + await saving; + expect(remote.remove).not.toHaveBeenCalled(); + expect(service.find(hostedKey)).toMatchObject({ name: "Sea" }); +}); + +it("keeps what it lists when the community's records cannot be read", async () => { + const { service, plugin, session, connect } = await setup(); + plugin.agents.register(cloud().type); + const read = vi.mocked(session.read); + read.mockRejectedValueOnce(new Error("offline")); + connect(false); + connect(true); + await settle(); + // Set up after the read failed, it is listed and shown. + const agent = await service.create({ type: "example/cloud", name: "Sky" }); + expect(agent).toMatchObject({ pubkey: hostedKey, place: "hosted" }); + // Renamed while a read is under way that then fails, it keeps the new name. + let fail = () => {}; + read.mockImplementationOnce( + () => + new Promise((_, reject) => { + fail = () => reject(new Error("offline")); + }) as never, + ); + const reads = read.mock.calls.length; + await service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => expect(read.mock.calls.length).toBe(reads + 1)); + await service.save(hostedKey, { name: "Sea" }); + fail(); + await settle(); + expect(service.find(hostedKey)).toMatchObject({ + name: "Sea", + place: "hosted", + }); + // The next read that succeeds keeps it too. + await service.save(bot, { name: "Echo Two" }); + await vi.waitFor(() => expect(read.mock.calls.length).toBe(reads + 2)); + await settle(); + expect(service.find(hostedKey)).toMatchObject({ + name: "Sea", + place: "hosted", + }); +}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 5e960e785..22447aeff 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -574,10 +574,13 @@ export class AgentsService extends Service implements Agents { private listings = new Map(); /** Agents whose record is being written, so a sync writes each once. */ private listing = new Set(); - /** By origin while its records are read: the listings written or removed - * meanwhile, which the read must not undo. */ + /** By origin until its records are first read: the listings written or + * removed meanwhile, which the read must not undo. */ private edits = new Map>(); - /** Hosted agents whose setup or delete is running, so one waits for the other. */ + /** By origin, the binding whose read of its records is under way. */ + private reading = new Map(); + /** Hosted agents whose setup, save or delete is running, so one waits for + * the others. */ private busy = new Set(); /** Hosted and elsewhere agents as last shown, kept while unchanged. */ private shown = new Map(); @@ -744,7 +747,8 @@ export class AgentsService extends Service implements Agents { throw new Error("The provider did not return the agent's key"); if ( pubkey === binding.viewer || - this.identities.some((identity) => identity.pubkey === pubkey) + this.identities.some((identity) => identity.pubkey === pubkey) || + [...this.bound.keys()].some((origin) => this.rowOf(origin, pubkey)) ) throw new Error("The provider returned a key that is already in use"); this.writeUnfinished([ @@ -830,6 +834,8 @@ export class AgentsService extends Service implements Agents { const binding = this.selectedBinding(); const kind = this.types().find((entry) => entry.key === type); if (!kind) throw new Error("That agent type is not available"); + if (kind.remote) + throw new Error("A hosted agent's key stays with its provider"); const settings = defaults("", kind); const { agent: identity, carry } = await save( native, @@ -861,7 +867,8 @@ export class AgentsService extends Service implements Agents { async save(pubkey: string, change: AgentChange) { const identity = this.live(pubkey); - if (!identity) return this.saveHosted(pubkey, change); + if (!identity) + return this.exclusive(pubkey, () => this.saveHosted(pubkey, change)); if (!this.native) throw new Error("No such agent on this device"); const name = change.name?.trim(); if (name === "") throw new Error("Name the agent"); @@ -975,7 +982,7 @@ export class AgentsService extends Service implements Agents { } private async exclusive(pubkey: string, step: () => Promise) { if (this.busy.has(pubkey)) - throw new Error("Wait for the agent's setup or delete to finish"); + throw new Error("Wait for the agent's last change to finish"); this.busy.add(pubkey); try { return await step(); @@ -1028,9 +1035,8 @@ export class AgentsService extends Service implements Agents { * its agents here that they lack. A sync asks again until one succeeds. * Without the desktop app there are no agents to show. */ private async readListings(origin: string, binding: Binding) { - if (!this.native || this.edits.has(origin)) return; - const edits = new Map(); - this.edits.set(origin, edits); + if (!this.native || this.reading.get(origin) === binding) return; + this.reading.set(origin, binding); const { session, viewer, controller } = binding; try { const events = await session.read( @@ -1038,17 +1044,20 @@ export class AgentsService extends Service implements Agents { { signal: controller.signal, priority: "background", fresh: true }, ); if (this.bound.get(origin)?.binding !== binding) return; + const edits = this.edits.get(origin); const read = listingsOf(events, viewer).filter( - (row) => !edits.has(row.pubkey), + (row) => !edits?.has(row.pubkey), ); - const written = [...edits.values()].filter((row) => row !== null); + const written = [...(edits?.values() ?? [])].filter((row) => !!row); this.listings.set(origin, Object.freeze([...read, ...written])); + this.edits.delete(origin); } catch (error) { + // Its edits stay for the next read, which a sync asks for. if (!controller.signal.aborted) console.warn("The agent list could not be read", error); return; } finally { - if (this.edits.get(origin) === edits) this.edits.delete(origin); + if (this.reading.get(origin) === binding) this.reading.delete(origin); } this.update(); this.list(origin, binding); @@ -1085,19 +1094,27 @@ export class AgentsService extends Service implements Agents { row.runtime === identity.type ) continue; - this.listing.add(identity.pubkey); + const { pubkey } = identity; + this.listing.add(pubkey); this.writeListing(origin, binding, { - pubkey: identity.pubkey, + pubkey, name: identity.name, ...(picture ? { picture } : {}), runtime: identity.type, prior: row?.prior, - }) - .catch((error) => { + }).then( + () => { + this.listing.delete(pubkey); + // Lists a rename or new picture made while this write was under way. + if (this.bound.get(origin)?.binding === binding) + this.list(origin, binding); + }, + (error) => { + this.listing.delete(pubkey); if (!binding.controller.signal.aborted) console.warn(`Agent ${identity.name} was not listed`, error); - }) - .finally(() => this.listing.delete(identity.pubkey)); + }, + ); } } /** Writes the agent's record and lists it so, without reading it back. */ @@ -1118,12 +1135,15 @@ export class AgentsService extends Service implements Agents { private unlist(origin: string, pubkey: string) { this.setRow(origin, pubkey, null); } - /** Replaces or removes the agent's listing, and keeps the change through a - * read under way. */ + /** Replaces or removes the agent's listing, and keeps the change until the + * community's records are first read. */ private setRow(origin: string, pubkey: string, row: Listing | null) { - this.edits.get(origin)?.set(pubkey, row); const listings = this.listings.get(origin); - if (!listings) return; + if (!listings) { + const edits = this.edits.get(origin) ?? new Map(); + this.edits.set(origin, edits.set(pubkey, row)); + return; + } const rest = listings.filter((entry) => entry.pubkey !== pubkey); if (row || rest.length !== listings.length) this.listings.set(origin, Object.freeze(row ? [...rest, row] : rest)); From d2ba7a65383a83bcb51ef6eb8aae26fa6285a86c Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 12 Oct 2026 12:21:45 -0700 Subject: [PATCH 3/3] test(browser): answer the Agents service's read of the owner's agent list The fixture relay only knew the profile panel's combined 30175/30177 read, so the new `{ authors: [viewer], kinds: [30177], limit: 500 }` read failed the unexpected-request check in plugin-import and updates. Co-authored-by: claude00 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Bradley Axen --- tests/browser/fixture.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 1ffdd0811..65cfdac3d 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -1097,6 +1097,19 @@ export const test = base.extend({ }); return []; } + // The Agents service's read of the owner's agent list. + if ( + filter.kinds?.length === 1 && + filter.kinds[0] === 30177 && + !filter["#d"] + ) { + expect(filter).toEqual({ + authors: [viewer], + kinds: [30177], + limit: 500, + }); + return []; + } if (filter.kinds?.includes(30175) || filter.kinds?.includes(30177)) { expect(filter).toEqual({ authors: [viewer],