Async Tool Calls Implementation Progress
On Sun, 04 Oct 2026, by @lucasdicioccio, 3176 words, 1 code snippets, 0 links, 0images.
Generated from todos/async-tool-calls-progress.md, the repository is the canonical source and may be ahead of this page.
Async Tool Calls Implementation Progress
Plan
See todos/async-tool-calls.md.
Phase Status
- [x] Phase 1: ECS Tool-Call Entities
- [x] Phase 2: Async Engine
- [x] Phase 3: System Capability (building blocks only — see review below)
- [x] Phase 3.5: End-to-End Correctness (added after review; blocks Phase 4)
- [x] Phase 4: TUI / OneShot UX (TUI rendering not yet checked by hand)
- [x] Phase 5: Cleanup & Hardening (tracing not done — see notes)
Success Criteria Status
Revised after the 2026-09-16 review, then after Phase 3.5 and Phase 4. The
criteria are met in the library and tests, and async execution is now enabled
from the agent JSON in the TUI, OneShot, sub-agents, and session commands.
The TUI rendering has not been checked by hand.
| Criterion | Status | Notes |
|---|---|---|
| Two long-running tool calls issued in one LLM turn execute concurrently | Met | asyncEngineTests; async calls now start before inline calls so they overlap. |
LLM receives a partial user turn as soon as the first call finishes (YieldOnAnyProgress) | Met (stepper) | Running calls get a placeholder tool message; late results are delivered in the next user turn. AsyncToolCallsTests. |
LLM can call get_tool_call_status and see structured progress or final result | Met | Accepts the provider id. Bash tools report output lines; sub-agent tools report their steps. |
LLM can call cancel_tool_call to stop a running call | Met | Interrupts the engine thread via ctxCancelToolCall; a running subprocess is terminated (processKilledOnCancel). |
Tool calls appear as ECS entities in the OS World | Met | Covered by sessionStepEntityTests and toolCallEntityTests. |
Existing synchronous behavior unchanged when ExecutionMode = Synchronous | Met | Same turns as before. Sync steps only differ when a session already holds background calls from an async run. |
get_tool_call_status returns orphaned for calls referenced in history that no longer exist | Met | Capability returns orphaned with is_final: true; the stepper fails such calls. Verified across a save/reload round-trip in a fresh world (orphanedAcrossRestart). |
Phase 1 Completion Notes
Phase 1 is complete. The following was done:
ECS Tool-Call Components
System.Agents.OS.Conversation.Typesalready hadToolCallConfig/ToolCallStateextended with:tcSessionId,tcConversationIdonToolCallConfig.tcProgress :: [ToolCallProgress],TcCancelledonToolCallState.ToolCallProgress/ProgressKindfor structured progress.
Tracked Tool Calls
System.Agents.Session.Typesalready hadtcEntityId :: Maybe EntityIdadded toTrackedToolCall.
Tool-Call Entity Helpers
- Extended
System.Agents.OS.Conversation.ToolCalls:- Added
ensureToolCallComponents/ensureToolCallComponentsIOfor idempotent component-store registration (safe to call on every step). createToolCallEntity,startToolCall,completeToolCall,failToolCall,cancelToolCall,addToolCallProgress.- Query helpers
findToolCallEntityBySessionIdandlistToolCallsBySessionAndConversation.
- Added
Session Step Integration
- Updated
System.Agents.Session.Step:prepareAgentWorldensures tool-call component stores are registered idempotently and threads the registered world through the agent/context.ensureTrackedCallEntitiespromotes everyTrackedToolCallto an OS entity when aWorldis available.executeTrackedCallWithEntityupdates the OS entity state toTcExecutingbefore running a call andTcCompleted(with the JSON result) after.- Both
runStepMSyncandrunStepMAsyncnow create/update OS entities for every tool call. - Existing behavior is unchanged when no
Worldis configured.
Tests
- Added
sessionStepEntityTeststotest/OS/ConversationTests.hscovering:- Sync step creates completed tool-call entities.
- Async step creates entities for both sync-completed and deferred calls.
- Async step with all-sync policy completes all calls and creates entities.
Baseline
- Full test suite passes.
Phase 2 Completion Notes
Phase 2 is complete. The async engine now runs RunAsync tool calls concurrently, keeps OS entity state in sync, and supports structured progress callbacks.
Async Engine (System.Agents.Session.Async.Engine)
AsyncEnginemanages a shared semaphore for max concurrency and a registry of active batches keyed byToolCallId.startAsyncBatchspawns each call in a backgroundAsyncthread, injects a per-call progress callback intoToolExecutionContext, and records the call in the registry.waitForProgress/waitForProgressTimeoutblock until at least one call reaches a final state.finalizeCompletedcollects finished calls and removes them from the running set and engine registry.cancelToolCall/cancelAsyncBatchsend async exceptions to background threads and mark OS entities asTcCancelled.
Yield Strategy
AsyncYieldStrategy(YieldOnAnyProgress,YieldWhenAllDone,YieldOnTimeout) is defined inSystem.Agents.Session.Types.waitAccordingToStrategyinSystem.Agents.Session.Stepimplements the three strategies.- The default strategy is
YieldWhenAllDone(set bywithAsyncConfig).
Progress & Lifecycle
- When a background call starts, the engine emits a
ToolCallProgressentry withProgressStarted(payload{"started":true}) alongside setting the entity status toTcExecuting. - The progress callback writes
ProgressPartialentries with arbitrary JSON payloads to the entity. completeToolCallandfailToolCallinSystem.Agents.OS.Conversation.ToolCallsnow leave the state unchanged if it is alreadyTcCancelled, preventing late engine completions from overwriting a cancellation.
Helpers
- Added
isToolCallCompletedIOandfindToolCallEntityByToolCallIdtoSystem.Agents.OS.Conversation.ToolCalls.
Tests
- Added
asyncEngineTeststotest/OS/ConversationTests.hscovering:- Two
RunAsynccalls execute concurrently (total time < 300 ms for two 200 ms sleeps). - Progress callback emits
ToolCallProgressentries, includingProgressStarted. cancelToolCallmarks an entity asTcCancelledand a later engine completion does not overwrite it; a second cancellation attempt returnsFalse.
- Two
Baseline
- Full test suite passes.
Phase 3 Completion Notes
Phase 3 is complete. Agents can now inspect and cancel async tool calls through the system toolbox.
Capability Constructors
- Added to
SystemToolCapabilityinSystem.Agents.Base:SystemToolGetToolCallStatusserialized asget-tool-call-status.SystemToolListRunningToolCallsserialized aslist-running-tool-calls.SystemToolCancelToolCallserialized ascancel-tool-call.
Types (System.Agents.Tools.SystemToolbox.Types)
GetToolCallStatusParams—tool_call_id,include_progress,wait_for_completion,timeout_seconds.ToolCallStatusResult— status, tool name, timing, result, progress,is_final.ListRunningToolCallsResult/RunningToolCallInfo.CancelToolCallParams/CancelToolCallResult.
Implementation (System.Agents.Tools.SystemToolbox.ToolCallStatus)
getToolCallStatuslooks up the OS entity byToolCallIdand translatesToolCallStatusto LLM-facing strings (pending,running,completed,failed,cancelled).- Supports
wait_for_completionwith a configurable timeout (polls every 50 ms). - If the entity is missing, searches the session’s
PartialUserTurnhistory and returnsorphanedwhen the call is referenced but no longer exists.
- Supports
listRunningToolCallsreturns all non-final tool-call entities for the current session/conversation.cancelToolCallByIdmarks a non-final entity asTcCancelledand reports the previous status; returnscancelled: falseif already final.
Registration (System.Agents.ToolRegistration)
- Updated
capabilityToTextandbuildSystemToolParamsto expose the new capabilities and their parameters (tool_call_id,include_progress,wait_for_completion,timeout_seconds,reason). - Routed
get-tool-call-status,list-running-tool-calls, andcancel-tool-callin thesystem_infotool handler.
Exports
System.Agents.Tools.SystemToolboxre-exports the new types and functions.agents.cabalexposesSystem.Agents.Tools.SystemToolbox.ToolCallStatus.
Tests
- Added
toolCallStatusTeststotest/OS/ConversationTests.hscovering:get-tool-call-statusreturnspendingthenrunning.get-tool-call-statusreturnscompletedafter completion.get-tool-call-statusreturnsorphanedfor a tracked call missing an OS entity.list-running-tool-callsreturns only non-final calls.cancel-tool-callmarks a running entity as cancelled and a second attempt reports already final.
Baseline
- Full test suite passes (870 tests).
Review (2026-09-16)
State at review time: branch asynctools, build green, 870 tests pass.
Phases 1–3 delivered the building blocks (entities, concurrent engine,
status/list/cancel capabilities), but the feature does not work end-to-end
in a real session. Findings:
R1. The LLM cannot address its own tool calls
get-tool-call-status/cancel-tool-calltake the internalToolCallIdUUID generated inmkReadyTrackedCall(Session/Step.hs,newToolCallId).- The model only ever sees the provider’s id (e.g.
call_abc123). There is no mapping between the two and the UUID is never surfaced to the model.
R2. Partial answers never reach the LLM correctly
turnToMessages (PartialUserTurn …)inSession/OpenAI.hsemits tool messages for completed calls only. A running call has no tool message, which OpenAI-compatible APIs reject.naiveTilNoToolCallStep(Session/Step.hs) only treatsReady/Deferredas outstanding. With onlyRunningcalls left, it asks for an LLM completion with a partial set of responses, then pushes anLlmTurnon top. The running calls’ results are buried and never delivered.continuePartialTurn→executeTrackedCallsprepends a newPartialUserTurninstead of replacing the head (unlikeSession/Wake.hs, which doesnewTurn : drop 1 turns). Every resume duplicates the turn in history. Pre-existing bug, but running calls now trigger it on every resume.
R3. cancel-tool-call does not stop anything
cancelToolCallById(Tools/SystemToolbox/ToolCallStatus.hs) only callsTCT.cancelToolCall, i.e. flips the entity toTcCancelled.- The background thread keeps running.
Engine.cancelToolCalldoes kill the thread, but the capability cannot reach the engine fromToolExecutionContext. - The capability test only asserts the status flag.
R4. The engine does not survive between steps
runAsyncWithProgress(Session/Loop.hs) returns only theSession; the agent carryingctxAsyncEngineis dropped. The caller resumes with an agent that has no engine.ensureAsyncEnginethen creates a fresh engine on each step, sodefaultMaxConcurrency = 4is per step rather than global, and the cancellation registry is lost.
R5. The stepper does not handle orphaned calls
pollRunningCallleaves a callRunningforever when its entity is missing (e.g. after a restart), so the partial turn never resolves.- Only the capability reports
orphaned, and it returnsis_final: falsefor a call that can never finish.
R6. Smaller issues
- Crash on malformed calls:
requireEntityIdinSession/Async/Engine.hscallserrorwhen a call has no entity id. That happens wheneverparseToolCallFromLlmToolCallfails, so a malformed call under an async policy crashes the step instead of degrading to inline. - No real overlap: in
executeTrackedCalls, inline calls run sequentially before the async batch is started. - No progress emitters: nothing calls
ctxProgressCallback. Also,mkSubcallContextpropagates the callback into sub-agent contexts, which would write their progress into the parent call’s entity. - Hard to enable: the agent JSON has
executionModeandtoolCallPolicyConfig, but only the durablesessionCLI applies them (applyAgentDurableConfiginCLI/SessionDurable.hs). The TUI and OneShot ignore them, and there is no config for yield strategy or concurrency.
Phase 3.5 Completion Notes
Done 2026-09-16. Build green, 875 tests pass (5 new end-to-end tests).
Semantics
- Placeholders.
partialToolMessages(Session/Types.hs) emits one tool message per call. Final calls carry their result. Non-final calls, and calls whose result was delivered late, get a JSON placeholder (status,tool_call_id, a hint to useget-tool-call-status/cancel-tool-call). Used byOpenAI.hs, both naive step functions, andWake.hs.Failedcalls now also get their tool message (previously dropped). - Late delivery.
collectLateResults(Session/Step.hs) findsRunningcalls in partial turns below the head, polls them, marks finished onestcDeliveredLate = True(new field onTrackedToolCall, optional in JSON), andlateResultsQueryrenders them into the next user turn’suserQuery(media is attached). The partial turn keeps showing the placeholder, so history matches what the model saw. Runs in both sync and async steps. - Stopping.
naiveTilNoToolCallStepno longer stops on an LLM turn without tool calls whilehasBackgroundCalls. It asks for a user turn, and the stepper blocks until the results arrive (all underYieldWhenAllDone, otherwise at least one). - Refresh.
runStepMAsyncrefreshes the head partial turn before each step (refreshHeadPartialTurn). Background completions are picked up, and the head becomes a fullUserTurnonce every call is final. - Resume.
executeTrackedCallstakesreplaceHead;continuePartialTurnreplaces the head instead of stacking. It also waits, per yield strategy, on calls carried over from earlier steps together with newly started ones. Finalization acceptsCompletedorFailed. - Message order.
OpenAI.hsnow emits tool messages before the user message in a turn (required once a user turn carries both).
Engine / ids / cancellation
ToolCallConfig.tcProviderCallIdstores the provider’s id (createToolCallEntityWithProviderId);findToolCallEntityByProviderCallIdresolves it within a session and conversation, preferring in-flight then most recently started calls (Kimi-style ids repeat).- Capabilities accept the provider id or the internal UUID;
list-running-tool-callsreturns the provider id. Session fallback reports final calls from history,Runningasorphaned(is_final: true), and deferred aspending.waitForFinalblocks on STM instead of polling. runStepMAsyncinstalls one engine on the agent (prepareAsyncEngine) and keeps it onEvolve.buildContextexposesEngine.cancelToolCallasctxCancelToolCall.cancel-tool-calluses it, falling back to marking the entity.- Engine: asynchronous exceptions are no longer swallowed (cancel really
interrupts the tool), each call unregisters itself when it finishes,
cancelToolCallreports whether the entity actually ended up cancelled, andTCT.cancelToolCallnever overwrites a final state.startAsyncBatchignores calls without an entity; the stepper runs those inline. - Orphans:
pollRunningCallfails aRunningcall whose entity (or world) is missing. withAsyncEngineregisters tool-call stores first and stores the resulting world on the agent, so engine and agent share the same world value.- Waits use
System.Timeoutrather thanregisterDelay, which needs the threaded RTS (the test suite is not threaded).
Tests (test/AsyncToolCallsTests.hs)
- Fast and gated slow call with
YieldOnAnyProgress: partial turn, placeholder seen by the LLM, stepper blocks and delivers the late result exactly once, no duplicated partial turn, every completion pairs each tool call with exactly one tool message, session stops afterwards. cancel-tool-callby provider id interrupts a sleeping tool (itsonExceptionhandler runs, it never finishes), entity iscancelled, next step finalizes the turn.Runningcall with a missing entity resolves as orphaned.- Partial turn with a deferred and a running call resumed twice stays a single head turn.
- Malformed
RunAsynccall runs inline instead of crashing. OS.ConversationTestsorphan capability test now uses aRunningcall and expectsis_final: true.
Known gaps carried forward
runAsync/runAsyncWithProgressstill return only the session. Resuming in-process with a fresh agent loses the engine registry unlesswithAsyncEnginewas installed up front (documented on both).- Placeholders do not include progress (history is pure; the model can call
get-tool-call-status). - If the LLM fetches a result via
get-tool-call-status, the late-delivery notice still repeats it once. - A head partial turn with only
Deferredcalls still spins underrun(pre-existing;runAsyncpauses correctly). - Cancel against a call whose engine is gone only marks the entity; the thread (if any) keeps running.
Phase 4 Completion Notes
Enablement
Base.AgentgainsasyncYieldStrategyandmaxConcurrency; the runtimeAgentgainsctxMaxConcurrency(used when the engine is created).applyAgentDurableConfig/buildToolCallPolicymoved toSystem.Agents.Session.AgentConfig(re-exported byCLI.SessionDurable) and are applied inOneShot.nodeToAgentWithThinking(OneShot, TUI,sessioncommands) andAgentTree.OneShotTool.nodeToAgent(sub-agents).prepareAgentWorldgives an asynchronous agent without aWorlda private one; otherwiseRunAsynccalls silently ran inline.llmToolCallNamenow lives inSession.Types.
Example agent JSON:
"executionMode": "asynchronous",
"asyncYieldStrategy": "yieldOnAnyProgress",
"maxConcurrency": 4,
"toolCallPolicyConfig": {"default": {"tag": "runAsync"}, "rules": []}Progress events
OSEvent_ToolCallActivity ToolCallActivity(OS.Events) with phases started / progressed / completed / failed / cancelled, keyed by session id, conversation id, tool-call id and provider id.- The engine publishes them on the context’s
ctxEventQueue. The TUI already bridges its OS event queue to the Brick channel (TUI.Core.startOSEventBridge), soconvertOSEventmaps them toAppEvent_ToolCallActivity; noRuntimeBridgechange was needed. - Tool-call progress entries on the entity are capped at 50 (newest first).
TUI
TUI.ToolCallActivitykeeps aToolCallViewsmap (per session, per call) inUIState._toolCallViews: latest phase, start time, last progress. Views are pruned when a session update shows the call is no longer running.- Rendering: a “Background tool calls running: …” line above the turns, and one line per tracked call in partial turns (pending / deferred / running with latest progress / completed / delivered later / failed). No spinner.
- While background calls run and the LLM has nothing to do, the TUI asks for
user input instead of blocking: the stepper’s new
askUserQueryracesusrQueryagainst the background calls (per the yield strategy) and cancelsusrQueryif the calls finish first.usrQuerymust tolerate cancellation (the TUI reads aBChan, which is STM).
OneShot / loops
--wait-for-asyncwas not added.runalready waits for in-process background calls (they cannot outlive the process). Stopping early would only orphan them.- New
Loop.runUntilBlocked/isBlockedOnDeferredCalls: returns the session when the head partial turn only waits on deferred calls instead of spinning. OneShot uses it, stores the session under its session id too, and prints a JSON report (status: paused,session_id, deferred calls with continuation tokens) forsession complete/session resume. - Markdown export (
SessionPrint) lists each call’s status in partial turns and shows finished results. The search index includes failed results and names of all calls in partial turns.
Progress emitters
- Bash tools: when
ctxProgressCallbackis set (async calls), the script runs throughBash.runProcessReportingOutput, which reports{stream, line, lines, bytes}at most every 0.5s per stream. The process is terminated if the call is cancelled. Synchronous calls keep the old path. - Sub-agent tools (
OneShotTool) report{message, turns}after each sub-agent step, and now rethrow async exceptions so cancelling them works (they were caught and turned into failures). mkSubcallContextno longer copies the parent’s progress callback or cancel hook.
Tests (test/AsyncToolCallsTests.hs, now 17)
- Async agent without a World;
ctxMaxConcurrencybound; engine activity events; background results vs user query race (both ways); TUI view apply/prune/summaries; markdown partial turn;runUntilBlocked; subprocess output reports; subprocess terminated on cancel. SessionDurableTests: yield strategy / max concurrency application and JSON parsing. Full suite: 890 tests.
Known gaps carried forward
- TUI rendering and the input race were not exercised by hand.
- Bash progress is tested on
runProcessReportingOutputdirectly, not through a registered bash toolbox end to end. terminateProcesssends SIGTERM to the script only; its children may survive (Phase 5: process groups).runitself still spins on deferred-only partial turns; onlyrunUntilBlockedusers (OneShot) avoid it. The TUI still usesrun.- Earlier gaps (engine not returned by
runAsync, repeated result afterget-tool-call-status) remain.
Phase 5 Completion Notes
Shutdown and cancellation
Engine.shutdownAsyncEnginecancels every batch the engine still owns.Loop.run/runWithProgress/runUntilBlockedshut the engine down when the run ends or throws (tracking the evolving agent in anIORef);runAsync*only on failure, since pausing with calls running is the point.- Bash scripts run in their own process group and are killed with
SIGTERM, thenSIGKILLafter 100ms, so cancelling kills what the script started. Verified: the test fails without the group kill. - The TUI kills conversation threads when quitting (bounded to 2s), which runs the same cleanup.
- The TUI uses
runUntilBlocked, so a conversation waiting only on deferred calls stops with a status message instead of spinning.
Stale calls
asyncCallTimeoutSeconds(agent JSON) →ctxAsyncCallTimeout→aeCallTimeout. A call that outlives it is interrupted (killing its subprocess) and reported as failed: “async tool call timed out after Ns”.
Orphans across a restart
get-tool-call-statusused to needincludeFullSession, which is off by default (it also pushes the whole session into every bash tool’s environment). Contexts now carryctxSessionToolCalls: the tracked calls of the session’s partial turns only. Without it, a reloaded session answered “tool call not found” instead of “orphaned”.orphanedAcrossRestartwrites a session with a running call to disk, reloads it in a freshWorld, and checks both the capability and the stepper.
Concurrency
maxConcurrencystays per agent.newAsyncConcurrencyLimit+mkAsyncEngineSharinglet a host share one limit across agents (each agent needs its own engine, since the executor is tied to its tools). No rate limiting (calls per minute) was added.
Docs
- New
documentation/async-tool-calls.mdcovers configuration, placeholders and late delivery, the capabilities, progress, cancellation, the TUI, one-shot runs, session files and the limits. Linked fromdocumentation/README.md,documentation/durable-workflows-howto.md. documentation/tools.mdlists the three capabilities;documentation/tui.mddocuments background call display and input while calls run;documentation/cli-commands.mddocuments the paused JSON report ofrun.- The agent JSON in the new doc was checked by parsing it with
Base.Agent.
MCP / OpenAPI streaming (question from the plan)
- OpenAPI tools wait for one complete HTTP response: nothing to stream.
- Done:
MCP.Clientadvertises a_meta.progressTokenontools/callwhen the call has a progress callback, and forwards the server’snotifications/progressfor that token toctxProgressCallback(payload{progress, total?, message?}), so MCP tools report progress like bash tools (tool.progressedevents,get_tool_call_status).
Not done
- Tracing.
Agentcarries no tracer (tracers live in the builders’ closures), soProd.Tracersupport would mean threading one through the agent and the engine. The activity events (OSEvent_ToolCallActivity) and the progress entries on the entity are the observability path for now. - Rate limiting beyond a concurrency cap.
- A pre-existing docs bug found on the way, now fixed: JSON examples used key
spellings the parsers reject. Every builtin toolbox (
Name,Description,Capabilities,FileSandbox, …), file sandboxes (fsbPredicate,fsbMaxFileSize,fsbName), bash toolboxes (Path,BasenameFilter), OpenAPI/PostgREST servers (SpecUrl,BaseUrl,Token), the removed LuaallowedPathsfield, the old SQLitepath/accessfields (nowVersioning), and the kebab-case agent keys indocumentation/advanced-configuration.md(api-key-id→apiKeyId, …). Fixed indocumentation/tools.md,documentation/file-loader.md,documentation/advanced-configuration.mdand theAgent/bashToolboxesHaddock inSystem.Agents.Base. Every JSON block indocumentation/now decodes into the real types; the throwaway checker used for this is in the session scratchpad.
Tests (test/AsyncToolCallsTests.hs, now 21)
- Failing run cancels background calls; call timeout; orphan across a save/reload; subprocess killed on cancel; process group killed on cancel.
- Full suite: 894 tests, async group stable over repeated runs.
Completion Plan
Phase 3.5: End-to-End Correctness (done — see completion notes above)
- Call ids (R1). Accept the provider’s tool-call id in
get-tool-call-status/cancel-tool-call/list-running-tool-calls, either by storing it onToolCallConfigor by resolving it through the tracked calls. Keep the internal UUID as a fallback.list-running-tool-callsshould return the id the model knows. - Placeholder responses (R2). For calls still
Running(orDeferred) when the LLM is asked for a completion, emit a tool message such as{"status":"running","tool_call_id":…,"progress":[…]}so the request is valid and the model knows the call is pending. - Delivering late results (R2). A finished result cannot be attached to a
tool message the model already answered. Inject it into the next user turn
as a notice (e.g. “tool call X completed: …”). Update
naiveTilNoToolCallStepsoRunningcalls count as outstanding and so completed-late calls are surfaced. - Partial turns (R2). Replace the head
PartialUserTurnon resume instead of prepending a new one (matchSession/Wake.hs). - One engine (R3, R4). Either return the agent from
runAsync/runAsyncWithProgress, or keep the engine alongside the OSWorldso every step shares it. Add a cancel hook toToolExecutionContextsocancel-tool-callactually kills the thread viaEngine.cancelToolCall. - Orphaned calls (R5). In
pollRunningCall, mark aRunningcall whose entity is missing asFailedwith an “orphaned” response. Returnis_final: truefor orphaned calls in the capability. - Malformed calls (R6). Replace the
errorinrequireEntityIdwith a fallback that executes the call inline. - End-to-end tests.
- Two slow calls with
YieldOnAnyProgress→PartialUserTurn→ resume → fullUserTurn, with a valid OpenAI message list at every step (every tool call has exactly one tool message, no duplicated turns). cancel-tool-callstops a real running tool (e.g. a sleeping bash command) and the thread is gone.- A
Runningcall whose entity is missing is resolved as orphaned.
- Two slow calls with
Phase 4: TUI / OneShot UX (done — see completion notes above)
- Enablement.
executionModeandtoolCallPolicyConfigalready exist in the agent JSON; apply them in the TUI and OneShot agent builders (reuseapplyAgentDurableConfig), and addasyncYieldStrategyandmaxConcurrency. Without this nothing in the UI can be exercised. - Progress events. Add
AppEvent_ToolCallProgresstoSystem.Agents.TUI.Types. The engine emits throughctxEventQueuewhen present. Decide how the event reaches the TUIBChangiven the TUI still goes through the legacyRuntimeBridge. - Rendering. Show running calls with a spinner and latest progress in
TUI/Render/Conversation.hs; handle partial turns inSessionPrint/formatSessionAsMarkdown. - OneShot. Add
--wait-for-async. Without it, exit with JSON listing the pending calls and the session to resume. - A real progress emitter. Make one tool call
ctxProgressCallback(bash output lines are the obvious candidate). Stop propagating the callback into sub-agent contexts, or give sub-calls their own.
Phase 5: Cleanup & Hardening (done except tracing — see completion notes above)
- Cancel running batches on session abort, TUI quit, or exception; shut the
engine down with the agent. Kill whole process groups on cancel.
Stop
run(and the TUI) from spinning on deferred-only partial turns. - Expire stale calls (timeout plus a check that the thread is still alive).
- Global max concurrency / rate limiting, configurable (builds on Phase 3.5 step 5).
- Tracing for async calls and progress in
Prod.Tracer. - Verify orphan handling across a real process restart + session reload.
- Update
documentation/tools.md,documentation/tui.md,documentation/cli-commands.md, and document the agent JSON schema for async. - Clarify whether MCP / OpenAPI tools can stream into the progress callback.