Asynchronous / Interruptible Tool Calls
On Sun, 04 Oct 2026, by @lucasdicioccio, 1519 words, 12 code snippets, 0 links, 0images.
Generated from todos/async-tool-calls.md, the repository is the canonical source and may be ahead of this page.
Asynchronous / Interruptible Tool Calls
Goal
Make tool calls in a TUI/oneshot session truly interruptible and inspectable:
- Tool calls can execute concurrently instead of blocking the whole turn.
- The LLM can receive partial answers as soon as individual calls finish.
- The LLM can later query the status of a still-running or previously-yielded tool call.
- Tool calls become first-class entities in the ECS-style OS, visible to the rest of the system.
Today the flow is:
- LLM turn emits tool calls.
agents-exeschedules them synchronously:- tool call 1 runs
- tool call 2 runs
agents-exereturns the LLM turn.
We want:
- LLM turn emits tool calls.
agents-exepromotes each call to an OS entity and starts them concurrently.- As soon as at least one call has a useful partial result, the scheduler can yield a partial user turn back to the LLM.
- The LLM can continue the conversation, optionally calling a system capability to inspect any call it is interested in.
- When all calls for the turn are complete, the turn finalizes normally.
Current State
There is already async scaffolding in place:
System.Agents.Session.TypesdefinesTrackedToolCall,ToolCallState(Ready,Running,Deferred,Completed,Failed),ToolCallDisposition(RunSync,RunAsync,RunIsolated,Defer,Decorate),ContinuationToken, etc.System.Agents.Session.Asyncprovides continuation tokens, snapshots, and aContinuationStore(SQLite-backed).System.Agents.Session.StephasrunStepMAsync, which classifies calls viactxToolCallPolicyand can defer calls.System.Agents.Session.LoophasrunAsync/runAsyncWithProgressthat pause when aPartialUserTurnis present.
What is missing:
RunAsync/Runningare mostly nominal. Calls are still executed sequentially in one batch;RunAsyncjust defers them.- There is no real concurrent execution engine.
- There is no way for the LLM to ask “what is the status of tool-call X?” while it is running.
- Tool calls are not yet represented as ECS entities with runtime components.
- Partial answers are not surfaced: a call is either done or deferred.
Design Overview
We introduce three layers:
- ECS OS layer — every tool call becomes an
EntityIdwithToolCallConfigandToolCallStatecomponents. The OS is the source of truth for status. - Async execution engine — a small scheduler that runs approved calls concurrently, updates OS components, and emits heartbeat/partial progress.
- System-toolbox introspection capability — a new
get-tool-call-statuscapability (and possiblylist-running-tool-calls) that any agent can invoke.
The key idea is that the existing PartialUserTurn mechanism is extended so that a turn can be yielded before all calls are complete, carrying intermediate responses. The LLM can then decide whether to keep waiting, do something else, or inspect a specific call.
ECS Integration
System.Agents.OS.Conversation.Types already has the right component shapes:
ToolCallConfig— static info: turn, tool name, input args, parent call.ToolCallState— runtime status, timestamps, result value.
We extend them with async-specific fields:
data ToolCallConfig = ToolCallConfig
{ tcTurnId :: TurnId
, tcToolName :: Text
, tcToolInput :: Value
, tcParentCallId :: Maybe ToolCallId
, tcSessionId :: SessionId -- NEW: which session owns this call
, tcConversationId :: ConversationId -- NEW
}
data ToolCallState = ToolCallState
{ tcStatus :: ToolCallStatus
, tcStartedAt :: Maybe UTCTime -- NEW: was required, now optional until actually started
, tcCompletedAt :: Maybe UTCTime
, tcResult :: Maybe Value
, tcProgress :: [ToolCallProgress] -- NEW: ordered structured partial updates
}
-- NEW component-like value stored inside ToolCallState.
-- All progress payloads are structured JSON.
data ToolCallProgress = ToolCallProgress
{ progressAt :: UTCTime
, progressKind :: ProgressKind
, progressPayload :: Value
}
deriving (Show, Eq, Generic)
data ProgressKind
= ProgressStarted
| ProgressLog Text -- free-form log line (still wrapped in JSON)
| ProgressPartial Value -- structured partial result
| ProgressHeartbeat -- "still alive"
deriving (Show, Eq, Generic)When the scheduler receives tool calls from an LLM turn, it:
- Creates one OS entity per call.
- Attaches
ToolCallConfigandToolCallState { TcPending }. - Links the call back to the current
TurnIdandConversationId. - Returns a lightweight
ToolCallHandle(entity id +ToolCallId) to the session layer.
The existing TrackedToolCall keeps its ToolCallId but gains a tcEntityId :: Maybe EntityId so the session can map back to the OS.
Async Execution Engine
A new module System.Agents.Session.Async.Engine provides:
data AsyncEngine = AsyncEngine
{ aeWorld :: World
, aeExecutor :: ToolExecutionContext -> LlmToolCall -> IO UserToolResponse
, aeMaxConcurrency :: Int
}
-- | Start executing a batch of calls concurrently.
-- Returns handles and, if some calls already completed synchronously, their results.
startAsyncBatch :: AsyncEngine -> [TrackedToolCall] -> IO AsyncBatch
-- | Block until at least one call in the batch makes progress, then return.
waitForProgress :: AsyncBatch -> IO AsyncBatchUpdate
-- | Finalize any completed calls into UserToolResponses.
finalizeCompleted :: AsyncBatch -> IO [(ToolCallId, UserToolResponse)]
-- | Cancel all still-running calls (best effort).
cancelAsyncBatch :: AsyncBatch -> IO ()
-- | Cancel a single running call by its handle.
cancelToolCall :: AsyncEngine -> ToolCallId -> IO BoolThe engine uses async/Async (or forkIO + TVar) to run calls. For each call:
- It sets the OS state to
TcExecuting. - It runs the call in a background thread.
- The call can emit progress via a callback injected into
ToolExecutionContext:All payloads passed to this callback must be valid JSON values.ctxProgressCallback :: Maybe (Value -> IO ()) - On completion it sets the OS state to
TcCompleted result(orTcFailed err/TcCancelled).
Policy integration:
RunSynccalls are still executed inline.RunAsynccalls go to the engine.RunIsolatedcalls are delegated to the configured deployment runner but also tracked as OS entities.Defercalls stay inTcPendingwith a continuation token.
Partial Answers
A tool can emit a partial answer by calling the progress callback if the tool supports streaming. For tools that do not support streaming, the engine can still yield after each individual call finishes, so the LLM gets some responses earlier.
When the session stepper asks for a user turn, the scheduler can produce a PartialUserTurn with the subset of calls that have completed so far. The LLM then sees:
{
"tool_call_id": "...",
"status": "completed",
"result": "..."
}and for still-running calls:
{
"tool_call_id": "...",
"status": "running",
"progress": [
{"at": "...", "kind": "log", "payload": "downloading..."},
{"at": "...", "kind": "partial", "payload": {"percent": 50}}
]
}The LLM can choose to continue immediately (if the completed answers are enough) or call get_tool_call_status for more detail.
Tool-Call Status System Capability
Add new capabilities to SystemToolCapability:
| SystemToolGetToolCallStatus
| SystemToolListRunningToolCalls
| SystemToolCancelToolCalland expose them through System.Agents.Tools.SystemToolbox.
get_tool_call_status
Input schema:
{
"tool_call_id": "uuid",
"include_progress": true,
"wait_for_completion": false,
"timeout_seconds": 5
}Output schema:
{
"tool_call_id": "uuid",
"status": "pending|running|completed|failed|cancelled|orphaned",
"tool_name": "bash",
"started_at": "...",
"completed_at": null,
"result": null,
"progress": [...],
"is_final": false
}Status values:
pending— call is queued but has not started.running— call is executing.completed— call finished successfully.failed— call finished with an error.cancelled— call was cancelled before completion.orphaned— the call is referenced in the chat history but no longer exists in the OS (process restarted, entity was dropped, etc.). This is the clear “task no longer exists” status.
If wait_for_completion is true, the tool blocks up to timeout_seconds and returns the final result when available. This lets an LLM poll efficiently.
list_running_tool_calls
Returns calls filtered by the current session/conversation scope (reusing the existing session-introspection scope rules). Useful for the LLM to discover what it can inspect.
cancel_tool_call
Input schema:
{
"tool_call_id": "uuid",
"reason": "user request"
}Output schema:
{
"tool_call_id": "uuid",
"cancelled": true,
"previous_status": "running"
}Cancellation is best-effort. If the call is already final, the capability returns cancelled: false.
Changes to Session Step / Loop
-
runStepMAsynccurrently defersRunAsynccalls. We change it to:- Classify calls.
- Execute
RunSynccalls inline. - Hand
RunAsynccalls to the async engine. - If all calls complete quickly, produce a normal
UserTurn. - Otherwise produce a
PartialUserTurnand yield.
-
Add a new agent configuration field:
ctxAsyncYieldStrategy :: AsyncYieldStrategy data AsyncYieldStrategy = YieldOnAnyProgress -- yield as soon as one call finishes | YieldWhenAllDone -- DEFAULT: yield only once every synchronous and asynchronous call in the batch has reached a final state | YieldOnTimeout Int -- yield after N milliseconds even if nothing finished deriving (Show, Eq)The default is
YieldWhenAllDonefor backward compatibility with the existing synchronous mental model. -
On resume (
continuePartialTurn), the stepper checks the OS for calls that have completed since the last step and finalizes them. Any still-running calls remain in thePartialUserTurn. -
When the LLM invokes
get_tool_call_statusand the call is still running, the stepper does not block the whole session; it returns the current snapshot. If the call completed, the result is copied back into theTrackedToolCall.
Sub-Conversations and Lineage
Lineage remains important. Tool-call entities already carry tcParentCallId for nested calls. Sub-conversations will eventually need richer primitives so that one conversation can ask another conversation for more information. The work described here is a prerequisite: once a tool-call result can be replaced by a later status update, the LLM can treat a long-running sub-conversation as an “interruptible” tool call. For now we keep the existing call-stack/lineage plumbing and ensure child tool-call entities are created with the correct parent reference.
TUI / OneShot Integration
- The TUI already has a buffered-message queue and pause mechanism (
ConversationStatus_Paused). - When a partial turn is yielded, the TUI should render running tool calls with a spinner/progress indicator.
- The TUI event loop should listen for
AppEvent_ToolCallProgressevents emitted by the async engine and refresh the conversation view. - OneShot mode should support a
--wait-for-asyncflag; without it, oneshot exits with a JSON description of pending calls and a continuation token.
Lifecycle State Machine
Pending -> Executing -> Completed
-> Executing -> Failed
-> Executing -> Cancelled
-> Deferred -> Pending (wake/resume)
-> Deferred -> Completed (external completion)
When a session resumes after a process restart, any tool call referenced in the chat history that has no corresponding OS entity is reported as orphaned by get_tool_call_status. No in-progress call persistence is required; the LLM is responsible for deciding what to do with an orphaned call.
Ready / Running / Deferred / Completed / Failed from ToolCallState map cleanly to ECS ToolCallStatus:
Session ToolCallState | ECS ToolCallStatus |
|---|---|
| Ready | TcPending |
| Running | TcExecuting |
| Deferred | TcPending (with continuation token) |
| Completed | TcCompleted |
| Failed | TcFailed |
Cancelled session calls become TcCancelled. Missing OS entities become the orphaned status in the capability response.
New Modules / Files
System.Agents.Session.Async.Engine— concurrent execution engine.System.Agents.OS.Conversation.ToolCalls— helpers to create/update/query tool-call entities.System.Agents.Tools.SystemToolbox.ToolCallStatus— implementation of the new capabilities.System.Agents.TUI.Event.ToolCallProgress— TUI event handling for progress updates.
Modified Modules
System.Agents.OS.Conversation.Types— add progress, session/conversation ids to tool-call components; addTcCancelled.System.Agents.Session.Types— addtcEntityIdtoTrackedToolCall; addAsyncYieldStrategy.System.Agents.Session.Step— integrate the async engine and partial-yield logic.System.Agents.Session.Loop— handle resume from partial turns with running background calls.System.Agents.Session.Async— reuse continuation store; add progress-related helpers.System.Agents.Base— add newSystemToolCapabilityconstructors.System.Agents.Tools.SystemToolbox.Core— register new capabilities.System.Agents.Tools.SystemToolbox.Types— add query/error types for tool-call status.System.Agents.TUI.Types— add events for tool-call progress.
Implementation Phases
Phase 1: ECS Tool-Call Entities
- Promote tool-call creation from session layer to OS layer.
- Add
tcEntityIdtoTrackedToolCall. - Ensure every tool call in a session has a corresponding OS entity.
- Write tests in
test/OS/ConversationTests.hs.
Phase 2: Async Engine
- Implement
AsyncEnginewith background threads and progress callbacks. - Integrate engine into
runStepMAsyncforRunAsynccalls. - Keep
RunSyncbehavior unchanged. - Add
AsyncYieldStrategywith defaultYieldWhenAllDone.
Phase 3: System Capability
- Add
SystemToolGetToolCallStatus,SystemToolListRunningToolCalls, andSystemToolCancelToolCall. - Implement lookup by
ToolCallId/ entity id from the OSWorld. - Add
wait_for_completionoptional blocking. - Implement cancellation and the
orphanedstatus. - Expose capabilities in agent configs.
Phase 4: TUI / OneShot UX
- Render running calls and progress in the TUI.
- Add progress events to the TUI event channel.
- Add oneshot
--wait-for-async/ continuation-token resume.
Phase 5: Cleanup & Hardening
- Cancellation on session abort.
- Expiration of stale running calls.
- Rate limiting / max concurrency enforcement.
- Ensure orphaned status is returned consistently after process restart.
Open Questions
None remaining. Decisions from review:
- Progress payloads are structured JSON.
- Cancellation capability is included.
- Lineage is preserved; sub-conversation primitives will be built later on top of interruptible tool calls.
- In-progress calls are not persisted; missing OS entities are reported as
orphaned. - Default
AsyncYieldStrategyisYieldWhenAllDone.
Success Criteria
- [ ] Two long-running tool calls issued in one LLM turn execute concurrently.
- [ ] The LLM receives a partial user turn as soon as the first call finishes (when
YieldOnAnyProgressis configured). - [ ] The LLM can call
get_tool_call_statusfor any call and see current structured progress or final result. - [ ] The LLM can call
cancel_tool_callto stop a running call. - [ ] Tool calls appear as ECS entities in the OS
World. - [ ] Existing synchronous behavior is unchanged when
ExecutionMode = Synchronous. - [ ]
get_tool_call_statusreturnsorphanedfor calls referenced in history that no longer exist.