diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index ed9c3486c1..36eec28e09 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -366,6 +366,21 @@ active registered types appear there; plugin activation remains owned by Settings → Plugins → Manage plugins. Provider defaults are versioned device preferences, not agent-record migrations; blank per-agent values inherit them. +An installed harness plugin gets the Buzz side of each turn from +`ctx.agents.harness`, as the bundled harnesses do it, and keeps its own +process, sessions and saved state. `conversation(delivery, scope)` admits a +mention or wake (undefined means the harness ignores it: not the owner's own +or attested by them, or with no conversation to go to) and freezes where +it goes. `systemPrompt` and `turnPrompt` give Buzz's prompts, `turnPrompt` +showing what the session has not seen and taking plugins' notes. Its +`cursor` is opaque: save it with the session and give it to the next +`turnPrompt`, which keeps a mark for each thread of a channel conversation. +`reply` posts as the agent there. `tools(agent, conversation, { read })` +answers the Buzz tools over MCP for `ctx.host.serveMcp`, so the manifest +declares `host.mcp`; `read` is the plugin's own file access (`host.files`), +which uploads of local files use. The kit grants nothing `ctx.agents` and +`ctx.host` do not already give the plugin. + A plugin, bundled or installed, can add a tab to the settings of agents running on this device, whatever their type, with `ctx.agents.registerTab({ id, title, component, when, subscribe })`. The component receives diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index dd11513390..6f8f2024e5 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -400,6 +400,7 @@ function setup( addedTabs: () => [], registerTab: () => {}, controlAttention: () => {}, + harness: {} as Agents["harness"], } satisfies Agents; const disconnected = { status: "disconnected", generation: 0 }; const relay = { diff --git a/src/features/agent-harness/kit.test.ts b/src/features/agent-harness/kit.test.ts new file mode 100644 index 0000000000..1b0da28f0c --- /dev/null +++ b/src/features/agent-harness/kit.test.ts @@ -0,0 +1,455 @@ +import { describe, expect, it, vi } from "vitest"; +import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; +import type { AgentHandle, Delivery } from "../agents/service"; +import type { EventData, RelayEvent } from "../relay/events"; +import type { RelayData } from "../relay/service"; +import { harnessKit } from "./kit"; + +const self = "c".repeat(64); +const alice = "a".repeat(64); +const mallory = "f".repeat(64); +const channel = "chan-1"; + +const message = ( + id: string, + content: string, + extra: Partial = {}, +): RelayEvent => + ({ + id: id.repeat(64).slice(0, 64), + pubkey: alice, + kind: 9, + created_at: 100 + Number(id), + content, + tags: [ + ["h", channel], + ["p", self], + ], + sig: "", + ...extra, + }) as RelayEvent; +const reply = (id: string, root: string, content: string, pubkey = alice) => + message(id, content, { + pubkey, + tags: [ + ["h", channel], + ["e", root.repeat(64).slice(0, 64), "", "reply"], + ], + }); + +function setup( + options: Readonly<{ + type?: "stream" | "dm" | "solo"; + name?: string; + thread?: readonly EventData[]; + core?: string | null; + notes?: readonly string[]; + }> = {}, +) { + const read = vi.fn( + async (filters: readonly { ids?: string[]; "#e"?: string[] }[]) => { + const root = filters[0]?.ids?.[0]; + return (options.thread ?? []).filter( + (event) => + !root || + event.id === root || + event.tags.some((tag) => tag[0] === "e" && tag[1] === root), + ); + }, + ); + const session = { + read, + channels: { + list: () => ({ + status: "ready", + channels: [ + { + id: channel, + ...(options.name === "" ? {} : { name: options.name ?? "general" }), + channelType: options.type ?? "stream", + members: [], + }, + ], + }), + }, + names: { resolve: (pubkey: string) => (pubkey === alice ? "Alice" : "") }, + profiles: { ensure: vi.fn(async () => {}), snapshot: () => new Map() }, + channelWrites: { startingCanvas: () => undefined }, + canvas: { available: false, read: vi.fn(async () => null) }, + agentMemories: { open: vi.fn() }, + }; + const relay = { + snapshot: () => ({ + status: "ready", + session, + viewer: alice, + scope: `https://kit.example:${alice}`, + }), + } as unknown as RelayData; + const kit = harnessKit({ + relay: () => relay, + core: async () => options.core, + added: () => [], + repositories: (partition) => ``, + }); + const published: { kind: number; content: string; tags: string[][] }[] = []; + const restored: unknown[][] = []; + const agent = { + key: self, + pubkey: self, + name: "Ampy", + owner: alice, + publish: vi.fn(async (template: (typeof published)[number]) => { + published.push(template); + return template; + }), + query: vi.fn(async () => []), + notes: vi.fn(() => [...(options.notes ?? [])]), + restoreNotes: vi.fn((...args: unknown[]) => void restored.push(args)), + } as unknown as AgentHandle; + const delivery = ( + trigger: Delivery["trigger"], + extra: Partial = {}, + ) => + ({ + trigger, + channelId: channel, + agent, + config: {}, + signal: new AbortController().signal, + workingUntil: () => undefined, + current: () => true, + ...extra, + }) as unknown as Delivery; + return { kit, agent, delivery, published, restored, read }; +} + +describe("harnessKit.conversation", () => { + it("admits the owner's mention, keyed by its thread", async () => { + const { kit, delivery } = setup(); + const event = reply("2", "1", "@Ampy go"); + expect( + await kit.conversation(delivery({ type: "mention", event }), "thread"), + ).toEqual({ + key: `${channel}/${"1".repeat(64)}`, + channel, + root: "1".repeat(64), + topLevel: false, + where: "#general", + }); + }); + + it("gives a top-level mention its own thread, or the channel when scoped so", async () => { + const { kit, delivery } = setup(); + const event = message("3", "@Ampy go"); + const trigger = { type: "mention", event } as const; + expect((await kit.conversation(delivery(trigger), "thread"))?.key).toBe( + `${channel}/${event.id}`, + ); + const whole = await kit.conversation(delivery(trigger), "channel"); + expect(whole).toMatchObject({ key: channel, topLevel: false }); + expect(whole?.root).toBeUndefined(); + }); + + it("keeps a DM one conversation, and a Solo chat one at the top level", async () => { + const event = reply("2", "1", "go"); + const dm = setup({ type: "dm", name: "" }); + expect( + await dm.kit.conversation( + dm.delivery({ type: "mention", event }), + "thread", + ), + ).toMatchObject({ key: channel, topLevel: false, where: "DM" }); + const solo = setup({ type: "solo" }); + expect( + await solo.kit.conversation( + solo.delivery({ type: "mention", event }), + "thread", + ), + ).toMatchObject({ key: channel, topLevel: true }); + }); + + it("refuses a mention from anyone else, and triggers that are not its", async () => { + const { kit, delivery } = setup(); + const event = message("2", "@Ampy rm -rf", { pubkey: mallory }); + expect( + await kit.conversation(delivery({ type: "mention", event }), "thread"), + ).toBeUndefined(); + const workflow = message("3", "@Ampy go", { + tags: [ + ["h", channel], + ["buzz:workflow", "w"], + ], + }); + expect( + await kit.conversation( + delivery({ type: "mention", event: workflow }), + "thread", + ), + ).toBeUndefined(); + expect( + await kit.conversation( + delivery({ + type: "timer", + slug: "t", + timer: {} as never, + spent: false, + }), + "thread", + ), + ).toBeUndefined(); + }); + + it("takes a plugin's wake in its thread, and drops one with nowhere to go", async () => { + const { kit, delivery } = setup(); + const wake = { + type: "plugin", + plugin: "example", + channel, + text: "CI passed", + id: "w".repeat(64), + at: 200_000, + } as const; + expect( + await kit.conversation( + delivery({ ...wake, root: "1".repeat(64) }), + "thread", + ), + ).toMatchObject({ key: `${channel}/${"1".repeat(64)}`, topLevel: false }); + expect(await kit.conversation(delivery(wake), "thread")).toBeUndefined(); + }); +}); + +describe("harnessKit.turnPrompt", () => { + it("shows what the session has not seen, takes notes and restores them", async () => { + const root = message("1", "Kick off"); + const earlier = reply("2", "1", "Some context"); + const mine = reply("3", "1", "On it", self); + const event = reply("4", "1", "@Ampy and now?"); + const { kit, delivery, restored } = setup({ + thread: [root, earlier, mine, event], + notes: ["from a plugin"], + }); + const d = delivery({ type: "mention", event }); + const conversation = await kit.conversation(d, "thread"); + if (!conversation) throw new Error("not admitted"); + + const fresh = await kit.turnPrompt(d, conversation); + expect(fresh.text).toContain("Kick off"); + expect(fresh.text).toContain("Some context"); + expect(fresh.text).toContain("On it"); + expect(fresh.text).toContain("@Ampy and now?"); + expect(fresh.text).toContain("from a plugin"); + expect(JSON.parse(fresh.cursor)).toEqual({ seen: event.created_at }); + + const cursor = JSON.stringify({ seen: earlier.created_at }); + const later = await kit.turnPrompt(d, conversation, { cursor }); + expect(later.text).not.toContain("Some context"); + expect(later.text).not.toContain("On it"); + expect(later.text).toContain("@Ampy and now?"); + + later.restore(); + expect(restored).toEqual([ + [channel, ["from a plugin"], "1".repeat(64)], + ]); + const again = await kit.turnPrompt(d, conversation, { cursor }); + expect(again.text).toBe(later.text); + }); + + it("keeps a mark for each thread of a channel conversation", async () => { + const rootA = message("1", "Thread A"); + const rootB = message("2", "Thread B"); + const contextB = reply("3", "2", "Context in B"); + const mentionA = reply("7", "1", "@Ampy in A"); + const mentionB = reply("8", "2", "@Ampy in B"); + const laterB = reply("9", "2", "@Ampy again in B"); + const { kit, delivery } = setup({ + thread: [rootA, rootB, contextB, mentionA, mentionB, laterB], + }); + const turn = async (event: RelayEvent, cursor?: string) => { + const d = delivery({ type: "mention", event }); + const conversation = await kit.conversation(d, "channel"); + if (!conversation) throw new Error("not admitted"); + expect(conversation.key).toBe(channel); + return kit.turnPrompt(d, conversation, cursor ? { cursor } : {}); + }; + + const a = await turn(mentionA); + expect(a.text).toContain("Thread A"); + const b = await turn(mentionB, a.cursor); + expect(b.text).toContain("Thread B"); + expect(b.text).toContain("Context in B"); + const again = await turn(laterB, b.cursor); + expect(again.text).not.toContain("Context in B"); + expect(again.text).toContain("@Ampy again in B"); + }); + + it("starts over from a cursor it cannot read", async () => { + const event = reply("2", "1", "@Ampy go"); + const earlier = reply("1", "1", "Earlier"); + const { kit, delivery } = setup({ thread: [earlier, event] }); + const d = delivery({ type: "mention", event }); + const conversation = await kit.conversation(d, "thread"); + if (!conversation) throw new Error("not admitted"); + expect( + (await kit.turnPrompt(d, conversation, { cursor: "{nope" })).text, + ).toContain("Earlier"); + }); + + it("tells a resumed turn to carry on", async () => { + const event = reply("2", "1", "@Ampy go"); + const { kit, delivery } = setup({ thread: [event] }); + const d = delivery({ type: "mention", event, resumed: true }); + const conversation = await kit.conversation(d, "thread"); + if (!conversation) throw new Error("not admitted"); + expect( + ( + await kit.turnPrompt(d, conversation, { + cursor: JSON.stringify({ seen: 150 }), + }) + ).text, + ).toContain(RESUMED_PROMPT); + }); + + it("rejects a conversation it did not admit", async () => { + const { kit, delivery } = setup(); + await expect( + kit.turnPrompt(delivery({ type: "mention", event: message("1", "x") }), { + key: channel, + channel, + topLevel: false, + where: "#general", + }), + ).rejects.toThrow("not admitted"); + }); +}); + +describe("harnessKit.systemPrompt", () => { + it("gives the agent its instructions, team and core memory", async () => { + const { kit, delivery } = setup({ core: "Remember the milk." }); + const text = await kit.systemPrompt( + delivery( + { type: "mention", event: message("1", "x") }, + { team: "Squad" }, + ), + { scope: "thread", cwd: "~/work", instructions: "Be terse." }, + ); + expect(text).toContain("Be terse."); + expect(text).toContain("Squad"); + expect(text).toContain("Remember the milk."); + expect(text).toContain("~/work"); + expect(text).toContain(``); + expect(text).not.toContain(""); + }); + + it("tells a session without tools that its final message is its reply", async () => { + const { kit, delivery } = setup(); + const text = await kit.systemPrompt( + delivery({ type: "mention", event: message("1", "x") }), + { scope: "thread", cwd: "~", instructions: "", tools: false }, + ); + expect(text).toMatch( + /[\s\S]*final message is posted as your reply/, + ); + }); +}); + +describe("harnessKit.reply", () => { + it("replies in the thread, tagging the owner when asked", async () => { + const { kit, delivery, published } = setup(); + const event = reply("2", "1", "@Ampy go"); + const d = delivery({ type: "mention", event }); + const conversation = await kit.conversation(d, "channel"); + if (!conversation) throw new Error("not admitted"); + await kit.reply(d.agent, conversation, "⚠️ failed", { notifyOwner: true }); + expect(published).toEqual([ + { + kind: 9, + content: "⚠️ failed", + tags: [ + ["h", channel], + ["e", "1".repeat(64), "", "reply"], + ["p", alice], + ], + }, + ]); + }); + + it("posts at a Solo chat's top level", async () => { + const { kit, delivery, published } = setup({ type: "solo" }); + const event = reply("2", "1", "go"); + const d = delivery({ type: "mention", event }); + const conversation = await kit.conversation(d, "thread"); + if (!conversation) throw new Error("not admitted"); + await kit.reply(d.agent, conversation, "Done"); + expect(published[0]?.tags).toEqual([["h", channel]]); + }); +}); + +describe("harnessKit.tools", () => { + it("answers MCP with the Buzz tools", async () => { + const { kit, agent } = setup(); + const handler = kit.tools(agent); + const listed = (await handler({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + })) as { result: { tools: { name: string }[] } }; + const names = listed.result.tools.map((tool) => tool.name); + expect(names).toContain("send"); + expect(names).toContain("read"); + const started = (await handler({ + jsonrpc: "2.0", + id: 2, + method: "initialize", + params: {}, + })) as { result: { instructions?: string } }; + expect(started.result.instructions).toContain("Read and act in Buzz"); + }); + + it("answers a message without a jsonrpc field", async () => { + const { kit, agent } = setup(); + const listed = (await kit.tools(agent)({ + id: 7, + method: "tools/list", + })) as { + id: number; + result: { tools: unknown[] }; + }; + expect(listed.id).toBe(7); + expect(listed.result.tools.length).toBeGreaterThan(0); + }); + + it("reads files for uploads only through the plugin's read", async () => { + const { kit, agent, delivery } = setup(); + const event = reply("2", "1", "@Ampy go"); + const conversation = await kit.conversation( + delivery({ type: "mention", event }), + "thread", + ); + const read = vi.fn(async () => { + throw new Error("No such file"); + }); + const call = (handler: ReturnType) => + handler({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "send", + arguments: { files: ["./a.txt"], audience: "everyone" }, + }, + }) as Promise<{ result: { content: { text: string }[] } }>; + const without = await call(kit.tools(agent, conversation)); + expect(JSON.stringify(without)).toContain("cannot read ./a.txt"); + await call(kit.tools(agent, conversation, { read })); + expect(read).toHaveBeenCalledWith("./a.txt"); + }); + + it("standalone prompts carry the agent's routing context", () => { + const { kit, agent } = setup(); + expect(kit.standalonePrompt(agent, "Check CI")).toBe( + `\nScope: standalone\nAgent: ${self}\n\n\nCheck CI`, + ); + }); +}); diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts new file mode 100644 index 0000000000..dac6a8b483 --- /dev/null +++ b/src/features/agent-harness/kit.ts @@ -0,0 +1,474 @@ +// The Buzz side of an agent turn, for a harness a plugin brings: whether a +// delivery is the harness's to act on and which conversation it goes to, the +// prompts the bundled harnesses give, the agent's Buzz tools as an MCP +// handler, and its replies. The harness owns its process, its sessions and +// what it saves; the kit grants nothing a plugin's `agents` and `host` do not +// already give it. +import { appClient } from "../../buzz-mcp/app-client"; +import type { Context } from "../../buzz-mcp/client"; +import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; +import { respond } from "../../buzz-mcp/rpc"; +import { + type AddedTool, + sessionHint, + turnNote, +} from "../../buzz-mcp/session-tools"; +import { + CONTEXT_LIMIT, + systemPrompt, + turnPrompt, +} from "../../bundled/claude-code/prompt"; +import type { McpHandler } from "../host/service"; +import type { AgentKey } from "../agents/membership"; +import { fromOwner } from "../agents/owner-attestation"; +import { ownerReads } from "../agents/reads"; +import type { + AgentHandle, + ConversationWake, + Delivery, +} from "../agents/service"; +import { soloChat } from "../agents/solo-chat"; +import type { EventData } from "../relay/events"; +import type { RelayData } from "../relay/service"; +import { threadReference } from "../relay/thread-reference"; +import { MemoryReads, READ_TIMEOUT_MS } from "./core-memory"; +import { wakeAnchor, wakeLands } from "./plugin-wake"; +import { ownerCanvas, readProject } from "./project"; + +/** Whether each thread is its own conversation, or the whole channel is one. */ +export type HarnessScope = "thread" | "channel"; + +/** Where a delivery's turn goes, frozen for the turn. */ +export type HarnessConversation = Readonly<{ + /** The channel id, or `/` for a thread's own conversation. + * A Solo chat and a DM are always one conversation. */ + key: string; + channel: string; + /** The thread's root, when the conversation is one thread. */ + root?: string; + /** Replies go at the channel's top level, as in a Solo chat. */ + topLevel: boolean; + /** `#name`, `DM` or a short channel id, for titles. */ + where: string; +}>; + +export type HarnessPrompt = Readonly<{ + /** The routing context, the conversation's messages the session has not + * been shown, the event, plugins' notes and the tools' end-of-turn note. */ + text: string; + /** What the session has been shown, once this prompt reaches it: save it + * with the session and give it to the next turn's prompt. Opaque. */ + cursor: string; + /** Puts back the notes the prompt took, for a turn that never started. */ + restore(): void; +}>; + +export type HarnessKit = Readonly<{ + /** The conversation a mention or a wake goes to. Undefined when the + * harness must not act on it: another trigger, a mention that is not the + * owner's own or attested by them (a workflow's never is), no channel, or a + * wake with no conversation to go to. */ + conversation( + delivery: Delivery, + scope: HarnessScope, + ): Promise; + /** Buzz's system prompt for the agent: the base prompt, its session model, + * `instructions`, its teams, its `core` memory and repositories folder. + * `tools: false` is for a session without the Buzz tools, whose final + * message the harness posts as its reply. */ + systemPrompt( + delivery: Delivery, + options: Readonly<{ + scope: HarnessScope; + cwd: string; + instructions: string; + tools?: boolean; + }>, + ): Promise; + /** The turn's prompt, for a session at `cursor`, the one its last prompt + * gave; none for a new session, which is shown the conversation so far. + * Takes the notes plugins left for the turn; after `restore`, asking + * again takes them again. */ + turnPrompt( + delivery: Delivery, + conversation: HarnessConversation, + options?: Readonly<{ cursor?: string }>, + ): Promise; + /** A watch's or timer's turn `text` with its routing context. */ + standalonePrompt(agent: AgentHandle, text: string): string; + /** Answers the agent's Buzz tools over MCP, for `Host.serveMcp`, acting in + * `conversation`, or nowhere by default for a standalone turn. `read` gives + * the file an upload names; without it, uploads of local files fail. */ + tools( + agent: AgentHandle, + conversation?: HarnessConversation, + options?: Readonly<{ read?(path: string): Promise }>, + ): McpHandler; + /** Posts `text` as the agent in the conversation: in the event's thread, or + * at the top level. `notifyOwner` tags the owner, as for a failed turn. */ + reply( + agent: AgentHandle, + conversation: HarnessConversation, + text: string, + options?: Readonly<{ notifyOwner?: boolean }>, + ): Promise; +}>; + +type Sources = Readonly<{ + relay(key: AgentKey): RelayData | undefined; + core(key: AgentKey): Promise; + /** The tools plugins add to every session now. */ + added(): readonly AddedTool[]; + /** The repositories folder section for a relay's partition. */ + repositories(partition: string | undefined): string; +}>; + +/** What the kit keeps of a conversation it admitted. */ +type Admitted = Readonly<{ + event: EventData; + solo: boolean; + dm: boolean; + name?: string; + wake?: ConversationWake; + resumed: boolean; +}>; + +const CHAT = [9, 40002]; +/** Threads a channel conversation's cursor keeps a mark for; forgetting one + * only shows that thread's context again. */ +const THREADS_LIMIT = 200; + +type Cursor = Readonly<{ + seen: number; + threads?: Readonly>; +}>; + +function parseCursor(cursor: string | undefined): Cursor { + try { + const value = cursor ? JSON.parse(cursor) : undefined; + if (typeof value?.seen !== "number") return { seen: 0 }; + const threads = + value.threads && typeof value.threads === "object" + ? Object.fromEntries( + Object.entries(value.threads).filter( + (entry): entry is [string, number] => + typeof entry[1] === "number", + ), + ) + : undefined; + return { seen: value.seen, ...(threads ? { threads } : {}) }; + } catch { + return { seen: 0 }; + } +} + +function advance(cursor: Cursor, newest: number, contextRoot?: string) { + let threads = cursor.threads; + if (contextRoot) { + const { [contextRoot]: previous = 0, ...rest } = threads ?? {}; + threads = Object.fromEntries( + Object.entries({ + ...rest, + [contextRoot]: Math.max(newest, previous), + }).slice(-THREADS_LIMIT), + ); + } + return JSON.stringify({ + seen: Math.max(newest, cursor.seen), + ...(threads ? { threads } : {}), + }); +} + +const NO_TOOLS = ` +This session has no Buzz tools: the tools named above, such as send and read, do not exist here. Your final message is posted as your reply in the conversation, so write it as that reply, and say there what you need from anyone. +`; +const NAMES_TIMEOUT_MS = 2_000; + +export function harnessKit(sources: Sources): HarnessKit { + const admitted = new WeakMap(); + const memoryReads = new MemoryReads(); + const session = (key: AgentKey) => { + const snapshot = sources.relay(key)?.snapshot(); + return snapshot?.status === "ready" ? snapshot.session : undefined; + }; + const summary = (key: AgentKey, channelId: string) => { + const found = session(key); + return ( + found?.channels.list().channels.find((item) => item.id === channelId) ?? + found?.channels.get?.(channelId) + ); + }; + const known = (conversation: HarnessConversation) => { + const found = admitted.get(conversation); + if (!found) + throw new Error("This conversation was not admitted by agents.harness"); + return found; + }; + + return Object.freeze({ + async conversation( + { trigger, agent, channelId, signal }: Delivery, + scope: HarnessScope, + ) { + if ( + trigger.type !== "mention" && + trigger.type !== "attention" && + trigger.type !== "plugin" + ) + return undefined; + // A wake is not a message to check: a plugin's is the owner's installed + // code, and the agent's own handoff comes from a turn already acting + // for the owner. + const wake = trigger.type !== "mention" ? trigger : undefined; + if (trigger.type === "mention") { + if (!(await fromOwner(trigger.event, agent.owner))) return undefined; + signal.throwIfAborted(); + } + const event = + trigger.type === "mention" ? trigger.event : wakeAnchor(trigger); + const channel = + channelId ?? event.tags.find((tag) => tag[0] === "h")?.[1]; + if (!channel) return undefined; + const solo = await soloChat( + () => sources.relay(agent.key), + agent.owner, + channel, + signal, + ); + const topLevel = solo || wake?.topLevel === true; + if (wake && !wakeLands(wake, topLevel)) return undefined; + const found = summary(agent.key, channel); + const dm = found?.channelType === "dm"; + const root = threadReference(event)?.rootId ?? event.id; + const whole = topLevel || dm || scope === "channel"; + const conversation: HarnessConversation = Object.freeze({ + key: whole ? channel : `${channel}/${root}`, + channel, + ...(whole ? {} : { root }), + topLevel, + where: found?.name ? `#${found.name}` : dm ? "DM" : channel.slice(0, 8), + }); + admitted.set(conversation, { + event, + solo, + dm, + ...(found?.name ? { name: found.name } : {}), + ...(wake ? { wake } : {}), + resumed: trigger.type === "mention" && !!trigger.resumed, + }); + return conversation; + }, + + async systemPrompt( + { agent, team }: Delivery, + { scope, cwd, instructions, tools = true }, + ) { + const memory = await sources.core(agent.key); + const text = systemPrompt({ + scope, + cwd, + repositories: sources.repositories( + sources.relay(agent.key)?.snapshot().scope, + ), + agent: { name: agent.name, pubkey: agent.pubkey }, + instructions, + team: team ?? "", + ...(memory === undefined ? {} : { memory }), + }); + return tools ? text : `${text}\n\n${NO_TOOLS}`; + }, + + async turnPrompt( + { agent, wakeText }: Delivery, + conversation, + options = {}, + ) { + const { event, solo, dm, name, wake, resumed } = known(conversation); + const { key, channel: channelId, topLevel } = conversation; + const ready = session(agent.key); + const thread = threadReference(event); + const rootId = thread?.rootId ?? event.id; + const shown = parseCursor(options.cursor); + // Sharing a session does not mean it has seen every thread in the + // channel, so a channel conversation keeps a mark for each thread. + const contextRoot = + key === channelId && !dm && !topLevel ? rootId : undefined; + // A resumed turn's session never saw its thread, its own replies + // included. + const since = resumed + ? 0 + : contextRoot + ? (shown.threads?.[contextRoot] ?? 0) + : shown.seen; + const owner = agent.owner; + // Only a Solo chat's Canvas is handed over. + const reading = solo + ? readProject( + ready + ? () => + ownerCanvas( + ready.canvas, + owner, + channelId, + ready.channelWrites.startingCanvas(channelId), + ) + : undefined, + ) + : Promise.resolve(undefined); + const threadRead = (thread || (resumed && !dm)) && !topLevel; + let earlier: readonly EventData[] = []; + if (ready && (thread || dm || topLevel || resumed)) + try { + const events = await ready.read( + threadRead + ? [ + { ids: [rootId], "#h": [channelId], limit: 1 }, + { + kinds: CHAT, + "#h": [channelId], + "#e": [rootId], + limit: 200, + }, + ] + : [{ kinds: CHAT, "#h": [channelId], limit: CONTEXT_LIMIT + 1 }], + { signal: AbortSignal.timeout(NAMES_TIMEOUT_MS * 2) }, + ); + earlier = events + .filter( + (item) => + CHAT.includes(item.kind) && + item.tags.some( + ([tag, value]) => tag === "h" && value === channelId, + ) && + item.id !== event.id && + (resumed || item.created_at <= event.created_at), + ) + .sort((a, b) => a.created_at - b.created_at); + } catch (error) { + console.warn("A harness could not read the conversation", error); + } + // A session already has its own replies; a new one is shown them too. + const unseen = earlier.filter( + (item) => + item.created_at > since && (!since || item.pubkey !== agent.pubkey), + ); + const pubkeys = [ + ...new Set([ + event.pubkey, + ...unseen.map((item) => item.pubkey), + ...event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1] ?? ""), + ]), + ].filter(Boolean); + if (ready) + await Promise.race([ + ready.profiles.ensure(pubkeys, "foreground").catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, NAMES_TIMEOUT_MS)), + ]); + const members = summary(agent.key, channelId)?.members ?? []; + const project = (await reading)?.section; + const text = turnPrompt({ + agent: agent.key, + event, + scope: key === channelId ? "channel" : "thread", + topLevel, + channelLevel: topLevel && !solo, + channel: { id: channelId, ...(name ? { name } : {}), dm }, + ...(thread ? { thread } : {}), + context: unseen, + total: earlier.length + 1, + label: "@mention", + name: (pubkey) => + ready?.names.resolve(pubkey, undefined, members) ?? + ready?.profiles.snapshot().get(pubkey)?.name ?? + undefined, + ...(project ? { project } : {}), + ...(wake + ? { wake: { ...wake, text: (await wakeText?.()) ?? wake.text } } + : {}), + }); + const noteRoot = conversation.root; + const notes = agent.notes?.(channelId, noteRoot) ?? []; + return { + text: [ + resumed ? `${text}\n\n${RESUMED_PROMPT}` : text, + ...notes, + turnNote(agent), + ] + .filter(Boolean) + .join("\n\n"), + cursor: advance( + shown, + Math.max(event.created_at, ...unseen.map((item) => item.created_at)), + contextRoot, + ), + restore: () => agent.restoreNotes?.(channelId, notes, noteRoot), + }; + }, + + standalonePrompt: (agent: AgentHandle, text: string) => + `\nScope: standalone\nAgent: ${agent.key}\n\n\n${text}`, + + tools(agent, conversation, options = {}) { + const solo = conversation && known(conversation).solo; + const context: Context = !conversation + ? {} + : conversation.topLevel + ? { + channel: conversation.channel, + ...(solo ? { topLevel: true } : {}), + } + : conversation.root + ? { channel: conversation.channel, root: conversation.root } + : { channel: conversation.channel }; + const pubkey = agent.pubkey; + const reads = () => ownerReads(sources.relay(agent.key), pubkey); + return async (message) => { + const ready = session(agent.key); + const client = appClient(agent, { + relayAuthor: ready?.relayAuthor, + signingAuthority: ready?.workflowAuthority, + read: (path) => + options.read + ? options.read(path) + : Promise.reject(new Error(`This agent cannot read ${path}`)), + memories: (slug) => { + const found = reads(); + if (!found) + return Promise.reject(new Error("Buzz is not connected")); + return memoryReads.read( + found, + pubkey, + AbortSignal.timeout(READ_TIMEOUT_MS), + slug, + ); + }, + remembered: () => { + const found = reads(); + if (found) memoryReads.forget(found.connection, pubkey); + }, + }); + const added = sources.added(); + return respond(client, context, message, added, { + instructions: sessionHint(added), + }); + }; + }, + + async reply(agent, conversation, text, options = {}) { + const { event } = known(conversation); + await agent.publish({ + kind: 9, + content: text, + tags: [ + ["h", conversation.channel], + ...(conversation.topLevel + ? [] + : [["e", threadReference(event)?.rootId ?? event.id, "", "reply"]]), + ...(options.notifyOwner ? [["p", agent.owner]] : []), + ], + }); + }, + }); +} diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index ab5926364a..0ccb5d1bf8 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -7,6 +7,8 @@ import { respondExternal } from "../../buzz-mcp/external"; import type { ExternalScope } from "./external-connection"; import { externalMcpEnabled } from "./external-connection"; import { connectMcp } from "../../buzz-mcp/connection"; +import { type HarnessKit, harnessKit } from "../agent-harness/kit"; +import { repositoriesContext } from "../communities/repositories"; import type { HostMcpServer, HostProcess, @@ -1064,6 +1066,10 @@ export type Agents = { addTool(tool: AddedTool): void; /** The tools active plugins add now. A name is only ever one plugin's. */ tools(): readonly AddedTool[]; + /** The Buzz side of a turn for an agent type a plugin brings: where a + * delivery goes, the bundled harnesses' prompts, the agent's Buzz tools as + * an MCP handler, and its replies. */ + readonly harness: HarnessKit; /** Wakes an agent this copy of the app runs in one of its conversations, * as a mention there would, but with no message: the agent is told `text` * comes from this plugin, not its owner, and is data. Throws when this copy does not run the agent or its queue is full; @@ -2446,6 +2452,12 @@ export class AgentsService extends Service implements Agents { }; } tools = () => this.addedTools.snapshot(); + readonly harness = harnessKit({ + relay: (key) => this.relay(key), + core: (key) => this.core(key), + added: () => this.tools(), + repositories: repositoriesContext, + }); wake(key: AgentKey, wake: AgentWake) { const plugin = this.ctx.pluginOwner?.id; if (!plugin) throw new Error("Only a plugin wakes an agent"); diff --git a/src/plugins/author.test.mjs b/src/plugins/author.test.mjs index 9e05251f66..489c6b8cee 100644 --- a/src/plugins/author.test.mjs +++ b/src/plugins/author.test.mjs @@ -33,7 +33,7 @@ test("generated author package in a path with spaces exposes shared message fact join(dir, "consumer.ts"), ` import { createElement } from "react"; -import type { Context, DecisionResult, Host, HostRequestOnceOptions, AddedTool, AddedToolContext, AddedToolReply, BuzzClient, Media, HostByteUpload, HostByteRequest, HostByteResponse, HostSavedBytes, PluginManifest, NamingPolicy, PageSidebarProps, SidebarSectionContribution, SidebarSections, UnreadCapability } from "@buzz/author"; +import type { Context, Delivery, HarnessConversation, McpHandler, StandaloneTurn, TurnResult, DecisionResult, Host, HostRequestOnceOptions, AddedTool, AddedToolContext, AddedToolReply, BuzzClient, Media, HostByteUpload, HostByteRequest, HostByteResponse, HostSavedBytes, PluginManifest, NamingPolicy, PageSidebarProps, SidebarSectionContribution, SidebarSections, UnreadCapability } from "@buzz/author"; export const manifest: PluginManifest = { id: "example.plugin", name: "Example", apiVersion: 1, host: { @@ -81,6 +81,32 @@ export function apply(ctx: Context) { }, }; ctx.agents.addTool(tool); + const { harness } = ctx.agents; + ctx.agents.register({ + id: "example", + title: "Example", + defaults: () => ({ config: {} }), + async run(delivery: Delivery) { + const conversation: HarnessConversation | undefined = await harness.conversation(delivery, "thread"); + if (!conversation) return delivery.ignored?.(); + void [conversation.key, conversation.channel, conversation.root, conversation.topLevel, conversation.where]; + const system: string = await harness.systemPrompt(delivery, { scope: "thread", cwd: "~", instructions: "", tools: false }); + const first = await harness.turnPrompt(delivery, conversation); + const prompt = await harness.turnPrompt(delivery, conversation, { cursor: first.cursor }); + const next: string = prompt.cursor; + const tools: McpHandler = harness.tools(delivery.agent, conversation, { read: async () => new Uint8Array() }); + void host.serveMcp?.(tools); + void [system, prompt.text, next]; + prompt.restore(); + await harness.reply(delivery.agent, conversation, "Done", { notifyOwner: true }); + }, + async runOnce(turn: StandaloneTurn): Promise { + const text = await turn.prompt(); + if (text !== undefined) void harness.standalonePrompt(turn.delivery.agent, text); + void harness.tools(turn.delivery.agent); + return { ok: true }; + }, + }); void host.runCommand("status").then((output: string | null) => void output); void host.request({ url: "https://api.example.com/graphql", diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 0d0dd1d90e..22c2b63662 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -12,6 +12,8 @@ export type { HostByteRequest, HostByteResponse, HostSavedBytes, + HostMcpServer, + McpHandler, } from "../features/host/service"; export type { IdentityNames, @@ -127,10 +129,20 @@ export type { VoiceRecording } from "../features/relay/voice-media"; export type { Agents, AgentAddedTab, + AgentHandle, AgentType, AgentSession, AgentSessions, + Delivery, + StandaloneTurn, + TurnResult, } from "../features/agents/service"; +export type { + HarnessConversation, + HarnessKit, + HarnessPrompt, + HarnessScope, +} from "../features/agent-harness/kit"; export type { ArtifactStorage } from "../features/artifact-storage/service"; export type {