From 3a64410641615678c8c405a2de0e9a7df7a106b5 Mon Sep 17 00:00:00 2001 From: qzl Date: Tue, 3 Mar 2026 15:44:41 +0800 Subject: [PATCH] feat(agent): add interrupt-aware tool dispatcher --- backend/src/v1/agent/tool_dispatcher.py | 54 +++++++++++++++++++ .../unit/v1/agent/test_tool_dispatcher.py | 54 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 backend/src/v1/agent/tool_dispatcher.py create mode 100644 backend/tests/unit/v1/agent/test_tool_dispatcher.py diff --git a/backend/src/v1/agent/tool_dispatcher.py b/backend/src/v1/agent/tool_dispatcher.py new file mode 100644 index 0000000..264f14f --- /dev/null +++ b/backend/src/v1/agent/tool_dispatcher.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + + +class InterruptResult(BaseModel): + interrupt_type: str + tool_name: str + tool_args: dict[str, Any] + + +class BackendExecutionResult(BaseModel): + tool_name: str + tool_args: dict[str, Any] + result: Any | None = None + + +class ToolDispatcher: + def dispatch( + self, tool: dict[str, Any] + ) -> InterruptResult | BackendExecutionResult: + return dispatch_tool_call(tool) + + +def dispatch_tool_call( + tool: dict[str, Any], +) -> InterruptResult | BackendExecutionResult: + name = tool["name"] + target = tool["execution_target"] + args = tool.get("args", {}) + + if target == "frontend": + return InterruptResult( + interrupt_type="tool_execution", + tool_name=name, + tool_args=args, + ) + + if target == "backend": + requires_approval = tool.get("requires_approval", False) + if requires_approval: + return InterruptResult( + interrupt_type="approval_required", + tool_name=name, + tool_args=args, + ) + return BackendExecutionResult( + tool_name=name, + tool_args=args, + ) + + raise ValueError(f"Unknown execution_target: {target}") diff --git a/backend/tests/unit/v1/agent/test_tool_dispatcher.py b/backend/tests/unit/v1/agent/test_tool_dispatcher.py new file mode 100644 index 0000000..8b7ecc2 --- /dev/null +++ b/backend/tests/unit/v1/agent/test_tool_dispatcher.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from v1.agent.tool_dispatcher import ( + BackendExecutionResult, + InterruptResult, + ToolDispatcher, + dispatch_tool_call, +) + + +class TestToolDispatcher: + def test_frontend_tool_returns_interrupt(self): + tool = { + "name": "ui.navigate_to", + "execution_target": "frontend", + "args": {"path": "/home"}, + } + result = dispatch_tool_call(tool) + assert isinstance(result, InterruptResult) + assert result.interrupt_type == "tool_execution" + assert result.tool_name == "ui.navigate_to" + + def test_backend_tool_executes_directly(self): + tool = { + "name": "srv.get_user_info", + "execution_target": "backend", + "args": {"user_id": "u1"}, + "requires_approval": False, + } + result = dispatch_tool_call(tool) + assert isinstance(result, BackendExecutionResult) + assert result.tool_name == "srv.get_user_info" + + def test_backend_tool_with_approval_returns_interrupt(self): + tool = { + "name": "srv.transfer_funds", + "execution_target": "backend", + "args": {"to": "u2", "amount": 100}, + "requires_approval": True, + } + result = dispatch_tool_call(tool) + assert isinstance(result, InterruptResult) + assert result.interrupt_type == "approval_required" + assert result.tool_name == "srv.transfer_funds" + + def test_dispatcher_class_can_dispatch(self): + dispatcher = ToolDispatcher() + tool = { + "name": "ui.show_dialog", + "execution_target": "frontend", + "args": {"message": "Hello"}, + } + result = dispatcher.dispatch(tool) + assert isinstance(result, InterruptResult)