From 8cf940816c512e220961611b5cb755160fd272a7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 11:07:09 -0400 Subject: [PATCH 1/2] refactor(agents): give screens a type's config functions through present defaults, importShared, adopt, carry, describeMigration, migrationWarning and summary depend only on config. Each harness now keeps them in one typeConfig object that both its entries pass, so the screens' side has them where the service does not run. The retype preview becomes retypePreview, a pure function of an agent's config and the two types, which the service still uses. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- docs/plugin-architecture.md | 6 ++ src/bundled/claude-code/index.ts | 62 ++++++++------- src/bundled/codex/index.ts | 90 ++++++++++++---------- src/bundled/goose/index.ts | 58 ++++++++------ src/bundled/pi/index.ts | 42 +++++----- src/features/agents/service.test.ts | 39 ++++++++++ src/features/agents/service.ts | 114 +++++++++++++++++----------- 7 files changed, 258 insertions(+), 153 deletions(-) diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index c7d2b32e89..8ba9eb02d0 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -253,6 +253,12 @@ handles its UI reaches of the runtime. Each is JSON, a store (`snapshot` and `subscribe`) or a call returning a promise, so it can later cross to another page. Claude is the first harness split this way. +The type's functions of config alone (`defaults`, `importShared`, `adopt`, +`carry`, `describeMigration`, `migrationWarning` and `summary`) go to both +calls. The service needs them to create and retype agents, and screens call +them while rendering. `retypePreview` computes a type change's preview from +them, so a screen can show it where the service does not run. + A plugin, bundled or installed, can add a tool to every agent session in the app with `ctx.agents.addTool({ name, description, inputSchema, call })`. `call` gets the agent's Buzz client and where it is working, so the tool acts as the diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 00ec7e50a0..a1ed09c6a3 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -5,6 +5,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; +import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; import { claudeRun, TYPE_ID } from "./run"; @@ -25,6 +26,38 @@ const supported = () => isTauri() && /Mac|Linux/i.test(navigator.platform); /** What the type's UI reaches of its runtime. */ export type ClaudeExposed = Parameters[0]; +/** The type's pure config functions: the service needs them to create and + * retype agents, and screens to show them. */ +const typeConfig = { + defaults: () => ({ config: DEFAULT_CONFIG }), + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + describeMigration: (saved) => { + const { model, effort, scope } = config(saved); + return [ + { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, + { id: "thinking", label: "Thinking", isOverride: Boolean(effort) }, + { + id: "sessions", + label: "Sessions", + isOverride: scope !== DEFAULT_CONFIG.scope, + unchangedKey: scope, + }, + ]; + }, + summary: (agent) => config(agent.config).model || "Claude", +} satisfies AgentTypeConfig; + export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { if (!supported()) return; @@ -38,6 +71,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ActivityTab, } = createTabs(exposed); return { + ...typeConfig, icon: ClaudeLogoIcon, Create, CreateModel, @@ -122,38 +156,12 @@ function applyAgent(ctx: Parameters[0]) { title: "Claude", description: "Claude on this computer. Each thread it is mentioned in is one continuing session.", - defaults: () => ({ config: DEFAULT_CONFIG }), + ...typeConfig, expose: { setup, defaults: providerDefaults, runtime, } satisfies ClaudeExposed, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - describeMigration: (saved) => { - const { model, effort, scope } = config(saved); - return [ - { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, - { id: "thinking", label: "Thinking", isOverride: Boolean(effort) }, - { - id: "sessions", - label: "Sessions", - isOverride: scope !== DEFAULT_CONFIG.scope, - unchangedKey: scope, - }, - ]; - }, - summary: (agent) => config(agent.config).model || "Claude", ...(spawn && readFile && { usage: (agent, { since, signal }) => diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 4db1e8585f..8045d405e4 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -2,6 +2,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; +import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { CodexLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, defaults, validWorkspace } from "./config"; @@ -24,6 +25,52 @@ type CodexExposed = Readonly<{ setup: Parameters[3]; }>; +/** The type's pure config functions: the service needs them to create and + * retype agents, and screens to show them. */ +const typeConfig = { + defaults: () => ({ config: { ...defaults } }), + importShared: (source) => + source.runtime === "codex" && + (!source.provider || source.provider === "openai") + ? { ...defaults, model: source.model } + : undefined, + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + // Codex runs only in an absolute or home workspace. + workspace: + carry.workspace && validWorkspace(carry.workspace) + ? carry.workspace + : saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== defaults.workspace && { workspace }), + }; + }, + describeMigration: (saved) => { + const { model, effort, scope } = config(saved); + return [ + { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, + { + id: "thinking", + label: "Thinking", + isOverride: Boolean(effort), + }, + { + id: "sessions", + label: "Sessions", + isOverride: scope !== defaults.scope, + unchangedKey: scope, + }, + ]; + }, + migrationWarning: "Any commands Codex left running will stop.", + summary: (agent) => config(agent.config).model || "Codex", +} satisfies AgentTypeConfig; + export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { if (!supported()) return; @@ -39,6 +86,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ActivityTab, } = createTabs(provider, runtime, transcript, setup); return { + ...typeConfig, icon: CodexLogoIcon, Create, CreateModel, @@ -147,53 +195,13 @@ function applyAgent(ctx: Parameters[0]) { title: "Codex", description: "Codex on this computer, using its native coding tools and your account.", - defaults: () => ({ config: { ...defaults } }), + ...typeConfig, expose: { provider: providerController, runtime, transcript: transcripts(start, runtime), setup, } satisfies CodexExposed, - importShared: (source) => - source.runtime === "codex" && - (!source.provider || source.provider === "openai") - ? { ...defaults, model: source.model } - : undefined, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - // Codex runs only in an absolute or home workspace. - workspace: - carry.workspace && validWorkspace(carry.workspace) - ? carry.workspace - : saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== defaults.workspace && { workspace }), - }; - }, - describeMigration: (saved) => { - const { model, effort, scope } = config(saved); - return [ - { id: "model", label: "Model", isOverride: Boolean(model.trim()) }, - { - id: "thinking", - label: "Thinking", - isOverride: Boolean(effort), - }, - { - id: "sessions", - label: "Sessions", - isOverride: scope !== defaults.scope, - unchangedKey: scope, - }, - ]; - }, - migrationWarning: "Any commands Codex left running will stop.", - summary: (agent) => config(agent.config).model || "Codex", ...(spawn && readFile && { usage: (agent, { since, signal }) => diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index 55f7008cb0..1585942e55 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -4,6 +4,7 @@ import { repositoriesContext } from "../../features/communities/repositories"; // in it; goose answers with the in-process Buzz tools, served to it over // loopback HTTP MCP. import { isTauri } from "@tauri-apps/api/core"; +import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { GooseLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; @@ -18,6 +19,36 @@ export const TYPE_ID = "goose"; /** What the type's UI reaches of its runtime. */ type GooseExposed = Parameters[0]; +/** The type's pure config functions: the service needs them to create and + * retype agents, and screens to show them. */ +const typeConfig = { + defaults: () => ({ config: DEFAULT_CONFIG }), + importShared: (source) => + source.runtime === "goose" || source.runtime === "goose-ai" + ? { + ...DEFAULT_CONFIG, + model: source.model, + provider: source.provider ?? "", + } + : undefined, + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Goose"; + }, +} satisfies AgentTypeConfig; + export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start `goose-acp`; no unusable type elsewhere. @@ -32,6 +63,7 @@ export const apply: PluginModule["apply"] = (ctx) => { ProviderDialogBody, } = createTabs(exposed); return { + ...typeConfig, icon: GooseLogoIcon, Create, CreateModel, @@ -100,37 +132,13 @@ function applyAgent(ctx: Parameters[0]) { title: "Goose", description: "goose on this computer, with any provider goose supports. Each thread it is mentioned in is one continuing session.", - defaults: () => ({ config: DEFAULT_CONFIG }), - importShared: (source) => - source.runtime === "goose" || source.runtime === "goose-ai" - ? { - ...DEFAULT_CONFIG, - model: source.model, - provider: source.provider ?? "", - } - : undefined, + ...typeConfig, expose: { setup, runtime, history } satisfies GooseExposed, provider: { snapshot: setup.snapshot, subscribe: setup.subscribe, check: setup.check, }, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - summary: (agent) => { - const { model, provider } = config(agent.config); - return [provider, model].filter(Boolean).join(" · ") || "Goose"; - }, sessions: { list: (key) => { const live = runtime.sessions(key); diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts index b816450be9..6ff9492f2a 100644 --- a/src/bundled/pi/index.ts +++ b/src/bundled/pi/index.ts @@ -6,6 +6,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { pubkeyOf } from "../../features/agents/membership"; import { ownerReads } from "../../features/agents/reads"; import { repositoriesContext } from "../../features/communities/repositories"; +import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { PiLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; @@ -19,6 +20,28 @@ const HANDOVER_MS = 2 * 60_000; /** What the type's UI reaches of its runtime. */ type PiExposed = Parameters[0]; +/** The type's pure config functions: the service needs them to create and + * retype agents, and screens to show them. */ +const typeConfig = { + defaults: () => ({ config: DEFAULT_CONFIG }), + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Pi"; + }, +} satisfies AgentTypeConfig; + export const inject = ["agents"]; export const apply: PluginModule["apply"] = (ctx) => { // Only the desktop app can start the Pi host; no unusable type elsewhere. @@ -27,6 +50,7 @@ export const apply: PluginModule["apply"] = (ctx) => { const { ActivityTab, Create, CreateModel, SettingsTab } = createTabs(exposed); return { + ...typeConfig, icon: PiLogoIcon, Create, CreateModel, @@ -85,24 +109,8 @@ function applyAgent(ctx: Parameters[0]) { title: "Pi", description: "Pi on this computer, with any provider Pi supports. Each thread it is mentioned in is one continuing conversation.", - defaults: () => ({ config: DEFAULT_CONFIG }), + ...typeConfig, expose: { setup: runtime.setup, runtime } satisfies PiExposed, - adopt: (carry, saved) => ({ - ...saved, - instructions: carry.instructions ?? saved.instructions, - workspace: carry.workspace ?? saved.workspace, - }), - carry: (saved) => { - const { instructions, workspace } = config(saved); - return { - ...(instructions.trim() && { instructions }), - ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), - }; - }, - summary: (agent) => { - const { model, provider } = config(agent.config); - return [provider, model].filter(Boolean).join(" · ") || "Pi"; - }, ...(host.spawn && { usage: (agent, { since, signal }) => runtime.usage(agent.key, since, signal), diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index c607a2eb3f..66b8b87630 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -24,8 +24,11 @@ import { type Agent, AgentsService, type AgentType, + type Carry, type ConfigBinding, type Delivery, + type RegisteredAgentType, + retypePreview, type Trigger, fromOwner, teamPrompt, @@ -309,6 +312,42 @@ it("shows a type with what its plugin presents, from the handles the type expose expect(shown).toHaveBeenCalledOnce(); }); +it("takes a type's config functions from what it presents, for screens where the service does not run", async () => { + const { service, plugin } = await setup(); + const summary = (agent: Agent) => `Shown ${agent.name}`; + const config = { + defaults: () => ({ config: { model: "m" } }), + carry: (saved: unknown): Carry => { + const { notes } = saved as { notes?: string }; + return notes ? { instructions: notes } : {}; + }, + adopt: (carry: Carry, saved: unknown) => ({ + ...(saved as object), + notes: carry.instructions, + }), + summary, + }; + // Here only the screens' side passes them; a type passes them to both. + plugin.agents.register({ + id: "shown", + title: "Shown", + defaults: config.defaults, + }); + plugin.agents.present("shown", () => config); + const type = service.types().find((t) => t.key === "example/shown"); + expect(type?.summary).toBe(summary); + expect(type?.carry).toBe(config.carry); + // The preview a screen computes is the service's. + expect( + retypePreview({ notes: "Keep" }, type, type as RegisteredAgentType), + ).toEqual({ + config: { model: "m", notes: "Keep" }, + moved: ["instructions"], + dropped: [], + settings: [], + }); +}); + it("still runs a type whose present throws", async () => { const { service, plugin } = await setup(); const error = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index cdfc7005d6..98f4e26e3a 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -716,14 +716,40 @@ export type AgentType = { }; /** What an agent type shows, from its plugin's UI entry (`present`); the * rest of the type comes from its agent entry (`register`). */ +/** A type's functions of config alone. Both its entries pass the same ones, + * so screens can call them where the service does not run. */ +export type AgentTypeConfig = Pick< + AgentType, + | "defaults" + | "importShared" + | "adopt" + | "carry" + | "describeMigration" + | "migrationWarning" + | "summary" +>; export type AgentPresentation = Pick< AgentType, "icon" | "Peek" | "Create" | "CreateModel" | "tabs" > & + Partial> & Readonly<{ provider?: Pick; }>; -const PRESENTED = ["icon", "Peek", "Create", "CreateModel", "tabs"] as const; +const PRESENTED = [ + "icon", + "Peek", + "Create", + "CreateModel", + "tabs", + "defaults", + "importShared", + "adopt", + "carry", + "describeMigration", + "migrationWarning", + "summary", +] as const; /** The type as its readers see it: `type` with what `present` shows. */ export function presented( type: AgentType, @@ -823,6 +849,48 @@ export type RetypePreview = Readonly<{ }>[]; }>; /** An unsaved creation request. Only the owner-facing review may approve it. */ +/** What changing an agent with `config` from `source` to `kind` keeps, moves + * and drops. It calls only the types' config functions, so screens can show + * it where the service does not run. */ +export function retypePreview( + config: unknown, + source: Pick | undefined, + kind: Pick, +): RetypePreview { + const carry = source?.carry?.(config) ?? {}; + const { config: fresh } = kind.defaults(); + const adopted = kind.adopt ? kind.adopt(carry, fresh) : fresh; + const kept = kind.carry?.(adopted) ?? {}; + const keys = (Object.keys(carry) as (keyof Carry)[]).filter( + (key) => carry[key] !== undefined, + ); + const moved = keys.filter((key) => kept[key] === carry[key]); + const dropped = keys.filter((key) => kept[key] !== carry[key]); + const destinationSettings = new Map( + kind.describeMigration?.(adopted).map((setting) => [setting.id, setting]), + ); + const settings = + source + ?.describeMigration?.(config) + .filter((setting) => setting.isOverride) + .flatMap((setting) => { + const next = destinationSettings.get(setting.id); + if ( + next && + setting.unchangedKey !== undefined && + setting.unchangedKey === next.unchangedKey + ) + return []; + return [ + { + id: setting.id, + label: setting.label, + outcome: next ? ("reset" as const) : ("dropped" as const), + }, + ]; + }) ?? []; + return { config: adopted, moved, dropped, settings }; +} export type AgentCreationRequest = Readonly<{ id: string; requester: string; @@ -3859,48 +3927,8 @@ export class AgentsService extends Service implements Agents { kind: RegisteredAgentType, ) { const record = this.settingsOf(identity); - const carry = source?.carry?.(record.config) ?? {}; - const { config } = kind.defaults(); - const adopted = kind.adopt ? kind.adopt(carry, config) : config; - const kept = kind.carry?.(adopted) ?? {}; - const keys = (Object.keys(carry) as (keyof Carry)[]).filter( - (key) => carry[key] !== undefined, - ); - const moved = keys.filter((key) => kept[key] === carry[key]); - const dropped = keys.filter((key) => kept[key] !== carry[key]); - const destinationSettings = new Map( - kind.describeMigration?.(adopted).map((setting) => [setting.id, setting]), - ); - const settings = - source - ?.describeMigration?.(record.config) - .filter((setting) => setting.isOverride) - .flatMap((setting) => { - const next = destinationSettings.get(setting.id); - if ( - next && - setting.unchangedKey !== undefined && - setting.unchangedKey === next.unchangedKey - ) - return []; - return [ - { - id: setting.id, - label: setting.label, - outcome: next ? ("reset" as const) : ("dropped" as const), - }, - ]; - }) ?? []; - return { - record, - adopted, - preview: { - config: adopted, - moved, - dropped, - settings, - }, - }; + const preview = retypePreview(record.config, source, kind); + return { record, adopted: preview.config, preview }; } private view(identity: Member, record: AgentRecord): Agent { return Object.freeze({ From 5ae7f7d4a098037e40c6d061ba7916ac0db7ec73 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 27 Oct 2026 11:44:32 -0400 Subject: [PATCH 2/2] docs(agents): put moved doc comments back on their declarations Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- src/features/agents/service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 98f4e26e3a..ebb4a1e755 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -714,8 +714,6 @@ export type AgentType = { * so that agents can later run in another page than their UI. */ expose?: unknown; }; -/** What an agent type shows, from its plugin's UI entry (`present`); the - * rest of the type comes from its agent entry (`register`). */ /** A type's functions of config alone. Both its entries pass the same ones, * so screens can call them where the service does not run. */ export type AgentTypeConfig = Pick< @@ -728,6 +726,8 @@ export type AgentTypeConfig = Pick< | "migrationWarning" | "summary" >; +/** What an agent type shows, from its plugin's UI entry (`present`); the + * rest of the type comes from its agent entry (`register`). */ export type AgentPresentation = Pick< AgentType, "icon" | "Peek" | "Create" | "CreateModel" | "tabs" @@ -848,7 +848,6 @@ export type RetypePreview = Readonly<{ outcome: "reset" | "dropped"; }>[]; }>; -/** An unsaved creation request. Only the owner-facing review may approve it. */ /** What changing an agent with `config` from `source` to `kind` keeps, moves * and drops. It calls only the types' config functions, so screens can show * it where the service does not run. */ @@ -891,6 +890,7 @@ export function retypePreview( }) ?? []; return { config: adopted, moved, dropped, settings }; } +/** An unsaved creation request. Only the owner-facing review may approve it. */ export type AgentCreationRequest = Readonly<{ id: string; requester: string;