mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: recognise an agent stream that opens with an empty token
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a2e10d734
commit
9a3f42d250
@@ -117,10 +117,6 @@ describe('buildAgentTrace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A run stopped by max_iterations serializes its partial messages itself rather
|
||||
// than reusing the success envelope's writer. `agent_action` is `skip_serializing`
|
||||
// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and
|
||||
// this trace silently empties — which is the one run worth reading.
|
||||
describe('splitFinalAnswer', () => {
|
||||
it('moves the answering turn out of the trace, with its citations', () => {
|
||||
const entries = buildAgentTrace([
|
||||
@@ -165,6 +161,10 @@ describe('splitFinalAnswer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A run stopped by max_iterations serializes its partial messages itself rather
|
||||
// than reusing the success envelope's writer. `agent_action` is `skip_serializing`
|
||||
// on `OpenAIMessage`, so if that path ever stops wrapping them the tags vanish and
|
||||
// this trace silently empties — which is the one run worth reading.
|
||||
describe('the max-iterations path', () => {
|
||||
it('traces the partial messages the error carries', () => {
|
||||
const partial = parseAgentErrorMessages({
|
||||
|
||||
@@ -78,6 +78,9 @@ describe('agent stream', () => {
|
||||
expect(isAgentStream('{"level":"info","msg":"hello"}\n')).toBe(false)
|
||||
// No newline yet, so the first line may still be half-written.
|
||||
expect(isAgentStream('{"type":"token_delta","content":"a"}')).toBe(false)
|
||||
// A provider that does not filter its empty deltas opens with one, and the
|
||||
// run is an agent's all the same.
|
||||
expect(isAgentStream('{"type":"token_delta","content":""}\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('folds the token deltas into the answer so far', () => {
|
||||
@@ -200,6 +203,15 @@ describe('a stream that narrates before calling a tool', () => {
|
||||
// The narration became a row rather than disappearing.
|
||||
expect(stream.entries[0]).toEqual({ kind: 'assistant', content: 'Let me check the metrics.' })
|
||||
})
|
||||
|
||||
// A script can write anything to `result_stream`, so a tool event is not
|
||||
// guaranteed the fields its type declares.
|
||||
it('ignores a tool event with nothing to key or label a row by', () => {
|
||||
const raw = '{"type":"token_delta","content":"Hi."}\n{"type":"tool_call"}\n'
|
||||
const { stream } = advanceAgentStream(raw, emptyAgentStreamProgress())
|
||||
expect(stream.entries).toEqual([])
|
||||
expect(stream.current).toBe('Hi.')
|
||||
})
|
||||
})
|
||||
|
||||
// A result is whatever a script returned, so a message that has a `role` still has
|
||||
|
||||
@@ -224,6 +224,24 @@ export function emptyAgentStreamProgress(): AgentStreamProgress {
|
||||
*/
|
||||
const TOOL_TURN_STARTED: StreamEvent['kind'][] = ['tool_call', 'tool_arguments', 'tool_execution']
|
||||
|
||||
/**
|
||||
* The worker's wire names, for deciding whether a stream is an agent's at all.
|
||||
*
|
||||
* Membership rather than whether `parseStreamEvents` yields something: that drops
|
||||
* an event carrying an empty payload, and a first `token_delta` with no content
|
||||
* is what a provider opens with when it does not filter them (Bedrock does not).
|
||||
* Reading the yield would answer "not an agent" for the whole run and leave a
|
||||
* real one rendering as a wall of event objects.
|
||||
*/
|
||||
const STREAM_EVENT_TYPES = [
|
||||
'token_delta',
|
||||
'reasoning_token_delta',
|
||||
'tool_call',
|
||||
'tool_call_arguments',
|
||||
'tool_execution',
|
||||
'tool_result'
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether `result_stream` is an agent's event stream rather than something a
|
||||
* script printed. Reads only the first complete line, because it runs on every
|
||||
@@ -239,15 +257,20 @@ export function isAgentStream(raw: string): boolean {
|
||||
}
|
||||
const line = raw.slice(start, end)
|
||||
if (line.trim() !== '') {
|
||||
// The JSON check is this function's own because `parseStreamEvents` reports
|
||||
// a line it cannot read, and most streams reaching here are a script's
|
||||
// plain output rather than a malformed event.
|
||||
let parsed: unknown
|
||||
// Parsed here rather than left to `parseStreamEvents`, which reports a line
|
||||
// it cannot read: most streams reaching this check are a script's plain
|
||||
// output, not a malformed event.
|
||||
try {
|
||||
JSON.parse(line)
|
||||
parsed = JSON.parse(line)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return parseStreamEvents(line).length > 0
|
||||
return (
|
||||
isRecord(parsed) &&
|
||||
typeof parsed.type === 'string' &&
|
||||
STREAM_EVENT_TYPES.includes(parsed.type)
|
||||
)
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
@@ -282,6 +305,13 @@ export function advanceAgentStream(
|
||||
stream.reasoning += event.content
|
||||
continue
|
||||
}
|
||||
// `result_stream` is whatever the job wrote, so a tool event can arrive
|
||||
// without the fields its type promises. Such a line is not a turn boundary
|
||||
// and not a row: keyed on nothing, it would draw an unlabelled card that
|
||||
// every later nameless event joins.
|
||||
if (typeof event.callId !== 'string' || typeof event.name !== 'string') {
|
||||
continue
|
||||
}
|
||||
if (TOOL_TURN_STARTED.includes(event.kind)) {
|
||||
if (stream.current !== '') {
|
||||
// A model can narrate and request a tool in the same turn. The call
|
||||
|
||||
Reference in New Issue
Block a user