chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
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"},
|
||||
)
|
||||
Reference in New Issue
Block a user