Summary
The Agent module ships a "Cancel pending tools" button (agent.ui.yaml / chat-agent.ui.yaml) that is currently disabled in code. When enabled, clicking it cancels the running child sub-workflows but does NOT persist tool_result blocks for the cancelled tool_use ids. The next llmTurn then sends Claude an assistant message containing tool_use blocks with no matching user tool_result blocks, and the API returns 400 ("tool_use without matching tool_result"). The widget: line in agent.workflow.ts is commented out as a stop-gap.
Steps to reproduce
- Re-enable the widget in
agent-module/src/workflows/agent.workflow.ts (uncomment the widget: line around L57) or run the chat-agent variant which still has its widget enabled.
- Start an agent run with at least one tool call that delegates to a sub-workflow so the agent enters
awaiting_tools.
- While in
awaiting_tools, click the "Cancel pending tools" button.
- Wait for the next
llmTurn to fire.
Expected behaviour
Cancel ends the in-flight tool calls cleanly, synthetic tool_result blocks (marked as errors / cancellation) are written to the message log for every pending tool_use id, and the next LLM turn proceeds normally (or the agent terminates) without an API error.
Actual behaviour
cancelPendingTools (agent.workflow.ts:144, chat-agent.workflow.ts:165) cancels children and transitions awaiting_tools → ready directly, skipping the toolsComplete step that writes tool_result blocks to LlmMessageDocument. The next llmTurn ships a history with assistant tool_use blocks but no matching user tool_result message → Anthropic returns 400 "tool_use without matching tool_result".
Contributing factors observed during investigation:
- The cancelled-children pipeline (
WorkflowOrchestrationService.cancelChildren → cancel → complete → resume(parent, ..., 'toolResultReceived')) does fire callbacks, but toolResultReceived is declared from: 'awaiting_tools'. By the time those callbacks arrive, the cancel transition has already moved the parent to ready, so the callbacks no longer match and are dropped.
- Even if they did fire, only
toolsComplete flushes tool_result blocks to the document store; the cancel path bypasses it.
LlmDelegateResult only retains completed toolResults and a pendingCount — it does not carry the list of pending tool_use ids. The ids only live on state.llmResult.message, which the cancel path does not consume.
Affected area
- Package(s):
@loopstack/agent-module, @loopstack/llm-provider-module, @loopstack/core
- File(s):
loopstack/registry/features/agent-module/src/workflows/agent.workflow.ts (cancelPendingTools at L144, disabled widget comment at L54-57)
loopstack/registry/features/agent-module/src/workflows/chat-agent.workflow.ts (cancelPendingTools at L165)
loopstack/registry/features/agent-module/src/workflows/agent.ui.yaml
loopstack/registry/features/llm-provider-module/src/services/llm-delegate.service.ts (updateToolResult, handleToolCompletion)
loopstack/registry/features/llm-provider-module/src/types/llm.types.ts (LlmDelegateResult shape)
loopstack/packages/core/src/workflow-processor/services/workflow-orchestration.service.ts (cancelChildren, cancel, complete)
Environment
- Branch / commit:
main
- Node / npm / OS: TBD
Additional context
The disabled widget comment in agent.workflow.ts:54-56 already documents the user-visible symptom. Re-enabling the button without addressing the persistence gap will reproduce the 400 every time. A fix needs to (a) decide where the synthetic tool_result blocks are constructed (cancel path or a still-running toolsComplete), (b) surface the pending tool_use ids to that code (e.g. via LlmDelegateResult or by reading llmResult.message), and (c) make sure the cancel transition and the cancelled-children callbacks do not race over delegateResult.
Summary
The Agent module ships a "Cancel pending tools" button (agent.ui.yaml / chat-agent.ui.yaml) that is currently disabled in code. When enabled, clicking it cancels the running child sub-workflows but does NOT persist
tool_resultblocks for the cancelledtool_useids. The nextllmTurnthen sends Claude an assistant message containingtool_useblocks with no matching usertool_resultblocks, and the API returns 400 ("tool_use without matching tool_result"). Thewidget:line inagent.workflow.tsis commented out as a stop-gap.Steps to reproduce
agent-module/src/workflows/agent.workflow.ts(uncomment thewidget:line around L57) or run the chat-agent variant which still has its widget enabled.awaiting_tools.awaiting_tools, click the "Cancel pending tools" button.llmTurnto fire.Expected behaviour
Cancel ends the in-flight tool calls cleanly, synthetic
tool_resultblocks (marked as errors / cancellation) are written to the message log for every pendingtool_useid, and the next LLM turn proceeds normally (or the agent terminates) without an API error.Actual behaviour
cancelPendingTools(agent.workflow.ts:144, chat-agent.workflow.ts:165) cancels children and transitionsawaiting_tools → readydirectly, skipping thetoolsCompletestep that writestool_resultblocks toLlmMessageDocument. The nextllmTurnships a history with assistanttool_useblocks but no matching usertool_resultmessage → Anthropic returns 400 "tool_use without matching tool_result".Contributing factors observed during investigation:
WorkflowOrchestrationService.cancelChildren→cancel→complete→resume(parent, ..., 'toolResultReceived')) does fire callbacks, buttoolResultReceivedis declaredfrom: 'awaiting_tools'. By the time those callbacks arrive, the cancel transition has already moved the parent toready, so the callbacks no longer match and are dropped.toolsCompleteflushestool_resultblocks to the document store; the cancel path bypasses it.LlmDelegateResultonly retains completedtoolResultsand apendingCount— it does not carry the list of pendingtool_useids. The ids only live onstate.llmResult.message, which the cancel path does not consume.Affected area
@loopstack/agent-module,@loopstack/llm-provider-module,@loopstack/coreloopstack/registry/features/agent-module/src/workflows/agent.workflow.ts(cancelPendingToolsat L144, disabled widget comment at L54-57)loopstack/registry/features/agent-module/src/workflows/chat-agent.workflow.ts(cancelPendingToolsat L165)loopstack/registry/features/agent-module/src/workflows/agent.ui.yamlloopstack/registry/features/llm-provider-module/src/services/llm-delegate.service.ts(updateToolResult,handleToolCompletion)loopstack/registry/features/llm-provider-module/src/types/llm.types.ts(LlmDelegateResultshape)loopstack/packages/core/src/workflow-processor/services/workflow-orchestration.service.ts(cancelChildren,cancel,complete)Environment
mainAdditional context
The disabled widget comment in
agent.workflow.ts:54-56already documents the user-visible symptom. Re-enabling the button without addressing the persistence gap will reproduce the 400 every time. A fix needs to (a) decide where the synthetictool_resultblocks are constructed (cancel path or a still-runningtoolsComplete), (b) surface the pendingtool_useids to that code (e.g. viaLlmDelegateResultor by readingllmResult.message), and (c) make sure the cancel transition and the cancelled-children callbacks do not race overdelegateResult.