diff --git a/apps/x/packages/core/src/agents/registry.test.ts b/apps/x/packages/core/src/agents/registry.test.ts new file mode 100644 index 00000000..dffe0994 --- /dev/null +++ b/apps/x/packages/core/src/agents/registry.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + agentFromRaw, + builtinAgentIds, + hasWorkspaceContext, + loadAgent, +} from "./registry.js"; + +describe("agent registry", () => { + it("knows every historical builtin id, including the rowboatx alias", () => { + expect(builtinAgentIds().sort()).toEqual( + [ + "copilot", + "rowboatx", + "live-note-agent", + "background-task-agent", + "note_creation", + "note_curation", + "labeling_agent", + "note_tagging_agent", + "inline_task_agent", + "agent_notes_agent", + ].sort(), + ); + }); + + it("grants workspace context to the copilot ids only", () => { + expect(hasWorkspaceContext("copilot")).toBe(true); + expect(hasWorkspaceContext("rowboatx")).toBe(true); + expect(hasWorkspaceContext("background-task-agent")).toBe(false); + expect(hasWorkspaceContext("note_creation")).toBe(false); + expect(hasWorkspaceContext("some-user-agent")).toBe(false); + expect(hasWorkspaceContext(null)).toBe(false); + expect(hasWorkspaceContext(undefined)).toBe(false); + }); + + it("loads the prompt-file knowledge agents with frontmatter config applied", async () => { + // These ship as raw strings with YAML frontmatter; the shared loader + // replaces five copy-pasted parsing blocks, so pin its behavior on + // the real bundled prompts. + for (const id of [ + "note_creation", + "note_curation", + "labeling_agent", + "note_tagging_agent", + "inline_task_agent", + "agent_notes_agent", + ]) { + const agent = await loadAgent(id); + expect(agent.name).toBe(id); + expect(agent.instructions.length).toBeGreaterThan(0); + // Frontmatter must be consumed, not left in the instructions. + expect(agent.instructions.startsWith("---")).toBe(false); + } + }); + + it("agentFromRaw parses frontmatter into agent config and strips it", () => { + const raw = [ + "---", + "tools:", + " file-readText:", + " type: builtin", + " name: file-readText", + "---", + "Do the thing.", + ].join("\n"); + const agent = agentFromRaw("tester", raw); + expect(agent.name).toBe("tester"); + expect(agent.instructions).toBe("Do the thing."); + expect(agent.tools).toEqual({ + "file-readText": { type: "builtin", name: "file-readText" }, + }); + }); + + it("agentFromRaw passes frontmatter-less prompts through verbatim", () => { + const agent = agentFromRaw("plain", "Just instructions."); + expect(agent.instructions).toBe("Just instructions."); + expect(agent.tools).toBeUndefined(); + }); +}); diff --git a/apps/x/packages/core/src/agents/registry.ts b/apps/x/packages/core/src/agents/registry.ts new file mode 100644 index 00000000..e8ab223e --- /dev/null +++ b/apps/x/packages/core/src/agents/registry.ts @@ -0,0 +1,96 @@ +import type { z } from "zod"; +import { parse } from "yaml"; +import { Agent } from "@x/shared/dist/agent.js"; +import { buildCopilotAgent } from "../application/assistant/agent.js"; +import { buildBackgroundTaskAgent } from "../background-tasks/agent.js"; +import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js"; +import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js"; +import { getRaw as getNoteCurationRaw } from "../knowledge/note_curation.js"; +import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; +import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; +import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; +import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; +import type { IAgentsRepo } from "./repo.js"; + +// The registry of built-in agents: one table instead of the historical +// if (id === ...) ladder. An entry owns its builder and its traits; traits +// replace stringly-typed id comparisons ("copilot" || "rowboatx") scattered +// across the assembly layer. User-defined agents are the fallthrough, +// fetched from the agents repo. + +export interface AgentTraits { + // Receives workspace context — agent notes and the user work directory — + // composed into its system prompt. + workspaceContext?: boolean; +} + +interface BuiltinAgentDefinition { + build: () => Promise> | z.infer; + traits?: AgentTraits; +} + +// Prompt-file agents: instructions ship as a raw string whose optional YAML +// frontmatter carries agent config (tools, model). One loader replaces the +// five copy-pasted parsing blocks the ladder accumulated. +export function agentFromRaw(id: string, raw: string): z.infer { + let agent: z.infer = { name: id, instructions: raw }; + if (raw.startsWith("---")) { + const end = raw.indexOf("\n---", 3); + if (end !== -1) { + const frontmatter = raw.slice(3, end).trim(); + const content = raw.slice(end + 4).trim(); + const yaml: unknown = parse(frontmatter); + const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); + agent = { ...agent, ...parsed, instructions: content }; + } + } + return agent; +} + +function promptFileAgent(id: string, getRaw: () => string): BuiltinAgentDefinition { + return { build: () => agentFromRaw(id, getRaw()) }; +} + +// "rowboatx" is a legacy alias for the copilot: both ids share one +// definition object. +const COPILOT: BuiltinAgentDefinition = { + build: buildCopilotAgent, + traits: { workspaceContext: true }, +}; + +const builtinAgents: Record = { + copilot: COPILOT, + rowboatx: COPILOT, + "live-note-agent": { build: buildLiveNoteAgent }, + "background-task-agent": { build: buildBackgroundTaskAgent }, + note_creation: promptFileAgent("note_creation", getNoteCreationRaw), + note_curation: promptFileAgent("note_curation", getNoteCurationRaw), + labeling_agent: promptFileAgent("labeling_agent", getLabelingAgentRaw), + note_tagging_agent: promptFileAgent("note_tagging_agent", getNoteTaggingAgentRaw), + inline_task_agent: promptFileAgent("inline_task_agent", getInlineTaskAgentRaw), + agent_notes_agent: promptFileAgent("agent_notes_agent", getAgentNotesAgentRaw), +}; + +export function builtinAgentIds(): string[] { + return Object.keys(builtinAgents); +} + +// Trait lookup for assembly decisions. Unknown/user agents have no traits. +export function hasWorkspaceContext(agentId: string | null | undefined): boolean { + return ( + agentId != null && + builtinAgents[agentId]?.traits?.workspaceContext === true + ); +} + +export async function loadAgent(id: string): Promise> { + const builtin = builtinAgents[id]; + if (builtin) { + return builtin.build(); + } + // User-defined agents. The container is imported lazily so this module + // adds no static edge into the DI graph (mirrors spawn-agent). + const { default: container } = await import("../di/container.js"); + const repo = container.resolve("agentsRepo"); + return repo.fetch(id); +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 95cdd51b..d4a8fa77 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -11,19 +11,15 @@ import { execTool } from "../application/lib/exec-tool.js"; import { TOOL_ADDITIONS_KEY } from "../application/lib/tool-additions.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js"; -import { buildCopilotAgent } from "../application/assistant/agent.js"; -import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js"; -import { buildBackgroundTaskAgent } from "../background-tasks/agent.js"; +import { hasWorkspaceContext, loadAgent } from "./registry.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js"; import { resolveFilePathForPermission } from "../filesystem/files.js"; -import container from "../di/container.js"; import { notifyIfEnabled } from "../application/notification/notifier.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createLanguageModel } from "../models/models.js"; import { chatActivity } from "../application/lib/chat-activity.js"; import { resolveProviderConfig } from "../models/defaults.js"; -import { IAgentsRepo } from "./repo.js"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; import { IMessageQueue, type MiddlePaneContext } from "../application/lib/message-queue.js"; @@ -31,15 +27,8 @@ import { IRunsRepo } from "../runs/repo.js"; import { IRunsLock } from "../runs/lock.js"; import { IAbortRegistry } from "../runs/abort-registry.js"; import { PrefixLogger } from "@x/shared"; -import { parse } from "yaml"; import { captureLlmUsage } from "../analytics/usage.js"; import { enterUseCase, withUseCase, type UseCase } from "../analytics/use_case.js"; -import { getRaw as getNoteCreationRaw } from "../knowledge/note_creation.js"; -import { getRaw as getNoteCurationRaw } from "../knowledge/note_curation.js"; -import { getRaw as getLabelingAgentRaw } from "../knowledge/labeling_agent.js"; -import { getRaw as getNoteTaggingAgentRaw } from "../knowledge/note_tagging_agent.js"; -import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.js"; -import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js"; import { classifyToolPermissions, type AutoPermissionCandidate } from "../security/auto-permission-classifier.js"; const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); @@ -240,10 +229,6 @@ export function loadAgentNotesContext(): string | null { return `# Agent Memory\n\n${sections.join('\n\n')}`; } -function isCopilotLikeAgent(agentName: string | null | undefined): boolean { - return agentName === 'copilot' || agentName === 'rowboatx'; -} - function formatCurrentDateTime(now: Date): string { return now.toLocaleString('en-US', { weekday: 'long', @@ -283,7 +268,7 @@ function buildUserMessageContext({ }): z.infer { return { currentDateTime: formatCurrentDateTime(new Date()), - ...(isCopilotLikeAgent(agentName) + ...(hasWorkspaceContext(agentName) ? { middlePane: toUserMessageContextMiddlePane(middlePaneContext) } : {}), }; @@ -790,148 +775,9 @@ function formatLlmStreamError(rawError: unknown): string { return lines.length ? lines.join("\n") : "Model stream error"; } -export async function loadAgent(id: string): Promise> { - if (id === "copilot" || id === "rowboatx") { - return buildCopilotAgent(); - } - - if (id === "live-note-agent") { - return buildLiveNoteAgent(); - } - - if (id === "background-task-agent") { - return buildBackgroundTaskAgent(); - } - - if (id === 'note_creation' || id === 'note_curation') { - const raw = id === 'note_curation' ? getNoteCurationRaw() : getNoteCreationRaw(); - let agent: z.infer = { - name: id, - instructions: raw, - }; - - // Parse frontmatter if present - if (raw.startsWith("---")) { - const end = raw.indexOf("\n---", 3); - if (end !== -1) { - const fm = raw.slice(3, end).trim(); - const content = raw.slice(end + 4).trim(); - const yaml = parse(fm); - const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } - } - - return agent; - } - - if (id === 'labeling_agent') { - const labelingAgentRaw = getLabelingAgentRaw(); - let agent: z.infer = { - name: id, - instructions: labelingAgentRaw, - }; - - if (labelingAgentRaw.startsWith("---")) { - const end = labelingAgentRaw.indexOf("\n---", 3); - if (end !== -1) { - const fm = labelingAgentRaw.slice(3, end).trim(); - const content = labelingAgentRaw.slice(end + 4).trim(); - const yaml = parse(fm); - const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } - } - - return agent; - } - - if (id === 'note_tagging_agent') { - const noteTaggingAgentRaw = getNoteTaggingAgentRaw(); - let agent: z.infer = { - name: id, - instructions: noteTaggingAgentRaw, - }; - - if (noteTaggingAgentRaw.startsWith("---")) { - const end = noteTaggingAgentRaw.indexOf("\n---", 3); - if (end !== -1) { - const fm = noteTaggingAgentRaw.slice(3, end).trim(); - const content = noteTaggingAgentRaw.slice(end + 4).trim(); - const yaml = parse(fm); - const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } - } - - return agent; - } - - if (id === 'inline_task_agent') { - const inlineTaskAgentRaw = getInlineTaskAgentRaw(); - let agent: z.infer = { - name: id, - instructions: inlineTaskAgentRaw, - }; - - if (inlineTaskAgentRaw.startsWith("---")) { - const end = inlineTaskAgentRaw.indexOf("\n---", 3); - if (end !== -1) { - const fm = inlineTaskAgentRaw.slice(3, end).trim(); - const content = inlineTaskAgentRaw.slice(end + 4).trim(); - const yaml = parse(fm); - const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } - } - - return agent; - } - - if (id === 'agent_notes_agent') { - const agentNotesAgentRaw = getAgentNotesAgentRaw(); - let agent: z.infer = { - name: id, - instructions: agentNotesAgentRaw, - }; - - if (agentNotesAgentRaw.startsWith("---")) { - const end = agentNotesAgentRaw.indexOf("\n---", 3); - if (end !== -1) { - const fm = agentNotesAgentRaw.slice(3, end).trim(); - const content = agentNotesAgentRaw.slice(end + 4).trim(); - const yaml = parse(fm); - const parsed = Agent.omit({ name: true, instructions: true }).parse(yaml); - agent = { - ...agent, - ...parsed, - instructions: content, - }; - } - } - - return agent; - } - - const repo = container.resolve('agentsRepo'); - return await repo.fetch(id); -} +// Agent identity now lives in the registry (registry.ts); re-exported here +// so legacy callers keep their import path until the runs engine dies. +export { loadAgent }; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -1604,7 +1450,7 @@ export async function* streamAgent({ loopLogger.log('running llm turn'); // stream agent response and build message const messageBuilder = new StreamStepMessageBuilder(); - const composeCopilotContext = state.agentName === 'copilot' || state.agentName === 'rowboatx'; + const composeCopilotContext = hasWorkspaceContext(state.agentName); const instructionsWithDateTime = composeSystemInstructions({ instructions: agent.instructions, agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null, diff --git a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts index a2cdaf6f..7450d51a 100644 --- a/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts +++ b/apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts @@ -8,10 +8,10 @@ import { } from "@x/shared/dist/turns.js"; import { composeSystemInstructions, - loadAgent, loadAgentNotesContext, loadUserWorkDir, } from "../../agents/runtime.js"; +import { hasWorkspaceContext, loadAgent } from "../../agents/registry.js"; import { BuiltinTools } from "../../application/lib/builtin-tools.js"; import { skillToolNames } from "../../application/assistant/skills/index.js"; import { getDefaultModelAndProvider } from "../../models/defaults.js"; @@ -124,10 +124,9 @@ export class RealAgentResolver { requested.overrides?.composition ?? {}, ); const composition = parsed.success ? parsed.data : {}; - // Agent notes and work-dir context are copilot-scoped, matching the - // old runtime's behavior. - const copilotContext = - requested.agentId === "copilot" || requested.agentId === "rowboatx"; + // Agent notes and work-dir context are scoped to agents with the + // workspaceContext trait (the copilot), per the agent registry. + const copilotContext = hasWorkspaceContext(requested.agentId); const systemPrompt = composeSystemInstructions({ instructions: agent.instructions, agentNotesContext: copilotContext ? this.loadNotes() : null,