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..ebb4a1e755 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -714,16 +714,42 @@ export type AgentType = { * so that agents can later run in another page than their UI. */ expose?: unknown; }; +/** 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" +>; /** 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" > & + 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, @@ -822,6 +848,48 @@ export type RetypePreview = Readonly<{ outcome: "reset" | "dropped"; }>[]; }>; +/** 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 }; +} /** An unsaved creation request. Only the owner-facing review may approve it. */ export type AgentCreationRequest = Readonly<{ id: 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({