forked from Rockachopa/Timmy-time-dashboard
feat: migrate to Agno native HITL tool confirmation flow (#158)
Replace the homebrew regex-based tool extraction and manual dispatch (tool_executor.py) with Agno's built-in Human-In-The-Loop confirmation: - Toolkit(requires_confirmation_tools=...) marks dangerous tools - agent.run() returns RunOutput with status=paused when confirmation needed - RunRequirement.confirm()/reject() + agent.continue_run() resumes execution Dashboard and Discord vendor both use the native flow. DuckDuckGo import isolated so its absence doesn't kill all tools. Test stubs cleaned up (agno is a real dependency, only truly optional packages stubbed). 1384 tests pass in parallel (~14s). Co-authored-by: Trip T <trip@local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
574031a55c
commit
904a7c564e
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
@@ -7,12 +8,22 @@ from fastapi.responses import HTMLResponse
|
||||
|
||||
from dashboard.store import message_log
|
||||
from dashboard.templating import templates
|
||||
from timmy.session import chat as agent_chat
|
||||
from timmy.session import _clean_response, chat_with_tools, continue_chat
|
||||
from timmy.tool_safety import (
|
||||
format_action_description,
|
||||
get_impact_level,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000 # chars — reject before hitting the model
|
||||
|
||||
# In-memory store for paused runs (approval_id -> run context).
|
||||
# Each entry holds the RunOutput, the RunRequirement ref, and tool metadata.
|
||||
_pending_runs: dict[str, dict] = {}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents():
|
||||
@@ -62,25 +73,72 @@ async def clear_history(request: Request):
|
||||
|
||||
@router.post("/default/chat", response_class=HTMLResponse)
|
||||
async def chat_agent(request: Request, message: str = Form(...)):
|
||||
"""Chat — synchronous response."""
|
||||
"""Chat — synchronous response with native Agno tool confirmation."""
|
||||
message = message.strip()
|
||||
if not message:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=400, detail="Message cannot be empty")
|
||||
|
||||
if len(message) > MAX_MESSAGE_LENGTH:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=422, detail="Message too long")
|
||||
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
response_text = None
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
response_text = await asyncio.to_thread(agent_chat, message)
|
||||
run_output = await asyncio.to_thread(chat_with_tools, message)
|
||||
except Exception as exc:
|
||||
logger.error("Chat error: %s", exc)
|
||||
error_text = f"Chat error: {exc}"
|
||||
run_output = None
|
||||
|
||||
# Check if Agno paused the run for tool confirmation
|
||||
tool_actions = []
|
||||
if run_output is not None:
|
||||
status = getattr(run_output, "status", None)
|
||||
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
|
||||
|
||||
if is_paused and getattr(run_output, "active_requirements", None):
|
||||
for req in run_output.active_requirements:
|
||||
if getattr(req, "needs_confirmation", False):
|
||||
te = req.tool_execution
|
||||
tool_name = getattr(te, "tool_name", "unknown")
|
||||
tool_args = getattr(te, "tool_args", {}) or {}
|
||||
|
||||
from timmy.approvals import create_item
|
||||
|
||||
item = create_item(
|
||||
title=f"Dashboard: {tool_name}",
|
||||
description=format_action_description(tool_name, tool_args),
|
||||
proposed_action=json.dumps({"tool": tool_name, "args": tool_args}),
|
||||
impact=get_impact_level(tool_name),
|
||||
)
|
||||
_pending_runs[item.id] = {
|
||||
"run_output": run_output,
|
||||
"requirement": req,
|
||||
"tool_name": tool_name,
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
tool_actions.append(
|
||||
{
|
||||
"approval_id": item.id,
|
||||
"tool_name": tool_name,
|
||||
"description": format_action_description(tool_name, tool_args),
|
||||
"impact": get_impact_level(tool_name),
|
||||
}
|
||||
)
|
||||
|
||||
raw_content = run_output.content if hasattr(run_output, "content") else ""
|
||||
response_text = _clean_response(raw_content or "")
|
||||
if not response_text and not tool_actions:
|
||||
response_text = None # let error template show if needed
|
||||
|
||||
message_log.append(role="user", content=message, timestamp=timestamp, source="browser")
|
||||
if response_text is not None:
|
||||
if response_text:
|
||||
message_log.append(
|
||||
role="agent", content=response_text, timestamp=timestamp, source="browser"
|
||||
)
|
||||
@@ -97,5 +155,84 @@ async def chat_agent(request: Request, message: str = Form(...)):
|
||||
"timestamp": timestamp,
|
||||
"task_id": None,
|
||||
"queue_info": None,
|
||||
"tool_actions": tool_actions,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/default/tool/{approval_id}/approve", response_class=HTMLResponse)
|
||||
async def approve_tool(request: Request, approval_id: str):
|
||||
"""Confirm a paused tool and resume execution via Agno."""
|
||||
from timmy.approvals import approve
|
||||
|
||||
pending = _pending_runs.pop(approval_id, None)
|
||||
if not pending:
|
||||
return HTMLResponse(
|
||||
"<p class='text-danger'>Action not found or already processed.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
approve(approval_id)
|
||||
|
||||
tool_name = pending["tool_name"]
|
||||
|
||||
# Confirm the requirement — Agno will execute the tool on continue_run
|
||||
req = pending["requirement"]
|
||||
req.confirm()
|
||||
|
||||
try:
|
||||
result_run = await asyncio.to_thread(continue_chat, pending["run_output"])
|
||||
# Extract tool result from the resumed run
|
||||
tool_result = ""
|
||||
for te in getattr(result_run, "tools", None) or []:
|
||||
if getattr(te, "tool_name", None) == tool_name and getattr(te, "result", None):
|
||||
tool_result = te.result
|
||||
break
|
||||
if not tool_result:
|
||||
tool_result = getattr(result_run, "content", None) or "Tool executed successfully."
|
||||
except Exception as exc:
|
||||
logger.error("Tool execution failed: %s", exc)
|
||||
tool_result = f"Error: {exc}"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/chat_tool_result.html",
|
||||
{
|
||||
"approval_id": approval_id,
|
||||
"tool_name": tool_name,
|
||||
"status": "approved",
|
||||
"result": str(tool_result)[:2000],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/default/tool/{approval_id}/reject", response_class=HTMLResponse)
|
||||
async def reject_tool(request: Request, approval_id: str):
|
||||
"""Reject a pending tool action."""
|
||||
from timmy.approvals import reject
|
||||
|
||||
pending = _pending_runs.pop(approval_id, None)
|
||||
tool_name = "action"
|
||||
|
||||
if pending:
|
||||
tool_name = pending["tool_name"]
|
||||
req = pending["requirement"]
|
||||
req.reject(note="User rejected from dashboard")
|
||||
# Resume so the agent knows the tool was rejected
|
||||
try:
|
||||
await asyncio.to_thread(continue_chat, pending["run_output"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reject(approval_id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"partials/chat_tool_result.html",
|
||||
{
|
||||
"approval_id": approval_id,
|
||||
"tool_name": tool_name,
|
||||
"status": "rejected",
|
||||
"result": "",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user