Skip to content

[Agent Builder] Support posting messages without agent execution - #290223

Open
brunofarache wants to merge 39 commits into
elastic:mainfrom
brunofarache:codex/agent-builder-message-only
Open

[Agent Builder] Support posting messages without agent execution#290223
brunofarache wants to merge 39 commits into
elastic:mainfrom
brunofarache:codex/agent-builder-message-only

Conversation

@brunofarache

@brunofarache brunofarache commented Sep 10, 2026

Copy link
Copy Markdown
Member

Closes #288967

Adds trigger_mode: 'never' to POST /api/chat/converse so integrations can append user messages to an existing conversation without running an agent. For example, an integration can persist incident updates as user_message events, then a later normal request can ask the agent to summarize them.

User-message requests require conversation_id and only support input and/or attachments in addition to trigger_mode. They do not accept agent_id, access_control, read_only, model-routing fields, execution fields, prompts, action, browser tools, configuration overrides, or project routing. This keeps the mode append-only for existing event-native conversations and avoids creating conversations through trigger_mode: 'never'.

User-message writes persist events and attachment versions atomically, enforce scoped conversation access through the conversation client, and return the updated ConversationWithPermissions.

This PR intentionally targets only POST /api/chat/converse. The streaming POST /api/chat/converse/async endpoint does not accept trigger_mode yet because trigger_mode: 'never' has no agent response to stream. We can add trigger_mode to the streaming endpoint later when auto is supported. The internal converse callback endpoint also stays execution-only and does not expose trigger_mode; callers that only want to append history should use POST /api/chat/converse.

Extends event-based context preparation to retain these user messages, attribution, attachment context, and ordering, so a later normal request sees them as ordinary human turns in the prompt. Omitted or explicit always mode retains existing execution behavior; legacy public converse schemas remain unchanged.

Limitations

Compaction still indexes rounds, not timeline entries. A conversation whose history contains standalone user messages therefore excludes them from the compaction token budget, and once a compaction runs they are dropped from the compacted context. Conversations that have not compacted are unaffected. Generalizing compaction to timeline entries — entry-indexed summaries with an event boundary — is deferred to a follow-up.

No UI work is included: the chat UI renders rounds, and nothing in the app reads the events timeline, so appended messages are stored and reach the agent but are not displayed. A conversation that has only appended messages therefore looks empty in the UI until a triggering request creates a round. Showing them is part of the UI work for this feature, tracked separately.

These user messages are history/context only: trigger_mode: 'never' rejects prompts, does not clear pending prompts, and cannot resume a paused HITL round.

Builds on the merged #288986. UI changes, automatic triggering, and legacy-conversation migration are outside this change.

Manual testing

/api/chat is gated by the agentBuilder:experimentalFeatures advanced setting, so enable it first. The examples assume a local Kibana at http://localhost:5601.

The calls authenticate with an API key, created from Stack Management → Security → API keys:

export API_KEY=<encoded key>

Requests authenticated this way are attributed to the key owner's user profile, so the conversation is also visible to that user in the Kibana UI.

Create an empty conversation:

CONVERSATION_ID=$(curl -s \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d '{"title":"Incident 4821"}' \
  'http://localhost:5601/api/agent_builder/conversations' \
  | jq -r '.id')

Append two user messages without triggering the agent. They carry a fact the agent could only know from them. Each call returns the updated conversation; rounds stays empty and events grows by one user_message:

curl -s \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d "{\"trigger_mode\":\"never\",\"conversation_id\":\"$CONVERSATION_ID\",\"input\":\"The passcode to the vogon shield generator is orange-swordfish-42\"}" \
  'http://localhost:5601/api/chat/converse' \
  | jq '{rounds: (.rounds | length), events: [.events[].data.message]}'
curl -s \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d "{\"trigger_mode\":\"never\",\"conversation_id\":\"$CONVERSATION_ID\",\"input\":\"Heads up: it rotated an hour later, the passcode is now blue-narwhal-7\"}" \
  'http://localhost:5601/api/chat/converse' \
  | jq '{rounds: (.rounds | length), events: [.events[].data.message]}'

Opening the conversation in the UI at this point shows nothing — see the limitation above. The responses of the two calls are where the appended messages are visible.

Now ask the agent. This request omits trigger_mode, so it executes normally and the two messages above are part of the history the model sees. It also passes _execution_mode: local: by default a converse request runs on a Task Manager node, whose reconstructed request does not carry the API key's profile identity, so it cannot read a private conversation and the run fails with Conversation … not found.

