120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""AskHumanTool — Human-in-the-Loop tool for Chat mode.
|
|
|
|
When registered in a Chat-mode Agent, this tool allows the ReAct loop
|
|
to pause and ask the user a question, then wait for a reply via the
|
|
WebSocket connection.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from agentkit.tools.base import Tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AskHumanTool(Tool):
|
|
"""Tool that asks the human user a question and waits for a reply.
|
|
|
|
Only functional in Chat mode where a WebSocket connection exists.
|
|
In Task mode, this tool should not be registered.
|
|
|
|
Usage in ReAct loop:
|
|
The Agent calls this tool when it needs clarification or
|
|
a decision from the user. The question is pushed to the
|
|
client via WebSocket, and the tool blocks until the user
|
|
replies or a timeout expires.
|
|
"""
|
|
|
|
def __init__(self, timeout: float = 60.0):
|
|
super().__init__(
|
|
name="ask_human",
|
|
description="Ask the human user a question and wait for their reply. "
|
|
"Use this when you need clarification, a decision, or "
|
|
"confirmation from the user before proceeding.",
|
|
)
|
|
self._timeout = timeout
|
|
# Shared dict injected by the Chat WebSocket handler:
|
|
# request_id -> asyncio.Future
|
|
self._pending_replies: dict[str, asyncio.Future] | None = None
|
|
# Callback to push question to client
|
|
self._ask_callback: Any = None
|
|
|
|
def configure(
|
|
self,
|
|
pending_replies: dict[str, asyncio.Future] | None = None,
|
|
ask_callback: Any = None,
|
|
) -> None:
|
|
"""Configure the tool with WebSocket communication channels.
|
|
|
|
Args:
|
|
pending_replies: Dict mapping request_id to Future that will
|
|
be resolved when the user replies.
|
|
ask_callback: Async callable(request_id, question, options)
|
|
that pushes the question to the client.
|
|
"""
|
|
self._pending_replies = pending_replies
|
|
self._ask_callback = ask_callback
|
|
|
|
@property
|
|
def parameters(self) -> dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"question": {
|
|
"type": "string",
|
|
"description": "The question to ask the user",
|
|
},
|
|
"options": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Optional list of choices for the user",
|
|
},
|
|
},
|
|
"required": ["question"],
|
|
}
|
|
|
|
async def execute(self, **kwargs: Any) -> dict:
|
|
"""Ask the user a question and wait for their reply.
|
|
|
|
Args:
|
|
question: The question to ask.
|
|
options: Optional list of choices.
|
|
|
|
Returns:
|
|
Dict with "reply" key containing the user's response.
|
|
"""
|
|
question = kwargs.get("question", "")
|
|
options = kwargs.get("options")
|
|
|
|
if self._pending_replies is None or self._ask_callback is None:
|
|
# Not in Chat mode — return a default response
|
|
logger.warning("AskHumanTool called outside Chat mode, returning default response")
|
|
default = options[0] if options else "confirmed"
|
|
return {"reply": default}
|
|
|
|
request_id = str(uuid.uuid4())[:8]
|
|
|
|
# Create and register future BEFORE calling callback so the
|
|
# callback (or any concurrent task) can resolve it immediately.
|
|
loop = asyncio.get_event_loop()
|
|
future = loop.create_future()
|
|
self._pending_replies[request_id] = future
|
|
|
|
# Push question to client
|
|
await self._ask_callback(request_id, question, options)
|
|
|
|
try:
|
|
reply = await asyncio.wait_for(future, timeout=self._timeout)
|
|
return {"reply": str(reply)}
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"AskHumanTool timeout for request {request_id}")
|
|
default = options[0] if options else "timeout — no response received"
|
|
return {"reply": default}
|
|
finally:
|
|
self._pending_replies.pop(request_id, None)
|