refactor(x): agent registry — one table instead of the loadAgent ladder

Built-in agent identity moves to agents/registry.ts: a table of
definitions with builders, an alias (rowboatx → copilot) expressed as
shared entries, and declared traits. One prompt-file loader replaces
the five copy-pasted frontmatter-parsing blocks, and the stringly
"copilot" || "rowboatx" comparisons in the resolver and the legacy
runtime become hasWorkspaceContext(agentId) trait lookups. loadAgent
keeps its signature and its legacy import path via re-export; the
user-agents repo fallback resolves the container lazily so the
registry adds no static edge into the DI graph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-09 22:31:02 +05:30
parent 20775d2a9c
commit b3330c09c4
4 changed files with 186 additions and 165 deletions

View file

@ -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();
});
});

View file

@ -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<typeof Agent>> | z.infer<typeof Agent>;
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<typeof Agent> {
let agent: z.infer<typeof Agent> = { 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<string, BuiltinAgentDefinition> = {
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<z.infer<typeof Agent>> {
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<IAgentsRepo>("agentsRepo");
return repo.fetch(id);
}

View file

@ -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<typeof UserMessageContext> {
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<z.infer<typeof Agent>> {
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<typeof Agent> = {
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<typeof Agent> = {
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<typeof Agent> = {
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<typeof Agent> = {
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<typeof Agent> = {
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<IAgentsRepo>('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,

View file

@ -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,