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
245
src/integrations/chat_bridge/vendors/discord.py
vendored
245
src/integrations/chat_bridge/vendors/discord.py
vendored
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user