Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

433 lines
14 KiB
Python

"""
Plugins API Router
==================
Lists registered tools, capabilities, and playground plugins.
Provides direct tool execution for the Playground tester.
"""
import asyncio
import contextlib
import json
import logging
import re
import time
from typing import Any, AsyncGenerator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from deeptutor.core.i18n import t
from deeptutor.i18n.metadata_i18n import tool_description_i18n
from deeptutor.logging import (
ProcessLogEvent,
bind_log_context,
capture_process_logs,
current_log_context,
)
from deeptutor.runtime.registry.capability_registry import get_capability_registry
from deeptutor.runtime.registry.tool_registry import get_tool_registry
logger = logging.getLogger(__name__)
router = APIRouter()
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
def _discover_plugins() -> list[Any]:
try:
from deeptutor.plugins.loader import discover_plugins
except Exception:
logger.debug("Plugin loader unavailable; returning no plugins.", exc_info=True)
return []
return discover_plugins()
class ToolExecuteRequest(BaseModel):
params: dict[str, Any] = Field(default_factory=dict)
class CapabilityExecuteRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
content: str
tools: list[str] = Field(default_factory=list, alias="enabledTools")
knowledge_bases: list[str] = Field(default_factory=list, alias="knowledgeBases")
language: str = "en"
config: dict[str, Any] = Field(default_factory=dict)
attachments: list[dict[str, Any]] = Field(default_factory=list)
# ``bot_id`` is the legacy TutorBot field name; it now addresses a partner.
partner_id: str | None = Field(default=None, alias="bot_id")
session_id: str | None = None
chat_id: str | None = None
llm_selection: dict[str, str] | None = Field(default=None, alias="llmSelection")
@router.get("/list")
async def list_plugins():
tool_registry = get_tool_registry()
capability_registry = get_capability_registry()
plugin_manifests = _discover_plugins()
tools = [
{
"name": definition.name,
"description": definition.description,
"description_i18n": tool_description_i18n(definition.name, definition.description),
"parameters": [
{
"name": parameter.name,
"type": parameter.type,
"description": parameter.description,
"required": parameter.required,
"default": parameter.default,
"enum": parameter.enum,
}
for parameter in definition.parameters
],
}
for definition in tool_registry.get_definitions()
]
capabilities = capability_registry.get_manifests()
plugins = [
{
"name": plugin.name,
"type": plugin.type,
"description": plugin.description,
"stages": plugin.stages,
"version": plugin.version,
"author": plugin.author,
}
for plugin in plugin_manifests
]
return {
"tools": tools,
"capabilities": capabilities,
"plugins": plugins,
}
@router.post("/tools/{tool_name}/execute")
async def execute_tool(tool_name: str, body: ToolExecuteRequest):
"""Execute a single tool with explicit parameters (for Playground testing)."""
registry = get_tool_registry()
tool = registry.get(tool_name)
if not tool:
raise HTTPException(status_code=404, detail=t("api.tool_not_found", name=tool_name))
try:
result = await tool.execute(**body.params)
return {
"success": result.success,
"content": result.content,
"sources": result.sources,
"metadata": result.metadata,
}
except Exception as exc:
logger.exception("Tool execution failed: %s", tool_name)
raise HTTPException(status_code=500, detail=str(exc))
def _sse(event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
def _queue_process_emit(
queue: asyncio.Queue[dict[str, Any]],
loop: asyncio.AbstractEventLoop,
):
def emit(event: ProcessLogEvent) -> None:
loop.call_soon_threadsafe(
queue.put_nowait,
{"kind": "process_log", "payload": event.to_dict()},
)
return emit
class _QueueTextStream:
"""Capture stdout/stderr lines as process-log events."""
def __init__(
self,
queue: asyncio.Queue[dict[str, Any]],
loop: asyncio.AbstractEventLoop,
stream,
*,
logger_name: str,
):
self._queue = queue
self._loop = loop
self._stream = stream
self._logger_name = logger_name
self._buffer = ""
def write(self, text: str) -> int:
if self._stream is not None:
self._stream.write(text)
self._stream.flush()
self._buffer += text
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
self._emit_line(line.rstrip("\r"))
return len(text)
def flush(self):
if self._stream is not None:
self._stream.flush()
if self._buffer.strip():
self._emit_line(self._buffer)
self._buffer = ""
def isatty(self) -> bool:
return False
def _emit_line(self, line: str) -> None:
clean = ANSI_ESCAPE_RE.sub("", line).strip()
if not clean:
return
event = ProcessLogEvent(
level="INFO",
message=clean,
logger=self._logger_name,
timestamp=time.time(),
context=current_log_context(),
)
self._loop.call_soon_threadsafe(
self._queue.put_nowait,
{"kind": "process_log", "payload": event.to_dict()},
)
async def _execute_stream(tool_name: str, params: dict[str, Any]) -> AsyncGenerator[str, None]:
"""Run a tool while streaming structured process logs and the final result."""
registry = get_tool_registry()
tool = registry.get(tool_name)
if not tool:
yield _sse("error", {"detail": t("api.tool_not_found", name=tool_name)})
return
event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
loop = asyncio.get_running_loop()
stdout_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stdout"
)
stderr_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stderr"
)
result_holder: dict[str, Any] = {}
error_holder: dict[str, str] = {}
done = asyncio.Event()
task_id = f"playground_tool_{tool_name}_{int(time.time() * 1000)}"
async def _run():
try:
import sys
stdout_stream._stream = sys.stdout
stderr_stream._stream = sys.stderr
with bind_log_context(task_id=task_id, capability="playground", sink="ui"):
with capture_process_logs(_queue_process_emit(event_queue, loop), task_id=task_id):
with (
contextlib.redirect_stdout(stdout_stream),
contextlib.redirect_stderr(stderr_stream),
):
result = await tool.execute(**params)
result_holder["data"] = {
"success": result.success,
"content": result.content,
"sources": result.sources,
"metadata": result.metadata,
}
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
stdout_stream.flush()
stderr_stream.flush()
done.set()
task = asyncio.create_task(_run())
t0 = time.monotonic()
try:
while not done.is_set():
try:
item = await asyncio.wait_for(event_queue.get(), timeout=0.15)
yield _sse(item["kind"], item["payload"])
except asyncio.TimeoutError:
pass
while not event_queue.empty():
item = event_queue.get_nowait()
yield _sse(item["kind"], item["payload"])
elapsed_ms = round((time.monotonic() - t0) * 1000)
if error_holder:
yield _sse("error", {"detail": error_holder["detail"], "elapsed_ms": elapsed_ms})
else:
payload = {**result_holder.get("data", {}), "elapsed_ms": elapsed_ms}
yield _sse("result", payload)
finally:
if not task.done():
task.cancel()
@router.post("/tools/{tool_name}/execute-stream")
async def execute_tool_stream(tool_name: str, body: ToolExecuteRequest):
"""Execute a tool and stream process logs + result as SSE."""
return StreamingResponse(
_execute_stream(tool_name, body.params),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
async def _execute_capability_stream(
capability_name: str,
body: CapabilityExecuteRequest,
) -> AsyncGenerator[str, None]:
"""Run a capability while streaming process logs, trace events, and the result."""
partner_id = (body.partner_id or "").strip()
if capability_name == "chat" and partner_id:
from deeptutor.api.routers.partners import (
ChatMessageRequest,
_ensure_running_partner,
_partner_chat_stream,
)
if not body.content.strip():
yield _sse("error", {"detail": "content is required"})
return
try:
await _ensure_running_partner(partner_id)
except HTTPException as exc:
yield _sse("error", {"detail": exc.detail, "status_code": exc.status_code})
return
request = ChatMessageRequest(
content=body.content,
session_id=body.session_id,
chat_id=body.chat_id,
llm_selection=body.llm_selection,
)
async for chunk in _partner_chat_stream(partner_id, request):
yield chunk
return
from deeptutor.core.context import Attachment, UnifiedContext
from deeptutor.runtime.orchestrator import ChatOrchestrator
orch = ChatOrchestrator()
if capability_name not in orch.list_capabilities():
yield _sse("error", {"detail": f"Capability {capability_name!r} not found"})
return
attachments = [
Attachment(
type=a.get("type", "file"),
url=a.get("url", ""),
base64=a.get("base64", ""),
filename=a.get("filename", ""),
mime_type=a.get("mime_type", ""),
)
for a in body.attachments
]
ctx = UnifiedContext(
user_message=body.content,
enabled_tools=body.tools,
active_capability=capability_name,
knowledge_bases=body.knowledge_bases,
attachments=attachments,
config_overrides=body.config,
language=body.language,
)
event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
loop = asyncio.get_running_loop()
stdout_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stdout"
)
stderr_stream = _QueueTextStream(
event_queue, loop, stream=None, logger_name="deeptutor.playground.stderr"
)
final_result: dict[str, Any] | None = None
error_holder: dict[str, str] = {}
done = asyncio.Event()
task_id = f"playground_capability_{capability_name}_{int(time.time() * 1000)}"
async def _run():
nonlocal final_result
try:
import sys
stdout_stream._stream = sys.stdout
stderr_stream._stream = sys.stderr
with bind_log_context(
task_id=task_id,
capability=capability_name,
sink="ui",
):
with capture_process_logs(_queue_process_emit(event_queue, loop), task_id=task_id):
with (
contextlib.redirect_stdout(stdout_stream),
contextlib.redirect_stderr(stderr_stream),
):
async for event in orch.handle(ctx):
if event.type.value == "result":
final_result = dict(event.metadata)
continue
await event_queue.put({"kind": "stream", "payload": event.to_dict()})
except Exception as exc:
error_holder["detail"] = str(exc)
finally:
stdout_stream.flush()
stderr_stream.flush()
done.set()
task = asyncio.create_task(_run())
t0 = time.monotonic()
try:
while not done.is_set():
try:
item = await asyncio.wait_for(event_queue.get(), timeout=0.15)
yield _sse(item["kind"], item["payload"])
except asyncio.TimeoutError:
pass
while not event_queue.empty():
item = event_queue.get_nowait()
yield _sse(item["kind"], item["payload"])
elapsed_ms = round((time.monotonic() - t0) * 1000)
if error_holder:
yield _sse("error", {"detail": error_holder["detail"], "elapsed_ms": elapsed_ms})
else:
yield _sse(
"result",
{"success": True, "data": final_result or {}, "elapsed_ms": elapsed_ms},
)
finally:
if not task.done():
task.cancel()
@router.post("/capabilities/{capability_name}/execute-stream")
async def execute_capability_stream(
capability_name: str,
body: CapabilityExecuteRequest,
):
"""Execute a capability and stream logs + trace + final result as SSE."""
return StreamingResponse(
_execute_capability_stream(capability_name, body),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)