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:
Alexander Whitestone
2026-03-09 21:54:04 -04:00
committed by GitHub
parent 574031a55c
commit 904a7c564e
18 changed files with 1317 additions and 85 deletions

View File

@@ -10,6 +10,7 @@ Architecture:
DiscordVendor
├── _client (discord.Client) — handles gateway events
├── _thread_map — channel_id -> active thread
├── _pending_actions — approval_id -> action details
└── _message_handler — bridges to Timmy agent
"""
@@ -17,7 +18,7 @@ import asyncio
import json
import logging
from pathlib import Path
from typing import Optional
from typing import Any, Optional
from integrations.chat_bridge.base import (
ChatMessage,
@@ -27,29 +28,75 @@ from integrations.chat_bridge.base import (
PlatformState,
PlatformStatus,
)
from timmy.session import _clean_response, chat_with_tools, continue_chat
from timmy.tool_safety import format_action_description as _format_action_description
from timmy.tool_safety import get_impact_level as _get_impact_level
logger = logging.getLogger(__name__)
_STATE_FILE = Path(__file__).parent.parent.parent.parent / "discord_state.json"
# Module-level agent singleton — reused across all Discord messages.
# Mirrors the pattern from timmy.session._agent.
_discord_agent = None
# ---------------------------------------------------------------------------
# Discord UI components (guarded — discord.py is optional)
# ---------------------------------------------------------------------------
try:
import discord as _discord_lib
_DISCORD_UI_AVAILABLE = True
except ImportError:
_DISCORD_UI_AVAILABLE = False
def _get_discord_agent():
"""Lazy-initialize the Discord agent singleton."""
global _discord_agent
if _discord_agent is None:
from timmy.agent import create_timmy
if _DISCORD_UI_AVAILABLE:
try:
_discord_agent = create_timmy()
logger.info("Discord: Timmy agent initialized (singleton)")
except Exception as exc:
logger.error("Discord: Failed to create Timmy agent: %s", exc)
raise
return _discord_agent
class ActionConfirmView(_discord_lib.ui.View):
"""Discord UI View with Approve and Reject buttons."""
def __init__(self, approval_id: str, vendor: "DiscordVendor"):
from config import settings
super().__init__(timeout=settings.discord_confirm_timeout)
self.approval_id = approval_id
self.vendor = vendor
@_discord_lib.ui.button(label="Approve", style=_discord_lib.ButtonStyle.green)
async def approve_button(self, interaction, button):
await self.vendor._on_action_approved(self.approval_id, interaction)
@_discord_lib.ui.button(label="Reject", style=_discord_lib.ButtonStyle.red)
async def reject_button(self, interaction, button):
await self.vendor._on_action_rejected(self.approval_id, interaction)
async def on_timeout(self):
"""Auto-reject on timeout."""
action = self.vendor._pending_actions.pop(self.approval_id, None)
if not action:
return
try:
from timmy.approvals import reject
reject(self.approval_id)
# Reject the requirement and resume so the agent knows
req = action.get("requirement")
if req:
req.reject(note="Timed out — auto-rejected")
await asyncio.to_thread(
continue_chat, action["run_output"], action.get("session_id")
)
await action["target"].send(
f"Action `{action['tool_name']}` timed out and was auto-rejected."
)
except Exception:
pass
# ---------------------------------------------------------------------------
# DiscordVendor
# ---------------------------------------------------------------------------
class DiscordVendor(ChatPlatform):
@@ -66,6 +113,7 @@ class DiscordVendor(ChatPlatform):
self._task: Optional[asyncio.Task] = None
self._guild_count: int = 0
self._active_threads: dict[str, str] = {} # channel_id -> thread_id
self._pending_actions: dict[str, dict] = {} # approval_id -> action details
# ── ChatPlatform interface ─────────────────────────────────────────────
@@ -289,6 +337,108 @@ class DiscordVendor(ChatPlatform):
f"&permissions={permissions}"
)
# ── Action confirmation ────────────────────────────────────────────────
async def _send_confirmation(
self, target: Any, tool_name: str, tool_args: dict, approval_id: str
) -> None:
"""Send a confirmation message with Approve/Reject buttons."""
description = _format_action_description(tool_name, tool_args)
impact = _get_impact_level(tool_name)
if _DISCORD_UI_AVAILABLE:
import discord
embed = discord.Embed(
title="Action Confirmation Required",
description=description,
color=discord.Color.orange(),
)
embed.add_field(name="Tool", value=f"`{tool_name}`", inline=True)
embed.add_field(name="Impact", value=impact, inline=True)
embed.set_footer(text=f"Approval ID: {approval_id[:8]}")
view = ActionConfirmView(approval_id=approval_id, vendor=self)
msg = await target.send(embed=embed, view=view)
else:
# Fallback when discord.py UI components not available
msg = await target.send(
f"**Action Confirmation Required**\n"
f"{description}\n"
f"Tool: `{tool_name}` | Impact: {impact}\n"
f"_Reply 'approve {approval_id[:8]}' or 'reject {approval_id[:8]}'_"
)
self._pending_actions[approval_id] = {
"tool_name": tool_name,
"tool_args": tool_args,
"target": target,
"message": msg,
}
async def _on_action_approved(self, approval_id: str, interaction: Any) -> None:
"""Confirm the tool and resume via Agno's continue_run."""
action = self._pending_actions.pop(approval_id, None)
if not action:
await interaction.response.send_message("Action already processed.", ephemeral=True)
return
from timmy.approvals import approve
approve(approval_id)
await interaction.response.send_message("Approved. Executing...", ephemeral=True)
target = action["target"]
tool_name = action["tool_name"]
# Confirm the requirement — Agno will execute the tool on continue_run
req = action["requirement"]
req.confirm()
try:
result_run = await asyncio.to_thread(
continue_chat, action["run_output"], action.get("session_id")
)
# 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."
result_text = f"**{tool_name}** result:\n```\n{str(tool_result)[:1800]}\n```"
for chunk in _chunk_message(result_text, 2000):
await target.send(chunk)
except Exception as exc:
logger.error("Discord: tool execution failed: %s", exc)
await target.send(f"**{tool_name}** failed: `{exc}`")
async def _on_action_rejected(self, approval_id: str, interaction: Any) -> None:
"""Reject the pending action and notify the agent."""
action = self._pending_actions.pop(approval_id, None)
if not action:
await interaction.response.send_message("Action already processed.", ephemeral=True)
return
from timmy.approvals import reject
reject(approval_id)
# Reject the requirement and resume so the agent knows
req = action["requirement"]
req.reject(note="User rejected from Discord")
try:
await asyncio.to_thread(continue_chat, action["run_output"], action.get("session_id"))
except Exception:
pass
await interaction.response.send_message(
f"Rejected. `{action['tool_name']}` will not execute.", ephemeral=True
)
# ── Internal ───────────────────────────────────────────────────────────
async def _run_client(self, token: str) -> None:
@@ -354,38 +504,67 @@ class DiscordVendor(ChatPlatform):
session_id = f"discord_{message.channel.id}"
# Run Timmy agent with typing indicator and timeout
run_output = None
response = None
try:
agent = _get_discord_agent()
# Show typing indicator while the agent processes
async with target.typing():
run = await asyncio.wait_for(
asyncio.to_thread(agent.run, content, stream=False, session_id=session_id),
run_output = await asyncio.wait_for(
asyncio.to_thread(chat_with_tools, content, session_id),
timeout=300,
)
response = run.content if hasattr(run, "content") else str(run)
except asyncio.TimeoutError:
logger.error("Discord: agent.run() timed out after 300s")
logger.error("Discord: chat_with_tools() timed out after 300s")
response = "Sorry, that took too long. Please try a simpler request."
except Exception as exc:
logger.error("Discord: agent.run() failed: %s", exc)
logger.error("Discord: chat_with_tools() failed: %s", exc)
response = (
"I'm having trouble reaching my language model right now. Please try again shortly."
)
# Strip hallucinated tool-call JSON and chain-of-thought narration
from timmy.session import _clean_response
# Check if Agno paused the run for tool confirmation
if run_output is not None:
status = getattr(run_output, "status", None)
is_paused = status == "PAUSED" or str(status) == "RunStatus.paused"
response = _clean_response(response)
if is_paused and getattr(run_output, "active_requirements", None):
from config import settings
if settings.discord_confirm_actions:
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"Discord: {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),
)
self._pending_actions[item.id] = {
"run_output": run_output,
"requirement": req,
"tool_name": tool_name,
"tool_args": tool_args,
"target": target,
"session_id": session_id,
}
await self._send_confirmation(target, tool_name, tool_args, item.id)
raw_content = run_output.content if hasattr(run_output, "content") else ""
response = _clean_response(raw_content or "")
# Discord has a 2000 character limit — send with error handling
for chunk in _chunk_message(response, 2000):
try:
await target.send(chunk)
except Exception as exc:
logger.error("Discord: failed to send message chunk: %s", exc)
break
if response and response.strip():
for chunk in _chunk_message(response, 2000):
try:
await target.send(chunk)
except Exception as exc:
logger.error("Discord: failed to send message chunk: %s", exc)
break
async def _get_or_create_thread(self, message):
"""Get the active thread for a channel, or create one.