fix(workflows): one workflow step per LLM call so runs stop blowing the 800s ceiling
Fixes the duplicate task emails and the failing scheduled runs in chat#1918 by giving the agent loop real step boundaries.
The bug, from the runtime
runAgentStep wrapped the entire agent loop in a single "use step" via stopWhen: CHAT_AGENT_STOP_WHEN (stepCountIs(111)), so one step ran 11-25 minutes. WDK deploys step handlers with maxDuration: max, and on Pro that resolves to 800 s (Vercel duration limits) — not unlimited. Vercel terminated the invocation, the step queue redelivered it (retryAfterSeconds: 5), and each retry was a complete agent run that mailed the customer again.
Step "step//./app/lib/workflows/runAgentStep//runAgentStep"
exceeded max retries (4 retries) code: USER_ERROR
Step record on wrun_01KYY8X6WKMHQ8DN5P8JQP2H4T: status: failed, attempt: 5, 09:01:31 → 10:13:55 = 72.4 min, error {"message": "Unknown error"} — no exception and no stack, which is what a platform kill looks like. Every other step in that run completed on attempt 1 in under 0.2 s.
45 of the last 100 runs failed this way, 42 of them at 72.4-73.5 min (5 attempts × ~870 s). Completed runs sit at a 4.0 min median — interactive chat turns finish inside 800 s and were never affected. Full evidence in root-cause note v3.
The fix
One journaled step per LLM call, with the loop in the workflow body.
| File | Change |
|---|---|
runAgentStep.ts | Drops stopWhen. The AI SDK default is isStepCount(1) (@default isStepCount(1) in ai@6.0.190), so a step is now one model call plus that call's tool executions. Takes modelMessages / originalMessages, returns responseMessages. |
runAgentWorkflow.ts | Owns the loop. Appends each iteration's responseMessages so iteration N+1 sees iteration N's tool results; bounded by CHAT_AGENT_MAX_ITERATIONS; breaks on abort or any finish reason other than tool-calls. |
sendStreamStart.ts / sendStreamFinish.ts | New. The turn's stream envelope, emitted once at workflow level. |
convertMessagesStep.ts | New. Conversion runs once, journaled, before the loop. |
buildMessageMetadataCallback.ts | Optional seed so usage/cost totals span the whole turn instead of resetting per iteration. |
lib/chat/const.ts | Adds CHAT_AGENT_MAX_ITERATIONS = 111. |
The streaming objection, and why it does not block this
The concern was that per-iteration steps break streaming and per-turn persistence. They do not, and the reference implementation (vercel-labs/open-agents apps/web/app/workflows/chat.ts) shows the shape:
- Each iteration passes
sendStart: false, sendFinish: falsetotoUIMessageStream, so only the workflow body emits the envelope. Without this the client would render N assistant messages instead of one. - Every iteration writes to the same
writable. WDK streams are Redis-backed, and perfoundations/streaming.mdx: "Stream locks acquired in a step only apply within that step. This enables multiple writers to write to the same stream concurrently." originalMessagesthreads the in-progress assistant message into each iteration, soresponseMessagestays cumulative and each persist overwrites one row.
pipeWorkflowStreamWithStopDetection already passed preventClose: true, so the shared writable already survived a step returning — no change needed there.
Two deliberate calls
CHAT_AGENT_STOP_WHENstays.getGeneralAgent(the non-durable/api/chatroute) still runs its tool loop insidestreamText. Only the workflow path stops using it.modelMessagesis passed as a snapshot ([...modelMessages]), not the live array. A durable step input must describe the conversation as it was at that call, unaffected by later appends. A test caught this.
Tests
app/lib/workflows/__tests__/runAgentWorkflowLoop.test.ts (new, 5 tests) — written first, confirmed RED (4 failing), then GREEN:
- keeps iterating while a step finishes on
tool-calls, stops onstop - threads each iteration's
responseMessagesinto the next iteration'smodelMessages - emits exactly one stream start and one stream finish across a multi-iteration turn
- stops looping when a step reports the user aborted, even on
tool-calls - bounds a runaway loop at
CHAT_AGENT_MAX_ITERATIONS
Plus 3 new cases in runAgentStep.test.ts: stopWhen is absent (regression guard — if it creeps back the step regrows past 800 s and this bug returns), sendStart/sendFinish are false, and responseMessages is returned. The obsolete prepareStep cacheControl test is replaced by one asserting cacheControl on the messages handed to streamText.
- Full api suite: 4,319 tests / 795 files passing (re-run against a clean
pnpm install --frozen-lockfile). pnpm build: TypeScript step passes; the local run then stops at page-data collection on env vars the worktree lacks (STRIPE_SK), which CI has. Vercel check is green.tsc --noEmit: 203 errors, of which exactly one touches a file in this PR —runAgentWorkflow.test.tsProperty 'sandbox' does not exist on type 'never', which is pre-existing onmain(line 315 there, 333 here after this diff). No new type errors.eslintclean on all touched files.
Verification still owed before merge
Preview verification against a real run is not done yet and is the gate for this PR: start a long POST /api/chat/runs, then confirm via npx workflow inspect steps --runId=<id> --env preview that every runAgentStep record is attempt: 1 and under 800 s, that the chat renders one assistant message, and that exactly one email_send_log row lands. Results will be posted as a comment here.
Refs recoupable/chat#1918
🤖 Generated with Claude Code
Summary by cubic
Split the agent loop into one workflow step per LLM call to keep each step under Vercel’s 800 s limit and stop duplicate customer emails. Streaming now matches upstream open-agents and preserves tool-call parts across iterations. Fixes recoupable/chat#1918.
-
Bug Fixes
- Scheduled runs stay under 800 s per attempt; no duplicate emails.
- Tool-call parts persist across iterations; the UI renders one assistant message per turn via a hoisted stream envelope.
- Robust abort handling: treat aborted streams as user stops and close open tool-calls on abort.
- ai@6 path threads via
result.response.messages.
-
Refactors
- Moved the loop to
runAgentWorkflowwithCHAT_AGENT_MAX_ITERATIONS;runAgentStepruns one model call (nostopWhen). - Dropped the outer
createUIMessageStream; writeresult.toUIMessageStream(...)parts directly to the shared writable withsendStart: falseandsendFinish: false. - Persist from the workflow via
persistAssistantMessageStep;runAgentStepno longer takeschatId. SeededbuildMessageMetadataCallbackfromoriginalMessagesso usage/cost totals carry across iterations. - Added
convertMessagesStep,sendStreamStart,sendStreamFinish; removedpipeWorkflowStreamWithStopDetectionandfinalizeAbortedAssistantMessage; addedisAbortErrorandisRunCancelled. - Step input is
{ modelMessages, originalMessages }; step returnsresponseMessagesfor threading into the next iteration.
- Moved the loop to
Summary by CodeRabbit
- Improvements
- Improved assistant responses involving multiple tool-use steps, with smoother continuation across the full interaction.
- Streamed replies now begin and finish cleanly as a single response, reducing interruptions during multi-step processing.
- Usage, cost, and completion details remain accurate across extended assistant interactions.
- Added safeguards to prevent excessively long automated processing loops.
- Improved handling of interrupted or cancelled assistant runs for more reliable completion behavior.