diff --git a/agents/codelayer/src/agent.ts b/agents/codelayer/src/agent.ts index 495b6e3..e5ffe61 100644 --- a/agents/codelayer/src/agent.ts +++ b/agents/codelayer/src/agent.ts @@ -1,5 +1,5 @@ import type { LanguageModel, JSONValue } from 'ai' -import { Agent, doomLoop, tarsPersona, type AgentConfig, type ProviderOptionsFactory, type Tool } from '@humanlayer/agentlayer-core' +import { Agent, doomLoop, tarsPersona, type AgentConfig, type AgentOverrides, type ProviderOptionsFactory, type Tool } from '@humanlayer/agentlayer-core' import { createAgentFilesystemHooks, createAgentSystemPrompt, @@ -39,6 +39,8 @@ export interface CodelayerAgentOptions { skillTool?: Tool additionalTools?: Record> subagentTool?: Tool + /** Per-subagent tool/prompt overrides, keyed by subagent name. Ignored when `subagentTool` is provided. */ + subagentOverrides?: Record providerOptionOverrides?: CodelayerProviderOptionOverrides subagentThinking?: string environment?: CodelayerEnvironmentOptions @@ -331,6 +333,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise skillTool, additionalTools = {}, subagentTool, + subagentOverrides, providerOptionOverrides, subagentThinking = 'low', environment, @@ -363,6 +366,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise providerOptions: subagentProviderOptions, outlineImplementerProviderOptions: providerOptions, systemPromptAdditions: personaPromptAdditions, + subagentOverrides, })) if (rlm) { diff --git a/agents/codelayer/src/coding-subagent-tool.ts b/agents/codelayer/src/coding-subagent-tool.ts index 4aa4b88..8bac98f 100644 --- a/agents/codelayer/src/coding-subagent-tool.ts +++ b/agents/codelayer/src/coding-subagent-tool.ts @@ -1,5 +1,13 @@ import type { LanguageModel } from 'ai' -import { Agent, createSubagentsTool, doomLoop, TodoWriteTool, type AgentConfig, type Tool } from '@humanlayer/agentlayer-core' +import { + Agent, + type AgentConfig, + type AgentOverrides, + createSubagentsTool, + doomLoop, + TodoWriteTool, + type Tool, +} from '@humanlayer/agentlayer-core' import { createWebFetchTool } from '@humanlayer/agentlayer-core' import type { CodeSearchInput } from '@humanlayer/agentlayer-core/interfaces' import { CodeSearchTool } from '@humanlayer/agentlayer-core/interfaces' @@ -59,6 +67,12 @@ export interface CreateCodingSubagentToolOptions stopWhen?: AgentConfig['stopWhen'] providerOptions?: AgentConfig['providerOptions'] outlineImplementerProviderOptions?: AgentConfig['providerOptions'] + /** + * Per-subagent tool/prompt overrides, keyed by subagent `name` (e.g. `rpi:codebase-analyzer`, + * `web-search-researcher`). Each entry is applied with {@link Agent.withOverrides}, granting that + * subagent extra tools + system guidance without replacing the whole subagent tool. + */ + subagentOverrides?: Record } async function fetchExaCodeSearch(input: CodeSearchInput, apiKey: string, timeoutMs: number): Promise { @@ -233,7 +247,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.['general-purpose'] ?? {}) const bashAgent = createBashSpecialistAgent({ model: opts.model, @@ -245,7 +259,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.['bash'] ?? {}) const implementerTools: Record> = family === 'codex' ? { @@ -278,7 +292,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.[IMPLEMENTER_AGENT_NAME] ?? {}) const outlineImplementerAgent = createOutlineImplementerAgent({ model: opts.model, tools: implementerTools, @@ -286,7 +300,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.outlineImplementerProviderOptions ?? opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.[OUTLINE_IMPLEMENTER_AGENT_NAME] ?? {}) const webResearcherTools: Record> = { web_fetch: createWebFetchTool(), @@ -302,7 +316,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.['web-search-researcher'] ?? {}) const libraryResearcherTools: Record> = { web_fetch: createWebFetchTool(), @@ -323,7 +337,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }) + }).withOverrides(opts.subagentOverrides?.['library-researcher'] ?? {}) : undefined const agents: SubAgentConfig[] = [ @@ -363,7 +377,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }), + }).withOverrides(opts.subagentOverrides?.[CODEBASE_LOCATOR_NAME] ?? {}), }, { name: CODEBASE_ANALYZER_NAME, @@ -380,7 +394,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }), + }).withOverrides(opts.subagentOverrides?.[CODEBASE_ANALYZER_NAME] ?? {}), }, { name: CODEBASE_PATTERN_FINDER_NAME, @@ -397,7 +411,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt hooks, stopWhen, providerOptions: opts.providerOptions, - }), + }).withOverrides(opts.subagentOverrides?.[CODEBASE_PATTERN_FINDER_NAME] ?? {}), }, { name: 'web-search-researcher', diff --git a/agents/codelayer/test/coding-subagent-tool.test.ts b/agents/codelayer/test/coding-subagent-tool.test.ts index 31f8f08..df6e17e 100644 --- a/agents/codelayer/test/coding-subagent-tool.test.ts +++ b/agents/codelayer/test/coding-subagent-tool.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { defineTool } from '@humanlayer/agentlayer-core' import { createCodingSubagentTool } from '../src/coding-subagent-tool' -import { OUTLINE_IMPLEMENTER_AGENT_NAME } from '../src/rpi-agents' +import { CODEBASE_ANALYZER_NAME, OUTLINE_IMPLEMENTER_AGENT_NAME } from '../src/rpi-agents' + +// The Agent stores `tools`/`system` privately; read them via cast for assertions. +function agentTools(agent: unknown): Record { + return (agent as { tools: Record }).tools +} +function agentSystem(agent: unknown): string { + return (agent as { system: string }).system +} describe('createCodingSubagentTool', () => { test('includes the outline implementer sub-agent once', async () => { @@ -16,4 +26,64 @@ describe('createCodingSubagentTool', () => { expect(subagents).toHaveLength(1) expect(subagents[0]?.description).toContain('Implements structure outlines') }) + + describe('subagentOverrides', () => { + const fakeWrite = defineTool({ + name: 'write', + description: 'fake write tool for tests', + input: z.object({ file_path: z.string(), content: z.string() }), + execute: async () => 'ok', + }) + + test('merges override tools and appends override system prompts for the named subagent', async () => { + const tool = await createCodingSubagentTool({ + cwd: process.cwd(), + model: 'claude-test' as any, + system: 'base system prompt', + subagentOverrides: { + [CODEBASE_ANALYZER_NAME]: { + tools: { write: fakeWrite }, + system: ['PERSIST YOUR FINDINGS to research/NN-topic.md'], + }, + }, + }) + + const analyzer = tool.subagents.find((a) => a.name === CODEBASE_ANALYZER_NAME)?.agent + expect(analyzer).toBeDefined() + expect(Object.keys(agentTools(analyzer))).toContain('write') + // original tools are preserved alongside the injected one + expect(Object.keys(agentTools(analyzer))).toContain('read') + expect(agentSystem(analyzer)).toContain('PERSIST YOUR FINDINGS') + }) + + test('leaves non-targeted subagents untouched', async () => { + const tool = await createCodingSubagentTool({ + cwd: process.cwd(), + model: 'claude-test' as any, + system: 'base system prompt', + subagentOverrides: { + [CODEBASE_ANALYZER_NAME]: { + tools: { write: fakeWrite }, + system: ['PERSIST YOUR FINDINGS'], + }, + }, + }) + + const locator = tool.subagents.find((a) => a.name === 'rpi:codebase-locator')?.agent + expect(locator).toBeDefined() + expect(Object.keys(agentTools(locator))).not.toContain('write') + expect(agentSystem(locator)).not.toContain('PERSIST YOUR FINDINGS') + }) + + test('is a no-op when no overrides are supplied', async () => { + const tool = await createCodingSubagentTool({ + cwd: process.cwd(), + model: 'claude-test' as any, + system: 'base system prompt', + }) + + const analyzer = tool.subagents.find((a) => a.name === CODEBASE_ANALYZER_NAME)?.agent + expect(Object.keys(agentTools(analyzer))).not.toContain('write') + }) + }) }) diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index 692a1eb..3104ba8 100644 --- a/packages/agentlayer-core/src/agent.ts +++ b/packages/agentlayer-core/src/agent.ts @@ -89,6 +89,14 @@ export interface AgentConfig> = Rec } } +/** Incremental additions applied by {@link Agent.withOverrides} to produce a derived agent. */ +export interface AgentOverrides { + /** Extra tools merged into the agent's tool set (keyed by tool name). */ + tools?: Record> + /** Extra system-prompt strings appended after the agent's existing system prompt. */ + system?: string[] +} + export type FinishReason = 'complete' | 'maxSteps' | 'stopCondition' | 'interrupted' | 'approvalRequired' | 'error' export interface RunResult { @@ -274,6 +282,35 @@ export class Agent> = Record 0 ? overrides.tools : undefined + const extraSystem = overrides.system && overrides.system.length > 0 ? overrides.system : undefined + if (!extraTools && !extraSystem) return this + return new Agent({ + model: this.model, + system: extraSystem ? [this.system, ...extraSystem].filter((s): s is string => !!s).join('\n\n') : this.system, + tools: extraTools ? { ...this.tools, ...extraTools } : this.tools, + toolChoice: this.toolChoice, + providerOptions: this.providerOptions, + maxSteps: this.maxStepsLimit, + stopWhen: this.stopWhen, + modelProvider: this.modelProvider, + onError: this.onError, + onStop: this.onStop, + onApprovalRequested: this.onApprovalRequested, + contextWindowLimit: this.contextWindowLimit, + hooks: this.hooks, + }) + } + run(options: RunOptions): AgentRun { const agentRun = new AgentRun() agentRun.stream = options.stream === true diff --git a/packages/agentlayer-core/src/index.ts b/packages/agentlayer-core/src/index.ts index fdb4bb0..add06b3 100644 --- a/packages/agentlayer-core/src/index.ts +++ b/packages/agentlayer-core/src/index.ts @@ -1,6 +1,7 @@ export { Agent, type AgentConfig, + type AgentOverrides, type FinishReason, type ProviderOptions, type ProviderOptionsFactory, diff --git a/packages/agentlayer-core/test/agent-with-overrides.test.ts b/packages/agentlayer-core/test/agent-with-overrides.test.ts new file mode 100644 index 0000000..4fc1c1a --- /dev/null +++ b/packages/agentlayer-core/test/agent-with-overrides.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { Agent, defineTool } from '../src' +import { mockModel } from './mocks' + +// The Agent keeps `tools`/`system` private; read them via cast for assertions. +function agentTools(agent: unknown): Record { + return (agent as { tools: Record }).tools +} +function agentSystem(agent: unknown): string | undefined { + return (agent as { system: string | undefined }).system +} + +const readTool = defineTool({ + name: 'read', + description: 'fake read tool', + input: z.object({ path: z.string() }), + execute: async () => 'ok', +}) +const writeTool = defineTool({ + name: 'write', + description: 'fake write tool', + input: z.object({ path: z.string(), content: z.string() }), + execute: async () => 'ok', +}) + +function baseAgent(): Agent { + return new Agent({ + model: mockModel([]), + system: ['base system prompt'], + tools: { read: readTool }, + }) +} + +describe('Agent.withOverrides', () => { + test('merges extra tools and appends system prompts into a NEW agent', () => { + const base = baseAgent() + const next = base.withOverrides({ + tools: { write: writeTool }, + system: ['PERSIST YOUR FINDINGS to research/NN-topic.md'], + }) + + // A new instance — the original is untouched. + expect(next).not.toBe(base) + expect(Object.keys(agentTools(base))).toEqual(['read']) + expect(agentSystem(base)).toBe('base system prompt') + + // The clone has the merged tools + appended system. + expect(Object.keys(agentTools(next)).sort()).toEqual(['read', 'write']) + expect(agentSystem(next)).toBe('base system prompt\n\nPERSIST YOUR FINDINGS to research/NN-topic.md') + }) + + test('returns the same instance when there is nothing to apply', () => { + const base = baseAgent() + expect(base.withOverrides({})).toBe(base) + expect(base.withOverrides({ tools: {}, system: [] })).toBe(base) + }) + + test('can apply tools-only or system-only', () => { + const base = baseAgent() + + const toolsOnly = base.withOverrides({ tools: { write: writeTool } }) + expect(Object.keys(agentTools(toolsOnly)).sort()).toEqual(['read', 'write']) + expect(agentSystem(toolsOnly)).toBe('base system prompt') + + const systemOnly = base.withOverrides({ system: ['extra guidance'] }) + expect(Object.keys(agentTools(systemOnly))).toEqual(['read']) + expect(agentSystem(systemOnly)).toBe('base system prompt\n\nextra guidance') + }) +})