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:
parent
9abd570750
commit
76e2d7c7fb
|
|
@ -504,27 +504,37 @@ export class Agent {
|
||||||
|
|
||||||
/** Extract the prompt text from the last user message to build hook context. */
|
/** Extract the prompt text from the last user message to build hook context. */
|
||||||
private buildBeforeRunHookContext(messages: LLMMessage[]): BeforeRunHookContext {
|
private buildBeforeRunHookContext(messages: LLMMessage[]): BeforeRunHookContext {
|
||||||
const lastUser = [...messages].reverse().find(m => m.role === 'user')
|
let prompt = ''
|
||||||
const prompt = lastUser
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
? lastUser.content
|
if (messages[i]!.role === 'user') {
|
||||||
|
prompt = messages[i]!.content
|
||||||
.filter((b): b is import('../types.js').TextBlock => b.type === 'text')
|
.filter((b): b is import('../types.js').TextBlock => b.type === 'text')
|
||||||
.map(b => b.text)
|
.map(b => b.text)
|
||||||
.join('')
|
.join('')
|
||||||
: ''
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
return { prompt, agent: this.config }
|
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 {
|
private applyHookContext(messages: LLMMessage[], ctx: BeforeRunHookContext): void {
|
||||||
const original = this.buildBeforeRunHookContext(messages)
|
const original = this.buildBeforeRunHookContext(messages)
|
||||||
if (ctx.prompt === original.prompt) return
|
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--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
if (messages[i]!.role === 'user') {
|
if (messages[i]!.role === 'user') {
|
||||||
|
const nonTextBlocks = messages[i]!.content.filter(b => b.type !== 'text')
|
||||||
messages[i] = {
|
messages[i] = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [{ type: 'text', text: ctx.prompt }],
|
content: [{ type: 'text', text: ctx.prompt }, ...nonTextBlocks],
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,7 @@ export interface AgentConfig {
|
||||||
/**
|
/**
|
||||||
* Called before each agent run. Receives the prompt and agent config.
|
* Called before each agent run. Receives the prompt and agent config.
|
||||||
* Return a (possibly modified) context to continue, or throw to abort the run.
|
* 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
|
readonly beforeRun?: (context: BeforeRunHookContext) => Promise<BeforeRunHookContext> | BeforeRunHookContext
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,52 @@ describe('Agent hooks — beforeRun / afterRun', () => {
|
||||||
// prompt() history integrity
|
// 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 () => {
|
it('beforeRun modifying prompt does not corrupt messageHistory', async () => {
|
||||||
const config: AgentConfig = {
|
const config: AgentConfig = {
|
||||||
...baseConfig,
|
...baseConfig,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue