mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
fix(ai-agent): reset the streamed answer on providers that skip tool_call
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b5389bc075
commit
3e2dac0cf9
@@ -79,13 +79,10 @@ lazy_static::lazy_static! {
|
||||
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
const HARD_MAX_AGENT_ITERATIONS: usize = 1000;
|
||||
|
||||
/// What a run stopped by `max_iterations` reports back: the conversation it got
|
||||
/// through before giving up.
|
||||
///
|
||||
/// `Message` rather than `OpenAIMessage` is load-bearing. `agent_action` is
|
||||
/// `skip_serializing` on `OpenAIMessage` and only reaches JSON through this
|
||||
/// wrapper, so serializing these raw drops every tool name and job id and leaves
|
||||
/// the partial run unreadable — which is the one thing worth having on this path.
|
||||
/// What a run stopped by `max_iterations` reports back. `Message` rather than
|
||||
/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the
|
||||
/// latter and reaches JSON only through this wrapper, so serializing these raw
|
||||
/// drops every tool name and job id and leaves the partial run unreadable.
|
||||
#[derive(serde::Serialize)]
|
||||
struct MaxIterPartialResult<'a> {
|
||||
messages: Vec<Message<'a>>,
|
||||
@@ -1903,10 +1900,6 @@ mod tests {
|
||||
assert!(!streaming_requested(Some(false)));
|
||||
}
|
||||
|
||||
/// The frontend reads a capped run's partial actions out of this payload and
|
||||
/// keys entirely off `agent_action`. That field is `skip_serializing` on
|
||||
/// `OpenAIMessage`, so serializing these messages directly rather than through
|
||||
/// `Message` silently empties the trace of the one run worth reading.
|
||||
#[test]
|
||||
fn max_iterations_partial_result_keeps_the_action_tags() {
|
||||
let messages = vec![OpenAIMessage {
|
||||
|
||||
@@ -151,4 +151,22 @@ describe('a stream that narrates before calling a tool', () => {
|
||||
const poll3 = advanceAgentStream(afterCall + '{"type":"token_delta","content":"Done."}\n', poll2)
|
||||
expect(poll3.stream.answer).toBe('Done.')
|
||||
})
|
||||
|
||||
// Bedrock has its own streaming implementation rather than the shared SSE
|
||||
// parsers, and never announces `tool_call` — only the arguments, then the
|
||||
// worker's `tool_execution`. Keying the reset on `tool_call` alone leaves the
|
||||
// narration in place for that provider.
|
||||
it('resets on a provider that never announces the call itself', () => {
|
||||
const raw = [
|
||||
'{"type":"token_delta","content":"Let me check the metrics."}',
|
||||
'{"type":"tool_call_arguments","call_id":"c1","function_name":"q","arguments":"{}"}',
|
||||
'{"type":"tool_execution","call_id":"c1","function_name":"q"}',
|
||||
'{"type":"tool_result","call_id":"c1","function_name":"q","result":"{}","success":true}',
|
||||
'{"type":"token_delta","content":"eu-central-1 is down."}',
|
||||
''
|
||||
].join('\n')
|
||||
expect(advanceAgentStream(raw, emptyAgentStreamProgress()).stream.answer).toBe(
|
||||
'eu-central-1 is down.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,6 +156,14 @@ export function emptyAgentStreamProgress(): AgentStreamProgress {
|
||||
return { consumed: 0, stream: { answer: '', reasoning: '' } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Any of these means a tool call is beginning, and which one arrives depends on
|
||||
* the provider: the SSE parsers announce `tool_call`, while Bedrock streams only
|
||||
* the arguments and the worker follows with `tool_execution`. Resetting on all
|
||||
* three is idempotent and keeps the rule provider-independent.
|
||||
*/
|
||||
const TOOL_TURN_STARTED = ['tool_call', 'tool_call_arguments', 'tool_execution']
|
||||
|
||||
const STREAM_EVENT_TYPES = [
|
||||
'token_delta',
|
||||
'reasoning_token_delta',
|
||||
@@ -232,11 +240,11 @@ export function advanceAgentStream(
|
||||
} else if (event.type === 'reasoning_token_delta' && typeof event.content === 'string') {
|
||||
stream.reasoning += event.content
|
||||
} else if (typeof event.function_name === 'string') {
|
||||
if (event.type === 'tool_call') {
|
||||
if (TOOL_TURN_STARTED.includes(event.type)) {
|
||||
// A model can narrate and request a tool in the same turn, and the loop
|
||||
// then runs again. That narration is not part of the answer — the
|
||||
// finished result keeps only the last turn's text — so a new call
|
||||
// starts the answer over rather than appending to what came before.
|
||||
// finished result keeps the text of the last turn that produced any — so
|
||||
// a starting call resets rather than appending to what came before.
|
||||
stream.answer = ''
|
||||
stream.reasoning = ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user