Compare commits
7 Commits
4972b4e6b1
...
fca7a7cf3d
| Author | SHA1 | Date | |
|---|---|---|---|
| fca7a7cf3d | |||
| d18167826e | |||
| 88955ed550 | |||
| a3fe4d24fc | |||
| e5bd492d74 | |||
| a7b6b5eb90 | |||
| aa0ef0fbea |
220
backend/app/agents/background/executor.py
Normal file
220
backend/app/agents/background/executor.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Background task executor - Phase 10.4"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .manager import (
|
||||||
|
BackgroundTask,
|
||||||
|
BackgroundTaskManager,
|
||||||
|
BackgroundTaskStatus,
|
||||||
|
get_background_task_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundExecutor:
|
||||||
|
"""Executes background tasks with error handling and result storage.
|
||||||
|
|
||||||
|
Provides methods to execute tasks synchronously or asynchronously,
|
||||||
|
with full integration into BackgroundTaskManager for tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, task_manager: BackgroundTaskManager | None = None):
|
||||||
|
"""Initialize the executor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_manager: Optional BackgroundTaskManager instance.
|
||||||
|
If not provided, uses the global singleton.
|
||||||
|
"""
|
||||||
|
self._task_manager = task_manager or get_background_task_manager()
|
||||||
|
self._executors: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> BackgroundTask:
|
||||||
|
"""Execute a specific task by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Unique task identifier
|
||||||
|
func: Async function to execute
|
||||||
|
*args: Positional arguments for the function
|
||||||
|
**kwargs: Keyword arguments for the function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The BackgroundTask with result or error
|
||||||
|
"""
|
||||||
|
# Get or create task record
|
||||||
|
task = self._task_manager.get_task_status(task_id)
|
||||||
|
if task is None:
|
||||||
|
# Create a new task record if one doesn't exist
|
||||||
|
task = BackgroundTask(
|
||||||
|
id=task_id,
|
||||||
|
name=f"executor_task_{task_id}",
|
||||||
|
status=BackgroundTaskStatus.PENDING,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
self._task_manager._tasks[task_id] = task
|
||||||
|
|
||||||
|
# Update status to running
|
||||||
|
task.status = BackgroundTaskStatus.RUNNING
|
||||||
|
task.started_at = datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute the async function
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
task.status = BackgroundTaskStatus.COMPLETED
|
||||||
|
task.result = result
|
||||||
|
except Exception as e:
|
||||||
|
task.status = BackgroundTaskStatus.FAILED
|
||||||
|
task.error = f"{type(e).__name__}: {str(e)}"
|
||||||
|
task.result = None
|
||||||
|
finally:
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
# Clean up executor reference
|
||||||
|
if task_id in self._executors:
|
||||||
|
del self._executors[task_id]
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def execute_async(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||||
|
*args: Any,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Execute a task asynchronously in the background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Unique task identifier
|
||||||
|
func: Async function to execute
|
||||||
|
*args: Positional arguments for the function
|
||||||
|
**kwargs: Keyword arguments for the function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The task ID
|
||||||
|
"""
|
||||||
|
# Create task record if it doesn't exist
|
||||||
|
if self._task_manager.get_task_status(task_id) is None:
|
||||||
|
self._task_manager._tasks[task_id] = BackgroundTask(
|
||||||
|
id=task_id,
|
||||||
|
name=f"async_task_{task_id}",
|
||||||
|
status=BackgroundTaskStatus.PENDING,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create and store the asyncio task
|
||||||
|
async_task = asyncio.create_task(self.execute_task(task_id, func, *args, **kwargs))
|
||||||
|
self._executors[task_id] = async_task
|
||||||
|
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
def cancel_task(self, task_id: str) -> bool:
|
||||||
|
"""Cancel a running task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task ID to cancel
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cancelled, False if not found or not running
|
||||||
|
"""
|
||||||
|
if task_id not in self._executors:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._executors[task_id].cancel()
|
||||||
|
del self._executors[task_id]
|
||||||
|
|
||||||
|
# Update task status
|
||||||
|
task = self._task_manager.get_task_status(task_id)
|
||||||
|
if task:
|
||||||
|
task.status = BackgroundTaskStatus.CANCELLED
|
||||||
|
task.completed_at = datetime.now()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_task_result(self, task_id: str) -> Any:
|
||||||
|
"""Get the result of a completed task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The task result or None if not found/not completed
|
||||||
|
"""
|
||||||
|
task = self._task_manager.get_task_status(task_id)
|
||||||
|
if task and task.status == BackgroundTaskStatus.COMPLETED:
|
||||||
|
return task.result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_task_error(self, task_id: str) -> str | None:
|
||||||
|
"""Get the error of a failed task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The error message or None if not found/not failed
|
||||||
|
"""
|
||||||
|
task = self._task_manager.get_task_status(task_id)
|
||||||
|
if task and task.status == BackgroundTaskStatus.FAILED:
|
||||||
|
return task.error
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_task_running(self, task_id: str) -> bool:
|
||||||
|
"""Check if a task is currently running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if running, False otherwise
|
||||||
|
"""
|
||||||
|
return task_id in self._executors
|
||||||
|
|
||||||
|
def wait_for_task(self, task_id: str, timeout: float | None = None) -> BackgroundTask:
|
||||||
|
"""Wait for a task to complete.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: The task ID to wait for
|
||||||
|
timeout: Optional timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The completed BackgroundTask
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
asyncio.TimeoutError: If task doesn't complete within timeout
|
||||||
|
asyncio.CancelledError: If task is cancelled
|
||||||
|
"""
|
||||||
|
if task_id not in self._executors:
|
||||||
|
task = self._task_manager.get_task_status(task_id)
|
||||||
|
if task:
|
||||||
|
return task
|
||||||
|
raise ValueError(f"Task {task_id} not found")
|
||||||
|
|
||||||
|
async def wait_task() -> BackgroundTask:
|
||||||
|
await self._executors[task_id]
|
||||||
|
return self._task_manager.get_task_status(task_id)
|
||||||
|
|
||||||
|
return asyncio.run_until_complete(asyncio.wait_for(wait_task(), timeout=timeout))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task_manager(self) -> BackgroundTaskManager:
|
||||||
|
"""Get the underlying task manager."""
|
||||||
|
return self._task_manager
|
||||||
|
|
||||||
|
|
||||||
|
# Global executor instance
|
||||||
|
_executor: BackgroundExecutor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_background_executor() -> BackgroundExecutor:
|
||||||
|
"""Get the global BackgroundExecutor instance."""
|
||||||
|
global _executor
|
||||||
|
if _executor is None:
|
||||||
|
_executor = BackgroundExecutor()
|
||||||
|
return _executor
|
||||||
119
backend/app/agents/background/manager.py
Normal file
119
backend/app/agents/background/manager.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""后台任务系统 - Phase 10.4"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTaskStatus(Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackgroundTask:
|
||||||
|
"""后台任务"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
status: BackgroundTaskStatus
|
||||||
|
created_at: datetime
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
result: Any = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundTaskManager:
|
||||||
|
"""后台任务管理器"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tasks: dict[str, BackgroundTask] = {}
|
||||||
|
self._.coroutines: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
def submit_task(self, name: str, coro: Any, *args, **kwargs) -> str:
|
||||||
|
"""提交后台任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 任务名称
|
||||||
|
coro: 协程函数
|
||||||
|
*args: 位置参数
|
||||||
|
**kwargs: 关键字参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务 ID
|
||||||
|
"""
|
||||||
|
task_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# 创建任务记录
|
||||||
|
self._tasks[task_id] = BackgroundTask(
|
||||||
|
id=task_id,
|
||||||
|
name=name,
|
||||||
|
status=BackgroundTaskStatus.PENDING,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建 asyncio task
|
||||||
|
async def run_task():
|
||||||
|
self._tasks[task_id].status = BackgroundTaskStatus.RUNNING
|
||||||
|
self._tasks[task_id].started_at = datetime.now()
|
||||||
|
try:
|
||||||
|
result = await coro(*args, **kwargs)
|
||||||
|
self._tasks[task_id].status = BackgroundTaskStatus.COMPLETED
|
||||||
|
self._tasks[task_id].result = result
|
||||||
|
except Exception as e:
|
||||||
|
self._tasks[task_id].status = BackgroundTaskStatus.FAILED
|
||||||
|
self._tasks[task_id].error = str(e)
|
||||||
|
finally:
|
||||||
|
self._tasks[task_id].completed_at = datetime.now()
|
||||||
|
if task_id in self._coroutines:
|
||||||
|
del self._coroutines[task_id]
|
||||||
|
|
||||||
|
self._coroutines[task_id] = asyncio.create_task(run_task())
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
def cancel_task(self, task_id: str) -> bool:
|
||||||
|
"""取消任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功取消
|
||||||
|
"""
|
||||||
|
if task_id not in self._tasks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if task_id in self._coroutines:
|
||||||
|
self._coroutines[task_id].cancel()
|
||||||
|
del self._coroutines[task_id]
|
||||||
|
|
||||||
|
self._tasks[task_id].status = BackgroundTaskStatus.CANCELLED
|
||||||
|
self._tasks[task_id].completed_at = datetime.now()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_task_status(self, task_id: str) -> BackgroundTask | None:
|
||||||
|
"""获取任务状态"""
|
||||||
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
def list_tasks(self) -> list[BackgroundTask]:
|
||||||
|
"""列出所有任务"""
|
||||||
|
return list(self._tasks.values())
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_manager: BackgroundTaskManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_background_task_manager() -> BackgroundTaskManager:
|
||||||
|
"""获取全局后台任务管理器"""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = BackgroundTaskManager()
|
||||||
|
return _manager
|
||||||
146
backend/app/agents/background/scheduler.py
Normal file
146
backend/app/agents/background/scheduler.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Background task scheduler - Phase 10.4"""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
|
|
||||||
|
from .manager import BackgroundTaskManager, get_background_task_manager
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundScheduler:
|
||||||
|
"""Background task scheduler using APScheduler.
|
||||||
|
|
||||||
|
Integrates with BackgroundTaskManager for task tracking and execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, task_manager: BackgroundTaskManager | None = None):
|
||||||
|
"""Initialize the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_manager: Optional BackgroundTaskManager instance.
|
||||||
|
If not provided, uses the global singleton.
|
||||||
|
"""
|
||||||
|
self._scheduler = AsyncIOScheduler()
|
||||||
|
self._task_manager = task_manager or get_background_task_manager()
|
||||||
|
self._job_tasks: dict[str, str] = {} # Maps APScheduler job_id to task_id
|
||||||
|
|
||||||
|
def add_job(
|
||||||
|
self,
|
||||||
|
func: Callable[..., Coroutine[Any, Any, Any]],
|
||||||
|
trigger: BaseTrigger,
|
||||||
|
args: tuple[Any, ...] | None = None,
|
||||||
|
kwargs: dict[str, Any] | None = None,
|
||||||
|
id: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
**apscheduler_kwargs: Any,
|
||||||
|
) -> str:
|
||||||
|
"""Add a job to the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Async function to execute
|
||||||
|
trigger: APScheduler trigger (date, interval, cron, etc.)
|
||||||
|
args: Positional arguments for the function
|
||||||
|
kwargs: Keyword arguments for the function
|
||||||
|
id: Unique job ID (auto-generated if not provided)
|
||||||
|
name: Job name for display purposes
|
||||||
|
**apscheduler_kwargs: Additional APScheduler options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The job ID
|
||||||
|
"""
|
||||||
|
job_id = id or f"job_{len(self._job_tasks)}"
|
||||||
|
task_name = name or f"scheduled_task_{job_id}"
|
||||||
|
|
||||||
|
# Wrap the async function to integrate with BackgroundTaskManager
|
||||||
|
async def wrapped_func() -> None:
|
||||||
|
coro = func(*(args or ()), **(kwargs or {}))
|
||||||
|
task_id = self._task_manager.submit_task(task_name, coro)
|
||||||
|
self._job_tasks[job_id] = task_id
|
||||||
|
|
||||||
|
self._scheduler.add_job(
|
||||||
|
wrapped_func,
|
||||||
|
trigger=trigger,
|
||||||
|
id=job_id,
|
||||||
|
name=task_name,
|
||||||
|
**apscheduler_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
def remove_job(self, job_id: str) -> bool:
|
||||||
|
"""Remove a job from the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: The ID of the job to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if job was removed, False if job didn't exist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._scheduler.remove_job(job_id)
|
||||||
|
# Clean up task mapping if exists
|
||||||
|
if job_id in self._job_tasks:
|
||||||
|
task_id = self._job_tasks.pop(job_id)
|
||||||
|
# Cancel the background task if still running
|
||||||
|
self._task_manager.cancel_task(task_id)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_jobs(self) -> list[dict[str, Any]]:
|
||||||
|
"""List all scheduled jobs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of job information dictionaries
|
||||||
|
"""
|
||||||
|
jobs = self._scheduler.get_jobs()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"next_run_time": job.next_run_time,
|
||||||
|
"trigger": str(job.trigger),
|
||||||
|
}
|
||||||
|
for job in jobs
|
||||||
|
]
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the scheduler."""
|
||||||
|
if not self._scheduler.running:
|
||||||
|
self._scheduler.start()
|
||||||
|
|
||||||
|
def shutdown(self, wait: bool = True) -> None:
|
||||||
|
"""Shutdown the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wait: Whether to wait for running jobs to complete
|
||||||
|
"""
|
||||||
|
if self._scheduler.running:
|
||||||
|
self._scheduler.shutdown(wait=wait)
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Pause the scheduler."""
|
||||||
|
self._scheduler.pause()
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Resume the scheduler."""
|
||||||
|
self._scheduler.resume()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task_manager(self) -> BackgroundTaskManager:
|
||||||
|
"""Get the underlying task manager."""
|
||||||
|
return self._task_manager
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler: BackgroundScheduler | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_background_scheduler() -> BackgroundScheduler:
|
||||||
|
"""Get the global BackgroundScheduler instance."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = BackgroundScheduler()
|
||||||
|
return _scheduler
|
||||||
508
backend/app/agents/coordinator.py
Normal file
508
backend/app/agents/coordinator.py
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
"""Agent 协调整器 - Phase 10.5
|
||||||
|
|
||||||
|
统一协调所有 Agent 组件:TeamLeader, RemoteTransport, BackgroundTaskManager, SessionManager
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.background.manager import BackgroundTaskManager, get_background_task_manager
|
||||||
|
from app.agents.session.manager import AgentSession, create_agent_session, get_agent_session
|
||||||
|
from app.agents.team.leader import TeamLeader
|
||||||
|
from app.agents.transport.remote import RemoteTransport
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCoordinator:
|
||||||
|
"""Agent 协调整器
|
||||||
|
|
||||||
|
统一协调所有 Agent 组件,提供单一入口处理各类 Agent 操作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
background_manager: BackgroundTaskManager | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
background_manager: 后台任务管理器,None 则使用全局单例
|
||||||
|
"""
|
||||||
|
self._team_leaders: dict[str, TeamLeader] = {}
|
||||||
|
self._remote_transport = RemoteTransport()
|
||||||
|
self._background_manager = background_manager or get_background_task_manager()
|
||||||
|
self._sessions: dict[str, AgentSession] = {}
|
||||||
|
|
||||||
|
# === Team 协作方法 ===
|
||||||
|
|
||||||
|
def create_team(self, team_id: str, members: list[str]) -> dict[str, Any]:
|
||||||
|
"""创建团队
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
members: 成员 ID 列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
团队创建结果
|
||||||
|
"""
|
||||||
|
if team_id in self._team_leaders:
|
||||||
|
return {"status": "error", "message": f"Team '{team_id}' already exists"}
|
||||||
|
|
||||||
|
leader = TeamLeader(team_id=team_id, members=members)
|
||||||
|
self._team_leaders[team_id] = leader
|
||||||
|
return {
|
||||||
|
"status": "created",
|
||||||
|
"team_id": team_id,
|
||||||
|
"members": members,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_team(self, team_id: str) -> TeamLeader | None:
|
||||||
|
"""获取团队
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TeamLeader 或 None
|
||||||
|
"""
|
||||||
|
return self._team_leaders.get(team_id)
|
||||||
|
|
||||||
|
def assign_task(self, team_id: str, description: str, member: str) -> dict[str, Any]:
|
||||||
|
"""创建并分配任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
description: 任务描述
|
||||||
|
member: 成员 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分配结果
|
||||||
|
"""
|
||||||
|
leader = self._team_leaders.get(team_id)
|
||||||
|
if not leader:
|
||||||
|
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||||
|
|
||||||
|
task_id = leader.create_task(description)
|
||||||
|
success = leader.assign_task(task_id, member)
|
||||||
|
return {
|
||||||
|
"status": "assigned" if success else "error",
|
||||||
|
"task_id": task_id,
|
||||||
|
"assignee": member,
|
||||||
|
}
|
||||||
|
|
||||||
|
def broadcast_task(self, team_id: str, description: str) -> dict[str, Any]:
|
||||||
|
"""广播任务给所有成员
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
description: 任务描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
广播结果
|
||||||
|
"""
|
||||||
|
leader = self._team_leaders.get(team_id)
|
||||||
|
if not leader:
|
||||||
|
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||||
|
|
||||||
|
task_ids = leader.broadcast_task(description)
|
||||||
|
return {
|
||||||
|
"status": "broadcast",
|
||||||
|
"team_id": team_id,
|
||||||
|
"task_ids": task_ids,
|
||||||
|
"member_count": len(leader.members),
|
||||||
|
}
|
||||||
|
|
||||||
|
def collect_team_results(self, team_id: str) -> dict[str, Any]:
|
||||||
|
"""收集团队任务结果
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
收集结果
|
||||||
|
"""
|
||||||
|
leader = self._team_leaders.get(team_id)
|
||||||
|
if not leader:
|
||||||
|
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||||
|
|
||||||
|
results = leader.collect_results()
|
||||||
|
status = leader.get_team_status()
|
||||||
|
return {
|
||||||
|
"status": "collected",
|
||||||
|
"team_id": team_id,
|
||||||
|
"results": results,
|
||||||
|
"completed": status["completed"],
|
||||||
|
"failed": status["failed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_team_status(self, team_id: str) -> dict[str, Any]:
|
||||||
|
"""获取团队状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
团队状态
|
||||||
|
"""
|
||||||
|
leader = self._team_leaders.get(team_id)
|
||||||
|
if not leader:
|
||||||
|
return {"status": "error", "message": f"Team '{team_id}' not found"}
|
||||||
|
|
||||||
|
return leader.get_team_status()
|
||||||
|
|
||||||
|
# === 后台任务方法 ===
|
||||||
|
|
||||||
|
def submit_background_task(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
coro: Any,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""提交后台任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 任务名称
|
||||||
|
coro: 协程函数
|
||||||
|
*args: 位置参数
|
||||||
|
**kwargs: 关键字参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提交结果
|
||||||
|
"""
|
||||||
|
task_id = self._background_manager.submit_task(name, coro, *args, **kwargs)
|
||||||
|
return {
|
||||||
|
"status": "submitted",
|
||||||
|
"task_id": task_id,
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cancel_background_task(self, task_id: str) -> dict[str, Any]:
|
||||||
|
"""取消后台任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
取消结果
|
||||||
|
"""
|
||||||
|
success = self._background_manager.cancel_task(task_id)
|
||||||
|
return {
|
||||||
|
"status": "cancelled" if success else "error",
|
||||||
|
"task_id": task_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_background_task_status(self, task_id: str) -> dict[str, Any]:
|
||||||
|
"""获取后台任务状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务状态
|
||||||
|
"""
|
||||||
|
task = self._background_manager.get_task_status(task_id)
|
||||||
|
if not task:
|
||||||
|
return {"status": "error", "message": f"Task '{task_id}' not found"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "found",
|
||||||
|
"task_id": task.id,
|
||||||
|
"name": task.name,
|
||||||
|
"task_status": task.status.value,
|
||||||
|
"result": task.result,
|
||||||
|
"error": task.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_background_tasks(self) -> dict[str, Any]:
|
||||||
|
"""列出所有后台任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务列表
|
||||||
|
"""
|
||||||
|
tasks = self._background_manager.list_tasks()
|
||||||
|
return {
|
||||||
|
"status": "list",
|
||||||
|
"count": len(tasks),
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"status": t.status.value,
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# === 会话方法 ===
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
self,
|
||||||
|
user_id: str | None = None,
|
||||||
|
parent_session_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""创建会话
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户 ID
|
||||||
|
parent_session_id: 父会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建结果
|
||||||
|
"""
|
||||||
|
session = create_agent_session(
|
||||||
|
user_id=user_id,
|
||||||
|
parent_session_id=parent_session_id,
|
||||||
|
)
|
||||||
|
self._sessions[session.session_id] = session
|
||||||
|
return {
|
||||||
|
"status": "created",
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"parent_session_id": parent_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> AgentSession | None:
|
||||||
|
"""获取会话
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentSession 或 None
|
||||||
|
"""
|
||||||
|
return self._sessions.get(session_id) or get_agent_session(session_id)
|
||||||
|
|
||||||
|
async def process_session_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
message: str,
|
||||||
|
response: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""处理会话消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
message: 用户消息
|
||||||
|
response: 助手响应
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
处理结果
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||||
|
|
||||||
|
await session.process_message(message, response)
|
||||||
|
return {
|
||||||
|
"status": "processed",
|
||||||
|
"session_id": session_id,
|
||||||
|
"message_count": session.context.message_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def spawn_child_session(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""创建子会话
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 父会话 ID
|
||||||
|
user_id: 用户 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建结果
|
||||||
|
"""
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||||
|
|
||||||
|
child = await session.spawn_child_session(user_id=user_id)
|
||||||
|
self._sessions[child.session_id] = child
|
||||||
|
return {
|
||||||
|
"status": "spawned",
|
||||||
|
"parent_session_id": session_id,
|
||||||
|
"child_session_id": child.session_id,
|
||||||
|
"depth": child.context.depth,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session_summary(self, session_id: str) -> dict[str, Any]:
|
||||||
|
"""获取会话摘要
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
会话摘要
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
session = self.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
return {"status": "error", "message": f"Session '{session_id}' not found"}
|
||||||
|
|
||||||
|
# get_session_summary is async, so we need to run it
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
# Create a future
|
||||||
|
future = asyncio.ensure_future(session.get_session_summary())
|
||||||
|
return {"status": "found", "summary": future}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "found",
|
||||||
|
"summary": loop.run_until_complete(session.get_session_summary()),
|
||||||
|
}
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop, create one
|
||||||
|
return {"status": "found", "summary": asyncio.run(session.get_session_summary())}
|
||||||
|
|
||||||
|
# === 远程传输方法 ===
|
||||||
|
|
||||||
|
def register_remote_handler(self, event_type: str, handler: Any) -> None:
|
||||||
|
"""注册远程消息处理器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: 事件类型
|
||||||
|
handler: 处理函数
|
||||||
|
"""
|
||||||
|
self._remote_transport.register_handler(event_type, handler)
|
||||||
|
|
||||||
|
async def send_remote_response(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
response: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""发送远程响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
response: 响应数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
return await self._remote_transport.send_response(session_id, response)
|
||||||
|
|
||||||
|
async def send_remote_event(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
event: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""发送远程事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
event: 事件数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
return await self._remote_transport.send_event(session_id, event)
|
||||||
|
|
||||||
|
async def send_remote_tool_call(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
tool_call: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""发送远程工具调用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
tool_call: 工具调用数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
return await self._remote_transport.send_tool_call(session_id, tool_call)
|
||||||
|
|
||||||
|
# === 统一协调入口 ===
|
||||||
|
|
||||||
|
async def coordinate(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""统一协调入口
|
||||||
|
|
||||||
|
根据请求类型协调各类 Agent 操作。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 请求数据,包含:
|
||||||
|
- action: 操作类型 (team_create, team_assign, task_submit, session_create, etc.)
|
||||||
|
- 其他参数根据 action 不同而不同
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
协调结果
|
||||||
|
"""
|
||||||
|
action = request.get("action")
|
||||||
|
|
||||||
|
if action == "team_create":
|
||||||
|
return self.create_team(
|
||||||
|
team_id=request["team_id"],
|
||||||
|
members=request["members"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "team_assign":
|
||||||
|
return self.assign_task(
|
||||||
|
team_id=request["team_id"],
|
||||||
|
description=request["description"],
|
||||||
|
member=request["member"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "team_broadcast":
|
||||||
|
return self.broadcast_task(
|
||||||
|
team_id=request["team_id"],
|
||||||
|
description=request["description"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "team_collect":
|
||||||
|
return self.collect_team_results(team_id=request["team_id"])
|
||||||
|
|
||||||
|
elif action == "team_status":
|
||||||
|
return self.get_team_status(team_id=request["team_id"])
|
||||||
|
|
||||||
|
elif action == "task_submit":
|
||||||
|
return self.submit_background_task(
|
||||||
|
name=request["name"],
|
||||||
|
coro=request["coro"],
|
||||||
|
*request.get("args", []),
|
||||||
|
**request.get("kwargs", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "task_cancel":
|
||||||
|
return self.cancel_background_task(task_id=request["task_id"])
|
||||||
|
|
||||||
|
elif action == "task_status":
|
||||||
|
return self.get_background_task_status(task_id=request["task_id"])
|
||||||
|
|
||||||
|
elif action == "session_create":
|
||||||
|
return self.create_session(
|
||||||
|
user_id=request.get("user_id"),
|
||||||
|
parent_session_id=request.get("parent_session_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "session_message":
|
||||||
|
return await self.process_session_message(
|
||||||
|
session_id=request["session_id"],
|
||||||
|
message=request["message"],
|
||||||
|
response=request["response"],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "session_spawn":
|
||||||
|
return await self.spawn_child_session(
|
||||||
|
session_id=request["session_id"],
|
||||||
|
user_id=request.get("user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == "session_summary":
|
||||||
|
return self.get_session_summary(session_id=request["session_id"])
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": f"Unknown action: {action}"}
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_coordinator: AgentCoordinator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_coordinator() -> AgentCoordinator:
|
||||||
|
"""获取全局 Agent 协调整器"""
|
||||||
|
global _coordinator
|
||||||
|
if _coordinator is None:
|
||||||
|
_coordinator = AgentCoordinator()
|
||||||
|
return _coordinator
|
||||||
File diff suppressed because it is too large
Load Diff
14
backend/app/agents/isolation/__init__.py
Normal file
14
backend/app/agents/isolation/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from app.agents.isolation.session_isolation import prepare_session_isolation
|
||||||
|
from app.agents.isolation.strategy_selector import IsolationDecision, select_isolation_strategy
|
||||||
|
from app.agents.isolation.worktree_isolation import (
|
||||||
|
WorktreeIsolationError,
|
||||||
|
prepare_worktree_isolation,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IsolationDecision",
|
||||||
|
"WorktreeIsolationError",
|
||||||
|
"prepare_session_isolation",
|
||||||
|
"prepare_worktree_isolation",
|
||||||
|
"select_isolation_strategy",
|
||||||
|
]
|
||||||
31
backend/app/agents/isolation/session_isolation.py
Normal file
31
backend/app/agents/isolation/session_isolation.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.isolation.strategy_selector import IsolationDecision
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_session_isolation(
|
||||||
|
*,
|
||||||
|
state: dict[str, Any],
|
||||||
|
decision: IsolationDecision,
|
||||||
|
role_value: str,
|
||||||
|
sub_commander: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
isolation_id = f"session-{uuid4().hex[:8]}"
|
||||||
|
return {
|
||||||
|
"mode": "session",
|
||||||
|
"isolation_id": isolation_id,
|
||||||
|
"workspace_path": None,
|
||||||
|
"parent_conversation_id": str(state.get("conversation_id") or "") or None,
|
||||||
|
"metadata": {
|
||||||
|
**dict(decision.metadata or {}),
|
||||||
|
"reason": decision.reason,
|
||||||
|
"role": role_value,
|
||||||
|
"sub_commander": sub_commander,
|
||||||
|
"tool_names": list(decision.tool_names),
|
||||||
|
"capability_ids": list(decision.capability_ids),
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
}
|
||||||
147
backend/app/agents/isolation/strategy_selector.py
Normal file
147
backend/app/agents/isolation/strategy_selector.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.agents.registry import load_builtin_registry_indexes
|
||||||
|
from app.agents.registry.models import CapabilityManifest, PermissionClass, SideEffectScope
|
||||||
|
|
||||||
|
|
||||||
|
IsolationMode = Literal["none", "session", "worktree"]
|
||||||
|
|
||||||
|
_WORKTREE_QUERY_MARKERS = (
|
||||||
|
"code",
|
||||||
|
"repo",
|
||||||
|
"repository",
|
||||||
|
"git",
|
||||||
|
"worktree",
|
||||||
|
"branch",
|
||||||
|
"patch",
|
||||||
|
"diff",
|
||||||
|
"refactor",
|
||||||
|
"build",
|
||||||
|
"test",
|
||||||
|
"fix",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"python",
|
||||||
|
"typescript",
|
||||||
|
"javascript",
|
||||||
|
"代码",
|
||||||
|
"仓库",
|
||||||
|
"分支",
|
||||||
|
"补丁",
|
||||||
|
"重构",
|
||||||
|
"构建",
|
||||||
|
"测试",
|
||||||
|
"修复",
|
||||||
|
"文件",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IsolationDecision:
|
||||||
|
mode: IsolationMode
|
||||||
|
reason: str
|
||||||
|
tool_names: tuple[str, ...] = ()
|
||||||
|
capability_ids: tuple[str, ...] = ()
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability_metadata(capability: CapabilityManifest | None) -> dict[str, Any]:
|
||||||
|
if capability is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"capability_id": capability.capability_id,
|
||||||
|
"tool_name": capability.tool_name,
|
||||||
|
"permission_class": capability.permission_class.value,
|
||||||
|
"side_effect_scope": capability.side_effect_scope.value,
|
||||||
|
"supports_retry": capability.supports_retry,
|
||||||
|
"idempotent": capability.idempotent,
|
||||||
|
"safe_for_parallel_use": capability.safe_for_parallel_use,
|
||||||
|
"requires_confirmation": capability.requires_confirmation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def select_isolation_strategy(
|
||||||
|
*,
|
||||||
|
user_query: str,
|
||||||
|
tool_names: list[str] | tuple[str, ...],
|
||||||
|
role_value: str,
|
||||||
|
execution_mode: str | None,
|
||||||
|
) -> IsolationDecision:
|
||||||
|
indexes = load_builtin_registry_indexes()
|
||||||
|
capabilities: list[CapabilityManifest] = []
|
||||||
|
capability_ids: list[str] = []
|
||||||
|
|
||||||
|
for tool_name in tool_names:
|
||||||
|
capability_id = indexes.capability_id_by_tool_name.get(tool_name)
|
||||||
|
capability = indexes.capability_by_id.get(capability_id) if capability_id else None
|
||||||
|
if capability is not None:
|
||||||
|
capabilities.append(capability)
|
||||||
|
capability_ids.append(capability.capability_id)
|
||||||
|
|
||||||
|
normalized_query = (user_query or "").strip().lower()
|
||||||
|
has_worktree_query_signal = any(marker in normalized_query for marker in _WORKTREE_QUERY_MARKERS)
|
||||||
|
has_write_capability = any(cap.permission_class == PermissionClass.WRITE for cap in capabilities)
|
||||||
|
has_external_capability = any(cap.permission_class == PermissionClass.EXTERNAL for cap in capabilities)
|
||||||
|
has_non_parallel_capability = any(not cap.safe_for_parallel_use for cap in capabilities)
|
||||||
|
has_stateful_side_effect = any(
|
||||||
|
cap.side_effect_scope in {SideEffectScope.LOCAL_STATE, SideEffectScope.DB_WRITE}
|
||||||
|
for cap in capabilities
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"role": role_value,
|
||||||
|
"execution_mode": execution_mode,
|
||||||
|
"capabilities": [_capability_metadata(capability) for capability in capabilities],
|
||||||
|
"workspace_strategy": "inline",
|
||||||
|
"risk_level": "low",
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_worktree_query_signal:
|
||||||
|
return IsolationDecision(
|
||||||
|
mode="worktree",
|
||||||
|
reason="workspace_mutation_signals_detected",
|
||||||
|
tool_names=tuple(tool_names),
|
||||||
|
capability_ids=tuple(capability_ids),
|
||||||
|
metadata={
|
||||||
|
**metadata,
|
||||||
|
"workspace_strategy": "ephemeral_worktree",
|
||||||
|
"risk_level": "high",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_write_capability or has_stateful_side_effect or has_non_parallel_capability:
|
||||||
|
return IsolationDecision(
|
||||||
|
mode="session",
|
||||||
|
reason="stateful_or_non_parallel_tooling",
|
||||||
|
tool_names=tuple(tool_names),
|
||||||
|
capability_ids=tuple(capability_ids),
|
||||||
|
metadata={
|
||||||
|
**metadata,
|
||||||
|
"workspace_strategy": "isolated_session",
|
||||||
|
"risk_level": "medium",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if execution_mode == "collaboration" or role_value in {"analyst", "librarian"} or has_external_capability:
|
||||||
|
return IsolationDecision(
|
||||||
|
mode="session",
|
||||||
|
reason="context_heavy_or_external_retrieval",
|
||||||
|
tool_names=tuple(tool_names),
|
||||||
|
capability_ids=tuple(capability_ids),
|
||||||
|
metadata={
|
||||||
|
**metadata,
|
||||||
|
"workspace_strategy": "isolated_session",
|
||||||
|
"risk_level": "medium",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return IsolationDecision(
|
||||||
|
mode="none",
|
||||||
|
reason="inline_execution_is_sufficient",
|
||||||
|
tool_names=tuple(tool_names),
|
||||||
|
capability_ids=tuple(capability_ids),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
83
backend/app/agents/isolation/worktree_isolation.py
Normal file
83
backend/app/agents/isolation/worktree_isolation.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.agents.isolation.strategy_selector import IsolationDecision
|
||||||
|
|
||||||
|
|
||||||
|
class WorktreeIsolationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str, *, fallback: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", (value or "").strip()).strip("-").lower()
|
||||||
|
return slug or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_git_root() -> Path:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise WorktreeIsolationError(exc.stderr.strip() or exc.stdout.strip() or "git_root_unavailable") from exc
|
||||||
|
git_root = Path(result.stdout.strip())
|
||||||
|
if not git_root.exists():
|
||||||
|
raise WorktreeIsolationError("git_root_not_found")
|
||||||
|
return git_root
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_worktree_isolation(
|
||||||
|
*,
|
||||||
|
state: dict[str, Any],
|
||||||
|
decision: IsolationDecision,
|
||||||
|
role_value: str,
|
||||||
|
sub_commander: str,
|
||||||
|
create_workspace: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
isolation_id = f"worktree-{uuid4().hex[:8]}"
|
||||||
|
conversation_slug = _slugify(str(state.get("conversation_id") or "conversation"), fallback="conversation")
|
||||||
|
role_slug = _slugify(role_value, fallback="agent")
|
||||||
|
git_root = _resolve_git_root()
|
||||||
|
workspace_root = git_root / ".worktrees" / "jarvis" / conversation_slug
|
||||||
|
workspace_path = workspace_root / f"{role_slug}-{isolation_id}"
|
||||||
|
branch = f"jarvis/{conversation_slug}/{role_slug}-{isolation_id}"
|
||||||
|
|
||||||
|
if create_workspace and not workspace_path.exists():
|
||||||
|
workspace_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(git_root), "worktree", "add", "-b", branch, str(workspace_path), "HEAD"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
raise WorktreeIsolationError(exc.stderr.strip() or exc.stdout.strip() or "worktree_add_failed") from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": "worktree",
|
||||||
|
"isolation_id": isolation_id,
|
||||||
|
"workspace_path": str(workspace_path),
|
||||||
|
"parent_conversation_id": str(state.get("conversation_id") or "") or None,
|
||||||
|
"metadata": {
|
||||||
|
**dict(decision.metadata or {}),
|
||||||
|
"reason": decision.reason,
|
||||||
|
"role": role_value,
|
||||||
|
"sub_commander": sub_commander,
|
||||||
|
"tool_names": list(decision.tool_names),
|
||||||
|
"capability_ids": list(decision.capability_ids),
|
||||||
|
"repo_root": str(git_root),
|
||||||
|
"branch": branch,
|
||||||
|
"workspace_strategy": "ephemeral_worktree",
|
||||||
|
"cleanup_status": "pending",
|
||||||
|
"materialized": workspace_path.exists(),
|
||||||
|
},
|
||||||
|
}
|
||||||
20
backend/app/agents/orchestration/__init__.py
Normal file
20
backend/app/agents/orchestration/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""高级编排系统 - Phase 10"""
|
||||||
|
|
||||||
|
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
||||||
|
from app.agents.transport.remote import RemoteTransport, StructuredMessage
|
||||||
|
from app.agents.background.manager import (
|
||||||
|
BackgroundTaskManager,
|
||||||
|
BackgroundTask,
|
||||||
|
get_background_task_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TeamLeader",
|
||||||
|
"TeamTask",
|
||||||
|
"TaskStatus",
|
||||||
|
"RemoteTransport",
|
||||||
|
"StructuredMessage",
|
||||||
|
"BackgroundTaskManager",
|
||||||
|
"BackgroundTask",
|
||||||
|
"get_background_task_manager",
|
||||||
|
]
|
||||||
12
backend/app/agents/plugins/__init__.py
Normal file
12
backend/app/agents/plugins/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""插件系统 - Phase 8"""
|
||||||
|
|
||||||
|
from app.agents.plugins.manager import PluginManager, get_plugin_manager
|
||||||
|
from app.agents.plugins.manifest import PluginManifest
|
||||||
|
from app.agents.plugins.sandbox import PluginSandbox
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PluginManager",
|
||||||
|
"PluginManifest",
|
||||||
|
"PluginSandbox",
|
||||||
|
"get_plugin_manager",
|
||||||
|
]
|
||||||
19
backend/app/agents/plugins/builtins/code_helper/__init__.py
Normal file
19
backend/app/agents/plugins/builtins/code_helper/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Code Helper Plugin - Linting, formatting, and code explanation tools"""
|
||||||
|
|
||||||
|
|
||||||
|
def lint_file(file_path: str) -> dict:
|
||||||
|
"""Lint a source file and return issues found."""
|
||||||
|
return {"status": "ok", "tool": "lint_file", "result": f"Linting {file_path}"}
|
||||||
|
|
||||||
|
|
||||||
|
def format_file(file_path: str) -> dict:
|
||||||
|
"""Format a source file and return the result."""
|
||||||
|
return {"status": "ok", "tool": "format_file", "result": f"Formatting {file_path}"}
|
||||||
|
|
||||||
|
|
||||||
|
def explain_code(code_snippet: str) -> dict:
|
||||||
|
"""Explain a code snippet and return the explanation."""
|
||||||
|
return {"status": "ok", "tool": "explain_code", "result": f"Explaining code snippet"}
|
||||||
|
|
||||||
|
|
||||||
|
tools = [lint_file, format_file, explain_code]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "code_helper",
|
||||||
|
"name": "Code Helper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Code linting, formatting, and explanation tools",
|
||||||
|
"author": "",
|
||||||
|
"homepage": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"plugin_type": "tool",
|
||||||
|
"main": "__init__.py",
|
||||||
|
"hooks": [],
|
||||||
|
"tools": ["lint_file", "format_file", "explain_code"],
|
||||||
|
"skills": [],
|
||||||
|
"dependencies": {},
|
||||||
|
"peer_dependencies": {},
|
||||||
|
"permissions": [],
|
||||||
|
"allowed_paths": [],
|
||||||
|
"denied_paths": [],
|
||||||
|
"network_allowed": false,
|
||||||
|
"allowed_hosts": [],
|
||||||
|
"config_schema": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""File Organizer Plugin - File organization and duplicate detection tools"""
|
||||||
|
|
||||||
|
|
||||||
|
def organize_by_type(directory: str) -> dict:
|
||||||
|
"""Organize files in a directory by file type."""
|
||||||
|
return {"status": "ok", "tool": "organize_by_type", "result": f"Organizing {directory} by type"}
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicates(directory: str) -> dict:
|
||||||
|
"""Find duplicate files in a directory."""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"tool": "find_duplicates",
|
||||||
|
"result": f"Finding duplicates in {directory}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
tools = [organize_by_type, find_duplicates]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "file_organizer",
|
||||||
|
"name": "File Organizer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "File organization and duplicate detection tools",
|
||||||
|
"author": "",
|
||||||
|
"homepage": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"plugin_type": "tool",
|
||||||
|
"main": "__init__.py",
|
||||||
|
"hooks": [],
|
||||||
|
"tools": ["organize_by_type", "find_duplicates"],
|
||||||
|
"skills": [],
|
||||||
|
"dependencies": {},
|
||||||
|
"peer_dependencies": {},
|
||||||
|
"permissions": [],
|
||||||
|
"allowed_paths": [],
|
||||||
|
"denied_paths": [],
|
||||||
|
"network_allowed": false,
|
||||||
|
"allowed_hosts": [],
|
||||||
|
"config_schema": {}
|
||||||
|
}
|
||||||
23
backend/app/agents/plugins/builtins/git_helper/__init__.py
Normal file
23
backend/app/agents/plugins/builtins/git_helper/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Git Helper Plugin - Git status, log, and diff summary tools"""
|
||||||
|
|
||||||
|
|
||||||
|
def git_status_summary() -> dict:
|
||||||
|
"""Get a summary of git status."""
|
||||||
|
return {"status": "ok", "tool": "git_status_summary", "result": "Git status summary"}
|
||||||
|
|
||||||
|
|
||||||
|
def git_log_summary(limit: int = 10) -> dict:
|
||||||
|
"""Get a summary of recent git commits."""
|
||||||
|
return {"status": "ok", "tool": "git_log_summary", "result": f"Git log summary (limit={limit})"}
|
||||||
|
|
||||||
|
|
||||||
|
def git_diff_summary(ref1: str = "HEAD", ref2: str = "HEAD~1") -> dict:
|
||||||
|
"""Get a summary of changes between two refs."""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"tool": "git_diff_summary",
|
||||||
|
"result": f"Git diff summary ({ref1}..{ref2})",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
tools = [git_status_summary, git_log_summary, git_diff_summary]
|
||||||
22
backend/app/agents/plugins/builtins/git_helper/manifest.json
Normal file
22
backend/app/agents/plugins/builtins/git_helper/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "git_helper",
|
||||||
|
"name": "Git Helper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Git status, log, and diff summary tools",
|
||||||
|
"author": "",
|
||||||
|
"homepage": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"plugin_type": "tool",
|
||||||
|
"main": "__init__.py",
|
||||||
|
"hooks": [],
|
||||||
|
"tools": ["git_status_summary", "git_log_summary", "git_diff_summary"],
|
||||||
|
"skills": [],
|
||||||
|
"dependencies": {},
|
||||||
|
"peer_dependencies": {},
|
||||||
|
"permissions": [],
|
||||||
|
"allowed_paths": [],
|
||||||
|
"denied_paths": [],
|
||||||
|
"network_allowed": false,
|
||||||
|
"allowed_hosts": [],
|
||||||
|
"config_schema": {}
|
||||||
|
}
|
||||||
14
backend/app/agents/plugins/builtins/web_helper/__init__.py
Normal file
14
backend/app/agents/plugins/builtins/web_helper/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Web Helper Plugin - Web fetching and HTML parsing tools"""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_url_content(url: str) -> dict:
|
||||||
|
"""Fetch content from a URL."""
|
||||||
|
return {"status": "ok", "tool": "fetch_url_content", "result": f"Fetching {url}"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_html_links(html_content: str) -> dict:
|
||||||
|
"""Parse HTML content and extract links."""
|
||||||
|
return {"status": "ok", "tool": "parse_html_links", "result": "Extracted links from HTML"}
|
||||||
|
|
||||||
|
|
||||||
|
tools = [fetch_url_content, parse_html_links]
|
||||||
22
backend/app/agents/plugins/builtins/web_helper/manifest.json
Normal file
22
backend/app/agents/plugins/builtins/web_helper/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": "web_helper",
|
||||||
|
"name": "Web Helper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web fetching and HTML parsing tools",
|
||||||
|
"author": "",
|
||||||
|
"homepage": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"plugin_type": "tool",
|
||||||
|
"main": "__init__.py",
|
||||||
|
"hooks": [],
|
||||||
|
"tools": ["fetch_url_content", "parse_html_links"],
|
||||||
|
"skills": [],
|
||||||
|
"dependencies": {},
|
||||||
|
"peer_dependencies": {},
|
||||||
|
"permissions": [],
|
||||||
|
"allowed_paths": [],
|
||||||
|
"denied_paths": [],
|
||||||
|
"network_allowed": true,
|
||||||
|
"allowed_hosts": [],
|
||||||
|
"config_schema": {}
|
||||||
|
}
|
||||||
207
backend/app/agents/plugins/manager.py
Normal file
207
backend/app/agents/plugins/manager.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""插件管理器 - Phase 8.2"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.plugins.manifest import PluginManifest
|
||||||
|
from app.agents.plugins.sandbox import PluginSandbox
|
||||||
|
|
||||||
|
|
||||||
|
class PluginManager:
|
||||||
|
"""插件管理器
|
||||||
|
|
||||||
|
负责插件的安装、卸载、启用、禁用和生命周期管理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, plugins_dir: str | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
plugins_dir: 插件目录,None 则使用默认目录
|
||||||
|
"""
|
||||||
|
if plugins_dir is None:
|
||||||
|
plugins_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "plugins")
|
||||||
|
self.plugins_dir = plugins_dir
|
||||||
|
self._plugins: dict[str, PluginManifest] = {}
|
||||||
|
self._enabled: dict[str, bool] = {}
|
||||||
|
self._modules: dict[str, Any] = {}
|
||||||
|
self._sandbox = PluginSandbox()
|
||||||
|
|
||||||
|
def install(self, plugin_path: str) -> bool:
|
||||||
|
"""安装插件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_path: 插件目录路径或 manifest.json 所在目录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否安装成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
manifest_path = os.path.join(plugin_path, "manifest.json")
|
||||||
|
|
||||||
|
if not os.path.exists(manifest_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
import json
|
||||||
|
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
manifest = PluginManifest.from_dict(data)
|
||||||
|
|
||||||
|
# 验证 manifest
|
||||||
|
if not self._validate_manifest(manifest, plugin_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 复制插件到 plugins_dir
|
||||||
|
target_dir = os.path.join(self.plugins_dir, manifest.id)
|
||||||
|
os.makedirs(os.path.dirname(target_dir), exist_ok=True)
|
||||||
|
|
||||||
|
# 保存 manifest
|
||||||
|
with open(os.path.join(target_dir, "manifest.json"), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 注册插件
|
||||||
|
self._plugins[manifest.id] = manifest
|
||||||
|
self._enabled[manifest.id] = True
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def uninstall(self, plugin_id: str) -> bool:
|
||||||
|
"""卸载插件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: 插件 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否卸载成功
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._plugins:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 禁用插件
|
||||||
|
self.disable(plugin_id)
|
||||||
|
|
||||||
|
# 移除模块
|
||||||
|
if plugin_id in self._modules:
|
||||||
|
del self._modules[plugin_id]
|
||||||
|
|
||||||
|
# 移除插件
|
||||||
|
del self._plugins[plugin_id]
|
||||||
|
del self._enabled[plugin_id]
|
||||||
|
|
||||||
|
# 删除目录
|
||||||
|
plugin_dir = os.path.join(self.plugins_dir, plugin_id)
|
||||||
|
if os.path.exists(plugin_dir):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.rmtree(plugin_dir)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def enable(self, plugin_id: str) -> bool:
|
||||||
|
"""启用插件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: 插件 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否启用成功
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._plugins:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._enabled[plugin_id] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def disable(self, plugin_id: str) -> bool:
|
||||||
|
"""禁用插件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: 插件 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否禁用成功
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._plugins:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._enabled[plugin_id] = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reload(self, plugin_id: str) -> bool:
|
||||||
|
"""重新加载插件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_id: 插件 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否重新加载成功
|
||||||
|
"""
|
||||||
|
if plugin_id not in self._plugins:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 卸载模块
|
||||||
|
if plugin_id in self._modules:
|
||||||
|
del self._modules[plugin_id]
|
||||||
|
|
||||||
|
# 重新加载
|
||||||
|
return self._load_plugin_module(plugin_id)
|
||||||
|
|
||||||
|
def list_plugins(self) -> list[PluginManifest]:
|
||||||
|
"""列出所有插件"""
|
||||||
|
return list(self._plugins.values())
|
||||||
|
|
||||||
|
def get_plugin(self, plugin_id: str) -> PluginManifest | None:
|
||||||
|
"""获取插件清单"""
|
||||||
|
return self._plugins.get(plugin_id)
|
||||||
|
|
||||||
|
def is_enabled(self, plugin_id: str) -> bool:
|
||||||
|
"""检查插件是否启用"""
|
||||||
|
return self._enabled.get(plugin_id, False)
|
||||||
|
|
||||||
|
def _validate_manifest(self, manifest: PluginManifest, plugin_path: str) -> bool:
|
||||||
|
"""验证 manifest"""
|
||||||
|
# 检查主入口文件是否存在
|
||||||
|
main_path = os.path.join(plugin_path, manifest.main)
|
||||||
|
if not os.path.exists(main_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _load_plugin_module(self, plugin_id: str) -> bool:
|
||||||
|
"""加载插件模块"""
|
||||||
|
plugin_dir = os.path.join(self.plugins_dir, plugin_id)
|
||||||
|
manifest = self._plugins.get(plugin_id)
|
||||||
|
if not manifest:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
main_path = os.path.join(plugin_dir, manifest.main)
|
||||||
|
spec = importlib.util.spec_from_file_location(plugin_id, main_path)
|
||||||
|
if spec and spec.loader:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[plugin_id] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
self._modules[plugin_id] = module
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_manager: PluginManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugin_manager() -> PluginManager:
|
||||||
|
"""获取全局插件管理器"""
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = PluginManager()
|
||||||
|
return _manager
|
||||||
73
backend/app/agents/plugins/manifest.py
Normal file
73
backend/app/agents/plugins/manifest.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""插件清单定义 - Phase 8.1"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PluginManifest:
|
||||||
|
"""插件清单
|
||||||
|
|
||||||
|
定义插件的元数据和接口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str # 唯一标识
|
||||||
|
name: str # 显示名称
|
||||||
|
version: str # 版本号
|
||||||
|
description: str # 描述
|
||||||
|
author: str = "" # 作者
|
||||||
|
homepage: str = "" # 主页
|
||||||
|
license: str = "MIT" # 许可证
|
||||||
|
|
||||||
|
# 插件类型
|
||||||
|
plugin_type: str = "tool" # tool, hook, skill, all
|
||||||
|
|
||||||
|
# 入口点
|
||||||
|
main: str = "index.py" # 主入口文件
|
||||||
|
hooks: list[str] = field(default_factory=list) # 提供的 Hook 列表
|
||||||
|
tools: list[str] = field(default_factory=list) # 提供的工具列表
|
||||||
|
skills: list[str] = field(default_factory=list) # 提供的 Skills 列表
|
||||||
|
|
||||||
|
# 依赖
|
||||||
|
dependencies: dict[str, str] = field(default_factory=dict) # pip 依赖
|
||||||
|
peer_dependencies: dict[str, str] = field(default_factory=dict) # 对等依赖
|
||||||
|
|
||||||
|
# 权限要求
|
||||||
|
permissions: list[str] = field(default_factory=list) # 需要的权限
|
||||||
|
allowed_paths: list[str] = field(default_factory=list) # 允许访问的路径
|
||||||
|
denied_paths: list[str] = field(default_factory=list) # 禁止访问的路径
|
||||||
|
|
||||||
|
# 网络权限
|
||||||
|
network_allowed: bool = False # 是否允许网络访问
|
||||||
|
allowed_hosts: list[str] = field(default_factory=list) # 允许访问的 host
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
config_schema: dict[str, Any] = field(default_factory=dict) # 配置 schema
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"version": self.version,
|
||||||
|
"description": self.description,
|
||||||
|
"author": self.author,
|
||||||
|
"homepage": self.homepage,
|
||||||
|
"license": self.license,
|
||||||
|
"plugin_type": self.plugin_type,
|
||||||
|
"main": self.main,
|
||||||
|
"hooks": self.hooks,
|
||||||
|
"tools": self.tools,
|
||||||
|
"skills": self.skills,
|
||||||
|
"dependencies": self.dependencies,
|
||||||
|
"peer_dependencies": self.peer_dependencies,
|
||||||
|
"permissions": self.permissions,
|
||||||
|
"allowed_paths": self.allowed_paths,
|
||||||
|
"denied_paths": self.denied_paths,
|
||||||
|
"network_allowed": self.network_allowed,
|
||||||
|
"allowed_hosts": self.allowed_hosts,
|
||||||
|
"config_schema": self.config_schema,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "PluginManifest":
|
||||||
|
return cls(**data)
|
||||||
111
backend/app/agents/plugins/sandbox.py
Normal file
111
backend/app/agents/plugins/sandbox.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""插件沙箱隔离 - Phase 8.3"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSandbox:
|
||||||
|
"""插件沙箱
|
||||||
|
|
||||||
|
提供插件执行隔离环境。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._allowed_paths: set[str] = set()
|
||||||
|
self._denied_paths: set[str] = set()
|
||||||
|
self._network_allowed: bool = False
|
||||||
|
self._allowed_hosts: set[str] = set()
|
||||||
|
|
||||||
|
def set_file_permissions(
|
||||||
|
self,
|
||||||
|
allowed_paths: list[str] | None = None,
|
||||||
|
denied_paths: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""设置文件访问权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allowed_paths: 允许访问的路径列表
|
||||||
|
denied_paths: 禁止访问的路径列表
|
||||||
|
"""
|
||||||
|
self._allowed_paths = set(allowed_paths or [])
|
||||||
|
self._denied_paths = set(denied_paths or [])
|
||||||
|
|
||||||
|
def set_network_permissions(
|
||||||
|
self, allowed: bool, allowed_hosts: list[str] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""设置网络访问权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allowed: 是否允许网络访问
|
||||||
|
allowed_hosts: 允许访问的 host 列表
|
||||||
|
"""
|
||||||
|
self._network_allowed = allowed
|
||||||
|
self._allowed_hosts = set(allowed_hosts or [])
|
||||||
|
|
||||||
|
def check_file_access(self, path: str) -> bool:
|
||||||
|
"""检查文件访问权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否允许访问
|
||||||
|
"""
|
||||||
|
# 如果有允许列表,只允许访问列表中的路径
|
||||||
|
if self._allowed_paths:
|
||||||
|
return path in self._allowed_paths or any(
|
||||||
|
path.startswith(allowed) for allowed in self._allowed_paths
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果有禁止列表,禁止访问列表中的路径
|
||||||
|
if self._denied_paths:
|
||||||
|
return not any(path.startswith(denied) for denied in self._denied_paths)
|
||||||
|
|
||||||
|
# 没有限制
|
||||||
|
return True
|
||||||
|
|
||||||
|
def check_network_access(self, host: str) -> bool:
|
||||||
|
"""检查网络访问权限
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: 主机地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否允许访问
|
||||||
|
"""
|
||||||
|
if not self._network_allowed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self._allowed_hosts:
|
||||||
|
return host in self._allowed_hosts or any(
|
||||||
|
host.endswith(allowed) for allowed in self._allowed_hosts
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def execute_in_sandbox(self, func: Any, *args, **kwargs) -> Any:
|
||||||
|
"""在沙箱中执行函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: 要执行的函数
|
||||||
|
*args: 位置参数
|
||||||
|
**kwargs: 关键字参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
函数返回值
|
||||||
|
"""
|
||||||
|
# 保存当前状态
|
||||||
|
old_allowed_paths = self._allowed_paths.copy()
|
||||||
|
old_denied_paths = self._denied_paths.copy()
|
||||||
|
old_network_allowed = self._network_allowed
|
||||||
|
old_allowed_hosts = self._allowed_hosts.copy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
finally:
|
||||||
|
# 恢复状态
|
||||||
|
self._allowed_paths = old_allowed_paths
|
||||||
|
self._denied_paths = old_denied_paths
|
||||||
|
self._network_allowed = old_network_allowed
|
||||||
|
self._allowed_hosts = old_allowed_hosts
|
||||||
@@ -324,6 +324,38 @@ ANALYST_INSIGHTS_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
COORDINATOR_SYSTEM_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||||
|
|
||||||
|
你是 Jarvis 的协作协调官,负责把复杂请求收束成最小受控协作,而不是放任系统进入自由 swarm。
|
||||||
|
|
||||||
|
## 你的职责:
|
||||||
|
- 先判断当前请求是否真的需要拆解;不需要时应明确建议继续走 direct
|
||||||
|
- 只有在明显多步骤、跨领域、需要多角色配合时,才拆成 2~4 个子任务
|
||||||
|
- 每个子任务必须清晰写出 `title`、`role`、`goal`、`expected_evidence`
|
||||||
|
- 角色建议只能来自现有 top-level agent:`schedule_planner`、`librarian`、`analyst`、`executor`
|
||||||
|
- 汇总时基于子任务结果回收,不依赖单点硬编码拼接
|
||||||
|
|
||||||
|
## 边界:
|
||||||
|
- 禁止无限递归拆分
|
||||||
|
- 禁止创建新的 runtime agent / worker
|
||||||
|
- 禁止把一个简单请求硬拆成多个空泛步骤
|
||||||
|
- 如果证据不足、子任务未闭环,必须把风险明确暴露出来
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
VERIFIER_PROMPT = f"""{JARVIS_PERSONA_PROMPT}
|
||||||
|
|
||||||
|
你是 Jarvis 的验证官,负责对执行结果做最小但明确的核验。
|
||||||
|
|
||||||
|
## 你的职责:
|
||||||
|
- 只输出 passed、failed、skipped 三种验证结论之一
|
||||||
|
- 用一句话总结验证判断
|
||||||
|
- 如有证据,保留关键证据点
|
||||||
|
- 当信息不足以证明成功或失败时,优先判定为 skipped
|
||||||
|
- 不重写执行方案,不扩展无关建议
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
JSON_ACTION_FALLBACK_PROMPT = """你当前运行在 JSON action fallback 模式。
|
JSON_ACTION_FALLBACK_PROMPT = """你当前运行在 JSON action fallback 模式。
|
||||||
|
|
||||||
你的输出必须满足以下规则:
|
你的输出必须满足以下规则:
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
"""Registry manifest models and validation helpers."""
|
"""Registry manifest models and validation helpers."""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from app.agents.registry.indexes import RegistryIndexes, build_registry_indexes
|
from app.agents.registry.indexes import RegistryIndexes, build_registry_indexes
|
||||||
from app.agents.registry.loader import RegistryBundle, load_builtin_registry_bundle
|
from app.agents.registry.loader import RegistryBundle, load_builtin_registry_bundle
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load_builtin_registry_indexes() -> RegistryIndexes:
|
||||||
|
return build_registry_indexes(load_builtin_registry_bundle())
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RegistryBundle",
|
"RegistryBundle",
|
||||||
"RegistryIndexes",
|
"RegistryIndexes",
|
||||||
"build_registry_indexes",
|
"build_registry_indexes",
|
||||||
"load_builtin_registry_bundle",
|
"load_builtin_registry_bundle",
|
||||||
|
"load_builtin_registry_indexes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from app.agents.prompts import SUB_COMMANDER_PROMPTS_BY_KEY
|
|||||||
from app.agents.registry.models import (
|
from app.agents.registry.models import (
|
||||||
AgentManifest,
|
AgentManifest,
|
||||||
CapabilityManifest,
|
CapabilityManifest,
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
SpecialistTemplateManifest,
|
SpecialistTemplateManifest,
|
||||||
SubCommanderManifest,
|
SubCommanderManifest,
|
||||||
)
|
)
|
||||||
@@ -55,6 +57,19 @@ TOP_LEVEL_AGENT_ROUTING_HINTS: dict[str, tuple[str, ...]] = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES: dict[str, tuple[str, ...]] = {
|
||||||
|
AgentRole.MASTER.value: (
|
||||||
|
AgentRole.SCHEDULE_PLANNER.value,
|
||||||
|
AgentRole.EXECUTOR.value,
|
||||||
|
AgentRole.LIBRARIAN.value,
|
||||||
|
AgentRole.ANALYST.value,
|
||||||
|
),
|
||||||
|
AgentRole.SCHEDULE_PLANNER.value: (AgentRole.SCHEDULE_PLANNER.value,),
|
||||||
|
AgentRole.EXECUTOR.value: (AgentRole.EXECUTOR.value,),
|
||||||
|
AgentRole.LIBRARIAN.value: (AgentRole.LIBRARIAN.value,),
|
||||||
|
AgentRole.ANALYST.value: (AgentRole.ANALYST.value,),
|
||||||
|
}
|
||||||
|
|
||||||
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
SUB_COMMANDER_PARENT_AGENT_IDS: dict[str, str] = {
|
||||||
"schedule_analysis": AgentRole.SCHEDULE_PLANNER.value,
|
"schedule_analysis": AgentRole.SCHEDULE_PLANNER.value,
|
||||||
"schedule_planning": AgentRole.SCHEDULE_PLANNER.value,
|
"schedule_planning": AgentRole.SCHEDULE_PLANNER.value,
|
||||||
@@ -75,6 +90,8 @@ BUILTIN_AGENT_MANIFESTS: tuple[AgentManifest, ...] = tuple(
|
|||||||
system_prompt_key=role.value,
|
system_prompt_key=role.value,
|
||||||
routing_hints=list(TOP_LEVEL_AGENT_ROUTING_HINTS[role.value]),
|
routing_hints=list(TOP_LEVEL_AGENT_ROUTING_HINTS[role.value]),
|
||||||
default_sub_commanders=list(TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS[role.value]),
|
default_sub_commanders=list(TOP_LEVEL_AGENT_DEFAULT_SUB_COMMANDERS[role.value]),
|
||||||
|
can_spawn_children=bool(TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES[role.value]),
|
||||||
|
allowed_spawn_role_values=list(TOP_LEVEL_AGENT_ALLOWED_SPAWN_ROLES[role.value]),
|
||||||
skill_context_key=role.value.replace("agent_", ""),
|
skill_context_key=role.value.replace("agent_", ""),
|
||||||
)
|
)
|
||||||
for role in AgentRole
|
for role in AgentRole
|
||||||
@@ -89,10 +106,150 @@ _capability_tool_names = tuple(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_CAPABILITY_METADATA_BY_TOOL_NAME: dict[str, dict[str, object]] = {
|
||||||
|
"get_tasks": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"get_schedule_day": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"resolve_time_expression": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"search_knowledge": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"hybrid_search": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"get_knowledge_graph_context": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"get_forum_posts": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"scan_forum_for_instructions": {
|
||||||
|
"permission_class": PermissionClass.READ,
|
||||||
|
"side_effect_scope": SideEffectScope.NONE,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"web_search": {
|
||||||
|
"permission_class": PermissionClass.EXTERNAL,
|
||||||
|
"side_effect_scope": SideEffectScope.NETWORK,
|
||||||
|
"supports_retry": True,
|
||||||
|
"idempotent": True,
|
||||||
|
"safe_for_parallel_use": True,
|
||||||
|
"requires_confirmation": False,
|
||||||
|
},
|
||||||
|
"create_task": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"update_task_status": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"create_todo": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"create_schedule_task": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"create_reminder": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"create_goal": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"create_forum_post": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
"build_knowledge_graph": {
|
||||||
|
"permission_class": PermissionClass.WRITE,
|
||||||
|
"side_effect_scope": SideEffectScope.LOCAL_STATE,
|
||||||
|
"supports_retry": False,
|
||||||
|
"idempotent": False,
|
||||||
|
"safe_for_parallel_use": False,
|
||||||
|
"requires_confirmation": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
BUILTIN_CAPABILITY_MANIFESTS: tuple[CapabilityManifest, ...] = tuple(
|
BUILTIN_CAPABILITY_MANIFESTS: tuple[CapabilityManifest, ...] = tuple(
|
||||||
CapabilityManifest(
|
CapabilityManifest(
|
||||||
capability_id=tool_name,
|
capability_id=tool_name,
|
||||||
tool_name=tool_name,
|
tool_name=tool_name,
|
||||||
|
**dict(_CAPABILITY_METADATA_BY_TOOL_NAME.get(tool_name, {})),
|
||||||
)
|
)
|
||||||
for tool_name in _capability_tool_names
|
for tool_name in _capability_tool_names
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.agents.registry.models import (
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RegistryIndexes:
|
class RegistryIndexes:
|
||||||
agent_by_id: Mapping[str, AgentManifest]
|
agent_by_id: Mapping[str, AgentManifest]
|
||||||
|
agent_by_role_value: Mapping[str, AgentManifest]
|
||||||
sub_commander_by_id: Mapping[str, SubCommanderManifest]
|
sub_commander_by_id: Mapping[str, SubCommanderManifest]
|
||||||
capability_by_id: Mapping[str, CapabilityManifest]
|
capability_by_id: Mapping[str, CapabilityManifest]
|
||||||
specialist_template_by_id: Mapping[str, SpecialistTemplateManifest]
|
specialist_template_by_id: Mapping[str, SpecialistTemplateManifest]
|
||||||
@@ -24,6 +25,7 @@ class RegistryIndexes:
|
|||||||
skill_context_key_by_agent_id: Mapping[str, str]
|
skill_context_key_by_agent_id: Mapping[str, str]
|
||||||
capability_id_by_tool_name: Mapping[str, str]
|
capability_id_by_tool_name: Mapping[str, str]
|
||||||
capability_ids_by_sub_commander_id: Mapping[str, tuple[str, ...]]
|
capability_ids_by_sub_commander_id: Mapping[str, tuple[str, ...]]
|
||||||
|
spawnable_role_values_by_agent_id: Mapping[str, tuple[str, ...]]
|
||||||
|
|
||||||
|
|
||||||
def summarize_registry_indexes(indexes: RegistryIndexes) -> dict[str, int]:
|
def summarize_registry_indexes(indexes: RegistryIndexes) -> dict[str, int]:
|
||||||
@@ -50,6 +52,9 @@ def build_registry_indexes(bundle: RegistryBundle) -> RegistryIndexes:
|
|||||||
|
|
||||||
return RegistryIndexes(
|
return RegistryIndexes(
|
||||||
agent_by_id=MappingProxyType(agent_by_id),
|
agent_by_id=MappingProxyType(agent_by_id),
|
||||||
|
agent_by_role_value=MappingProxyType({
|
||||||
|
agent.role_value: agent for agent in bundle.agents
|
||||||
|
}),
|
||||||
sub_commander_by_id=MappingProxyType(sub_commander_by_id),
|
sub_commander_by_id=MappingProxyType(sub_commander_by_id),
|
||||||
capability_by_id=MappingProxyType(capability_by_id),
|
capability_by_id=MappingProxyType(capability_by_id),
|
||||||
specialist_template_by_id=MappingProxyType(specialist_template_by_id),
|
specialist_template_by_id=MappingProxyType(specialist_template_by_id),
|
||||||
@@ -73,4 +78,9 @@ def build_registry_indexes(bundle: RegistryBundle) -> RegistryIndexes:
|
|||||||
sub_commander.sub_commander_id: tuple(sub_commander.capability_ids)
|
sub_commander.sub_commander_id: tuple(sub_commander.capability_ids)
|
||||||
for sub_commander in bundle.sub_commanders
|
for sub_commander in bundle.sub_commanders
|
||||||
}),
|
}),
|
||||||
|
spawnable_role_values_by_agent_id=MappingProxyType({
|
||||||
|
agent.agent_id: tuple(agent.allowed_spawn_role_values)
|
||||||
|
for agent in bundle.agents
|
||||||
|
if agent.can_spawn_children and agent.allowed_spawn_role_values
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
from pydantic import BaseModel
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionClass(str, Enum):
|
||||||
|
READ = "read"
|
||||||
|
WRITE = "write"
|
||||||
|
EXTERNAL = "external"
|
||||||
|
|
||||||
|
|
||||||
|
class SideEffectScope(str, Enum):
|
||||||
|
NONE = "none"
|
||||||
|
LOCAL_STATE = "local_state"
|
||||||
|
DB_WRITE = "db_write"
|
||||||
|
NETWORK = "network"
|
||||||
|
|
||||||
|
|
||||||
class AgentManifest(BaseModel):
|
class AgentManifest(BaseModel):
|
||||||
@@ -8,6 +23,8 @@ class AgentManifest(BaseModel):
|
|||||||
system_prompt_key: str
|
system_prompt_key: str
|
||||||
routing_hints: list[str]
|
routing_hints: list[str]
|
||||||
default_sub_commanders: list[str]
|
default_sub_commanders: list[str]
|
||||||
|
can_spawn_children: bool = False
|
||||||
|
allowed_spawn_role_values: list[str] = Field(default_factory=list)
|
||||||
skill_context_key: str | None = None
|
skill_context_key: str | None = None
|
||||||
continuity_policy: str | None = None
|
continuity_policy: str | None = None
|
||||||
clarification_policy: str | None = None
|
clarification_policy: str | None = None
|
||||||
@@ -23,6 +40,12 @@ class SubCommanderManifest(BaseModel):
|
|||||||
class CapabilityManifest(BaseModel):
|
class CapabilityManifest(BaseModel):
|
||||||
capability_id: str
|
capability_id: str
|
||||||
tool_name: str
|
tool_name: str
|
||||||
|
permission_class: PermissionClass = PermissionClass.READ
|
||||||
|
side_effect_scope: SideEffectScope = SideEffectScope.NONE
|
||||||
|
supports_retry: bool = False
|
||||||
|
idempotent: bool = False
|
||||||
|
safe_for_parallel_use: bool = False
|
||||||
|
requires_confirmation: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SpecialistTemplateManifest(BaseModel):
|
class SpecialistTemplateManifest(BaseModel):
|
||||||
|
|||||||
86
backend/app/agents/runtime_metrics.py
Normal file
86
backend/app/agents/runtime_metrics.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
INPUT_TOKEN_USD_RATE = 0.000003
|
||||||
|
OUTPUT_TOKEN_USD_RATE = 0.000015
|
||||||
|
DEFAULT_COST_THRESHOLDS = {
|
||||||
|
"total_tokens": 4000,
|
||||||
|
"estimated_cost": 0.02,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_token_cost(input_tokens: int, output_tokens: int) -> float | None:
|
||||||
|
total_tokens = max(input_tokens, 0) + max(output_tokens, 0)
|
||||||
|
if total_tokens <= 0:
|
||||||
|
return None
|
||||||
|
return round(
|
||||||
|
(max(input_tokens, 0) * INPUT_TOKEN_USD_RATE)
|
||||||
|
+ (max(output_tokens, 0) * OUTPUT_TOKEN_USD_RATE),
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_token_usage(response: Any) -> tuple[int, int]:
|
||||||
|
usage_metadata = getattr(response, "usage_metadata", None) or {}
|
||||||
|
if isinstance(usage_metadata, dict):
|
||||||
|
input_tokens = int(
|
||||||
|
usage_metadata.get("input_tokens")
|
||||||
|
or usage_metadata.get("prompt_tokens")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
output_tokens = int(
|
||||||
|
usage_metadata.get("output_tokens")
|
||||||
|
or usage_metadata.get("completion_tokens")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if input_tokens or output_tokens:
|
||||||
|
return input_tokens, output_tokens
|
||||||
|
|
||||||
|
response_metadata = getattr(response, "response_metadata", None) or {}
|
||||||
|
token_usage = {}
|
||||||
|
if isinstance(response_metadata, dict):
|
||||||
|
token_usage = response_metadata.get("token_usage") or response_metadata.get("usage") or {}
|
||||||
|
if isinstance(token_usage, dict):
|
||||||
|
input_tokens = int(
|
||||||
|
token_usage.get("prompt_tokens")
|
||||||
|
or token_usage.get("input_tokens")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
output_tokens = int(
|
||||||
|
token_usage.get("completion_tokens")
|
||||||
|
or token_usage.get("output_tokens")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
if input_tokens or output_tokens:
|
||||||
|
return input_tokens, output_tokens
|
||||||
|
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_cost_thresholds(raw_thresholds: Any) -> dict[str, float]:
|
||||||
|
thresholds: dict[str, float] = dict(DEFAULT_COST_THRESHOLDS)
|
||||||
|
if not isinstance(raw_thresholds, dict):
|
||||||
|
return thresholds
|
||||||
|
for key in DEFAULT_COST_THRESHOLDS:
|
||||||
|
value = raw_thresholds.get(key)
|
||||||
|
if isinstance(value, (int, float)) and value > 0:
|
||||||
|
thresholds[key] = float(value)
|
||||||
|
return thresholds
|
||||||
|
|
||||||
|
|
||||||
|
def is_cost_budget_warning(
|
||||||
|
input_tokens: int,
|
||||||
|
output_tokens: int,
|
||||||
|
estimated_cost: float | None,
|
||||||
|
thresholds: dict[str, float] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
effective_thresholds = thresholds or DEFAULT_COST_THRESHOLDS
|
||||||
|
total_tokens = max(input_tokens, 0) + max(output_tokens, 0)
|
||||||
|
token_threshold = float(effective_thresholds.get("total_tokens") or 0)
|
||||||
|
cost_threshold = float(effective_thresholds.get("estimated_cost") or 0)
|
||||||
|
return (
|
||||||
|
(token_threshold > 0 and total_tokens >= token_threshold)
|
||||||
|
or (cost_threshold > 0 and estimated_cost is not None and estimated_cost >= cost_threshold)
|
||||||
|
)
|
||||||
25
backend/app/agents/schemas/__init__.py
Normal file
25
backend/app/agents/schemas/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from app.agents.schemas.event import AgentEvent
|
||||||
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.task import (
|
||||||
|
AgentTask,
|
||||||
|
CollaborationBudget,
|
||||||
|
InterruptRecord,
|
||||||
|
RecoveryRecord,
|
||||||
|
TaskLifecycleStatus,
|
||||||
|
TaskResult,
|
||||||
|
TaskResultStatus,
|
||||||
|
VerificationStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentEvent",
|
||||||
|
"AgentMessage",
|
||||||
|
"AgentTask",
|
||||||
|
"CollaborationBudget",
|
||||||
|
"InterruptRecord",
|
||||||
|
"RecoveryRecord",
|
||||||
|
"TaskLifecycleStatus",
|
||||||
|
"TaskResult",
|
||||||
|
"TaskResultStatus",
|
||||||
|
"VerificationStatus",
|
||||||
|
]
|
||||||
52
backend/app/agents/schemas/event.py
Normal file
52
backend/app/agents/schemas/event.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
AgentEventType = Literal[
|
||||||
|
"agent.tool.start",
|
||||||
|
"agent.tool.result",
|
||||||
|
"agent.verify.started",
|
||||||
|
"agent.verify.completed",
|
||||||
|
"agent.created",
|
||||||
|
"agent.spawn.blocked",
|
||||||
|
"agent.message.sent",
|
||||||
|
"agent.message.received",
|
||||||
|
"agent.interrupt.requested",
|
||||||
|
"agent.interrupt.completed",
|
||||||
|
"agent.recovery.started",
|
||||||
|
"agent.recovery.completed",
|
||||||
|
"agent.task.interrupted",
|
||||||
|
"agent.task.recovered",
|
||||||
|
"agent.task.reassigned",
|
||||||
|
"agent.collaboration.budget.updated",
|
||||||
|
"agent.isolation.selected",
|
||||||
|
"agent.isolation.fallback",
|
||||||
|
"agent.cost.updated",
|
||||||
|
"agent.cost.warning",
|
||||||
|
"agent.phase.changed",
|
||||||
|
"agent.checkpoint.recorded",
|
||||||
|
"agent.error",
|
||||||
|
]
|
||||||
|
AgentEventSeverity = Literal["info", "warning", "error"]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentEvent(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
event_type: AgentEventType
|
||||||
|
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
conversation_id: str | None = None
|
||||||
|
agent_id: str | None = None
|
||||||
|
sub_commander_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
child_task_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
interrupt_id: str | None = None
|
||||||
|
recovery_id: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
severity: AgentEventSeverity = "info"
|
||||||
29
backend/app/agents/schemas/message.py
Normal file
29
backend/app/agents/schemas/message.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
AgentMessageType = Literal[
|
||||||
|
"task_request",
|
||||||
|
"task_update",
|
||||||
|
"handoff",
|
||||||
|
"verification_request",
|
||||||
|
"verification_feedback",
|
||||||
|
"interrupt_notice",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMessage(BaseModel):
|
||||||
|
message_id: str
|
||||||
|
thread_id: str
|
||||||
|
from_agent_id: str
|
||||||
|
to_agent_id: str
|
||||||
|
task_id: str | None = None
|
||||||
|
reply_to_message_id: str | None = None
|
||||||
|
message_type: AgentMessageType = "task_update"
|
||||||
|
content_summary: str
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
85
backend/app/agents/schemas/task.py
Normal file
85
backend/app/agents/schemas/task.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
TaskLifecycleStatus = Literal["pending", "in_progress", "completed", "failed", "blocked"]
|
||||||
|
VerificationStatus = Literal["passed", "failed", "skipped"]
|
||||||
|
TaskResultStatus = Literal["completed", "failed", "blocked", "passed", "skipped"]
|
||||||
|
InterruptStatus = Literal["requested", "acknowledged", "resolved"]
|
||||||
|
BudgetMode = Literal["direct", "collaboration"]
|
||||||
|
|
||||||
|
|
||||||
|
class InterruptRecord(BaseModel):
|
||||||
|
interrupt_id: str
|
||||||
|
reason: str
|
||||||
|
status: InterruptStatus = "requested"
|
||||||
|
requested_by: str | None = None
|
||||||
|
source_event_id: str | None = None
|
||||||
|
requested_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryRecord(BaseModel):
|
||||||
|
recovery_id: str
|
||||||
|
source_interrupt_id: str | None = None
|
||||||
|
strategy: str | None = None
|
||||||
|
resumed_from_task_id: str | None = None
|
||||||
|
resumed_from_thread_id: str | None = None
|
||||||
|
recovered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationBudget(BaseModel):
|
||||||
|
mode: BudgetMode = "direct"
|
||||||
|
max_parallel_tasks: int | None = None
|
||||||
|
remaining_parallel_tasks: int | None = None
|
||||||
|
max_tool_calls: int | None = None
|
||||||
|
remaining_tool_calls: int | None = None
|
||||||
|
max_iterations: int | None = None
|
||||||
|
remaining_iterations: int | None = None
|
||||||
|
escalation_threshold: int | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTask(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
title: str
|
||||||
|
status: TaskLifecycleStatus = "pending"
|
||||||
|
owner_agent_id: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
goal: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
child_task_ids: list[str] = Field(default_factory=list)
|
||||||
|
thread_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
message_index: int | None = None
|
||||||
|
expected_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
interrupt_records: list[InterruptRecord | dict[str, Any]] = Field(default_factory=list)
|
||||||
|
recovery_records: list[RecoveryRecord | dict[str, Any]] = Field(default_factory=list)
|
||||||
|
collaboration_budget: CollaborationBudget | dict[str, Any] | None = None
|
||||||
|
result_summary: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResult(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
status: TaskResultStatus
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
owner_agent_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
child_task_ids: list[str] = Field(default_factory=list)
|
||||||
|
thread_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
message_index: int | None = None
|
||||||
|
interrupt_records: list[InterruptRecord | dict[str, Any]] = Field(default_factory=list)
|
||||||
|
recovery_records: list[RecoveryRecord | dict[str, Any]] = Field(default_factory=list)
|
||||||
|
budget_snapshot: CollaborationBudget | dict[str, Any] | None = None
|
||||||
|
next_action: str | None = None
|
||||||
|
output_data: dict[str, Any] | None = None
|
||||||
17
backend/app/agents/session/__init__.py
Normal file
17
backend/app/agents/session/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Agent Session Management - Phase 10.3"""
|
||||||
|
|
||||||
|
from app.agents.session.manager import (
|
||||||
|
AgentSession,
|
||||||
|
SessionContext,
|
||||||
|
SessionPersistence,
|
||||||
|
create_agent_session,
|
||||||
|
get_agent_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AgentSession",
|
||||||
|
"SessionContext",
|
||||||
|
"SessionPersistence",
|
||||||
|
"create_agent_session",
|
||||||
|
"get_agent_session",
|
||||||
|
]
|
||||||
238
backend/app/agents/session/manager.py
Normal file
238
backend/app/agents/session/manager.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Agent Session 管理 - Phase 10.3
|
||||||
|
|
||||||
|
支持会话层级管理和子会话创建。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionContext:
|
||||||
|
"""会话上下文"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
parent_session_id: str | None = None
|
||||||
|
root_session_id: str | None = None
|
||||||
|
depth: int = 0
|
||||||
|
user_id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
last_active: str | None = None
|
||||||
|
message_count: int = 0
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.created_at is None:
|
||||||
|
self.created_at = datetime.now().isoformat()
|
||||||
|
if self.last_active is None:
|
||||||
|
self.last_active = self.created_at
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionPersistence:
|
||||||
|
"""会话持久化"""
|
||||||
|
|
||||||
|
def __init__(self, persistence_dir: str | None = None):
|
||||||
|
if persistence_dir is None:
|
||||||
|
persistence_dir = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "..", "data", "sessions"
|
||||||
|
)
|
||||||
|
self.persistence_dir = persistence_dir
|
||||||
|
|
||||||
|
def _get_session_path(self, session_id: str) -> str:
|
||||||
|
return os.path.join(self.persistence_dir, f"{session_id}.json")
|
||||||
|
|
||||||
|
def save(self, session: "AgentSession") -> bool:
|
||||||
|
"""保存会话"""
|
||||||
|
try:
|
||||||
|
os.makedirs(self.persistence_dir, exist_ok=True)
|
||||||
|
path = self._get_session_path(session.session_id)
|
||||||
|
data = {
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"parent_session_id": session.context.parent_session_id,
|
||||||
|
"root_session_id": session.context.root_session_id,
|
||||||
|
"depth": session.context.depth,
|
||||||
|
"user_id": session.context.user_id,
|
||||||
|
"created_at": session.context.created_at,
|
||||||
|
"last_active": session.context.last_active,
|
||||||
|
"message_count": session.context.message_count,
|
||||||
|
"metadata": session.context.metadata,
|
||||||
|
"history": session._history,
|
||||||
|
}
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def load(self, session_id: str) -> dict[str, Any] | None:
|
||||||
|
"""加载会话"""
|
||||||
|
try:
|
||||||
|
path = self._get_session_path(session_id)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return None
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(self, session_id: str) -> bool:
|
||||||
|
"""删除会话"""
|
||||||
|
try:
|
||||||
|
path = self._get_session_path(session_id)
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""列出所有会话"""
|
||||||
|
sessions = []
|
||||||
|
try:
|
||||||
|
os.makedirs(self.persistence_dir, exist_ok=True)
|
||||||
|
for filename in os.listdir(self.persistence_dir):
|
||||||
|
if filename.endswith(".json"):
|
||||||
|
path = os.path.join(self.persistence_dir, filename)
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if user_id is None or data.get("user_id") == user_id:
|
||||||
|
sessions.append(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSession:
|
||||||
|
"""Agent 会话管理器
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 会话层级(parent/root/depth)
|
||||||
|
- 子会话创建
|
||||||
|
- 会话摘要
|
||||||
|
- 持久化
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
parent_session_id: str | None = None,
|
||||||
|
):
|
||||||
|
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||||
|
self.context = SessionContext(
|
||||||
|
session_id=self.session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_session_id=parent_session_id,
|
||||||
|
depth=0 if parent_session_id is None else 1,
|
||||||
|
)
|
||||||
|
self._history: list[dict[str, Any]] = []
|
||||||
|
self._persistence = SessionPersistence()
|
||||||
|
|
||||||
|
# 如果有父会话,设置 root_session_id
|
||||||
|
if parent_session_id:
|
||||||
|
parent_data = self._persistence.load(parent_session_id)
|
||||||
|
if parent_data:
|
||||||
|
self.context.root_session_id = (
|
||||||
|
parent_data.get("root_session_id") or parent_session_id
|
||||||
|
)
|
||||||
|
self.context.depth = parent_data.get("depth", 0) + 1
|
||||||
|
|
||||||
|
async def initialize(self) -> dict[str, Any]:
|
||||||
|
"""初始化会话"""
|
||||||
|
self.context.last_active = datetime.now().isoformat()
|
||||||
|
self._persistence.save(self)
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"depth": self.context.depth,
|
||||||
|
"parent_session_id": self.context.parent_session_id,
|
||||||
|
"root_session_id": self.context.root_session_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def process_message(self, message: str, response: str) -> None:
|
||||||
|
"""处理消息并记录到历史"""
|
||||||
|
self.context.message_count += 1
|
||||||
|
self.context.last_active = datetime.now().isoformat()
|
||||||
|
self._history.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": message,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._history.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": response,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._persistence.save(self)
|
||||||
|
|
||||||
|
async def spawn_child_session(self, user_id: str | None = None) -> "AgentSession":
|
||||||
|
"""创建子会话"""
|
||||||
|
child = AgentSession(
|
||||||
|
user_id=user_id or self.context.user_id,
|
||||||
|
parent_session_id=self.session_id,
|
||||||
|
)
|
||||||
|
child.context.root_session_id = self.context.root_session_id or self.session_id
|
||||||
|
await child.initialize()
|
||||||
|
return child
|
||||||
|
|
||||||
|
async def get_session_summary(self) -> dict[str, Any]:
|
||||||
|
"""获取会话摘要"""
|
||||||
|
return {
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"parent_session_id": self.context.parent_session_id,
|
||||||
|
"root_session_id": self.context.root_session_id,
|
||||||
|
"depth": self.context.depth,
|
||||||
|
"user_id": self.context.user_id,
|
||||||
|
"created_at": self.context.created_at,
|
||||||
|
"last_active": self.context.last_active,
|
||||||
|
"message_count": self.context.message_count,
|
||||||
|
"history_length": len(self._history),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def persist(self) -> bool:
|
||||||
|
"""持久化会话"""
|
||||||
|
return self._persistence.save(self)
|
||||||
|
|
||||||
|
def get_history(self) -> list[dict[str, Any]]:
|
||||||
|
"""获取会话历史"""
|
||||||
|
return self._history.copy()
|
||||||
|
|
||||||
|
def add_metadata(self, key: str, value: Any) -> None:
|
||||||
|
"""添加会话元数据"""
|
||||||
|
self.context.metadata[key] = value
|
||||||
|
|
||||||
|
def get_metadata(self, key: str) -> Any:
|
||||||
|
"""获取会话元数据"""
|
||||||
|
return self.context.metadata.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局会话存储(内存中)
|
||||||
|
_sessions: dict[str, AgentSession] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_session(session_id: str) -> AgentSession | None:
|
||||||
|
"""获取会话"""
|
||||||
|
return _sessions.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def create_agent_session(
|
||||||
|
session_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
parent_session_id: str | None = None,
|
||||||
|
) -> AgentSession:
|
||||||
|
"""创建新会话"""
|
||||||
|
session = AgentSession(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_session_id=parent_session_id,
|
||||||
|
)
|
||||||
|
_sessions[session.session_id] = session
|
||||||
|
return session
|
||||||
16
backend/app/agents/skills/__init__.py
Normal file
16
backend/app/agents/skills/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Skills 注册表 - Phase 9"""
|
||||||
|
|
||||||
|
from app.agents.skills.registry import SkillRegistry, get_skill_registry
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
from app.agents.skills.loaders.local_loader import LocalSkillLoader
|
||||||
|
from app.agents.skills.loaders.plugin_loader import PluginSkillLoader
|
||||||
|
from app.agents.skills.mcp_builder import MCPSkillBuilder
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SkillRegistry",
|
||||||
|
"SkillMetadata",
|
||||||
|
"LocalSkillLoader",
|
||||||
|
"PluginSkillLoader",
|
||||||
|
"MCPSkillBuilder",
|
||||||
|
"get_skill_registry",
|
||||||
|
]
|
||||||
72
backend/app/agents/skills/bundled.py
Normal file
72
backend/app/agents/skills/bundled.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Built-in Skills - Phase 9.4
|
||||||
|
|
||||||
|
This module contains bundled skills that are always available
|
||||||
|
without requiring external skill loaders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# SkillMetadata-compatible structure for bundled skills
|
||||||
|
BUNDLED_SKILLS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "code-analysis",
|
||||||
|
"name": "Code Analysis",
|
||||||
|
"description": "Analyze code structure, patterns, and quality. Helps understand codebase architecture, find issues, and suggest improvements.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"prompts": [
|
||||||
|
"Analyze the code structure and identify key components, their relationships, and responsibilities.",
|
||||||
|
"Review the code for potential issues like bugs, security vulnerabilities, or performance problems.",
|
||||||
|
"Explain how the code works and what it does in simple terms.",
|
||||||
|
],
|
||||||
|
"tools": ["grep", "read", "glob", "lsp_symbols", "lsp_find_references"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git-helper",
|
||||||
|
"name": "Git Helper",
|
||||||
|
"description": "Assists with Git operations including commit, branch management, merge conflicts, and repository exploration.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"prompts": [
|
||||||
|
"Show me the current git status and any uncommitted changes.",
|
||||||
|
"Help me create a meaningful commit message for these changes.",
|
||||||
|
"Explain the git history and branch structure of this repository.",
|
||||||
|
],
|
||||||
|
"tools": ["bash"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "web-research",
|
||||||
|
"name": "Web Research",
|
||||||
|
"description": "Search the web for information, documentation, and resources. Helps find answers and learn about technologies.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"prompts": [
|
||||||
|
"Search the web for information about {topic} and summarize the key findings.",
|
||||||
|
"Find official documentation or reliable resources about {topic}.",
|
||||||
|
"Look up the latest news or developments in {topic}.",
|
||||||
|
],
|
||||||
|
"tools": ["search_brave_web_search", "websearch_web_search_exa", "webfetch"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "file-management",
|
||||||
|
"name": "File Management",
|
||||||
|
"description": "Helps with file operations like creating, editing, organizing, and managing project files and directories.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"prompts": [
|
||||||
|
"Create a new file at {path} with the following content: {content}",
|
||||||
|
"Organize the files in the project structure and suggest improvements.",
|
||||||
|
"Find all files related to {topic} or matching {pattern}.",
|
||||||
|
],
|
||||||
|
"tools": ["read", "write", "glob", "bash"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-planning",
|
||||||
|
"name": "Task Planning",
|
||||||
|
"description": "Helps break down complex tasks into smaller steps, create implementation plans, and track progress.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"prompts": [
|
||||||
|
"Break down this task into smaller, manageable steps: {task}",
|
||||||
|
"Create an implementation plan for building {feature} with clear phases.",
|
||||||
|
"Review the current progress and suggest next steps for completing {goal}.",
|
||||||
|
],
|
||||||
|
"tools": ["todowrite", "read", "write"],
|
||||||
|
},
|
||||||
|
]
|
||||||
12
backend/app/agents/skills/loaders/__init__.py
Normal file
12
backend/app/agents/skills/loaders/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""Skills 加载器包"""
|
||||||
|
|
||||||
|
from app.agents.skills.loaders.local_loader import LocalSkillLoader
|
||||||
|
from app.agents.skills.loaders.plugin_loader import PluginSkillLoader
|
||||||
|
from app.agents.skills.loaders.mcp_loader import MCPSkillLoader, get_mcp_skill_loader
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LocalSkillLoader",
|
||||||
|
"PluginSkillLoader",
|
||||||
|
"MCPSkillLoader",
|
||||||
|
"get_mcp_skill_loader",
|
||||||
|
]
|
||||||
100
backend/app/agents/skills/loaders/local_loader.py
Normal file
100
backend/app/agents/skills/loaders/local_loader.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""本地 Skills 加载器 - Phase 9.2"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class LocalSkillLoader:
|
||||||
|
"""本地 Skills 加载器
|
||||||
|
|
||||||
|
从 skills_dir 目录加载 SKILL.md 文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, skills_dir: str):
|
||||||
|
self.skills_dir = skills_dir
|
||||||
|
|
||||||
|
def load_all(self) -> list[SkillMetadata]:
|
||||||
|
"""加载所有本地 Skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 元数据列表
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
if not os.path.exists(self.skills_dir):
|
||||||
|
return skills
|
||||||
|
|
||||||
|
for root, dirs, files in os.walk(self.skills_dir):
|
||||||
|
# 跳过隐藏目录
|
||||||
|
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||||
|
|
||||||
|
if "SKILL.md" in files:
|
||||||
|
skill = self._load_skill_from_dir(root)
|
||||||
|
if skill:
|
||||||
|
skills.append(skill)
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _load_skill_from_dir(self, skill_dir: str) -> SkillMetadata | None:
|
||||||
|
"""从目录加载 Skill
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_dir: Skill 目录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 元数据
|
||||||
|
"""
|
||||||
|
skill_path = os.path.join(skill_dir, "SKILL.md")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(skill_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# 解析 frontmatter
|
||||||
|
metadata = self._parse_frontmatter(content)
|
||||||
|
|
||||||
|
# 获取 Skill 名称(目录名)
|
||||||
|
name = os.path.basename(skill_dir)
|
||||||
|
|
||||||
|
return SkillMetadata(
|
||||||
|
name=metadata.get("name", name),
|
||||||
|
description=metadata.get("description", ""),
|
||||||
|
version=metadata.get("version", "1.0.0"),
|
||||||
|
author=metadata.get("author", ""),
|
||||||
|
tags=metadata.get("tags", []),
|
||||||
|
triggers=metadata.get("triggers", []),
|
||||||
|
content=content,
|
||||||
|
source="local",
|
||||||
|
source_id=skill_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_frontmatter(self, content: str) -> dict[str, Any]:
|
||||||
|
"""解析 frontmatter"""
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# 匹配 --- 包裹的 frontmatter
|
||||||
|
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
frontmatter = match.group(1)
|
||||||
|
|
||||||
|
for line in frontmatter.split("\n"):
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
# 处理列表
|
||||||
|
if value.startswith("[") and value.endswith("]"):
|
||||||
|
value = [v.strip().strip('"').strip("'") for v in value[1:-1].split(",")]
|
||||||
|
elif value.lower() in ("true", "false"):
|
||||||
|
value = value.lower() == "true"
|
||||||
|
|
||||||
|
metadata[key] = value
|
||||||
|
|
||||||
|
return metadata
|
||||||
169
backend/app/agents/skills/loaders/mcp_loader.py
Normal file
169
backend/app/agents/skills/loaders/mcp_loader.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""MCP Skill 加载器 - Phase 9.2
|
||||||
|
|
||||||
|
从 MCP (Model Context Protocol) 服务器发现和加载 Skills。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class MCPSkillLoader:
|
||||||
|
"""MCP Skill 加载器
|
||||||
|
|
||||||
|
从 MCP 服务器发现可用的 Skills。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mcp_servers: list[dict[str, Any]] | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
mcp_servers: MCP 服务器列表,每项包含 name, command, env 等
|
||||||
|
"""
|
||||||
|
self.mcp_servers = mcp_servers or []
|
||||||
|
self._discovered_skills: dict[str, SkillMetadata] = {}
|
||||||
|
|
||||||
|
def discover_skills(self) -> list[SkillMetadata]:
|
||||||
|
"""从所有配置的 MCP 服务器发现 Skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
发现的 Skill 列表
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
for server in self.mcp_servers:
|
||||||
|
server_skills = self._discover_from_server(server)
|
||||||
|
skills.extend(server_skills)
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _discover_from_server(self, server: dict[str, Any]) -> list[SkillMetadata]:
|
||||||
|
"""从单个 MCP 服务器发现 Skills
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server: 服务器配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 列表
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
server_name = server.get("name", "unknown")
|
||||||
|
|
||||||
|
# 模拟从 MCP 服务器获取工具列表
|
||||||
|
# 实际实现时,这里会调用 MCP 服务器的 list_tools 接口
|
||||||
|
try:
|
||||||
|
tools = self._call_mcp_list_tools(server)
|
||||||
|
for tool in tools:
|
||||||
|
skill = self._tool_to_skill(tool, server_name)
|
||||||
|
if skill:
|
||||||
|
skills.append(skill)
|
||||||
|
self._discovered_skills[skill.name] = skill
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _call_mcp_list_tools(self, server: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""调用 MCP 服务器的 list_tools 接口
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server: 服务器配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具列表
|
||||||
|
"""
|
||||||
|
# TODO: 实现实际的 MCP 协议调用
|
||||||
|
# 目前返回空列表,实际使用时需要实现 MCP 客户端
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _tool_to_skill(self, tool: dict[str, Any], server: str) -> SkillMetadata | None:
|
||||||
|
"""将 MCP 工具转换为 Skill
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: MCP 工具定义
|
||||||
|
server: 服务器名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 元数据或 None
|
||||||
|
"""
|
||||||
|
tool_name = tool.get("name")
|
||||||
|
if not tool_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return SkillMetadata(
|
||||||
|
id=f"mcp_{server}_{tool_name}",
|
||||||
|
name=f"{server}:{tool_name}",
|
||||||
|
description=tool.get("description", f"MCP tool: {tool_name}"),
|
||||||
|
version="1.0.0",
|
||||||
|
content=self._generate_skill_content(tool),
|
||||||
|
triggers=[f"@{server}", f"/{tool_name}"],
|
||||||
|
tools=[tool_name],
|
||||||
|
tags=["mcp", server],
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_skill_content(self, tool: dict[str, Any]) -> str:
|
||||||
|
"""生成 Skill 内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool: MCP 工具定义
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 内容字符串
|
||||||
|
"""
|
||||||
|
name = tool.get("name", "unknown")
|
||||||
|
description = tool.get("description", "No description")
|
||||||
|
input_schema = tool.get("inputSchema", {})
|
||||||
|
|
||||||
|
content = f"""# MCP Tool: {name}
|
||||||
|
|
||||||
|
**Description**: {description}
|
||||||
|
|
||||||
|
**Server**: {tool.get("server", "unknown")}
|
||||||
|
|
||||||
|
**Input Schema**:
|
||||||
|
```json
|
||||||
|
{input_schema}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
Use the `/{name}` command or `@{tool.get("server", "server")}` to invoke this tool.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
```
|
||||||
|
/{name} arg1=value1 arg2=value2
|
||||||
|
@{tool.get("server", "server")} {name} --arg1 value1
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return content
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> SkillMetadata | None:
|
||||||
|
"""获取已发现的 Skill
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Skill 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 元数据或 None
|
||||||
|
"""
|
||||||
|
return self._discovered_skills.get(name)
|
||||||
|
|
||||||
|
def list_skills(self) -> list[SkillMetadata]:
|
||||||
|
"""列出所有已发现的 Skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 列表
|
||||||
|
"""
|
||||||
|
return list(self._discovered_skills.values())
|
||||||
|
|
||||||
|
|
||||||
|
# 全局加载器
|
||||||
|
_loader: MCPSkillLoader | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mcp_skill_loader() -> MCPSkillLoader:
|
||||||
|
"""获取全局 MCP Skill 加载器"""
|
||||||
|
global _loader
|
||||||
|
if _loader is None:
|
||||||
|
_loader = MCPSkillLoader()
|
||||||
|
return _loader
|
||||||
53
backend/app/agents/skills/loaders/plugin_loader.py
Normal file
53
backend/app/agents/skills/loaders/plugin_loader.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""插件 Skills 加载器 - Phase 9.2"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
from app.agents.plugins.manager import get_plugin_manager
|
||||||
|
|
||||||
|
|
||||||
|
class PluginSkillLoader:
|
||||||
|
"""插件 Skills 加载器
|
||||||
|
|
||||||
|
从已安装的插件中加载 Skills。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.plugin_manager = get_plugin_manager()
|
||||||
|
|
||||||
|
def load_all(self) -> list[SkillMetadata]:
|
||||||
|
"""从所有已启用的插件加载 Skills
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 元数据列表
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
for plugin in self.plugin_manager.list_plugins():
|
||||||
|
if not self.plugin_manager.is_enabled(plugin.id):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 从插件加载 Skills
|
||||||
|
plugin_skills = self._load_from_plugin(plugin)
|
||||||
|
skills.extend(plugin_skills)
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _load_from_plugin(self, plugin: Any) -> list[SkillMetadata]:
|
||||||
|
"""从单个插件加载 Skills"""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
for skill_name in plugin.skills:
|
||||||
|
skill = SkillMetadata(
|
||||||
|
name=f"{plugin.id}/{skill_name}",
|
||||||
|
description=f"Skill from plugin: {plugin.name}",
|
||||||
|
version=plugin.version,
|
||||||
|
author=plugin.author,
|
||||||
|
tags=["plugin", plugin.id],
|
||||||
|
content=f"# {skill_name}\n\nFrom plugin: {plugin.name}",
|
||||||
|
source="plugin",
|
||||||
|
source_id=plugin.id,
|
||||||
|
)
|
||||||
|
skills.append(skill)
|
||||||
|
|
||||||
|
return skills
|
||||||
100
backend/app/agents/skills/mcp_builder.py
Normal file
100
backend/app/agents/skills/mcp_builder.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""MCP Skill Builder - Phase 9.3"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class MCPSkillBuilder:
|
||||||
|
"""MCP Skill Builder
|
||||||
|
|
||||||
|
从 MCP 服务器发现和构建 Skills。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._skills: dict[str, SkillMetadata] = {}
|
||||||
|
|
||||||
|
def discover_skills_from_mcp(self, mcp_servers: list[dict[str, Any]]) -> list[SkillMetadata]:
|
||||||
|
"""从 MCP 服务器发现 Skills
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcp_servers: MCP 服务器配置列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
发现的 Skill 元数据列表
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
for server in mcp_servers:
|
||||||
|
server_skills = self._discover_from_server(server)
|
||||||
|
skills.extend(server_skills)
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _discover_from_server(self, server: dict[str, Any]) -> list[SkillMetadata]:
|
||||||
|
"""从单个 MCP 服务器发现 Skills"""
|
||||||
|
skills = []
|
||||||
|
server_name = server.get("name", "unknown")
|
||||||
|
tools = server.get("tools", [])
|
||||||
|
|
||||||
|
# 按工具分组
|
||||||
|
tool_groups: dict[str, list[str]] = {}
|
||||||
|
for tool in tools:
|
||||||
|
group = tool.get("group", "default")
|
||||||
|
if group not in tool_groups:
|
||||||
|
tool_groups[group] = []
|
||||||
|
tool_groups[group].append(tool)
|
||||||
|
|
||||||
|
# 为每个组创建一个 Skill
|
||||||
|
for group_name, group_tools in tool_groups.items():
|
||||||
|
skill = self._tool_to_skill(group_name, group_tools, server_name)
|
||||||
|
skills.append(skill)
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _tool_to_skill(self, group: str, tools: list[dict[str, Any]], server: str) -> SkillMetadata:
|
||||||
|
"""将 MCP 工具转换为 Skill"""
|
||||||
|
tool_summaries = []
|
||||||
|
for tool in tools:
|
||||||
|
name = tool.get("name", "unknown")
|
||||||
|
description = tool.get("description", "")
|
||||||
|
input_schema = tool.get("inputSchema", {})
|
||||||
|
|
||||||
|
tool_summaries.append(f"### {name}\n{description}\n\nInput: {input_schema}")
|
||||||
|
|
||||||
|
content = f"""# MCP Skill: {group}
|
||||||
|
|
||||||
|
来自 MCP 服务器: {server}
|
||||||
|
|
||||||
|
## 工具列表
|
||||||
|
|
||||||
|
{chr(10).join(tool_summaries)}
|
||||||
|
|
||||||
|
## 使用说明
|
||||||
|
|
||||||
|
使用这些工具前请确保理解每个工具的输入输出格式。
|
||||||
|
"""
|
||||||
|
|
||||||
|
return SkillMetadata(
|
||||||
|
name=f"mcp-{server}-{group}",
|
||||||
|
description=f"MCP skill from {server}: {group}",
|
||||||
|
version="1.0.0",
|
||||||
|
tags=["mcp", server, group],
|
||||||
|
triggers=[group, server],
|
||||||
|
content=content,
|
||||||
|
source="mcp",
|
||||||
|
source_id=f"{server}:{group}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _group_to_skill(self, group: str, tools: list[str], server: str) -> SkillMetadata:
|
||||||
|
"""将 MCP 工具组转换为 Skill"""
|
||||||
|
return SkillMetadata(
|
||||||
|
name=f"mcp-{server}-{group}",
|
||||||
|
description=f"MCP skill from {server}: {group}",
|
||||||
|
version="1.0.0",
|
||||||
|
tags=["mcp", server, group],
|
||||||
|
triggers=[group, server],
|
||||||
|
content=f"# {group}\n\nTools: {', '.join(tools)}",
|
||||||
|
source="mcp",
|
||||||
|
source_id=f"{server}:{group}",
|
||||||
|
)
|
||||||
42
backend/app/agents/skills/metadata.py
Normal file
42
backend/app/agents/skills/metadata.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Skill 元数据定义 - Phase 9.1"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkillMetadata:
|
||||||
|
"""Skill 元数据"""
|
||||||
|
|
||||||
|
id: str = "" # Skill ID
|
||||||
|
name: str = "" # Skill 名称
|
||||||
|
description: str = "" # 描述
|
||||||
|
version: str = "1.0.0" # 版本
|
||||||
|
author: str = "" # 作者
|
||||||
|
tags: list[str] = field(default_factory=list) # 标签
|
||||||
|
triggers: list[str] = field(default_factory=list) # 触发关键词
|
||||||
|
content: str = "" # Skill 内容(markdown)
|
||||||
|
source: str = "local" # 来源:local, plugin, mcp, bundled
|
||||||
|
source_id: str = "" # 来源 ID
|
||||||
|
enabled: bool = True # 是否启用
|
||||||
|
tools: list[str] = field(default_factory=list) # 关联的工具
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"version": self.version,
|
||||||
|
"author": self.author,
|
||||||
|
"tags": self.tags,
|
||||||
|
"triggers": self.triggers,
|
||||||
|
"content": self.content,
|
||||||
|
"source": self.source,
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"tools": self.tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "SkillMetadata":
|
||||||
|
return cls(**data)
|
||||||
133
backend/app/agents/skills/registry.py
Normal file
133
backend/app/agents/skills/registry.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Skills 注册表 - Phase 9.1"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
from app.agents.skills.loaders.local_loader import LocalSkillLoader
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRegistry:
|
||||||
|
"""Skills 注册表
|
||||||
|
|
||||||
|
管理所有 Skills 的注册、发现和加载。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._skills: dict[str, SkillMetadata] = {}
|
||||||
|
self._loaders: list[Any] = []
|
||||||
|
|
||||||
|
def load_all(self, skills_dir: str | None = None) -> int:
|
||||||
|
"""加载所有 Skills
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skills_dir: Skills 目录,None 则使用默认目录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
加载的 Skill 数量
|
||||||
|
"""
|
||||||
|
if skills_dir is None:
|
||||||
|
skills_dir = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "..", ".claude", "skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
# 本地加载器
|
||||||
|
local_loader = LocalSkillLoader(skills_dir)
|
||||||
|
local_skills = local_loader.load_all()
|
||||||
|
for skill in local_skills:
|
||||||
|
self.register(skill)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# 插件加载器
|
||||||
|
for loader in self._loaders:
|
||||||
|
try:
|
||||||
|
external_skills = loader.load_all()
|
||||||
|
for skill in external_skills:
|
||||||
|
self.register(skill)
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def register(self, skill: SkillMetadata) -> None:
|
||||||
|
"""注册 Skill"""
|
||||||
|
self._skills[skill.name] = skill
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> bool:
|
||||||
|
"""注销 Skill"""
|
||||||
|
if name in self._skills:
|
||||||
|
del self._skills[name]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_skill(self, name: str) -> SkillMetadata | None:
|
||||||
|
"""获取 Skill"""
|
||||||
|
return self._skills.get(name)
|
||||||
|
|
||||||
|
def search(self, query: str) -> list[SkillMetadata]:
|
||||||
|
"""搜索 Skills
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: 搜索关键词
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
匹配的 Skills 列表
|
||||||
|
"""
|
||||||
|
query_lower = query.lower()
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for skill in self._skills.values():
|
||||||
|
if not skill.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 匹配名称、描述、标签
|
||||||
|
if (
|
||||||
|
query_lower in skill.name.lower()
|
||||||
|
or query_lower in skill.description.lower()
|
||||||
|
or any(query_lower in tag.lower() for tag in skill.tags)
|
||||||
|
or any(query_lower in trigger.lower() for trigger in skill.triggers)
|
||||||
|
):
|
||||||
|
results.append(skill)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_skill_context(self, names: list[str]) -> str:
|
||||||
|
"""获取 Skill 上下文
|
||||||
|
|
||||||
|
Args:
|
||||||
|
names: Skill 名称列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
拼接的 Skill 内容
|
||||||
|
"""
|
||||||
|
contexts = []
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
skill = self._skills.get(name)
|
||||||
|
if skill and skill.enabled:
|
||||||
|
contexts.append(f"# {skill.name}\n\n{skill.content}")
|
||||||
|
|
||||||
|
return "\n\n---\n\n".join(contexts)
|
||||||
|
|
||||||
|
def add_loader(self, loader: Any) -> None:
|
||||||
|
"""添加加载器"""
|
||||||
|
self._loaders.append(loader)
|
||||||
|
|
||||||
|
def list_all(self) -> list[SkillMetadata]:
|
||||||
|
"""列出所有 Skills"""
|
||||||
|
return list(self._skills.values())
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_registry: SkillRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_registry() -> SkillRegistry:
|
||||||
|
"""获取全局 Skills 注册表"""
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = SkillRegistry()
|
||||||
|
return _registry
|
||||||
140
backend/app/agents/skills/trigger.py
Normal file
140
backend/app/agents/skills/trigger.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Skill 触发检测器 - Phase 9.5
|
||||||
|
|
||||||
|
检测消息中的 Skill 触发条件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.skills.metadata import SkillMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class SkillTriggerDetector:
|
||||||
|
"""Skill 触发检测器
|
||||||
|
|
||||||
|
检测用户消息中是否触发了某个 Skill。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._skills: dict[str, SkillMetadata] = {}
|
||||||
|
|
||||||
|
def register_skill(self, skill: SkillMetadata) -> None:
|
||||||
|
"""注册 Skill
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill: Skill 元数据
|
||||||
|
"""
|
||||||
|
self._skills[skill.name] = skill
|
||||||
|
|
||||||
|
def unregister_skill(self, name: str) -> bool:
|
||||||
|
"""注销 Skill
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Skill 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
if name in self._skills:
|
||||||
|
del self._skills[name]
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def detect_triggered_skills(self, message: str) -> list[str]:
|
||||||
|
"""检测触发的 Skills
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 用户消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
触发的 Skill 名称列表
|
||||||
|
"""
|
||||||
|
triggered = []
|
||||||
|
message_lower = message.lower()
|
||||||
|
|
||||||
|
for skill in self._skills.values():
|
||||||
|
if not skill.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._matches_triggers(message, message_lower, skill):
|
||||||
|
triggered.append(skill.name)
|
||||||
|
|
||||||
|
return triggered
|
||||||
|
|
||||||
|
def _matches_triggers(self, message: str, message_lower: str, skill: SkillMetadata) -> bool:
|
||||||
|
"""检查消息是否匹配 Skill 触发条件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 原始消息
|
||||||
|
message_lower: 小写消息
|
||||||
|
skill: Skill 元数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否匹配
|
||||||
|
"""
|
||||||
|
for trigger in skill.triggers:
|
||||||
|
trigger_lower = trigger.lower()
|
||||||
|
|
||||||
|
# 前缀匹配,如 "/code" 或 "@git"
|
||||||
|
if trigger_lower.startswith("/") or trigger_lower.startswith("@"):
|
||||||
|
if message_lower.startswith(trigger_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 命令格式,如 "//analyze"
|
||||||
|
if trigger_lower.startswith("//"):
|
||||||
|
pattern = trigger_lower[2:]
|
||||||
|
if re.search(rf"\b{re.escape(pattern)}\b", message_lower):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 关键词匹配
|
||||||
|
if trigger_lower in message_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_skill_prompt(self, skill_name: str) -> str | None:
|
||||||
|
"""获取 Skill 的提示词
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_name: Skill 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Skill 内容或 None
|
||||||
|
"""
|
||||||
|
skill = self._skills.get(skill_name)
|
||||||
|
if skill:
|
||||||
|
return skill.content
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_triggered_skill_context(self, message: str) -> str:
|
||||||
|
"""获取触发的 Skills 上下文
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 用户消息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
拼接的 Skill 上下文
|
||||||
|
"""
|
||||||
|
triggered = self.detect_triggered_skills(message)
|
||||||
|
if not triggered:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
contexts = []
|
||||||
|
for skill_name in triggered:
|
||||||
|
skill = self._skills.get(skill_name)
|
||||||
|
if skill:
|
||||||
|
contexts.append(f"# {skill.name}\n\n{skill.content}")
|
||||||
|
|
||||||
|
return "\n\n---\n\n".join(contexts)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局检测器
|
||||||
|
_detector: SkillTriggerDetector | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_skill_trigger_detector() -> SkillTriggerDetector:
|
||||||
|
"""获取全局 Skill 触发检测器"""
|
||||||
|
global _detector
|
||||||
|
if _detector is None:
|
||||||
|
_detector = SkillTriggerDetector()
|
||||||
|
return _detector
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Any, TypedDict
|
from typing import Annotated, Any, Literal, TypedDict
|
||||||
|
|
||||||
from langchain_core.messages import BaseMessage
|
from app.agents.schemas.event import AgentEvent
|
||||||
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult, VerificationStatus
|
||||||
|
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||||
from langgraph.graph.message import add_messages
|
from langgraph.graph.message import add_messages
|
||||||
|
|
||||||
|
AgentPhase = Literal[
|
||||||
|
"phase_0_bootstrap",
|
||||||
|
"phase_1_routing",
|
||||||
|
"phase_2_controlled_collaboration",
|
||||||
|
"phase_3_dynamic_collaboration",
|
||||||
|
"phase_4_visibility_and_verification",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AgentRole(str, Enum):
|
class AgentRole(str, Enum):
|
||||||
MASTER = "master"
|
MASTER = "master"
|
||||||
@@ -22,11 +33,27 @@ class ConversationTurn:
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def turn_to_message(turn: ConversationTurn) -> BaseMessage:
|
||||||
|
if turn.role == "user":
|
||||||
|
return HumanMessage(content=turn.content)
|
||||||
|
return AIMessage(content=turn.content)
|
||||||
|
|
||||||
|
|
||||||
class AgentState(TypedDict):
|
class AgentState(TypedDict):
|
||||||
messages: Annotated[list[BaseMessage], add_messages]
|
messages: Annotated[list[BaseMessage], add_messages]
|
||||||
user_id: str
|
user_id: str
|
||||||
conversation_id: str
|
conversation_id: str
|
||||||
|
parent_conversation_id: str | None
|
||||||
|
thread_id: str | None
|
||||||
|
last_message_id: str | None
|
||||||
|
message_sequence: int
|
||||||
|
agent_id: str | None
|
||||||
|
parent_agent_id: str | None
|
||||||
|
root_agent_id: str | None
|
||||||
|
collaboration_depth: int
|
||||||
|
spawned_agent_ids: list[str]
|
||||||
|
|
||||||
|
execution_mode: Literal["direct", "collaboration", "delegated", "verified"]
|
||||||
current_agent: str | None
|
current_agent: str | None
|
||||||
next_step: str | None
|
next_step: str | None
|
||||||
active_agents: list[AgentRole]
|
active_agents: list[AgentRole]
|
||||||
@@ -34,14 +61,45 @@ class AgentState(TypedDict):
|
|||||||
active_sub_commanders: list[str]
|
active_sub_commanders: list[str]
|
||||||
sub_commander_trace: list[dict[str, Any]]
|
sub_commander_trace: list[dict[str, Any]]
|
||||||
agent_trace: list[str]
|
agent_trace: list[str]
|
||||||
|
event_trace: list[AgentEvent | dict[str, Any]]
|
||||||
|
message_trace: list[AgentMessage | dict[str, Any]]
|
||||||
|
|
||||||
pending_tasks: list[dict[str, Any]]
|
pending_tasks: list[dict[str, Any]]
|
||||||
completed_tasks: list[dict[str, Any]]
|
completed_tasks: list[dict[str, Any]]
|
||||||
|
active_tasks: list[AgentTask | dict[str, Any]]
|
||||||
|
task_results: list[TaskResult | dict[str, Any]]
|
||||||
|
task_hierarchy: dict[str, list[str]]
|
||||||
|
interrupted_tasks: list[InterruptRecord | dict[str, Any]]
|
||||||
|
recovery_trace: list[RecoveryRecord | dict[str, Any]]
|
||||||
|
recovery_points: list[dict[str, Any]]
|
||||||
tool_calls: list[dict[str, Any]]
|
tool_calls: list[dict[str, Any]]
|
||||||
last_tool_result: str | None
|
last_tool_result: str | None
|
||||||
action_results: list[dict[str, Any]]
|
action_results: list[dict[str, Any]]
|
||||||
created_entities: list[dict[str, Any]]
|
created_entities: list[dict[str, Any]]
|
||||||
tool_outcomes: list[dict[str, Any]]
|
tool_outcomes: list[dict[str, Any]]
|
||||||
|
task_result_summary: dict[str, Any] | None
|
||||||
|
verifier_hints: dict[str, Any] | None
|
||||||
|
|
||||||
|
verification_status: VerificationStatus | None
|
||||||
|
verification_summary: str | None
|
||||||
|
verification_evidence: list[dict[str, Any]]
|
||||||
|
isolation_mode: str
|
||||||
|
isolation_id: str | None
|
||||||
|
isolation_workspace_path: str | None
|
||||||
|
isolation_parent_conversation_id: str | None
|
||||||
|
isolation_metadata: dict[str, Any]
|
||||||
|
input_tokens: int
|
||||||
|
output_tokens: int
|
||||||
|
estimated_cost: float | None
|
||||||
|
budget_warning: bool
|
||||||
|
cost_by_agent: dict[str, dict[str, Any]]
|
||||||
|
cost_thresholds: dict[str, Any]
|
||||||
|
budget_state: CollaborationBudget | dict[str, Any] | None
|
||||||
|
collaboration_budget_history: list[CollaborationBudget | dict[str, Any]]
|
||||||
|
current_phase: AgentPhase
|
||||||
|
phase_history: list[dict[str, Any]]
|
||||||
|
current_checkpoint: str | None
|
||||||
|
checkpoint_history: list[dict[str, Any]]
|
||||||
|
|
||||||
tool_strategy_used: str | None
|
tool_strategy_used: str | None
|
||||||
tool_round_count: int
|
tool_round_count: int
|
||||||
@@ -89,6 +147,16 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
|||||||
messages=[],
|
messages=[],
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
|
parent_conversation_id=None,
|
||||||
|
thread_id=None,
|
||||||
|
last_message_id=None,
|
||||||
|
message_sequence=0,
|
||||||
|
agent_id=AgentRole.MASTER.value,
|
||||||
|
parent_agent_id=None,
|
||||||
|
root_agent_id=AgentRole.MASTER.value,
|
||||||
|
collaboration_depth=0,
|
||||||
|
spawned_agent_ids=[],
|
||||||
|
execution_mode="direct",
|
||||||
current_agent=AgentRole.MASTER.value,
|
current_agent=AgentRole.MASTER.value,
|
||||||
next_step=None,
|
next_step=None,
|
||||||
active_agents=[AgentRole.MASTER],
|
active_agents=[AgentRole.MASTER],
|
||||||
@@ -96,13 +164,54 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
|||||||
active_sub_commanders=[],
|
active_sub_commanders=[],
|
||||||
sub_commander_trace=[],
|
sub_commander_trace=[],
|
||||||
agent_trace=[AgentRole.MASTER.value],
|
agent_trace=[AgentRole.MASTER.value],
|
||||||
|
event_trace=[],
|
||||||
|
message_trace=[],
|
||||||
pending_tasks=[],
|
pending_tasks=[],
|
||||||
completed_tasks=[],
|
completed_tasks=[],
|
||||||
|
active_tasks=[],
|
||||||
|
task_results=[],
|
||||||
|
task_hierarchy={},
|
||||||
|
interrupted_tasks=[],
|
||||||
|
recovery_trace=[],
|
||||||
|
recovery_points=[],
|
||||||
tool_calls=[],
|
tool_calls=[],
|
||||||
last_tool_result=None,
|
last_tool_result=None,
|
||||||
action_results=[],
|
action_results=[],
|
||||||
created_entities=[],
|
created_entities=[],
|
||||||
tool_outcomes=[],
|
tool_outcomes=[],
|
||||||
|
task_result_summary=None,
|
||||||
|
verifier_hints=None,
|
||||||
|
verification_status=None,
|
||||||
|
verification_summary=None,
|
||||||
|
verification_evidence=[],
|
||||||
|
isolation_mode="none",
|
||||||
|
isolation_id=None,
|
||||||
|
isolation_workspace_path=None,
|
||||||
|
isolation_parent_conversation_id=None,
|
||||||
|
isolation_metadata={},
|
||||||
|
input_tokens=0,
|
||||||
|
output_tokens=0,
|
||||||
|
estimated_cost=None,
|
||||||
|
budget_warning=False,
|
||||||
|
cost_by_agent={},
|
||||||
|
cost_thresholds={},
|
||||||
|
budget_state=None,
|
||||||
|
collaboration_budget_history=[],
|
||||||
|
current_phase="phase_0_bootstrap",
|
||||||
|
phase_history=[
|
||||||
|
{
|
||||||
|
"phase": "phase_0_bootstrap",
|
||||||
|
"reason": "initial_state_created",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
current_checkpoint="bootstrap.initialized",
|
||||||
|
checkpoint_history=[
|
||||||
|
{
|
||||||
|
"checkpoint": "bootstrap.initialized",
|
||||||
|
"phase": "phase_0_bootstrap",
|
||||||
|
"reason": "initial_state_created",
|
||||||
|
}
|
||||||
|
],
|
||||||
tool_strategy_used=None,
|
tool_strategy_used=None,
|
||||||
tool_round_count=0,
|
tool_round_count=0,
|
||||||
max_tool_rounds=2,
|
max_tool_rounds=2,
|
||||||
|
|||||||
13
backend/app/agents/team/__init__.py
Normal file
13
backend/app/agents/team/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Team 多 Agent 协作"""
|
||||||
|
|
||||||
|
from app.agents.team.leader import TeamLeader, TeamTask, TaskStatus
|
||||||
|
from app.agents.team.member import TeamMember, MemberStatus, MemberTask
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TeamLeader",
|
||||||
|
"TeamTask",
|
||||||
|
"TaskStatus",
|
||||||
|
"TeamMember",
|
||||||
|
"MemberStatus",
|
||||||
|
"MemberTask",
|
||||||
|
]
|
||||||
121
backend/app/agents/team/leader.py
Normal file
121
backend/app/agents/team/leader.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Team 多 Agent 协作 - Phase 10.1"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TaskStatus(Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TeamTask:
|
||||||
|
"""团队任务"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
description: str
|
||||||
|
assignee: str | None = None
|
||||||
|
status: TaskStatus = TaskStatus.PENDING
|
||||||
|
result: Any = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamLeader:
|
||||||
|
"""团队领导者
|
||||||
|
|
||||||
|
协调多个 Agent 成员执行任务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, team_id: str, members: list[str]):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
team_id: 团队 ID
|
||||||
|
members: 成员 ID 列表
|
||||||
|
"""
|
||||||
|
self.team_id = team_id
|
||||||
|
self.members = members
|
||||||
|
self._tasks: dict[str, TeamTask] = {}
|
||||||
|
|
||||||
|
def create_task(self, description: str) -> str:
|
||||||
|
"""创建任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: 任务描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务 ID
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
task_id = str(uuid.uuid4())[:8]
|
||||||
|
self._tasks[task_id] = TeamTask(
|
||||||
|
id=task_id,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
def assign_task(self, task_id: str, member: str) -> bool:
|
||||||
|
"""分配任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
member: 成员 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功
|
||||||
|
"""
|
||||||
|
if task_id not in self._tasks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if member not in self.members:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._tasks[task_id].assignee = member
|
||||||
|
self._tasks[task_id].status = TaskStatus.IN_PROGRESS
|
||||||
|
return True
|
||||||
|
|
||||||
|
def broadcast_task(self, description: str) -> list[str]:
|
||||||
|
"""广播任务给所有成员
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: 任务描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的任务 ID 列表
|
||||||
|
"""
|
||||||
|
task_ids = []
|
||||||
|
for member in self.members:
|
||||||
|
task_id = self.create_task(description)
|
||||||
|
self.assign_task(task_id, member)
|
||||||
|
task_ids.append(task_id)
|
||||||
|
return task_ids
|
||||||
|
|
||||||
|
def collect_results(self) -> dict[str, Any]:
|
||||||
|
"""收集所有任务结果
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务 ID -> 结果的映射
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
task_id: task.result
|
||||||
|
for task_id, task in self._tasks.items()
|
||||||
|
if task.status == TaskStatus.COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_team_status(self) -> dict[str, Any]:
|
||||||
|
"""获取团队状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
团队状态摘要
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"team_id": self.team_id,
|
||||||
|
"members": self.members,
|
||||||
|
"task_count": len(self._tasks),
|
||||||
|
"completed": sum(1 for t in self._tasks.values() if t.status == TaskStatus.COMPLETED),
|
||||||
|
"failed": sum(1 for t in self._tasks.values() if t.status == TaskStatus.FAILED),
|
||||||
|
}
|
||||||
166
backend/app/agents/team/member.py
Normal file
166
backend/app/agents/team/member.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""TeamMember 实现 - Phase 10.1
|
||||||
|
|
||||||
|
团队成员实现,负责执行分配的任务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class MemberStatus(Enum):
|
||||||
|
"""成员状态"""
|
||||||
|
|
||||||
|
IDLE = "idle"
|
||||||
|
BUSY = "busy"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemberTask:
|
||||||
|
"""成员任务"""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
description: str
|
||||||
|
status: str = "pending" # pending, in_progress, completed, failed
|
||||||
|
result: Any = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMember:
|
||||||
|
"""团队成员
|
||||||
|
|
||||||
|
代表团队中的一个 Agent 成员,负责执行分配的任务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, member_id: str, name: str, capabilities: list[str] | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
member_id: 成员 ID
|
||||||
|
name: 成员名称
|
||||||
|
capabilities: 成员能力列表
|
||||||
|
"""
|
||||||
|
self.member_id = member_id
|
||||||
|
self.name = name
|
||||||
|
self.capabilities = capabilities or []
|
||||||
|
self.status = MemberStatus.IDLE
|
||||||
|
self._tasks: dict[str, MemberTask] = {}
|
||||||
|
self._metadata: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def assign_task(self, task_id: str, description: str) -> MemberTask:
|
||||||
|
"""接收任务分配
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
description: 任务描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的任务对象
|
||||||
|
"""
|
||||||
|
task = MemberTask(task_id=task_id, description=description)
|
||||||
|
self._tasks[task_id] = task
|
||||||
|
self.status = MemberStatus.BUSY
|
||||||
|
return task
|
||||||
|
|
||||||
|
def update_task_status(
|
||||||
|
self, task_id: str, status: str, result: Any = None, error: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""更新任务状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
status: 新状态
|
||||||
|
result: 任务结果
|
||||||
|
error: 错误信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否更新成功
|
||||||
|
"""
|
||||||
|
if task_id not in self._tasks:
|
||||||
|
return False
|
||||||
|
|
||||||
|
task = self._tasks[task_id]
|
||||||
|
task.status = status
|
||||||
|
if result is not None:
|
||||||
|
task.result = result
|
||||||
|
if error is not None:
|
||||||
|
task.error = error
|
||||||
|
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
self.status = MemberStatus.IDLE
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_task(self, task_id: str) -> MemberTask | None:
|
||||||
|
"""获取任务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 任务 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
任务对象或 None
|
||||||
|
"""
|
||||||
|
return self._tasks.get(task_id)
|
||||||
|
|
||||||
|
def get_pending_tasks(self) -> list[MemberTask]:
|
||||||
|
"""获取待处理任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
待处理任务列表
|
||||||
|
"""
|
||||||
|
return [t for t in self._tasks.values() if t.status == "pending"]
|
||||||
|
|
||||||
|
def get_active_task(self) -> MemberTask | None:
|
||||||
|
"""获取当前执行中的任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
当前任务或 None
|
||||||
|
"""
|
||||||
|
for task in self._tasks.values():
|
||||||
|
if task.status == "in_progress":
|
||||||
|
return task
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_completed_tasks(self) -> list[MemberTask]:
|
||||||
|
"""获取已完成任务
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
已完成任务列表
|
||||||
|
"""
|
||||||
|
return [t for t in self._tasks.values() if t.status == "completed"]
|
||||||
|
|
||||||
|
def set_metadata(self, key: str, value: Any) -> None:
|
||||||
|
"""设置元数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 元数据键
|
||||||
|
value: 元数据值
|
||||||
|
"""
|
||||||
|
self._metadata[key] = value
|
||||||
|
|
||||||
|
def get_metadata(self, key: str) -> Any:
|
||||||
|
"""获取元数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 元数据键
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
元数据值或 None
|
||||||
|
"""
|
||||||
|
return self._metadata.get(key)
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
"""获取成员状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
状态字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"member_id": self.member_id,
|
||||||
|
"name": self.name,
|
||||||
|
"status": self.status.value,
|
||||||
|
"capabilities": self.capabilities,
|
||||||
|
"task_count": len(self._tasks),
|
||||||
|
"pending_count": len(self.get_pending_tasks()),
|
||||||
|
"active_task": self.get_active_task().__dict__ if self.get_active_task() else None,
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from app.agents.tools.search import (
|
from app.agents.tools.search import (
|
||||||
search_knowledge, get_knowledge_graph_context,
|
search_knowledge,
|
||||||
build_knowledge_graph, hybrid_search, web_search,
|
get_knowledge_graph_context,
|
||||||
|
build_knowledge_graph,
|
||||||
|
hybrid_search,
|
||||||
|
web_search,
|
||||||
)
|
)
|
||||||
from app.agents.tools.task import get_tasks, create_task, update_task_status
|
from app.agents.tools.task import get_tasks, create_task, update_task_status
|
||||||
from app.agents.tools.forum import get_forum_posts, create_forum_post, scan_forum_for_instructions
|
from app.agents.tools.forum import get_forum_posts, create_forum_post, scan_forum_for_instructions
|
||||||
@@ -13,6 +16,58 @@ from app.agents.tools.schedule import (
|
|||||||
)
|
)
|
||||||
from app.agents.tools.time_reasoning import resolve_time_expression
|
from app.agents.tools.time_reasoning import resolve_time_expression
|
||||||
|
|
||||||
|
# Phase 6.1: Tool Registry exports
|
||||||
|
from app.agents.tools.registry import (
|
||||||
|
ToolRegistry,
|
||||||
|
get_tool_registry,
|
||||||
|
reset_tool_registry,
|
||||||
|
)
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
HookConfig,
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
ToolCategory,
|
||||||
|
ToolManifest,
|
||||||
|
)
|
||||||
|
from app.agents.tools.migration import (
|
||||||
|
migrate_tool,
|
||||||
|
migrate_all_tools,
|
||||||
|
get_tool_executor,
|
||||||
|
BackwardCompatTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 6.2: Hook System exports
|
||||||
|
from app.agents.tools.hooks import (
|
||||||
|
HookManager,
|
||||||
|
HookExecutor,
|
||||||
|
HookType,
|
||||||
|
HookDefinition,
|
||||||
|
HookResult,
|
||||||
|
ExecutionContext,
|
||||||
|
get_hook_manager,
|
||||||
|
get_hook_executor,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 6.3: Streaming Executor exports
|
||||||
|
from app.agents.tools.streaming import (
|
||||||
|
StreamingToolExecutor,
|
||||||
|
get_streaming_executor,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 6.4: Builtin Tools exports
|
||||||
|
from app.agents.tools.builtins import (
|
||||||
|
GlobTool,
|
||||||
|
GrepTool,
|
||||||
|
ReadFileTool,
|
||||||
|
WriteFileTool,
|
||||||
|
BashTool,
|
||||||
|
PowerShellTool,
|
||||||
|
LSPTools,
|
||||||
|
GitTool,
|
||||||
|
TeamAgentTool,
|
||||||
|
TaskBroadcastTool,
|
||||||
|
)
|
||||||
|
|
||||||
TASK_TOOLS = [
|
TASK_TOOLS = [
|
||||||
get_tasks,
|
get_tasks,
|
||||||
create_task,
|
create_task,
|
||||||
|
|||||||
161
backend/app/agents/tools/base.py
Normal file
161
backend/app/agents/tools/base.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""工具基类 - 工具系统重构 Phase 6.1"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
ToolCategory,
|
||||||
|
ToolManifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTool(ABC, Generic[T]):
|
||||||
|
"""工具基类
|
||||||
|
|
||||||
|
提供工具的标准接口和默认实现。
|
||||||
|
所有自定义工具应继承此类。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
category: ToolCategory,
|
||||||
|
permission_class: PermissionClass,
|
||||||
|
side_effect_scope: SideEffectScope = SideEffectScope.NONE,
|
||||||
|
requires_confirmation: bool = False,
|
||||||
|
is_streaming: bool = False,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.category = category
|
||||||
|
self.permission_class = permission_class
|
||||||
|
self.side_effect_scope = side_effect_scope
|
||||||
|
self.requires_confirmation = requires_confirmation
|
||||||
|
self.is_streaming = is_streaming
|
||||||
|
self.tags = tags or []
|
||||||
|
|
||||||
|
def get_manifest(self) -> ToolManifest:
|
||||||
|
"""获取工具元数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具元数据
|
||||||
|
"""
|
||||||
|
return ToolManifest(
|
||||||
|
name=self.name,
|
||||||
|
description=self.description,
|
||||||
|
category=self.category,
|
||||||
|
parameters=self.get_parameters(),
|
||||||
|
return_schema=self.get_return_schema(),
|
||||||
|
permission_class=self.permission_class,
|
||||||
|
side_effect_scope=self.side_effect_scope,
|
||||||
|
requires_confirmation=self.requires_confirmation,
|
||||||
|
is_streaming=self.is_streaming,
|
||||||
|
tags=self.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
"""获取参数 Schema(JSON Schema 格式)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
参数 schema
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
"""获取返回值 Schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
返回值 schema
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def execute(self, **kwargs) -> T:
|
||||||
|
"""执行工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: 工具参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
执行结果
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def execute_safe(self, **kwargs) -> dict[str, Any]:
|
||||||
|
"""安全执行工具,捕获异常
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: 工具参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 success 和 result/error 的字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.execute(**kwargs)
|
||||||
|
return {"success": True, "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.__class__.__name__}(name={self.name!r})>"
|
||||||
|
|
||||||
|
|
||||||
|
class ReadTool(BaseTool):
|
||||||
|
"""只读工具基类"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault("category", ToolCategory.READ)
|
||||||
|
kwargs.setdefault("permission_class", PermissionClass.READ)
|
||||||
|
kwargs.setdefault("side_effect_scope", SideEffectScope.NONE)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class WriteTool(BaseTool):
|
||||||
|
"""写入工具基类"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault("category", ToolCategory.WRITE)
|
||||||
|
kwargs.setdefault("permission_class", PermissionClass.WRITE)
|
||||||
|
kwargs.setdefault("side_effect_scope", SideEffectScope.LOCAL_STATE)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class DBWriteTool(BaseTool):
|
||||||
|
"""数据库写入工具基类"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault("category", ToolCategory.DB_WRITE)
|
||||||
|
kwargs.setdefault("permission_class", PermissionClass.WRITE)
|
||||||
|
kwargs.setdefault("side_effect_scope", SideEffectScope.DB_WRITE)
|
||||||
|
kwargs.setdefault("requires_confirmation", True)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalTool(BaseTool):
|
||||||
|
"""外部工具基类(执行外部命令等)"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault("category", ToolCategory.EXTERNAL)
|
||||||
|
kwargs.setdefault("permission_class", PermissionClass.EXTERNAL)
|
||||||
|
kwargs.setdefault("side_effect_scope", SideEffectScope.NETWORK)
|
||||||
|
kwargs.setdefault("requires_confirmation", True)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class NetworkTool(BaseTool):
|
||||||
|
"""网络工具基类"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs.setdefault("category", ToolCategory.NETWORK)
|
||||||
|
kwargs.setdefault("permission_class", PermissionClass.EXTERNAL)
|
||||||
|
kwargs.setdefault("side_effect_scope", SideEffectScope.NETWORK)
|
||||||
|
super().__init__(**kwargs)
|
||||||
43
backend/app/agents/tools/builtins/__init__.py
Normal file
43
backend/app/agents/tools/builtins/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""内置工具集 - Phase 6.4
|
||||||
|
|
||||||
|
新的内置工具,使用 BaseTool 基类。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.agents.tools.builtins.file_tools import (
|
||||||
|
GlobTool,
|
||||||
|
GrepTool,
|
||||||
|
ReadFileTool,
|
||||||
|
WriteFileTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.agents.tools.builtins.system_tools import (
|
||||||
|
BashTool,
|
||||||
|
PowerShellTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.agents.tools.builtins.dev_tools import (
|
||||||
|
LSPTools,
|
||||||
|
GitTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.agents.tools.builtins.collaboration_tools import (
|
||||||
|
TeamAgentTool,
|
||||||
|
TaskBroadcastTool,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# File tools
|
||||||
|
"GlobTool",
|
||||||
|
"GrepTool",
|
||||||
|
"ReadFileTool",
|
||||||
|
"WriteFileTool",
|
||||||
|
# System tools
|
||||||
|
"BashTool",
|
||||||
|
"PowerShellTool",
|
||||||
|
# Dev tools
|
||||||
|
"LSPTools",
|
||||||
|
"GitTool",
|
||||||
|
# Collaboration tools
|
||||||
|
"TeamAgentTool",
|
||||||
|
"TaskBroadcastTool",
|
||||||
|
]
|
||||||
129
backend/app/agents/tools/builtins/collaboration_tools.py
Normal file
129
backend/app/agents/tools/builtins/collaboration_tools.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""协作工具 - Phase 6.4"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.base import WriteTool
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamAgentTool(WriteTool):
|
||||||
|
"""团队 Agent 通信工具
|
||||||
|
|
||||||
|
用于与其他 Agent 进行消息传递和协作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="team_agent",
|
||||||
|
description="向团队 Agent 发送消息或请求协作",
|
||||||
|
permission_class=PermissionClass.WRITE,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
tags=["collaboration", "team", "agent"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "目标 Agent 名称",
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要发送的消息",
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["send", "request", "delegate"],
|
||||||
|
"description": "操作类型",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["agent_name", "message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {"type": "boolean"},
|
||||||
|
"response": {"type": "string"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, agent_name: str, message: str, action: str = "send") -> dict[str, Any]:
|
||||||
|
# 注意:实际实现需要通过 Agent 通信协议
|
||||||
|
# 这里只是一个框架实现
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"response": f"Message '{action}' to agent '{agent_name}': {message}",
|
||||||
|
"agent_name": agent_name,
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskBroadcastTool(WriteTool):
|
||||||
|
"""任务广播工具
|
||||||
|
|
||||||
|
向多个 Agent 广播任务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="task_broadcast",
|
||||||
|
description="向多个 Agent 广播任务",
|
||||||
|
permission_class=PermissionClass.WRITE,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
tags=["collaboration", "broadcast", "task"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"agent_names": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "目标 Agent 列表",
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要广播的任务描述",
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["low", "normal", "high", "urgent"],
|
||||||
|
"description": "任务优先级",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["agent_names", "task"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {"type": "boolean"},
|
||||||
|
"broadcast_to": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"responses": {"type": "array"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
agent_names: list[str],
|
||||||
|
task: str,
|
||||||
|
priority: str = "normal",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# 注意:实际实现需要通过 Agent 通信协议
|
||||||
|
# 这里只是一个框架实现
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"broadcast_to": agent_names,
|
||||||
|
"task": task,
|
||||||
|
"priority": priority,
|
||||||
|
"responses": [f"Acknowledged by {agent}" for agent in agent_names],
|
||||||
|
}
|
||||||
155
backend/app/agents/tools/builtins/dev_tools.py
Normal file
155
backend/app/agents/tools/builtins/dev_tools.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""开发工具 - Phase 6.4"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.base import ReadTool, WriteTool
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LSPTools(ReadTool):
|
||||||
|
"""语言服务器协议工具集
|
||||||
|
|
||||||
|
提供代码导航、查找引用等 LSP 功能。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="lsp_tools",
|
||||||
|
description="LSP 代码导航和查找引用",
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
tags=["development", "lsp", "code"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["goto_definition", "find_references", "document_symbols"],
|
||||||
|
"description": "LSP 操作类型",
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件路径",
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "行号(1-based)",
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "列号(0-based)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action", "file"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {"type": "boolean"},
|
||||||
|
"results": {"type": "array"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
file: str,
|
||||||
|
line: int = 1,
|
||||||
|
character: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# 注意:实际 LSP 调用需要通过 lsp-utils 或类似库
|
||||||
|
# 这里只是一个框架实现
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"LSP action '{action}' not fully implemented - requires LSP server integration",
|
||||||
|
"action": action,
|
||||||
|
"file": file,
|
||||||
|
"position": {"line": line, "character": character},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GitTool(ReadTool):
|
||||||
|
"""Git 操作工具
|
||||||
|
|
||||||
|
提供常用的 Git 操作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, repo_path: str = "."):
|
||||||
|
super().__init__(
|
||||||
|
name="git",
|
||||||
|
description="执行 Git 命令",
|
||||||
|
permission_class=PermissionClass.EXTERNAL,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
tags=["development", "git", "version-control"],
|
||||||
|
)
|
||||||
|
self.repo_path = repo_path
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Git 子命令和参数,如 'status' 或 'log --oneline -10'",
|
||||||
|
},
|
||||||
|
"repo_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "仓库路径(可选)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"stdout": {"type": "string"},
|
||||||
|
"stderr": {"type": "string"},
|
||||||
|
"returncode": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, command: str, repo_path: str | None = None) -> dict[str, Any]:
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
repo = repo_path or self.repo_path
|
||||||
|
|
||||||
|
# 构建完整的 git 命令
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
full_command = f'git -C "{repo}" {command}'
|
||||||
|
else:
|
||||||
|
full_command = f"git -C '{repo}' {command}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_shell(
|
||||||
|
full_command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stdout": stdout.decode("utf-8", errors="replace"),
|
||||||
|
"stderr": stderr.decode("utf-8", errors="replace"),
|
||||||
|
"returncode": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": str(e),
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
255
backend/app/agents/tools/builtins/file_tools.py
Normal file
255
backend/app/agents/tools/builtins/file_tools.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""文件操作工具 - Phase 6.4"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.base import ExternalTool, ReadTool, WriteTool
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
ToolCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobTool(ReadTool):
|
||||||
|
"""文件路径匹配工具
|
||||||
|
|
||||||
|
使用 glob 模式查找文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, root_dir: str = "."):
|
||||||
|
super().__init__(
|
||||||
|
name="glob",
|
||||||
|
description="使用 glob 模式查找文件路径",
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
tags=["file", "search", "glob"],
|
||||||
|
)
|
||||||
|
self.root_dir = root_dir
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Glob 模式,如 **/*.py",
|
||||||
|
},
|
||||||
|
"root_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "搜索根目录(可选)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["pattern"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, pattern: str, root_dir: str | None = None) -> list[str]:
|
||||||
|
import glob as glob_module
|
||||||
|
|
||||||
|
root = root_dir or self.root_dir
|
||||||
|
return glob_module.glob(pattern, root_dir=root, recursive=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GrepTool(ReadTool):
|
||||||
|
"""文件内容搜索工具
|
||||||
|
|
||||||
|
在文件中搜索匹配的行。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="grep",
|
||||||
|
description="在文件中搜索匹配的文本行",
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
tags=["file", "search", "text"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "正则表达式模式",
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "要搜索的文件路径列表",
|
||||||
|
},
|
||||||
|
"case_sensitive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否区分大小写",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["pattern", "paths"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {"type": "string"},
|
||||||
|
"line": {"type": "integer"},
|
||||||
|
"content": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self, pattern: str, paths: list[str], case_sensitive: bool = True
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
flags = 0 if case_sensitive else re.IGNORECASE
|
||||||
|
regex = re.compile(pattern, flags)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line_num, line in enumerate(f, 1):
|
||||||
|
if regex.search(line):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"file": path,
|
||||||
|
"line": line_num,
|
||||||
|
"content": line.rstrip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (UnicodeDecodeError, PermissionError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class ReadFileTool(ReadTool):
|
||||||
|
"""文件读取工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="read_file",
|
||||||
|
description="读取文件内容",
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
tags=["file", "read"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件路径",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "最大行数",
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "起始行号",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"lines": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, path: str, limit: int | None = None, offset: int = 0) -> dict[str, Any]:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError(f"File not found: {path}")
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
total_lines = len(lines)
|
||||||
|
start = max(0, offset)
|
||||||
|
end = len(lines) if limit is None else min(start + limit, len(lines))
|
||||||
|
|
||||||
|
content = "".join(lines[start:end])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": content,
|
||||||
|
"lines": total_lines,
|
||||||
|
"truncated": limit is not None and end < len(lines),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WriteFileTool(WriteTool):
|
||||||
|
"""文件写入工具"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="write_file",
|
||||||
|
description="写入文件内容",
|
||||||
|
permission_class=PermissionClass.WRITE,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
tags=["file", "write"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件路径",
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件内容",
|
||||||
|
},
|
||||||
|
"append": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否追加模式",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["path", "content"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {"type": "boolean"},
|
||||||
|
"bytes_written": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, path: str, content: str, append: bool = False) -> dict[str, Any]:
|
||||||
|
mode = "a" if append else "w"
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
if directory and not os.path.exists(directory):
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
|
with open(path, mode, encoding="utf-8") as f:
|
||||||
|
bytes_written = f.write(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"bytes_written": bytes_written,
|
||||||
|
}
|
||||||
193
backend/app/agents/tools/builtins/system_tools.py
Normal file
193
backend/app/agents/tools/builtins/system_tools.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""系统工具 - Phase 6.4"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shlex
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.base import ExternalTool
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BashTool(ExternalTool):
|
||||||
|
"""Bash 命令执行工具"""
|
||||||
|
|
||||||
|
def __init__(self, working_dir: str = "."):
|
||||||
|
super().__init__(
|
||||||
|
name="bash",
|
||||||
|
description="执行 Bash 命令",
|
||||||
|
permission_class=PermissionClass.EXTERNAL,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
tags=["system", "bash", "shell"],
|
||||||
|
)
|
||||||
|
self.working_dir = working_dir
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要执行的 Bash 命令",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "超时时间(秒)",
|
||||||
|
},
|
||||||
|
"working_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "工作目录(可选)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"stdout": {"type": "string"},
|
||||||
|
"stderr": {"type": "string"},
|
||||||
|
"returncode": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self, command: str, timeout: int = 30, working_dir: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
import os
|
||||||
|
|
||||||
|
cwd = working_dir or self.working_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_shell(
|
||||||
|
command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"Command timed out after {timeout} seconds",
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stdout": stdout.decode("utf-8", errors="replace"),
|
||||||
|
"stderr": stderr.decode("utf-8", errors="replace"),
|
||||||
|
"returncode": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": str(e),
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PowerShellTool(ExternalTool):
|
||||||
|
"""PowerShell 命令执行工具"""
|
||||||
|
|
||||||
|
def __init__(self, working_dir: str = "."):
|
||||||
|
super().__init__(
|
||||||
|
name="powershell",
|
||||||
|
description="执行 PowerShell 命令",
|
||||||
|
permission_class=PermissionClass.EXTERNAL,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
tags=["system", "powershell", "shell"],
|
||||||
|
)
|
||||||
|
self.working_dir = working_dir
|
||||||
|
|
||||||
|
def get_parameters(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要执行的 PowerShell 命令",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "超时时间(秒)",
|
||||||
|
},
|
||||||
|
"working_dir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "工作目录(可选)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["command"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_return_schema(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"stdout": {"type": "string"},
|
||||||
|
"stderr": {"type": "string"},
|
||||||
|
"returncode": {"type": "integer"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self, command: str, timeout: int = 30, working_dir: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
# 检测是否是 Windows 平台
|
||||||
|
is_windows = platform.system() == "Windows"
|
||||||
|
|
||||||
|
if not is_windows:
|
||||||
|
# 非 Windows 平台,可能没有 PowerShell
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "PowerShell is not available on this platform",
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd = working_dir or self.working_dir
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
"powershell.exe",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": f"Command timed out after {timeout} seconds",
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stdout": stdout.decode("utf-8", errors="replace"),
|
||||||
|
"stderr": stderr.decode("utf-8", errors="replace"),
|
||||||
|
"returncode": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": str(e),
|
||||||
|
"returncode": -1,
|
||||||
|
}
|
||||||
46
backend/app/agents/tools/hooks/__init__.py
Normal file
46
backend/app/agents/tools/hooks/__init__.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Hook 系统 - Phase 6.2"""
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
HookDefinition,
|
||||||
|
HookResult,
|
||||||
|
HookStage,
|
||||||
|
HookTrigger,
|
||||||
|
HookType,
|
||||||
|
ExecutionContext,
|
||||||
|
HookHandler,
|
||||||
|
PreToolHook,
|
||||||
|
PostToolHook,
|
||||||
|
ErrorToolHook,
|
||||||
|
SkipToolHook,
|
||||||
|
)
|
||||||
|
from app.agents.tools.hooks.manager import (
|
||||||
|
HookManager,
|
||||||
|
get_hook_manager,
|
||||||
|
reset_hook_manager,
|
||||||
|
)
|
||||||
|
from app.agents.tools.hooks.executor import (
|
||||||
|
HookExecutor,
|
||||||
|
get_hook_executor,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Types
|
||||||
|
"HookType",
|
||||||
|
"HookStage",
|
||||||
|
"HookTrigger",
|
||||||
|
"HookDefinition",
|
||||||
|
"HookResult",
|
||||||
|
"ExecutionContext",
|
||||||
|
"HookHandler",
|
||||||
|
"PreToolHook",
|
||||||
|
"PostToolHook",
|
||||||
|
"ErrorToolHook",
|
||||||
|
"SkipToolHook",
|
||||||
|
# Manager
|
||||||
|
"HookManager",
|
||||||
|
"get_hook_manager",
|
||||||
|
"reset_hook_manager",
|
||||||
|
# Executor
|
||||||
|
"HookExecutor",
|
||||||
|
"get_hook_executor",
|
||||||
|
]
|
||||||
11
backend/app/agents/tools/hooks/builtins/__init__.py
Normal file
11
backend/app/agents/tools/hooks/builtins/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""内置 Hook 集合 - Phase 7"""
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.builtins.audit_log import AuditLogHook
|
||||||
|
from app.agents.tools.hooks.builtins.dangerous_confirmation import DangerousConfirmationHook
|
||||||
|
from app.agents.tools.hooks.builtins.security_scan import SecurityScanHook
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuditLogHook",
|
||||||
|
"DangerousConfirmationHook",
|
||||||
|
"SecurityScanHook",
|
||||||
|
]
|
||||||
115
backend/app/agents/tools/hooks/builtins/audit_log.py
Normal file
115
backend/app/agents/tools/hooks/builtins/audit_log.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""审计日志 Hook - Phase 7.2
|
||||||
|
|
||||||
|
记录所有工具调用到审计日志。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
ExecutionContext,
|
||||||
|
HookResult,
|
||||||
|
HookType,
|
||||||
|
)
|
||||||
|
from app.agents.tools.manifest import ToolCategory
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogHook:
|
||||||
|
"""审计日志 Hook
|
||||||
|
|
||||||
|
记录所有工具调用的详细信息,包括:
|
||||||
|
- 调用时间
|
||||||
|
- 工具名称
|
||||||
|
- 输入参数
|
||||||
|
- 执行结果
|
||||||
|
- 执行时长
|
||||||
|
- 用户 ID
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, log_path: str | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
log_path: 日志文件路径,None 则输出到 stdout
|
||||||
|
"""
|
||||||
|
self.log_path = log_path
|
||||||
|
self._logs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def pre_tool_use(self, context: ExecutionContext) -> HookResult:
|
||||||
|
"""工具执行前记录"""
|
||||||
|
log_entry = {
|
||||||
|
"event": "pre_tool",
|
||||||
|
"tool_name": context.tool_name,
|
||||||
|
"input": context.tool_input,
|
||||||
|
"user_id": context.user_id,
|
||||||
|
"session_id": context.session_id,
|
||||||
|
}
|
||||||
|
self._logs.append(log_entry)
|
||||||
|
self._write_log(log_entry)
|
||||||
|
return HookResult(
|
||||||
|
hook_name="audit_log",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post_tool_use(self, context: ExecutionContext, result: Any) -> HookResult:
|
||||||
|
"""工具执行后记录"""
|
||||||
|
log_entry = {
|
||||||
|
"event": "post_tool",
|
||||||
|
"tool_name": context.tool_name,
|
||||||
|
"result": str(result)[:500] if result else None,
|
||||||
|
"duration_ms": (
|
||||||
|
(context.end_time - context.start_time) * 1000
|
||||||
|
if context.start_time and context.end_time
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self._logs.append(log_entry)
|
||||||
|
self._write_log(log_entry)
|
||||||
|
return HookResult(
|
||||||
|
hook_name="audit_log",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
modified_output=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def tool_error(self, context: ExecutionContext, error: Exception) -> HookResult:
|
||||||
|
"""工具出错时记录"""
|
||||||
|
log_entry = {
|
||||||
|
"event": "tool_error",
|
||||||
|
"tool_name": context.tool_name,
|
||||||
|
"error": str(error),
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
}
|
||||||
|
self._logs.append(log_entry)
|
||||||
|
self._write_log(log_entry)
|
||||||
|
return HookResult(
|
||||||
|
hook_name="audit_log",
|
||||||
|
success=False,
|
||||||
|
continue_execution=True,
|
||||||
|
error=str(error),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_log(self, entry: dict[str, Any]) -> None:
|
||||||
|
"""写入日志"""
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
entry["timestamp"] = datetime.datetime.now().isoformat()
|
||||||
|
|
||||||
|
if self.log_path:
|
||||||
|
try:
|
||||||
|
with open(self.log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||||
|
except Exception:
|
||||||
|
# 日志写入失败不影响主流程
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 输出到 stdout
|
||||||
|
print(f"[AUDIT] {json.dumps(entry, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
def get_logs(self) -> list[dict[str, Any]]:
|
||||||
|
"""获取所有日志"""
|
||||||
|
return self._logs.copy()
|
||||||
|
|
||||||
|
def clear_logs(self) -> None:
|
||||||
|
"""清空日志"""
|
||||||
|
self._logs.clear()
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""危险操作确认 Hook - Phase 7.2
|
||||||
|
|
||||||
|
对危险操作要求用户确认。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
ExecutionContext,
|
||||||
|
HookResult,
|
||||||
|
)
|
||||||
|
from app.agents.tools.manifest import SideEffectScope
|
||||||
|
|
||||||
|
|
||||||
|
# 危险操作关键词
|
||||||
|
DANGEROUS_PATTERNS = [
|
||||||
|
# 文件操作
|
||||||
|
"delete",
|
||||||
|
"remove",
|
||||||
|
"rm ",
|
||||||
|
"rmdir",
|
||||||
|
"unlink",
|
||||||
|
"format",
|
||||||
|
"truncate",
|
||||||
|
# 系统操作
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"kill",
|
||||||
|
"pkill",
|
||||||
|
"sudo",
|
||||||
|
"chmod",
|
||||||
|
"chown",
|
||||||
|
# 数据操作
|
||||||
|
"drop",
|
||||||
|
"truncate",
|
||||||
|
"delete from",
|
||||||
|
"delete.*where",
|
||||||
|
"insert into.*select",
|
||||||
|
"update.*set",
|
||||||
|
# 网络操作
|
||||||
|
"curl",
|
||||||
|
"wget",
|
||||||
|
"nc ",
|
||||||
|
"netcat",
|
||||||
|
"ssh ",
|
||||||
|
"scp ",
|
||||||
|
"sftp ",
|
||||||
|
# 环境变量
|
||||||
|
"export.*secret",
|
||||||
|
"export.*key",
|
||||||
|
"export.*token",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DangerousConfirmationHook:
|
||||||
|
"""危险操作确认 Hook
|
||||||
|
|
||||||
|
检查工具调用是否包含危险操作,如是则要求确认。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, auto_block: bool = False):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
auto_block: True 表示自动拦截危险操作,False 表示仅警告
|
||||||
|
"""
|
||||||
|
self.auto_block = auto_block
|
||||||
|
self._pending_confirmations: dict[str, bool] = {}
|
||||||
|
|
||||||
|
async def pre_tool_use(self, context: ExecutionContext) -> HookResult:
|
||||||
|
"""检查是否为危险操作"""
|
||||||
|
is_dangerous = self._check_dangerous(context.tool_name, context.tool_input)
|
||||||
|
|
||||||
|
if is_dangerous:
|
||||||
|
if self.auto_block:
|
||||||
|
return HookResult(
|
||||||
|
hook_name="dangerous_confirmation",
|
||||||
|
success=False,
|
||||||
|
continue_execution=False,
|
||||||
|
error=f"危险操作被自动拦截: {context.tool_name}",
|
||||||
|
metadata={"dangerous": True, "auto_blocked": True},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 标记需要确认
|
||||||
|
context.metadata["requires_confirmation"] = True
|
||||||
|
context.metadata["dangerous_operation"] = True
|
||||||
|
return HookResult(
|
||||||
|
hook_name="dangerous_confirmation",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
metadata={"dangerous": True, "requires_confirmation": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
return HookResult(
|
||||||
|
hook_name="dangerous_confirmation",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_dangerous(self, tool_name: str, tool_input: dict[str, Any]) -> bool:
|
||||||
|
"""检查是否为危险操作"""
|
||||||
|
# 检查工具名称
|
||||||
|
dangerous_tools = [
|
||||||
|
"delete",
|
||||||
|
"remove",
|
||||||
|
"drop",
|
||||||
|
"truncate",
|
||||||
|
"kill",
|
||||||
|
"shutdown",
|
||||||
|
"reboot",
|
||||||
|
"bash",
|
||||||
|
"powershell",
|
||||||
|
"shell",
|
||||||
|
]
|
||||||
|
|
||||||
|
if tool_name.lower() in dangerous_tools:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 检查输入参数
|
||||||
|
input_str = str(tool_input).lower()
|
||||||
|
|
||||||
|
for pattern in DANGEROUS_PATTERNS:
|
||||||
|
if pattern.lower() in input_str:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def confirm(self, session_id: str, confirmed: bool) -> None:
|
||||||
|
"""确认危险操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
confirmed: True 表示用户确认,False 表示取消
|
||||||
|
"""
|
||||||
|
self._pending_confirmations[session_id] = confirmed
|
||||||
|
|
||||||
|
def is_confirmed(self, session_id: str) -> bool:
|
||||||
|
"""检查是否已确认"""
|
||||||
|
return self._pending_confirmations.get(session_id, False)
|
||||||
|
|
||||||
|
def clear_confirmation(self, session_id: str) -> None:
|
||||||
|
"""清除确认状态"""
|
||||||
|
self._pending_confirmations.pop(session_id, None)
|
||||||
183
backend/app/agents/tools/hooks/builtins/security_scan.py
Normal file
183
backend/app/agents/tools/hooks/builtins/security_scan.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""安全扫描 Hook - Phase 7.2
|
||||||
|
|
||||||
|
扫描工具调用和结果中的敏感信息。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
ExecutionContext,
|
||||||
|
HookResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 敏感信息模式
|
||||||
|
SENSITIVE_PATTERNS = {
|
||||||
|
"api_key": [
|
||||||
|
r"api[_-]?key['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_\-]{20,}",
|
||||||
|
r"apikey['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_\-]{20,}",
|
||||||
|
],
|
||||||
|
"password": [
|
||||||
|
r"password['\"]?\s*[:=]\s*['\"]?[^\s'\"]{8,}",
|
||||||
|
r"passwd['\"]?\s*[:=]\s*['\"]?[^\s'\"]{8,}",
|
||||||
|
r"secret['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_\-]{20,}",
|
||||||
|
],
|
||||||
|
"token": [
|
||||||
|
r"token['\"]?\s*[:=]\s*['\"]?[a-zA-Z0-9_\-\.]{20,}",
|
||||||
|
r"bearer\s+[a-zA-Z0-9_\-\.]+",
|
||||||
|
r"ghp_[a-zA-Z0-9]{36}",
|
||||||
|
r"sk-[a-zA-Z0-9]{48}",
|
||||||
|
],
|
||||||
|
"private_key": [
|
||||||
|
r"-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
||||||
|
r"-----END (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
||||||
|
],
|
||||||
|
"ip_address": [
|
||||||
|
r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
|
||||||
|
],
|
||||||
|
"email": [
|
||||||
|
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityScanHook:
|
||||||
|
"""安全扫描 Hook
|
||||||
|
|
||||||
|
扫描工具输入和输出中的敏感信息,进行脱敏处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
redact: bool = True,
|
||||||
|
block_on_detect: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
redact: 是否对敏感信息进行脱敏
|
||||||
|
block_on_detect: 检测到敏感信息时是否阻止执行
|
||||||
|
"""
|
||||||
|
self.redact = redact
|
||||||
|
self.block_on_detect = block_on_detect
|
||||||
|
self._compiled_patterns = {
|
||||||
|
name: [re.compile(p, re.IGNORECASE) for p in patterns]
|
||||||
|
for name, patterns in SENSITIVE_PATTERNS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def pre_tool_use(self, context: ExecutionContext) -> HookResult:
|
||||||
|
"""扫描输入参数"""
|
||||||
|
detected = self._scan_dict(context.tool_input)
|
||||||
|
|
||||||
|
if detected:
|
||||||
|
context.metadata["security_detected"] = detected
|
||||||
|
|
||||||
|
if self.block_on_detect:
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=False,
|
||||||
|
continue_execution=False,
|
||||||
|
error=f"检测到敏感信息: {', '.join(detected.keys())}",
|
||||||
|
metadata={"detected": detected, "blocked": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.redact:
|
||||||
|
redacted_input = self._redact_dict(context.tool_input.copy())
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
modified_input=redacted_input,
|
||||||
|
metadata={"detected": detected, "redacted": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post_tool_use(self, context: ExecutionContext, result: Any) -> HookResult:
|
||||||
|
"""扫描输出结果"""
|
||||||
|
if isinstance(result, dict):
|
||||||
|
detected = self._scan_dict(result)
|
||||||
|
|
||||||
|
if detected:
|
||||||
|
context.metadata["security_detected_output"] = detected
|
||||||
|
|
||||||
|
if self.redact:
|
||||||
|
redacted_result = self._redact_dict(result.copy())
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
modified_output=redacted_result,
|
||||||
|
metadata={"detected": detected, "redacted": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
elif isinstance(result, str):
|
||||||
|
detected = self._scan_string(result)
|
||||||
|
if detected:
|
||||||
|
context.metadata["security_detected_output"] = detected
|
||||||
|
|
||||||
|
if self.redact:
|
||||||
|
redacted_result = self._redact_string(result)
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
modified_output=redacted_result,
|
||||||
|
metadata={"detected": detected, "redacted": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
return HookResult(
|
||||||
|
hook_name="security_scan",
|
||||||
|
success=True,
|
||||||
|
continue_execution=True,
|
||||||
|
modified_output=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _scan_dict(self, data: dict[str, Any]) -> dict[str, list[str]]:
|
||||||
|
"""扫描字典中的敏感信息"""
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
found = self._scan_string(value)
|
||||||
|
if found:
|
||||||
|
result[key] = found
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _scan_string(self, text: str) -> list[str]:
|
||||||
|
"""扫描字符串中的敏感信息"""
|
||||||
|
found_types = []
|
||||||
|
|
||||||
|
for name, patterns in self._compiled_patterns.items():
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern.search(text):
|
||||||
|
if name not in found_types:
|
||||||
|
found_types.append(name)
|
||||||
|
break
|
||||||
|
|
||||||
|
return found_types
|
||||||
|
|
||||||
|
def _redact_dict(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""脱敏字典中的敏感信息"""
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, str):
|
||||||
|
data[key] = self._redact_string(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
data[key] = self._redact_dict(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
data[key] = [self._redact_string(v) if isinstance(v, str) else v for v in value]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _redact_string(self, text: str) -> str:
|
||||||
|
"""脱敏字符串中的敏感信息"""
|
||||||
|
for name, patterns in self._compiled_patterns.items():
|
||||||
|
for pattern in patterns:
|
||||||
|
text = pattern.sub(f"[REDACTED:{name}]", text)
|
||||||
|
|
||||||
|
return text
|
||||||
105
backend/app/agents/tools/hooks/config.py
Normal file
105
backend/app/agents/tools/hooks/config.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Hook 配置持久化 - Phase 7.3"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.manager import get_hook_manager
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookConfigEntry:
|
||||||
|
"""Hook 配置条目"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
hook_type: str
|
||||||
|
enabled: bool
|
||||||
|
tool_names: list[str] | None = None
|
||||||
|
categories: list[str] | None = None
|
||||||
|
priority: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class HookConfigPersistence:
|
||||||
|
"""Hook 配置持久化"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
config_path: 配置文件路径,None 则使用默认路径
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "..", "..", "config", "hooks.json"
|
||||||
|
)
|
||||||
|
self.config_path = config_path
|
||||||
|
|
||||||
|
def load_config(self) -> list[HookConfigEntry]:
|
||||||
|
"""从文件加载 Hook 配置"""
|
||||||
|
if not os.path.exists(self.config_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [HookConfigEntry(**entry) for entry in data]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_config(self, entries: list[HookConfigEntry]) -> bool:
|
||||||
|
"""保存 Hook 配置到文件"""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||||
|
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump([asdict(e) for e in entries], f, indent=2, ensure_ascii=False)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def apply_config(self) -> int:
|
||||||
|
"""应用配置到 HookManager
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
应用的 Hook 数量
|
||||||
|
"""
|
||||||
|
from app.agents.tools.hooks.types import HookType
|
||||||
|
|
||||||
|
manager = get_hook_manager()
|
||||||
|
entries = self.load_config()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.enabled:
|
||||||
|
from app.agents.tools.hooks.types import HookDefinition, HookTrigger
|
||||||
|
|
||||||
|
trigger = HookTrigger(
|
||||||
|
tool_names=entry.tool_names,
|
||||||
|
categories=entry.categories,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建空的 handler,只是注册配置
|
||||||
|
hook_def = HookDefinition(
|
||||||
|
name=entry.name,
|
||||||
|
hook_type=HookType(entry.hook_type),
|
||||||
|
trigger=trigger,
|
||||||
|
handler=lambda ctx, *args: ctx,
|
||||||
|
priority=entry.priority,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.register(hook_def)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_persistence: HookConfigPersistence | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_hook_config_persistence() -> HookConfigPersistence:
|
||||||
|
"""获取全局 Hook 配置持久化实例"""
|
||||||
|
global _persistence
|
||||||
|
if _persistence is None:
|
||||||
|
_persistence = HookConfigPersistence()
|
||||||
|
return _persistence
|
||||||
5
backend/app/agents/tools/hooks/custom/__init__.py
Normal file
5
backend/app/agents/tools/hooks/custom/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""自定义 Hook 加载器包"""
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.custom.loader import CustomHookLoader, get_custom_hook_loader
|
||||||
|
|
||||||
|
__all__ = ["CustomHookLoader", "get_custom_hook_loader"]
|
||||||
153
backend/app/agents/tools/hooks/custom/loader.py
Normal file
153
backend/app/agents/tools/hooks/custom/loader.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""自定义 Hook 加载器 - Phase 7.4
|
||||||
|
|
||||||
|
支持动态加载用户自定义的 Hook。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import HookDefinition, HookType, HookTrigger, HookResult
|
||||||
|
|
||||||
|
|
||||||
|
class CustomHookLoader:
|
||||||
|
"""自定义 Hook 加载器
|
||||||
|
|
||||||
|
从指定目录动态加载自定义 Hook 模块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hooks_dir: str | None = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
hooks_dir: Hook 目录,None 则使用默认目录
|
||||||
|
"""
|
||||||
|
if hooks_dir is None:
|
||||||
|
hooks_dir = os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "..", "..", "data", "custom_hooks"
|
||||||
|
)
|
||||||
|
self.hooks_dir = hooks_dir
|
||||||
|
self._loaded_hooks: dict[str, HookDefinition] = {}
|
||||||
|
|
||||||
|
def load_all(self) -> list[HookDefinition]:
|
||||||
|
"""加载所有自定义 Hook
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 定义列表
|
||||||
|
"""
|
||||||
|
hooks = []
|
||||||
|
|
||||||
|
if not os.path.exists(self.hooks_dir):
|
||||||
|
return hooks
|
||||||
|
|
||||||
|
for filename in os.listdir(self.hooks_dir):
|
||||||
|
if filename.endswith(".py") and not filename.startswith("_"):
|
||||||
|
hook_path = os.path.join(self.hooks_dir, filename)
|
||||||
|
hook_def = self._load_hook_from_file(hook_path, filename[:-3])
|
||||||
|
if hook_def:
|
||||||
|
hooks.append(hook_def)
|
||||||
|
self._loaded_hooks[hook_def.name] = hook_def
|
||||||
|
|
||||||
|
return hooks
|
||||||
|
|
||||||
|
def _load_hook_from_file(self, hook_path: str, module_name: str) -> HookDefinition | None:
|
||||||
|
"""从文件加载 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_path: Hook 文件路径
|
||||||
|
module_name: 模块名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 定义或 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, hook_path)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
return None
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
# 查找 HOOK_DEFINITION 或 hook_definition
|
||||||
|
hook_def = getattr(module, "HOOK_DEFINITION", None) or getattr(
|
||||||
|
module, "hook_definition", None
|
||||||
|
)
|
||||||
|
|
||||||
|
if hook_def and isinstance(hook_def, HookDefinition):
|
||||||
|
return hook_def
|
||||||
|
|
||||||
|
# 如果没有定义,尝试从函数自动推断
|
||||||
|
if hasattr(module, "pre_tool_hook") or hasattr(module, "post_tool_hook"):
|
||||||
|
return self._infer_hook_definition(module, module_name)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _infer_hook_definition(self, module: Any, module_name: str) -> HookDefinition | None:
|
||||||
|
"""从模块函数推断 Hook 定义
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module: 模块对象
|
||||||
|
module_name: 模块名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 定义或 None
|
||||||
|
"""
|
||||||
|
hook_type = None
|
||||||
|
handler = None
|
||||||
|
|
||||||
|
if hasattr(module, "pre_tool_hook"):
|
||||||
|
handler = module.pre_tool_hook
|
||||||
|
hook_type = HookType.PRE_TOOL_USE
|
||||||
|
elif hasattr(module, "post_tool_hook"):
|
||||||
|
handler = module.post_tool_hook
|
||||||
|
hook_type = HookType.POST_TOOL_USE
|
||||||
|
elif hasattr(module, "error_tool_hook"):
|
||||||
|
handler = module.error_tool_hook
|
||||||
|
hook_type = HookType.TOOL_ERROR
|
||||||
|
|
||||||
|
if not handler or not hook_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return HookDefinition(
|
||||||
|
name=module_name,
|
||||||
|
hook_type=hook_type,
|
||||||
|
trigger=HookTrigger(),
|
||||||
|
handler=handler,
|
||||||
|
priority=0,
|
||||||
|
enabled=True,
|
||||||
|
description=f"Auto-loaded hook from {module_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_hook(self, name: str) -> HookDefinition | None:
|
||||||
|
"""获取已加载的 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Hook 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 定义或 None
|
||||||
|
"""
|
||||||
|
return self._loaded_hooks.get(name)
|
||||||
|
|
||||||
|
def reload(self) -> list[HookDefinition]:
|
||||||
|
"""重新加载所有 Hook
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
重新加载的 Hook 列表
|
||||||
|
"""
|
||||||
|
self._loaded_hooks.clear()
|
||||||
|
return self.load_all()
|
||||||
|
|
||||||
|
|
||||||
|
# 全局加载器
|
||||||
|
_loader: CustomHookLoader | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_custom_hook_loader() -> CustomHookLoader:
|
||||||
|
"""获取全局自定义 Hook 加载器"""
|
||||||
|
global _loader
|
||||||
|
if _loader is None:
|
||||||
|
_loader = CustomHookLoader()
|
||||||
|
return _loader
|
||||||
170
backend/app/agents/tools/hooks/executor.py
Normal file
170
backend/app/agents/tools/hooks/executor.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Hook 执行器 - Phase 6.2
|
||||||
|
|
||||||
|
执行 Hook 拦截逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.manager import get_hook_manager
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
HookDefinition,
|
||||||
|
HookResult,
|
||||||
|
HookType,
|
||||||
|
ExecutionContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HookExecutor:
|
||||||
|
"""Hook 执行器
|
||||||
|
|
||||||
|
负责在工具执行前后执行 Hook 逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._manager = get_hook_manager()
|
||||||
|
|
||||||
|
async def execute_pre_hooks(
|
||||||
|
self, context: ExecutionContext
|
||||||
|
) -> tuple[bool, dict[str, Any] | None]:
|
||||||
|
"""执行 pre-tool Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: 执行上下文
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否继续执行, 修改后的输入)
|
||||||
|
"""
|
||||||
|
hooks = self._manager.get_hooks(HookType.PRE_TOOL_USE, context.tool_name)
|
||||||
|
modified_input = context.tool_input
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
# 调用 hook handler
|
||||||
|
handler = hook.handler
|
||||||
|
if callable(handler):
|
||||||
|
result = await self._call_hook(handler, context)
|
||||||
|
if result and not result.continue_execution:
|
||||||
|
# Hook 决定中断执行
|
||||||
|
return False, modified_input
|
||||||
|
if result.modified_input is not None:
|
||||||
|
modified_input = result.modified_input
|
||||||
|
except Exception as e:
|
||||||
|
# Hook 出错,默认继续执行
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True, modified_input
|
||||||
|
|
||||||
|
async def execute_post_hooks(self, context: ExecutionContext, result: Any) -> Any:
|
||||||
|
"""执行 post-tool Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: 执行上下文
|
||||||
|
result: 工具执行结果
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
修改后的结果
|
||||||
|
"""
|
||||||
|
hooks = self._manager.get_hooks(HookType.POST_TOOL_USE, context.tool_name)
|
||||||
|
modified_result = result
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
handler = hook.handler
|
||||||
|
if callable(handler):
|
||||||
|
hook_result = await self._call_hook(handler, context, modified_result)
|
||||||
|
if hook_result and hook_result.modified_output is not None:
|
||||||
|
modified_result = hook_result.modified_output
|
||||||
|
except Exception:
|
||||||
|
# Hook 出错,默认保留原结果
|
||||||
|
pass
|
||||||
|
|
||||||
|
return modified_result
|
||||||
|
|
||||||
|
async def execute_error_hooks(
|
||||||
|
self, context: ExecutionContext, error: Exception
|
||||||
|
) -> HookResult | None:
|
||||||
|
"""执行 error Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: 执行上下文
|
||||||
|
error: 异常
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 结果,如果返回 None 则继续传播错误
|
||||||
|
"""
|
||||||
|
hooks = self._manager.get_hooks(HookType.TOOL_ERROR, context.tool_name)
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
handler = hook.handler
|
||||||
|
if callable(handler):
|
||||||
|
result = await self._call_hook(handler, context, error)
|
||||||
|
if result is not None and result.continue_execution:
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
# Hook 出错,继续执行其他 error hooks
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def execute_skip_check(self, context: ExecutionContext) -> bool:
|
||||||
|
"""检查是否应跳过工具执行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: 执行上下文
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 表示跳过,False 表示执行
|
||||||
|
"""
|
||||||
|
hooks = self._manager.get_hooks(HookType.TOOL_SKIP, context.tool_name)
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
handler = hook.handler
|
||||||
|
if callable(handler):
|
||||||
|
result = await self._call_hook(handler, context)
|
||||||
|
if result is not None and isinstance(result, bool):
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
# Hook 出错,默认不跳过
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _call_hook(
|
||||||
|
self, handler: Any, context: ExecutionContext, *args: Any
|
||||||
|
) -> HookResult | None:
|
||||||
|
"""调用 Hook 处理函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
handler: Hook 处理函数
|
||||||
|
context: 执行上下文
|
||||||
|
*args: 额外参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 结果
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# 如果是普通函数,直接调用
|
||||||
|
if asyncio.iscoroutinefunction(handler):
|
||||||
|
return await handler(context, *args)
|
||||||
|
else:
|
||||||
|
return handler(context, *args)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_executor: HookExecutor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_hook_executor() -> HookExecutor:
|
||||||
|
"""获取全局 Hook 执行器
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
全局 HookExecutor 实例
|
||||||
|
"""
|
||||||
|
global _executor
|
||||||
|
if _executor is None:
|
||||||
|
_executor = HookExecutor()
|
||||||
|
return _executor
|
||||||
174
backend/app/agents/tools/hooks/manager.py
Normal file
174
backend/app/agents/tools/hooks/manager.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Hook 管理器 - Phase 6.2
|
||||||
|
|
||||||
|
管理 Hook 的注册、查找和配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.types import (
|
||||||
|
HookDefinition,
|
||||||
|
HookResult,
|
||||||
|
HookTrigger,
|
||||||
|
HookType,
|
||||||
|
ExecutionContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HookManager:
|
||||||
|
"""Hook 管理器
|
||||||
|
|
||||||
|
管理全局 Hook 的注册和配置。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._hooks: dict[HookType, list[HookDefinition]] = {
|
||||||
|
HookType.PRE_TOOL_USE: [],
|
||||||
|
HookType.POST_TOOL_USE: [],
|
||||||
|
HookType.TOOL_ERROR: [],
|
||||||
|
HookType.TOOL_SKIP: [],
|
||||||
|
}
|
||||||
|
self._global_hooks: list[HookDefinition] = [] # 全局 Hook(对所有工具生效)
|
||||||
|
|
||||||
|
def register(self, definition: HookDefinition) -> None:
|
||||||
|
"""注册 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
definition: Hook 定义
|
||||||
|
"""
|
||||||
|
if definition.trigger.tool_names is None and definition.trigger.categories is None:
|
||||||
|
# 全局 Hook
|
||||||
|
self._global_hooks.append(definition)
|
||||||
|
else:
|
||||||
|
# 特定工具 Hook
|
||||||
|
self._hooks[definition.hook_type].append(definition)
|
||||||
|
|
||||||
|
# 按优先级排序
|
||||||
|
self._hooks[definition.hook_type].sort(key=lambda h: h.priority, reverse=True)
|
||||||
|
self._global_hooks.sort(key=lambda h: h.priority, reverse=True)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> bool:
|
||||||
|
"""注销 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Hook 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功注销
|
||||||
|
"""
|
||||||
|
# 从特定工具 Hook 中移除
|
||||||
|
for hooks in self._hooks.values():
|
||||||
|
for i, hook in enumerate(hooks):
|
||||||
|
if hook.name == name:
|
||||||
|
hooks.pop(i)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 从全局 Hook 中移除
|
||||||
|
for i, hook in enumerate(self._global_hooks):
|
||||||
|
if hook.name == name:
|
||||||
|
self._global_hooks.pop(i)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_hooks(self, hook_type: HookType, tool_name: str | None = None) -> list[HookDefinition]:
|
||||||
|
"""获取指定类型和工具的 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hook_type: Hook 类型
|
||||||
|
tool_name: 工具名称(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
匹配的 Hook 列表
|
||||||
|
"""
|
||||||
|
result: list[HookDefinition] = []
|
||||||
|
|
||||||
|
# 添加全局 Hook
|
||||||
|
for hook in self._global_hooks:
|
||||||
|
if hook.hook_type == hook_type and hook.enabled:
|
||||||
|
result.append(hook)
|
||||||
|
|
||||||
|
# 添加特定工具 Hook
|
||||||
|
for hook in self._hooks[hook_type]:
|
||||||
|
if not hook.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if hook.trigger.tool_names is None and hook.trigger.categories is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查是否匹配
|
||||||
|
if hook.trigger.tool_names and tool_name not in hook.trigger.tool_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result.append(hook)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list_all(self) -> list[HookDefinition]:
|
||||||
|
"""列出所有已注册的 Hook
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 列表
|
||||||
|
"""
|
||||||
|
all_hooks = list(self._global_hooks)
|
||||||
|
for hooks in self._hooks.values():
|
||||||
|
all_hooks.extend(hooks)
|
||||||
|
return all_hooks
|
||||||
|
|
||||||
|
def enable(self, name: str) -> bool:
|
||||||
|
"""启用 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Hook 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功启用
|
||||||
|
"""
|
||||||
|
for hook in self.list_all():
|
||||||
|
if hook.name == name:
|
||||||
|
hook.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self, name: str) -> bool:
|
||||||
|
"""禁用 Hook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Hook 名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功禁用
|
||||||
|
"""
|
||||||
|
for hook in self.list_all():
|
||||||
|
if hook.name == name:
|
||||||
|
hook.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""清除所有 Hook"""
|
||||||
|
self._hooks = {ht: [] for ht in HookType}
|
||||||
|
self._global_hooks = []
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_global_hook_manager: HookManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_hook_manager() -> HookManager:
|
||||||
|
"""获取全局 Hook 管理器
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
全局 HookManager 实例
|
||||||
|
"""
|
||||||
|
global _global_hook_manager
|
||||||
|
if _global_hook_manager is None:
|
||||||
|
_global_hook_manager = HookManager()
|
||||||
|
return _global_hook_manager
|
||||||
|
|
||||||
|
|
||||||
|
def reset_hook_manager() -> None:
|
||||||
|
"""重置全局 Hook 管理器(用于测试)"""
|
||||||
|
global _global_hook_manager
|
||||||
|
if _global_hook_manager is not None:
|
||||||
|
_global_hook_manager.clear()
|
||||||
|
_global_hook_manager = None
|
||||||
90
backend/app/agents/tools/hooks/types.py
Normal file
90
backend/app/agents/tools/hooks/types.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Hook 类型定义 - Phase 6.2
|
||||||
|
|
||||||
|
Hook 拦截系统类型定义。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class HookType(Enum):
|
||||||
|
"""Hook 类型"""
|
||||||
|
|
||||||
|
PRE_TOOL_USE = "pre_tool_use" # 工具执行前
|
||||||
|
POST_TOOL_USE = "post_tool_use" # 工具执行后
|
||||||
|
TOOL_ERROR = "tool_error" # 工具执行出错
|
||||||
|
TOOL_SKIP = "tool_skip" # 工具跳过(条件执行)
|
||||||
|
|
||||||
|
|
||||||
|
class HookStage(Enum):
|
||||||
|
"""Hook 执行阶段"""
|
||||||
|
|
||||||
|
BEFORE = "before"
|
||||||
|
AFTER = "after"
|
||||||
|
ON_ERROR = "on_error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookTrigger:
|
||||||
|
"""Hook 触发条件"""
|
||||||
|
|
||||||
|
tool_names: list[str] | None = None # 只对特定工具生效,None 表示全部
|
||||||
|
categories: list[str] | None = None # 只对特定类别生效
|
||||||
|
conditions: dict[str, Any] | None = None # 自定义条件
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookDefinition:
|
||||||
|
"""Hook 定义"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
hook_type: HookType
|
||||||
|
trigger: HookTrigger
|
||||||
|
handler: Callable[..., Any] # Hook 处理函数
|
||||||
|
priority: int = 0 # 优先级,数字越大越先执行
|
||||||
|
enabled: bool = True
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookResult:
|
||||||
|
"""Hook 执行结果"""
|
||||||
|
|
||||||
|
hook_name: str
|
||||||
|
success: bool
|
||||||
|
continue_execution: bool = True # False 表示中断执行
|
||||||
|
modified_input: Any = None # 修改后的输入
|
||||||
|
modified_output: Any = None # 修改后的输出
|
||||||
|
error: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExecutionContext:
|
||||||
|
"""工具执行上下文"""
|
||||||
|
|
||||||
|
tool_name: str
|
||||||
|
tool_input: dict[str, Any]
|
||||||
|
user_id: str | None = None
|
||||||
|
session_id: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# 执行结果(由 HookExecutor 填充)
|
||||||
|
result: Any = None
|
||||||
|
error: Exception | None = None
|
||||||
|
start_time: float | None = None
|
||||||
|
end_time: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Hook 处理函数类型
|
||||||
|
HookHandler = Callable[[ExecutionContext, HookDefinition], HookResult]
|
||||||
|
|
||||||
|
# Pre-hook: 在工具执行前调用,可以修改输入或决定是否跳过
|
||||||
|
PreToolHook = Callable[[ExecutionContext], tuple[bool, dict[str, Any] | None]]
|
||||||
|
# post-hook: 在工具执行后调用,可以修改输出
|
||||||
|
PostToolHook = Callable[[ExecutionContext, Any], Any]
|
||||||
|
# Error hook: 在工具出错时调用
|
||||||
|
ErrorToolHook = Callable[[ExecutionContext, Exception], HookResult | None]
|
||||||
|
# Skip hook: 决定是否跳过工具执行
|
||||||
|
SkipToolHook = Callable[[ExecutionContext], bool]
|
||||||
77
backend/app/agents/tools/manifest.py
Normal file
77
backend/app/agents/tools/manifest.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""工具元数据和数据类型定义"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCategory(Enum):
|
||||||
|
"""工具类别"""
|
||||||
|
|
||||||
|
READ = "read"
|
||||||
|
WRITE = "write"
|
||||||
|
EXTERNAL = "external"
|
||||||
|
DB_WRITE = "db_write"
|
||||||
|
NETWORK = "network"
|
||||||
|
|
||||||
|
|
||||||
|
class SideEffectScope(Enum):
|
||||||
|
"""副作用范围"""
|
||||||
|
|
||||||
|
NONE = "none"
|
||||||
|
LOCAL_STATE = "local_state"
|
||||||
|
DB_WRITE = "db_write"
|
||||||
|
NETWORK = "network"
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionClass(Enum):
|
||||||
|
"""权限级别"""
|
||||||
|
|
||||||
|
READ = "read"
|
||||||
|
WRITE = "write"
|
||||||
|
EXTERNAL = "external"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolManifest:
|
||||||
|
"""工具元数据"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
category: ToolCategory
|
||||||
|
parameters: dict[str, Any] # JSON Schema
|
||||||
|
return_schema: dict[str, Any]
|
||||||
|
permission_class: PermissionClass
|
||||||
|
side_effect_scope: SideEffectScope
|
||||||
|
requires_confirmation: bool = False
|
||||||
|
is_streaming: bool = False
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"category": self.category.value,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
"return_schema": self.return_schema,
|
||||||
|
"permission_class": self.permission_class.value,
|
||||||
|
"side_effect_scope": self.side_effect_scope.value,
|
||||||
|
"requires_confirmation": self.requires_confirmation,
|
||||||
|
"is_streaming": self.is_streaming,
|
||||||
|
"tags": self.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HookConfig:
|
||||||
|
"""Hook 配置"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
hook_type: str # "pre_tool_use", "post_tool_use", "tool_error", "tool_skip"
|
||||||
|
filter_names: list[str] | None = None # 只对特定工具生效,None 表示全部
|
||||||
|
|
||||||
|
def matches_tool(self, tool_name: str) -> bool:
|
||||||
|
"""检查 Hook 是否对指定工具生效"""
|
||||||
|
if self.filter_names is None:
|
||||||
|
return True
|
||||||
|
return tool_name in self.filter_names
|
||||||
251
backend/app/agents/tools/migration.py
Normal file
251
backend/app/agents/tools/migration.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""工具迁移和向后兼容层 - Phase 6.1
|
||||||
|
|
||||||
|
将现有 @tool 装饰的工具迁移到 ToolRegistry,同时保持向后兼容。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
ToolCategory,
|
||||||
|
ToolManifest,
|
||||||
|
)
|
||||||
|
from app.agents.tools.registry import get_tool_registry
|
||||||
|
|
||||||
|
|
||||||
|
# 现有工具的类别映射
|
||||||
|
_TOOL_CATEGORY_MAP: dict[str, tuple[ToolCategory, PermissionClass, SideEffectScope]] = {
|
||||||
|
# 知识检索 - 只读
|
||||||
|
"search_knowledge": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"get_knowledge_graph_context": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"hybrid_search": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"web_search": (ToolCategory.NETWORK, PermissionClass.EXTERNAL, SideEffectScope.NETWORK),
|
||||||
|
# 知识构建 - 写入
|
||||||
|
"build_knowledge_graph": (
|
||||||
|
ToolCategory.WRITE,
|
||||||
|
PermissionClass.WRITE,
|
||||||
|
SideEffectScope.LOCAL_STATE,
|
||||||
|
),
|
||||||
|
# 任务工具
|
||||||
|
"get_tasks": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"create_task": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
"update_task_status": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
# 日程工具
|
||||||
|
"get_schedule_day": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"create_todo": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
"create_schedule_task": (
|
||||||
|
ToolCategory.WRITE,
|
||||||
|
PermissionClass.WRITE,
|
||||||
|
SideEffectScope.LOCAL_STATE,
|
||||||
|
),
|
||||||
|
"create_reminder": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
"create_goal": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
"resolve_time_expression": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
# 论坛工具
|
||||||
|
"get_forum_posts": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
"create_forum_post": (ToolCategory.WRITE, PermissionClass.WRITE, SideEffectScope.LOCAL_STATE),
|
||||||
|
"scan_forum_for_instructions": (ToolCategory.READ, PermissionClass.READ, SideEffectScope.NONE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_category(name: str) -> tuple[ToolCategory, PermissionClass, SideEffectScope]:
|
||||||
|
"""获取工具的类别信息"""
|
||||||
|
return _TOOL_CATEGORY_MAP.get(
|
||||||
|
name,
|
||||||
|
(ToolCategory.EXTERNAL, PermissionClass.EXTERNAL, SideEffectScope.NETWORK),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_tags_from_docstring(docstring: str | None) -> list[str]:
|
||||||
|
"""从 docstring 推断工具标签"""
|
||||||
|
if not docstring:
|
||||||
|
return []
|
||||||
|
tags = []
|
||||||
|
doc_lower = docstring.lower()
|
||||||
|
if "搜索" in docstring or "查询" in docstring or "search" in doc_lower:
|
||||||
|
tags.append("search")
|
||||||
|
if "创建" in docstring or "新建" in docstring or "create" in doc_lower:
|
||||||
|
tags.append("create")
|
||||||
|
if "获取" in docstring or "读取" in docstring or "get" in doc_lower:
|
||||||
|
tags.append("read")
|
||||||
|
if "更新" in docstring or "修改" in docstring or "update" in doc_lower:
|
||||||
|
tags.append("update")
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_tool(tool_func: Callable) -> Callable:
|
||||||
|
"""将现有 @tool 装饰的函数迁移到 ToolRegistry
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_func: LangChain @tool 装饰的函数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
原函数(已注册到 registry)
|
||||||
|
"""
|
||||||
|
registry = get_tool_registry()
|
||||||
|
|
||||||
|
# 如果已经注册,跳过
|
||||||
|
if registry.get(tool_func.name):
|
||||||
|
return tool_func
|
||||||
|
|
||||||
|
# 获取类别信息
|
||||||
|
category, permission, side_effect = get_tool_category(tool_func.name)
|
||||||
|
|
||||||
|
# 从 docstring 提取 description
|
||||||
|
description = tool_func.description if hasattr(tool_func, "description") else ""
|
||||||
|
|
||||||
|
# 推断 tags
|
||||||
|
tags = infer_tags_from_docstring(description)
|
||||||
|
tags.append("migrated")
|
||||||
|
|
||||||
|
# 创建 manifest
|
||||||
|
manifest = ToolManifest(
|
||||||
|
name=tool_func.name,
|
||||||
|
description=description,
|
||||||
|
category=category,
|
||||||
|
parameters={}, # LangChain @tool 动态处理参数
|
||||||
|
return_schema={},
|
||||||
|
permission_class=permission,
|
||||||
|
side_effect_scope=side_effect,
|
||||||
|
requires_confirmation=side_effect != SideEffectScope.NONE,
|
||||||
|
is_streaming=False,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册到 registry
|
||||||
|
registry.register(manifest, tool_func)
|
||||||
|
|
||||||
|
return tool_func
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_all_tools() -> int:
|
||||||
|
"""迁移所有现有工具到 ToolRegistry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
迁移的工具数量
|
||||||
|
"""
|
||||||
|
from app.agents.tools import (
|
||||||
|
ALL_TOOLS,
|
||||||
|
KNOWLEDGE_GRAPH_TOOLS,
|
||||||
|
KNOWLEDGE_RETRIEVAL_TOOLS,
|
||||||
|
SCHEDULE_READ_TOOLS,
|
||||||
|
SCHEDULE_WRITE_TOOLS,
|
||||||
|
TASK_TOOLS,
|
||||||
|
FORUM_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
all_tools = (
|
||||||
|
KNOWLEDGE_RETRIEVAL_TOOLS
|
||||||
|
+ KNOWLEDGE_GRAPH_TOOLS
|
||||||
|
+ TASK_TOOLS
|
||||||
|
+ SCHEDULE_READ_TOOLS
|
||||||
|
+ SCHEDULE_WRITE_TOOLS
|
||||||
|
+ FORUM_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for tool in all_tools:
|
||||||
|
try:
|
||||||
|
migrate_tool(tool)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to migrate tool {getattr(tool, 'name', 'unknown')}: {e}")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
class BackwardCompatTool:
|
||||||
|
"""向后兼容工具包装器
|
||||||
|
|
||||||
|
确保现有代码通过 registry.get_executor() 仍能正常调用工具。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self._registry = get_tool_registry()
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs) -> Any:
|
||||||
|
executor = self._registry.get_executor(self.name)
|
||||||
|
if executor is None:
|
||||||
|
raise ValueError(f"Tool not found in registry: {self.name}")
|
||||||
|
return executor(*args, **kwargs)
|
||||||
|
|
||||||
|
def invoke(self, tool_input: dict[str, Any]) -> Any:
|
||||||
|
"""LangChain 风格的 invoke 调用"""
|
||||||
|
executor = self._registry.get_executor(self.name)
|
||||||
|
if executor is None:
|
||||||
|
raise ValueError(f"Tool not found in registry: {self.name}")
|
||||||
|
|
||||||
|
# 处理位置参数
|
||||||
|
if isinstance(tool_input, dict):
|
||||||
|
return executor(**tool_input)
|
||||||
|
return executor(tool_input)
|
||||||
|
|
||||||
|
|
||||||
|
def create_compat_layer() -> dict[str, BackwardCompatTool]:
|
||||||
|
"""创建向后兼容层
|
||||||
|
|
||||||
|
返回一个字典,允许通过名称访问兼容的工具包装器。
|
||||||
|
"""
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.list_all()
|
||||||
|
|
||||||
|
return {tool.name: BackwardCompatTool(tool.name) for tool in tools}
|
||||||
|
|
||||||
|
|
||||||
|
# 自动迁移装饰器
|
||||||
|
def auto_migrate(func: Callable) -> Callable:
|
||||||
|
"""自动迁移装饰器
|
||||||
|
|
||||||
|
用于装饰新的 @tool 函数,自动注册到 registry。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
# 迁移到 registry
|
||||||
|
migrate_tool(wrapper)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# 便捷函数:获取兼容的工具执行器
|
||||||
|
def get_tool_executor(name: str) -> Callable | None:
|
||||||
|
"""获取工具执行器(兼容层)
|
||||||
|
|
||||||
|
优先从 registry 获取,fallback 到直接导入。
|
||||||
|
"""
|
||||||
|
registry = get_tool_registry()
|
||||||
|
executor = registry.get_executor(name)
|
||||||
|
|
||||||
|
if executor is not None:
|
||||||
|
return executor
|
||||||
|
|
||||||
|
# Fallback: 直接从模块导入(仅用于迁移期间)
|
||||||
|
try:
|
||||||
|
from app.agents.tools import (
|
||||||
|
TASK_TOOLS,
|
||||||
|
SCHEDULE_READ_TOOLS,
|
||||||
|
SCHEDULE_WRITE_TOOLS,
|
||||||
|
FORUM_TOOLS,
|
||||||
|
KNOWLEDGE_RETRIEVAL_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
all_tools = (
|
||||||
|
KNOWLEDGE_RETRIEVAL_TOOLS
|
||||||
|
+ TASK_TOOLS
|
||||||
|
+ SCHEDULE_READ_TOOLS
|
||||||
|
+ SCHEDULE_WRITE_TOOLS
|
||||||
|
+ FORUM_TOOLS
|
||||||
|
)
|
||||||
|
|
||||||
|
for tool in all_tools:
|
||||||
|
if hasattr(tool, "name") and tool.name == name:
|
||||||
|
return tool
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
206
backend/app/agents/tools/registry.py
Normal file
206
backend/app/agents/tools/registry.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""工具注册表 - 工具系统重构 Phase 6.1"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from app.agents.tools.manifest import HookConfig, ToolManifest
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
"""工具注册表
|
||||||
|
|
||||||
|
统一管理所有工具的注册、发现和调用。
|
||||||
|
支持工具元数据、权限分类、Hook 拦截。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tools: dict[str, ToolManifest] = {}
|
||||||
|
self._executors: dict[str, Callable] = {}
|
||||||
|
self._hooks: dict[str, list[HookConfig]] = defaultdict(list)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self, manifest: ToolManifest, executor: Callable, hooks: list[HookConfig] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""注册工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
manifest: 工具元数据
|
||||||
|
executor: 工具执行函数
|
||||||
|
hooks: 可选的 Hook 配置列表
|
||||||
|
"""
|
||||||
|
if manifest.name in self._tools:
|
||||||
|
raise ValueError(f"Tool already registered: {manifest.name}")
|
||||||
|
|
||||||
|
self._tools[manifest.name] = manifest
|
||||||
|
self._executors[manifest.name] = executor
|
||||||
|
|
||||||
|
if hooks:
|
||||||
|
for hook in hooks:
|
||||||
|
self._hooks[manifest.name].append(hook)
|
||||||
|
|
||||||
|
def unregister(self, name: str) -> bool:
|
||||||
|
"""注销工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功注销
|
||||||
|
"""
|
||||||
|
if name not in self._tools:
|
||||||
|
return False
|
||||||
|
|
||||||
|
del self._tools[name]
|
||||||
|
del self._executors[name]
|
||||||
|
if name in self._hooks:
|
||||||
|
del self._hooks[name]
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, name: str) -> ToolManifest | None:
|
||||||
|
"""获取工具元数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具元数据,不存在返回 None
|
||||||
|
"""
|
||||||
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_executor(self, name: str) -> Callable | None:
|
||||||
|
"""获取工具执行器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具执行函数,不存在返回 None
|
||||||
|
"""
|
||||||
|
return self._executors.get(name)
|
||||||
|
|
||||||
|
def get_hooks(self, name: str) -> list[HookConfig]:
|
||||||
|
"""获取工具的 Hook 配置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hook 配置列表
|
||||||
|
"""
|
||||||
|
return self._hooks.get(name, [])
|
||||||
|
|
||||||
|
def list_all(self) -> list[ToolManifest]:
|
||||||
|
"""列出所有已注册的工具
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具元数据列表
|
||||||
|
"""
|
||||||
|
return list(self._tools.values())
|
||||||
|
|
||||||
|
def list_by_category(self, category: Any) -> list[ToolManifest]:
|
||||||
|
"""按类别列出工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: 工具类别
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
该类别下的所有工具
|
||||||
|
"""
|
||||||
|
return [t for t in self._tools.values() if t.category == category]
|
||||||
|
|
||||||
|
def list_by_permission(self, permission: Any) -> list[ToolManifest]:
|
||||||
|
"""按权限级别列出工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission: 权限级别
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
该权限级别下的所有工具
|
||||||
|
"""
|
||||||
|
return [t for t in self._tools.values() if t.permission_class == permission]
|
||||||
|
|
||||||
|
def search_by_tag(self, tag: str) -> list[ToolManifest]:
|
||||||
|
"""按标签搜索工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag: 标签
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含该标签的工具
|
||||||
|
"""
|
||||||
|
return [t for t in self._tools.values() if tag in t.tags]
|
||||||
|
|
||||||
|
def search_by_name(self, keyword: str) -> list[ToolManifest]:
|
||||||
|
"""按名称关键词搜索工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keyword: 关键词
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
名称包含关键词的工具
|
||||||
|
"""
|
||||||
|
keyword = keyword.lower()
|
||||||
|
return [t for t in self._tools.values() if keyword in t.name.lower()]
|
||||||
|
|
||||||
|
def get_requires_confirmation(self, name: str) -> bool:
|
||||||
|
"""检查工具是否需要确认
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否需要确认
|
||||||
|
"""
|
||||||
|
manifest = self._tools.get(name)
|
||||||
|
return manifest.requires_confirmation if manifest else False
|
||||||
|
|
||||||
|
def get_is_streaming(self, name: str) -> bool:
|
||||||
|
"""检查工具是否支持流式执行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 工具名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否支持流式
|
||||||
|
"""
|
||||||
|
manifest = self._tools.get(name)
|
||||||
|
return manifest.is_streaming if manifest else False
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""清空注册表"""
|
||||||
|
self._tools.clear()
|
||||||
|
self._executors.clear()
|
||||||
|
self._hooks.clear()
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._tools)
|
||||||
|
|
||||||
|
def __contains__(self, name: str) -> bool:
|
||||||
|
return name in self._tools
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._tools.values())
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例实例
|
||||||
|
_global_registry: ToolRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_registry() -> ToolRegistry:
|
||||||
|
"""获取全局工具注册表单例
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
全局 ToolRegistry 实例
|
||||||
|
"""
|
||||||
|
global _global_registry
|
||||||
|
if _global_registry is None:
|
||||||
|
_global_registry = ToolRegistry()
|
||||||
|
return _global_registry
|
||||||
|
|
||||||
|
|
||||||
|
def reset_tool_registry() -> None:
|
||||||
|
"""重置全局工具注册表(用于测试)"""
|
||||||
|
global _global_registry
|
||||||
|
if _global_registry is not None:
|
||||||
|
_global_registry.clear()
|
||||||
|
_global_registry = None
|
||||||
210
backend/app/agents/tools/streaming.py
Normal file
210
backend/app/agents/tools/streaming.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""流式工具执行器 - Phase 6.3
|
||||||
|
|
||||||
|
支持流式输出的工具执行器。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
from app.agents.tools.hooks.executor import get_hook_executor
|
||||||
|
from app.agents.tools.hooks.types import ExecutionContext
|
||||||
|
from app.agents.tools.registry import get_tool_registry
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingToolExecutor:
|
||||||
|
"""流式工具执行器
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 普通工具的同步/异步执行
|
||||||
|
- 流式工具的流式输出
|
||||||
|
- Hook 拦截(pre/post/error)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._registry = get_tool_registry()
|
||||||
|
self._hook_executor = get_hook_executor()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: dict[str, Any],
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""执行工具(非流式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: 工具名称
|
||||||
|
tool_input: 工具输入参数
|
||||||
|
user_id: 用户 ID(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
工具执行结果
|
||||||
|
"""
|
||||||
|
# 创建执行上下文
|
||||||
|
context = ExecutionContext(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取工具和执行器
|
||||||
|
manifest = self._registry.get(tool_name)
|
||||||
|
if manifest is None:
|
||||||
|
raise ValueError(f"Tool not found: {tool_name}")
|
||||||
|
|
||||||
|
executor = self._registry.get_executor(tool_name)
|
||||||
|
if executor is None:
|
||||||
|
raise ValueError(f"Executor not found for tool: {tool_name}")
|
||||||
|
|
||||||
|
# 检查是否跳过
|
||||||
|
if await self._hook_executor.execute_skip_check(context):
|
||||||
|
return {"skipped": True, "tool": tool_name}
|
||||||
|
|
||||||
|
# 执行 pre-hooks
|
||||||
|
continue_execution, modified_input = await self._hook_executor.execute_pre_hooks(context)
|
||||||
|
if not continue_execution:
|
||||||
|
return {"pre_hook_aborted": True, "tool": tool_name}
|
||||||
|
|
||||||
|
# 执行工具
|
||||||
|
try:
|
||||||
|
context.start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# 判断是同步还是异步执行
|
||||||
|
if asyncio.iscoroutinefunction(executor):
|
||||||
|
result = await executor(**modified_input)
|
||||||
|
else:
|
||||||
|
# 同步函数在线程池中执行
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await loop.run_in_executor(None, lambda: executor(**modified_input))
|
||||||
|
|
||||||
|
context.result = result
|
||||||
|
context.end_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# 执行 post-hooks
|
||||||
|
result = await self._hook_executor.execute_post_hooks(context, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
context.error = e
|
||||||
|
context.end_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# 执行 error-hooks
|
||||||
|
error_result = await self._hook_executor.execute_error_hooks(context, e)
|
||||||
|
if error_result is not None:
|
||||||
|
return error_result
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def execute_streaming(
|
||||||
|
self,
|
||||||
|
tool_name: str,
|
||||||
|
tool_input: dict[str, Any],
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> AsyncGenerator[dict[str, Any], None]:
|
||||||
|
"""执行流式工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_name: 工具名称
|
||||||
|
tool_input: 工具输入参数
|
||||||
|
user_id: 用户 ID(可选)
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
流式输出片段
|
||||||
|
"""
|
||||||
|
# 创建执行上下文
|
||||||
|
context = ExecutionContext(
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool_input=tool_input,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取工具和执行器
|
||||||
|
manifest = self._registry.get(tool_name)
|
||||||
|
if manifest is None:
|
||||||
|
raise ValueError(f"Tool not found: {tool_name}")
|
||||||
|
|
||||||
|
if not manifest.is_streaming:
|
||||||
|
raise ValueError(f"Tool is not streaming: {tool_name}")
|
||||||
|
|
||||||
|
executor = self._registry.get_executor(tool_name)
|
||||||
|
if executor is None:
|
||||||
|
raise ValueError(f"Executor not found for tool: {tool_name}")
|
||||||
|
|
||||||
|
# 检查是否跳过
|
||||||
|
if await self._hook_executor.execute_skip_check(context):
|
||||||
|
yield {"type": "skipped", "tool": tool_name}
|
||||||
|
return
|
||||||
|
|
||||||
|
# 执行 pre-hooks
|
||||||
|
continue_execution, modified_input = await self._hook_executor.execute_pre_hooks(context)
|
||||||
|
if not continue_execution:
|
||||||
|
yield {"type": "pre_hook_aborted", "tool": tool_name}
|
||||||
|
return
|
||||||
|
|
||||||
|
# 执行流式工具
|
||||||
|
try:
|
||||||
|
context.start_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
# 调用执行器(应该返回 AsyncGenerator)
|
||||||
|
result = executor(**modified_input)
|
||||||
|
|
||||||
|
# 如果是 async generator
|
||||||
|
if asyncio.isasyncgen(result):
|
||||||
|
async for chunk in result:
|
||||||
|
yield {"type": "chunk", "data": chunk}
|
||||||
|
else:
|
||||||
|
# 普通协程
|
||||||
|
data = await result
|
||||||
|
yield {"type": "chunk", "data": data}
|
||||||
|
|
||||||
|
context.end_time = asyncio.get_event_loop().time()
|
||||||
|
yield {"type": "done", "tool": tool_name}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
context.error = e
|
||||||
|
context.end_time = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
yield {"type": "error", "error": str(e), "tool": tool_name}
|
||||||
|
|
||||||
|
# 执行 error-hooks
|
||||||
|
await self._hook_executor.execute_error_hooks(context, e)
|
||||||
|
|
||||||
|
async def execute_batch(
|
||||||
|
self,
|
||||||
|
tool_calls: list[dict[str, Any]],
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> list[Any]:
|
||||||
|
"""批量执行工具
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tool_calls: 工具调用列表,每个元素包含 tool_name 和 tool_input
|
||||||
|
user_id: 用户 ID(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
执行结果列表
|
||||||
|
"""
|
||||||
|
tasks = []
|
||||||
|
for call in tool_calls:
|
||||||
|
tool_name = call.get("tool_name") or call.get("name")
|
||||||
|
tool_input = call.get("tool_input") or call.get("input") or {}
|
||||||
|
tasks.append(self.execute(tool_name, tool_input, user_id))
|
||||||
|
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_executor: StreamingToolExecutor | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_streaming_executor() -> StreamingToolExecutor:
|
||||||
|
"""获取全局流式执行器
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
全局 StreamingToolExecutor 实例
|
||||||
|
"""
|
||||||
|
global _executor
|
||||||
|
if _executor is None:
|
||||||
|
_executor = StreamingToolExecutor()
|
||||||
|
return _executor
|
||||||
113
backend/app/agents/transport/remote.py
Normal file
113
backend/app/agents/transport/remote.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""远程传输层 - Phase 10.2"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructuredMessage:
|
||||||
|
"""结构化消息"""
|
||||||
|
|
||||||
|
type: str # response, event, tool_call, error
|
||||||
|
data: dict[str, Any]
|
||||||
|
session_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteTransport:
|
||||||
|
"""远程传输层
|
||||||
|
|
||||||
|
处理与远程 Agent 的通信。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._connections: dict[str, Any] = {}
|
||||||
|
self._handlers: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def send_response(self, session_id: str, response: dict[str, Any]) -> bool:
|
||||||
|
"""发送响应
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
response: 响应数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
message = StructuredMessage(
|
||||||
|
type="response",
|
||||||
|
data=response,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return await self._send(session_id, message)
|
||||||
|
|
||||||
|
async def send_event(self, session_id: str, event: dict[str, Any]) -> bool:
|
||||||
|
"""发送事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
event: 事件数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
message = StructuredMessage(
|
||||||
|
type="event",
|
||||||
|
data=event,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return await self._send(session_id, message)
|
||||||
|
|
||||||
|
async def send_tool_call(self, session_id: str, tool_call: dict[str, Any]) -> bool:
|
||||||
|
"""发送工具调用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
tool_call: 工具调用数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
message = StructuredMessage(
|
||||||
|
type="tool_call",
|
||||||
|
data=tool_call,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return await self._send(session_id, message)
|
||||||
|
|
||||||
|
async def _send(self, session_id: str, message: StructuredMessage) -> bool:
|
||||||
|
"""内部发送方法"""
|
||||||
|
if session_id not in self._connections:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = self._connections[session_id]
|
||||||
|
if hasattr(connection, "send"):
|
||||||
|
await connection.send(json.dumps(message.__dict__))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def register_handler(self, event_type: str, handler: Any) -> None:
|
||||||
|
"""注册消息处理器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: 事件类型
|
||||||
|
handler: 处理函数
|
||||||
|
"""
|
||||||
|
self._handlers[event_type] = handler
|
||||||
|
|
||||||
|
async def handle_message(self, session_id: str, message: dict[str, Any]) -> None:
|
||||||
|
"""处理收到的消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
message: 消息数据
|
||||||
|
"""
|
||||||
|
msg_type = message.get("type")
|
||||||
|
handler = self._handlers.get(msg_type)
|
||||||
|
if handler:
|
||||||
|
await handler(session_id, message.get("data"))
|
||||||
86
backend/app/agents/transport/structured_io.py
Normal file
86
backend/app/agents/transport/structured_io.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Structured IO for typed input/output - Phase 10.2"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructuredInput:
|
||||||
|
"""Structured input wrapper"""
|
||||||
|
|
||||||
|
skill_name: str
|
||||||
|
parameters: dict[str, Any]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructuredOutput:
|
||||||
|
"""Structured output wrapper"""
|
||||||
|
|
||||||
|
skill_name: str
|
||||||
|
result: Any
|
||||||
|
success: bool
|
||||||
|
error: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StructuredIO:
|
||||||
|
"""Handles structured input/output for agent communication"""
|
||||||
|
|
||||||
|
def parse_input(self, data: dict[str, Any]) -> StructuredInput:
|
||||||
|
"""Parse structured input from dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing skill_name, parameters, and metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StructuredInput instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required fields are missing
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("Input data must be a dictionary")
|
||||||
|
|
||||||
|
skill_name = data.get("skill_name")
|
||||||
|
if not skill_name:
|
||||||
|
raise ValueError("Missing required field: skill_name")
|
||||||
|
if not isinstance(skill_name, str):
|
||||||
|
raise ValueError("skill_name must be a string")
|
||||||
|
|
||||||
|
parameters = data.get("parameters")
|
||||||
|
if parameters is None:
|
||||||
|
raise ValueError("Missing required field: parameters")
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
raise ValueError("parameters must be a dictionary")
|
||||||
|
|
||||||
|
metadata = data.get("metadata", {})
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise ValueError("metadata must be a dictionary")
|
||||||
|
|
||||||
|
return StructuredInput(skill_name=skill_name, parameters=parameters, metadata=metadata)
|
||||||
|
|
||||||
|
def format_output(self, output: StructuredOutput) -> dict[str, Any]:
|
||||||
|
"""Format structured output to dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output: StructuredOutput instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary representation of the output
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"skill_name": output.skill_name,
|
||||||
|
"result": output.result,
|
||||||
|
"success": output.success,
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.error is not None:
|
||||||
|
result["error"] = output.error
|
||||||
|
|
||||||
|
if output.metadata is not None:
|
||||||
|
result["metadata"] = output.metadata
|
||||||
|
|
||||||
|
return result
|
||||||
207
backend/app/agents/transport/websocket.py
Normal file
207
backend/app/agents/transport/websocket.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""WebSocket 连接管理 - Phase 10.2
|
||||||
|
|
||||||
|
管理 WebSocket 连接的生命周期。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WSConnection:
|
||||||
|
"""WebSocket 连接"""
|
||||||
|
|
||||||
|
session_id: str
|
||||||
|
websocket: Any # WebSocket 连接
|
||||||
|
user_id: str | None = None
|
||||||
|
created_at: float | None = None
|
||||||
|
last_ping: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketManager:
|
||||||
|
"""WebSocket 连接管理器
|
||||||
|
|
||||||
|
管理所有 WebSocket 连接的生命周期。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ping_interval: float = 30.0):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
ping_interval: 心跳间隔(秒)
|
||||||
|
"""
|
||||||
|
self._connections: dict[str, WSConnection] = {}
|
||||||
|
self._handlers: dict[str, Callable] = {}
|
||||||
|
self._ping_interval = ping_interval
|
||||||
|
self._ping_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
async def connect(self, session_id: str, websocket: Any, user_id: str | None = None) -> bool:
|
||||||
|
"""建立连接
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
websocket: WebSocket 连接
|
||||||
|
user_id: 用户 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否连接成功
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
if session_id in self._connections:
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = WSConnection(
|
||||||
|
session_id=session_id,
|
||||||
|
websocket=websocket,
|
||||||
|
user_id=user_id,
|
||||||
|
created_at=time.time(),
|
||||||
|
last_ping=time.time(),
|
||||||
|
)
|
||||||
|
self._connections[session_id] = conn
|
||||||
|
|
||||||
|
# 启动心跳
|
||||||
|
self._ping_tasks[session_id] = asyncio.create_task(self._ping_loop(session_id))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def disconnect(self, session_id: str) -> bool:
|
||||||
|
"""断开连接
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否断开成功
|
||||||
|
"""
|
||||||
|
if session_id not in self._connections:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 停止心跳
|
||||||
|
if session_id in self._ping_tasks:
|
||||||
|
self._ping_tasks[session_id].cancel()
|
||||||
|
del self._ping_tasks[session_id]
|
||||||
|
|
||||||
|
del self._connections[session_id]
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def send(self, session_id: str, message: dict[str, Any]) -> bool:
|
||||||
|
"""发送消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
message: 消息内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否发送成功
|
||||||
|
"""
|
||||||
|
if session_id not in self._connections:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = self._connections[session_id]
|
||||||
|
await conn.websocket.send_json(message)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict[str, Any]) -> int:
|
||||||
|
"""广播消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: 消息内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
发送成功的数量
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
for session_id in list(self._connections.keys()):
|
||||||
|
if await self.send(session_id, message):
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def _ping_loop(self, session_id: str) -> None:
|
||||||
|
"""心跳循环
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
while session_id in self._connections:
|
||||||
|
await asyncio.sleep(self._ping_interval)
|
||||||
|
|
||||||
|
if session_id not in self._connections:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = self._connections[session_id]
|
||||||
|
await conn.websocket.send_json({"type": "ping", "timestamp": time.time()})
|
||||||
|
conn.last_ping = time.time()
|
||||||
|
except Exception:
|
||||||
|
await self.disconnect(session_id)
|
||||||
|
break
|
||||||
|
|
||||||
|
def register_handler(self, event_type: str, handler: Callable) -> None:
|
||||||
|
"""注册消息处理器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: 事件类型
|
||||||
|
handler: 处理函数
|
||||||
|
"""
|
||||||
|
self._handlers[event_type] = handler
|
||||||
|
|
||||||
|
async def handle_message(self, session_id: str, message: dict[str, Any]) -> None:
|
||||||
|
"""处理消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
message: 消息内容
|
||||||
|
"""
|
||||||
|
msg_type = message.get("type")
|
||||||
|
handler = self._handlers.get(msg_type)
|
||||||
|
if handler:
|
||||||
|
await handler(session_id, message.get("data"))
|
||||||
|
|
||||||
|
def get_connection(self, session_id: str) -> WSConnection | None:
|
||||||
|
"""获取连接
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
连接信息或 None
|
||||||
|
"""
|
||||||
|
return self._connections.get(session_id)
|
||||||
|
|
||||||
|
def list_connections(self) -> list[WSConnection]:
|
||||||
|
"""列出所有连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
连接列表
|
||||||
|
"""
|
||||||
|
return list(self._connections.values())
|
||||||
|
|
||||||
|
def is_connected(self, session_id: str) -> bool:
|
||||||
|
"""检查是否连接
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: 会话 ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否已连接
|
||||||
|
"""
|
||||||
|
return session_id in self._connections
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
_ws_manager: WebSocketManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_websocket_manager() -> WebSocketManager:
|
||||||
|
"""获取全局 WebSocket 管理器"""
|
||||||
|
global _ws_manager
|
||||||
|
if _ws_manager is None:
|
||||||
|
_ws_manager = WebSocketManager()
|
||||||
|
return _ws_manager
|
||||||
93
backend/app/agents/verifier.py
Normal file
93
backend/app/agents/verifier.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agents.schemas.task import AgentTask, TaskResult, TaskResultStatus, VerificationStatus
|
||||||
|
from app.agents.state import AgentState
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationVerdict(BaseModel):
|
||||||
|
status: VerificationStatus
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_task_result(
|
||||||
|
task_result: TaskResult | dict[str, Any],
|
||||||
|
*,
|
||||||
|
default_task_id: str | None = None,
|
||||||
|
) -> TaskResult:
|
||||||
|
payload = task_result.model_dump(mode="json") if isinstance(task_result, TaskResult) else dict(task_result or {})
|
||||||
|
normalized_status = payload.get("status")
|
||||||
|
if normalized_status not in {"completed", "failed", "blocked", "passed", "skipped"}:
|
||||||
|
normalized_status = "failed"
|
||||||
|
return TaskResult(
|
||||||
|
task_id=str(payload.get("task_id") or default_task_id or "unknown-task"),
|
||||||
|
status=cast(TaskResultStatus, normalized_status),
|
||||||
|
summary=payload.get("summary"),
|
||||||
|
evidence=list(payload.get("evidence") or []),
|
||||||
|
owner_agent_id=payload.get("owner_agent_id"),
|
||||||
|
parent_task_id=payload.get("parent_task_id"),
|
||||||
|
child_task_ids=list(payload.get("child_task_ids") or []),
|
||||||
|
thread_id=payload.get("thread_id"),
|
||||||
|
message_id=payload.get("message_id"),
|
||||||
|
message_index=payload.get("message_index") if isinstance(payload.get("message_index"), int) else None,
|
||||||
|
interrupt_records=list(payload.get("interrupt_records") or []),
|
||||||
|
recovery_records=list(payload.get("recovery_records") or []),
|
||||||
|
budget_snapshot=payload.get("budget_snapshot") if isinstance(payload.get("budget_snapshot"), dict) else None,
|
||||||
|
next_action=payload.get("next_action"),
|
||||||
|
output_data=payload.get("output_data") if isinstance(payload.get("output_data"), dict) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_task_result(
|
||||||
|
*,
|
||||||
|
task: AgentTask | dict[str, Any] | None = None,
|
||||||
|
result: TaskResult | dict[str, Any] | None = None,
|
||||||
|
summary: str | None = None,
|
||||||
|
evidence: list[dict[str, Any]] | None = None,
|
||||||
|
status: VerificationStatus | None = None,
|
||||||
|
) -> VerificationVerdict:
|
||||||
|
normalized_result = result.model_dump() if isinstance(result, TaskResult) else dict(result or {})
|
||||||
|
normalized_task = task.model_dump() if isinstance(task, AgentTask) else dict(task or {})
|
||||||
|
normalized_summary = summary or normalized_result.get("summary") or normalized_task.get("result_summary")
|
||||||
|
normalized_evidence = list(evidence or normalized_result.get("evidence") or normalized_task.get("evidence") or [])
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
return VerificationVerdict(status=status, summary=normalized_summary, evidence=normalized_evidence)
|
||||||
|
|
||||||
|
normalized_status = normalized_result.get("status")
|
||||||
|
if normalized_status in {"passed", "failed", "skipped"}:
|
||||||
|
inferred_status = normalized_status
|
||||||
|
elif normalized_status == "completed":
|
||||||
|
inferred_status = "passed"
|
||||||
|
elif normalized_status == "blocked":
|
||||||
|
inferred_status = "skipped"
|
||||||
|
elif normalized_result.get("success") is True:
|
||||||
|
inferred_status = "passed"
|
||||||
|
elif normalized_result.get("success") is False:
|
||||||
|
inferred_status = "failed"
|
||||||
|
elif normalized_summary or normalized_evidence:
|
||||||
|
inferred_status = "skipped"
|
||||||
|
else:
|
||||||
|
inferred_status = "failed"
|
||||||
|
normalized_summary = "No verification input available."
|
||||||
|
|
||||||
|
return VerificationVerdict(
|
||||||
|
status=inferred_status,
|
||||||
|
summary=normalized_summary,
|
||||||
|
evidence=normalized_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_verification_verdict(state: AgentState, verdict: VerificationVerdict) -> AgentState:
|
||||||
|
next_state = dict(state)
|
||||||
|
next_state["verification_status"] = verdict.status
|
||||||
|
next_state["verification_summary"] = verdict.summary
|
||||||
|
next_state["verification_evidence"] = list(verdict.evidence)
|
||||||
|
return AgentState(**next_state)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["VerificationVerdict", "apply_verification_verdict", "normalize_task_result", "verify_task_result"]
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
from sqlalchemy import text
|
from collections.abc import AsyncGenerator
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
from app.config import settings
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
os.makedirs(settings.DATA_DIR, exist_ok=True)
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
@@ -24,12 +27,9 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
try:
|
yield session
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
@@ -37,6 +37,7 @@ async def init_db():
|
|||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
await ensure_log_columns(conn)
|
await ensure_log_columns(conn)
|
||||||
await ensure_message_columns(conn)
|
await ensure_message_columns(conn)
|
||||||
|
await ensure_conversation_columns(conn)
|
||||||
await ensure_document_columns(conn)
|
await ensure_document_columns(conn)
|
||||||
await ensure_user_columns(conn)
|
await ensure_user_columns(conn)
|
||||||
await ensure_forum_columns(conn)
|
await ensure_forum_columns(conn)
|
||||||
@@ -79,6 +80,20 @@ async def ensure_message_columns(conn):
|
|||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_conversation_columns(conn):
|
||||||
|
rows = await _get_table_info(conn, 'conversations')
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {row[1] for row in rows}
|
||||||
|
required_columns = {
|
||||||
|
'agent_state': "ALTER TABLE conversations ADD COLUMN agent_state JSON",
|
||||||
|
}
|
||||||
|
for column, ddl in required_columns.items():
|
||||||
|
if column not in columns:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
|
||||||
|
|
||||||
async def ensure_document_columns(conn):
|
async def ensure_document_columns(conn):
|
||||||
result = await conn.execute(text("PRAGMA table_info(documents)"))
|
result = await conn.execute(text("PRAGMA table_info(documents)"))
|
||||||
rows = result.fetchall()
|
rows = result.fetchall()
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ from app.routers import (
|
|||||||
log_router,
|
log_router,
|
||||||
system_router,
|
system_router,
|
||||||
brain_router,
|
brain_router,
|
||||||
|
hooks_router,
|
||||||
|
plugins_router,
|
||||||
|
marketplace_router,
|
||||||
|
agent_skills_router,
|
||||||
|
agent_sessions_router,
|
||||||
)
|
)
|
||||||
from app.routers.scheduler import router as scheduler_router
|
from app.routers.scheduler import router as scheduler_router
|
||||||
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
from app.services.scheduler_service import start_scheduler, stop_scheduler, get_scheduler_status
|
||||||
@@ -40,15 +45,15 @@ import os
|
|||||||
|
|
||||||
|
|
||||||
INSECURE_SECRET_KEYS = {
|
INSECURE_SECRET_KEYS = {
|
||||||
'change-me-in-production',
|
"change-me-in-production",
|
||||||
'change-me-to-a-random-secret-key',
|
"change-me-to-a-random-secret-key",
|
||||||
'jarvis-secret-key-change-in-production',
|
"jarvis-secret-key-change-in-production",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_startup_security() -> None:
|
def validate_startup_security() -> None:
|
||||||
if not settings.DEBUG and settings.SECRET_KEY in INSECURE_SECRET_KEYS:
|
if not settings.DEBUG and settings.SECRET_KEY in INSECURE_SECRET_KEYS:
|
||||||
raise RuntimeError('SECRET_KEY must be changed before running with DEBUG disabled')
|
raise RuntimeError("SECRET_KEY must be changed before running with DEBUG disabled")
|
||||||
|
|
||||||
|
|
||||||
async def run_startup() -> None:
|
async def run_startup() -> None:
|
||||||
@@ -117,6 +122,11 @@ app.include_router(log_router)
|
|||||||
app.include_router(system_router)
|
app.include_router(system_router)
|
||||||
app.include_router(brain_router)
|
app.include_router(brain_router)
|
||||||
app.include_router(scheduler_router)
|
app.include_router(scheduler_router)
|
||||||
|
app.include_router(hooks_router)
|
||||||
|
app.include_router(plugins_router)
|
||||||
|
app.include_router(marketplace_router)
|
||||||
|
app.include_router(agent_skills_router)
|
||||||
|
app.include_router(agent_sessions_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -15,3 +15,8 @@ from app.routers.skill import router as skill_router
|
|||||||
from app.routers.log import router as log_router
|
from app.routers.log import router as log_router
|
||||||
from app.routers.system import router as system_router
|
from app.routers.system import router as system_router
|
||||||
from app.routers.brain import router as brain_router
|
from app.routers.brain import router as brain_router
|
||||||
|
from app.routers.hooks import router as hooks_router
|
||||||
|
from app.routers.plugins import router as plugins_router
|
||||||
|
from app.routers.plugins import _marketplace_router as marketplace_router
|
||||||
|
from app.routers.agent_skills import router as agent_skills_router
|
||||||
|
from app.routers.agent_sessions import router as agent_sessions_router
|
||||||
|
|||||||
@@ -1,12 +1,42 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from datetime import datetime
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.agents.registry import load_builtin_registry_indexes
|
||||||
|
from app.agents.runtime_metrics import coerce_cost_thresholds, estimate_token_cost, is_cost_budget_warning
|
||||||
from app.models.agent import Agent
|
from app.models.agent import Agent
|
||||||
|
from app.models.conversation import Conversation
|
||||||
from app.models.skill import Skill
|
from app.models.skill import Skill
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.schemas.agent import AgentCreate, AgentOut, AgentStats, AgentConfigUpdate, AgentConfigOut
|
from app.schemas.agent import (
|
||||||
|
AgentConfigOut,
|
||||||
|
AgentConfigUpdate,
|
||||||
|
AgentCreate,
|
||||||
|
AgentOut,
|
||||||
|
AgentStats,
|
||||||
|
AgentVisibilityCostByAgentOut,
|
||||||
|
AgentVisibilityCostOut,
|
||||||
|
AgentVisibilityCostSummaryOut,
|
||||||
|
AgentVisibilityEvidenceOut,
|
||||||
|
AgentVisibilityEventsResponse,
|
||||||
|
AgentVisibilityEventOut,
|
||||||
|
AgentVisibilityIsolationOut,
|
||||||
|
AgentVisibilityRuntimeSummaryOut,
|
||||||
|
AgentVisibilityTaskSummaryOut,
|
||||||
|
AgentVisibilityThreadMessageOut,
|
||||||
|
AgentVisibilityThreadOut,
|
||||||
|
AgentVisibilityTopologyNodeOut,
|
||||||
|
AgentVisibilityTopologyOut,
|
||||||
|
AgentVisibilityToolGovernanceItemOut,
|
||||||
|
AgentVisibilityToolGovernanceOut,
|
||||||
|
AgentVisibilityVerifierOut,
|
||||||
|
)
|
||||||
|
from app.services.agent_service import _extract_continuity_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
router = APIRouter(prefix="/api/agents", tags=["Agent"])
|
||||||
|
|
||||||
@@ -21,6 +51,295 @@ SUB_COMMANDERS_BY_ROLE = {
|
|||||||
"librarian": ["librarian_retrieval", "librarian_graph"],
|
"librarian": ["librarian_retrieval", "librarian_graph"],
|
||||||
"analyst": ["analyst_progress", "analyst_insights"],
|
"analyst": ["analyst_progress", "analyst_insights"],
|
||||||
}
|
}
|
||||||
|
ALLOWED_AGENT_ROLES = set(DEFAULT_AGENT_ROLES) | {
|
||||||
|
role
|
||||||
|
for sub_roles in SUB_COMMANDERS_BY_ROLE.values()
|
||||||
|
for role in sub_roles
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_visibility_datetime(value: str | None) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="时间参数必须是 ISO 8601 格式") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_visibility_state(
|
||||||
|
conversation_id: str,
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Conversation).where(
|
||||||
|
Conversation.id == conversation_id,
|
||||||
|
Conversation.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conversation = result.scalar_one_or_none()
|
||||||
|
if conversation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="对话不存在")
|
||||||
|
snapshot = _extract_continuity_snapshot(conversation.agent_state)
|
||||||
|
if snapshot is None:
|
||||||
|
raise HTTPException(status_code=404, detail="当前会话暂无可视化运行时数据")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_event_payload(event: dict[str, Any]) -> AgentVisibilityEventOut:
|
||||||
|
return AgentVisibilityEventOut.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_events(
|
||||||
|
events: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
agent_id: str | None,
|
||||||
|
thread_id: str | None,
|
||||||
|
event_type: str | None,
|
||||||
|
started_after: datetime | None,
|
||||||
|
ended_before: datetime | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
filtered: list[dict[str, Any]] = []
|
||||||
|
for event in events:
|
||||||
|
if agent_id and event.get("agent_id") != agent_id:
|
||||||
|
continue
|
||||||
|
if thread_id and event.get("thread_id") != thread_id:
|
||||||
|
continue
|
||||||
|
if event_type and event.get("event_type") != event_type:
|
||||||
|
continue
|
||||||
|
timestamp_raw = event.get("timestamp")
|
||||||
|
timestamp = None
|
||||||
|
if isinstance(timestamp_raw, str):
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(timestamp_raw.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
timestamp = None
|
||||||
|
if started_after and timestamp and timestamp < started_after:
|
||||||
|
continue
|
||||||
|
if ended_before and timestamp and timestamp > ended_before:
|
||||||
|
continue
|
||||||
|
filtered.append(event)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_tasks(tasks: list[dict[str, Any]], task_results: list[dict[str, Any]]) -> list[AgentVisibilityTaskSummaryOut]:
|
||||||
|
result_by_task_id = {item.get("task_id"): item for item in task_results}
|
||||||
|
summaries: list[AgentVisibilityTaskSummaryOut] = []
|
||||||
|
for task in tasks:
|
||||||
|
task_id = str(task.get("task_id") or "")
|
||||||
|
result = result_by_task_id.get(task_id) or {}
|
||||||
|
evidence = result.get("evidence") or task.get("evidence") or []
|
||||||
|
summaries.append(
|
||||||
|
AgentVisibilityTaskSummaryOut(
|
||||||
|
task_id=task_id,
|
||||||
|
role=task.get("role"),
|
||||||
|
owner_agent_id=task.get("owner_agent_id") or result.get("owner_agent_id"),
|
||||||
|
status=result.get("status") or task.get("status"),
|
||||||
|
summary=result.get("summary") or task.get("result_summary"),
|
||||||
|
evidence_count=len(evidence),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
def _build_topology_nodes(
|
||||||
|
state: dict[str, Any],
|
||||||
|
tasks: list[dict[str, Any]],
|
||||||
|
task_results: list[dict[str, Any]],
|
||||||
|
) -> list[AgentVisibilityTopologyNodeOut]:
|
||||||
|
task_counts: dict[str, int] = {}
|
||||||
|
completed_counts: dict[str, int] = {}
|
||||||
|
for task in tasks:
|
||||||
|
owner = str(task.get("owner_agent_id") or "")
|
||||||
|
if owner:
|
||||||
|
task_counts[owner] = task_counts.get(owner, 0) + 1
|
||||||
|
for result in task_results:
|
||||||
|
owner = str(result.get("owner_agent_id") or "")
|
||||||
|
if owner and result.get("status") == "completed":
|
||||||
|
completed_counts[owner] = completed_counts.get(owner, 0) + 1
|
||||||
|
|
||||||
|
root_agent_id = str(state.get("root_agent_id") or state.get("agent_id") or "") or None
|
||||||
|
current_agent = str(state.get("current_agent") or "") or None
|
||||||
|
parent_agent_id = str(state.get("parent_agent_id") or "") or None
|
||||||
|
nodes: dict[str, AgentVisibilityTopologyNodeOut] = {}
|
||||||
|
if root_agent_id:
|
||||||
|
nodes[root_agent_id] = AgentVisibilityTopologyNodeOut(
|
||||||
|
agent_id=root_agent_id,
|
||||||
|
role=root_agent_id.split("-")[0],
|
||||||
|
parent_agent_id=parent_agent_id if root_agent_id != state.get("agent_id") else None,
|
||||||
|
source="root",
|
||||||
|
task_count=task_counts.get(root_agent_id, 0),
|
||||||
|
completed_task_count=completed_counts.get(root_agent_id, 0),
|
||||||
|
)
|
||||||
|
for agent_id in state.get("spawned_agent_ids") or []:
|
||||||
|
agent_id = str(agent_id)
|
||||||
|
nodes[agent_id] = AgentVisibilityTopologyNodeOut(
|
||||||
|
agent_id=agent_id,
|
||||||
|
role=agent_id.split("-")[0],
|
||||||
|
parent_agent_id=root_agent_id,
|
||||||
|
source="spawned",
|
||||||
|
task_count=task_counts.get(agent_id, 0),
|
||||||
|
completed_task_count=completed_counts.get(agent_id, 0),
|
||||||
|
)
|
||||||
|
if current_agent and current_agent not in nodes:
|
||||||
|
nodes[current_agent] = AgentVisibilityTopologyNodeOut(
|
||||||
|
agent_id=current_agent,
|
||||||
|
role=current_agent.split("-")[0],
|
||||||
|
parent_agent_id=None if current_agent == root_agent_id else root_agent_id,
|
||||||
|
source="current",
|
||||||
|
task_count=task_counts.get(current_agent, 0),
|
||||||
|
completed_task_count=completed_counts.get(current_agent, 0),
|
||||||
|
)
|
||||||
|
return list(nodes.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_runtime_cost(input_tokens: int, output_tokens: int) -> float | None:
|
||||||
|
return estimate_token_cost(input_tokens, output_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cost_summary(
|
||||||
|
state: dict[str, Any],
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> AgentVisibilityCostSummaryOut:
|
||||||
|
input_tokens = int(state.get("input_tokens") or 0)
|
||||||
|
output_tokens = int(state.get("output_tokens") or 0)
|
||||||
|
estimated_cost = _estimate_runtime_cost(input_tokens, output_tokens)
|
||||||
|
thresholds = coerce_cost_thresholds(state.get("cost_thresholds"))
|
||||||
|
total_budget_warning = bool(state.get("budget_warning") or False) or is_cost_budget_warning(
|
||||||
|
input_tokens,
|
||||||
|
output_tokens,
|
||||||
|
estimated_cost,
|
||||||
|
thresholds,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_agent_items: list[AgentVisibilityCostByAgentOut] = []
|
||||||
|
for agent_id, payload in dict(state.get("cost_by_agent") or {}).items():
|
||||||
|
payload_dict = dict(payload or {})
|
||||||
|
agent_input_tokens = int(payload_dict.get("input_tokens") or 0)
|
||||||
|
agent_output_tokens = int(payload_dict.get("output_tokens") or 0)
|
||||||
|
agent_estimated_cost = payload_dict.get("estimated_cost")
|
||||||
|
if agent_estimated_cost is None:
|
||||||
|
agent_estimated_cost = _estimate_runtime_cost(agent_input_tokens, agent_output_tokens)
|
||||||
|
by_agent_items.append(
|
||||||
|
AgentVisibilityCostByAgentOut(
|
||||||
|
agent_id=str(payload_dict.get("agent_id") or agent_id),
|
||||||
|
input_tokens=agent_input_tokens,
|
||||||
|
output_tokens=agent_output_tokens,
|
||||||
|
total_tokens=int(payload_dict.get("total_tokens") or (agent_input_tokens + agent_output_tokens)),
|
||||||
|
estimated_cost=agent_estimated_cost,
|
||||||
|
budget_warning=bool(payload_dict.get("budget_warning") or False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
by_agent_items.sort(key=lambda item: item.total_tokens, reverse=True)
|
||||||
|
|
||||||
|
return AgentVisibilityCostSummaryOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
total=AgentVisibilityCostOut(
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
total_tokens=input_tokens + output_tokens,
|
||||||
|
estimated_cost=estimated_cost,
|
||||||
|
budget_warning=total_budget_warning,
|
||||||
|
),
|
||||||
|
thresholds=thresholds,
|
||||||
|
by_agent=by_agent_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool_governance(
|
||||||
|
state: dict[str, Any],
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> AgentVisibilityToolGovernanceOut:
|
||||||
|
indexes = load_builtin_registry_indexes()
|
||||||
|
tool_outcomes = [dict(item) for item in state.get("tool_outcomes") or [] if isinstance(item, dict)]
|
||||||
|
usage_count_by_tool: dict[str, int] = {}
|
||||||
|
last_result_preview_by_tool: dict[str, str | None] = {}
|
||||||
|
for item in tool_outcomes:
|
||||||
|
tool_name = str(item.get("tool_name") or "")
|
||||||
|
if tool_name == "search_web":
|
||||||
|
tool_name = "web_search"
|
||||||
|
if not tool_name:
|
||||||
|
continue
|
||||||
|
usage_count_by_tool[tool_name] = usage_count_by_tool.get(tool_name, 0) + 1
|
||||||
|
preview = item.get("result_preview")
|
||||||
|
if isinstance(preview, str) and preview:
|
||||||
|
last_result_preview_by_tool[tool_name] = preview
|
||||||
|
|
||||||
|
items = [
|
||||||
|
AgentVisibilityToolGovernanceItemOut(
|
||||||
|
capability_id=capability.capability_id,
|
||||||
|
tool_name=capability.tool_name,
|
||||||
|
permission_class=capability.permission_class.value,
|
||||||
|
side_effect_scope=capability.side_effect_scope.value,
|
||||||
|
supports_retry=capability.supports_retry,
|
||||||
|
idempotent=capability.idempotent,
|
||||||
|
safe_for_parallel_use=capability.safe_for_parallel_use,
|
||||||
|
requires_confirmation=capability.requires_confirmation,
|
||||||
|
usage_count=usage_count_by_tool.get(capability.tool_name, 0),
|
||||||
|
last_result_preview=last_result_preview_by_tool.get(capability.tool_name),
|
||||||
|
)
|
||||||
|
for capability in indexes.capability_by_id.values()
|
||||||
|
]
|
||||||
|
items.sort(key=lambda item: (-item.usage_count, item.tool_name))
|
||||||
|
|
||||||
|
return AgentVisibilityToolGovernanceOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
total_tools=len(items),
|
||||||
|
used_tools=sum(1 for item in items if item.usage_count > 0),
|
||||||
|
items=items,
|
||||||
|
upgrade_candidates=[
|
||||||
|
"worktree_manager",
|
||||||
|
"cost_inspector",
|
||||||
|
"runtime_event_drilldown",
|
||||||
|
"tool_policy_explorer",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_runtime_summary(
|
||||||
|
state: dict[str, Any],
|
||||||
|
*,
|
||||||
|
conversation_id: str,
|
||||||
|
) -> AgentVisibilityRuntimeSummaryOut:
|
||||||
|
tasks = [dict(item) for item in state.get("active_tasks") or []]
|
||||||
|
task_results = [dict(item) for item in state.get("task_results") or []]
|
||||||
|
topology_nodes = _build_topology_nodes(state, tasks, task_results)
|
||||||
|
cost_summary = _build_cost_summary(state, conversation_id=conversation_id)
|
||||||
|
input_tokens = cost_summary.total.input_tokens
|
||||||
|
output_tokens = cost_summary.total.output_tokens
|
||||||
|
recent_events_raw = [dict(item) for item in (state.get("event_trace") or [])[-10:]]
|
||||||
|
isolation_mode = str(state.get("isolation_mode") or "none")
|
||||||
|
|
||||||
|
return AgentVisibilityRuntimeSummaryOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
execution_mode=state.get("execution_mode"),
|
||||||
|
current_phase=state.get("current_phase"),
|
||||||
|
current_checkpoint=state.get("current_checkpoint"),
|
||||||
|
phase_history=list(state.get("phase_history") or []),
|
||||||
|
checkpoint_history=list(state.get("checkpoint_history") or []),
|
||||||
|
verifier=AgentVisibilityVerifierOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
status=state.get("verification_status"),
|
||||||
|
summary=state.get("verification_summary"),
|
||||||
|
evidence=list(state.get("verification_evidence") or []),
|
||||||
|
),
|
||||||
|
isolation=AgentVisibilityIsolationOut(
|
||||||
|
mode=isolation_mode,
|
||||||
|
isolation_id=state.get("isolation_id"),
|
||||||
|
workspace_path=state.get("isolation_workspace_path"),
|
||||||
|
parent_conversation_id=state.get("isolation_parent_conversation_id") or state.get("parent_conversation_id"),
|
||||||
|
metadata=dict(state.get("isolation_metadata") or {}),
|
||||||
|
),
|
||||||
|
cost=cost_summary.total,
|
||||||
|
topology_node_count=len(topology_nodes),
|
||||||
|
active_task_count=len(tasks),
|
||||||
|
completed_task_count=sum(1 for item in task_results if item.get("status") == "completed"),
|
||||||
|
recent_events=[_coerce_event_payload(item) for item in recent_events_raw],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def record_agent_call(agent_id: str):
|
def record_agent_call(agent_id: str):
|
||||||
@@ -83,6 +402,7 @@ async def get_agent_hierarchy_stats(
|
|||||||
@router.get("/config/{agent_id}", response_model=AgentConfigOut)
|
@router.get("/config/{agent_id}", response_model=AgentConfigOut)
|
||||||
async def get_agent_config(
|
async def get_agent_config(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Agent).where(Agent.role == agent_id))
|
result = await db.execute(select(Agent).where(Agent.role == agent_id))
|
||||||
@@ -172,12 +492,189 @@ async def update_agent_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/events", response_model=AgentVisibilityEventsResponse)
|
||||||
|
async def get_visibility_events(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
agent_id: str | None = None,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
event_type: str | None = None,
|
||||||
|
started_after: str | None = None,
|
||||||
|
ended_before: str | None = None,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
events = [dict(item) for item in state.get("event_trace") or []]
|
||||||
|
filtered = _filter_events(
|
||||||
|
events,
|
||||||
|
agent_id=agent_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
event_type=event_type,
|
||||||
|
started_after=_parse_visibility_datetime(started_after),
|
||||||
|
ended_before=_parse_visibility_datetime(ended_before),
|
||||||
|
)
|
||||||
|
paged = filtered[offset:offset + limit]
|
||||||
|
return AgentVisibilityEventsResponse(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
total=len(filtered),
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
items=[_coerce_event_payload(item) for item in paged],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/topology", response_model=AgentVisibilityTopologyOut)
|
||||||
|
async def get_visibility_topology(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
tasks = [dict(item) for item in state.get("active_tasks") or []]
|
||||||
|
task_results = [dict(item) for item in state.get("task_results") or []]
|
||||||
|
nodes = _build_topology_nodes(state, tasks, task_results)
|
||||||
|
root_agent_id = str(state.get("root_agent_id") or state.get("agent_id") or "") or None
|
||||||
|
edges = [
|
||||||
|
{"parent_agent_id": root_agent_id, "child_agent_id": node.agent_id}
|
||||||
|
for node in nodes
|
||||||
|
if node.parent_agent_id and root_agent_id and node.agent_id != root_agent_id
|
||||||
|
]
|
||||||
|
return AgentVisibilityTopologyOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
root_agent_id=root_agent_id,
|
||||||
|
current_agent=str(state.get("current_agent") or "") or None,
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
tasks=_summarize_tasks(tasks, task_results),
|
||||||
|
task_hierarchy=dict(state.get("task_hierarchy") or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/tasks/{task_id}/evidence", response_model=AgentVisibilityEvidenceOut)
|
||||||
|
async def get_visibility_task_evidence(
|
||||||
|
task_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
tasks = [dict(item) for item in state.get("active_tasks") or []]
|
||||||
|
task = next((item for item in tasks if item.get("task_id") == task_id), None)
|
||||||
|
task_results = [dict(item) for item in state.get("task_results") or []]
|
||||||
|
result = next((item for item in task_results if item.get("task_id") == task_id), None)
|
||||||
|
if task is None and result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
|
tool_outcomes = [
|
||||||
|
dict(evidence)
|
||||||
|
for evidence in (result or {}).get("evidence") or []
|
||||||
|
if isinstance(evidence, dict) and evidence.get("tool_name")
|
||||||
|
]
|
||||||
|
verification_entry = next(
|
||||||
|
(
|
||||||
|
dict(evidence)
|
||||||
|
for evidence in (result or {}).get("evidence") or []
|
||||||
|
if isinstance(evidence, dict) and evidence.get("type") == "verification"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
verifier = {
|
||||||
|
"status": (verification_entry or {}).get("status"),
|
||||||
|
"summary": (verification_entry or {}).get("summary"),
|
||||||
|
"evidence": [dict(item) for item in state.get("verification_evidence") or [] if item.get("task_id") == task_id],
|
||||||
|
}
|
||||||
|
return AgentVisibilityEvidenceOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
task_id=task_id,
|
||||||
|
task=task,
|
||||||
|
result=result,
|
||||||
|
tool_outcomes=tool_outcomes,
|
||||||
|
verifier=verifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/threads/{thread_id}/messages", response_model=AgentVisibilityThreadOut)
|
||||||
|
async def get_visibility_thread_messages(
|
||||||
|
thread_id: str,
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
items = [
|
||||||
|
AgentVisibilityThreadMessageOut.model_validate(item)
|
||||||
|
for item in state.get("message_trace") or []
|
||||||
|
if isinstance(item, dict) and item.get("thread_id") == thread_id
|
||||||
|
]
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=404, detail="线程不存在")
|
||||||
|
return AgentVisibilityThreadOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
total=len(items),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/verifier", response_model=AgentVisibilityVerifierOut)
|
||||||
|
async def get_visibility_verifier(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
return AgentVisibilityVerifierOut(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
status=state.get("verification_status"),
|
||||||
|
summary=state.get("verification_summary"),
|
||||||
|
evidence=list(state.get("verification_evidence") or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/runtime-summary", response_model=AgentVisibilityRuntimeSummaryOut)
|
||||||
|
async def get_visibility_runtime_summary(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
return _build_runtime_summary(state, conversation_id=conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/cost", response_model=AgentVisibilityCostSummaryOut)
|
||||||
|
async def get_visibility_cost(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
return _build_cost_summary(state, conversation_id=conversation_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/visibility/tools", response_model=AgentVisibilityToolGovernanceOut)
|
||||||
|
async def get_visibility_tools(
|
||||||
|
conversation_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
state = await _get_visibility_state(conversation_id, current_user=current_user, db=db)
|
||||||
|
return _build_tool_governance(state, conversation_id=conversation_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AgentOut, status_code=201)
|
@router.post("", response_model=AgentOut, status_code=201)
|
||||||
async def create_agent(
|
async def create_agent(
|
||||||
data: AgentCreate,
|
data: AgentCreate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
raise HTTPException(status_code=403, detail="仅管理员可创建 Agent")
|
||||||
|
if not data.spawn_permission:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少 spawn_permission,禁止直接创建 runtime agent")
|
||||||
|
if data.role not in ALLOWED_AGENT_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的 Agent 角色")
|
||||||
|
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
role=data.role,
|
role=data.role,
|
||||||
@@ -193,6 +690,7 @@ async def create_agent(
|
|||||||
@router.get("/{agent_id}", response_model=AgentOut)
|
@router.get("/{agent_id}", response_model=AgentOut)
|
||||||
async def get_agent(
|
async def get_agent(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||||
|
|||||||
113
backend/app/routers/agent_sessions.py
Normal file
113
backend/app/routers/agent_sessions.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Agent Session API 路由 - Phase 10.3"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.agents.session.manager import AgentSession, create_agent_session, get_agent_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent/sessions", tags=["Agent Sessions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=dict[str, Any])
|
||||||
|
async def create_session(
|
||||||
|
user_id: str | None = None,
|
||||||
|
parent_session_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""创建新会话"""
|
||||||
|
session = create_agent_session(
|
||||||
|
user_id=user_id,
|
||||||
|
parent_session_id=parent_session_id,
|
||||||
|
)
|
||||||
|
return await session.initialize()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_model=dict[str, Any])
|
||||||
|
async def get_session(session_id: str) -> dict[str, Any]:
|
||||||
|
"""获取会话信息"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
return await session.get_session_summary()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/message", response_model=dict[str, str])
|
||||||
|
async def process_message(
|
||||||
|
session_id: str,
|
||||||
|
message: str,
|
||||||
|
response: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""处理消息"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
await session.process_message(message, response)
|
||||||
|
return {"status": "recorded", "session_id": session_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/spawn", response_model=dict[str, Any])
|
||||||
|
async def spawn_child_session(
|
||||||
|
session_id: str,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""创建子会话"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
child = await session.spawn_child_session(user_id=user_id)
|
||||||
|
return await child.get_session_summary()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/history", response_model=dict[str, Any])
|
||||||
|
async def get_session_history(session_id: str) -> dict[str, Any]:
|
||||||
|
"""获取会话历史"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"history": session.get_history(),
|
||||||
|
"count": len(session._history),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/persist", response_model=dict[str, str])
|
||||||
|
async def persist_session(session_id: str) -> dict[str, str]:
|
||||||
|
"""持久化会话"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
success = await session.persist()
|
||||||
|
if success:
|
||||||
|
return {"status": "persisted", "session_id": session_id}
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to persist session")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/metadata", response_model=dict[str, Any])
|
||||||
|
async def set_session_metadata(
|
||||||
|
session_id: str,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""设置会话元数据"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
session.add_metadata(key, value)
|
||||||
|
await session.persist()
|
||||||
|
return {"key": key, "value": value}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/metadata/{key}", response_model=dict[str, Any])
|
||||||
|
async def get_session_metadata(
|
||||||
|
session_id: str,
|
||||||
|
key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取会话元数据"""
|
||||||
|
session = get_agent_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
|
||||||
|
value = session.get_metadata(key)
|
||||||
|
if value is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Metadata key '{key}' not found")
|
||||||
|
return {"key": key, "value": value}
|
||||||
126
backend/app/routers/agent_skills.py
Normal file
126
backend/app/routers/agent_skills.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Agent Skills API 路由 - Phase 9.6
|
||||||
|
|
||||||
|
使用新的 SkillRegistry (file-based) 而不是 DB-based skill 系统。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from app.agents.skills.registry import get_skill_registry, SkillRegistry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agent/skills", tags=["Agent Skills"])
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_to_dict(skill) -> dict[str, Any]:
|
||||||
|
"""将 SkillMetadata 转换为字典"""
|
||||||
|
return {
|
||||||
|
"name": skill.name,
|
||||||
|
"description": skill.description,
|
||||||
|
"tags": skill.tags,
|
||||||
|
"triggers": skill.triggers,
|
||||||
|
"enabled": skill.enabled,
|
||||||
|
"content_preview": skill.content[:200] + "..."
|
||||||
|
if len(skill.content) > 200
|
||||||
|
else skill.content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=dict[str, Any])
|
||||||
|
async def list_agent_skills() -> dict[str, Any]:
|
||||||
|
"""列出所有已加载的 Agent Skills"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
skills = registry.list_all()
|
||||||
|
return {
|
||||||
|
"skills": [_skill_to_dict(s) for s in skills],
|
||||||
|
"count": len(skills),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=dict[str, Any])
|
||||||
|
async def search_agent_skills(
|
||||||
|
query: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""搜索 Skills"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
results = registry.search(query)
|
||||||
|
return {
|
||||||
|
"skills": [_skill_to_dict(s) for s in results],
|
||||||
|
"count": len(results),
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{skill_name}", response_model=dict[str, Any])
|
||||||
|
async def get_agent_skill(skill_name: str) -> dict[str, Any]:
|
||||||
|
"""获取指定 Skill 详情"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
skill = registry.get_skill(skill_name)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
return {
|
||||||
|
"name": skill.name,
|
||||||
|
"description": skill.description,
|
||||||
|
"tags": skill.tags,
|
||||||
|
"triggers": skill.triggers,
|
||||||
|
"enabled": skill.enabled,
|
||||||
|
"content": skill.content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{skill_name}/context", response_model=dict[str, str])
|
||||||
|
async def get_skill_context(skill_name: str) -> dict[str, str]:
|
||||||
|
"""获取 Skill 上下文字符串"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
context = registry.get_skill_context([skill_name])
|
||||||
|
if not context:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Skill '{skill_name}' not found or not enabled"
|
||||||
|
)
|
||||||
|
return {"skill_name": skill_name, "context": context}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/context/batch", response_model=dict[str, str])
|
||||||
|
async def get_batch_skill_context(
|
||||||
|
skill_names: list[str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""批量获取多个 Skill 的上下文"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
context = registry.get_skill_context(skill_names)
|
||||||
|
return {"skills": skill_names, "context": context}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reload", response_model=dict[str, Any])
|
||||||
|
async def reload_skills(
|
||||||
|
skills_dir: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""重新加载所有 Skills"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
# 清除旧 skills
|
||||||
|
for name in list(registry._skills.keys()):
|
||||||
|
registry.unregister(name)
|
||||||
|
# 重新加载
|
||||||
|
count = registry.load_all(skills_dir)
|
||||||
|
return {"loaded": count, "message": f"Loaded {count} skills"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{skill_name}/enable", response_model=dict[str, str])
|
||||||
|
async def enable_skill(skill_name: str) -> dict[str, str]:
|
||||||
|
"""启用 Skill"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
skill = registry.get_skill(skill_name)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
skill.enabled = True
|
||||||
|
return {"status": "enabled", "skill_name": skill_name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{skill_name}/disable", response_model=dict[str, str])
|
||||||
|
async def disable_skill(skill_name: str) -> dict[str, str]:
|
||||||
|
"""禁用 Skill"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
skill = registry.get_skill(skill_name)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
||||||
|
skill.enabled = False
|
||||||
|
return {"status": "disabled", "skill_name": skill_name}
|
||||||
241
backend/app/routers/hooks.py
Normal file
241
backend/app/routers/hooks.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""Hook API 路由 - Phase 7.5"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.agents.tools.hooks import HookType
|
||||||
|
from app.agents.tools.hooks.builtins import (
|
||||||
|
AuditLogHook,
|
||||||
|
DangerousConfirmationHook,
|
||||||
|
SecurityScanHook,
|
||||||
|
)
|
||||||
|
from app.agents.tools.hooks.config import (
|
||||||
|
HookConfigEntry,
|
||||||
|
get_hook_config_persistence,
|
||||||
|
)
|
||||||
|
from app.agents.tools.hooks.manager import get_hook_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/hooks", tags=["Hooks"])
|
||||||
|
|
||||||
|
|
||||||
|
class HookInfo(BaseModel):
|
||||||
|
"""Hook 信息"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
hook_type: str
|
||||||
|
description: str
|
||||||
|
builtin: bool
|
||||||
|
|
||||||
|
|
||||||
|
class HookConfigUpdate(BaseModel):
|
||||||
|
"""更新 Hook 配置"""
|
||||||
|
|
||||||
|
entries: list[HookConfigEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class HookConfigResponse(BaseModel):
|
||||||
|
"""Hook 配置响应"""
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class HookStatusResponse(BaseModel):
|
||||||
|
"""Hook 状态响应"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
hook_type: str
|
||||||
|
registered: bool
|
||||||
|
|
||||||
|
|
||||||
|
# 内置 Hook 注册表
|
||||||
|
BUILTIN_HOOKS: dict[str, dict[str, str]] = {
|
||||||
|
"audit_log": {
|
||||||
|
"name": "audit_log",
|
||||||
|
"hook_type": "pre_tool_use,post_tool_use,tool_error",
|
||||||
|
"description": "审计日志 Hook - 记录所有工具调用",
|
||||||
|
"class": "AuditLogHook",
|
||||||
|
},
|
||||||
|
"dangerous_confirmation": {
|
||||||
|
"name": "dangerous_confirmation",
|
||||||
|
"hook_type": "pre_tool_use",
|
||||||
|
"description": "危险操作确认 Hook - 拦截危险工具调用",
|
||||||
|
"class": "DangerousConfirmationHook",
|
||||||
|
},
|
||||||
|
"security_scan": {
|
||||||
|
"name": "security_scan",
|
||||||
|
"hook_type": "post_tool_use",
|
||||||
|
"description": "安全扫描 Hook - 检测敏感信息泄露",
|
||||||
|
"class": "SecurityScanHook",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/available", response_model=list[HookInfo])
|
||||||
|
async def list_available_hooks() -> list[HookInfo]:
|
||||||
|
"""列出所有可用的内置 Hook"""
|
||||||
|
return [
|
||||||
|
HookInfo(
|
||||||
|
name=info["name"],
|
||||||
|
hook_type=info["hook_type"],
|
||||||
|
description=info["description"],
|
||||||
|
builtin=True,
|
||||||
|
)
|
||||||
|
for info in BUILTIN_HOOKS.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=HookConfigResponse)
|
||||||
|
async def get_hook_config() -> HookConfigResponse:
|
||||||
|
"""获取当前 Hook 配置"""
|
||||||
|
persistence = get_hook_config_persistence()
|
||||||
|
entries = persistence.load_config()
|
||||||
|
return HookConfigResponse(
|
||||||
|
entries=[vars(e) if isinstance(e, HookConfigEntry) else e for e in entries],
|
||||||
|
count=len(entries),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config", response_model=HookConfigResponse)
|
||||||
|
async def update_hook_config(
|
||||||
|
entries: list[HookConfigEntry],
|
||||||
|
) -> HookConfigResponse:
|
||||||
|
"""更新 Hook 配置"""
|
||||||
|
persistence = get_hook_config_persistence()
|
||||||
|
success = persistence.save_config(entries)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save hook config")
|
||||||
|
|
||||||
|
# 应用配置到 HookManager
|
||||||
|
manager = get_hook_manager()
|
||||||
|
manager.clear() # 清除旧配置
|
||||||
|
persistence.apply_config() # 应用新配置
|
||||||
|
|
||||||
|
return HookConfigResponse(
|
||||||
|
entries=[vars(e) if isinstance(e, HookConfigEntry) else e for e in entries],
|
||||||
|
count=len(entries),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply-config", response_model=dict[str, Any])
|
||||||
|
async def apply_hook_config() -> dict[str, Any]:
|
||||||
|
"""应用配置文件到 HookManager"""
|
||||||
|
persistence = get_hook_config_persistence()
|
||||||
|
manager = get_hook_manager()
|
||||||
|
manager.clear()
|
||||||
|
count = persistence.apply_config()
|
||||||
|
return {"applied": count, "message": f"Applied {count} hook configurations"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=list[HookStatusResponse])
|
||||||
|
async def get_hook_status() -> list[HookStatusResponse]:
|
||||||
|
"""获取所有已注册 Hook 的状态"""
|
||||||
|
manager = get_hook_manager()
|
||||||
|
all_hooks = manager.list_all()
|
||||||
|
|
||||||
|
# 按名称索引已注册的 hooks
|
||||||
|
registered: dict[str, dict[str, Any]] = {}
|
||||||
|
for hook in all_hooks:
|
||||||
|
registered[hook.name] = {
|
||||||
|
"name": hook.name,
|
||||||
|
"enabled": hook.enabled,
|
||||||
|
"hook_type": hook.hook_type.value,
|
||||||
|
"registered": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 合并内置 Hook 信息
|
||||||
|
result: list[HookStatusResponse] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
# 先添加已注册的
|
||||||
|
for hook in all_hooks:
|
||||||
|
result.append(
|
||||||
|
HookStatusResponse(
|
||||||
|
name=hook.name,
|
||||||
|
enabled=hook.enabled,
|
||||||
|
hook_type=hook.hook_type.value,
|
||||||
|
registered=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen.add(hook.name)
|
||||||
|
|
||||||
|
# 再添加内置但未注册的
|
||||||
|
for name, info in BUILTIN_HOOKS.items():
|
||||||
|
if name not in seen:
|
||||||
|
result.append(
|
||||||
|
HookStatusResponse(
|
||||||
|
name=name,
|
||||||
|
enabled=False,
|
||||||
|
hook_type=info["hook_type"],
|
||||||
|
registered=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/enable", response_model=dict[str, str])
|
||||||
|
async def enable_hook(name: str) -> dict[str, str]:
|
||||||
|
"""启用指定 Hook"""
|
||||||
|
manager = get_hook_manager()
|
||||||
|
if manager.enable(name):
|
||||||
|
return {"status": "enabled", "name": name}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Hook '{name}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/disable", response_model=dict[str, str])
|
||||||
|
async def disable_hook(name: str) -> dict[str, str]:
|
||||||
|
"""禁用指定 Hook"""
|
||||||
|
manager = get_hook_manager()
|
||||||
|
if manager.disable(name):
|
||||||
|
return {"status": "disabled", "name": name}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Hook '{name}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register-builtin", response_model=dict[str, str])
|
||||||
|
async def register_builtin_hook(
|
||||||
|
name: str,
|
||||||
|
hook_type: str = "pre_tool_use",
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""注册内置 Hook 到 HookManager"""
|
||||||
|
from app.agents.tools.hooks.types import HookDefinition, HookTrigger
|
||||||
|
|
||||||
|
manager = get_hook_manager()
|
||||||
|
|
||||||
|
if name == "audit_log":
|
||||||
|
hook_instance = AuditLogHook()
|
||||||
|
handler = hook_instance.pre_tool_use
|
||||||
|
hook_types = [HookType.PRE_TOOL_USE, HookType.POST_TOOL_USE, HookType.TOOL_ERROR]
|
||||||
|
elif name == "dangerous_confirmation":
|
||||||
|
hook_instance = DangerousConfirmationHook()
|
||||||
|
handler = hook_instance.pre_tool_use
|
||||||
|
hook_types = [HookType.PRE_TOOL_USE]
|
||||||
|
elif name == "security_scan":
|
||||||
|
hook_instance = SecurityScanHook()
|
||||||
|
handler = hook_instance.post_tool_use
|
||||||
|
hook_types = [HookType.POST_TOOL_USE]
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown builtin hook: {name}")
|
||||||
|
|
||||||
|
registered = []
|
||||||
|
for ht in hook_types:
|
||||||
|
hook_def = HookDefinition(
|
||||||
|
name=f"{name}_{ht.value}",
|
||||||
|
hook_type=ht,
|
||||||
|
trigger=HookTrigger(),
|
||||||
|
handler=handler,
|
||||||
|
priority=0,
|
||||||
|
enabled=True,
|
||||||
|
description=f"Built-in {name} hook",
|
||||||
|
)
|
||||||
|
manager.register(hook_def)
|
||||||
|
registered.append(ht.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "registered",
|
||||||
|
"name": name,
|
||||||
|
"hook_types": ", ".join(registered),
|
||||||
|
}
|
||||||
222
backend/app/routers/plugins.py
Normal file
222
backend/app/routers/plugins.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""Plugin API 路由 - Phase 8.6"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.agents.plugins import get_plugin_manager, PluginManifest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/plugins", tags=["Plugins"])
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInfo(BaseModel):
|
||||||
|
"""插件信息"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
author: str
|
||||||
|
enabled: bool
|
||||||
|
main: str
|
||||||
|
|
||||||
|
|
||||||
|
class PluginInstallRequest(BaseModel):
|
||||||
|
"""插件安装请求"""
|
||||||
|
|
||||||
|
plugin_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PluginListResponse(BaseModel):
|
||||||
|
"""插件列表响应"""
|
||||||
|
|
||||||
|
plugins: list[dict[str, Any]]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
# 全局插件市场(简单内存实现)
|
||||||
|
_plugin_marketplace: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_to_dict(manifest: PluginManifest, enabled: bool) -> dict[str, Any]:
|
||||||
|
"""将 PluginManifest 转换为字典"""
|
||||||
|
return {
|
||||||
|
"id": manifest.id,
|
||||||
|
"name": manifest.name,
|
||||||
|
"version": manifest.version,
|
||||||
|
"description": manifest.description,
|
||||||
|
"author": manifest.author,
|
||||||
|
"enabled": enabled,
|
||||||
|
"main": manifest.main,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PluginListResponse)
|
||||||
|
async def list_plugins() -> PluginListResponse:
|
||||||
|
"""列出所有已安装的插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
plugins = manager.list_plugins()
|
||||||
|
result = []
|
||||||
|
for p in plugins:
|
||||||
|
enabled = manager.is_enabled(p.id)
|
||||||
|
result.append(_manifest_to_dict(p, enabled))
|
||||||
|
return PluginListResponse(plugins=result, count=len(result))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plugin_id}", response_model=dict[str, Any])
|
||||||
|
async def get_plugin(plugin_id: str) -> dict[str, Any]:
|
||||||
|
"""获取指定插件信息"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
manifest = manager.get_plugin(plugin_id)
|
||||||
|
if not manifest:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found")
|
||||||
|
enabled = manager.is_enabled(plugin_id)
|
||||||
|
return _manifest_to_dict(manifest, enabled)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/install", response_model=dict[str, str])
|
||||||
|
async def install_plugin(request: PluginInstallRequest) -> dict[str, str]:
|
||||||
|
"""安装插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if not os.path.exists(request.plugin_path):
|
||||||
|
raise HTTPException(status_code=400, detail="Plugin path does not exist")
|
||||||
|
|
||||||
|
if manager.install(request.plugin_path):
|
||||||
|
return {"status": "installed", "path": request.plugin_path}
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to install plugin")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plugin_id}/enable", response_model=dict[str, str])
|
||||||
|
async def enable_plugin(plugin_id: str) -> dict[str, str]:
|
||||||
|
"""启用插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if manager.enable(plugin_id):
|
||||||
|
return {"status": "enabled", "plugin_id": plugin_id}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plugin_id}/disable", response_model=dict[str, str])
|
||||||
|
async def disable_plugin(plugin_id: str) -> dict[str, str]:
|
||||||
|
"""禁用插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if manager.disable(plugin_id):
|
||||||
|
return {"status": "disabled", "plugin_id": plugin_id}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{plugin_id}", response_model=dict[str, str])
|
||||||
|
async def uninstall_plugin(plugin_id: str) -> dict[str, str]:
|
||||||
|
"""卸载插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if manager.uninstall(plugin_id):
|
||||||
|
return {"status": "uninstalled", "plugin_id": plugin_id}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plugin_id}/reload", response_model=dict[str, str])
|
||||||
|
async def reload_plugin(plugin_id: str) -> dict[str, str]:
|
||||||
|
"""重新加载插件"""
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if manager.reload(plugin_id):
|
||||||
|
return {"status": "reloaded", "plugin_id": plugin_id}
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found")
|
||||||
|
|
||||||
|
|
||||||
|
# === Plugin Marketplace ===
|
||||||
|
|
||||||
|
_marketplace_router = APIRouter(prefix="/api/marketplace", tags=["Plugin Marketplace"])
|
||||||
|
|
||||||
|
|
||||||
|
@_marketplace_router.get("/plugins", response_model=dict[str, Any])
|
||||||
|
async def search_marketplace_plugins(
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""搜索插件市场"""
|
||||||
|
results = _plugin_marketplace
|
||||||
|
if query:
|
||||||
|
results = [
|
||||||
|
p
|
||||||
|
for p in results
|
||||||
|
if query.lower() in p.get("name", "").lower()
|
||||||
|
or query.lower() in p.get("description", "").lower()
|
||||||
|
]
|
||||||
|
if category:
|
||||||
|
results = [p for p in results if p.get("category") == category]
|
||||||
|
return {"plugins": results, "count": len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
@_marketplace_router.get("/plugins/{plugin_id}", response_model=dict[str, Any])
|
||||||
|
async def get_marketplace_plugin(plugin_id: str) -> dict[str, Any]:
|
||||||
|
"""获取市场中的插件详情"""
|
||||||
|
for plugin in _plugin_marketplace:
|
||||||
|
if plugin.get("id") == plugin_id:
|
||||||
|
return plugin
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin '{plugin_id}' not found in marketplace")
|
||||||
|
|
||||||
|
|
||||||
|
@_marketplace_router.post("/plugins", response_model=dict[str, str])
|
||||||
|
async def add_to_marketplace(plugin: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""添加插件到市场(仅供测试/开发)"""
|
||||||
|
if "id" not in plugin or "name" not in plugin:
|
||||||
|
raise HTTPException(status_code=400, detail="Plugin must have id and name")
|
||||||
|
# 移除已存在的同 ID 插件
|
||||||
|
global _plugin_marketplace
|
||||||
|
_plugin_marketplace = [p for p in _plugin_marketplace if p.get("id") != plugin["id"]]
|
||||||
|
_plugin_marketplace.append(plugin)
|
||||||
|
return {"status": "added", "id": plugin["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@_marketplace_router.post("/plugins/{plugin_id}/download", response_model=dict[str, str])
|
||||||
|
async def download_plugin(plugin_id: str) -> dict[str, str]:
|
||||||
|
"""从市场下载并安装插件"""
|
||||||
|
# Find plugin in marketplace
|
||||||
|
plugin = None
|
||||||
|
for p in _plugin_marketplace:
|
||||||
|
if p.get("id") == plugin_id:
|
||||||
|
plugin = p
|
||||||
|
break
|
||||||
|
|
||||||
|
if not plugin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Plugin '{plugin_id}' not found in marketplace"
|
||||||
|
)
|
||||||
|
|
||||||
|
download_url = plugin.get("download_url")
|
||||||
|
if not download_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Plugin has no download URL")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download the plugin archive
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(download_url, timeout=60.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
archive_content = response.content
|
||||||
|
|
||||||
|
# Extract to temp directory and install
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
archive_path = os.path.join(temp_dir, "plugin.zip")
|
||||||
|
with open(archive_path, "wb") as f:
|
||||||
|
f.write(archive_content)
|
||||||
|
|
||||||
|
extract_dir = os.path.join(temp_dir, "extracted")
|
||||||
|
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Install the plugin
|
||||||
|
manager = get_plugin_manager()
|
||||||
|
if manager.install(extract_dir):
|
||||||
|
return {"status": "installed", "plugin_id": plugin_id}
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to install plugin")
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Download failed: {str(e)}")
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise HTTPException(status_code=502, detail="Invalid plugin archive")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Installation failed: {str(e)}")
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
from pydantic import BaseModel
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class AgentCreate(BaseModel):
|
class AgentCreate(BaseModel):
|
||||||
@@ -6,6 +9,7 @@ class AgentCreate(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
system_prompt: str
|
system_prompt: str
|
||||||
|
spawn_permission: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AgentOut(BaseModel):
|
class AgentOut(BaseModel):
|
||||||
@@ -55,3 +59,163 @@ class AgentConfigOut(BaseModel):
|
|||||||
selected_skill_ids: list[str]
|
selected_skill_ids: list[str]
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityEventOut(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
event_type: str
|
||||||
|
timestamp: datetime
|
||||||
|
conversation_id: str | None = None
|
||||||
|
agent_id: str | None = None
|
||||||
|
sub_commander_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
parent_task_id: str | None = None
|
||||||
|
child_task_id: str | None = None
|
||||||
|
thread_id: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
interrupt_id: str | None = None
|
||||||
|
recovery_id: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
severity: str = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityEventsResponse(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
items: list[AgentVisibilityEventOut]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityTaskSummaryOut(BaseModel):
|
||||||
|
task_id: str
|
||||||
|
role: str | None = None
|
||||||
|
owner_agent_id: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
evidence_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityTopologyNodeOut(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
role: str | None = None
|
||||||
|
parent_agent_id: str | None = None
|
||||||
|
source: str
|
||||||
|
task_count: int = 0
|
||||||
|
completed_task_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityTopologyOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
root_agent_id: str | None = None
|
||||||
|
current_agent: str | None = None
|
||||||
|
nodes: list[AgentVisibilityTopologyNodeOut]
|
||||||
|
edges: list[dict[str, str]]
|
||||||
|
tasks: list[AgentVisibilityTaskSummaryOut]
|
||||||
|
task_hierarchy: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityEvidenceOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
task_id: str
|
||||||
|
task: dict[str, Any] | None = None
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
|
tool_outcomes: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
verifier: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityThreadMessageOut(BaseModel):
|
||||||
|
message_id: str
|
||||||
|
thread_id: str
|
||||||
|
from_agent_id: str
|
||||||
|
to_agent_id: str
|
||||||
|
task_id: str | None = None
|
||||||
|
reply_to_message_id: str | None = None
|
||||||
|
message_type: str
|
||||||
|
content_summary: str
|
||||||
|
created_at: datetime
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityThreadOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
thread_id: str
|
||||||
|
total: int
|
||||||
|
items: list[AgentVisibilityThreadMessageOut]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityVerifierOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
status: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityIsolationOut(BaseModel):
|
||||||
|
mode: str = "none"
|
||||||
|
isolation_id: str | None = None
|
||||||
|
workspace_path: str | None = None
|
||||||
|
parent_conversation_id: str | None = None
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityCostOut(BaseModel):
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
estimated_cost: float | None = None
|
||||||
|
budget_warning: bool = False
|
||||||
|
currency: str = "USD"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityCostByAgentOut(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
estimated_cost: float | None = None
|
||||||
|
budget_warning: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityCostSummaryOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
total: AgentVisibilityCostOut
|
||||||
|
thresholds: dict[str, float] = Field(default_factory=dict)
|
||||||
|
by_agent: list[AgentVisibilityCostByAgentOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityToolGovernanceItemOut(BaseModel):
|
||||||
|
capability_id: str
|
||||||
|
tool_name: str
|
||||||
|
permission_class: str
|
||||||
|
side_effect_scope: str
|
||||||
|
supports_retry: bool = False
|
||||||
|
idempotent: bool = False
|
||||||
|
safe_for_parallel_use: bool = False
|
||||||
|
requires_confirmation: bool = False
|
||||||
|
usage_count: int = 0
|
||||||
|
last_result_preview: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityToolGovernanceOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
total_tools: int = 0
|
||||||
|
used_tools: int = 0
|
||||||
|
items: list[AgentVisibilityToolGovernanceItemOut] = Field(default_factory=list)
|
||||||
|
upgrade_candidates: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentVisibilityRuntimeSummaryOut(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
execution_mode: str | None = None
|
||||||
|
current_phase: str | None = None
|
||||||
|
current_checkpoint: str | None = None
|
||||||
|
phase_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
checkpoint_history: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
verifier: AgentVisibilityVerifierOut
|
||||||
|
isolation: AgentVisibilityIsolationOut
|
||||||
|
cost: AgentVisibilityCostOut
|
||||||
|
topology_node_count: int = 0
|
||||||
|
active_task_count: int = 0
|
||||||
|
completed_task_count: int = 0
|
||||||
|
recent_events: list[AgentVisibilityEventOut] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.models.conversation import Conversation, Message
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.agents.graph import get_agent_graph
|
from app.agents.graph import get_agent_graph
|
||||||
from app.agents.context import set_current_user, clear_current_user
|
from app.agents.context import set_current_user, clear_current_user
|
||||||
|
from app.agents.skills.registry import get_skill_registry
|
||||||
from app.services import memory_service
|
from app.services import memory_service
|
||||||
from app.services.brain_service import BrainService
|
from app.services.brain_service import BrainService
|
||||||
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
from app.services.llm_service import create_llm_from_config, resolve_provider_capabilities
|
||||||
@@ -95,9 +96,8 @@ def _is_streaming_rejection_error(error: Exception, user_llm_config: dict | None
|
|||||||
]
|
]
|
||||||
|
|
||||||
if isinstance(error, BadRequestError):
|
if isinstance(error, BadRequestError):
|
||||||
return (
|
return getattr(capabilities, "provider", None) not in {"openai", "claude"} and any(
|
||||||
getattr(capabilities, "provider", None) not in {"openai", "claude"}
|
marker in error_text for marker in markers
|
||||||
and any(marker in error_text for marker in markers)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return any(marker in error_text for marker in markers)
|
return any(marker in error_text for marker in markers)
|
||||||
@@ -134,6 +134,42 @@ _CONTINUITY_SNAPSHOT_FIELDS = (
|
|||||||
"current_agent",
|
"current_agent",
|
||||||
"next_step",
|
"next_step",
|
||||||
"agent_trace",
|
"agent_trace",
|
||||||
|
"agent_id",
|
||||||
|
"parent_agent_id",
|
||||||
|
"root_agent_id",
|
||||||
|
"collaboration_depth",
|
||||||
|
"thread_id",
|
||||||
|
"last_message_id",
|
||||||
|
"message_sequence",
|
||||||
|
"spawned_agent_ids",
|
||||||
|
"current_sub_commander",
|
||||||
|
"active_sub_commanders",
|
||||||
|
"sub_commander_trace",
|
||||||
|
"event_trace",
|
||||||
|
"message_trace",
|
||||||
|
"active_tasks",
|
||||||
|
"task_results",
|
||||||
|
"task_hierarchy",
|
||||||
|
"verification_status",
|
||||||
|
"verification_summary",
|
||||||
|
"verification_evidence",
|
||||||
|
"isolation_mode",
|
||||||
|
"isolation_id",
|
||||||
|
"isolation_workspace_path",
|
||||||
|
"isolation_parent_conversation_id",
|
||||||
|
"isolation_metadata",
|
||||||
|
"input_tokens",
|
||||||
|
"output_tokens",
|
||||||
|
"estimated_cost",
|
||||||
|
"budget_warning",
|
||||||
|
"cost_by_agent",
|
||||||
|
"cost_thresholds",
|
||||||
|
"budget_state",
|
||||||
|
"collaboration_budget_history",
|
||||||
|
"current_phase",
|
||||||
|
"phase_history",
|
||||||
|
"current_checkpoint",
|
||||||
|
"checkpoint_history",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +181,11 @@ def _normalize_legacy_turn_context(turn_context: Any, current_agent: Any) -> dic
|
|||||||
active_sub_flow = normalized.pop("active_sub_flow", None)
|
active_sub_flow = normalized.pop("active_sub_flow", None)
|
||||||
if isinstance(active_agent, str) and active_agent and "active_agent" not in normalized:
|
if isinstance(active_agent, str) and active_agent and "active_agent" not in normalized:
|
||||||
normalized["active_agent"] = active_agent
|
normalized["active_agent"] = active_agent
|
||||||
if isinstance(active_sub_flow, str) and active_sub_flow and "active_sub_commander" not in normalized:
|
if (
|
||||||
|
isinstance(active_sub_flow, str)
|
||||||
|
and active_sub_flow
|
||||||
|
and "active_sub_commander" not in normalized
|
||||||
|
):
|
||||||
normalized["active_sub_commander"] = active_sub_flow
|
normalized["active_sub_commander"] = active_sub_flow
|
||||||
if not normalized.get("active_agent") and isinstance(current_agent, str) and current_agent:
|
if not normalized.get("active_agent") and isinstance(current_agent, str) and current_agent:
|
||||||
normalized["active_agent"] = current_agent
|
normalized["active_agent"] = current_agent
|
||||||
@@ -321,11 +361,32 @@ class AgentService:
|
|||||||
"【当前时间】\n"
|
"【当前时间】\n"
|
||||||
f"- current_time_utc: {reference['current_time_iso']}\n"
|
f"- current_time_utc: {reference['current_time_iso']}\n"
|
||||||
f"- current_date_utc: {reference['current_date_iso']}\n"
|
f"- current_date_utc: {reference['current_date_iso']}\n"
|
||||||
"说明:解析‘今天/明天/后天/本周/下周’等相对时间时,请以 current_time_utc 为准。"
|
"说明:解析'今天/明天/后天/本周/下周'等相对时间时,请以 current_time_utc 为准。"
|
||||||
)
|
)
|
||||||
return context, reference
|
return context, reference
|
||||||
|
|
||||||
async def _get_user_llm_config(self, user_id: str, model_name: str | None = None) -> dict | None:
|
def build_skill_context(self, skill_names: list[str]) -> dict:
|
||||||
|
"""构建 Skills 上下文
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skill_names: Skill 名称列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 skills 上下文的字典
|
||||||
|
"""
|
||||||
|
registry = get_skill_registry()
|
||||||
|
merged_context = registry.get_skill_context(skill_names)
|
||||||
|
return {
|
||||||
|
"skills_context": merged_context,
|
||||||
|
"skills_metadata": {
|
||||||
|
"skills": skill_names,
|
||||||
|
"count": len(skill_names),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_user_llm_config(
|
||||||
|
self, user_id: str, model_name: str | None = None
|
||||||
|
) -> dict | None:
|
||||||
"""获取用户的 LLM 模型配置"""
|
"""获取用户的 LLM 模型配置"""
|
||||||
user = await self.db.get(User, user_id)
|
user = await self.db.get(User, user_id)
|
||||||
if not user or not user.llm_config:
|
if not user or not user.llm_config:
|
||||||
@@ -375,13 +436,15 @@ class AgentService:
|
|||||||
user_llm_config: dict | None,
|
user_llm_config: dict | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
state = initial_state(user_id, conversation.id)
|
state = initial_state(user_id, conversation.id)
|
||||||
state.update({
|
state.update(
|
||||||
"messages": [HumanMessage(content=full_message)],
|
{
|
||||||
"memory_context": memory_context,
|
"messages": [HumanMessage(content=full_message)],
|
||||||
"current_datetime_context": current_datetime_context,
|
"memory_context": memory_context,
|
||||||
"current_datetime_reference": current_datetime_reference,
|
"current_datetime_context": current_datetime_context,
|
||||||
"user_llm_config": user_llm_config,
|
"current_datetime_reference": current_datetime_reference,
|
||||||
})
|
"user_llm_config": user_llm_config,
|
||||||
|
}
|
||||||
|
)
|
||||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||||
if previous_snapshot:
|
if previous_snapshot:
|
||||||
state.update(previous_snapshot)
|
state.update(previous_snapshot)
|
||||||
@@ -443,6 +506,7 @@ class AgentService:
|
|||||||
file_context = ""
|
file_context = ""
|
||||||
if file_ids:
|
if file_ids:
|
||||||
from app.services.document_service import DocumentService
|
from app.services.document_service import DocumentService
|
||||||
|
|
||||||
doc_svc = DocumentService(self.db)
|
doc_svc = DocumentService(self.db)
|
||||||
for file_id in file_ids:
|
for file_id in file_ids:
|
||||||
content = await doc_svc.get_document_content(user_id, file_id)
|
content = await doc_svc.get_document_content(user_id, file_id)
|
||||||
@@ -508,7 +572,9 @@ class AgentService:
|
|||||||
set_current_user(user_id)
|
set_current_user(user_id)
|
||||||
try:
|
try:
|
||||||
graph = get_agent_graph()
|
graph = get_agent_graph()
|
||||||
current_datetime_context, current_datetime_reference = self._build_current_datetime_context()
|
current_datetime_context, current_datetime_reference = (
|
||||||
|
self._build_current_datetime_context()
|
||||||
|
)
|
||||||
|
|
||||||
state = await self._build_agent_state(
|
state = await self._build_agent_state(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -521,7 +587,9 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||||
|
|
||||||
yield self._build_progress_event("thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题")
|
yield self._build_progress_event(
|
||||||
|
"thinking", "Jarvis 正在分析请求", agent="master", step="理解你的问题"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for event in graph.astream_events(state, version="v2"):
|
async for event in graph.astream_events(state, version="v2"):
|
||||||
@@ -530,7 +598,13 @@ class AgentService:
|
|||||||
metadata = event.get("metadata", {})
|
metadata = event.get("metadata", {})
|
||||||
data = event.get("data", {})
|
data = event.get("data", {})
|
||||||
|
|
||||||
if kind == "on_chain_start" and event_name in {"master", "schedule_planner", "executor", "librarian", "analyst"}:
|
if kind == "on_chain_start" and event_name in {
|
||||||
|
"master",
|
||||||
|
"schedule_planner",
|
||||||
|
"executor",
|
||||||
|
"librarian",
|
||||||
|
"analyst",
|
||||||
|
}:
|
||||||
stage_map = {
|
stage_map = {
|
||||||
"master": ("thinking", "Jarvis 正在理解请求"),
|
"master": ("thinking", "Jarvis 正在理解请求"),
|
||||||
"schedule_planner": ("planning", "Jarvis 正在编排日程"),
|
"schedule_planner": ("planning", "Jarvis 正在编排日程"),
|
||||||
@@ -538,9 +612,13 @@ class AgentService:
|
|||||||
"librarian": ("tool", "Jarvis 正在检索知识"),
|
"librarian": ("tool", "Jarvis 正在检索知识"),
|
||||||
"analyst": ("thinking", "Jarvis 正在分析信息"),
|
"analyst": ("thinking", "Jarvis 正在分析信息"),
|
||||||
}
|
}
|
||||||
stage, label = stage_map.get(event_name, ("thinking", "Jarvis 正在思考"))
|
stage, label = stage_map.get(
|
||||||
yield self._build_progress_event(stage, label, agent=event_name, step=label)
|
event_name, ("thinking", "Jarvis 正在思考")
|
||||||
|
)
|
||||||
|
yield self._build_progress_event(
|
||||||
|
stage, label, agent=event_name, step=label
|
||||||
|
)
|
||||||
|
|
||||||
elif kind == "on_tool_start":
|
elif kind == "on_tool_start":
|
||||||
yield self._build_progress_event(
|
yield self._build_progress_event(
|
||||||
"tool",
|
"tool",
|
||||||
@@ -549,7 +627,7 @@ class AgentService:
|
|||||||
tool_name=event_name,
|
tool_name=event_name,
|
||||||
step=f"正在执行 {event_name}",
|
step=f"正在执行 {event_name}",
|
||||||
)
|
)
|
||||||
|
|
||||||
elif kind == "on_tool_end":
|
elif kind == "on_tool_end":
|
||||||
tool_result = data.get("output")
|
tool_result = data.get("output")
|
||||||
step = f"已完成 {event_name}"
|
step = f"已完成 {event_name}"
|
||||||
@@ -562,14 +640,16 @@ class AgentService:
|
|||||||
tool_name=event_name,
|
tool_name=event_name,
|
||||||
step=step,
|
step=step,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif kind == "on_chat_model_stream":
|
elif kind == "on_chat_model_stream":
|
||||||
chunk = data.get("chunk")
|
chunk = data.get("chunk")
|
||||||
content = _coerce_event_text(getattr(chunk, "content", "") if chunk else "")
|
content = _coerce_event_text(
|
||||||
|
getattr(chunk, "content", "") if chunk else ""
|
||||||
|
)
|
||||||
if content:
|
if content:
|
||||||
collected += content
|
collected += content
|
||||||
yield {"type": "chunk", "content": content}
|
yield {"type": "chunk", "content": content}
|
||||||
|
|
||||||
elif kind == "on_chain_end":
|
elif kind == "on_chain_end":
|
||||||
output = data.get("output")
|
output = data.get("output")
|
||||||
final_resp = None
|
final_resp = None
|
||||||
@@ -584,7 +664,9 @@ class AgentService:
|
|||||||
|
|
||||||
elif kind == "on_chat_model_end":
|
elif kind == "on_chat_model_end":
|
||||||
output = data.get("output")
|
output = data.get("output")
|
||||||
final_content = _coerce_event_text(getattr(output, "content", "") if output else "")
|
final_content = _coerce_event_text(
|
||||||
|
getattr(output, "content", "") if output else ""
|
||||||
|
)
|
||||||
if final_content:
|
if final_content:
|
||||||
final_text = final_content
|
final_text = final_content
|
||||||
if final_text != collected:
|
if final_text != collected:
|
||||||
@@ -593,12 +675,16 @@ class AgentService:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_streaming_rejection_error(e, user_llm_config) and not collected:
|
if _is_streaming_rejection_error(e, user_llm_config) and not collected:
|
||||||
yield self._build_progress_event("responding", "Jarvis 正在生成回复", agent="master", step="fallback")
|
yield self._build_progress_event(
|
||||||
|
"responding", "Jarvis 正在生成回复", agent="master", step="fallback"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
result_state = await graph.ainvoke(state)
|
result_state = await graph.ainvoke(state)
|
||||||
if isinstance(result_state, dict):
|
if isinstance(result_state, dict):
|
||||||
state.update(result_state)
|
state.update(result_state)
|
||||||
fallback_content = result_state.get("final_response") or str(result_state.get("messages", [AIMessage(content="")])[-1].content)
|
fallback_content = result_state.get("final_response") or str(
|
||||||
|
result_state.get("messages", [AIMessage(content="")])[-1].content
|
||||||
|
)
|
||||||
collected = str(fallback_content)
|
collected = str(fallback_content)
|
||||||
yield {"type": "chunk", "content": collected}
|
yield {"type": "chunk", "content": collected}
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -622,14 +708,24 @@ class AgentService:
|
|||||||
if collected:
|
if collected:
|
||||||
assistant_msg.content = collected
|
assistant_msg.content = collected
|
||||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||||
assistant_msg.attachments = ([{
|
assistant_msg.attachments = (
|
||||||
"kind": "agent_continuity_state",
|
[
|
||||||
**continuity_snapshot,
|
{
|
||||||
}] if continuity_snapshot else None)
|
"kind": "agent_continuity_state",
|
||||||
conv.agent_state = ({
|
**continuity_snapshot,
|
||||||
"kind": "agent_continuity_state",
|
}
|
||||||
**continuity_snapshot,
|
]
|
||||||
} if continuity_snapshot else None)
|
if continuity_snapshot
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
conv.agent_state = (
|
||||||
|
{
|
||||||
|
"kind": "agent_continuity_state",
|
||||||
|
**continuity_snapshot,
|
||||||
|
}
|
||||||
|
if continuity_snapshot
|
||||||
|
else None
|
||||||
|
)
|
||||||
await BrainService(self.db).create_event(
|
await BrainService(self.db).create_event(
|
||||||
user_id,
|
user_id,
|
||||||
**_build_assistant_event_payload(collected),
|
**_build_assistant_event_payload(collected),
|
||||||
@@ -707,12 +803,16 @@ class AgentService:
|
|||||||
importance_signal=1.0,
|
importance_signal=1.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
memory_ctx = await memory_service.build_memory_context(self.db, user_id, conversation_id, message)
|
memory_ctx = await memory_service.build_memory_context(
|
||||||
|
self.db, user_id, conversation_id, message
|
||||||
|
)
|
||||||
|
|
||||||
set_current_user(user_id)
|
set_current_user(user_id)
|
||||||
try:
|
try:
|
||||||
graph = get_agent_graph()
|
graph = get_agent_graph()
|
||||||
current_datetime_context, current_datetime_reference = self._build_current_datetime_context()
|
current_datetime_context, current_datetime_reference = (
|
||||||
|
self._build_current_datetime_context()
|
||||||
|
)
|
||||||
state = await self._build_agent_state(
|
state = await self._build_agent_state(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
conversation=conv,
|
conversation=conv,
|
||||||
@@ -724,7 +824,9 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||||
result_state = await graph.ainvoke(state)
|
result_state = await graph.ainvoke(state)
|
||||||
response_content = result_state.get("final_response") or str(result_state.get("messages", [AIMessage(content="")])[-1].content)
|
response_content = result_state.get("final_response") or str(
|
||||||
|
result_state.get("messages", [AIMessage(content="")])[-1].content
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("agent_chat_simple_failed")
|
logger.exception("agent_chat_simple_failed")
|
||||||
response_content = "抱歉,发生错误。"
|
response_content = "抱歉,发生错误。"
|
||||||
@@ -745,15 +847,27 @@ class AgentService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assistant_msg.content = response_content
|
assistant_msg.content = response_content
|
||||||
continuity_snapshot = _build_continuity_snapshot(result_state) if 'result_state' in locals() else None
|
continuity_snapshot = (
|
||||||
assistant_msg.attachments = ([{
|
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||||
"kind": "agent_continuity_state",
|
)
|
||||||
**continuity_snapshot,
|
assistant_msg.attachments = (
|
||||||
}] if continuity_snapshot else None)
|
[
|
||||||
conv.agent_state = ({
|
{
|
||||||
"kind": "agent_continuity_state",
|
"kind": "agent_continuity_state",
|
||||||
**continuity_snapshot,
|
**continuity_snapshot,
|
||||||
} if continuity_snapshot else None)
|
}
|
||||||
|
]
|
||||||
|
if continuity_snapshot
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
conv.agent_state = (
|
||||||
|
{
|
||||||
|
"kind": "agent_continuity_state",
|
||||||
|
**continuity_snapshot,
|
||||||
|
}
|
||||||
|
if continuity_snapshot
|
||||||
|
else None
|
||||||
|
)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
await self.db.refresh(assistant_msg)
|
await self.db.refresh(assistant_msg)
|
||||||
|
|
||||||
|
|||||||
167
backend/tests/backend/app/agents/test_agent_schemas.py
Normal file
167
backend/tests/backend/app/agents/test_agent_schemas.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from app.agents.schemas.event import AgentEvent
|
||||||
|
from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRecord, RecoveryRecord, TaskResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_task_accepts_day1_fields():
|
||||||
|
task = AgentTask(
|
||||||
|
task_id="task-1",
|
||||||
|
title="Verify foundation",
|
||||||
|
status="in_progress",
|
||||||
|
owner_agent_id="executor",
|
||||||
|
role="verifier",
|
||||||
|
goal="check output",
|
||||||
|
expected_evidence=[{"type": "assertion"}],
|
||||||
|
evidence=[{"type": "log"}],
|
||||||
|
result_summary="running",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.task_id == "task-1"
|
||||||
|
assert task.owner_agent_id == "executor"
|
||||||
|
assert task.status == "in_progress"
|
||||||
|
assert task.expected_evidence == [{"type": "assertion"}]
|
||||||
|
assert task.evidence == [{"type": "log"}]
|
||||||
|
assert task.result_summary == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_task_accepts_day3_runtime_fields():
|
||||||
|
task = AgentTask(
|
||||||
|
task_id="task-2",
|
||||||
|
title="Recover interrupted collaboration",
|
||||||
|
owner_agent_id="executor",
|
||||||
|
parent_task_id="task-1",
|
||||||
|
child_task_ids=["task-2a"],
|
||||||
|
thread_id="thread-1",
|
||||||
|
message_id="msg-1",
|
||||||
|
message_index=2,
|
||||||
|
interrupt_records=[
|
||||||
|
InterruptRecord(
|
||||||
|
interrupt_id="interrupt-1",
|
||||||
|
reason="manual stop",
|
||||||
|
requested_by="coordinator",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
recovery_records=[
|
||||||
|
RecoveryRecord(
|
||||||
|
recovery_id="recovery-1",
|
||||||
|
source_interrupt_id="interrupt-1",
|
||||||
|
resumed_from_task_id="task-2",
|
||||||
|
resumed_from_thread_id="thread-1",
|
||||||
|
strategy="resume_from_checkpoint",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
collaboration_budget=CollaborationBudget(
|
||||||
|
mode="collaboration",
|
||||||
|
max_parallel_tasks=2,
|
||||||
|
remaining_parallel_tasks=1,
|
||||||
|
max_tool_calls=4,
|
||||||
|
remaining_tool_calls=3,
|
||||||
|
max_iterations=5,
|
||||||
|
remaining_iterations=4,
|
||||||
|
escalation_threshold=1,
|
||||||
|
metadata={"max_spawn_depth": 2},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert task.parent_task_id == "task-1"
|
||||||
|
assert task.child_task_ids == ["task-2a"]
|
||||||
|
assert task.thread_id == "thread-1"
|
||||||
|
assert task.message_id == "msg-1"
|
||||||
|
assert task.message_index == 2
|
||||||
|
assert task.interrupt_records[0].interrupt_id == "interrupt-1"
|
||||||
|
assert task.recovery_records[0].recovery_id == "recovery-1"
|
||||||
|
assert task.collaboration_budget.mode == "collaboration"
|
||||||
|
assert task.collaboration_budget.metadata == {"max_spawn_depth": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_event_accepts_day1_fields():
|
||||||
|
event = AgentEvent(
|
||||||
|
event_id="evt-1",
|
||||||
|
event_type="agent.verify.completed",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
agent_id="executor",
|
||||||
|
sub_commander_id="executor_tasks",
|
||||||
|
task_id="task-1",
|
||||||
|
payload={"status": "passed"},
|
||||||
|
severity="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.event_id == "evt-1"
|
||||||
|
assert event.event_type == "agent.verify.completed"
|
||||||
|
assert event.conversation_id == "conv-1"
|
||||||
|
assert event.payload == {"status": "passed"}
|
||||||
|
assert event.severity == "info"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_event_accepts_day3_trace_fields():
|
||||||
|
event = AgentEvent(
|
||||||
|
event_id="evt-2",
|
||||||
|
event_type="agent.collaboration.budget.updated",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
agent_id="coordinator",
|
||||||
|
task_id="task-2",
|
||||||
|
parent_task_id="task-1",
|
||||||
|
child_task_id="task-2a",
|
||||||
|
thread_id="thread-1",
|
||||||
|
message_id="msg-3",
|
||||||
|
interrupt_id="interrupt-1",
|
||||||
|
recovery_id="recovery-1",
|
||||||
|
payload={"remaining_parallel_tasks": 1},
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.parent_task_id == "task-1"
|
||||||
|
assert event.child_task_id == "task-2a"
|
||||||
|
assert event.thread_id == "thread-1"
|
||||||
|
assert event.message_id == "msg-3"
|
||||||
|
assert event.interrupt_id == "interrupt-1"
|
||||||
|
assert event.recovery_id == "recovery-1"
|
||||||
|
assert event.severity == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_supports_collaboration_result_fields():
|
||||||
|
result = TaskResult(
|
||||||
|
task_id="task-1",
|
||||||
|
status="completed",
|
||||||
|
summary="retrieval finished",
|
||||||
|
evidence=[{"type": "source"}],
|
||||||
|
owner_agent_id="librarian",
|
||||||
|
next_action="handoff_to_analyst",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "completed"
|
||||||
|
assert result.owner_agent_id == "librarian"
|
||||||
|
assert result.next_action == "handoff_to_analyst"
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_result_supports_day3_thread_budget_and_recovery_fields():
|
||||||
|
result = TaskResult(
|
||||||
|
task_id="task-2",
|
||||||
|
status="blocked",
|
||||||
|
owner_agent_id="executor",
|
||||||
|
parent_task_id="task-1",
|
||||||
|
child_task_ids=["task-2a"],
|
||||||
|
thread_id="thread-1",
|
||||||
|
message_id="msg-4",
|
||||||
|
message_index=4,
|
||||||
|
interrupt_records=[{"interrupt_id": "interrupt-1", "reason": "budget exceeded"}],
|
||||||
|
recovery_records=[{"recovery_id": "recovery-1", "strategy": "resume_after_budget_reset"}],
|
||||||
|
budget_snapshot=CollaborationBudget(
|
||||||
|
mode="collaboration",
|
||||||
|
max_parallel_tasks=2,
|
||||||
|
remaining_parallel_tasks=0,
|
||||||
|
max_tool_calls=4,
|
||||||
|
remaining_tool_calls=0,
|
||||||
|
),
|
||||||
|
next_action="resume_after_budget_reset",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.parent_task_id == "task-1"
|
||||||
|
assert result.child_task_ids == ["task-2a"]
|
||||||
|
assert result.thread_id == "thread-1"
|
||||||
|
assert result.message_id == "msg-4"
|
||||||
|
assert result.message_index == 4
|
||||||
|
assert result.interrupt_records[0].interrupt_id == "interrupt-1"
|
||||||
|
assert result.recovery_records[0].recovery_id == "recovery-1"
|
||||||
|
assert result.budget_snapshot.mode == "collaboration"
|
||||||
|
assert result.budget_snapshot.remaining_parallel_tasks == 0
|
||||||
|
assert result.next_action == "resume_after_budget_reset"
|
||||||
@@ -2,22 +2,36 @@ import sys
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
sys.modules.setdefault("trafilatura", Mock())
|
sys.modules.setdefault("trafilatura", Mock())
|
||||||
|
|
||||||
import app.agents.graph as graph_module
|
import app.agents.graph as graph_module
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
from app.agents.graph import (
|
from app.agents.graph import (
|
||||||
|
_build_collaboration_tasks,
|
||||||
|
_build_verifier_hints,
|
||||||
_choose_sub_commander,
|
_choose_sub_commander,
|
||||||
|
_create_child_agent,
|
||||||
_execute_tool_calls,
|
_execute_tool_calls,
|
||||||
_parse_json_action,
|
_parse_json_action,
|
||||||
|
_record_checkpoint,
|
||||||
|
_record_interrupt,
|
||||||
|
_record_recovery,
|
||||||
_route_agent_from_user_query,
|
_route_agent_from_user_query,
|
||||||
|
_select_request_mode,
|
||||||
|
_set_phase,
|
||||||
|
_spawn_permission_for_role,
|
||||||
|
_run_collaboration_flow,
|
||||||
_run_sub_commander,
|
_run_sub_commander,
|
||||||
create_agent_graph,
|
create_agent_graph,
|
||||||
master_node,
|
master_node,
|
||||||
planner_node,
|
planner_node,
|
||||||
route_agent,
|
route_agent,
|
||||||
)
|
)
|
||||||
|
from app.agents.schemas.message import AgentMessage
|
||||||
|
from app.agents.schemas.task import AgentTask
|
||||||
from app.agents.state import AgentRole, initial_state
|
from app.agents.state import AgentRole, initial_state
|
||||||
from app.agents.tools import SUB_COMMANDER_TOOLSETS
|
from app.agents.tools import SUB_COMMANDER_TOOLSETS
|
||||||
|
|
||||||
@@ -29,29 +43,76 @@ def _base_state(message: str, user_llm_config: dict | None = None) -> dict:
|
|||||||
'messages': [HumanMessage(content=message)],
|
'messages': [HumanMessage(content=message)],
|
||||||
'user_id': 'u1',
|
'user_id': 'u1',
|
||||||
'conversation_id': 'c1',
|
'conversation_id': 'c1',
|
||||||
'current_agent': AgentRole.MASTER,
|
'parent_conversation_id': None,
|
||||||
|
'thread_id': None,
|
||||||
|
'last_message_id': None,
|
||||||
|
'message_sequence': 0,
|
||||||
|
'agent_id': AgentRole.MASTER.value,
|
||||||
|
'parent_agent_id': None,
|
||||||
|
'root_agent_id': AgentRole.MASTER.value,
|
||||||
|
'collaboration_depth': 0,
|
||||||
|
'spawned_agent_ids': [],
|
||||||
|
'execution_mode': 'direct',
|
||||||
|
'current_agent': AgentRole.MASTER.value,
|
||||||
|
'next_step': None,
|
||||||
'active_agents': [AgentRole.MASTER],
|
'active_agents': [AgentRole.MASTER],
|
||||||
'current_sub_commander': None,
|
'current_sub_commander': None,
|
||||||
'active_sub_commanders': [],
|
'active_sub_commanders': [],
|
||||||
'sub_commander_trace': [],
|
'sub_commander_trace': [],
|
||||||
|
'agent_trace': [AgentRole.MASTER.value],
|
||||||
|
'event_trace': [],
|
||||||
|
'message_trace': [],
|
||||||
'pending_tasks': [],
|
'pending_tasks': [],
|
||||||
'completed_tasks': [],
|
'completed_tasks': [],
|
||||||
|
'active_tasks': [],
|
||||||
|
'task_results': [],
|
||||||
|
'task_hierarchy': {},
|
||||||
|
'interrupted_tasks': [],
|
||||||
|
'recovery_trace': [],
|
||||||
|
'recovery_points': [],
|
||||||
'tool_calls': [],
|
'tool_calls': [],
|
||||||
'last_tool_result': None,
|
'last_tool_result': None,
|
||||||
'action_results': [],
|
'action_results': [],
|
||||||
'created_entities': [],
|
'created_entities': [],
|
||||||
|
'tool_outcomes': [],
|
||||||
|
'task_result_summary': None,
|
||||||
|
'verifier_hints': None,
|
||||||
|
'verification_status': None,
|
||||||
|
'verification_summary': None,
|
||||||
|
'verification_evidence': [],
|
||||||
|
'isolation_mode': 'none',
|
||||||
|
'isolation_id': None,
|
||||||
|
'isolation_workspace_path': None,
|
||||||
|
'isolation_parent_conversation_id': None,
|
||||||
|
'isolation_metadata': {},
|
||||||
|
'input_tokens': 0,
|
||||||
|
'output_tokens': 0,
|
||||||
|
'estimated_cost': None,
|
||||||
|
'budget_warning': False,
|
||||||
|
'cost_by_agent': {},
|
||||||
|
'cost_thresholds': {},
|
||||||
|
'budget_state': None,
|
||||||
|
'collaboration_budget_history': [],
|
||||||
|
'current_phase': 'phase_0_bootstrap',
|
||||||
|
'phase_history': [{'phase': 'phase_0_bootstrap', 'reason': 'initial_state_created'}],
|
||||||
|
'current_checkpoint': 'bootstrap.initialized',
|
||||||
|
'checkpoint_history': [{'checkpoint': 'bootstrap.initialized', 'phase': 'phase_0_bootstrap', 'reason': 'initial_state_created'}],
|
||||||
|
'tool_strategy_used': None,
|
||||||
'tool_round_count': 0,
|
'tool_round_count': 0,
|
||||||
'max_tool_rounds': 2,
|
'max_tool_rounds': 2,
|
||||||
'retry_count': 0,
|
'retry_count': 0,
|
||||||
'max_retries': 1,
|
'max_retries': 1,
|
||||||
'iteration_count': 0,
|
'iteration_count': 0,
|
||||||
'max_iterations': 3,
|
'max_iterations': 3,
|
||||||
|
'routing_hops': 0,
|
||||||
|
'max_routing_hops': 2,
|
||||||
|
'terminated_due_to_loop_guard': False,
|
||||||
'retrieval_trace': [],
|
'retrieval_trace': [],
|
||||||
'stop_reason': None,
|
'stop_reason': None,
|
||||||
'clarification_needed': False,
|
'clarification_needed': False,
|
||||||
'clarification_question': None,
|
'clarification_question': None,
|
||||||
'provider_capabilities': None,
|
|
||||||
'fallback_parse_error': None,
|
'fallback_parse_error': None,
|
||||||
|
'should_respond': True,
|
||||||
'knowledge_context': None,
|
'knowledge_context': None,
|
||||||
'graph_context': None,
|
'graph_context': None,
|
||||||
'schedule_context_summary': None,
|
'schedule_context_summary': None,
|
||||||
@@ -59,11 +120,17 @@ def _base_state(message: str, user_llm_config: dict | None = None) -> dict:
|
|||||||
'plan_steps': [],
|
'plan_steps': [],
|
||||||
'analysis_report': None,
|
'analysis_report': None,
|
||||||
'final_response': None,
|
'final_response': None,
|
||||||
'should_respond': True,
|
|
||||||
'memory_context': None,
|
'memory_context': None,
|
||||||
'current_datetime_context': 'CURRENT_TIME: 2026-03-28T12:00:00+08:00',
|
'current_datetime_context': 'CURRENT_TIME: 2026-03-28T12:00:00+08:00',
|
||||||
'current_datetime_reference': {'current_time_iso': '2026-03-28T12:00:00+08:00', 'current_date_iso': '2026-03-28', 'timezone': 'UTC'},
|
'current_datetime_reference': {'current_time_iso': '2026-03-28T12:00:00+08:00', 'current_date_iso': '2026-03-28', 'timezone': 'UTC'},
|
||||||
|
'turn_context': None,
|
||||||
|
'routing_decision': None,
|
||||||
|
'continuity_state': None,
|
||||||
|
'pending_action': None,
|
||||||
|
'last_completed_action': None,
|
||||||
|
'clarification_context': None,
|
||||||
'user_llm_config': user_llm_config,
|
'user_llm_config': user_llm_config,
|
||||||
|
'provider_capabilities': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -258,7 +325,86 @@ def test_initial_state_sets_structured_continuity_defaults():
|
|||||||
assert state['pending_action'] is None
|
assert state['pending_action'] is None
|
||||||
assert state['last_completed_action'] is None
|
assert state['last_completed_action'] is None
|
||||||
assert state['clarification_context'] is None
|
assert state['clarification_context'] is None
|
||||||
|
assert state['event_trace'] == []
|
||||||
assert state['tool_outcomes'] == []
|
assert state['tool_outcomes'] == []
|
||||||
|
assert state['current_phase'] == 'phase_0_bootstrap'
|
||||||
|
assert state['current_checkpoint'] == 'bootstrap.initialized'
|
||||||
|
assert state['phase_history'][-1]['phase'] == 'phase_0_bootstrap'
|
||||||
|
assert state['checkpoint_history'][-1]['checkpoint'] == 'bootstrap.initialized'
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_phase_and_record_checkpoint_append_history():
|
||||||
|
state = _base_state('test')
|
||||||
|
|
||||||
|
_set_phase(state, 'phase_1_routing', reason='entered_master')
|
||||||
|
_record_checkpoint(state, 'routing.master_entered', reason='entered_master')
|
||||||
|
|
||||||
|
assert state['current_phase'] == 'phase_1_routing'
|
||||||
|
assert state['current_checkpoint'] == 'routing.master_entered'
|
||||||
|
assert state['phase_history'][-1]['phase'] == 'phase_1_routing'
|
||||||
|
assert state['checkpoint_history'][-1]['checkpoint'] == 'routing.master_entered'
|
||||||
|
assert 'agent.phase.changed' in [event['event_type'] for event in state['event_trace']]
|
||||||
|
assert 'agent.checkpoint.recorded' in [event['event_type'] for event in state['event_trace']]
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawn_permission_for_role_uses_registry_policy():
|
||||||
|
state = _base_state('test')
|
||||||
|
state['current_agent'] = AgentRole.MASTER.value
|
||||||
|
assert _spawn_permission_for_role(state, AgentRole.LIBRARIAN) is True
|
||||||
|
assert _spawn_permission_for_role(state, AgentRole.MASTER) is False
|
||||||
|
|
||||||
|
state['current_agent'] = AgentRole.LIBRARIAN.value
|
||||||
|
assert _spawn_permission_for_role(state, AgentRole.LIBRARIAN) is True
|
||||||
|
assert _spawn_permission_for_role(state, AgentRole.EXECUTOR) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_child_agent_blocks_disallowed_spawn_role():
|
||||||
|
state = _base_state('test')
|
||||||
|
state['current_agent'] = AgentRole.LIBRARIAN.value
|
||||||
|
state['agent_id'] = AgentRole.LIBRARIAN.value
|
||||||
|
task = AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='分析',
|
||||||
|
role=AgentRole.ANALYST.value,
|
||||||
|
owner_agent_id=AgentRole.ANALYST.value,
|
||||||
|
goal='输出分析',
|
||||||
|
expected_evidence=[{'type': 'analysis'}],
|
||||||
|
)
|
||||||
|
|
||||||
|
child_agent_id = _create_child_agent(state, role=AgentRole.ANALYST, task=task)
|
||||||
|
|
||||||
|
assert child_agent_id is None
|
||||||
|
assert state['spawned_agent_ids'] == []
|
||||||
|
assert state['event_trace'][-1]['event_type'] == 'agent.spawn.blocked'
|
||||||
|
assert state['event_trace'][-1]['payload']['reason'] == 'role_policy_blocked'
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_interrupt_and_recovery_write_day3_traces():
|
||||||
|
state = _base_state('test')
|
||||||
|
state['current_agent'] = AgentRole.EXECUTOR.value
|
||||||
|
task = AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='执行动作',
|
||||||
|
role=AgentRole.EXECUTOR.value,
|
||||||
|
owner_agent_id=AgentRole.EXECUTOR.value,
|
||||||
|
goal='执行必要动作',
|
||||||
|
expected_evidence=[{'type': 'execution'}],
|
||||||
|
)
|
||||||
|
|
||||||
|
interrupt = _record_interrupt(state, reason='spawn_blocked', task=task, payload={'target_role': AgentRole.EXECUTOR.value})
|
||||||
|
recovery = _record_recovery(state, interrupt=interrupt, strategy='fallback_to_direct_role_execution', task=task)
|
||||||
|
|
||||||
|
assert state['interrupted_tasks'][-1]['interrupt_id'] == interrupt.interrupt_id
|
||||||
|
assert state['recovery_trace'][-1]['recovery_id'] == recovery.recovery_id
|
||||||
|
assert state['recovery_points'][-1]['task_id'] == 'task-1'
|
||||||
|
assert [event['event_type'] for event in state['event_trace']] == [
|
||||||
|
'agent.interrupt.requested',
|
||||||
|
'agent.task.interrupted',
|
||||||
|
'agent.interrupt.completed',
|
||||||
|
'agent.recovery.started',
|
||||||
|
'agent.task.recovered',
|
||||||
|
'agent.recovery.completed',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_master_node_sets_next_step_when_routing_to_schedule_planner(monkeypatch):
|
async def test_master_node_sets_next_step_when_routing_to_schedule_planner(monkeypatch):
|
||||||
@@ -322,6 +468,259 @@ async def test_planner_node_clears_next_step_after_consuming_routed_turn(monkeyp
|
|||||||
assert result['final_response'] is not None
|
assert result['final_response'] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_request_mode_prefers_collaboration_for_multi_role_request():
|
||||||
|
mode, metadata = _select_request_mode('先帮我搜索竞品资料,然后分析风险,再给我安排下周计划')
|
||||||
|
|
||||||
|
assert mode == 'collaboration'
|
||||||
|
assert metadata['reason'] == 'multi_role_request'
|
||||||
|
assert AgentRole.LIBRARIAN.value in metadata['roles']
|
||||||
|
assert AgentRole.ANALYST.value in metadata['roles']
|
||||||
|
assert AgentRole.SCHEDULE_PLANNER.value in metadata['roles']
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_collaboration_tasks_generates_structured_owned_tasks():
|
||||||
|
tasks = _build_collaboration_tasks('先帮我搜索竞品资料,然后分析风险,再给我安排下周计划')
|
||||||
|
|
||||||
|
assert len(tasks) == 3
|
||||||
|
assert [task.role for task in tasks] == [
|
||||||
|
AgentRole.LIBRARIAN.value,
|
||||||
|
AgentRole.ANALYST.value,
|
||||||
|
AgentRole.SCHEDULE_PLANNER.value,
|
||||||
|
]
|
||||||
|
assert all(task.owner_agent_id for task in tasks)
|
||||||
|
assert all(task.expected_evidence for task in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_collaboration_results_uses_explicit_task_results_snapshot():
|
||||||
|
task = AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='补齐事实与证据',
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal='检索资料',
|
||||||
|
expected_evidence=[{'type': 'evidence'}],
|
||||||
|
)
|
||||||
|
state = _base_state('test')
|
||||||
|
state['task_results'] = [
|
||||||
|
{
|
||||||
|
'task_id': 'stale-task',
|
||||||
|
'status': 'failed',
|
||||||
|
'summary': 'stale failure',
|
||||||
|
'evidence': [{'type': 'verification'}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
graph_module._verify_collaboration_results(
|
||||||
|
state,
|
||||||
|
[task],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'status': 'completed',
|
||||||
|
'summary': 'done',
|
||||||
|
'evidence': [{'type': 'verification'}],
|
||||||
|
'owner_agent_id': AgentRole.LIBRARIAN.value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['verification_status'] == 'passed'
|
||||||
|
assert '1/1 个子任务' in state['verification_summary']
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_collaboration_results_ignores_stale_results_outside_current_plan():
|
||||||
|
tasks = [
|
||||||
|
AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='补齐事实与证据',
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal='检索资料',
|
||||||
|
expected_evidence=[{'type': 'evidence'}],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
state = _base_state('test')
|
||||||
|
|
||||||
|
graph_module._verify_collaboration_results(
|
||||||
|
state,
|
||||||
|
tasks,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'task_id': 'stale-task',
|
||||||
|
'status': 'failed',
|
||||||
|
'summary': 'stale failure',
|
||||||
|
'evidence': [{'type': 'verification'}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'status': 'completed',
|
||||||
|
'summary': 'done',
|
||||||
|
'evidence': [{'type': 'verification'}],
|
||||||
|
'owner_agent_id': AgentRole.LIBRARIAN.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['verification_status'] == 'passed'
|
||||||
|
assert '1/1 个子任务' in state['verification_summary']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_sub_commander_verifies_only_current_turn_tool_outcomes(monkeypatch):
|
||||||
|
class FakeBoundLLM:
|
||||||
|
def __init__(self, response):
|
||||||
|
self._response = response
|
||||||
|
|
||||||
|
def bind_tools(self, _toolset):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def ainvoke(self, _messages):
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
state = _base_state('查一下资料')
|
||||||
|
state['tool_outcomes'] = [
|
||||||
|
{
|
||||||
|
'tool_name': 'stale_tool',
|
||||||
|
'args': {'query': 'old'},
|
||||||
|
'result_preview': '工具执行失败: stale',
|
||||||
|
'verifier_hints': {'tool_name': 'stale_tool'},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
response = AIMessage(content='当前回合完成')
|
||||||
|
monkeypatch.setattr(graph_module, '_get_llm_for_state', lambda _state: FakeBoundLLM(response))
|
||||||
|
monkeypatch.setattr(graph_module, '_resolve_capabilities', lambda _state, _llm: type('Caps', (), {'supports_native_tools': True})())
|
||||||
|
monkeypatch.setattr(graph_module, '_choose_sub_commander', lambda _role, _query: 'librarian_retrieval')
|
||||||
|
monkeypatch.setattr(graph_module, '_record_sub_commander', lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
await graph_module._run_sub_commander(
|
||||||
|
state,
|
||||||
|
AgentRole.LIBRARIAN,
|
||||||
|
'prompt',
|
||||||
|
'查一下资料',
|
||||||
|
use_tools=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['final_response'] == '当前回合完成'
|
||||||
|
assert state['verification_status'] == 'passed'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_collaboration_flow_collects_task_results_and_verifies(monkeypatch):
|
||||||
|
planned_tasks = [
|
||||||
|
AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='补齐事实与证据',
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal='检索资料',
|
||||||
|
expected_evidence=[{'type': 'evidence'}],
|
||||||
|
),
|
||||||
|
AgentTask(
|
||||||
|
task_id='task-2',
|
||||||
|
title='给出分析与判断',
|
||||||
|
role=AgentRole.ANALYST.value,
|
||||||
|
owner_agent_id=AgentRole.ANALYST.value,
|
||||||
|
goal='输出分析',
|
||||||
|
expected_evidence=[{'type': 'analysis'}],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_run_sub_commander(state, role, manager_prompt, user_query, *, use_tools, summary_target=None):
|
||||||
|
state['current_agent'] = role.value
|
||||||
|
state['current_sub_commander'] = f'{role.value}_worker'
|
||||||
|
state['final_response'] = f'{role.value} finished'
|
||||||
|
state['verification_status'] = 'passed'
|
||||||
|
state['verification_summary'] = f'{role.value} verified'
|
||||||
|
state['tool_outcomes'] = [
|
||||||
|
*(state.get('tool_outcomes') or []),
|
||||||
|
{
|
||||||
|
'tool_name': f'{role.value}_tool',
|
||||||
|
'args': {'query': user_query},
|
||||||
|
'result_preview': 'ok',
|
||||||
|
'verifier_hints': {'tool_name': f'{role.value}_tool'},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
state['messages'] = [*state.get('messages', []), AIMessage(content=state['final_response'])]
|
||||||
|
return state
|
||||||
|
|
||||||
|
monkeypatch.setattr(graph_module, '_build_collaboration_tasks', lambda user_query: planned_tasks)
|
||||||
|
monkeypatch.setattr(graph_module, '_run_sub_commander', fake_run_sub_commander)
|
||||||
|
|
||||||
|
state = _base_state('先帮我搜索竞品资料,然后分析风险')
|
||||||
|
result = await _run_collaboration_flow(state, '先帮我搜索竞品资料,然后分析风险')
|
||||||
|
|
||||||
|
assert result['execution_mode'] == 'collaboration'
|
||||||
|
assert len(result['active_tasks']) == 2
|
||||||
|
assert len(result['task_results']) == 2
|
||||||
|
assert result['task_results'][0]['status'] == 'completed'
|
||||||
|
assert result['task_results'][1]['owner_agent_id'] == AgentRole.ANALYST.value
|
||||||
|
assert result['verification_status'] == 'passed'
|
||||||
|
assert '协作模式已完成 2/2 个子任务' in result['verification_summary']
|
||||||
|
assert '已按协作模式回收 2 个子任务结果' in result['final_response']
|
||||||
|
assert len(result['message_trace']) >= 2
|
||||||
|
assert all(message['message_type'] == 'task_update' for message in result['message_trace'])
|
||||||
|
assert result['message_trace'][-1]['message_type'] == 'task_update'
|
||||||
|
assert 'agent.created' in [event['event_type'] for event in result['event_trace']]
|
||||||
|
assert 'agent.message.sent' in [event['event_type'] for event in result['event_trace']]
|
||||||
|
assert 'agent.phase.changed' in [event['event_type'] for event in result['event_trace']]
|
||||||
|
assert 'agent.checkpoint.recorded' in [event['event_type'] for event in result['event_trace']]
|
||||||
|
assert result['current_phase'] == 'phase_4_visibility_and_verification'
|
||||||
|
assert result['current_checkpoint'] == 'collaboration.completed'
|
||||||
|
assert [entry['phase'] for entry in result['phase_history']][-3:] == [
|
||||||
|
'phase_2_controlled_collaboration',
|
||||||
|
'phase_3_dynamic_collaboration',
|
||||||
|
'phase_4_visibility_and_verification',
|
||||||
|
]
|
||||||
|
assert 'agent.spawn.blocked' not in [event['event_type'] for event in result['event_trace']]
|
||||||
|
assert result['spawned_agent_ids']
|
||||||
|
assert all(not agent_id.startswith('blocked-') for agent_id in result['spawned_agent_ids'])
|
||||||
|
assert result['task_hierarchy']
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_enters_collaboration_mode_for_complex_multi_role_request(monkeypatch):
|
||||||
|
async def fake_collaboration_flow(state, user_query):
|
||||||
|
state['execution_mode'] = 'collaboration'
|
||||||
|
state['final_response'] = 'collaboration done'
|
||||||
|
state['current_phase'] = 'phase_4_visibility_and_verification'
|
||||||
|
state['current_checkpoint'] = 'collaboration.completed'
|
||||||
|
state['messages'] = [*state.get('messages', []), AIMessage(content=state['final_response'])]
|
||||||
|
return state
|
||||||
|
|
||||||
|
monkeypatch.setattr(graph_module, '_run_collaboration_flow', fake_collaboration_flow)
|
||||||
|
|
||||||
|
state = _base_state('先帮我搜索竞品资料,然后分析风险,再给我安排下周计划')
|
||||||
|
result = await master_node(state)
|
||||||
|
|
||||||
|
assert result['execution_mode'] == 'collaboration'
|
||||||
|
assert result['final_response'] == 'collaboration done'
|
||||||
|
assert result['current_phase'] == 'phase_4_visibility_and_verification'
|
||||||
|
assert result['current_checkpoint'] == 'collaboration.completed'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_collaboration_flow_fallback_restores_routing_phase(monkeypatch):
|
||||||
|
monkeypatch.setattr(graph_module, '_build_collaboration_tasks', lambda _user_query: [
|
||||||
|
AgentTask(
|
||||||
|
task_id='task-1',
|
||||||
|
title='单任务',
|
||||||
|
role=AgentRole.LIBRARIAN.value,
|
||||||
|
owner_agent_id=AgentRole.LIBRARIAN.value,
|
||||||
|
goal='检索资料',
|
||||||
|
expected_evidence=[{'type': 'evidence'}],
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
state = _base_state('帮我搜一下资料')
|
||||||
|
result = await _run_collaboration_flow(state, '帮我搜一下资料')
|
||||||
|
|
||||||
|
assert result['execution_mode'] == 'direct'
|
||||||
|
assert result['routing_decision']['reason'] == 'collaboration_plan_fell_back'
|
||||||
|
assert result['current_phase'] == 'phase_1_routing'
|
||||||
|
assert result['current_checkpoint'] == 'routing.direct_resumed'
|
||||||
|
assert result['checkpoint_history'][-2]['checkpoint'] == 'collaboration.fallback_to_direct'
|
||||||
|
assert result['checkpoint_history'][-1]['checkpoint'] == 'routing.direct_resumed'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_master_node_returns_stable_reply_for_simple_greeting(monkeypatch):
|
||||||
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM())
|
monkeypatch.setattr('app.agents.graph._get_llm_for_state', lambda state: FailIfCalledLLM())
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
@@ -1062,8 +1461,221 @@ async def test_master_node_returns_stable_reply_for_capability_question(monkeypa
|
|||||||
assert getattr(result['messages'][-1], 'content', '') == result['final_response']
|
assert getattr(result['messages'][-1], 'content', '') == result['final_response']
|
||||||
|
|
||||||
|
|
||||||
def test_choose_sub_commander_routes_schedule_requests_to_schedule_planning():
|
def test_build_verifier_hints_uses_capability_metadata():
|
||||||
assert _choose_sub_commander(AgentRole.SCHEDULE_PLANNER, '帮我安排一下这周计划') == 'schedule_planning'
|
state = _base_state('明天提醒我开会')
|
||||||
|
|
||||||
|
hints = _build_verifier_hints(state, 'create_reminder', '提醒创建成功')
|
||||||
|
|
||||||
|
assert hints['tool_name'] == 'create_reminder'
|
||||||
|
assert hints['permission_class'] == 'write'
|
||||||
|
assert hints['side_effect_scope'] == 'local_state'
|
||||||
|
assert hints['requires_confirmation'] is True
|
||||||
|
assert hints['supports_retry'] is False
|
||||||
|
assert hints['safe_for_parallel_use'] is False
|
||||||
|
assert '提醒创建成功' in hints['result_preview']
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_isolation_context_selects_session_for_stateful_tools():
|
||||||
|
state = _base_state('reminder request')
|
||||||
|
|
||||||
|
graph_module._prepare_isolation_context(
|
||||||
|
state,
|
||||||
|
role=AgentRole.SCHEDULE_PLANNER,
|
||||||
|
sub_commander='schedule_planning',
|
||||||
|
user_query='create a reminder for tomorrow morning and keep the intermediate state isolated',
|
||||||
|
toolset=[FakeTool('create_reminder', 'ok')],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['isolation_mode'] == 'session'
|
||||||
|
assert state['isolation_workspace_path'] is None
|
||||||
|
assert state['isolation_metadata']['reason'] == 'stateful_or_non_parallel_tooling'
|
||||||
|
assert state['event_trace'][-1]['event_type'] == 'agent.isolation.selected'
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_isolation_context_uses_worktree_for_repo_mutation_queries(monkeypatch):
|
||||||
|
state = _base_state('fix repo build and create patch')
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
graph_module,
|
||||||
|
'prepare_worktree_isolation',
|
||||||
|
lambda **kwargs: {
|
||||||
|
'mode': 'worktree',
|
||||||
|
'isolation_id': 'worktree-test',
|
||||||
|
'workspace_path': '/tmp/jarvis/worktree-test',
|
||||||
|
'parent_conversation_id': 'c1',
|
||||||
|
'metadata': {
|
||||||
|
'reason': kwargs['decision'].reason,
|
||||||
|
'branch': 'jarvis/c1/executor-worktree-test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
graph_module._prepare_isolation_context(
|
||||||
|
state,
|
||||||
|
role=AgentRole.EXECUTOR,
|
||||||
|
sub_commander='executor_tasks',
|
||||||
|
user_query='fix repo build and create patch for the failing tests',
|
||||||
|
toolset=[FakeTool('create_task', 'ok')],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['isolation_mode'] == 'worktree'
|
||||||
|
assert state['isolation_workspace_path'] == '/tmp/jarvis/worktree-test'
|
||||||
|
assert state['isolation_metadata']['branch'] == 'jarvis/c1/executor-worktree-test'
|
||||||
|
assert state['event_trace'][-1]['event_type'] == 'agent.isolation.selected'
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_response_usage_updates_state_cost_totals_and_budget_warning():
|
||||||
|
state = _base_state('test')
|
||||||
|
state['cost_thresholds'] = {'total_tokens': 100, 'estimated_cost': 0.0001}
|
||||||
|
|
||||||
|
graph_module._record_response_usage(
|
||||||
|
state,
|
||||||
|
AIMessage(
|
||||||
|
content='ok',
|
||||||
|
usage_metadata={'input_tokens': 60, 'output_tokens': 50, 'total_tokens': 110},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert state['input_tokens'] == 60
|
||||||
|
assert state['output_tokens'] == 50
|
||||||
|
assert state['estimated_cost'] == 0.00093
|
||||||
|
assert state['budget_warning'] is True
|
||||||
|
assert state['cost_by_agent'][AgentRole.MASTER.value]['total_tokens'] == 110
|
||||||
|
assert [event['event_type'] for event in state['event_trace']] == [
|
||||||
|
'agent.cost.updated',
|
||||||
|
'agent.cost.warning',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_tool_calls_records_schema_events_and_aggregate_summaries(monkeypatch):
|
||||||
|
tool = FakeTool('create_reminder', '提醒创建成功: 开会 @ 2026-03-29 09:00')
|
||||||
|
state = _base_state('test')
|
||||||
|
|
||||||
|
normalized_calls, tool_result, created_entities, tool_messages = await _execute_tool_calls(
|
||||||
|
[{'id': 'task-1', 'name': 'create_reminder', 'args': {'title': '开会', 'reminder_at': '2026-03-29T09:00:00'}}],
|
||||||
|
[tool],
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert normalized_calls[0]['name'] == 'create_reminder'
|
||||||
|
assert tool_result.startswith('[create_reminder]')
|
||||||
|
assert created_entities == [{'type': 'reminder', 'tool': 'create_reminder'}]
|
||||||
|
assert len(tool_messages) == 1
|
||||||
|
assert state['verifier_hints'] == {
|
||||||
|
'tools': [
|
||||||
|
{
|
||||||
|
'tool_name': 'create_reminder',
|
||||||
|
'permission_class': 'write',
|
||||||
|
'side_effect_scope': 'local_state',
|
||||||
|
'requires_confirmation': True,
|
||||||
|
'supports_retry': False,
|
||||||
|
'safe_for_parallel_use': False,
|
||||||
|
'result_preview': '提醒创建成功: 开会 @ 2026-03-29 09:00',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert state['task_result_summary']['tool_count'] == 1
|
||||||
|
assert state['task_result_summary']['created_entity_types'] == ['reminder']
|
||||||
|
assert state['tool_outcomes'][0]['tool_name'] == 'create_reminder'
|
||||||
|
assert state['event_trace'][0]['event_type'] == 'agent.tool.start'
|
||||||
|
assert state['event_trace'][-1]['event_type'] == 'agent.tool.result'
|
||||||
|
assert state['event_trace'][-1]['payload']['verification']['tool_name'] == 'create_reminder'
|
||||||
|
assert state['task_result_summary'] == {
|
||||||
|
'tool_count': 1,
|
||||||
|
'tools': [
|
||||||
|
{
|
||||||
|
'tool_name': 'create_reminder',
|
||||||
|
'result_preview': '提醒创建成功: 开会 @ 2026-03-29 09:00',
|
||||||
|
'created_entity_types': ['reminder'],
|
||||||
|
'created_count': 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'created_count': 1,
|
||||||
|
'created_entity_types': ['reminder'],
|
||||||
|
'stop_reason': None,
|
||||||
|
}
|
||||||
|
assert state['action_results'][-1] == state['task_result_summary']
|
||||||
|
assert state['tool_outcomes'][0]['tool_name'] == 'create_reminder'
|
||||||
|
assert [event['event_type'] for event in state['event_trace']] == [
|
||||||
|
'agent.tool.start',
|
||||||
|
'agent.tool.result',
|
||||||
|
]
|
||||||
|
assert all('event_id' in event for event in state['event_trace'])
|
||||||
|
assert all('timestamp' in event for event in state['event_trace'])
|
||||||
|
assert all(event['conversation_id'] == 'c1' for event in state['event_trace'])
|
||||||
|
assert all(event['agent_id'] == AgentRole.MASTER.value for event in state['event_trace'])
|
||||||
|
assert all(event['task_id'] == 'task-1' for event in state['event_trace'])
|
||||||
|
assert all(event['thread_id'] is not None for event in state['event_trace'])
|
||||||
|
assert all(event['message_id'] is None for event in state['event_trace'])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_execute_tool_calls_aggregates_multiple_tool_turns_without_overwrite(monkeypatch):
|
||||||
|
reminder_tool = FakeTool('create_reminder', '提醒创建成功: 开会 @ 2026-03-29 09:00')
|
||||||
|
search_tool = FakeTool('web_search', '成功搜索到 2 条网页结果')
|
||||||
|
state = _base_state('test')
|
||||||
|
|
||||||
|
normalized_calls, tool_result, created_entities, tool_messages = await _execute_tool_calls(
|
||||||
|
[
|
||||||
|
{'id': 'task-1', 'name': 'create_reminder', 'args': {'title': '开会', 'reminder_at': '2026-03-29T09:00:00'}},
|
||||||
|
{'id': 'task-2', 'name': 'web_search', 'args': {'query': 'Jarvis 最新模型更新'}},
|
||||||
|
],
|
||||||
|
[reminder_tool, search_tool],
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [call['name'] for call in normalized_calls] == ['create_reminder', 'web_search']
|
||||||
|
assert tool_result == '[create_reminder] 提醒创建成功: 开会 @ 2026-03-29 09:00\n[web_search] 成功搜索到 2 条网页结果'
|
||||||
|
assert created_entities == [{'type': 'reminder', 'tool': 'create_reminder'}]
|
||||||
|
assert [message.name for message in tool_messages] == ['create_reminder', 'web_search']
|
||||||
|
assert state['verifier_hints'] == {
|
||||||
|
'tools': [
|
||||||
|
{
|
||||||
|
'tool_name': 'create_reminder',
|
||||||
|
'permission_class': 'write',
|
||||||
|
'side_effect_scope': 'local_state',
|
||||||
|
'requires_confirmation': True,
|
||||||
|
'supports_retry': False,
|
||||||
|
'safe_for_parallel_use': False,
|
||||||
|
'result_preview': '提醒创建成功: 开会 @ 2026-03-29 09:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'tool_name': 'web_search',
|
||||||
|
'permission_class': 'external',
|
||||||
|
'side_effect_scope': 'network',
|
||||||
|
'requires_confirmation': False,
|
||||||
|
'supports_retry': True,
|
||||||
|
'safe_for_parallel_use': True,
|
||||||
|
'result_preview': '成功搜索到 2 条网页结果',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert state['task_result_summary'] == {
|
||||||
|
'tool_count': 2,
|
||||||
|
'tools': [
|
||||||
|
{
|
||||||
|
'tool_name': 'create_reminder',
|
||||||
|
'result_preview': '提醒创建成功: 开会 @ 2026-03-29 09:00',
|
||||||
|
'created_entity_types': ['reminder'],
|
||||||
|
'created_count': 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'tool_name': 'web_search',
|
||||||
|
'result_preview': '成功搜索到 2 条网页结果',
|
||||||
|
'created_entity_types': [],
|
||||||
|
'created_count': 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'created_count': 1,
|
||||||
|
'created_entity_types': ['reminder'],
|
||||||
|
'stop_reason': None,
|
||||||
|
}
|
||||||
|
assert len(state['tool_outcomes']) == 2
|
||||||
|
assert [event['event_type'] for event in state['event_trace']] == [
|
||||||
|
'agent.tool.start',
|
||||||
|
'agent.tool.result',
|
||||||
|
'agent.tool.start',
|
||||||
|
'agent.tool.result',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_choose_sub_commander_routes_focus_requests_to_schedule_analysis():
|
def test_choose_sub_commander_routes_focus_requests_to_schedule_analysis():
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from app.agents.prompts import MASTER_SYSTEM_PROMPT
|
from app.agents.prompts import COORDINATOR_SYSTEM_PROMPT, MASTER_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
def test_master_prompt_forbids_subagent_rollcall_in_simple_greetings():
|
def test_master_prompt_forbids_subagent_rollcall_in_simple_greetings():
|
||||||
@@ -10,3 +10,10 @@ def test_master_prompt_does_not_include_full_canned_answers_for_greetings_or_ide
|
|||||||
assert 'Jarvis:您好。我在。' not in MASTER_SYSTEM_PROMPT
|
assert 'Jarvis:您好。我在。' not in MASTER_SYSTEM_PROMPT
|
||||||
assert 'Jarvis:我是 Jarvis。' not in MASTER_SYSTEM_PROMPT
|
assert 'Jarvis:我是 Jarvis。' not in MASTER_SYSTEM_PROMPT
|
||||||
assert 'Jarvis:主要做三件事。' not in MASTER_SYSTEM_PROMPT
|
assert 'Jarvis:主要做三件事。' not in MASTER_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_prompt_limits_collaboration_scope():
|
||||||
|
assert "2~4 个子任务" in COORDINATOR_SYSTEM_PROMPT
|
||||||
|
assert "禁止无限递归拆分" in COORDINATOR_SYSTEM_PROMPT
|
||||||
|
assert "schedule_planner" in COORDINATOR_SYSTEM_PROMPT
|
||||||
|
assert "librarian" in COORDINATOR_SYSTEM_PROMPT
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ from app.agents.prompts import (
|
|||||||
SUB_COMMANDER_PROMPTS_BY_KEY,
|
SUB_COMMANDER_PROMPTS_BY_KEY,
|
||||||
TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY,
|
TOP_LEVEL_SYSTEM_PROMPTS_BY_KEY,
|
||||||
)
|
)
|
||||||
from app.agents.registry import build_registry_indexes, load_builtin_registry_bundle
|
from app.agents.registry import build_registry_indexes, load_builtin_registry_bundle, load_builtin_registry_indexes
|
||||||
from app.agents.registry.indexes import summarize_registry_indexes
|
from app.agents.registry.indexes import summarize_registry_indexes
|
||||||
from app.agents.registry.models import (
|
from app.agents.registry.models import (
|
||||||
AgentManifest,
|
AgentManifest,
|
||||||
CapabilityManifest,
|
CapabilityManifest,
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
SpecialistTemplateManifest,
|
SpecialistTemplateManifest,
|
||||||
SubCommanderManifest,
|
SubCommanderManifest,
|
||||||
)
|
)
|
||||||
@@ -251,17 +253,34 @@ def test_builtin_capabilities_reference_actual_runtime_tool_names() -> None:
|
|||||||
assert manifest_tool_names == expected_tool_names
|
assert manifest_tool_names == expected_tool_names
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_sub_commander_capabilities_match_runtime_toolsets() -> None:
|
def test_builtin_capability_metadata_distinguishes_read_and_write_surfaces() -> None:
|
||||||
capabilities_by_tool_name = {
|
capability_by_id = {manifest.capability_id: manifest for manifest in BUILTIN_CAPABILITY_MANIFESTS}
|
||||||
manifest.tool_name: manifest.capability_id for manifest in BUILTIN_CAPABILITY_MANIFESTS
|
|
||||||
}
|
|
||||||
|
|
||||||
for sub_commander in BUILTIN_SUB_COMMANDER_MANIFESTS:
|
assert capability_by_id["get_tasks"].permission_class == PermissionClass.READ
|
||||||
expected_capability_ids = {
|
assert capability_by_id["get_tasks"].side_effect_scope == SideEffectScope.NONE
|
||||||
capabilities_by_tool_name[tool.name]
|
assert capability_by_id["get_tasks"].supports_retry is True
|
||||||
for tool in SUB_COMMANDER_TOOLSETS[sub_commander.sub_commander_id]
|
assert capability_by_id["get_tasks"].idempotent is True
|
||||||
}
|
assert capability_by_id["get_tasks"].safe_for_parallel_use is True
|
||||||
assert set(sub_commander.capability_ids) == expected_capability_ids
|
assert capability_by_id["get_tasks"].requires_confirmation is False
|
||||||
|
|
||||||
|
assert capability_by_id["create_reminder"].permission_class == PermissionClass.WRITE
|
||||||
|
assert capability_by_id["create_reminder"].side_effect_scope == SideEffectScope.LOCAL_STATE
|
||||||
|
assert capability_by_id["create_reminder"].supports_retry is False
|
||||||
|
assert capability_by_id["create_reminder"].idempotent is False
|
||||||
|
assert capability_by_id["create_reminder"].safe_for_parallel_use is False
|
||||||
|
assert capability_by_id["create_reminder"].requires_confirmation is True
|
||||||
|
|
||||||
|
assert capability_by_id["web_search"].permission_class == PermissionClass.EXTERNAL
|
||||||
|
assert capability_by_id["web_search"].side_effect_scope == SideEffectScope.NETWORK
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_builtin_registry_indexes_is_cached_and_matches_bundle_indexes() -> None:
|
||||||
|
cached = load_builtin_registry_indexes()
|
||||||
|
rebuilt = build_registry_indexes(load_builtin_registry_bundle())
|
||||||
|
|
||||||
|
assert cached is load_builtin_registry_indexes()
|
||||||
|
assert cached.capability_id_by_tool_name == rebuilt.capability_id_by_tool_name
|
||||||
|
assert cached.capability_by_id["create_reminder"].requires_confirmation is True
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_manifests_form_a_valid_registry_bundle() -> None:
|
def test_builtin_manifests_form_a_valid_registry_bundle() -> None:
|
||||||
@@ -288,6 +307,7 @@ def test_build_registry_indexes_exposes_manifest_lookups_by_id() -> None:
|
|||||||
indexes = build_registry_indexes(bundle)
|
indexes = build_registry_indexes(bundle)
|
||||||
|
|
||||||
assert indexes.agent_by_id
|
assert indexes.agent_by_id
|
||||||
|
assert indexes.agent_by_role_value
|
||||||
assert indexes.sub_commander_by_id
|
assert indexes.sub_commander_by_id
|
||||||
assert indexes.capability_by_id
|
assert indexes.capability_by_id
|
||||||
assert isinstance(indexes.specialist_template_by_id, Mapping)
|
assert isinstance(indexes.specialist_template_by_id, Mapping)
|
||||||
@@ -343,6 +363,14 @@ def test_build_registry_indexes_exposes_prompt_keys_skill_context_keys_and_capab
|
|||||||
sub_commander.sub_commander_id: tuple(sub_commander.capability_ids)
|
sub_commander.sub_commander_id: tuple(sub_commander.capability_ids)
|
||||||
for sub_commander in bundle.sub_commanders
|
for sub_commander in bundle.sub_commanders
|
||||||
}
|
}
|
||||||
|
assert indexes.agent_by_role_value == {
|
||||||
|
agent.role_value: agent for agent in bundle.agents
|
||||||
|
}
|
||||||
|
assert indexes.spawnable_role_values_by_agent_id == {
|
||||||
|
agent.agent_id: tuple(agent.allowed_spawn_role_values)
|
||||||
|
for agent in bundle.agents
|
||||||
|
if agent.can_spawn_children and agent.allowed_spawn_role_values
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_validate_registry_bundle_accepts_loaded_builtin_registry_bundle() -> None:
|
def test_validate_registry_bundle_accepts_loaded_builtin_registry_bundle() -> None:
|
||||||
|
|||||||
135
backend/tests/backend/app/agents/test_schema_verifier.py
Normal file
135
backend/tests/backend/app/agents/test_schema_verifier.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
from app.agents.schemas import AgentEvent, AgentTask, TaskResult
|
||||||
|
from app.agents.schemas.task import CollaborationBudget, InterruptRecord, RecoveryRecord
|
||||||
|
from app.agents.state import initial_state
|
||||||
|
from app.agents.verifier import apply_verification_verdict, normalize_task_result, verify_task_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_task_supports_day3_interrupt_recovery_and_budget_fields():
|
||||||
|
interrupt = InterruptRecord(interrupt_id="interrupt-1", reason="user_cancel")
|
||||||
|
recovery = RecoveryRecord(recovery_id="recovery-1", source_interrupt_id="interrupt-1", resumed_from_task_id="task-1")
|
||||||
|
budget = CollaborationBudget(
|
||||||
|
mode="collaboration",
|
||||||
|
max_parallel_tasks=3,
|
||||||
|
remaining_parallel_tasks=2,
|
||||||
|
max_tool_calls=6,
|
||||||
|
remaining_tool_calls=4,
|
||||||
|
metadata={"phase": "day3"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = AgentTask(
|
||||||
|
task_id="task-1",
|
||||||
|
title="Recover interrupted collaboration task",
|
||||||
|
owner_agent_id="analyst",
|
||||||
|
role="analyst",
|
||||||
|
parent_task_id="parent-1",
|
||||||
|
child_task_ids=["child-1"],
|
||||||
|
thread_id="thread-1",
|
||||||
|
message_id="message-1",
|
||||||
|
message_index=3,
|
||||||
|
interrupt_records=[interrupt],
|
||||||
|
recovery_records=[recovery],
|
||||||
|
collaboration_budget=budget,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = task.model_dump(mode="json")
|
||||||
|
|
||||||
|
assert payload["parent_task_id"] == "parent-1"
|
||||||
|
assert payload["child_task_ids"] == ["child-1"]
|
||||||
|
assert payload["thread_id"] == "thread-1"
|
||||||
|
assert payload["message_id"] == "message-1"
|
||||||
|
assert payload["message_index"] == 3
|
||||||
|
assert payload["interrupt_records"][0]["interrupt_id"] == "interrupt-1"
|
||||||
|
assert payload["recovery_records"][0]["recovery_id"] == "recovery-1"
|
||||||
|
assert payload["collaboration_budget"]["mode"] == "collaboration"
|
||||||
|
assert payload["collaboration_budget"]["remaining_tool_calls"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_event_supports_day3_thread_interrupt_and_recovery_metadata():
|
||||||
|
event = AgentEvent(
|
||||||
|
event_id="evt-1",
|
||||||
|
event_type="agent.task.recovered",
|
||||||
|
conversation_id="conv-1",
|
||||||
|
agent_id="executor",
|
||||||
|
task_id="task-1",
|
||||||
|
parent_task_id="parent-1",
|
||||||
|
child_task_id="child-1",
|
||||||
|
thread_id="thread-1",
|
||||||
|
message_id="message-1",
|
||||||
|
interrupt_id="interrupt-1",
|
||||||
|
recovery_id="recovery-1",
|
||||||
|
severity="warning",
|
||||||
|
payload={"status": "resumed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = event.model_dump(mode="json")
|
||||||
|
|
||||||
|
assert payload["event_type"] == "agent.task.recovered"
|
||||||
|
assert payload["parent_task_id"] == "parent-1"
|
||||||
|
assert payload["child_task_id"] == "child-1"
|
||||||
|
assert payload["thread_id"] == "thread-1"
|
||||||
|
assert payload["message_id"] == "message-1"
|
||||||
|
assert payload["interrupt_id"] == "interrupt-1"
|
||||||
|
assert payload["recovery_id"] == "recovery-1"
|
||||||
|
assert payload["severity"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_task_result_preserves_day3_metadata_fields():
|
||||||
|
result = normalize_task_result(
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"status": "completed",
|
||||||
|
"summary": "Recovered successfully.",
|
||||||
|
"owner_agent_id": "executor",
|
||||||
|
"parent_task_id": "parent-1",
|
||||||
|
"child_task_ids": ["child-1"],
|
||||||
|
"thread_id": "thread-1",
|
||||||
|
"message_id": "message-1",
|
||||||
|
"message_index": 2,
|
||||||
|
"interrupt_records": [{"interrupt_id": "interrupt-1", "reason": "user_pause"}],
|
||||||
|
"recovery_records": [{"recovery_id": "recovery-1", "source_interrupt_id": "interrupt-1"}],
|
||||||
|
"budget_snapshot": {"mode": "collaboration", "max_parallel_tasks": 4},
|
||||||
|
"next_action": "notify_user",
|
||||||
|
"output_data": {"ok": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.parent_task_id == "parent-1"
|
||||||
|
assert result.child_task_ids == ["child-1"]
|
||||||
|
assert result.thread_id == "thread-1"
|
||||||
|
assert result.message_id == "message-1"
|
||||||
|
assert result.message_index == 2
|
||||||
|
assert result.interrupt_records[0].interrupt_id == "interrupt-1"
|
||||||
|
assert result.recovery_records[0].recovery_id == "recovery-1"
|
||||||
|
assert result.budget_snapshot.mode == "collaboration"
|
||||||
|
assert result.budget_snapshot.max_parallel_tasks == 4
|
||||||
|
assert result.next_action == "notify_user"
|
||||||
|
assert result.output_data == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_verification_verdict_updates_state_with_recovery_evidence():
|
||||||
|
state = initial_state("u1", "c1")
|
||||||
|
|
||||||
|
verdict = verify_task_result(
|
||||||
|
status="passed",
|
||||||
|
summary="Interrupt and recovery chain verified.",
|
||||||
|
evidence=[
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"thread_id": "thread-1",
|
||||||
|
"interrupt_id": "interrupt-1",
|
||||||
|
"recovery_id": "recovery-1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
updated_state = apply_verification_verdict(state, verdict)
|
||||||
|
|
||||||
|
assert updated_state["verification_status"] == "passed"
|
||||||
|
assert updated_state["verification_summary"] == "Interrupt and recovery chain verified."
|
||||||
|
assert updated_state["verification_evidence"] == [
|
||||||
|
{
|
||||||
|
"task_id": "task-1",
|
||||||
|
"thread_id": "thread-1",
|
||||||
|
"interrupt_id": "interrupt-1",
|
||||||
|
"recovery_id": "recovery-1",
|
||||||
|
}
|
||||||
|
]
|
||||||
324
backend/tests/backend/app/agents/test_tools_registry.py
Normal file
324
backend/tests/backend/app/agents/test_tools_registry.py
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
"""ToolRegistry 单元测试"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from app.agents.tools.manifest import (
|
||||||
|
HookConfig,
|
||||||
|
PermissionClass,
|
||||||
|
SideEffectScope,
|
||||||
|
ToolCategory,
|
||||||
|
ToolManifest,
|
||||||
|
)
|
||||||
|
from app.agents.tools.registry import (
|
||||||
|
ToolRegistry,
|
||||||
|
get_tool_registry,
|
||||||
|
reset_tool_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def registry():
|
||||||
|
"""创建空的 ToolRegistry 实例"""
|
||||||
|
return ToolRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_manifest():
|
||||||
|
"""创建示例工具元数据"""
|
||||||
|
return ToolManifest(
|
||||||
|
name="test_tool",
|
||||||
|
description="A test tool",
|
||||||
|
category=ToolCategory.READ,
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"input": {"type": "string"}},
|
||||||
|
"required": ["input"],
|
||||||
|
},
|
||||||
|
return_schema={"type": "string"},
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
requires_confirmation=False,
|
||||||
|
is_streaming=False,
|
||||||
|
tags=["test", "sample"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_executor():
|
||||||
|
"""创建示例执行器"""
|
||||||
|
|
||||||
|
def executor(input: str) -> str:
|
||||||
|
return f"processed: {input}"
|
||||||
|
|
||||||
|
return executor
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryInit:
|
||||||
|
"""测试 ToolRegistry 初始化"""
|
||||||
|
|
||||||
|
def test_empty_registry(self, registry):
|
||||||
|
assert len(registry) == 0
|
||||||
|
assert list(registry) == []
|
||||||
|
|
||||||
|
def test_empty_contains_false(self, registry):
|
||||||
|
assert "test_tool" not in registry
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryRegister:
|
||||||
|
"""测试工具注册"""
|
||||||
|
|
||||||
|
def test_register_single_tool(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert len(registry) == 1
|
||||||
|
assert "test_tool" in registry
|
||||||
|
|
||||||
|
def test_register_duplicate_raises(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Tool already registered"):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
def test_register_with_hooks(self, registry, sample_manifest, sample_executor):
|
||||||
|
hooks = [
|
||||||
|
HookConfig(name="audit", hook_type="pre_tool_use"),
|
||||||
|
HookConfig(name="security", hook_type="post_tool_use"),
|
||||||
|
]
|
||||||
|
registry.register(sample_manifest, sample_executor, hooks=hooks)
|
||||||
|
|
||||||
|
tool_hooks = registry.get_hooks("test_tool")
|
||||||
|
assert len(tool_hooks) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryGet:
|
||||||
|
"""测试获取工具"""
|
||||||
|
|
||||||
|
def test_get_existing_tool(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
manifest = registry.get("test_tool")
|
||||||
|
assert manifest is not None
|
||||||
|
assert manifest.name == "test_tool"
|
||||||
|
|
||||||
|
def test_get_nonexistent_tool(self, registry):
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_get_executor(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
executor = registry.get_executor("test_tool")
|
||||||
|
assert executor is not None
|
||||||
|
assert executor("hello") == "processed: hello"
|
||||||
|
|
||||||
|
def test_get_nonexistent_executor(self, registry):
|
||||||
|
assert registry.get_executor("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryList:
|
||||||
|
"""测试工具列表"""
|
||||||
|
|
||||||
|
def test_list_all_empty(self, registry):
|
||||||
|
assert registry.list_all() == []
|
||||||
|
|
||||||
|
def test_list_all_with_tools(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
manifest2 = ToolManifest(
|
||||||
|
name="test_tool2",
|
||||||
|
description="Another test tool",
|
||||||
|
category=ToolCategory.WRITE,
|
||||||
|
parameters={"type": "object"},
|
||||||
|
return_schema={"type": "string"},
|
||||||
|
permission_class=PermissionClass.WRITE,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
)
|
||||||
|
registry.register(manifest2, sample_executor)
|
||||||
|
|
||||||
|
all_tools = registry.list_all()
|
||||||
|
assert len(all_tools) == 2
|
||||||
|
|
||||||
|
def test_list_by_category(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
manifest2 = ToolManifest(
|
||||||
|
name="write_tool",
|
||||||
|
description="A write tool",
|
||||||
|
category=ToolCategory.WRITE,
|
||||||
|
parameters={"type": "object"},
|
||||||
|
return_schema={"type": "string"},
|
||||||
|
permission_class=PermissionClass.WRITE,
|
||||||
|
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||||
|
requires_confirmation=True,
|
||||||
|
)
|
||||||
|
registry.register(manifest2, sample_executor)
|
||||||
|
|
||||||
|
read_tools = registry.list_by_category(ToolCategory.READ)
|
||||||
|
write_tools = registry.list_by_category(ToolCategory.WRITE)
|
||||||
|
|
||||||
|
assert len(read_tools) == 1
|
||||||
|
assert len(write_tools) == 1
|
||||||
|
|
||||||
|
def test_list_by_permission(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
read_tools = registry.list_by_permission(PermissionClass.READ)
|
||||||
|
write_tools = registry.list_by_permission(PermissionClass.WRITE)
|
||||||
|
|
||||||
|
assert len(read_tools) == 1
|
||||||
|
assert len(write_tools) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistrySearch:
|
||||||
|
"""测试工具搜索"""
|
||||||
|
|
||||||
|
def test_search_by_tag(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
results = registry.search_by_tag("test")
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].name == "test_tool"
|
||||||
|
|
||||||
|
results = registry.search_by_tag("nonexistent")
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
def test_search_by_name(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
results = registry.search_by_name("test")
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
results = registry.search_by_name("tool")
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
results = registry.search_by_name("xyz")
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryUtility:
|
||||||
|
"""测试工具方法"""
|
||||||
|
|
||||||
|
def test_requires_confirmation_true(self, registry, sample_manifest, sample_executor):
|
||||||
|
sample_manifest.requires_confirmation = True
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert registry.get_requires_confirmation("test_tool") is True
|
||||||
|
|
||||||
|
def test_requires_confirmation_false(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert registry.get_requires_confirmation("test_tool") is False
|
||||||
|
|
||||||
|
def test_requires_confirmation_nonexistent(self, registry):
|
||||||
|
assert registry.get_requires_confirmation("nonexistent") is False
|
||||||
|
|
||||||
|
def test_is_streaming_true(self, registry, sample_manifest, sample_executor):
|
||||||
|
sample_manifest.is_streaming = True
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert registry.get_is_streaming("test_tool") is True
|
||||||
|
|
||||||
|
def test_is_streaming_false(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert registry.get_is_streaming("test_tool") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryUnregister:
|
||||||
|
"""测试工具注销"""
|
||||||
|
|
||||||
|
def test_unregister_existing(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
assert registry.unregister("test_tool") is True
|
||||||
|
assert len(registry) == 0
|
||||||
|
assert "test_tool" not in registry
|
||||||
|
|
||||||
|
def test_unregister_nonexistent(self, registry):
|
||||||
|
assert registry.unregister("nonexistent") is False
|
||||||
|
|
||||||
|
def test_unregister_clears_hooks(self, registry, sample_manifest, sample_executor):
|
||||||
|
hooks = [HookConfig(name="test", hook_type="pre_tool_use")]
|
||||||
|
registry.register(sample_manifest, sample_executor, hooks=hooks)
|
||||||
|
|
||||||
|
registry.unregister("test_tool")
|
||||||
|
|
||||||
|
assert registry.get_hooks("test_tool") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistryClear:
|
||||||
|
"""测试清空注册表"""
|
||||||
|
|
||||||
|
def test_clear(self, registry, sample_manifest, sample_executor):
|
||||||
|
registry.register(sample_manifest, sample_executor)
|
||||||
|
|
||||||
|
manifest2 = ToolManifest(
|
||||||
|
name="tool2",
|
||||||
|
description="Another tool",
|
||||||
|
category=ToolCategory.READ,
|
||||||
|
parameters={"type": "object"},
|
||||||
|
return_schema={"type": "string"},
|
||||||
|
permission_class=PermissionClass.READ,
|
||||||
|
side_effect_scope=SideEffectScope.NONE,
|
||||||
|
requires_confirmation=False,
|
||||||
|
)
|
||||||
|
registry.register(manifest2, sample_executor)
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
assert len(registry) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalRegistry:
|
||||||
|
"""测试全局注册表"""
|
||||||
|
|
||||||
|
def test_get_tool_registry_returns_singleton(self):
|
||||||
|
reset_tool_registry()
|
||||||
|
|
||||||
|
reg1 = get_tool_registry()
|
||||||
|
reg2 = get_tool_registry()
|
||||||
|
|
||||||
|
assert reg1 is reg2
|
||||||
|
|
||||||
|
def test_reset_tool_registry(self):
|
||||||
|
reset_tool_registry()
|
||||||
|
reg1 = get_tool_registry()
|
||||||
|
|
||||||
|
reset_tool_registry()
|
||||||
|
reg2 = get_tool_registry()
|
||||||
|
|
||||||
|
# After reset, should get a new instance
|
||||||
|
# (Note: this tests the reset function works)
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolManifest:
|
||||||
|
"""测试 ToolManifest"""
|
||||||
|
|
||||||
|
def test_to_dict(self, sample_manifest):
|
||||||
|
d = sample_manifest.to_dict()
|
||||||
|
|
||||||
|
assert d["name"] == "test_tool"
|
||||||
|
assert d["description"] == "A test tool"
|
||||||
|
assert d["category"] == "read"
|
||||||
|
assert d["permission_class"] == "read"
|
||||||
|
assert d["side_effect_scope"] == "none"
|
||||||
|
assert d["requires_confirmation"] is False
|
||||||
|
assert d["is_streaming"] is False
|
||||||
|
assert d["tags"] == ["test", "sample"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHookConfig:
|
||||||
|
"""测试 HookConfig"""
|
||||||
|
|
||||||
|
def test_matches_tool_with_no_filter(self):
|
||||||
|
hook = HookConfig(name="test", hook_type="pre_tool_use")
|
||||||
|
|
||||||
|
assert hook.matches_tool("any_tool") is True
|
||||||
|
|
||||||
|
def test_matches_tool_with_filter(self):
|
||||||
|
hook = HookConfig(name="test", hook_type="pre_tool_use", filter_names=["tool_a", "tool_b"])
|
||||||
|
|
||||||
|
assert hook.matches_tool("tool_a") is True
|
||||||
|
assert hook.matches_tool("tool_b") is True
|
||||||
|
assert hook.matches_tool("tool_c") is False
|
||||||
39
backend/tests/backend/app/agents/test_verifier.py
Normal file
39
backend/tests/backend/app/agents/test_verifier.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from app.agents.schemas.task import AgentTask
|
||||||
|
from app.agents.verifier import verify_task_result
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_verdict_is_separate_from_task_lifecycle_status():
|
||||||
|
task = AgentTask(task_id="task-1", title="Verify", status="blocked", result_summary="waiting")
|
||||||
|
|
||||||
|
verdict = verify_task_result(task=task)
|
||||||
|
|
||||||
|
assert verdict.status == "skipped"
|
||||||
|
assert verdict.summary == "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_prefers_explicit_result_success_signal():
|
||||||
|
verdict = verify_task_result(result={"success": True, "summary": "all checks passed"})
|
||||||
|
|
||||||
|
assert verdict.status == "passed"
|
||||||
|
assert verdict.summary == "all checks passed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_treats_completed_task_result_as_passed():
|
||||||
|
verdict = verify_task_result(result={"status": "completed", "summary": "done", "evidence": [{"type": "log"}]})
|
||||||
|
|
||||||
|
assert verdict.status == "passed"
|
||||||
|
assert verdict.summary == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_treats_blocked_task_result_as_skipped():
|
||||||
|
verdict = verify_task_result(result={"status": "blocked", "summary": "waiting on user"})
|
||||||
|
|
||||||
|
assert verdict.status == "skipped"
|
||||||
|
assert verdict.summary == "waiting on user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verifier_fails_when_no_verification_input_exists():
|
||||||
|
verdict = verify_task_result()
|
||||||
|
|
||||||
|
assert verdict.status == "failed"
|
||||||
|
assert verdict.summary == "No verification input available."
|
||||||
739
backend/tests/backend/app/agents/test_visibility_api.py
Normal file
739
backend/tests/backend/app/agents/test_visibility_api.py
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
import app.models # noqa: F401
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.models.conversation import Conversation
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.agent import router as agent_router
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.services.auth_service import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def visibility_env(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_visibility_api.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
snapshot = {
|
||||||
|
'kind': 'agent_continuity_state',
|
||||||
|
'version': 1,
|
||||||
|
'state': {
|
||||||
|
'agent_id': 'master',
|
||||||
|
'root_agent_id': 'master',
|
||||||
|
'current_agent': 'analyst-1234abcd',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'spawned_agent_ids': ['analyst-1234abcd'],
|
||||||
|
'event_trace': [
|
||||||
|
{
|
||||||
|
'event_id': 'evt-1',
|
||||||
|
'event_type': 'agent.created',
|
||||||
|
'timestamp': (now - timedelta(minutes=10)).isoformat(),
|
||||||
|
'conversation_id': 'placeholder',
|
||||||
|
'agent_id': 'master',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'payload': {'child_agent_id': 'analyst-1234abcd'},
|
||||||
|
'severity': 'info',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'event_id': 'evt-2',
|
||||||
|
'event_type': 'agent.tool.result',
|
||||||
|
'timestamp': (now - timedelta(minutes=5)).isoformat(),
|
||||||
|
'conversation_id': 'placeholder',
|
||||||
|
'agent_id': 'analyst-1234abcd',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'payload': {'tool_name': 'search_web', 'result_preview': 'ok'},
|
||||||
|
'severity': 'info',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'message_trace': [
|
||||||
|
{
|
||||||
|
'message_id': 'msg-1',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'from_agent_id': 'master',
|
||||||
|
'to_agent_id': 'analyst-1234abcd',
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'message_type': 'task_request',
|
||||||
|
'content_summary': 'Analyze the issue',
|
||||||
|
'created_at': (now - timedelta(minutes=9)).isoformat(),
|
||||||
|
'payload': {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'message_id': 'msg-2',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'from_agent_id': 'analyst-1234abcd',
|
||||||
|
'to_agent_id': 'master',
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'reply_to_message_id': 'msg-1',
|
||||||
|
'message_type': 'task_update',
|
||||||
|
'content_summary': 'Done',
|
||||||
|
'created_at': (now - timedelta(minutes=4)).isoformat(),
|
||||||
|
'payload': {'status': 'completed'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'active_tasks': [
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'title': 'Analyze issue',
|
||||||
|
'role': 'analyst',
|
||||||
|
'owner_agent_id': 'analyst-1234abcd',
|
||||||
|
'status': 'completed',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'result_summary': 'Analysis complete',
|
||||||
|
'evidence': [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'ok',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'task_results': [
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'status': 'completed',
|
||||||
|
'summary': 'Analysis complete',
|
||||||
|
'owner_agent_id': 'analyst-1234abcd',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'evidence': [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'ok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'verification',
|
||||||
|
'status': 'passed',
|
||||||
|
'summary': 'Verified',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'task_hierarchy': {'root-task': ['task-1']},
|
||||||
|
'tool_outcomes': [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'ok',
|
||||||
|
'verifier_hints': {'tool_name': 'search_web'},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'verification_status': 'passed',
|
||||||
|
'verification_summary': 'All task evidence verified.',
|
||||||
|
'verification_evidence': [
|
||||||
|
{'task_id': 'task-1', 'status': 'passed', 'summary': 'Verified'}
|
||||||
|
],
|
||||||
|
'execution_mode': 'collaboration',
|
||||||
|
'current_phase': 'phase_4_visibility_and_verification',
|
||||||
|
'current_checkpoint': 'visibility.runtime_summary_ready',
|
||||||
|
'phase_history': [
|
||||||
|
{'phase': 'phase_0_bootstrap'},
|
||||||
|
{'phase': 'phase_4_visibility_and_verification'},
|
||||||
|
],
|
||||||
|
'checkpoint_history': [
|
||||||
|
{'checkpoint': 'bootstrap.initialized'},
|
||||||
|
{'checkpoint': 'visibility.runtime_summary_ready'},
|
||||||
|
],
|
||||||
|
'input_tokens': 120,
|
||||||
|
'output_tokens': 80,
|
||||||
|
'budget_warning': True,
|
||||||
|
'estimated_cost': 0.00156,
|
||||||
|
'cost_thresholds': {'total_tokens': 150, 'estimated_cost': 0.001},
|
||||||
|
'cost_by_agent': {
|
||||||
|
'master': {
|
||||||
|
'agent_id': 'master',
|
||||||
|
'input_tokens': 60,
|
||||||
|
'output_tokens': 20,
|
||||||
|
'total_tokens': 80,
|
||||||
|
'estimated_cost': 0.00048,
|
||||||
|
'budget_warning': False,
|
||||||
|
},
|
||||||
|
'analyst-1234abcd': {
|
||||||
|
'agent_id': 'analyst-1234abcd',
|
||||||
|
'input_tokens': 60,
|
||||||
|
'output_tokens': 60,
|
||||||
|
'total_tokens': 120,
|
||||||
|
'estimated_cost': 0.00108,
|
||||||
|
'budget_warning': True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'isolation_mode': 'worktree',
|
||||||
|
'isolation_id': 'iso-1',
|
||||||
|
'isolation_workspace_path': '/tmp/jarvis/worktree-1',
|
||||||
|
'isolation_parent_conversation_id': 'parent-conv-1',
|
||||||
|
'isolation_metadata': {'branch': 'jarvis/test-worker'},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
username='visibility_user',
|
||||||
|
email='visibility@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Visibility Tester',
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
conversation = Conversation(user_id=user.id, title='Visibility test', agent_state=snapshot)
|
||||||
|
session.add(conversation)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
await session.refresh(conversation)
|
||||||
|
|
||||||
|
snapshot['state']['event_trace'][0]['conversation_id'] = conversation.id
|
||||||
|
snapshot['state']['event_trace'][1]['conversation_id'] = conversation.id
|
||||||
|
conversation.agent_state = snapshot
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(conversation)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(agent_router)
|
||||||
|
test_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield test_app, {
|
||||||
|
'conversation_id': conversation.id,
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'started_after': (now - timedelta(minutes=11)).isoformat(),
|
||||||
|
'ended_before': (now - timedelta(minutes=1)).isoformat(),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_events_support_filters_and_pagination(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/events',
|
||||||
|
params={
|
||||||
|
'conversation_id': ids['conversation_id'],
|
||||||
|
'agent_id': 'analyst-1234abcd',
|
||||||
|
'thread_id': ids['thread_id'],
|
||||||
|
'event_type': 'agent.tool.result',
|
||||||
|
'limit': 1,
|
||||||
|
'offset': 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['total'] == 1
|
||||||
|
assert payload['limit'] == 1
|
||||||
|
assert payload['items'][0]['event_id'] == 'evt-2'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_topology_returns_nodes_edges_and_task_summary(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/topology',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['root_agent_id'] == 'master'
|
||||||
|
assert payload['current_agent'] == 'analyst-1234abcd'
|
||||||
|
assert any(node['agent_id'] == 'analyst-1234abcd' for node in payload['nodes'])
|
||||||
|
assert any(edge['child_agent_id'] == 'analyst-1234abcd' for edge in payload['edges'])
|
||||||
|
assert payload['tasks'][0]['task_id'] == ids['task_id']
|
||||||
|
assert payload['task_hierarchy'] == {'root-task': ['task-1']}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_task_evidence_returns_tool_and_verifier_evidence(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
f'/api/agents/visibility/tasks/{ids["task_id"]}/evidence',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['task']['task_id'] == ids['task_id']
|
||||||
|
assert payload['result']['status'] == 'completed'
|
||||||
|
assert payload['tool_outcomes'][0]['tool_name'] == 'search_web'
|
||||||
|
assert payload['verifier']['status'] == 'passed'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_task_evidence_uses_task_evidence_instead_of_global_tool_outcomes(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_visibility_api_task_evidence_filter.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
'kind': 'agent_continuity_state',
|
||||||
|
'version': 1,
|
||||||
|
'state': {
|
||||||
|
'agent_id': 'master',
|
||||||
|
'root_agent_id': 'master',
|
||||||
|
'current_agent': 'analyst-1234abcd',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'spawned_agent_ids': ['analyst-1234abcd'],
|
||||||
|
'event_trace': [],
|
||||||
|
'message_trace': [],
|
||||||
|
'active_tasks': [
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'title': 'Analyze issue',
|
||||||
|
'role': 'analyst',
|
||||||
|
'owner_agent_id': 'analyst-1234abcd',
|
||||||
|
'status': 'completed',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'result_summary': 'Analysis complete',
|
||||||
|
'evidence': [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'task_results': [
|
||||||
|
{
|
||||||
|
'task_id': 'task-1',
|
||||||
|
'status': 'completed',
|
||||||
|
'summary': 'Analysis complete',
|
||||||
|
'owner_agent_id': 'analyst-1234abcd',
|
||||||
|
'thread_id': 'thread-1',
|
||||||
|
'evidence': [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'task-specific',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'verification',
|
||||||
|
'status': 'passed',
|
||||||
|
'summary': 'Verified',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'task_hierarchy': {'root-task': ['task-1']},
|
||||||
|
'tool_outcomes': [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'global-duplicate',
|
||||||
|
'verifier_hints': {'tool_name': 'search_web'},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'verification_status': 'passed',
|
||||||
|
'verification_summary': 'All task evidence verified.',
|
||||||
|
'verification_evidence': [
|
||||||
|
{'task_id': 'task-1', 'status': 'passed', 'summary': 'Verified'}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
username='task_evidence_user',
|
||||||
|
email='task-evidence@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Task Evidence Tester',
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
conversation = Conversation(user_id=user.id, title='Task evidence test', agent_state=snapshot)
|
||||||
|
session.add(conversation)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
await session.refresh(conversation)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(agent_router)
|
||||||
|
test_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
transport = ASGITransport(app=test_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/tasks/task-1/evidence',
|
||||||
|
params={'conversation_id': conversation.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['tool_outcomes'] == [
|
||||||
|
{
|
||||||
|
'tool_name': 'search_web',
|
||||||
|
'args': {'query': 'jarvis visibility'},
|
||||||
|
'result_preview': 'task-specific',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_thread_messages_returns_thread_history(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
f'/api/agents/visibility/threads/{ids["thread_id"]}/messages',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['thread_id'] == ids['thread_id']
|
||||||
|
assert payload['total'] == 2
|
||||||
|
assert payload['items'][1]['reply_to_message_id'] == 'msg-1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_verifier_returns_verdict(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/verifier',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['status'] == 'passed'
|
||||||
|
assert payload['summary'] == 'All task evidence verified.'
|
||||||
|
assert payload['evidence'][0]['task_id'] == ids['task_id']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_runtime_summary_returns_phase_cost_and_isolation_metadata(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/runtime-summary',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['conversation_id'] == ids['conversation_id']
|
||||||
|
assert payload['execution_mode'] == 'collaboration'
|
||||||
|
assert payload['current_phase'] == 'phase_4_visibility_and_verification'
|
||||||
|
assert payload['current_checkpoint'] == 'visibility.runtime_summary_ready'
|
||||||
|
assert payload['verifier']['status'] == 'passed'
|
||||||
|
assert payload['isolation']['mode'] == 'worktree'
|
||||||
|
assert payload['isolation']['workspace_path'] == '/tmp/jarvis/worktree-1'
|
||||||
|
assert payload['isolation']['metadata']['branch'] == 'jarvis/test-worker'
|
||||||
|
assert payload['cost']['input_tokens'] == 120
|
||||||
|
assert payload['cost']['output_tokens'] == 80
|
||||||
|
assert payload['cost']['total_tokens'] == 200
|
||||||
|
assert payload['cost']['estimated_cost'] == 0.00156
|
||||||
|
assert payload['cost']['budget_warning'] is True
|
||||||
|
assert payload['topology_node_count'] == 2
|
||||||
|
assert payload['active_task_count'] == 1
|
||||||
|
assert payload['completed_task_count'] == 1
|
||||||
|
assert payload['recent_events'][0]['event_id'] == 'evt-1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_cost_returns_totals_thresholds_and_agent_breakdown(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/cost',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['total']['input_tokens'] == 120
|
||||||
|
assert payload['total']['output_tokens'] == 80
|
||||||
|
assert payload['total']['total_tokens'] == 200
|
||||||
|
assert payload['total']['budget_warning'] is True
|
||||||
|
assert payload['thresholds']['total_tokens'] == 150
|
||||||
|
assert payload['thresholds']['estimated_cost'] == 0.001
|
||||||
|
assert payload['by_agent'][0]['agent_id'] == 'analyst-1234abcd'
|
||||||
|
assert payload['by_agent'][0]['budget_warning'] is True
|
||||||
|
assert payload['by_agent'][1]['agent_id'] == 'master'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_tools_returns_governance_metadata_and_usage_counts(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/tools',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['total_tools'] >= 1
|
||||||
|
assert payload['used_tools'] >= 1
|
||||||
|
search_tool = next(item for item in payload['items'] if item['tool_name'] == 'search_web')
|
||||||
|
assert search_tool['permission_class'] == 'external'
|
||||||
|
assert search_tool['side_effect_scope'] == 'network'
|
||||||
|
assert search_tool['usage_count'] == 1
|
||||||
|
assert search_tool['last_result_preview'] == 'ok'
|
||||||
|
assert payload['upgrade_candidates'] == [
|
||||||
|
'worktree_manager',
|
||||||
|
'cost_inspector',
|
||||||
|
'runtime_event_drilldown',
|
||||||
|
'tool_policy_explorer',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_events_reject_invalid_datetime(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/events',
|
||||||
|
params={
|
||||||
|
'conversation_id': ids['conversation_id'],
|
||||||
|
'started_after': 'not-a-date',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()['detail'] == '时间参数必须是 ISO 8601 格式'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_events_support_time_window_and_offset_pagination(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/events',
|
||||||
|
params={
|
||||||
|
'conversation_id': ids['conversation_id'],
|
||||||
|
'started_after': ids['started_after'],
|
||||||
|
'ended_before': ids['ended_before'],
|
||||||
|
'limit': 1,
|
||||||
|
'offset': 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['total'] == 2
|
||||||
|
assert payload['limit'] == 1
|
||||||
|
assert payload['offset'] == 1
|
||||||
|
assert len(payload['items']) == 1
|
||||||
|
assert payload['items'][0]['event_id'] == 'evt-2'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_topology_includes_task_counts_for_root_and_child(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/topology',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
nodes = {node['agent_id']: node for node in payload['nodes']}
|
||||||
|
assert nodes['master']['task_count'] == 0
|
||||||
|
assert nodes['master']['completed_task_count'] == 0
|
||||||
|
assert nodes['analyst-1234abcd']['task_count'] == 1
|
||||||
|
assert nodes['analyst-1234abcd']['completed_task_count'] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_task_evidence_returns_404_for_unknown_task(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/tasks/missing-task/evidence',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()['detail'] == '任务不存在'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_thread_messages_returns_404_for_unknown_thread(visibility_env):
|
||||||
|
app, ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/threads/missing-thread/messages',
|
||||||
|
params={'conversation_id': ids['conversation_id']},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()['detail'] == '线程不存在'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_returns_404_when_conversation_is_missing(visibility_env):
|
||||||
|
app, _ids = visibility_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/events',
|
||||||
|
params={'conversation_id': 'missing-conversation'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()['detail'] == '对话不存在'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_returns_404_when_snapshot_is_missing(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_visibility_api_missing_snapshot.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
username='missing_snapshot_user',
|
||||||
|
email='missing-snapshot@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Missing Snapshot Tester',
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
conversation = Conversation(user_id=user.id, title='Missing snapshot test', agent_state=None)
|
||||||
|
session.add(conversation)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
await session.refresh(conversation)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(agent_router)
|
||||||
|
test_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
transport = ASGITransport(app=test_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/verifier',
|
||||||
|
params={'conversation_id': conversation.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()['detail'] == '当前会话暂无可视化运行时数据'
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_visibility_verifier_returns_empty_verdict_when_state_is_unverified(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_visibility_api_empty_verifier.db'
|
||||||
|
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
snapshot = {
|
||||||
|
'kind': 'agent_continuity_state',
|
||||||
|
'version': 1,
|
||||||
|
'state': {
|
||||||
|
'agent_id': 'master',
|
||||||
|
'root_agent_id': 'master',
|
||||||
|
'current_agent': 'master',
|
||||||
|
'event_trace': [],
|
||||||
|
'message_trace': [],
|
||||||
|
'active_tasks': [],
|
||||||
|
'task_results': [],
|
||||||
|
'task_hierarchy': {},
|
||||||
|
'tool_outcomes': [],
|
||||||
|
'verification_status': None,
|
||||||
|
'verification_summary': None,
|
||||||
|
'verification_evidence': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
username='empty_verifier_user',
|
||||||
|
email='empty-verifier@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Empty Verifier Tester',
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
conversation = Conversation(user_id=user.id, title='Empty verifier test', agent_state=snapshot)
|
||||||
|
session.add(conversation)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
await session.refresh(conversation)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(agent_router)
|
||||||
|
test_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
transport = ASGITransport(app=test_app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(
|
||||||
|
'/api/agents/visibility/verifier',
|
||||||
|
params={'conversation_id': conversation.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['status'] is None
|
||||||
|
assert payload['summary'] is None
|
||||||
|
assert payload['evidence'] == []
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
@@ -13,7 +13,7 @@ from app.models.conversation import Conversation, Message
|
|||||||
from app.models.memory import MemorySummary, UserMemory
|
from app.models.memory import MemorySummary, UserMemory
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import agent_service, memory_service
|
from app.services import agent_service, memory_service
|
||||||
from app.services.agent_service import AgentService
|
from app.services.agent_service import AgentService, _build_continuity_snapshot, _extract_continuity_snapshot
|
||||||
from app.services.auth_service import get_password_hash
|
from app.services.auth_service import get_password_hash
|
||||||
from app.services.document_service import DocumentService
|
from app.services.document_service import DocumentService
|
||||||
|
|
||||||
@@ -23,6 +23,32 @@ class FakeGraph:
|
|||||||
return {"final_response": "已记录你的请求。"}
|
return {"final_response": "已记录你的请求。"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_continuity_snapshot_roundtrip_preserves_phase_and_checkpoint():
|
||||||
|
payload = {
|
||||||
|
"current_agent": "master",
|
||||||
|
"current_phase": "phase_4_visibility_and_verification",
|
||||||
|
"phase_history": [
|
||||||
|
{"phase": "phase_0_bootstrap", "reason": "initial_state_created"},
|
||||||
|
{"phase": "phase_4_visibility_and_verification", "reason": "verification_started"},
|
||||||
|
],
|
||||||
|
"current_checkpoint": "collaboration.completed",
|
||||||
|
"checkpoint_history": [
|
||||||
|
{"checkpoint": "bootstrap.initialized", "phase": "phase_0_bootstrap", "reason": "initial_state_created"},
|
||||||
|
{"checkpoint": "collaboration.completed", "phase": "phase_4_visibility_and_verification", "reason": "collaboration_flow_finished"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = _build_continuity_snapshot(payload)
|
||||||
|
|
||||||
|
assert snapshot is not None
|
||||||
|
restored = _extract_continuity_snapshot({"kind": "agent_continuity_state", **snapshot})
|
||||||
|
assert restored is not None
|
||||||
|
assert restored["current_phase"] == "phase_4_visibility_and_verification"
|
||||||
|
assert restored["current_checkpoint"] == "collaboration.completed"
|
||||||
|
assert restored["phase_history"][-1]["phase"] == "phase_4_visibility_and_verification"
|
||||||
|
assert restored["checkpoint_history"][-1]["checkpoint"] == "collaboration.completed"
|
||||||
|
|
||||||
|
|
||||||
class FakeStreamingGraph:
|
class FakeStreamingGraph:
|
||||||
async def astream_events(self, state, version="v2"):
|
async def astream_events(self, state, version="v2"):
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
@@ -53,19 +53,17 @@ async def agent_env(tmp_path):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
owner_id=user.id,
|
owner_id=user.id,
|
||||||
)
|
)
|
||||||
session.add_all([
|
agent = Agent(
|
||||||
Agent(
|
name='SCHEDULE PLANNER',
|
||||||
name='SCHEDULE PLANNER',
|
role='schedule_planner',
|
||||||
role='schedule_planner',
|
description='日程规划师',
|
||||||
description='日程规划师',
|
system_prompt='prompt',
|
||||||
system_prompt='prompt',
|
is_active=True,
|
||||||
is_active=True,
|
)
|
||||||
),
|
session.add_all([agent, skill_a, skill_b])
|
||||||
skill_a,
|
|
||||||
skill_b,
|
|
||||||
])
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
await session.refresh(agent)
|
||||||
await session.refresh(skill_a)
|
await session.refresh(skill_a)
|
||||||
await session.refresh(skill_b)
|
await session.refresh(skill_b)
|
||||||
|
|
||||||
@@ -82,7 +80,7 @@ async def agent_env(tmp_path):
|
|||||||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield test_app, {'skill_a_id': skill_a.id, 'skill_b_id': skill_b.id}
|
yield test_app, {'agent_id': agent.id, 'skill_a_id': skill_a.id, 'skill_b_id': skill_b.id}
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
@@ -116,6 +114,32 @@ async def test_update_agent_config_persists_selected_skill_ids(agent_env):
|
|||||||
assert get_response.json()['selected_skill_ids'] == [ids['skill_a_id'], ids['skill_b_id']]
|
assert get_response.json()['selected_skill_ids'] == [ids['skill_a_id'], ids['skill_b_id']]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_agent_config_requires_authentication(agent_env):
|
||||||
|
app, _ids = agent_env
|
||||||
|
|
||||||
|
async def override_get_current_user_unauthorized():
|
||||||
|
raise RuntimeError('should not be called')
|
||||||
|
|
||||||
|
app.dependency_overrides.pop(get_current_user, None)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get('/api/agents/config/schedule_planner')
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_agent_requires_authentication(agent_env):
|
||||||
|
app, ids = agent_env
|
||||||
|
app.dependency_overrides.pop(get_current_user, None)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get(f"/api/agents/{ids['agent_id']}")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_update_agent_config_preserves_selected_skill_ids_when_omitted(agent_env):
|
async def test_update_agent_config_preserves_selected_skill_ids_when_omitted(agent_env):
|
||||||
app, ids = agent_env
|
app, ids = agent_env
|
||||||
@@ -148,3 +172,84 @@ async def test_update_agent_config_rejects_invalid_selected_skill_ids(agent_env)
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json()['detail'] == '存在无效的技能绑定'
|
assert response.json()['detail'] == '存在无效的技能绑定'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_agent_requires_superuser(agent_env):
|
||||||
|
app, _ids = agent_env
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.post(
|
||||||
|
'/api/agents',
|
||||||
|
json={
|
||||||
|
'name': 'Runtime Planner',
|
||||||
|
'role': 'schedule_planning',
|
||||||
|
'description': 'runtime',
|
||||||
|
'system_prompt': 'prompt',
|
||||||
|
'spawn_permission': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()['detail'] == '仅管理员可创建 Agent'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_agent_requires_spawn_permission_for_runtime_role(agent_env):
|
||||||
|
app, _ids = agent_env
|
||||||
|
|
||||||
|
async def override_admin_user():
|
||||||
|
return User(
|
||||||
|
username='admin_user',
|
||||||
|
email='admin@example.com',
|
||||||
|
hashed_password='x',
|
||||||
|
is_superuser=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = override_admin_user
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.post(
|
||||||
|
'/api/agents',
|
||||||
|
json={
|
||||||
|
'name': 'Runtime Planner',
|
||||||
|
'role': 'schedule_planning',
|
||||||
|
'description': 'runtime',
|
||||||
|
'system_prompt': 'prompt',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()['detail'] == '缺少 spawn_permission,禁止直接创建 runtime agent'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_agent_accepts_allowed_runtime_role_for_superuser(agent_env):
|
||||||
|
app, _ids = agent_env
|
||||||
|
|
||||||
|
async def override_admin_user():
|
||||||
|
return User(
|
||||||
|
username='admin_user',
|
||||||
|
email='admin@example.com',
|
||||||
|
hashed_password='x',
|
||||||
|
is_superuser=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_user] = override_admin_user
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.post(
|
||||||
|
'/api/agents',
|
||||||
|
json={
|
||||||
|
'name': 'Runtime Planner',
|
||||||
|
'role': 'schedule_planning',
|
||||||
|
'description': 'runtime',
|
||||||
|
'system_prompt': 'prompt',
|
||||||
|
'spawn_permission': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['name'] == 'Runtime Planner'
|
||||||
|
assert payload['role'] == 'schedule_planning'
|
||||||
|
|||||||
75
backend/tests/backend/app/test_conversation_router.py
Normal file
75
backend/tests/backend/app/test_conversation_router.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
import app.models # noqa: F401
|
||||||
|
from app.database import Base, get_db, ensure_conversation_columns
|
||||||
|
from app.models.conversation import Conversation
|
||||||
|
from app.models.user import User
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.routers.conversation import router as conversation_router
|
||||||
|
from app.services.auth_service import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def conversation_env(tmp_path):
|
||||||
|
db_path = tmp_path / 'test_conversation_router.db'
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", future=True)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
await conn.execute(text('ALTER TABLE conversations DROP COLUMN agent_state'))
|
||||||
|
await ensure_conversation_columns(conn)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = User(
|
||||||
|
username='conversation_user',
|
||||||
|
email='conversation@example.com',
|
||||||
|
hashed_password=get_password_hash('secret123'),
|
||||||
|
full_name='Conversation Tester',
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
Conversation(
|
||||||
|
user_id=user.id,
|
||||||
|
title='Existing conversation',
|
||||||
|
message_count=3,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
async def override_get_db():
|
||||||
|
async with session_factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def override_get_current_user():
|
||||||
|
return user
|
||||||
|
|
||||||
|
test_app = FastAPI()
|
||||||
|
test_app.include_router(conversation_router)
|
||||||
|
test_app.dependency_overrides[get_db] = override_get_db
|
||||||
|
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield test_app
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_conversations_succeeds_when_agent_state_column_was_missing(conversation_env):
|
||||||
|
transport = ASGITransport(app=conversation_env)
|
||||||
|
async with AsyncClient(transport=transport, base_url='http://testserver') as client:
|
||||||
|
response = await client.get('/api/conversations')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 1
|
||||||
|
assert payload[0]['title'] == 'Existing conversation'
|
||||||
|
assert payload[0]['message_count'] == 3
|
||||||
BIN
data/jarvis.db
BIN
data/jarvis.db
Binary file not shown.
52
development-doc/README.md
Normal file
52
development-doc/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Development Doc
|
||||||
|
|
||||||
|
本目录用于持续记录 Jarvis 的规划与开发过程。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
### `plan/`
|
||||||
|
用于记录中长期规划、升级方案、分阶段设计、专项计划。
|
||||||
|
|
||||||
|
建议内容:
|
||||||
|
- 每个升级点的目标
|
||||||
|
- 计划改动的模块和文件
|
||||||
|
- 分阶段实施顺序
|
||||||
|
- 风险与验收标准
|
||||||
|
- 设计决策与取舍
|
||||||
|
|
||||||
|
当前文件:
|
||||||
|
- `plan/README.md`
|
||||||
|
- `plan/phase-0-current-state-and-target.md`
|
||||||
|
- `plan/phase-1-safe-foundation.md`
|
||||||
|
- `plan/phase-2-controlled-collaboration.md`
|
||||||
|
- `plan/phase-3-dynamic-collaboration.md`
|
||||||
|
- `plan/phase-4-visibility-and-isolation.md`
|
||||||
|
|
||||||
|
### `daily/`
|
||||||
|
用于记录每日工作日志、当天进展、开发计划、执行情况、问题、决策和下一步安排。
|
||||||
|
|
||||||
|
建议内容:
|
||||||
|
- 今日开发计划
|
||||||
|
- 今日实际完成内容
|
||||||
|
- 当前进度
|
||||||
|
- 修改了哪些模块 / 文件
|
||||||
|
- 当前阻塞点
|
||||||
|
- 风险与临时决策
|
||||||
|
- 下一步计划
|
||||||
|
- 验证 / 测试情况
|
||||||
|
|
||||||
|
维护要求:
|
||||||
|
- 开始做当天开发前,先写当天计划
|
||||||
|
- 每完成一个关键步骤后,及时补充进度
|
||||||
|
- 遇到阻塞、改方案、改优先级时,必须更新 daily
|
||||||
|
- 一天结束前,补齐“完成情况 / 未完成 / 下一步”
|
||||||
|
- 后续正式改代码时,要把 daily 作为持续更新的开发日志,而不是事后总结
|
||||||
|
|
||||||
|
当前文件:
|
||||||
|
- `daily/2026-04-03.md`
|
||||||
|
- `daily/2026-04-04.md`
|
||||||
|
|
||||||
|
补充约定:
|
||||||
|
- 多天改造内容不要挤在同一个 daily 文档里
|
||||||
|
- 每一天单独一个 daily 文件
|
||||||
|
- 分步骤执行时,已完成项使用 `~~删除线~~` 标记
|
||||||
203
development-doc/daily/2026-04-03.md
Normal file
203
development-doc/daily/2026-04-03.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 2026-04-03 工作日志
|
||||||
|
|
||||||
|
## 今日开发计划
|
||||||
|
|
||||||
|
### 今日目标
|
||||||
|
|
||||||
|
- ~~分析 `demo/` 下三个 agent 项目与 Jarvis 当前 agents 的差异~~
|
||||||
|
- ~~明确 Jarvis 2.0 升级方向~~
|
||||||
|
- ~~建立 development-doc 文档结构~~
|
||||||
|
- ~~形成分阶段 plan 文档~~
|
||||||
|
- ~~形成 2 天融合改造计划~~
|
||||||
|
|
||||||
|
### 今日计划拆分
|
||||||
|
|
||||||
|
1. ~~分析 demo 项目能力与设计重点~~
|
||||||
|
2. ~~评估 Jarvis 当前 agents 的优势与短板~~
|
||||||
|
3. ~~输出 Jarvis 2.0 总体升级思路~~
|
||||||
|
4. ~~建立 `development-doc/plan` 与 `development-doc/daily`~~
|
||||||
|
5. ~~将 plan 拆成 phase 文档~~
|
||||||
|
6. ~~输出 2 天融合改造计划~~
|
||||||
|
|
||||||
|
### Day 1 工作内容
|
||||||
|
|
||||||
|
#### Day 1 目标
|
||||||
|
|
||||||
|
- ~~完成 demo 项目分析~~
|
||||||
|
- ~~完成 Jarvis 当前能力差距判断~~
|
||||||
|
- ~~完成 `development-doc/` 目录搭建~~
|
||||||
|
- ~~完成 phase 文档拆分~~
|
||||||
|
- ~~完成 2 天融合计划初稿~~
|
||||||
|
|
||||||
|
#### Day 1 分步骤执行
|
||||||
|
|
||||||
|
1. ~~分析 `demo/swarm-ide-chore-specs-mvp`~~
|
||||||
|
2. ~~分析 `demo/claude-code-cli-master`~~
|
||||||
|
3. ~~分析 `demo/claw-code-main`~~
|
||||||
|
4. ~~梳理 Jarvis 当前 agents 架构优势与短板~~
|
||||||
|
5. ~~输出 `plan/README.md` 与 phase 文档~~
|
||||||
|
6. ~~输出 `2026-04-03-jarvis-agents-2-day-integration-plan.md`~~
|
||||||
|
7. ~~建立并整理 `daily/` 日志结构~~
|
||||||
|
|
||||||
|
#### Day 1 完成标准
|
||||||
|
|
||||||
|
- ~~文档结构齐全~~
|
||||||
|
- ~~分阶段计划可读~~
|
||||||
|
- ~~两天改造路线明确~~
|
||||||
|
- ~~daily 可持续维护~~
|
||||||
|
|
||||||
|
> Day 2 内容已拆分到 `daily/2026-04-04.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日实际完成
|
||||||
|
|
||||||
|
1. 分析了以下 demo 项目:
|
||||||
|
- `demo/swarm-ide-chore-specs-mvp`
|
||||||
|
- `demo/claude-code-cli-master`
|
||||||
|
- `demo/claw-code-main`
|
||||||
|
|
||||||
|
2. 梳理了 Jarvis 当前 agent 架构的优势与短板:
|
||||||
|
- 当前强项:分层路由、业务导向、continuity、fallback、测试基础
|
||||||
|
- 当前短板:动态协作不足、缺少 verifier、缺少 task/runtime、可观察性不足
|
||||||
|
|
||||||
|
3. 输出并整理了 Jarvis 规划文档结构:
|
||||||
|
- `development-doc/README.md`
|
||||||
|
- `development-doc/plan/README.md`
|
||||||
|
- `development-doc/plan/phase-0-current-state-and-target.md`
|
||||||
|
- `development-doc/plan/phase-1-safe-foundation.md`
|
||||||
|
- `development-doc/plan/phase-2-controlled-collaboration.md`
|
||||||
|
- `development-doc/plan/phase-3-dynamic-collaboration.md`
|
||||||
|
- `development-doc/plan/phase-4-visibility-and-isolation.md`
|
||||||
|
- `development-doc/plan/2026-04-03-jarvis-agents-2-day-integration-plan.md`
|
||||||
|
|
||||||
|
4. 建立并整理了 daily 目录:
|
||||||
|
- `development-doc/daily/2026-04-03.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
|
||||||
|
### 文档规划进度
|
||||||
|
|
||||||
|
- demo 分析:已完成
|
||||||
|
- 总体升级方向:已完成
|
||||||
|
- plan 目录搭建:已完成
|
||||||
|
- 分阶段 plan:已完成
|
||||||
|
- 2 天融合计划:已完成
|
||||||
|
- daily 规范:已完成
|
||||||
|
- Day 3 清单与验收文档:已更新
|
||||||
|
- Day 4 清单:已更新
|
||||||
|
|
||||||
|
### 代码改造进度
|
||||||
|
|
||||||
|
- 已完成 Day 1 / Day 2 底座与协作闭环
|
||||||
|
- 已完成 Day 3 最小受限动态协作 runtime
|
||||||
|
- 已补齐 registry spawn policy、graph spawn guardrail、message trace、interrupt / recovery 最小闭环
|
||||||
|
- Phase 1-3 核心功能已基本落地
|
||||||
|
- Day 4 聚焦 Phase 4 可视化与隔离执行能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日修改的模块 / 文件
|
||||||
|
|
||||||
|
### 新增 / 更新的开发文档
|
||||||
|
|
||||||
|
- `development-doc/README.md`
|
||||||
|
- `development-doc/plan/README.md`
|
||||||
|
- `development-doc/plan/phase-0-current-state-and-target.md`
|
||||||
|
- `development-doc/plan/phase-1-safe-foundation.md`
|
||||||
|
- `development-doc/plan/phase-2-controlled-collaboration.md`
|
||||||
|
- `development-doc/plan/phase-3-dynamic-collaboration.md`
|
||||||
|
- `development-doc/plan/phase-4-visibility-and-isolation.md`
|
||||||
|
- `development-doc/plan/2026-04-03-jarvis-agents-2-day-integration-plan.md`
|
||||||
|
- `development-doc/daily/2026-04-03.md`
|
||||||
|
|
||||||
|
### 本轮补充分析与改造涉及的代码文件
|
||||||
|
|
||||||
|
- `backend/app/agents/state.py`
|
||||||
|
- `backend/app/agents/graph.py`
|
||||||
|
- `backend/app/agents/prompts.py`
|
||||||
|
- `backend/app/agents/registry/models.py`
|
||||||
|
- `backend/app/agents/registry/builtins.py`
|
||||||
|
- `backend/app/agents/registry/indexes.py`
|
||||||
|
- `backend/app/agents/tools/__init__.py`
|
||||||
|
- `backend/tests/backend/app/agents/test_graph.py`
|
||||||
|
- `backend/tests/backend/app/agents/test_registry.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日结论
|
||||||
|
|
||||||
|
### 从 demo 项目吸收的核心方向
|
||||||
|
|
||||||
|
- 从 Swarm-IDE 学:动态通信原语、可观察性、协作拓扑
|
||||||
|
- 从 Claude Code CLI 学:coordinator / task / verifier 的平台化编排
|
||||||
|
- 从 Claw Code 学:runtime 分层、工具注册表、权限模型
|
||||||
|
|
||||||
|
### Jarvis 总体升级方向
|
||||||
|
|
||||||
|
Jarvis 不应直接变成完全自由的 swarm,而应升级为:
|
||||||
|
|
||||||
|
- 受控的动态协作运行时
|
||||||
|
|
||||||
|
原则:
|
||||||
|
|
||||||
|
- 简单请求继续走当前稳定路径
|
||||||
|
- 复杂请求才进入协作模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前阻塞点
|
||||||
|
|
||||||
|
- 暂无明显代码层阻塞
|
||||||
|
- 当前主要待办是完成定向回归测试并收尾验收
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与临时决策
|
||||||
|
|
||||||
|
### 当前风险
|
||||||
|
|
||||||
|
- 不要一开始就引入无限动态 agent
|
||||||
|
- 不要直接替换现有 graph 主路径
|
||||||
|
- 应优先保持 reminder/task/search 等现有业务稳定
|
||||||
|
- 新能力必须配套测试和约束策略
|
||||||
|
|
||||||
|
### 当前决策
|
||||||
|
|
||||||
|
1. 采用受限动态协作,而不是自由 swarm
|
||||||
|
2. 通过 registry 固化 spawn role policy,再由 graph 在运行时执行权限校验
|
||||||
|
3. interrupt / recovery 先落最小闭环,优先保证 direct 主路径稳定
|
||||||
|
4. daily 后续必须作为开发过程中的持续更新日志使用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证 / 测试情况
|
||||||
|
|
||||||
|
- 已补充 Day 3 相关 runtime / registry / graph 回归测试
|
||||||
|
- 已更新 Day 3 执行清单与 daily 状态
|
||||||
|
- 正在执行定向 pytest 验证,重点覆盖 `test_graph.py` 与 `test_registry.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
|
||||||
|
1. 完成 Day 3 定向回归测试
|
||||||
|
2. 若有失败,修正 runtime / test 偏差
|
||||||
|
3. 统一整理 Day 3 最终验收结论
|
||||||
|
4. 启动 Day 4:Phase 4 可视化 API 实现
|
||||||
|
5. 设计隔离执行最小方案
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 每日维护要求
|
||||||
|
|
||||||
|
后续正式进入改造阶段后,本文件需要持续更新:
|
||||||
|
|
||||||
|
1. 开始开发前更新“今日开发计划”
|
||||||
|
2. 完成一个阶段性步骤后更新“当前进度”
|
||||||
|
3. 变更方案时更新“风险与临时决策”
|
||||||
|
4. 出现问题时更新“当前阻塞点”
|
||||||
|
5. 每次完成验证后更新“验证 / 测试情况”
|
||||||
|
6. 一天结束前补齐“已完成 / 未完成 / 下一步计划”
|
||||||
116
development-doc/daily/2026-04-04.md
Normal file
116
development-doc/daily/2026-04-04.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 2026-04-04 工作日志
|
||||||
|
|
||||||
|
## 今日开发计划
|
||||||
|
|
||||||
|
### 今日目标
|
||||||
|
|
||||||
|
- 巩固 `Phase 4` 已完成的可见性最小闭环
|
||||||
|
- 把 runtime summary 接到 Agents 页面
|
||||||
|
- 为后续 90 分路径明确 isolation / cost / operator surface 升级项
|
||||||
|
- 保持 reminder / task / search 主路径稳定
|
||||||
|
|
||||||
|
### 今日计划拆分
|
||||||
|
|
||||||
|
1. 新增 `backend/app/agents/api/visibility.py` 可见性 API 模块
|
||||||
|
2. 实现 event stream API
|
||||||
|
3. 实现协作链路拓扑查询 API
|
||||||
|
4. 实现 task 执行证据查询 API
|
||||||
|
5. 实现 message thread 查询 API
|
||||||
|
6. 实现 verifier 结果查询 API
|
||||||
|
7. 设计隔离执行最小方案
|
||||||
|
8. 补测试并验证主流程
|
||||||
|
|
||||||
|
### Day 4 工作内容
|
||||||
|
|
||||||
|
#### Day 4 目标
|
||||||
|
|
||||||
|
- 完成 `Phase 4` 可见性 API 最小闭环
|
||||||
|
- 完成 runtime summary API 与前端 Agents 页面首屏接入
|
||||||
|
- 为后续完整隔离执行与成本治理预留接口
|
||||||
|
- 保证已有路径测试不回退
|
||||||
|
|
||||||
|
#### Day 4 分步骤执行
|
||||||
|
|
||||||
|
1. 新增 `backend/app/agents/api/visibility.py` 及各可见性 API
|
||||||
|
2. `GET /agents/visibility/events` - event stream 按条件过滤
|
||||||
|
3. `GET /agents/visibility/topology` - 协作拓扑视图
|
||||||
|
4. `GET /agents/visibility/tasks/{task_id}/evidence` - task 执行证据
|
||||||
|
5. `GET /agents/visibility/threads/{thread_id}/messages` - thread 消息流
|
||||||
|
6. `GET /agents/visibility/verifier` - verifier 验收结论
|
||||||
|
7. 在 `development-doc/plan/phase-4-visibility-and-isolation.md` 补充隔离执行设计方案
|
||||||
|
8. 补 `test_visibility_api.py` 及主流程回归测试
|
||||||
|
|
||||||
|
#### Day 4 完成标准
|
||||||
|
|
||||||
|
- event stream API 可按 conversation_id / thread_id / agent_id 过滤
|
||||||
|
- topology API 可返回协作拓扑视图
|
||||||
|
- evidence API 可返回 task 执行证据链
|
||||||
|
- thread API 可重建消息流向
|
||||||
|
- verifier API 可返回验收结论
|
||||||
|
- 隔离执行设计方案可落地
|
||||||
|
- 现有主流程测试继续通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日实际完成
|
||||||
|
|
||||||
|
- 分析了 Jarvis 现有代码实现状态(`graph.py`、`state.py`、`verifier.py`、`registry/models.py`、`schemas/`)
|
||||||
|
- 确认 Phase 1-3 核心功能已基本落地:task schema、event schema、verifier、tool metadata、collaboration flow、interrupt/recovery、message trace
|
||||||
|
- 识别了 Phase 4(可视化与隔离执行)待实现内容
|
||||||
|
- 在 `2026-04-03-jarvis-agents-5-day-work-checklist.md` 中新增了 Day 4 任务清单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前进度
|
||||||
|
|
||||||
|
### 代码改造进度(Phase 1-3)
|
||||||
|
|
||||||
|
- ✅ task schema / event schema 已完整
|
||||||
|
- ✅ verifier 模块已独立
|
||||||
|
- ✅ state.py 已包含 collaboration 全部字段
|
||||||
|
- ✅ registry/models.py 已补充 tool metadata
|
||||||
|
- ✅ graph.py 已接入 event trace、verifier 调用、collaboration flow
|
||||||
|
- ✅ interrupt / recovery 最小闭环已实现
|
||||||
|
- ✅ message trace 已实现
|
||||||
|
|
||||||
|
### Day 4 待启动
|
||||||
|
|
||||||
|
- 待实现可见性 API(event stream、topology、evidence、thread、verifier)
|
||||||
|
- 待设计隔离执行方案
|
||||||
|
- 待补可视化 API 测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 今日修改的模块 / 文件
|
||||||
|
|
||||||
|
- 待更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前阻塞点
|
||||||
|
|
||||||
|
- 待开发时更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与临时决策
|
||||||
|
|
||||||
|
- 不直接重写 graph 主路径
|
||||||
|
- verifier 优先以 helper 形式接入
|
||||||
|
- 先补底座,不直接做自由 swarm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证 / 测试情况
|
||||||
|
|
||||||
|
- 待更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
|
||||||
|
1. 实现 `visibility.py` 可见性 API 模块
|
||||||
|
2. 按顺序实现 event stream、topology、evidence、thread、verifier API
|
||||||
|
3. 设计隔离执行最小方案
|
||||||
|
4. 补 `test_visibility_api.py` 测试
|
||||||
|
5. 跑测试验证主流程不回退
|
||||||
330
development-doc/plan/agent-update/README.md
Normal file
330
development-doc/plan/agent-update/README.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Jarvis Agents 升级计划索引
|
||||||
|
|
||||||
|
本目录用于存放 Jarvis Agents 2.0 的分阶段规划文档,同时也用于记录**当前代码真实落地状态**。
|
||||||
|
|
||||||
|
## 文档说明
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `README.md` | 总览、阶段关系、实施顺序、当前状态 |
|
||||||
|
| `phase-0-current-state-and-target.md` | 当前现状、问题、目标架构、ADR |
|
||||||
|
| `phase-1-safe-foundation.md` | 基础设施加固阶段 |
|
||||||
|
| `phase-2-controlled-collaboration.md` | 受控协作阶段 |
|
||||||
|
| `phase-3-dynamic-collaboration.md` | 动态协作阶段 |
|
||||||
|
| `phase-4-visibility-and-isolation.md` | 可视化与隔离执行阶段 |
|
||||||
|
| `phase-5-advanced-features.md` | 高级特性(可选) |
|
||||||
|
| `phase-6-tool-system-refactoring.md` | 工具系统重构 |
|
||||||
|
| `phase-7-hook-interception-layer.md` | Hook 拦截层 |
|
||||||
|
| `phase-8-plugin-ecosystem.md` | 插件生态 |
|
||||||
|
| `phase-9-skills-registry.md` | Skills 注册表 |
|
||||||
|
| `phase-10-advanced-orchestration.md` | 高级编排 |
|
||||||
|
| `phase-r-rag-upgrade.md` | RAG 系统升级专项(VCPToolBox 借鉴) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前总体状态(2026-04-04)
|
||||||
|
|
||||||
|
当前 Jarvis agent runtime 不再是“Phase 2/3/4 纯草案”,而是已经具备以下现实状态:
|
||||||
|
|
||||||
|
### 78 → 90 成熟度标尺
|
||||||
|
|
||||||
|
| 分数 | 含义 | 当前状态 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 75 | 受控协作基线:task/event/verifier/collaboration/dynamic guardrail 已稳定 | 已达到 |
|
||||||
|
| 85 | visibility + verification 基线:phase/checkpoint、topology、evidence、runtime summary、operator 调试入口可用 | 基本达到 |
|
||||||
|
| 90 | isolation runtime + cost governance + operator surface:会话/工作区隔离、成本阈值治理、前端可运营面板闭环 | 已达到 |
|
||||||
|
| 95+ | full sandbox / persistence / realtime UI / advanced memory | 明确延后 |
|
||||||
|
|
||||||
|
| Phase | 当前状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 1 | 已落地 | verifier、task/event schema、基础执行模式已存在 |
|
||||||
|
| Phase 2 | 已实现基线 | collaboration mode、task decomposition、owner、result collection、verifier 收尾已运行 |
|
||||||
|
| Phase 3 | 已实现受限基线 | parent/root/depth、spawn policy、budget、interrupt/recovery、事件链路已存在 |
|
||||||
|
| Phase 4 | 已完成 90 分闭环 | visibility API、isolation runtime MVP、cost governance MVP、operator/debug surface 已落地 |
|
||||||
|
| Phase 5 | 未开始 | 保留为 full sandbox / persistence / realtime push 等可选增强 |
|
||||||
|
| Phase 6 | 待开始 | 工具系统重构(对标 claw-code) |
|
||||||
|
| Phase 7 | 待开始 | Hook 拦截层 |
|
||||||
|
| Phase 8 | 待开始 | 插件生态 |
|
||||||
|
| Phase 9 | 待开始 | Skills 注册表 |
|
||||||
|
| Phase 10 | 待开始 | 高级编排 |
|
||||||
|
| Phase R | 部分推进 | RAG 升级按专项继续推进 |
|
||||||
|
|
||||||
|
### 本次新增落地
|
||||||
|
|
||||||
|
本次补齐了一个此前缺失但非常关键的层:
|
||||||
|
|
||||||
|
- runtime 显式 phase model
|
||||||
|
- runtime checkpoint model
|
||||||
|
- phase / checkpoint history 持久化
|
||||||
|
- phase / checkpoint event trace
|
||||||
|
- 对应自动化测试
|
||||||
|
|
||||||
|
新增后,当前 runtime 已可显式追踪:
|
||||||
|
|
||||||
|
- `current_phase`
|
||||||
|
- `phase_history`
|
||||||
|
- `current_checkpoint`
|
||||||
|
- `checkpoint_history`
|
||||||
|
|
||||||
|
并且会进入这些显式阶段:
|
||||||
|
|
||||||
|
- `phase_0_bootstrap`
|
||||||
|
- `phase_1_routing`
|
||||||
|
- `phase_2_controlled_collaboration`
|
||||||
|
- `phase_3_dynamic_collaboration`
|
||||||
|
- `phase_4_visibility_and_verification`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐阅读顺序
|
||||||
|
|
||||||
|
1. 先读 `phase-0-current-state-and-target.md`
|
||||||
|
2. 再读 `phase-2-controlled-collaboration.md`
|
||||||
|
3. 再读 `phase-3-dynamic-collaboration.md`
|
||||||
|
4. 最后读 `phase-4-visibility-and-isolation.md`
|
||||||
|
|
||||||
|
原因:当前最重要的不是继续写理想化蓝图,而是先理解“代码里已经实现到了哪一步”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总体升级原则
|
||||||
|
|
||||||
|
1. **保持简单请求路径稳定** - Direct Mode 不受影响
|
||||||
|
2. **复杂请求才启用协作模式** - Collaboration Mode 按需触发
|
||||||
|
3. **执行与验证分离** - Verifier 作为独立角色
|
||||||
|
4. **动态能力必须受约束** - Budget + Permission + Depth
|
||||||
|
5. **所有升级都要配套测试** - 回归测试优先
|
||||||
|
6. **优先做显式状态,不先做大拆分** - 先让运行时可观察、可验证,再抽模块
|
||||||
|
7. **优先服务个人助手主线** - 先补记忆、会话、计划闭环、开发协作稳定性,再考虑平台化外壳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加 checkpoint(按个人助手定位)
|
||||||
|
|
||||||
|
下面这些 checkpoint 比“做成通用开源 harness”更值得优先推进:
|
||||||
|
|
||||||
|
### P0:必要升级点
|
||||||
|
|
||||||
|
- **Checkpoint A:会话连续性可靠**
|
||||||
|
- conversation / session 重启后可恢复关键状态
|
||||||
|
- phase / checkpoint / active task / verifier summary 不丢失
|
||||||
|
- 降低“每次都要重新解释上下文”的成本
|
||||||
|
|
||||||
|
- **Checkpoint B:记忆系统可用且可控**
|
||||||
|
- 用户偏好、项目背景、日常规划信息可稳定沉淀
|
||||||
|
- memory 写入有分类、检索、去重、更新机制
|
||||||
|
- 避免记忆污染、过期信息误用、重复记录
|
||||||
|
|
||||||
|
- **Checkpoint C:plan / daily / task 闭环打通**
|
||||||
|
- 对话中识别出的行动项能沉淀到 plan / daily
|
||||||
|
- task 状态变化能反映到 daily 执行记录
|
||||||
|
- 支持“继续昨天未完成事项”的续做能力
|
||||||
|
|
||||||
|
- **Checkpoint D:开发协作稳定性提升**
|
||||||
|
- 多文件读改查路径更稳
|
||||||
|
- tool 失败时有更清晰的恢复策略
|
||||||
|
- 常见开发任务(解释/修改/调试/重构)成功率优先于花哨能力
|
||||||
|
|
||||||
|
- **Checkpoint E:后台任务与自动化可靠**
|
||||||
|
- 后台任务状态可追踪、失败原因可定位
|
||||||
|
- 定时任务/异步任务不易丢失
|
||||||
|
- background manager / scheduler 路径优先做稳定性修补
|
||||||
|
|
||||||
|
### P1:有价值但可后置
|
||||||
|
|
||||||
|
- verifier 更强的证据链能力
|
||||||
|
- team / 多 agent 协作体验优化
|
||||||
|
- 更细的 tool governance 与 operator drilldown
|
||||||
|
- 更好的 RAG / 长短期知识组织
|
||||||
|
|
||||||
|
### P2:可明显推后
|
||||||
|
|
||||||
|
- 通用 CLI / REPL 产品壳
|
||||||
|
- 面向外部的插件生态/市场
|
||||||
|
- 平台级 OAuth / 多租户 / 对外服务化
|
||||||
|
- 完整对标 claw-code-main 的通用 harness 外层
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段关系图(按真实状态修订)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Phase 0 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 现状与目标 │
|
||||||
|
│ - 当前架构分析 │
|
||||||
|
│ - Demo 借鉴映射 │
|
||||||
|
│ - ADR 架构决策 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 1 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 基础设施加固 (Safe Foundation) │
|
||||||
|
│ - verifier / schema / execution mode 基础 │
|
||||||
|
│ 状态:已落地 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 2 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 受控协作 (Controlled Collaboration) │
|
||||||
|
│ - collaboration mode │
|
||||||
|
│ - 任务拆解 / owner / 结果回收 / verifier │
|
||||||
|
│ - 当前已补 phase + checkpoint │
|
||||||
|
│ 状态:已实现基线 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 3 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 动态协作 (Dynamic Collaboration) │
|
||||||
|
│ - parent/root/depth tracking │
|
||||||
|
│ - spawn policy + budget │
|
||||||
|
│ - interrupt/recovery │
|
||||||
|
│ - phase + checkpoint trace │
|
||||||
|
│ 状态:已实现受限基线 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 4 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 可视化与隔离 (Visibility + Isolation) │
|
||||||
|
│ - visibility 查询 API │
|
||||||
|
│ - continuity snapshot 持久化 │
|
||||||
|
│ - isolation strategy 设计 │
|
||||||
|
│ 状态:最小闭环已完成 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 5 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 高级特性 (Advanced Features) │
|
||||||
|
│ - full sandbox / persistence / cost monitoring / advanced UI │
|
||||||
|
│ 状态:规划中,可选 │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 6 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 工具系统重构 (Tool System Refactoring) │
|
||||||
|
│ - ToolRegistry / HookExecutor / StreamingToolExecutor │
|
||||||
|
│ - 新增工具集:Glob/Grep/LSP/Bash/PowerShell/Cron │
|
||||||
|
│ 状态:待开始(对标 claw-code tools/) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 7 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Hook 拦截层 (Hook Interception Layer) │
|
||||||
|
│ - PreTool/PostTool Hook 机制 │
|
||||||
|
│ - 危险操作确认 / 安全扫描 / 审计日志 │
|
||||||
|
│ 状态:待开始(依赖 Phase 6) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 8 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ 插件生态 (Plugin Ecosystem) │
|
||||||
|
│ - PluginManager / 生命周期管理 / 插件市场 │
|
||||||
|
│ 状态:待开始(依赖 Phase 6, 7) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 9 ──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Skills 注册表 (Skills Registry) │
|
||||||
|
│ - 动态 Skills 加载 / MCP Skill Builder / Bundled Skills │
|
||||||
|
│ 状态:待开始(依赖 Phase 6) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Phase 10 ─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 高级编排 (Advanced Orchestration) │
|
||||||
|
│ - Team Leader / Remote Transport / Session Manager / Background Tasks │
|
||||||
|
│ 状态:待开始(对标 claw-code assistant/) │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo 项目借鉴映射
|
||||||
|
|
||||||
|
| Demo项目 | 主要借鉴点 | 对应 Phase |
|
||||||
|
|---------|-----------|-----------|
|
||||||
|
| **Swarm-IDE** | Event trace、Dynamic Spawn、拓扑可视化 | Phase 3, 4 |
|
||||||
|
| **Claude Code CLI** | Coordinator-worker、Verifier 分离、Tool 权限 | Phase 1, 2 |
|
||||||
|
| **Claw Code** | Runtime 分层、Port Manifest、隔离策略 | Phase 2, 4, 6, 7, 8, 9, 10 |
|
||||||
|
| **VCPToolBox** | TagMemo V6、多索引、Token 感知分块 | Phase R, Phase 5 |
|
||||||
|
|
||||||
|
### Claw Code 详细对照
|
||||||
|
|
||||||
|
| Claw Code 组件 | Jarvis Phase | 说明 |
|
||||||
|
|----------------|-------------|------|
|
||||||
|
| `tools/` | Phase 6 | 工具注册表、分层执行 |
|
||||||
|
| `StreamingToolExecutor` | Phase 6 | 流式工具执行 |
|
||||||
|
| `toolHooks.ts` | Phase 7 | Hook 拦截层 |
|
||||||
|
| `PluginLifecycle` | Phase 8 | 插件生态 |
|
||||||
|
| `skills/loadSkillsDir.ts` | Phase 9 | Skills 注册表 |
|
||||||
|
| `skills/bundledSkills.ts` | Phase 9 | Bundled Skills |
|
||||||
|
| `assistant/sessionHistory.ts` | Phase 10 | 高级会话管理 |
|
||||||
|
| `cli/structuredIO.ts` | Phase 10 | 结构化传输 |
|
||||||
|
| `cli/remoteIO.ts` | Phase 10 | 远程传输 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本次代码落点
|
||||||
|
|
||||||
|
本次 phase/checkpoint 补强主要修改:
|
||||||
|
|
||||||
|
- `backend/app/agents/state.py`
|
||||||
|
- `backend/app/agents/graph.py`
|
||||||
|
- `backend/app/agents/schemas/event.py`
|
||||||
|
- `backend/app/services/agent_service.py`
|
||||||
|
- `backend/tests/backend/app/agents/test_graph.py`
|
||||||
|
- `backend/tests/backend/app/services/test_brain_ingestion.py`
|
||||||
|
|
||||||
|
### 新增的关键事件
|
||||||
|
|
||||||
|
- `agent.phase.changed`
|
||||||
|
- `agent.checkpoint.recorded`
|
||||||
|
|
||||||
|
### 新增的关键持久化字段
|
||||||
|
|
||||||
|
- `current_phase`
|
||||||
|
- `phase_history`
|
||||||
|
- `current_checkpoint`
|
||||||
|
- `checkpoint_history`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前仍未完成的内容
|
||||||
|
|
||||||
|
虽然能力已经明显前进,但下面这些仍属于后续工作:
|
||||||
|
|
||||||
|
### 工程结构层
|
||||||
|
|
||||||
|
- 独立 `coordinator.py`
|
||||||
|
- 独立 `message_bus.py`
|
||||||
|
- 独立 `event_bus.py`
|
||||||
|
- `dynamic/` 与 `recovery/` 目录化拆分
|
||||||
|
|
||||||
|
### Claw Code 差距(Phase 6-10)
|
||||||
|
|
||||||
|
- Phase 6: 工具系统重构(ToolRegistry/HookExecutor/StreamingToolExecutor)
|
||||||
|
- Phase 7: Hook 拦截层(PreTool/PostTool)
|
||||||
|
- Phase 8: 插件生态(PluginManager/生命周期/市场)
|
||||||
|
- Phase 9: Skills 注册表(动态加载/MCP Builder)
|
||||||
|
- Phase 10: 高级编排(Team/Remote Transport/Session Manager)
|
||||||
|
|
||||||
|
### 平台能力层
|
||||||
|
|
||||||
|
- full sandbox / persistence / realtime push
|
||||||
|
- 独立 `coordinator.py` / `message_bus.py` / `event_bus.py`
|
||||||
|
- 更完整的 operator drilldown 与实时推送
|
||||||
|
- SSE / WebSocket 实时推送(延后)
|
||||||
|
- sandbox container 执行器(延后)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前阶段结论
|
||||||
|
|
||||||
|
目前最准确的说法不是:
|
||||||
|
|
||||||
|
> “Jarvis 还在做 agent phase 规划。”
|
||||||
|
|
||||||
|
而是:
|
||||||
|
|
||||||
|
> “Jarvis 已经具备多阶段 agent runtime 的核心基线,当前工作重点已经从‘是否可行’转向‘如何把已存在能力继续工程化、可视化、隔离化’。”
|
||||||
|
|
||||||
|
这也是后续测试、验收和继续升级的正确前提。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user