feat(tools): per-call approval for action tools
3-way action_tool_policy (off/ask/allow). In "ask", the chat stream stays open and the tool loop awaits approval: emits event:tool_request, the UI shows Approve/Deny, POST /chat/approve resumes the same stream. Declined actions return a denied result; a timeout denies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+48
-11
@@ -131,18 +131,28 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
|
||||
yield str(item)
|
||||
|
||||
|
||||
async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
|
||||
"""Let the model call read-only tools before the final streamed answer.
|
||||
# Per-call approval waiters, keyed by conversation_id. The chat stream stays open
|
||||
# and the loop awaits the Event; POST /chat/approve fills decisions and sets it.
|
||||
# ponytail: in-memory, single-process — fine for a local single-user app; needs a
|
||||
# shared store only if this ever runs multi-worker.
|
||||
pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
|
||||
|
||||
|
||||
async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu,
|
||||
conversation_id="", policy="allow"):
|
||||
"""Let the model call tools before the final streamed answer.
|
||||
|
||||
Mutates `messages` IN PLACE, appending the assistant tool-call turns and
|
||||
their `role:"tool"` results, and yields a `__status__<tool>` sentinel before
|
||||
each tool runs (surfaced to the UI as a "running tool" indicator).
|
||||
Non-streamed — tool calls arrive as whole messages. Degrades to an untouched
|
||||
`messages` if the model can't do tool calling.
|
||||
their `role:"tool"` results, and yields `__status__<tool>` sentinels.
|
||||
When policy == "ask" and a turn contains action tools, yields an
|
||||
`__approve__<json>` sentinel and awaits the user's decision (via
|
||||
`pending_approvals`) before running them; declined actions get a "denied"
|
||||
result the model can react to. Degrades to untouched `messages` if the model
|
||||
can't do tool calling.
|
||||
|
||||
ponytail: the turn that finally returns content is thrown away and the answer
|
||||
is re-generated by the streaming turn (one wasted call). Simpler than
|
||||
streaming a maybe-already-complete message; revisit if latency matters.
|
||||
is re-generated by the streaming turn (one wasted call).
|
||||
"""
|
||||
for _ in range(MAX_TOOL_STEPS):
|
||||
msg = await manager.chat(
|
||||
@@ -155,9 +165,32 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
|
||||
if not calls:
|
||||
break
|
||||
messages.append(msg)
|
||||
|
||||
# If any action tool needs per-call approval, pause and wait for the user.
|
||||
decisions = None
|
||||
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
|
||||
if policy == "ask" and action_calls:
|
||||
event = asyncio.Event()
|
||||
pending_approvals[conversation_id] = {"event": event, "decisions": {}}
|
||||
yield "__approve__" + _json.dumps([
|
||||
{"name": c.get("function", {}).get("name", ""),
|
||||
"arguments": c.get("function", {}).get("arguments")}
|
||||
for c in action_calls
|
||||
])
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
|
||||
decisions = pending_approvals[conversation_id]["decisions"]
|
||||
except asyncio.TimeoutError:
|
||||
decisions = {} # no answer in time -> deny all actions
|
||||
finally:
|
||||
pending_approvals.pop(conversation_id, None)
|
||||
|
||||
for c in calls:
|
||||
fn = c.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
if decisions is not None and _tools.is_action(name) and not decisions.get(name, False):
|
||||
messages.append({"role": "tool", "content": _json.dumps({"denied": f"user declined {name}"})})
|
||||
continue
|
||||
yield f"__status__{name}"
|
||||
result = await _tools.dispatch(name, fn.get("arguments"))
|
||||
messages.append({"role": "tool", "content": result})
|
||||
@@ -195,12 +228,16 @@ async def stream_chat_response(
|
||||
user_msg["images"] = images
|
||||
messages.append(user_msg)
|
||||
|
||||
# Tool-using playbooks: run read-only tool calls, then stream the final answer
|
||||
# with their results already in the messages array.
|
||||
# Tool-using playbooks: run tool calls, then stream the final answer with
|
||||
# their results already in the messages array.
|
||||
tool_schemas = metadata.get("tools")
|
||||
if tool_schemas:
|
||||
try:
|
||||
async for status in _run_tool_loop(manager, messages, model, tool_schemas, temperature, num_gpu):
|
||||
async for status in _run_tool_loop(
|
||||
manager, messages, model, tool_schemas, temperature, num_gpu,
|
||||
conversation_id=metadata.get("conversation_id", ""),
|
||||
policy=metadata.get("action_tool_policy", "allow"),
|
||||
):
|
||||
yield status
|
||||
except Exception:
|
||||
_logger.exception("tool loop failed; streaming without tools")
|
||||
|
||||
Reference in New Issue
Block a user