posit-dev / posit-dev/chatlas

Deferred (async) tool approval: pause a turn and resume later

Open
#348 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

ai-triage:needs-review enhancement
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_async in chatlas/_chat.py iterate turn.contents for ContentToolRequests and call _invoke_tool/_invoke_tool_async synchronously. A deferred request would short-circuit that loop: leave the ContentToolRequest in the last AssistantTurn without a matching ContentToolResult, record it in a new Chat._pending_approvals: dict[str, ContentToolRequest], and return/stop the generator instead of raising (deliberately distinct from ToolNotInvokedError in chatlas/_turn.py, which represents an abandoned request, not a paused one).
  • A new Tool.requires_approval flag (chatlas/_tools.py) or an on_tool_request callback that can raise/return a new ToolDeferError (parallel to ToolRejectError) marks a request as deferred rather than rejected.
  • Chat.resume(approvals=...) supplies bool | ToolRejectError per request id, constructs the resulting ContentToolResults (reusing _handle_tool_error_result for denials), appends a UserTurn, and re-enters _chat_impl.
  • Turn/AssistantTurn (chatlas/_turn.py) already serializes cleanly via pydantic; a turn with dangling ContentToolRequests round-trips today (see complete_dangling_tool_requests), so persisting a paused chat via the save/load mechanism from a companion issue should "just work" as long as _pending_approvals is 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/ellmer for "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 ToolRejectError path unchanged; this is additive.

Open questions

  • Should deferral be declared per-tool (register_tool(..., requires_approval=True)) or per-request (raised dynamically from on_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_approvals power 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.