Skip to content
Merged
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
5 changes: 1 addition & 4 deletions agents/codelayer/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
detectModelFamily,
getSystemPromptForModel,
} from '@humanlayer/agentlayer-filesystem'
import { saneDefaultOutputTruncationHooks } from '@humanlayer/agentlayer-filesystem/hooks'
import { createApplyPatchTool } from '@humanlayer/agentlayer-filesystem/tools'
import { createEditTool } from '@humanlayer/agentlayer-filesystem/tools'
import { createReadMultimodalTool } from '@humanlayer/agentlayer-filesystem/tools'
Expand Down Expand Up @@ -343,12 +342,10 @@ export function subagentThinkingOverrides(
}

function mergeHooks(base: ReturnType<typeof createAgentFilesystemHooks>, hooks?: AgentConfig['hooks']): AgentConfig['hooks'] {
const fileStatePostHooks = base.postToolUse.filter((hook) => !saneDefaultOutputTruncationHooks.includes(hook))

return {
approval: hooks?.approval,
preToolUse: [...base.preToolUse, ...(hooks?.preToolUse ?? [])],
postToolUse: [...saneDefaultOutputTruncationHooks, ...fileStatePostHooks, ...(hooks?.postToolUse ?? [])],
postToolUse: [...base.postToolUse, ...(hooks?.postToolUse ?? [])],
preRequest: [...base.preRequest, ...(hooks?.preRequest ?? [])],
compaction: [...base.compaction, ...(hooks?.compaction ?? [])],
}
Expand Down
6 changes: 2 additions & 4 deletions agents/codelayer/src/coding-subagent-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
type CreateAgentFilesystemHooksOptions,
type CreateCodingAgentAuxToolsetOptions,
} from '@humanlayer/agentlayer-filesystem'
import { saneDefaultOutputTruncationHooks } from '@humanlayer/agentlayer-filesystem/hooks'
import { createWebSearchTool } from '@humanlayer/agentlayer-filesystem/tools'
import {
createBashSpecialistAgent,
Expand Down Expand Up @@ -166,12 +165,10 @@ function mergeHooks(
base: ReturnType<typeof createAgentFilesystemHooks>,
hooks?: AgentConfig['hooks'],
): AgentConfig['hooks'] {
const fileStatePostHooks = base.postToolUse.filter((hook) => !saneDefaultOutputTruncationHooks.includes(hook))

return {
approval: hooks?.approval,
preToolUse: [...base.preToolUse, ...(hooks?.preToolUse ?? [])],
postToolUse: [...saneDefaultOutputTruncationHooks, ...fileStatePostHooks, ...(hooks?.postToolUse ?? [])],
postToolUse: [...base.postToolUse, ...(hooks?.postToolUse ?? [])],
preRequest: [...base.preRequest, ...(hooks?.preRequest ?? [])],
compaction: [...base.compaction, ...(hooks?.compaction ?? [])],
}
Expand Down Expand Up @@ -329,6 +326,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt

const libraryResearcherTools: Record<string, Tool<any, any>> = {
web_fetch: createWebFetchTool(),
read: createReadMultimodalTool({ cwd: opts.cwd, readToolModalities: CODELAYER_READ_TOOL_MODALITIES }),
skill: skillTool,
}
if (opts.exaApiKey || opts.context7ApiKey) {
Expand Down
33 changes: 16 additions & 17 deletions agents/codelayer/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { LanguageModel } from 'ai'
import type { AgentConfig, PostToolUseHook, SubAgentConfig, Tool } from '@humanlayer/agentlayer-core'
import { saneDefaultOutputTruncationHooks } from '@humanlayer/agentlayer-filesystem/hooks'
import { createAgentFilesystemHooks } from '@humanlayer/agentlayer-filesystem'
import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth'
import * as providerAuth from '@humanlayer/agentlayer-provider-auth'
import * as codexProvider from '@humanlayer/agentlayer-provider-openai-codex'
Expand Down Expand Up @@ -123,10 +123,12 @@ function getSystemEntries(agent: object): string[] {
return Array.isArray(system) ? system : [system]
}

function expectDefaultTruncationHooksFirst(hooks: AgentConfig['hooks']) {
expect(hooks?.postToolUse?.slice(0, saneDefaultOutputTruncationHooks.length)).toEqual(
saneDefaultOutputTruncationHooks,
)
function expectFilesystemHooksBeforeUser(hooks: AgentConfig['hooks'], userPostHook: PostToolUseHook) {
const postToolUse = hooks?.postToolUse ?? []
const expectedFilesystemHookCount = createAgentFilesystemHooks({ cwd: '/tmp' }).postToolUse.length

expect(postToolUse).toHaveLength(expectedFilesystemHookCount + 1)
expect(postToolUse.at(-1)).toBe(userPostHook)
}

function getSubagents(tool: unknown): SubAgentConfig[] {
Expand Down Expand Up @@ -779,7 +781,7 @@ describe('createCodelayerAgent', () => {
expect(config.tools?.agent).toBeDefined()
})

test('prepends default truncation hooks before file-state and user hooks for standard agents', async () => {
test('installs the filesystem hook chain once before user hooks for standard agents', async () => {
const userPostHook: PostToolUseHook = (ctx) => ctx.done()
const agent = await createCodelayerAgent({
model: createMockModel('claude-sonnet-4-5'),
Expand All @@ -788,12 +790,11 @@ describe('createCodelayerAgent', () => {
})
const postToolUse = getAgentConfig(agent).hooks?.postToolUse ?? []

expectDefaultTruncationHooksFirst(getAgentConfig(agent).hooks)
expect(postToolUse.at(-1)).toBe(userPostHook)
expect(postToolUse.length).toBeGreaterThan(saneDefaultOutputTruncationHooks.length + 1)
expectFilesystemHooksBeforeUser(getAgentConfig(agent).hooks, userPostHook)
expect(postToolUse).toHaveLength(8)
})

test('prepends default truncation hooks before file-state and user hooks for rlm agents', async () => {
test('installs the filesystem hook chain once before user hooks for rlm agents', async () => {
const userPostHook: PostToolUseHook = (ctx) => ctx.done()
const agent = await createCodelayerAgent({
model: createMockModel('gpt-5.4'),
Expand All @@ -803,12 +804,11 @@ describe('createCodelayerAgent', () => {
})
const postToolUse = getAgentConfig(agent).hooks?.postToolUse ?? []

expectDefaultTruncationHooksFirst(getAgentConfig(agent).hooks)
expect(postToolUse.at(-1)).toBe(userPostHook)
expect(postToolUse.length).toBeGreaterThan(saneDefaultOutputTruncationHooks.length + 1)
expectFilesystemHooksBeforeUser(getAgentConfig(agent).hooks, userPostHook)
expect(postToolUse).toHaveLength(8)
})

test('prepends default truncation hooks before inherited user hooks for subagents', async () => {
test('installs the filesystem hook chain once before inherited user hooks for subagents', async () => {
const userPostHook: PostToolUseHook = (ctx) => ctx.done()
const agent = await createCodelayerAgent({
model: createMockModel('claude-sonnet-4-5'),
Expand All @@ -819,9 +819,8 @@ describe('createCodelayerAgent', () => {
const subagent = getSubagents(subagentTool).find((candidate) => candidate.name === 'general-purpose')
const postToolUse = getAgentConfig(subagent?.agent ?? {}).hooks?.postToolUse ?? []

expectDefaultTruncationHooksFirst(getAgentConfig(subagent?.agent ?? {}).hooks)
expect(postToolUse.at(-1)).toBe(userPostHook)
expect(postToolUse.length).toBeGreaterThan(saneDefaultOutputTruncationHooks.length + 1)
expectFilesystemHooksBeforeUser(getAgentConfig(subagent?.agent ?? {}).hooks, userPostHook)
expect(postToolUse).toHaveLength(8)
})

test('propagates context7 support into the subagent tool inventory', async () => {
Expand Down
39 changes: 39 additions & 0 deletions agents/codelayer/test/coding-subagent-tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { Tool } from '@humanlayer/agentlayer-core'
import { createAgentFilesystemHooks } from '@humanlayer/agentlayer-filesystem'
import { createCodingSubagentTool } from '../src/coding-subagent-tool'
import { OUTLINE_IMPLEMENTER_AGENT_NAME } from '../src/rpi-agents'

Expand Down Expand Up @@ -30,6 +31,10 @@ function getAgentAssembly(agent: object) {
return agent as { model: unknown; providerOptions?: unknown }
}

function getAgentPostToolHooks(agent: object) {
return (agent as { hooks?: { postToolUse?: unknown[] } }).hooks?.postToolUse ?? []
}

describe('createCodingSubagentTool', () => {
test('exposes one complete strict fork and specialist contract', async () => {
const tool = await createCodingSubagentTool({
Expand Down Expand Up @@ -136,6 +141,40 @@ describe('createCodingSubagentTool', () => {
expect(new Set(skillTools)).toHaveLength(1)
})

test('installs the shared web-output hook chain for web-capable agents and gives library research read access', async () => {
const tool = await createCodingSubagentTool({
cwd: process.cwd(),
model: 'claude-test' as any,
system: 'test system prompt',
exaApiKey: 'test-exa-key',
context7ApiKey: 'test-context7-key',
})
const expectedHookCount = createAgentFilesystemHooks({ cwd: process.cwd() }).postToolUse.length
const subagentsByName = new Map(tool.subagents.map((agent) => [agent.name, agent]))

for (const name of [
'general-purpose',
'rpi:implementer-agent',
'rpi:outline-implementer-agent',
'web-search-researcher',
'library-researcher',
]) {
const subagent = subagentsByName.get(name)
expect(subagent, name).toBeDefined()
expect(getAgentPostToolHooks(subagent!.agent), name).toHaveLength(expectedHookCount)
}

const webResearcherTools = getAgentTools(subagentsByName.get('web-search-researcher')!.agent)
expect(webResearcherTools.web_fetch).toBeDefined()
expect(webResearcherTools.web_search).toBeDefined()
expect(webResearcherTools.read).toBeDefined()

const libraryResearcherTools = getAgentTools(subagentsByName.get('library-researcher')!.agent)
expect(libraryResearcherTools.web_fetch).toBeDefined()
expect(libraryResearcherTools.web_search).toBeDefined()
expect(libraryResearcherTools.read).toBeDefined()
})

test('applies the grouped research override only to designated research agents', async () => {
const rootModel = { modelId: 'gpt-5.6-sol' } as any
const researchModel = { modelId: 'gpt-5.6-terra' } as any
Expand Down
2 changes: 2 additions & 0 deletions packages/agentlayer-filesystem/src/coding-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
createGrepOutputTruncationHook,
createListOutputTruncationHook,
createReadTruncationHook,
createWebOutputTruncationHook,
} from './hooks/output-truncation'
import { createApplyPatchTool } from './tools/apply-patch'
import { createBashTool } from './tools/bash'
Expand Down Expand Up @@ -74,6 +75,7 @@ export function createAgentFilesystemHooks(opts: CreateAgentFilesystemHooksOptio
createGlobOutputTruncationHook(sharedOutputTruncation),
createGrepOutputTruncationHook(sharedOutputTruncation),
createListOutputTruncationHook(sharedOutputTruncation),
createWebOutputTruncationHook(sharedOutputTruncation),
createFileStateTrackingHook({ cwd: opts.cwd }),
],
preRequest: [],
Expand Down
48 changes: 48 additions & 0 deletions packages/agentlayer-filesystem/src/hooks/output-truncation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface OutputTruncationOptions {
hint?: (ctx: { toolName: string; outputPath: string }) => string
}

export interface WebOutputTruncationOptions {
maxLines?: number
maxBytes?: number
}

function createOutputTruncationHook(
Tool: ToolInterface<any, any>,
defaultDirection: 'head' | 'tail',
Expand Down Expand Up @@ -81,10 +86,53 @@ export function createListOutputTruncationHook(opts?: OutputTruncationOptions):

export const listOutputTruncationHook = createListOutputTruncationHook()

const WEB_TOOL_NAMES = new Set(['web_fetch', 'web_search'])

function webOutputHint(input: { outputPath: string; maxBytes: number; keptLines: number; hitBytes: boolean }): string {
const read = (offset: number) => `read(file_path="${input.outputPath}", offset=${offset})`
if (input.keptLines === 0) {
return `(Output truncated. Full output saved to ${input.outputPath}. The first line exceeds the ${input.maxBytes}-byte limit. Use ${read(1)} to inspect it.)`
}

const byteLimit = input.hitBytes ? ` (${input.maxBytes}-byte limit)` : ''
const nextOffset = input.keptLines + 1
return `(Output truncated. Full output saved to ${input.outputPath}. Showing lines 1-${input.keptLines}${byteLimit}. Use ${read(nextOffset)} to continue.)`
}

export function createWebOutputTruncationHook(opts?: WebOutputTruncationOptions): PostToolUseHook {
const maxLines = opts?.maxLines ?? 2000
const maxBytes = opts?.maxBytes ?? 50 * 1024

return async (ctx) => {
if (!WEB_TOOL_NAMES.has(ctx.toolName) || typeof ctx.output !== 'string') {
return ctx.done()
}

const result = truncateWithOptions(ctx.output, { maxLines, maxBytes, direction: 'head' })
if (!result.truncated) {
return ctx.done()
}

const outputPath = await saveFullOutput(ctx.output)
const keptLines = ctx.output.split('\n').length - result.truncatedLines
return ctx.done(
`${result.content}\n\n${webOutputHint({
outputPath,
maxBytes,
keptLines,
hitBytes: result.hitBytes,
})}`,
)
}
}

export const webOutputTruncationHook = createWebOutputTruncationHook()

export const saneDefaultOutputTruncationHooks: PostToolUseHook[] = [
readTruncationHook,
bashOutputTruncationHook,
globOutputTruncationHook,
grepOutputTruncationHook,
listOutputTruncationHook,
webOutputTruncationHook,
]
45 changes: 42 additions & 3 deletions packages/agentlayer-filesystem/test/coding-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { defineTool } from '@humanlayer/agentlayer-core'
import { Agent, defineTool, startState, WebFetchTool } from '@humanlayer/agentlayer-core'
import { claudePrompt, codexPrompt } from '@humanlayer/agentlayer-core/prompts'
import { z } from 'zod'
import {
Expand All @@ -16,7 +16,15 @@ import {
createCodexCodingAgentToolset,
createSkillToolFromRepoDirs,
} from '../src'
import { makeToolContext } from './mocks'
import {
assistantText,
assistantWithToolCall,
mockModel as createMockToolModel,
getToolResults,
makeToolContext,
outputValue,
userMessage,
} from './mocks'

function mockModel(modelId: string) {
return { modelId } as any
Expand Down Expand Up @@ -202,10 +210,41 @@ describe('createAgentFilesystemHooks', () => {
await withTemporaryDirectory('agentlayer-hooks-', async (dir) => {
const hooks = createAgentFilesystemHooks({ cwd: dir })
expect(hooks.preToolUse).toHaveLength(2)
expect(hooks.postToolUse).toHaveLength(6)
expect(hooks.postToolUse).toHaveLength(7)
expect(hooks.preRequest).toHaveLength(0)
})
})

test('forwards configured output limits to the web output hook', async () => {
await withTemporaryDirectory('agentlayer-hooks-', async (dir) => {
const hooks = createAgentFilesystemHooks({
cwd: dir,
outputTruncation: { maxLines: 1, maxBytes: 100_000 },
})
const agent = new Agent({
model: createMockToolModel([
assistantWithToolCall('web_fetch', { url: 'https://example.com' }),
assistantText('Done.'),
]),
tools: {
web_fetch: WebFetchTool.define(async () => 'first line\nsecond line'),
},
hooks: {
preToolUse: [...hooks.preToolUse],
postToolUse: [...hooks.postToolUse],
preRequest: [...hooks.preRequest],
compaction: [...hooks.compaction],
},
})
const result = await agent.run({ state: startState([userMessage('go')]) }).result
const [toolResult] = getToolResults(result.state.messages)
const output = outputValue(toolResult!)

expect(output).toContain('first line')
expect(output).not.toContain('second line')
expect(output).toContain('Full output saved to')
})
})
})

describe('coding agent toolsets', () => {
Expand Down
Loading
Loading