diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 0ae28f0..e22c613 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -504,27 +504,37 @@ export class Agent { /** Extract the prompt text from the last user message to build hook context. */ private buildBeforeRunHookContext(messages: LLMMessage[]): BeforeRunHookContext { - const lastUser = [...messages].reverse().find(m => m.role === 'user') - const prompt = lastUser - ? lastUser.content + let prompt = '' + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]!.role === 'user') { + prompt = messages[i]!.content .filter((b): b is import('../types.js').TextBlock => b.type === 'text') .map(b => b.text) .join('') - : '' + break + } + } return { prompt, agent: this.config } } - /** Apply a (possibly modified) hook context back to the messages array. */ + /** + * Apply a (possibly modified) hook context back to the messages array. + * + * Only text blocks in the last user message are replaced; non-text content + * (images, tool results) is preserved. The array element is replaced (not + * mutated in place) so that shallow copies of the original array (e.g. from + * `prompt()`) are not affected. + */ private applyHookContext(messages: LLMMessage[], ctx: BeforeRunHookContext): void { const original = this.buildBeforeRunHookContext(messages) if (ctx.prompt === original.prompt) return - // Find the last user message and replace its text content. for (let i = messages.length - 1; i >= 0; i--) { if (messages[i]!.role === 'user') { + const nonTextBlocks = messages[i]!.content.filter(b => b.type !== 'text') messages[i] = { role: 'user', - content: [{ type: 'text', text: ctx.prompt }], + content: [{ type: 'text', text: ctx.prompt }, ...nonTextBlocks], } break } diff --git a/src/types.ts b/src/types.ts index 738e6d6..9dcbd6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -218,6 +218,7 @@ export interface AgentConfig { /** * Called before each agent run. Receives the prompt and agent config. * Return a (possibly modified) context to continue, or throw to abort the run. + * Only `prompt` from the returned context is applied; `agent` is read-only informational. */ readonly beforeRun?: (context: BeforeRunHookContext) => Promise | BeforeRunHookContext /** diff --git a/tests/agent-hooks.test.ts b/tests/agent-hooks.test.ts index 10b88ba..a3cbf30 100644 --- a/tests/agent-hooks.test.ts +++ b/tests/agent-hooks.test.ts @@ -313,6 +313,52 @@ describe('Agent hooks — beforeRun / afterRun', () => { // prompt() history integrity // ----------------------------------------------------------------------- + it('beforeRun modifying prompt preserves non-text content blocks', async () => { + // Simulate a multi-turn message where the last user message has mixed content + // (text + tool_result). beforeRun should only replace text, not strip other blocks. + const config: AgentConfig = { + ...baseConfig, + beforeRun: (ctx) => ({ ...ctx, prompt: 'modified' }), + } + const { adapter, calls } = mockAdapter('ok') + const registry = new ToolRegistry() + const executor = new ToolExecutor(registry) + const agent = new Agent(config, registry, executor) + + const runner = new AgentRunner(adapter, registry, executor, { + model: config.model, + agentName: config.name, + }) + ;(agent as any).runner = runner + + // Directly call run which creates a single text-only user message. + // To test mixed content, we need to go through the private executeRun. + // Instead, we test via prompt() after injecting history with mixed content. + ;(agent as any).messageHistory = [ + { + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'original' }, + { type: 'image' as const, source: { type: 'base64' as const, media_type: 'image/png', data: 'abc' } }, + ], + }, + ] + + // prompt() appends a new user message then calls executeRun with full history + await agent.prompt('follow up') + + // The last user message sent to the LLM should have modified text + const sentMessages = calls[0]! + const lastUser = [...sentMessages].reverse().find(m => m.role === 'user')! + const textBlock = lastUser.content.find(b => b.type === 'text') + expect((textBlock as any).text).toBe('modified') + + // The earlier user message (with the image) should be untouched + const firstUser = sentMessages.find(m => m.role === 'user')! + const imageBlock = firstUser.content.find(b => b.type === 'image') + expect(imageBlock).toBeDefined() + }) + it('beforeRun modifying prompt does not corrupt messageHistory', async () => { const config: AgentConfig = { ...baseConfig,