Deferred (async) tool approval: pause a turn and resume later
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 176
- Forks
- 28
- Avg merge
- 18h 42m
- Merged PRs (30d)
- 16
Description
Motivation
chatlas already supports synchronous tool approval: raising ToolRejectError from a Chat.on_tool_request() callback (chatlas/_tools.py) rejects a call before it's invoked (see docs/tool-calling/approval.qmd, which currently ends with "### Chatbots — Coming soon"). But this only works when the approval decision is available immediately, in-process — the callback in _invoke_tool/_invoke_tool_async (chatlas/_chat.py) must return synchronously. There's no way to pause a turn mid-tool-loop, return control to the caller (e.g. a Shiny app rendering an "Approve/Deny" button, or a human who reviews the request hours later), and resume the same conversation once a decision arrives.
Three frameworks now solve exactly this: llm 0.32a3's llm.PauseChain mechanism (https://llm.datasette.io/en/latest/changelog.html), Pydantic AI's requires_approval=True tools that raise ApprovalRequired, ending the run with a DeferredToolRequests object resumed later via DeferredToolResults (https://ai.pydantic.dev/deferred-tools/), and LangChain's HumanInTheLoopMiddleware / interrupts. chatlas's own docs flag this gap already ("Chatbots: Coming soon" in approval.qmd), so this issue proposes closing it.
Proposed approach
import chatlas as ctl
chat = ctl.ChatAnthropic()
def delete_file(path: str):
"Delete a file"
...
# Mark a tool (or a specific request) as requiring deferred approval,
# analogous to Pydantic AI's `requires_approval=True`.
chat.register_tool(delete_file, annotations={"destructiveHint": True}, requires_approval=True)
result = chat.chat("Delete scratch.csv")
# `.chat()`/`.stream()` returns/raises early instead of looping forever:
if chat.pending_approvals:
for req in chat.pending_approvals: # list[ContentToolRequest]
print(req.name, req.arguments)
# ... hand `req.id` to a UI, persist `chat.export_state()` if the process
# might restart, wait for a human ...
# Resume with decisions, keyed by ContentToolRequest.id
chat.resume(
approvals={req.id: True}, # or ToolRejectError(reason) to deny
)
Implementation sketch, grounded in the existing tool loop:
_chat_impl/_chat_impl_asyncinchatlas/_chat.pyiterateturn.contentsforContentToolRequests and call_invoke_tool/_invoke_tool_asyncsynchronously. A deferred request would short-circuit that loop: leave theContentToolRequestin the lastAssistantTurnwithout a matchingContentToolResult, record it in a newChat._pending_approvals: dict[str, ContentToolRequest], and return/stop the generator instead of raising (deliberately distinct fromToolNotInvokedErrorinchatlas/_turn.py, which represents an abandoned request, not a paused one).- A new
Tool.requires_approvalflag (chatlas/_tools.py) or anon_tool_requestcallback that can raise/return a newToolDeferError(parallel toToolRejectError) marks a request as deferred rather than rejected. Chat.resume(approvals=...)suppliesbool | ToolRejectErrorper request id, constructs the resultingContentToolResults (reusing_handle_tool_error_resultfor denials), appends aUserTurn, and re-enters_chat_impl.Turn/AssistantTurn(chatlas/_turn.py) already serializes cleanly via pydantic; a turn with danglingContentToolRequests round-trips today (seecomplete_dangling_tool_requests), so persisting a paused chat via the save/load mechanism from a companion issue should "just work" as long as_pending_approvalsis reconstructed from the last turn on load rather than stored separately.
Explicitly out of scope: durable, cross-process-restart workflow engines (LangGraph-style checkpointing with retries/queues). This is in-process/session-level pause-resume only — if the process dies, the caller is responsible for persisting turns and resuming with a fresh Chat.
Alternatives / prior art
- ellmer has no equivalent issue yet (searched
tidyverse/ellmerfor "approval", "human in the loop", "pause resume" — no hits), so this would be a case of chatlas leading rather than following ellmer, worth flagging upstream once designed. - Keep the existing synchronous
ToolRejectErrorpath unchanged; this is additive.
Open questions
- Should deferral be declared per-tool (
register_tool(..., requires_approval=True)) or per-request (raised dynamically fromon_tool_request, so the same tool can be sometimes-deferred based on arguments)? Pydantic AI supports both. - How does this interact with streaming (
.stream())? Presumably the generator ends cleanly at the pause point. - Should
chat.pending_approvalspower a first-class shinychat affordance, or stay a plain data structure that app authors wire up themselves? - Timeout/expiry semantics for a request that's never resumed?
Drafted from a competitive review of llm / Pydantic AI / LangChain / LiteLLM (July 2026); filed via Claude Code on behalf of @cpsievert.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with _chat_impl and _chat_impl_async in chatlas/_chat.py, then read tool approval handling in chatlas/_tools.py and dangling-request serialization in chatlas/_turn.py. Review docs/tool-calling/approval.qmd and trace the existing synchronous approval flow. Done should cover a defined pause/resume design for synchronous and streaming chats while preserving current rejection behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- ai, backend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100