mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
20775d2a9c
commit
b3330c09c4
4 changed files with 186 additions and 165 deletions
80
apps/x/packages/core/src/agents/registry.test.ts
Normal file
80
apps/x/packages/core/src/agents/registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
96
apps/x/packages/core/src/agents/registry.ts
Normal file
96
apps/x/packages/core/src/agents/registry.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue