fix(agent): preserve non-text content blocks in beforeRun hook

- applyHookContext now replaces only text blocks, keeping images and
  tool results intact (was silently stripping them)
- Use backward loop instead of reverse() + find() for efficiency
- Clarify JSDoc that only `prompt` is applied from hook return value
- Add test for mixed-content user messages
This commit is contained in:
JackChen 2026-04-05 00:29:10 +08:00
parent 9abd570750
commit 76e2d7c7fb
3 changed files with 64 additions and 7 deletions

View File

@ -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
}

View File

@ -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> | BeforeRunHookContext
/**

View File

@ -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,