Skip to content

Commit 3b9a884

Browse files
committed
fix(workflow): align engine with ant 2.1.150 (throttle, alias, serialize guard)
Three faithful-parity fixes to the workflow engine ported in 933da2a, found by diffing against ant 2.1.150 (3886/3892/3904): 1. Throttle backoff (hooks.ts) — ant 3886 `aH`/`r6(45000)`. After the stall loop, detect a throttled/empty response: no stop_reason, no structured output, <50 output tokens, yet ran >half the stall window. Sleep 45s (abort-aware) and retry once. Without it a rate-limited workflow burns its hard-stall (180s) retries instead of cooling down. Threaded `stop_reason` through AgentExecResult to drive the predicate. 2. RunWorkflow alias (WorkflowTool.ts) — ant 3904 `gJ3` declares `aliases:["RunWorkflow"]`; ccb omitted it, so the model couldn't invoke the tool under that name. 3. Serializability guard (engine.ts) — ant 3892 `ZHK` calls `IH(M)` (JSON.stringify) on the cloned result before returning. ccb's structuredCloneSafe falls back to the raw value for non-cloneable inputs, so a non-serializable result would leak into the task notification. Re-assert JSON.stringify(cloned) so it surfaces as a workflow error instead. Two ant divergences left as documented staged-gaps (not regressions): the named-workflow registry (.claude/workflows/ loader + builtin merge — ccb marks it "wired in P5") and worktree-isolation agent spawning (ccb's `isolation:'worktree'` is currently a progress-event field only; ant serializes real worktree creation via an ElH(1) semaphore — full worktree creation + cleanup is a self-contained follow-up, not half-implemented here).
1 parent 3279daf commit 3b9a884

3 files changed

Lines changed: 78 additions & 0 deletions

File tree

packages/agent/workflow/engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ export async function runWorkflow(
9090
// Deep-clone the result out of the vm realm so host-side consumers don't
9191
// hold references into the (frozen) sandbox.
9292
const cloned = structuredCloneSafe(result)
93+
// ant 3892 ZHK calls `IH(M)` (= JSON.stringify) on the cloned result before
94+
// returning — a serializability guard. structuredCloneSafe can fall back to
95+
// the raw value for non-cloneable inputs, so re-assert here: a result that
96+
// can't serialize must surface as a workflow error (caught below), not leak
97+
// a non-JSON value into the task notification.
98+
JSON.stringify(cloned)
9399
return {
94100
result: cloned,
95101
agentCount: hooks.getAgentCount(),

packages/agent/workflow/hooks.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,33 @@ type AgentExecResult = {
120120
skipped: boolean
121121
durationMs: number
122122
outputTokens?: number
123+
// Last assistant message's stop_reason — null when the turn ended without
124+
// one (the throttle signal: ant 3886 `aH` treats stopReason==null + tiny
125+
// output + long duration as a throttled/empty response).
126+
stopReason?: string | null
127+
}
128+
129+
/**
130+
* Abort-aware sleep — ant 3886 `r6(ms, signal, {throwOnAbort:true})`. Resolves
131+
* after `ms`, or rejects immediately if `signal` aborts (so a workflow abort
132+
* during the throttle cooldown propagates instead of waiting out the 45s).
133+
*/
134+
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
135+
return new Promise<void>((resolve, reject) => {
136+
if (signal?.aborted) {
137+
reject(new Error('Workflow aborted'))
138+
return
139+
}
140+
const timer = setTimeout(() => {
141+
signal?.removeEventListener('abort', onAbort)
142+
resolve()
143+
}, ms)
144+
const onAbort = (): void => {
145+
clearTimeout(timer)
146+
reject(new Error('Workflow aborted'))
147+
}
148+
signal?.addEventListener('abort', onAbort, { once: true })
149+
})
123150
}
124151

125152
/**
@@ -404,6 +431,44 @@ export function createWorkflowHooks(
404431
)
405432
}
406433

434+
// Throttle backoff — ant 3886 `aH`/`r6(45000)`. A turn with no stop_reason,
435+
// no structured output, <50 output tokens, yet running >half the stall
436+
// window is a rate-limited/empty response, not real work. Sleep 45s + retry
437+
// once (ant parity); without it a throttled workflow burns the hard-stall
438+
// (180s) retries instead of cooling down.
439+
const isThrottled = (r: AgentExecResult): boolean =>
440+
!r.stalled &&
441+
!r.skipped &&
442+
(r.stopReason == null) &&
443+
r.structured === undefined &&
444+
(r.outputTokens ?? Infinity) < 50 &&
445+
r.durationMs > stallMs * 0.5
446+
if (isThrottled(res)) {
447+
log(
448+
`[throttle] agent "${label}" throttled response (no stop_reason, ${res.outputTokens ?? '?'} output tokens in ${Math.round(res.durationMs / 1000)}s) — sleeping 45s before retry`,
449+
)
450+
await sleepAbortable(45_000, abortSignal)
451+
res = await runWithStall(
452+
index,
453+
prompt,
454+
`${label} (throttle-retry)`,
455+
phaseTitle,
456+
phaseIndex,
457+
stallMs,
458+
opts,
459+
agentDef,
460+
availableTools,
461+
!!schemaTool,
462+
onAgentId,
463+
res.durationMs,
464+
)
465+
if (isThrottled(res)) {
466+
log(
467+
`[throttle] agent "${label}" still throttled after retry — continuing with partial result`,
468+
)
469+
}
470+
}
471+
407472
if (res.skipped) return null
408473
if (res.stalled) {
409474
throw new Error(
@@ -445,6 +510,7 @@ export function createWorkflowHooks(
445510
let structured: unknown
446511
let lastText = ''
447512
let outputTokens: number | undefined
513+
let stopReason: string | null = null
448514

449515
const model = opts?.model ?? toolUseContext.options.mainLoopModel
450516
const emit = (
@@ -533,6 +599,7 @@ export function createWorkflowHooks(
533599
message: {
534600
content: Array<{ type: string; text?: string; name?: string }>
535601
usage?: { output_tokens?: number }
602+
stop_reason?: string | null
536603
}
537604
}
538605
let textPart = ''
@@ -543,6 +610,7 @@ export function createWorkflowHooks(
543610
}
544611
if (textPart) lastText = textPart
545612
toolCalls += calls
613+
stopReason = am.message.stop_reason ?? null
546614
outputTokens = am.message.usage?.output_tokens ?? outputTokens
547615
if (typeof outputTokens === 'number') tokens = outputTokens
548616
if (calls > 0) {
@@ -616,6 +684,7 @@ export function createWorkflowHooks(
616684
skipped: false,
617685
durationMs,
618686
outputTokens,
687+
stopReason,
619688
}
620689
}
621690

packages/tool-registry/src/tools/WorkflowTool/WorkflowTool.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ async function resolveScript(input: {
118118
export const WorkflowTool = buildTool({
119119
isMcp: false,
120120
name: WORKFLOW_TOOL_NAME,
121+
// ant 3904.js gJ3: `aliases:["RunWorkflow"]` — the model may invoke the tool
122+
// under either name.
123+
aliases: ['RunWorkflow'],
121124
searchHint: 'orchestrate subagents with a deterministic JavaScript workflow',
122125
maxResultSizeChars: 100_000,
123126
isEnabled() {

0 commit comments

Comments
 (0)