Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion agents/codelayer/src/agent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -39,6 +39,8 @@ export interface CodelayerAgentOptions {
skillTool?: Tool<any, any>
additionalTools?: Record<string, Tool<any, any>>
subagentTool?: Tool<any, any>
/** Per-subagent tool/prompt overrides, keyed by subagent name. Ignored when `subagentTool` is provided. */
subagentOverrides?: Record<string, AgentOverrides>
providerOptionOverrides?: CodelayerProviderOptionOverrides
subagentThinking?: string
environment?: CodelayerEnvironmentOptions
Expand Down Expand Up @@ -331,6 +333,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise
skillTool,
additionalTools = {},
subagentTool,
subagentOverrides,
providerOptionOverrides,
subagentThinking = 'low',
environment,
Expand Down Expand Up @@ -363,6 +366,7 @@ export async function createCodelayerAgent(opts: CodelayerAgentOptions): Promise
providerOptions: subagentProviderOptions,
outlineImplementerProviderOptions: providerOptions,
systemPromptAdditions: personaPromptAdditions,
subagentOverrides,
}))

if (rlm) {
Expand Down
34 changes: 24 additions & 10 deletions agents/codelayer/src/coding-subagent-tool.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, AgentOverrides>
}

async function fetchExaCodeSearch(input: CodeSearchInput, apiKey: string, timeoutMs: number): Promise<string | null> {
Expand Down Expand Up @@ -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,
Expand All @@ -245,7 +259,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt
hooks,
stopWhen,
providerOptions: opts.providerOptions,
})
}).withOverrides(opts.subagentOverrides?.['bash'] ?? {})

const implementerTools: Record<string, Tool<any, any>> = family === 'codex'
? {
Expand Down Expand Up @@ -278,15 +292,15 @@ 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,
system: baseSystem,
hooks,
stopWhen,
providerOptions: opts.outlineImplementerProviderOptions ?? opts.providerOptions,
})
}).withOverrides(opts.subagentOverrides?.[OUTLINE_IMPLEMENTER_AGENT_NAME] ?? {})

const webResearcherTools: Record<string, Tool<any, any>> = {
web_fetch: createWebFetchTool(),
Expand All @@ -302,7 +316,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt
hooks,
stopWhen,
providerOptions: opts.providerOptions,
})
}).withOverrides(opts.subagentOverrides?.['web-search-researcher'] ?? {})

const libraryResearcherTools: Record<string, Tool<any, any>> = {
web_fetch: createWebFetchTool(),
Expand All @@ -323,7 +337,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt
hooks,
stopWhen,
providerOptions: opts.providerOptions,
})
}).withOverrides(opts.subagentOverrides?.['library-researcher'] ?? {})
: undefined

const agents: SubAgentConfig[] = [
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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',
Expand Down
72 changes: 71 additions & 1 deletion agents/codelayer/test/coding-subagent-tool.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return (agent as { tools: Record<string, unknown> }).tools
}
function agentSystem(agent: unknown): string {
return (agent as { system: string }).system
}

describe('createCodingSubagentTool', () => {
test('includes the outline implementer sub-agent once', async () => {
Expand All @@ -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')
})
})
})
37 changes: 37 additions & 0 deletions packages/agentlayer-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export interface AgentConfig<TTools extends Record<string, Tool<any, any>> = 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<string, Tool<any, any>>
/** 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 {
Expand Down Expand Up @@ -274,6 +282,35 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
this.modelProvider = config.modelProvider ?? new ModelProvider()
}

/**
* Return a NEW agent with extra tools merged in and extra system-prompt strings appended,
* leaving this instance unchanged. Returns `this` when `overrides` is empty, so it chains
* cleanly off a builder: `createSomeAgent({ … }).withOverrides(subagentOverrides[name] ?? {})`.
*
* Handy for granting a single (sub)agent an extra capability — e.g. a scoped write tool plus
* some guidance — without rebuilding its whole config or reaching into private state.
*/
withOverrides(overrides: AgentOverrides): Agent {
const extraTools = overrides.tools && Object.keys(overrides.tools).length > 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
Expand Down
1 change: 1 addition & 0 deletions packages/agentlayer-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
Agent,
type AgentConfig,
type AgentOverrides,
type FinishReason,
type ProviderOptions,
type ProviderOptionsFactory,
Expand Down
70 changes: 70 additions & 0 deletions packages/agentlayer-core/test/agent-with-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return (agent as { tools: Record<string, unknown> }).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')
})
})
Loading