From ed1507b1be23f5ea45c05380cdaed2dd89756356 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Mon, 17 Aug 2026 17:19:05 -0700 Subject: [PATCH] feat(filesystem): spill oversized web tool output HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0122e-2c95-7eb1-95d5-5714b1150186 --- agents/codelayer/src/agent.ts | 5 +- agents/codelayer/src/coding-subagent-tool.ts | 6 +- agents/codelayer/test/agent.test.ts | 33 ++++--- .../test/coding-subagent-tool.test.ts | 39 ++++++++ .../agentlayer-filesystem/src/coding-agent.ts | 2 + .../src/hooks/output-truncation.ts | 48 ++++++++++ .../test/coding-agent.test.ts | 45 ++++++++- .../test/output-truncation.test.ts | 94 ++++++++++++++++++- .../test/save-to-disk-hooks.test.ts | 76 ++++++++++++++- .../docs/content/packages/filesystem/hooks.md | 18 +++- 10 files changed, 329 insertions(+), 37 deletions(-) diff --git a/agents/codelayer/src/agent.ts b/agents/codelayer/src/agent.ts index b815623..fb24b8e 100644 --- a/agents/codelayer/src/agent.ts +++ b/agents/codelayer/src/agent.ts @@ -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' @@ -343,12 +342,10 @@ export function subagentThinkingOverrides( } function mergeHooks(base: ReturnType, 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 ?? [])], } diff --git a/agents/codelayer/src/coding-subagent-tool.ts b/agents/codelayer/src/coding-subagent-tool.ts index aefff38..e191b40 100644 --- a/agents/codelayer/src/coding-subagent-tool.ts +++ b/agents/codelayer/src/coding-subagent-tool.ts @@ -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, @@ -166,12 +165,10 @@ function mergeHooks( base: ReturnType, 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 ?? [])], } @@ -329,6 +326,7 @@ export async function createCodingSubagentTool(opts: CreateCodingSubagentToolOpt const libraryResearcherTools: Record> = { web_fetch: createWebFetchTool(), + read: createReadMultimodalTool({ cwd: opts.cwd, readToolModalities: CODELAYER_READ_TOOL_MODALITIES }), skill: skillTool, } if (opts.exaApiKey || opts.context7ApiKey) { diff --git a/agents/codelayer/test/agent.test.ts b/agents/codelayer/test/agent.test.ts index 0bac8a3..3a5539f 100644 --- a/agents/codelayer/test/agent.test.ts +++ b/agents/codelayer/test/agent.test.ts @@ -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' @@ -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[] { @@ -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'), @@ -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'), @@ -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'), @@ -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 () => { diff --git a/agents/codelayer/test/coding-subagent-tool.test.ts b/agents/codelayer/test/coding-subagent-tool.test.ts index 67cfa2b..e143030 100644 --- a/agents/codelayer/test/coding-subagent-tool.test.ts +++ b/agents/codelayer/test/coding-subagent-tool.test.ts @@ -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' @@ -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({ @@ -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 diff --git a/packages/agentlayer-filesystem/src/coding-agent.ts b/packages/agentlayer-filesystem/src/coding-agent.ts index 70817cb..af73a3d 100644 --- a/packages/agentlayer-filesystem/src/coding-agent.ts +++ b/packages/agentlayer-filesystem/src/coding-agent.ts @@ -23,6 +23,7 @@ import { createGrepOutputTruncationHook, createListOutputTruncationHook, createReadTruncationHook, + createWebOutputTruncationHook, } from './hooks/output-truncation' import { createApplyPatchTool } from './tools/apply-patch' import { createBashTool } from './tools/bash' @@ -74,6 +75,7 @@ export function createAgentFilesystemHooks(opts: CreateAgentFilesystemHooksOptio createGlobOutputTruncationHook(sharedOutputTruncation), createGrepOutputTruncationHook(sharedOutputTruncation), createListOutputTruncationHook(sharedOutputTruncation), + createWebOutputTruncationHook(sharedOutputTruncation), createFileStateTrackingHook({ cwd: opts.cwd }), ], preRequest: [], diff --git a/packages/agentlayer-filesystem/src/hooks/output-truncation.ts b/packages/agentlayer-filesystem/src/hooks/output-truncation.ts index 6f451c6..b5a1ce8 100644 --- a/packages/agentlayer-filesystem/src/hooks/output-truncation.ts +++ b/packages/agentlayer-filesystem/src/hooks/output-truncation.ts @@ -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, defaultDirection: 'head' | 'tail', @@ -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, ] diff --git a/packages/agentlayer-filesystem/test/coding-agent.test.ts b/packages/agentlayer-filesystem/test/coding-agent.test.ts index b70bc0b..348eb02 100644 --- a/packages/agentlayer-filesystem/test/coding-agent.test.ts +++ b/packages/agentlayer-filesystem/test/coding-agent.test.ts @@ -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 { @@ -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 @@ -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', () => { diff --git a/packages/agentlayer-filesystem/test/output-truncation.test.ts b/packages/agentlayer-filesystem/test/output-truncation.test.ts index 4afc871..4733609 100644 --- a/packages/agentlayer-filesystem/test/output-truncation.test.ts +++ b/packages/agentlayer-filesystem/test/output-truncation.test.ts @@ -1,18 +1,29 @@ import { describe, expect, test } from 'bun:test' import { readFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { Agent, BashTool, GlobTool, GrepTool, ListTool, startState } from '@humanlayer/agentlayer-core' +import { + Agent, + BashTool, + GlobTool, + GrepTool, + ListTool, + startState, + WebFetchTool, + WebSearchTool, +} from '@humanlayer/agentlayer-core' import { bashOutputTruncationHook, createBashOutputTruncationHook, createGlobOutputTruncationHook, createGrepOutputTruncationHook, createListOutputTruncationHook, + createWebOutputTruncationHook, globOutputTruncationHook, grepOutputTruncationHook, listOutputTruncationHook, saneDefaultOutputTruncationHooks, saveFullOutput, + webOutputTruncationHook, } from '../src/hooks' import { assistantText, assistantWithToolCall, getToolResults, mockModel, outputValue, userMessage } from './mocks' @@ -33,6 +44,12 @@ function extractSavedPath(output: string): string { return match![1]! } +function extractWebSavedPath(output: string): string { + const match = output.match(/Full output saved to (.+?)\. (?:Showing lines|The first line)/) + expect(match).toBeTruthy() + return match![1]! +} + describe('saveFullOutput', () => { test('creates temp output file with exact content', async () => { const filePath = await saveFullOutput('line one\nline two') @@ -110,6 +127,78 @@ describe('disk-backed output truncation hooks — truncation', () => { }) }) +describe('web output truncation hook', () => { + test('preserves under-limit web fetch and search results registered under model-facing names', async () => { + const hook = createWebOutputTruncationHook({ maxLines: 10, maxBytes: 100_000 }) + const fetchOutput = await runToolWithHook( + 'web_fetch', + WebFetchTool.define(async () => 'short fetched page'), + hook, + { url: 'https://example.com' }, + ) + const searchOutput = await runToolWithHook( + 'web_search', + WebSearchTool.define(async () => ({ + results: [{ title: 'Result', url: 'https://example.com/result', snippet: 'short result' }], + })), + hook, + { query: 'example' }, + ) + + expect(fetchOutput).toBe('short fetched page') + expect(searchOutput).toBe('Result\n https://example.com/result\n short result') + expect(fetchOutput).not.toContain('Full output saved') + expect(searchOutput).not.toContain('Full output saved') + }) + + test('keeps a head excerpt, saves the complete fetch result, and provides a read offset', async () => { + const fullOutput = 'first line\nsecond line\nthird line\nfourth line' + const output = await runToolWithHook( + 'web_fetch', + WebFetchTool.define(async () => fullOutput), + createWebOutputTruncationHook({ maxLines: 2, maxBytes: 100_000 }), + { url: 'https://example.com' }, + ) + const savedPath = extractWebSavedPath(output) + + expect(output).toContain('first line\nsecond line') + expect(output).not.toContain('third line') + expect(output).toContain('Showing lines 1-2') + expect(output).toContain(`read(file_path="${savedPath}", offset=3)`) + expect(await readFile(savedPath, 'utf8')).toBe(fullOutput) + }) + + test('truncates serialized web search output from the head', async () => { + const output = await runToolWithHook( + 'web_search', + WebSearchTool.define(async () => ({ + results: [ + { title: 'First', url: 'https://example.com/first', snippet: 'first snippet' }, + { title: 'Second', url: 'https://example.com/second', snippet: 'second snippet' }, + ], + })), + createWebOutputTruncationHook({ maxLines: 3, maxBytes: 100_000 }), + { query: 'example' }, + ) + + expect(output).toContain('First\n https://example.com/first\n first snippet') + expect(output).not.toContain('Second') + expect(await readFile(extractWebSavedPath(output), 'utf8')).toContain('Second') + }) + + test('does not affect non-web tools', async () => { + const output = await runToolWithHook( + 'bash', + BashTool.define(async () => 'first line\nsecond line'), + createWebOutputTruncationHook({ maxLines: 1, maxBytes: 1 }), + { command: 'echo test', timeout: 5000 }, + ) + + expect(output).toBe('first line\nsecond line') + expect(output).not.toContain('Full output saved') + }) +}) + describe('disk-backed output truncation hooks — custom hints and defaults', () => { test('uses custom hint function', async () => { const tool = GlobTool.define(async () => ['a.ts', 'b.ts', 'c.ts']) @@ -130,6 +219,7 @@ describe('disk-backed output truncation hooks — custom hints and defaults', () expect(globOutputTruncationHook).toBeFunction() expect(grepOutputTruncationHook).toBeFunction() expect(listOutputTruncationHook).toBeFunction() - expect(saneDefaultOutputTruncationHooks).toHaveLength(5) + expect(webOutputTruncationHook).toBeFunction() + expect(saneDefaultOutputTruncationHooks).toHaveLength(6) }) }) diff --git a/packages/agentlayer-filesystem/test/save-to-disk-hooks.test.ts b/packages/agentlayer-filesystem/test/save-to-disk-hooks.test.ts index 122442c..1b23b75 100644 --- a/packages/agentlayer-filesystem/test/save-to-disk-hooks.test.ts +++ b/packages/agentlayer-filesystem/test/save-to-disk-hooks.test.ts @@ -5,6 +5,7 @@ * - createGlobOutputTruncationHook * - createGrepOutputTruncationHook * - createListOutputTruncationHook + * - createWebOutputTruncationHook * - saneDefaultOutputTruncationHooks composition * * Integration tests use the same pattern as output-truncation.test.ts: @@ -14,22 +15,44 @@ import { describe, expect, test } from 'bun:test' import { readFile } from 'node:fs/promises' import { Agent, startState } from '@humanlayer/agentlayer-core' -import { BashTool, GlobTool, GrepTool, ListTool } from '@humanlayer/agentlayer-core/interfaces' +import { BashTool, GlobTool, GrepTool, ListTool, WebFetchTool } from '@humanlayer/agentlayer-core/interfaces' import { bashOutputTruncationHook, createBashOutputTruncationHook, createGlobOutputTruncationHook, createGrepOutputTruncationHook, createListOutputTruncationHook, + createWebOutputTruncationHook, globOutputTruncationHook, grepOutputTruncationHook, listOutputTruncationHook, readTruncationHook, saneDefaultOutputTruncationHooks, saveFullOutput, + webOutputTruncationHook, } from '../src/hooks' import { assistantText, assistantWithToolCall, getToolResults, mockModel, outputValue, userMessage } from './mocks' +async function runWebFetchWithHook( + hook: ReturnType, + output: string, +): Promise { + const agent = new Agent({ + model: mockModel([assistantWithToolCall('web_fetch', { url: 'https://example.com' }), assistantText('Done.')]), + tools: { web_fetch: WebFetchTool.define(async () => output) }, + hooks: { postToolUse: [hook] }, + }) + const result = await agent.run({ state: startState([userMessage('go')]) }).result + const [toolResult] = getToolResults(result.state.messages) + return outputValue(toolResult!) +} + +function extractWebSavedPath(output: string): string { + const match = output.match(/Full output saved to (.+?)\. (?:Showing lines|The first line)/) + expect(match).not.toBeNull() + return match![1]! +} + // ── saveFullOutput ──────────────────────────────────────────────────────────── describe('saveFullOutput', () => { @@ -787,11 +810,11 @@ describe('save-to-disk hooks — direction override', () => { // ── saneDefaultOutputTruncationHooks composition ───────────────────────────── describe('saneDefaultOutputTruncationHooks', () => { - test('array contains exactly 5 hooks', () => { - expect(saneDefaultOutputTruncationHooks).toHaveLength(5) + test('array contains exactly 6 hooks', () => { + expect(saneDefaultOutputTruncationHooks).toHaveLength(6) }) - test('all five hooks are functions', () => { + test('all six hooks are functions', () => { for (const hook of saneDefaultOutputTruncationHooks) { expect(typeof hook).toBe('function') } @@ -817,6 +840,10 @@ describe('saneDefaultOutputTruncationHooks', () => { expect(saneDefaultOutputTruncationHooks[4]).toBe(listOutputTruncationHook) }) + test('includes the pre-composed webOutputTruncationHook instance', () => { + expect(saneDefaultOutputTruncationHooks[5]).toBe(webOutputTruncationHook) + }) + test('wired into an agent, bash tool is truncated and saved to disk', async () => { // Use very tight limits to trigger truncation const tightHooks = [ @@ -825,6 +852,7 @@ describe('saneDefaultOutputTruncationHooks', () => { createGlobOutputTruncationHook({ maxLines: 2, maxBytes: 100_000 }), createGrepOutputTruncationHook({ maxLines: 2, maxBytes: 100_000 }), createListOutputTruncationHook({ maxLines: 2, maxBytes: 100_000 }), + createWebOutputTruncationHook({ maxLines: 2, maxBytes: 100_000 }), ] const lines = Array.from({ length: 10 }, (_, i) => `output line ${i + 1}`) @@ -881,6 +909,46 @@ describe('saneDefaultOutputTruncationHooks', () => { }) }) +describe('web output hook boundaries', () => { + test('keeps exactly 2,000 lines and spills the 2,001st line', async () => { + const underLimit = Array.from({ length: 2000 }, (_, index) => `line ${index + 1}`).join('\n') + const overLimit = `${underLimit}\nline 2001` + const hook = createWebOutputTruncationHook() + + expect(await runWebFetchWithHook(hook, underLimit)).toBe(underLimit) + + const output = await runWebFetchWithHook(hook, overLimit) + expect(output).toContain('line 1') + expect(output).toContain('line 2000') + expect(output).not.toContain('line 2001') + expect(await readFile(extractWebSavedPath(output), 'utf8')).toBe(overLimit) + }) + + test('counts UTF-8 bytes while preserving complete output', async () => { + const fullOutput = 'ééé\nnext line' + const output = await runWebFetchWithHook( + createWebOutputTruncationHook({ maxLines: 10, maxBytes: 8 }), + fullOutput, + ) + + expect(output).toContain('ééé') + expect(output).not.toContain('next line') + expect(output).toContain('8-byte limit') + expect(await readFile(extractWebSavedPath(output), 'utf8')).toBe(fullOutput) + }) + + test('provides a valid read instruction when the first line exceeds the byte limit', async () => { + const fullOutput = `${'x'.repeat(50 * 1024)}\nsecond line` + const output = await runWebFetchWithHook(createWebOutputTruncationHook(), fullOutput) + const savedPath = extractWebSavedPath(output) + + expect(output).toContain('The first line exceeds the 51200-byte limit') + expect(output).not.toContain('Showing lines') + expect(output).toContain(`read(file_path="${savedPath}", offset=1)`) + expect(await readFile(savedPath, 'utf8')).toBe(fullOutput) + }) +}) + // ── glob serialize — inline truncation removed ─────────────────────────────── describe('GlobTool.serialize — no inline truncation', () => { diff --git a/packages/docs/content/packages/filesystem/hooks.md b/packages/docs/content/packages/filesystem/hooks.md index 7a737e1..1b3b1f4 100644 --- a/packages/docs/content/packages/filesystem/hooks.md +++ b/packages/docs/content/packages/filesystem/hooks.md @@ -133,6 +133,8 @@ const { preToolUseHook, postToolUseHook } = createReadBeforeWriteHooks({ cwd: pr Hooks that truncate excessive tool output to save context. +`createAgentFilesystemHooks()` installs the ordered defaults once: Read, Bash, Glob, Grep, List, web output, then file-state tracking. Caller-provided post-tool hooks run after that chain. + ### Individual Truncation Hooks ```ts @@ -141,7 +143,8 @@ import { createBashOutputTruncationHook, createGlobOutputTruncationHook, createGrepOutputTruncationHook, - createListOutputTruncationHook + createListOutputTruncationHook, + createWebOutputTruncationHook } from '@humanlayer/agentlayer-filesystem' const options = { @@ -155,8 +158,15 @@ const bashHook = createBashOutputTruncationHook(options) const globHook = createGlobOutputTruncationHook(options) const grepHook = createGrepOutputTruncationHook(options) const listHook = createListOutputTruncationHook(options) +const webHook = createWebOutputTruncationHook(options) ``` +### Web Fetch and Search Output + +`createWebOutputTruncationHook()` applies only to the standard coding-tool registry keys `web_fetch` and `web_search`. It uses a head-oriented 2,000-line, 50-KiB policy by default. Under-limit results are unchanged. When a result exceeds either limit, the complete serialized string is saved in an `agent-tool-output-*` directory under the OS temp directory and the model receives the leading lines plus a `read(file_path=..., offset=...)` instruction. + +The hook deliberately matches runtime registry keys rather than the underlying interface names (`webfetch` and `websearch`), so it applies to normal coding-agent toolsets. If the first line alone exceeds the byte limit, the hint directs the agent to read the saved file from offset 1 without claiming that any lines were shown. + ### Pre-configured Hook Instances ```ts @@ -166,6 +176,7 @@ import { globOutputTruncationHook, grepOutputTruncationHook, listOutputTruncationHook, + webOutputTruncationHook, saneDefaultOutputTruncationHooks } from '@humanlayer/agentlayer-filesystem' @@ -207,9 +218,9 @@ const agent = new Agent({ wastedRead.preToolUseHook ], postToolUse: [ + ...saneDefaultOutputTruncationHooks, createFileStateTrackingHook({ cwd }), - wastedRead.postToolUseHook, - ...saneDefaultOutputTruncationHooks + wastedRead.postToolUseHook ] } }) @@ -237,6 +248,7 @@ import type { TruncationOptions, TruncationResult, OutputTruncationOptions, + WebOutputTruncationOptions, ReadTruncationOptions, AgentOutputTruncationOptions, } from '@humanlayer/agentlayer-filesystem'