curl -s \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d "{\"conversation_id\":\"$CONVERSATION_ID\",\"input\":\"What is the current passcode to the shield generator, and what was it before?\",\"_execution_mode\":\"local\"}" \
  'http://localhost:5601/api/chat/converse' \
  | jq -r '.rounds[-1].response.message'

The answer should be blue-narwhal-7, previously orange-swordfish-42. Neither string appears in the triggering message, so answering at all proves the appended messages reached the model, and naming the current one proves they arrived in order.

Run locally, the agent answered:

- **Current passcode:** blue-narwhal-7 (this is the one that took effect after the rotation)
- **Previous passcode:** orange-swordfish-42 (the original one, before it rotated an hour later)

Rejected requests, for the negative cases. A user message needs a conversation to append to:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d '{"trigger_mode":"never","input":"orphan"}' \
  'http://localhost:5601/api/chat/converse'

Neither input nor attachments:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d "{\"trigger_mode\":\"never\",\"conversation_id\":\"$CONVERSATION_ID\"}" \
  'http://localhost:5601/api/chat/converse'

Execution-only options are not accepted in this mode:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: ApiKey $API_KEY" \
  -X POST \
  -H 'kbn-xsrf: true' \
  -H 'Content-Type: application/json' \
  -H 'elastic-api-version: 2023-10-31' \
  -d "{\"trigger_mode\":\"never\",\"conversation_id\":\"$CONVERSATION_ID\",\"input\":\"x\",\"agent_id\":\"my-agent\"}" \
  'http://localhost:5601/api/chat/converse'

@infra-vault-gh-plugin-prod

Copy link
Copy Markdown
🤖 Jobs for this PR can be triggered through checkboxes. 🚧

ℹ️ To trigger the CI, please tick the checkbox below 👇

  • Click to trigger kibana-pull-request for this PR!
  • Click to trigger kibana-deploy-project-from-pr for this PR!
  • Click to trigger kibana-deploy-cloud-from-pr for this PR!
  • Click to trigger kibana-entity-store-performance-from-pr for this PR!
  • Click to trigger kibana-storybooks-from-pr for this PR!

@brunofarache
brunofarache force-pushed the codex/agent-builder-message-only branch 2 times, most recently from 784c622 to f5f9b45 Compare September 10, 2026 11:14
@brunofarache
brunofarache force-pushed the codex/agent-builder-message-only branch from f5f9b45 to 49d325b Compare September 10, 2026 11:19
Return the value produced by contextMessagePayloadSchema.validate() instead of
casting the raw request body, and move the ContextMessagePayload type next to
the other chat wire types in common/http_api/chat.ts.
callbackConversePayloadSchema already rejects trigger_mode at runtime, so omitting it
from ChatCallbackRequestBodyPayload only made the type diverge from the other converse
payloads. With the omission gone the type extends ChatRequestBodyPayload, which also
collapses executeAgent's payload union to a single type.
appendContextMessage minted neither: every caller had to pass a messageId and a
createdAt, plus empty-value fallbacks for message and attachments. The route was
the only caller and always passed a fresh uuid and the current time, so the
replay-identity check the messageId existed for could never match across
requests; it is removed along with the parameters.
appendContextMessage hand-rolled a read-modify-write loop because its attachment
materialization was async and writeConversation's fields callback is not. Callers
elsewhere (sml, attachments routes, runner) already own the attachment state manager
and hand the client refs plus snapshot/produced, which reconcileAttachments merges
against stored state, so the route now does the same. That makes the field callback
synchronous, so the method delegates to writeConversation and drops its duplicate
retry loop and the getTypeDefinition parameter.

Also drops the unused create and origin inputs, and the read_only guard: read_only
is documented as a UI-only presentation flag that carries no authorization meaning,
and no other write path enforces it.
The conversation client no longer carries a bespoke appendContextMessage: the
operation is now ConversationService.appendContextMessage, which reads the
conversation, materializes the caller's attachment inputs and appends a single
user message event through the existing appendEvents write path. appendEvents
itself is unchanged.

The service is the natural home because the actor depends on the requesting user,
which is private to it, and because it can own the attachment state manager now
that it takes the attachment service as a dependency. The route is left with
payload validation, attachment validation and its HTTP error mapping.
prepare_conversation already promoted a user message's attachment inputs into
conversation-level attachments, deduplicating by content hash and passing a resolve
context so by-reference attachments resolve. Context messages had a simpler loop
that did neither. The logic moves to the attachment service, which already builds
the resolve context for validate, and both paths now call it.

The execution path calls the module directly rather than the service method, since
it only holds the public AttachmentsService contract; the dedupe map is no longer
threaded through, as seeding it from the state manager is equivalent.

Context messages now reuse a stored attachment when the same content is posted
again, matching converse.
Narrowing it to Pick<Conversation, 'user'> was not needed: the conversation service
holds the conversation, so it can spread it and override the user the way the
conversation client did before the move.
The route treats a whitespace-only input as no message when it checks that a request
carries input or attachments, so store the trimmed value rather than the raw one.
It only wrapped a comparison the execution paths already made inline, so restore
the original conversation.operation === 'CREATE' checks.
chatPayloadSchema now reads as its two modes instead of inlining the extended
converse payload in the union.
The entry carried an events array that was always [userMessage], so drop it and let
its one consumer derive the events it iterates. Naming the union member also makes
TimelineEntry read as its two kinds.

The generic stays: the entry is read both on the raw timeline and on the processed
one, where the message payload is a ProcessedRoundInput.
Four call sites discriminated the entry union with an inline 'terminated' in entry
check. isTimelineRound now holds that knowledge, and isTimelineStandaloneUserMessage
reads the other way where a positive condition is clearer.

The standalone guard only narrows in positive position: a round satisfies the
standalone member structurally, so excluding it leaves never. Sites that need round
fields in the negative branch keep isTimelineRound.
The processed timeline loop matched the entry's own message by object identity, which
held only because grouping spreads the same references into the round's event list.
Comparing ids is equivalent, matches how the rest of the file identifies events, and
does not silently drop the message if a future step clones events.

formatRoundInput also renders standalone user messages now, so it becomes
formatUserInput, and prepareMessages iterates entries rather than rounds.
Hoist every type in context_timeline.ts above the functions, align the missing
position fallback between the two comparators, and say why each one sorts the way
it does: one repairs a lossy fold, the other a concatenation by entry kind, and
neither can order on timestamps alone.
The relevant-skills selector built its recent context from executed rounds only, so
messages posted without triggering the agent never informed it. It now receives
every timeline entry; an entry with no response renders just its user line, and the
character budget counts the message slots that actually exist rather than assuming
two per round.
The test posted trigger_mode: never with no conversation_id and expected the
conversation to be created, which stopped being true once user messages required
a conversation to append to. It now asserts that request is rejected, creates the
conversation through the conversations API, and appends to it.
Main reworked attachment validation while this branch was open: validate is now
per attachment and returns a result object, carries a validateContext, and skips
revalidation when content is unchanged. That contract wins; this branch's plural,
throwing variant is gone.

The array handling the routes need — validate each input, reject the request on
the first invalid one — moves to validate_attachment_inputs, shared by the
execution service (which held it privately) and the chat route. Main's
validateContext and content-hash check are ported into merge_attachment_inputs,
the extracted helper both the execution path and the conversation service use.
@brunofarache
brunofarache marked this pull request as ready for review September 14, 2026 12:47
@brunofarache
brunofarache requested a review from a team as a code owner September 14, 2026 12:47
@brunofarache brunofarache self-assigned this Sep 14, 2026
@kibanamachine kibanamachine added the reviewer:scout Agentic PR Scout test review label Sep 14, 2026
@brunofarache brunofarache added backport:skip This PR does not require backporting release_note:skip Skip the PR/issue when compiling release notes feature:agent-builder Identify agent builder functionalities to be grouped together for release notes labels Sep 14, 2026
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
Comment thread x-pack/platform/plugins/shared/agent_builder/server/routes/chat.ts Dismissed
The test walks a five-phase flow and was one ~90-line block, so a failure surfaced
as a bare assertion with no indication of which phase broke. Each phase is now a
labelled step, and the shared request headers are hoisted.
@brunofarache

Copy link
Copy Markdown
Member Author

CodeQL alerts dismissed as pre-existing

CodeQL flagged six unbounded schemas in server/routes/chat.ts (12838, 12839, 12840, 12841, 12842, 12843): the input string, the attachment id / type / origin / data keys, and the attachments arrayOf.

All six are pre-existing. Those schemas are identically unbounded on main — this PR only extracted them from inline definitions into the inputSchema and attachmentsSchema constants so the new trigger_mode: 'never' payload could reuse them. CodeQL attributes alerts to changed lines, so the move re-surfaced them.

Bounding them changes what the public converse endpoints accept (/api/agent_builder/converse, /converse/async, the internal callback, and /api/chat/converse): a request over the limit starts returning 400. That is an API contract decision about maximum message and attachment sizes, not part of "post a message without running the agent", so it belongs in its own PR.

Dismissed as "won't fix" with that reasoning recorded on each alert.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the trigger_mode: 'never' (append user message without execution) change. The schema split (chatPayloadSchema oneOf), scoped/atomic append in appendUserMessage, the extracted mergeAttachmentInputs/validateAttachmentInputs, and the timeline-entry refactor (groupTimelineEntries, standaloneUserMessages, buildRecentContext framing math) look faithful and are well covered by unit + scout tests. One concrete finding on error classification is left inline.

Generated by Claude Reviewer for #290223 · claude · opus · 199.7 AIC · ⌖ 46 AIC · ⊞ 7K

A conversation created before the events-native migration cannot take a user
message, and the caller cannot fix that by retrying. createInternalError carries
no status code, so the route answered 500; it is now a bad request, and the test
asserts the class rather than only the message.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the trigger_mode: 'never' append-user-message feature. The schema split (chatPayloadSchema oneOf), route handling, authorization via the scoped conversation client, atomic event/attachment persistence, and the timeline/context refactors are well-structured and thoroughly tested. One concrete concern noted inline about de-duplicated attachment content losing its per-message reference in the new append path.

Generated by Claude Reviewer for #290223 · claude · opus · 228.1 AIC · ⌖ 22.7 AIC · ⊞ 5.5K

type: TimelineEventType.userMessage,
created_at: new Date().toISOString(),
actor: userMessageActor({ ...conversation, user }, { author }),
data: { message: message.trim(), attachment_refs: stateManager.getAccessedRefs() },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new user message's attachment_refs are taken solely from stateManager.getAccessedRefs(), but mergeAttachmentInputs (in merge_attachment_inputs.ts) skips an input via continue whenever its content already matches a stored attachment (storedIdByContentKey.has(getContentKey(...))) — that skip path does not add/update/get, so nothing is access-tracked.

Result: if an integration appends a message carrying an attachment whose content is already stored on the conversation (a realistic case for repeated incident/context posts), the attachment is correctly de-duplicated at the conversation level, but the new user_message event is persisted with that attachment omitted from attachment_refs — so the message silently loses its association to the attachment the caller sent.

In the pre-existing round flow this was masked because refs were merged with the input's own attachment_refs (mergeAttachmentRefs(input.attachment_refs, getAccessedRefs())); appendUserMessage has no incoming refs, so getAccessedRefs() is the only source and the dropped ref becomes observable. Consider having the dedup-skip path resolve the existing attachment id into an accessed ref (or otherwise ensure de-duplicated inputs still produce a ref) before building the event. Note the reuses a stored attachment unit test only asserts attachments length, not that the second message references it, so this gap isn't currently covered.

@kibanamachine

kibanamachine commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⏳ Build in-progress, with failures

Failed CI Steps

Test Failures

  • [job] [logs] Scout Lane #46 - serverless-search / agent_builder / local-serverless-search - Agent Builder — chat API converse (/api/chat) - user message sync requests persist context for the next model request
  • [job] [logs] Scout Lane #46 - serverless-search / agent_builder / local-serverless-search - Agent Builder — chat API converse (/api/chat) - user message sync requests persist context for the next model request
  • [job] [logs] Scout Lane #28 - stateful-classic / agent_builder / local-stateful-classic - Agent Builder — chat API converse (/api/chat) - user message sync requests persist context for the next model request
  • [job] [logs] Scout Lane #28 - stateful-classic / agent_builder / local-stateful-classic - Agent Builder — chat API converse (/api/chat) - user message sync requests persist context for the next model request
  • [job] [logs] FTR Configs #44 / Webhook - disabled ssl pfx webhook should not render the pfx tab for ssl auth

History

cc @brunofarache

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:skip This PR does not require backporting feature:agent-builder Identify agent builder functionalities to be grouped together for release notes release_note:skip Skip the PR/issue when compiling release notes reviewer:scout Agentic PR Scout test review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Agent Builder] Send message to group, don't trigger the agent

3 participants