(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 (
+
+
+
+ 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.
-
-
}
- onClick={onClose}
- />
-
-
- Open
-
- {Peek ? (
+
void finish()}
+ >
+ Finish setup
+
+ {error && (
+
+ {error}
+
+ )}
+
+ >
+ ) : (
+
+ Open
+
+ )}
+ {Peek && !agent.unfinished ? (
@@ -423,9 +481,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}
+
+
}
+ onClick={onClose}
+ />
);
}
@@ -492,14 +580,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..35f78d4b3 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,477 @@ 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/,
+ );
+});
+
+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 ae3547287..22447aeff 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,38 @@ 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 until its records are first read: the listings written or
+ * removed meanwhile, which the read must not undo. */
+ private edits = new Map>();
+ /** 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();
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 +662,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 +726,74 @@ 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) ||
+ [...this.bound.keys()].some((origin) => this.rowOf(origin, 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,14 +830,12 @@ 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");
+ 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,
@@ -633,10 +855,21 @@ 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.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");
let record = this.recordOf(identity);
@@ -670,13 +903,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 last change 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 +1024,210 @@ 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.reading.get(origin) === binding) return;
+ this.reading.set(origin, binding);
+ 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 edits = this.edits.get(origin);
+ const read = listingsOf(events, viewer).filter(
+ (row) => !edits?.has(row.pubkey),
+ );
+ 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.reading.get(origin) === binding) this.reading.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;
+ const { pubkey } = identity;
+ this.listing.add(pubkey);
+ this.writeListing(origin, binding, {
+ pubkey,
+ name: identity.name,
+ ...(picture ? { picture } : {}),
+ runtime: identity.type,
+ prior: row?.prior,
+ }).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);
+ },
+ );
+ }
+ }
+ /** 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 until the
+ * community's records are first read. */
+ private setRow(origin: string, pubkey: string, row: Listing | null) {
+ const listings = this.listings.get(origin);
+ 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));
+ }
+ /** 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 +1260,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 +1349,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 +1403,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 +1462,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 +1492,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 +1501,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 +1549,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);
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],