From 935db41b79dfe92037ecfd61148ca2a90b0f4141 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 11:01:05 +0200 Subject: [PATCH 01/18] [Agent Builder] stream reasoning events --- .../langchain/tools.ts | 2 +- .../execution/run_agent/convert_graph_events.ts | 17 +++++++++++------ .../run_agent/prompts/research_agent.ts | 8 ++++++++ .../execution/runner/utils/tool_manager.ts | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts index cd2b77c4bb011..de8f8cbd7ead9 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.ts @@ -104,7 +104,7 @@ export const toolToLangchain = async ({ toolId, logger, sendEvent, - addReasoningParam = true, + addReasoningParam = false, }: { tool: ExecutableTool; toolId?: string; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts index 6beea99b19f13..95ec2547a6384 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts @@ -20,7 +20,6 @@ import { createReasoningEvent, createTextChunkEvent, createBackgroundAgentCompleteEvent, - createThinkingCompleteEvent, createToolCallEvent, createToolResultEvent, extractTextContent, @@ -74,9 +73,10 @@ export const convertGraphEvents = ({ }); } + // TODO: fix, need a new ID per cycle const messageId = uuidv4(); - let isThinkingComplete = false; + // let isThinkingComplete = false; return streamEvents$.pipe( mergeMap((event) => { @@ -84,17 +84,22 @@ export const convertGraphEvents = ({ return EMPTY; } - // stream answering text chunks for the UI - if (matchEvent(event, 'on_chat_model_stream') && hasTag(event, tags.answerAgent)) { + // streaming text chunks for the UI (answering + research) + if ( + matchEvent(event, 'on_chat_model_stream') && + (hasTag(event, tags.answerAgent) || hasTag(event, tags.researchAgent)) + ) { const chunk: AIMessageChunk = event.data.chunk; const textContent = extractTextContent(chunk); if (textContent) { const events: ConvertedEvents[] = []; - if (!isThinkingComplete) { + + // TODO: fix - thinking complete not possible without answer step - https://github.com/elastic/kibana/pull/241681 + /* if (!isThinkingComplete) { // Emit thinking complete event when first chunk arrives events.push(createThinkingCompleteEvent(Date.now() - startTime.getTime())); isThinkingComplete = true; - } + }*/ events.push(createTextChunkEvent(textContent, { messageId })); return of(...events); } diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts index 1aa409a15813a..69a3fea7add04 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts @@ -91,6 +91,14 @@ Before each tool call, assess whether your current approach is making progress: - **Loop**: if you are repeating the same sequence of tool calls, treat it as a signal to change approach. - **Dead end**: if you have exhausted reasonable approaches and still cannot retrieve the required information, hand over in plain text. Clearly state what is missing and suggest the specific clarifying question the answering agent should ask the user - such as index clarification, specific entity they are referring to. +## COMMUNICATING WITH THE USER + +When sending user-facing text, you're writing for a person, not logging to a console. +Assume users can't see most tool calls or thinking - only your text output. +- Before your first tool call, briefly state what you're about to do. +- Before tool calls, briefly explain why you are calling this or those tools. +- While working, give short updates at key moments: when you find something load-bearing, when changing direction, when you've made progress without an update. + ${experimentalFeatures.filestore ? await getFileSystemInstructions({ filesystem: filestore }) : ''} ${experimentalFeatures.skills ? await getSkillsInstructions({ filesystem: filestore }) : ''} diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/runner/utils/tool_manager.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/runner/utils/tool_manager.ts index 605c15a3e4978..e5047a5d0f311 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/runner/utils/tool_manager.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/runner/utils/tool_manager.ts @@ -86,6 +86,7 @@ export class ToolManager implements IToolManager { logger: input.logger, sendEvent: this.eventEmitter, toolId: toolIdMapping.get(tool.id), + addReasoningParam: false, }) ) ); From 5feca09751992f803f9e2d11db6ad95764492649 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 14:55:46 +0200 Subject: [PATCH 02/18] update researcher instructions --- .../run_agent/prompts/research_agent.ts | 59 +++++++++---------- .../prompts/utils/custom_instructions.ts | 2 +- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts index 69a3fea7add04..3e6f366f6a89f 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/research_agent.ts @@ -10,12 +10,13 @@ import { cleanPrompt } from '@kbn/agent-builder-genai-utils/prompts'; import { getSkillsInstructions } from './utils/skills'; import { getConversationAttachmentsSection } from '../utils/attachment_presentation'; import { convertPreviousRounds } from '../utils/to_langchain_messages'; -import { attachmentTypeInstructions } from './utils/attachments'; +import { attachmentTypeInstructions, renderAttachmentPrompt } from './utils/attachments'; import { structuredOutputDescription } from './utils/custom_instructions'; import { formatResearcherActionHistory } from './utils/actions'; import { formatDate } from './utils/helpers'; import { getFileSystemInstructions } from '../../runner/store'; import type { PromptFactoryParams, ResearchAgentPromptRuntimeParams } from './types'; +import { renderVisualizationPrompt } from './utils/visualizations'; type ResearchAgentPromptParams = PromptFactoryParams & ResearchAgentPromptRuntimeParams; @@ -41,10 +42,6 @@ export const getResearchAgentPrompt = async ( ]; }; -// Rule 3 (parallel tool calls) includes a skill-loading exception because skills -// dynamically add tools via the loadSkillToolsAfterRead hook. Without the exception, -// the LLM parallelizes filestore.read (skill load) with general-purpose tool calls, -// missing the specialized tools the skill would have provided. const getAgentSystemMessage = async ({ configuration: { research: { instructions: customInstructions }, @@ -54,21 +51,19 @@ const getAgentSystemMessage = async ({ outputSchema, filestore, experimentalFeatures, + capabilities, }: ResearchAgentPromptParams): Promise => { - return cleanPrompt(`You are an expert enterprise AI assistant from Elastic, the company behind Elasticsearch. + const visEnabled = capabilities.visualizations; -Your sole responsibility is to use available tools to gather and prepare information. -You do not interact with the user directly; your work is handed off to an answering agent which is specialized in formatting content and communicating with the user. -That answering agent will have access to the conversation history and to all information you gathered - you do not need to summarize your findings in the handover note. + return cleanPrompt(`You are an expert enterprise AI assistant from Elastic, the company behind Elasticsearch. ## NON-NEGOTIABLE RULES -1) You will execute a series of tool calls to find the required data or perform the requested task. During that phase, your output MUST be a tool call. -2) Once you have gathered sufficient information, you will stop calling tools. Your final step is to respond in plain text. This response will serve as a handover note for the answering agent, summarizing your readiness or providing key context. This plain text handover is the ONLY time you should not call a tool. -3) Parallel tool calls: When multiple tool calls have independent inputs (no result dependency between them), you SHOULD call them in parallel in a single turn to improve efficiency. Exception: always load applicable skills before calling non-skill tools — dedicate a turn to skill loading (multiple skills can be loaded in parallel in that turn). -4) Tool-first: For any factual, procedural, or product-specific question you MUST call at least one available tool before answering. -5) Grounding: Every claim must come from tool output or user-provided content. If the information is not present in either, omit it. -6) No speculation or capability disclaimers: Do not deflect, over-explain limitations, guess, or fabricate links, data, or tool behavior. -7) Bias to action: When uncertain about an information-seeking query, default to calling tools to gather information. +1) You will execute a series of tool calls to find the required data or perform the requested task. During that phase, your output MUST be a tool call. Once you have gathered sufficient information, you will stop calling tools. Your final step is to respond in plain text. +2) Parallel tool calls: When multiple tool calls have independent inputs (no result dependency between them), you SHOULD call them in parallel in a single turn to improve efficiency. Exception: always load applicable skills before calling non-skill tools — dedicate a turn to skill loading (multiple skills can be loaded in parallel in that turn). +3) Tool-first: For any factual, procedural, or product-specific question you MUST call at least one available tool before answering. +4) Grounding: Every claim must come from tool output or user-provided content. If the information is not present in either, omit it. +5) No speculation or capability disclaimers: Do not deflect, over-explain limitations, guess, or fabricate links, data, or tool behavior. +6) Bias to action: When uncertain about an information-seeking query, default to calling tools to gather information. ## TOOL SELECTION When choosing which tool to use, follow this precedence (stop at first applicable): @@ -79,12 +74,6 @@ When choosing which tool to use, follow this precedence (stop at first applicabl 5. Follow up before asking: if initial results do not fully answer the question, issue targeted follow-up tool calls rather than asking the user for more information. 6. Adapt gracefully: if a tool is unavailable or returns an error, re-evaluate and continue with the remaining available tools. -## SML @ REFERENCES -When the user picks from the @ menu, the message includes markdown links: \`[@label](sml://CHUNK_ID)\`. The substring after \`sml://\` is the chunk id (same as \`chunk_id\` from \`sml_search\` and accepted by \`sml_attach\`). -- For each distinct chunk id in \`sml://\` links in the **current** user message, call \`sml_attach\` with those ids **before** other tools that need that asset's content. When this applies, it overrides generic tool-order rules for tools that depend on those assets. -- Skip \`sml_attach\` for a chunk id only if a **previous** turn already ran \`sml_attach\` successfully for that chunk id (see prior tool output text such as \`created from SML item '...'\`). Do **not** infer skip from conversation attachment XML: attachment \`id\` attributes are conversation attachment ids, not SML chunk ids. -- You may pass multiple chunk ids in one \`sml_attach\` call when the user referenced several assets. - ## REFLECTION Before each tool call, assess whether your current approach is making progress: - **Stuck**: if a tool has returned empty, unhelpful, or near-identical results across multiple attempts with similar inputs, do not retry the same way. Change strategy — adjust parameters, try a different tool, or reframe the query from a different angle. @@ -92,13 +81,17 @@ Before each tool call, assess whether your current approach is making progress: - **Dead end**: if you have exhausted reasonable approaches and still cannot retrieve the required information, hand over in plain text. Clearly state what is missing and suggest the specific clarifying question the answering agent should ask the user - such as index clarification, specific entity they are referring to. ## COMMUNICATING WITH THE USER - When sending user-facing text, you're writing for a person, not logging to a console. Assume users can't see most tool calls or thinking - only your text output. - Before your first tool call, briefly state what you're about to do. - Before tool calls, briefly explain why you are calling this or those tools. - While working, give short updates at key moments: when you find something load-bearing, when changing direction, when you've made progress without an update. +## OUTPUT STYLE +- Clear, direct, and scoped. No extraneous commentary. +- Use custom rendering when appropriate. +- Use minimal Markdown for readability (short bullets; code blocks for queries/JSON when helpful). + ${experimentalFeatures.filestore ? await getFileSystemInstructions({ filesystem: filestore }) : ''} ${experimentalFeatures.skills ? await getSkillsInstructions({ filesystem: filestore }) : ''} @@ -113,12 +106,18 @@ ${attachmentTypeInstructions(attachmentTypes)} ${getConversationAttachmentsSection(versionedAttachmentPresentation)} -## ADDITIONAL INFO -- Current date: ${formatDate(conversationTimestamp)} +## SML @ REFERENCES +When the user picks from the @ menu, the message includes markdown links: \`[@label](sml://CHUNK_ID)\`. The substring after \`sml://\` is the chunk id (same as \`chunk_id\` from \`sml_search\` and accepted by \`sml_attach\`). +- For each distinct chunk id in \`sml://\` links in the **current** user message, call \`sml_attach\` with those ids **before** other tools that need that asset's content. When this applies, it overrides generic tool-order rules for tools that depend on those assets. +- Skip \`sml_attach\` for a chunk id only if a **previous** turn already ran \`sml_attach\` successfully for that chunk id (see prior tool output text such as \`created from SML item '...'\`). Do **not** infer skip from conversation attachment XML: attachment \`id\` attributes are conversation attachment ids, not SML chunk ids. +- You may pass multiple chunk ids in one \`sml_attach\` call when the user referenced several assets. + +## CUSTOM RENDERING + +${visEnabled ? renderVisualizationPrompt() : 'No custom renderers available'} -## PRE-RESPONSE COMPLIANCE CHECK -- [ ] Have I gathered all necessary information or performed the requested task? If NO, my response MUST be a tool call. -- [ ] If I'm calling a tool, Did I use the \`_reasoning\` parameter to clearly explain why I'm taking this next step? -- [ ] For each \`sml://\` chunk id in the current user message, did I call \`sml_attach\` (or skip only because a prior turn's \`sml_attach\` already attached that chunk id)? -- [ ] If I am handing over to the answer agent, is my plain text note a concise, non-summarizing piece of meta-commentary?`); +${renderAttachmentPrompt()} + +## ADDITIONAL INFO +- Current date: ${formatDate(conversationTimestamp)}`); }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/custom_instructions.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/custom_instructions.ts index 6997f3d139edc..93b441e3de635 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/custom_instructions.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/custom_instructions.ts @@ -27,7 +27,7 @@ export const structuredOutputDescription = (outputSchema?: Record Date: Wed, 29 Apr 2026 16:38:22 +0200 Subject: [PATCH 03/18] feat(agent_builder): add cycle-limit fallback message helper --- .../prompts/utils/fallback_messages.test.ts | 21 +++++++++++++++++++ .../prompts/utils/fallback_messages.ts | 15 +++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts create mode 100644 x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts new file mode 100644 index 0000000000000..6569c5bf448c6 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createCycleLimitFallbackMessage } from './fallback_messages'; + +describe('createCycleLimitFallbackMessage', () => { + it('returns a non-empty user-facing string', () => { + const message = createCycleLimitFallbackMessage(); + expect(typeof message).toBe('string'); + expect(message.length).toBeGreaterThan(0); + }); + + it('mentions running out of steps so the user understands why', () => { + const message = createCycleLimitFallbackMessage(); + expect(message.toLowerCase()).toContain('step'); + }); +}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts new file mode 100644 index 0000000000000..93c3791c79095 --- /dev/null +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Static fallback message surfaced to the user when the agent hits the hard + * cycle limit without producing a HandoverAction. Used by the + * `prepareFallbackAnswer` graph node in non-structured mode. + */ +export const createCycleLimitFallbackMessage = (): string => { + return "I ran out of steps before finishing this task. Here's what I gathered so far — feel free to ask me to continue."; +}; From ea1de2fb2e2ad53205a0125f51485e2cff20d853 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:38:37 +0200 Subject: [PATCH 04/18] feat(agent_builder): reserve prepareFallbackAnswer step constant --- .../server/services/execution/run_agent/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts index 0f04d338b6307..fff65a7925e50 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts @@ -15,6 +15,7 @@ export const steps = { executeTool: 'executeTool', handleToolInterrupt: 'handleToolInterrupt', prepareToAnswer: 'prepareToAnswer', + prepareFallbackAnswer: 'prepareFallbackAnswer', answerAgent: 'answerAgent', finalize: 'finalize', }; From 8175f4a97abb572eca930a4086f72b924d4a1007 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:39:38 +0200 Subject: [PATCH 05/18] refactor(agent_builder): thread structuredOutput into convertGraphEvents --- .../services/execution/run_agent/convert_graph_events.test.ts | 1 + .../server/services/execution/run_agent/convert_graph_events.ts | 2 ++ .../server/services/execution/run_agent/run_chat_agent.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts index 6c113a5aa1549..19e46d2955a21 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts @@ -85,6 +85,7 @@ describe('convertGraphEvents', () => { pendingRound: undefined, logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, startTime: new Date(), + structuredOutput: false, }), toArray() ); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts index 95ec2547a6384..49b8d392028b7 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts @@ -56,12 +56,14 @@ export const convertGraphEvents = ({ pendingRound, logger, startTime, + structuredOutput, }: { graphName: string; toolManager: ToolManager; pendingRound: ConversationRound | undefined; logger: Logger; startTime: Date; + structuredOutput: boolean; }): OperatorFunction => { return (streamEvents$) => { const toolCallIdToIdMap = new Map(); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/run_chat_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/run_chat_agent.ts index 21235b9495299..9039de607aa0e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/run_chat_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/run_chat_agent.ts @@ -324,6 +324,7 @@ export const runDefaultAgentMode: RunChatAgentFn = async ( logger, startTime, pendingRound, + structuredOutput, }), finalize(() => manualEvents$.complete()) ); From 5c18d2941b7e177220340dcaf1bd0ee82146898f Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:41:06 +0200 Subject: [PATCH 06/18] refactor(agent_builder): emit messageEvent from finalize step --- .../run_agent/convert_graph_events.test.ts | 99 +++++++++++++++++++ .../run_agent/convert_graph_events.ts | 30 ++---- 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts index 19e46d2955a21..46ab7eb86e4f5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts @@ -108,4 +108,103 @@ describe('convertGraphEvents', () => { }), ]); }); + + it('emits messageEvent at on_chain_end of finalize using state.finalAnswer (string)', async () => { + const { createMessageEvent } = jest.requireMock('@kbn/agent-builder-genai-utils/langchain'); + createMessageEvent.mockImplementation((content: string | object, opts: any) => ({ + type: 'message_complete', + data: { + message_id: opts?.messageId ?? 'unknown', + message_content: typeof content === 'string' ? content : JSON.stringify(content), + ...(typeof content === 'object' ? { structured_output: content } : {}), + }, + })); + + const streamEvent = { + event: 'on_chain_end', + name: steps.finalize, + metadata: { graphName: 'test-graph' }, + data: { + output: { + finalAnswer: 'final answer text', + }, + }, + } as any; + + const events = await lastValueFrom( + of(streamEvent).pipe( + convertGraphEvents({ + graphName: 'test-graph', + toolManager: { + getToolIdMapping: jest.fn().mockReturnValue(new Map()), + getToolOrigin: jest.fn(), + } as any, + pendingRound: undefined, + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + startTime: new Date(), + structuredOutput: false, + }), + toArray() + ) + ); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'message_complete', + data: expect.objectContaining({ + message_content: 'final answer text', + }), + }) + ); + }); + + it('emits messageEvent at on_chain_end of finalize using state.finalAnswer (object) for structured output', async () => { + const { createMessageEvent } = jest.requireMock('@kbn/agent-builder-genai-utils/langchain'); + createMessageEvent.mockImplementation((content: string | object, opts: any) => ({ + type: 'message_complete', + data: { + message_id: opts?.messageId ?? 'unknown', + message_content: typeof content === 'string' ? content : JSON.stringify(content), + ...(typeof content === 'object' ? { structured_output: content } : {}), + }, + })); + + const structuredAnswer = { foo: 'bar' }; + const streamEvent = { + event: 'on_chain_end', + name: steps.finalize, + metadata: { graphName: 'test-graph' }, + data: { + output: { + finalAnswer: structuredAnswer, + }, + }, + } as any; + + const events = await lastValueFrom( + of(streamEvent).pipe( + convertGraphEvents({ + graphName: 'test-graph', + toolManager: { + getToolIdMapping: jest.fn().mockReturnValue(new Map()), + getToolOrigin: jest.fn(), + } as any, + pendingRound: undefined, + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + startTime: new Date(), + structuredOutput: true, + }), + toArray() + ) + ); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'message_complete', + data: expect.objectContaining({ + structured_output: structuredAnswer, + }), + }) + ); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts index 49b8d392028b7..f54af1f4eac76 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts @@ -38,10 +38,8 @@ import type { StateType } from './state'; import { BROWSER_TOOL_PREFIX, steps, tags } from './constants'; import type { ToolCallResult } from './actions'; import { - isAnswerAction, isBackgroundExecutionCompleteAction, isExecuteToolAction, - isStructuredAnswerAction, isToolCallAction, isToolPromptAction, } from './actions'; @@ -161,27 +159,15 @@ export const convertGraphEvents = ({ return of(...events); } - // emit messages for answering step - if (matchEvent(event, 'on_chain_end') && matchName(event, steps.answerAgent)) { - const events: ConvertedEvents[] = []; - - // process last emitted message - const answerActions = (event.data.output as StateType).answerActions; - const lastAction = answerActions[answerActions.length - 1]; - - if (isStructuredAnswerAction(lastAction)) { - const messageEvent = createMessageEvent(lastAction.data, { - messageId, - }); - events.push(messageEvent); - } else if (isAnswerAction(lastAction)) { - const messageEvent = createMessageEvent(lastAction.message, { - messageId, - }); - events.push(messageEvent); + // emit messageEvent at finalize: state.finalAnswer is the canonical answer + // (string for non-structured, object for structured) + if (matchEvent(event, 'on_chain_end') && matchName(event, steps.finalize)) { + const finalState = event.data.output as StateType; + const finalAnswer = finalState.finalAnswer; + if (finalAnswer !== undefined && finalAnswer !== null && finalAnswer !== '') { + return of(createMessageEvent(finalAnswer, { messageId })); } - - return of(...events); + return EMPTY; } // emit tool result events and/or prompt request events From 14a7d1fb184e243523370adfd35eed6b18b2dc09 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:43:33 +0200 Subject: [PATCH 07/18] feat(agent_builder): emit backdated thinkingCompleteEvent for non-structured handover --- .../run_agent/convert_graph_events.test.ts | 189 +++++++++++++++++- .../run_agent/convert_graph_events.ts | 44 +++- 2 files changed, 219 insertions(+), 14 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts index 46ab7eb86e4f5..9b6b68bb9561e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts @@ -42,11 +42,13 @@ jest.mock('@kbn/agent-builder-genai-utils/langchain', () => ({ })); jest.mock('./actions', () => ({ - isAnswerAction: jest.fn(() => false), + isBackgroundExecutionCompleteAction: jest.fn(() => false), isExecuteToolAction: jest.fn(() => false), - isStructuredAnswerAction: jest.fn(() => false), isToolPromptAction: jest.fn(() => false), - isToolCallAction: jest.fn((action: any) => action.kind === 'tool_call_action'), + isToolCallAction: jest.fn( + (action: any) => action.kind === 'tool_call_action' || action.type === 'tool_call' + ), + isHandoverAction: jest.fn((action: any) => action?.type === 'hand_over'), })); describe('convertGraphEvents', () => { @@ -207,4 +209,185 @@ describe('convertGraphEvents', () => { }) ); }); + + it('emits thinkingCompleteEvent backdated to first chunk of terminal research turn', async () => { + const { createThinkingCompleteEvent, hasTag, extractTextContent } = jest.requireMock( + '@kbn/agent-builder-genai-utils/langchain' + ); + hasTag.mockImplementation((event: any, tag: string) => event.tags?.includes(tag)); + extractTextContent.mockImplementation((chunk: any) => chunk?.content ?? ''); + createThinkingCompleteEvent.mockImplementation((time: number) => ({ + type: 'thinking_complete', + data: { time_to_first_token: time }, + })); + + const startTime = new Date(1000); + const events: any[] = [ + { + event: 'on_chain_start', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + }, + { + event: 'on_chat_model_stream', + name: 'unused', + tags: ['agent', 'research-agent'], + metadata: { graphName: 'test-graph' }, + data: { chunk: { content: 'hello' } }, + }, + { + event: 'on_chain_end', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + data: { + output: { + mainActions: [{ type: 'hand_over', message: 'final answer' }], + }, + }, + }, + ]; + + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2500); + + try { + const converted = await lastValueFrom( + of(...events).pipe( + convertGraphEvents({ + graphName: 'test-graph', + toolManager: { + getToolIdMapping: jest.fn().mockReturnValue(new Map()), + getToolOrigin: jest.fn(), + } as any, + pendingRound: undefined, + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + startTime, + structuredOutput: false, + }), + toArray() + ) + ); + + expect(converted).toContainEqual( + expect.objectContaining({ + type: 'thinking_complete', + data: expect.objectContaining({ time_to_first_token: 1500 }), + }) + ); + } finally { + nowSpy.mockRestore(); + } + }); + + it('does not emit thinkingCompleteEvent when research turn ends in tool calls', async () => { + const { hasTag, extractTextContent } = jest.requireMock( + '@kbn/agent-builder-genai-utils/langchain' + ); + hasTag.mockImplementation((event: any, tag: string) => event.tags?.includes(tag)); + extractTextContent.mockImplementation((chunk: any) => chunk?.content ?? ''); + + const events: any[] = [ + { + event: 'on_chain_start', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + }, + { + event: 'on_chat_model_stream', + name: 'unused', + tags: ['agent', 'research-agent'], + metadata: { graphName: 'test-graph' }, + data: { chunk: { content: 'searching...' } }, + }, + { + event: 'on_chain_end', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + data: { + output: { + mainActions: [ + { + type: 'tool_call', + tool_calls: [{ toolCallId: 'c1', args: {} }], + }, + ], + }, + }, + }, + ]; + + const converted = await lastValueFrom( + of(...events).pipe( + convertGraphEvents({ + graphName: 'test-graph', + toolManager: { + getToolIdMapping: jest.fn().mockReturnValue(new Map()), + getToolOrigin: jest.fn(), + } as any, + pendingRound: undefined, + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + startTime: new Date(), + structuredOutput: false, + }), + toArray() + ) + ); + + expect(converted).not.toContainEqual( + expect.objectContaining({ type: 'thinking_complete' }) + ); + }); + + it('does not emit thinkingCompleteEvent in structured mode', async () => { + const { hasTag, extractTextContent } = jest.requireMock( + '@kbn/agent-builder-genai-utils/langchain' + ); + hasTag.mockImplementation((event: any, tag: string) => event.tags?.includes(tag)); + extractTextContent.mockImplementation((chunk: any) => chunk?.content ?? ''); + + const events: any[] = [ + { + event: 'on_chain_start', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + }, + { + event: 'on_chat_model_stream', + name: 'unused', + tags: ['agent', 'research-agent'], + metadata: { graphName: 'test-graph' }, + data: { chunk: { content: 'hello' } }, + }, + { + event: 'on_chain_end', + name: steps.researchAgent, + metadata: { graphName: 'test-graph' }, + data: { + output: { + mainActions: [{ type: 'hand_over', message: 'draft' }], + }, + }, + }, + ]; + + const converted = await lastValueFrom( + of(...events).pipe( + convertGraphEvents({ + graphName: 'test-graph', + toolManager: { + getToolIdMapping: jest.fn().mockReturnValue(new Map()), + getToolOrigin: jest.fn(), + } as any, + pendingRound: undefined, + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() } as any, + startTime: new Date(), + structuredOutput: true, + }), + toArray() + ) + ); + + expect(converted).not.toContainEqual( + expect.objectContaining({ type: 'thinking_complete' }) + ); + }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts index f54af1f4eac76..fa4eb53b58fc4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts @@ -19,6 +19,7 @@ import { createPromptRequestEvent, createReasoningEvent, createTextChunkEvent, + createThinkingCompleteEvent, createBackgroundAgentCompleteEvent, createToolCallEvent, createToolResultEvent, @@ -40,6 +41,7 @@ import type { ToolCallResult } from './actions'; import { isBackgroundExecutionCompleteAction, isExecuteToolAction, + isHandoverAction, isToolCallAction, isToolPromptAction, } from './actions'; @@ -76,7 +78,11 @@ export const convertGraphEvents = ({ // TODO: fix, need a new ID per cycle const messageId = uuidv4(); - // let isThinkingComplete = false; + // Tracks the timestamp of the first text chunk of the current research turn. + // Used to backdate `thinkingCompleteEvent` to the first chunk of the terminal + // turn (the turn ending in a HandoverAction). Reset on each researchAgent + // on_chain_start. Only relevant in non-structured mode. + let currentTurnFirstChunkAt: number | undefined; return streamEvents$.pipe( mergeMap((event) => { @@ -84,6 +90,12 @@ export const convertGraphEvents = ({ return EMPTY; } + // reset per-turn first-chunk tracker at the start of each research turn + if (matchEvent(event, 'on_chain_start') && matchName(event, steps.researchAgent)) { + currentTurnFirstChunkAt = undefined; + return EMPTY; + } + // streaming text chunks for the UI (answering + research) if ( matchEvent(event, 'on_chat_model_stream') && @@ -92,16 +104,14 @@ export const convertGraphEvents = ({ const chunk: AIMessageChunk = event.data.chunk; const textContent = extractTextContent(chunk); if (textContent) { - const events: ConvertedEvents[] = []; - - // TODO: fix - thinking complete not possible without answer step - https://github.com/elastic/kibana/pull/241681 - /* if (!isThinkingComplete) { - // Emit thinking complete event when first chunk arrives - events.push(createThinkingCompleteEvent(Date.now() - startTime.getTime())); - isThinkingComplete = true; - }*/ - events.push(createTextChunkEvent(textContent, { messageId })); - return of(...events); + if ( + !structuredOutput && + hasTag(event, tags.researchAgent) && + currentTurnFirstChunkAt === undefined + ) { + currentTurnFirstChunkAt = Date.now(); + } + return of(createTextChunkEvent(textContent, { messageId })); } } @@ -156,6 +166,18 @@ export const convertGraphEvents = ({ } } + // Backdated thinking-complete: when the research agent's terminal turn + // produces a HandoverAction in non-structured mode, emit + // thinkingCompleteEvent with the timestamp of the first chunk of that + // turn. Falls back to "now" if no chunk timestamp was captured. + if (!structuredOutput && isHandoverAction(nextAction)) { + const firstChunkOffset = + currentTurnFirstChunkAt !== undefined + ? currentTurnFirstChunkAt - startTime.getTime() + : Date.now() - startTime.getTime(); + events.push(createThinkingCompleteEvent(firstChunkOffset)); + } + return of(...events); } From 7e329f208c05c788e83d52b45dbd3542fa5b4f7f Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:46:12 +0200 Subject: [PATCH 08/18] refactor(agent_builder): make finalize read mainActions in non-structured mode --- .../services/execution/run_agent/graph.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts index a1598be52ef7e..e4da7988492ed 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts @@ -287,18 +287,25 @@ export const createAgentGraph = ({ }; const finalize = async (state: StateType) => { - const answerAction = state.answerActions[state.answerActions.length - 1]; - if (isStructuredAnswerAction(answerAction)) { - return { - finalAnswer: answerAction.data, - }; - } else if (isAnswerAction(answerAction)) { - return { - finalAnswer: answerAction.message, - }; - } else { - throw invalidState(`[finalize] expect answer action, got ${answerAction.type} instead.`); + if (structuredOutput) { + const answerAction = state.answerActions[state.answerActions.length - 1]; + if (isStructuredAnswerAction(answerAction)) { + return { finalAnswer: answerAction.data }; + } + throw invalidState( + `[finalize] expected structured answer action, got ${answerAction.type} instead.` + ); + } + + // Non-structured: the research agent's terminal HandoverAction (real or + // synthetic from prepareFallbackAnswer) carries the user-facing answer. + const lastMainAction = state.mainActions[state.mainActions.length - 1]; + if (isHandoverAction(lastMainAction)) { + return { finalAnswer: lastMainAction.message }; } + throw invalidState( + `[finalize] expected handover action, got ${lastMainAction.type} instead.` + ); }; const selectedAnswerAgent = structuredOutput ? answerAgentStructured : answerAgent; From f9fee533df68b37469f5974f66a0b6ee8aa7e1b7 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:48:05 +0200 Subject: [PATCH 09/18] feat(agent_builder): replace answer step with direct finalize in non-structured mode --- .../services/execution/run_agent/graph.ts | 105 +++++++----------- 1 file changed, 40 insertions(+), 65 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts index e4da7988492ed..49b0a2b7fb46e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts @@ -22,28 +22,24 @@ import type { ToolManager } from '@kbn/agent-builder-server/runner'; import type { ResolvedConfiguration } from './types'; import { convertError, isRecoverableError } from './utils/errors'; import type { PromptFactory } from './prompts'; -import { getRandomAnsweringMessage, getRandomThinkingMessage } from './i18n'; +import { getRandomThinkingMessage } from './i18n'; import { steps, tags, BACKGROUND_CHECK_CYCLE_INTERVAL } from './constants'; import type { BackgroundExecutionService } from './background_execution_service'; import type { StateType } from './state'; import { StateAnnotation } from './state'; -import { - processAnswerResponse, - processResearchResponse, - processToolNodeResponse, -} from './action_utils'; +import { processResearchResponse, processToolNodeResponse } from './action_utils'; import { createAnswerAgentStructured } from './answer_agent_structured'; import { errorAction, handoverAction, backgroundExecutionCompleteAction, isAgentErrorAction, - isAnswerAction, isHandoverAction, isStructuredAnswerAction, isToolCallAction, isToolPromptAction, } from './actions'; +import { createCycleLimitFallbackMessage } from './prompts/utils/fallback_messages'; import type { ProcessedConversation } from './utils/prepare_conversation'; // number of successive recoverable errors we try to recover from before throwing @@ -160,12 +156,12 @@ export const createAgentGraph = ({ } else if (isToolCallAction(lastAction)) { const maxCycleReached = state.currentCycle > state.cycleLimit; if (maxCycleReached) { - return steps.prepareToAnswer; + return structuredOutput ? steps.prepareToAnswer : steps.prepareFallbackAnswer; } else { return steps.executeTool; } } else if (isHandoverAction(lastAction)) { - return steps.prepareToAnswer; + return structuredOutput ? steps.prepareToAnswer : steps.finalize; } throw invalidState(`[researchAgentEdge] last action type was ${lastAction.type}}`); @@ -224,40 +220,10 @@ export const createAgentGraph = ({ } }; - const answerAgent = async (state: StateType) => { - const answeringModel = chatModel.bindTools(toolManager.list()).withConfig({ - tags: [tags.agent, tags.answerAgent], - }); - - if (state.answerActions.length === 0 && state.errorCount === 0) { - events.emit(createReasoningEvent(getRandomAnsweringMessage(), { transient: true })); - } - try { - const response = await answeringModel.invoke( - await promptFactory.getAnswerPrompt({ - actions: state.mainActions, - answerActions: state.answerActions, - cycleLimit: state.cycleLimit, - }) - ); - - const action = processAnswerResponse(response); - - return { - answerActions: [action], - errorCount: 0, - }; - } catch (error) { - const executionError = convertError(error); - if (isRecoverableError(executionError)) { - return { - answerActions: [errorAction(executionError)], - errorCount: state.errorCount + 1, - }; - } else { - throw executionError; - } - } + const prepareFallbackAnswer = async (_state: StateType) => { + return { + mainActions: [handoverAction(createCycleLimitFallbackMessage(), true)], + }; }; const answerAgentStructured = createAnswerAgentStructured({ @@ -278,7 +244,7 @@ export const createAgentGraph = ({ // max error count reached, stop execution by throwing throw lastAction.error; } - } else if (isAnswerAction(lastAction) || isStructuredAnswerAction(lastAction)) { + } else if (isStructuredAnswerAction(lastAction)) { return steps.finalize; } @@ -308,42 +274,51 @@ export const createAgentGraph = ({ ); }; - const selectedAnswerAgent = structuredOutput ? answerAgentStructured : answerAgent; - // note: the node names are used in the event convertion logic, they should *not* be changed - const graph = new StateGraph(StateAnnotation) - // nodes + const graphBuilder = new StateGraph(StateAnnotation) .addNode(steps.init, init) .addNode(steps.checkBackgroundWork, checkBackgroundWork) .addNode(steps.researchAgent, researchAgent) .addNode(steps.executeTool, executeTool) .addNode(steps.handleToolInterrupt, handleToolInterrupt) - .addNode(steps.prepareToAnswer, prepareToAnswer) - .addNode(steps.answerAgent, selectedAnswerAgent) .addNode(steps.finalize, finalize) - // edges .addEdge(_START_, steps.init) .addEdge(steps.init, steps.checkBackgroundWork) .addEdge(steps.checkBackgroundWork, steps.researchAgent) - .addConditionalEdges(steps.researchAgent, researchAgentEdge, { - [steps.researchAgent]: steps.researchAgent, - [steps.executeTool]: steps.executeTool, - [steps.prepareToAnswer]: steps.prepareToAnswer, - }) .addConditionalEdges(steps.executeTool, executeToolEdge, { [steps.checkBackgroundWork]: steps.checkBackgroundWork, [steps.handleToolInterrupt]: steps.handleToolInterrupt, }) .addEdge(steps.handleToolInterrupt, _END_) - .addEdge(steps.prepareToAnswer, steps.answerAgent) - .addConditionalEdges(steps.answerAgent, answerAgentEdge, { - [steps.answerAgent]: steps.answerAgent, - [steps.finalize]: steps.finalize, - }) - .addEdge(steps.finalize, _END_) - .compile(); - - return graph; + .addEdge(steps.finalize, _END_); + + if (structuredOutput) { + graphBuilder + .addNode(steps.prepareToAnswer, prepareToAnswer) + .addNode(steps.answerAgent, answerAgentStructured) + .addConditionalEdges(steps.researchAgent, researchAgentEdge, { + [steps.researchAgent]: steps.researchAgent, + [steps.executeTool]: steps.executeTool, + [steps.prepareToAnswer]: steps.prepareToAnswer, + }) + .addEdge(steps.prepareToAnswer, steps.answerAgent) + .addConditionalEdges(steps.answerAgent, answerAgentEdge, { + [steps.answerAgent]: steps.answerAgent, + [steps.finalize]: steps.finalize, + }); + } else { + graphBuilder + .addNode(steps.prepareFallbackAnswer, prepareFallbackAnswer) + .addConditionalEdges(steps.researchAgent, researchAgentEdge, { + [steps.researchAgent]: steps.researchAgent, + [steps.executeTool]: steps.executeTool, + [steps.prepareFallbackAnswer]: steps.prepareFallbackAnswer, + [steps.finalize]: steps.finalize, + }) + .addEdge(steps.prepareFallbackAnswer, steps.finalize); + } + + return graphBuilder.compile(); }; const invalidState = (message: string) => { From ba545828e5b1fc8917e8d5d5f6118c9dd9e81237 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:49:10 +0200 Subject: [PATCH 10/18] refactor(agent_builder): drop processAnswerResponse and tighten structured response processing --- .../execution/run_agent/action_utils.ts | 55 +++---------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/action_utils.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/action_utils.ts index 8240667f868f3..c8bfca2dcb16b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/action_utils.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/action_utils.ts @@ -9,7 +9,6 @@ import type { AIMessageChunk, BaseMessage, ToolMessage } from '@langchain/core/m import { isToolMessage } from '@langchain/core/messages'; import { extractTextContent, - extractToolCalls, extractToolCallsWithReasoning, } from '@kbn/agent-builder-genai-utils/langchain'; import { createAgentExecutionError } from '@kbn/agent-builder-common/base/errors'; @@ -22,7 +21,6 @@ import type { AgentErrorAction, ExecuteToolAction, ToolPromptAction, - AnswerAction, StructuredAnswerAction, } from './actions'; import { @@ -30,7 +28,6 @@ import { handoverAction, executeToolAction, toolPromptAction, - answerAction, errorAction, structuredAnswerAction, } from './actions'; @@ -121,56 +118,20 @@ export const processToolNodeResponse = ( return actions; }; -export const processAnswerResponse = (message: AIMessageChunk): AnswerAction | AgentErrorAction => { - // The answering agent should not call tools. Some models/providers can still emit tool calls - // unexpectedly, so we treat that as a recoverable error and retry with an explicit tool-result - // error message in the prompt history. - if (message.tool_calls?.length) { - const [firstToolCall] = extractToolCalls(message); - const toolName = firstToolCall?.toolName ?? 'unknown'; - const toolArgs = firstToolCall?.args ?? {}; - - return errorAction( - createAgentExecutionError( - `Answer agent attempted to call tool "${toolName}"`, - AgentExecutionErrorCode.toolNotFound, - { toolName, toolArgs } - ) - ); - } - - const textContent = extractTextContent(message); - if (textContent) { - return answerAction(extractTextContent(message)); - } else { +export const processStructuredAnswerResponse = ( + response: unknown +): StructuredAnswerAction | AgentErrorAction => { + try { + if (response && typeof response === 'object') { + return structuredAnswerAction(response); + } return errorAction( createAgentExecutionError( - 'agent returned an empty response', + 'agent returned an invalid structured response', AgentExecutionErrorCode.emptyResponse, {} ) ); - } -}; - -export const processStructuredAnswerResponse = ( - response: unknown -): StructuredAnswerAction | AnswerAction | AgentErrorAction => { - try { - if (response && typeof response === 'object') { - const action = structuredAnswerAction(response); - return action; - } else if (typeof response === 'string') { - return answerAction(response); - } else { - return errorAction( - createAgentExecutionError( - 'agent returned an invalid structured response', - AgentExecutionErrorCode.emptyResponse, - {} - ) - ); - } } catch (error) { return errorAction( createAgentExecutionError( From 66b07e2b8bd5cb6237fb260db280b580ba22da43 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:50:49 +0200 Subject: [PATCH 11/18] refactor(agent_builder): remove AnswerAction type and helpers --- .../services/execution/run_agent/actions.ts | 19 +------------------ .../run_agent/prompts/utils/actions.ts | 2 +- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/actions.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/actions.ts index ae3aa66be8e10..f84053598ca66 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/actions.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/actions.ts @@ -17,7 +17,6 @@ export enum AgentActionType { ExecuteTool = 'execute_tool', ToolPrompt = 'tool_prompt', HandOver = 'hand_over', - Answer = 'answer', StructuredAnswer = 'structured_answer', BackgroundExecutionComplete = 'background_execution_complete', } @@ -82,17 +81,12 @@ export type ResearchAgentAction = // answer phase actions -export interface AnswerAction { - type: AgentActionType.Answer; - message: string; -} - export interface StructuredAnswerAction { type: AgentActionType.StructuredAnswer; data: object; } -export type AnswerAgentAction = AnswerAction | StructuredAnswerAction | AgentErrorAction; +export type AnswerAgentAction = StructuredAnswerAction | AgentErrorAction; // all possible actions for the agent flow @@ -120,10 +114,6 @@ export function isHandoverAction(action: AgentAction): action is HandoverAction return action.type === AgentActionType.HandOver; } -export function isAnswerAction(action: AgentAction): action is AnswerAction { - return action.type === AgentActionType.Answer; -} - export function isStructuredAnswerAction(action: AgentAction): action is StructuredAnswerAction { return action.type === AgentActionType.StructuredAnswer; } @@ -190,13 +180,6 @@ export function handoverAction(message: string, forceful: boolean = false): Hand }; } -export function answerAction(message: string): AnswerAction { - return { - type: AgentActionType.Answer, - message, - }; -} - export function structuredAnswerAction(data: object): StructuredAnswerAction { return { type: AgentActionType.StructuredAnswer, diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/actions.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/actions.ts index 384a8ea99f00f..686cd7de49bf1 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/actions.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/actions.ts @@ -102,7 +102,7 @@ export const formatAnswerActionHistory = ({ // returns a single [AI, user] tuple formatted.push(...formatErrorAction(action)); } - // [...] we don't need to format AnswerAction because it will terminate the execution + // [...] we don't need to format StructuredAnswerAction because it will terminate the execution } return formatted; From b75217d3316643918ed69c4b3fa636e073f76f46 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 29 Apr 2026 16:52:18 +0200 Subject: [PATCH 12/18] refactor(agent_builder): drop non-structured answer prompt and factory entry --- .../run_agent/prompts/answer_agent.ts | 81 +------------------ .../run_agent/prompts/prompt_factory.ts | 8 +- .../execution/run_agent/prompts/types.ts | 1 - 3 files changed, 2 insertions(+), 88 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/answer_agent.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/answer_agent.ts index 3786a3db947e7..1e8f822401192 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/answer_agent.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/answer_agent.ts @@ -13,90 +13,11 @@ import { formatDate } from './utils/helpers'; import { customInstructionsBlock } from './utils/custom_instructions'; import { formatResearcherActionHistory, formatAnswerActionHistory } from './utils/actions'; import { renderVisualizationPrompt } from './utils/visualizations'; -import { attachmentTypeInstructions, renderAttachmentPrompt } from './utils/attachments'; +import { attachmentTypeInstructions } from './utils/attachments'; import type { PromptFactoryParams, AnswerAgentPromptRuntimeParams } from './types'; type AnswerAgentPromptParams = PromptFactoryParams & AnswerAgentPromptRuntimeParams; -export const getAnswerAgentPrompt = async ( - params: AnswerAgentPromptParams -): Promise => { - const { actions, cycleLimit, answerActions, processedConversation, resultTransformer } = params; - - // Generate messages from the conversation's rounds, with optional compaction summary - // sourced from processedConversation.compactionSummary (set during compaction phase). - const previousRoundsAsMessages = await convertPreviousRounds({ - conversation: processedConversation, - resultTransformer, - compactionSummary: processedConversation.compactionSummary, - }); - - return [ - ['system', getAnswerSystemMessage(params)], - ...previousRoundsAsMessages, - ...formatResearcherActionHistory({ actions, cycleLimit }), - ...formatAnswerActionHistory({ actions: answerActions }), - ]; -}; - -export const getAnswerSystemMessage = ({ - configuration: { - answer: { instructions: customInstructions }, - }, - conversationTimestamp, - capabilities, - processedConversation: { attachmentTypes, versionedAttachmentPresentation }, -}: AnswerAgentPromptParams): string => { - const visEnabled = capabilities.visualizations; - - return cleanPrompt(`You are an expert enterprise AI assistant from Elastic, the company behind Elasticsearch. - -Your role is to be the **final answering agent** in a multi-agent flow. Your **ONLY** purpose is to generate a natural language response to the user. - -## INSTRUCTIONS -- Carefully read the original discussion and the gathered information. -- Synthesize an accurate response that directly answers the user's question. -- Do not hedge. If the information is complete, provide a confident and final answer. -- If there are still uncertainties or unresolved issues, acknowledge them clearly and state what is known and what is not. -- You **MUST NOT** under any circumstances, attempt to call or generate syntax for any tool. - -## GUIDELINES -- Do not mention the research process or that you are an AI or assistant. -- Do not mention that the answer was generated based on previous steps. -- Do not repeat the user's question or summarize the JSON input. -- Do not speculate beyond the gathered information unless logically inferred from it. -- Do not mention internal reasoning or tool names unless user explicitly asks. - -${customInstructionsBlock(customInstructions)} - -${attachmentTypeInstructions(attachmentTypes)} - -${getConversationAttachmentsSection(versionedAttachmentPresentation)} - -## OUTPUT STYLE -- Clear, direct, and scoped. No extraneous commentary. -- Use custom rendering when appropriate. -- Use minimal Markdown for readability (short bullets; code blocks for queries/JSON when helpful). - -## CUSTOM RENDERING - -${visEnabled ? renderVisualizationPrompt() : 'No custom renderers available'} - -${renderAttachmentPrompt()} - -## ADDITIONAL INFO -- Current date: ${formatDate(conversationTimestamp)} - -## PRE-RESPONSE COMPLIANCE CHECK -- [ ] I answered with a text response -- [ ] I did not call any tool -- [ ] All claims are grounded in tool output, conversation history or user-provided content. -- [ ] I asked for missing mandatory parameters only when required. -- [ ] The answer stays within the user's requested scope. -- [ ] I answered every part of the user's request (identified sub-questions/requirements). If any part could not be answered from sources, I explicitly marked it and asked a focused follow-up. -- [ ] No internal tool process or names revealed (unless user asked).`); -}; - export const getStructuredAnswerPrompt = async ( params: AnswerAgentPromptParams ): Promise => { diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/prompt_factory.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/prompt_factory.ts index f57bc865606ba..95a570e3b14ff 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/prompt_factory.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/prompt_factory.ts @@ -7,7 +7,7 @@ import type { PromptFactoryParams, PromptFactory } from './types'; import { getResearchAgentPrompt } from './research_agent'; -import { getAnswerAgentPrompt, getStructuredAnswerPrompt } from './answer_agent'; +import { getStructuredAnswerPrompt } from './answer_agent'; export const createPromptFactory = (params: PromptFactoryParams): PromptFactory => { return { @@ -17,12 +17,6 @@ export const createPromptFactory = (params: PromptFactoryParams): PromptFactory ...args, }); }, - getAnswerPrompt: async (args) => { - return getAnswerAgentPrompt({ - ...params, - ...args, - }); - }, getStructuredAnswerPrompt: async (args) => { return getStructuredAnswerPrompt({ ...params, diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/types.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/types.ts index c5e1966ffd6aa..3a73d1aab7338 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/types.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/types.ts @@ -42,6 +42,5 @@ export interface AnswerAgentPromptRuntimeParams { export interface PromptFactory { getMainPrompt(params: ResearchAgentPromptRuntimeParams): Promise; - getAnswerPrompt(params: AnswerAgentPromptRuntimeParams): Promise; getStructuredAnswerPrompt(params: AnswerAgentPromptRuntimeParams): Promise; } From 7790b5332265365fdd852682e2c298b25130276b Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 May 2026 12:45:05 +0000 Subject: [PATCH 13/18] Changes from node scripts/check --- .../execution/run_agent/convert_graph_events.test.ts | 8 ++------ .../server/services/execution/run_agent/graph.ts | 4 +--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts index 9b6b68bb9561e..0fd5addb997ad 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.test.ts @@ -332,9 +332,7 @@ describe('convertGraphEvents', () => { ) ); - expect(converted).not.toContainEqual( - expect.objectContaining({ type: 'thinking_complete' }) - ); + expect(converted).not.toContainEqual(expect.objectContaining({ type: 'thinking_complete' })); }); it('does not emit thinkingCompleteEvent in structured mode', async () => { @@ -386,8 +384,6 @@ describe('convertGraphEvents', () => { ) ); - expect(converted).not.toContainEqual( - expect.objectContaining({ type: 'thinking_complete' }) - ); + expect(converted).not.toContainEqual(expect.objectContaining({ type: 'thinking_complete' })); }); }); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts index 49b0a2b7fb46e..250bd152293d1 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts @@ -269,9 +269,7 @@ export const createAgentGraph = ({ if (isHandoverAction(lastMainAction)) { return { finalAnswer: lastMainAction.message }; } - throw invalidState( - `[finalize] expected handover action, got ${lastMainAction.type} instead.` - ); + throw invalidState(`[finalize] expected handover action, got ${lastMainAction.type} instead.`); }; // note: the node names are used in the event convertion logic, they should *not* be changed From 965bfca1762697f0620dfe5359fb67154ba8312f Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Tue, 19 May 2026 15:16:10 +0200 Subject: [PATCH 14/18] fix unit tests --- .../langchain/tools.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.test.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.test.ts index c5a76816268d0..050584b58375e 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.test.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-genai-utils/langchain/tools.test.ts @@ -44,6 +44,23 @@ describe('toolToLangchain', () => { expect(langchainTool.description).toEqual('desc'); expect(langchainTool.responseFormat).toEqual('content_and_artifact'); + const toolKeys = Object.keys((langchainTool.schema as any).shape); + expect(toolKeys.sort()).toEqual(['foo']); + }); + + it('adds reasoning param when specified', async () => { + const tool = createTool('toolA', { + description: 'desc', + getSchema: () => z.object({ foo: z.string() }), + }); + + const langchainTool = await toolToLangchain({ + tool, + toolId: tool.id, + logger, + addReasoningParam: true, + }); + const toolKeys = Object.keys((langchainTool.schema as any).shape); expect(toolKeys.sort()).toEqual(['_reasoning', 'foo']); }); From 14b35a50a69185d244c0508aee86c4630f8c2691 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Tue, 19 May 2026 16:14:12 +0200 Subject: [PATCH 15/18] adapt scout tests due to removal (1) --- .../fixtures/setup_answer_agent_invalid_tool.ts | 5 +---- .../api/tests/converse_attachments_api.spec.ts | 4 ++-- .../api/tests/converse_tool_calling_api.spec.ts | 8 ++++---- .../lib/proxy_scenario/calls.ts | 17 +---------------- .../lib/proxy_scenario/scenarios.ts | 10 +--------- 5 files changed, 9 insertions(+), 35 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts index 17958def94fff..094b9c08f34a5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts @@ -9,12 +9,11 @@ import type { LlmProxy } from '@kbn/ftr-llm-proxy'; import { mockAgentToolCall, mockFinalAnswer, - mockHandoverToAnswer, mockTitleGeneration, } from '../../../scout_agent_builder_shared/lib/proxy_scenario/calls'; /** - * Answer agent calls an invalid tool on first call and then responds on the second call. + * Agent calls an invalid tool on first call and then responds on the second call. */ export async function setupAnswerAgentCallsInvalidTool({ response, @@ -31,8 +30,6 @@ export async function setupAnswerAgentCallsInvalidTool({ mockTitleGeneration(proxy, title); } - mockHandoverToAnswer(proxy, 'ready to answer'); - mockAgentToolCall({ llmProxy: proxy, toolName: 'platform_core_search', diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_attachments_api.spec.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_attachments_api.spec.ts index 2dd72094e5bd7..3a8547bf1ac07 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_attachments_api.spec.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_attachments_api.spec.ts @@ -99,7 +99,7 @@ apiTest.describe( ); await llmProxy.waitForAllInterceptorsToHaveBeenCalled(); const firstAgentRequest = llmProxy.interceptedRequests.find( - (request) => request.matchingInterceptorName === 'handover-to-answer' + (request) => request.matchingInterceptorName === 'final-assistant-response' )?.requestBody; expect(firstAgentRequest).toBeDefined(); const allMessageContent = firstAgentRequest!.messages @@ -166,7 +166,7 @@ apiTest.describe( ); await llmProxy.waitForAllInterceptorsToHaveBeenCalled(); const firstAgentRequest = llmProxy.interceptedRequests.find( - (request) => request.matchingInterceptorName === 'handover-to-answer' + (request) => request.matchingInterceptorName === 'final-assistant-response' )?.requestBody; expect(firstAgentRequest).toBeDefined(); const allMessageContent = firstAgentRequest!.messages diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_tool_calling_api.spec.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_tool_calling_api.spec.ts index ae1e0305ccfc2..cd99aeacc4e3b 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_tool_calling_api.spec.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/tests/converse_tool_calling_api.spec.ts @@ -122,11 +122,11 @@ apiTest.describe( conversationIds.push(body.conversation_id); await llmProxy.waitForAllInterceptorsToHaveBeenCalled(); - const handoverRequest = llmProxy.interceptedRequests.find( - (request) => request.matchingInterceptorName === 'handover-to-answer' + const finalRequest = llmProxy.interceptedRequests.find( + (request) => request.matchingInterceptorName === 'final-assistant-response' )?.requestBody; - expect(handoverRequest).toBeDefined(); - const esqlToolCallMsg = handoverRequest!.messages[handoverRequest!.messages.length - 1]!; + expect(finalRequest).toBeDefined(); + const esqlToolCallMsg = finalRequest!.messages[finalRequest!.messages.length - 1]!; expect(esqlToolCallMsg.role).toBe('tool'); const toolCallContent = parseToolMessageContent(String(esqlToolCallMsg.content)); const [queryResult, esqlResults] = toolCallContent.results; diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/calls.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/calls.ts index 1303bae829b0d..2bdcff3e857d5 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/calls.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/calls.ts @@ -95,22 +95,7 @@ export const mockAgentParallelToolCalls = ({ }); }; -export const mockHandoverToAnswer = (llmProxy: LlmProxy, answer: string | LLmError) => { - void llmProxy - .intercept({ - name: 'handover-to-answer', - when: ({ messages }) => { - const systemMessage = messages.find((message) => message.role === 'system'); - return (systemMessage?.content as string).includes( - 'This response will serve as a handover note for the answering agent' - ); - }, - responseMock: answer, - }) - .completeAfterIntercept(); -}; - -export const mockFinalAnswer = (llmProxy: LlmProxy, answer: string) => { +export const mockFinalAnswer = (llmProxy: LlmProxy, answer: string | LLmError) => { void llmProxy .intercept({ name: 'final-assistant-response', diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/scenarios.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/scenarios.ts index da9d37fd0d168..f69dd285003f3 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/scenarios.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder_shared/lib/proxy_scenario/scenarios.ts @@ -9,7 +9,6 @@ import type { LlmProxy, LLmError } from '@kbn/ftr-llm-proxy'; import { mockTitleGeneration, mockTitleGenerationWithError, - mockHandoverToAnswer, mockFinalAnswer, mockAgentToolCall, mockAgentParallelToolCalls, @@ -33,7 +32,6 @@ export const setupAgentDirectAnswer = async ({ if (!continueConversation) { mockTitleGeneration(proxy, title); } - mockHandoverToAnswer(proxy, 'ready to answer'); mockFinalAnswer(proxy, response); }; @@ -54,7 +52,7 @@ export const setupAgentDirectError = async ({ if (!continueConversation) { mockTitleGenerationWithError(proxy, titleError ?? error); } - mockHandoverToAnswer(proxy, error); + mockFinalAnswer(proxy, error); }; /** @@ -91,8 +89,6 @@ export const setupAgentCallSearchToolWithEsqlThenAnswer = async ({ resource: { name: resourceName, type: resourceType }, }); - mockHandoverToAnswer(proxy, 'ready to answer'); - mockFinalAnswer(proxy, response); }; @@ -119,8 +115,6 @@ export const setupAgentCallSearchToolWithNoIndexSelectedThenAnswer = async ({ }, }); - mockHandoverToAnswer(proxy, 'ready to answer'); - mockFinalAnswer(proxy, response); }; @@ -145,7 +139,5 @@ export const setupAgentParallelToolCallsThenAnswer = async ({ toolCalls, }); - mockHandoverToAnswer(proxy, 'ready to answer'); - mockFinalAnswer(proxy, response); }; From 0b8640b7812359fea6a4d55ea7b258d826f431ca Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Tue, 19 May 2026 16:56:17 +0200 Subject: [PATCH 16/18] adapt scout tests due to removal (2) --- .../api/fixtures/setup_answer_agent_invalid_tool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts index 094b9c08f34a5..8720d80aa545d 100644 --- a/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts +++ b/x-pack/platform/plugins/shared/agent_builder/test/scout_agent_builder/api/fixtures/setup_answer_agent_invalid_tool.ts @@ -32,7 +32,7 @@ export async function setupAnswerAgentCallsInvalidTool({ mockAgentToolCall({ llmProxy: proxy, - toolName: 'platform_core_search', + toolName: 'nonexistent_tool', toolArg: { query: 'just a query', }, From f36b38cffd27c6a7cb48c9751605b1f0f913b715 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 20 May 2026 09:13:54 +0200 Subject: [PATCH 17/18] reset messageId between calls --- .../execution/run_agent/convert_graph_events.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts index fa4eb53b58fc4..430eb26224535 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/convert_graph_events.ts @@ -75,13 +75,11 @@ export const convertGraphEvents = ({ }); } - // TODO: fix, need a new ID per cycle - const messageId = uuidv4(); + // message identifier for emitted chunks + let messageId = uuidv4(); // Tracks the timestamp of the first text chunk of the current research turn. - // Used to backdate `thinkingCompleteEvent` to the first chunk of the terminal - // turn (the turn ending in a HandoverAction). Reset on each researchAgent - // on_chain_start. Only relevant in non-structured mode. + // Used to backdate `thinkingCompleteEvent` to the first chunk of the terminal turn let currentTurnFirstChunkAt: number | undefined; return streamEvents$.pipe( @@ -92,7 +90,10 @@ export const convertGraphEvents = ({ // reset per-turn first-chunk tracker at the start of each research turn if (matchEvent(event, 'on_chain_start') && matchName(event, steps.researchAgent)) { + // reset per-turn first-chunk tracker at the start of each research turn currentTurnFirstChunkAt = undefined; + // reset message id between research turns + messageId = uuidv4(); return EMPTY; } From af49cf84a1d896f8f2681a4c752a62ce4fad726e Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Wed, 20 May 2026 09:50:08 +0200 Subject: [PATCH 18/18] remove dummy default message - throw instead --- .../agents/execution_errors.ts | 3 ++ .../services/execution/run_agent/constants.ts | 1 - .../services/execution/run_agent/graph.ts | 34 ++++++++----------- .../prompts/utils/fallback_messages.test.ts | 21 ------------ .../prompts/utils/fallback_messages.ts | 15 -------- 5 files changed, 18 insertions(+), 56 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts delete mode 100644 x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/execution_errors.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/execution_errors.ts index f99a80c1b9fcc..4f9d703ebc8f9 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/execution_errors.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/execution_errors.ts @@ -20,6 +20,8 @@ export enum AgentExecutionErrorCode { invalidState = 'invalid_state', /** connector returned an HTTP error (4xx, 5xx) - status propagated to client */ connectorError = 'connector_error', + /** agent did not produce a final answer within its cycle budget */ + cycleLimitExceeded = 'cycle_limit_exceeded', } export interface ToolNotFoundErrorMeta { @@ -50,6 +52,7 @@ interface ExecutionErrorMetaMap { [AgentExecutionErrorCode.invalidState]: {}; [AgentExecutionErrorCode.emptyResponse]: {}; [AgentExecutionErrorCode.connectorError]: ConnectorErrorMeta; + [AgentExecutionErrorCode.cycleLimitExceeded]: {}; } export type ExecutionErrorMetaOf = diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts index fff65a7925e50..0f04d338b6307 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/constants.ts @@ -15,7 +15,6 @@ export const steps = { executeTool: 'executeTool', handleToolInterrupt: 'handleToolInterrupt', prepareToAnswer: 'prepareToAnswer', - prepareFallbackAnswer: 'prepareFallbackAnswer', answerAgent: 'answerAgent', finalize: 'finalize', }; diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts index 250bd152293d1..228415f9e836e 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/graph.ts @@ -39,7 +39,6 @@ import { isToolCallAction, isToolPromptAction, } from './actions'; -import { createCycleLimitFallbackMessage } from './prompts/utils/fallback_messages'; import type { ProcessedConversation } from './utils/prepare_conversation'; // number of successive recoverable errors we try to recover from before throwing @@ -156,7 +155,14 @@ export const createAgentGraph = ({ } else if (isToolCallAction(lastAction)) { const maxCycleReached = state.currentCycle > state.cycleLimit; if (maxCycleReached) { - return structuredOutput ? steps.prepareToAnswer : steps.prepareFallbackAnswer; + if (structuredOutput) { + return steps.prepareToAnswer; + } + throw createAgentExecutionError( + `Agent exceeded its cycle budget of ${state.cycleLimit} without producing a final answer.`, + ErrCodes.cycleLimitExceeded, + {} + ); } else { return steps.executeTool; } @@ -220,12 +226,6 @@ export const createAgentGraph = ({ } }; - const prepareFallbackAnswer = async (_state: StateType) => { - return { - mainActions: [handoverAction(createCycleLimitFallbackMessage(), true)], - }; - }; - const answerAgentStructured = createAnswerAgentStructured({ chatModel, promptFactory, @@ -263,8 +263,8 @@ export const createAgentGraph = ({ ); } - // Non-structured: the research agent's terminal HandoverAction (real or - // synthetic from prepareFallbackAnswer) carries the user-facing answer. + // Non-structured: the research agent's terminal HandoverAction carries the + // user-facing answer. const lastMainAction = state.mainActions[state.mainActions.length - 1]; if (isHandoverAction(lastMainAction)) { return { finalAnswer: lastMainAction.message }; @@ -305,15 +305,11 @@ export const createAgentGraph = ({ [steps.finalize]: steps.finalize, }); } else { - graphBuilder - .addNode(steps.prepareFallbackAnswer, prepareFallbackAnswer) - .addConditionalEdges(steps.researchAgent, researchAgentEdge, { - [steps.researchAgent]: steps.researchAgent, - [steps.executeTool]: steps.executeTool, - [steps.prepareFallbackAnswer]: steps.prepareFallbackAnswer, - [steps.finalize]: steps.finalize, - }) - .addEdge(steps.prepareFallbackAnswer, steps.finalize); + graphBuilder.addConditionalEdges(steps.researchAgent, researchAgentEdge, { + [steps.researchAgent]: steps.researchAgent, + [steps.executeTool]: steps.executeTool, + [steps.finalize]: steps.finalize, + }); } return graphBuilder.compile(); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts deleted file mode 100644 index 6569c5bf448c6..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createCycleLimitFallbackMessage } from './fallback_messages'; - -describe('createCycleLimitFallbackMessage', () => { - it('returns a non-empty user-facing string', () => { - const message = createCycleLimitFallbackMessage(); - expect(typeof message).toBe('string'); - expect(message.length).toBeGreaterThan(0); - }); - - it('mentions running out of steps so the user understands why', () => { - const message = createCycleLimitFallbackMessage(); - expect(message.toLowerCase()).toContain('step'); - }); -}); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts b/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts deleted file mode 100644 index 93c3791c79095..0000000000000 --- a/x-pack/platform/plugins/shared/agent_builder/server/services/execution/run_agent/prompts/utils/fallback_messages.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/** - * Static fallback message surfaced to the user when the agent hits the hard - * cycle limit without producing a HandoverAction. Used by the - * `prepareFallbackAnswer` graph node in non-structured mode. - */ -export const createCycleLimitFallbackMessage = (): string => { - return "I ran out of steps before finishing this task. Here's what I gathered so far — feel free to ask me to continue."; -};