feat(agents): Phase 8.4-10.5 built-in plugins, bundled skills, coordinator
This commit is contained in:
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
|
||||
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
|
||||
@@ -12,6 +12,12 @@ from typing import Any, Literal, cast
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langgraph.graph import END, StateGraph
|
||||
|
||||
from app.agents.isolation import (
|
||||
WorktreeIsolationError,
|
||||
prepare_session_isolation,
|
||||
prepare_worktree_isolation,
|
||||
select_isolation_strategy,
|
||||
)
|
||||
from app.agents.prompts import (
|
||||
ANALYST_SYSTEM_PROMPT,
|
||||
COORDINATOR_SYSTEM_PROMPT,
|
||||
@@ -22,6 +28,12 @@ from app.agents.prompts import (
|
||||
SCHEDULE_PLANNER_SYSTEM_PROMPT,
|
||||
)
|
||||
from app.agents.registry import load_builtin_registry_indexes
|
||||
from app.agents.runtime_metrics import (
|
||||
coerce_cost_thresholds,
|
||||
estimate_token_cost,
|
||||
extract_token_usage,
|
||||
is_cost_budget_warning,
|
||||
)
|
||||
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
|
||||
@@ -193,6 +205,175 @@ def _get_state_int(state: AgentState, key: str) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _clear_isolation_state(state: AgentState) -> None:
|
||||
state["isolation_mode"] = "none"
|
||||
state["isolation_id"] = None
|
||||
state["isolation_workspace_path"] = None
|
||||
state["isolation_parent_conversation_id"] = None
|
||||
state["isolation_metadata"] = {}
|
||||
|
||||
|
||||
def _apply_isolation_payload(state: AgentState, payload: dict[str, Any]) -> None:
|
||||
state["isolation_mode"] = str(payload.get("mode") or "none")
|
||||
state["isolation_id"] = str(payload.get("isolation_id") or "") or None
|
||||
state["isolation_workspace_path"] = str(payload.get("workspace_path") or "") or None
|
||||
state["isolation_parent_conversation_id"] = str(payload.get("parent_conversation_id") or "") or None
|
||||
state["isolation_metadata"] = dict(payload.get("metadata") or {})
|
||||
|
||||
|
||||
def _prepare_isolation_context(
|
||||
state: AgentState,
|
||||
*,
|
||||
role: AgentRole,
|
||||
sub_commander: str,
|
||||
user_query: str,
|
||||
toolset: list[Any],
|
||||
) -> None:
|
||||
tool_names = [tool.name for tool in toolset]
|
||||
decision = select_isolation_strategy(
|
||||
user_query=user_query,
|
||||
tool_names=tool_names,
|
||||
role_value=role.value,
|
||||
execution_mode=str(state.get("execution_mode") or "direct"),
|
||||
)
|
||||
if decision.mode == "none":
|
||||
_clear_isolation_state(state)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload={"mode": "none", "reason": decision.reason, "tool_names": tool_names},
|
||||
)
|
||||
return
|
||||
|
||||
if decision.mode == "session":
|
||||
isolation_payload = prepare_session_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
_apply_isolation_payload(state, isolation_payload)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload=isolation_payload,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
isolation_payload = prepare_worktree_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
except WorktreeIsolationError as exc:
|
||||
isolation_payload = prepare_session_isolation(
|
||||
state=state,
|
||||
decision=decision,
|
||||
role_value=role.value,
|
||||
sub_commander=sub_commander,
|
||||
)
|
||||
isolation_payload["metadata"] = {
|
||||
**dict(isolation_payload.get("metadata") or {}),
|
||||
"fallback_reason": str(exc),
|
||||
"fallback_from": "worktree",
|
||||
}
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.fallback",
|
||||
payload={
|
||||
"requested_mode": "worktree",
|
||||
"fallback_mode": "session",
|
||||
"reason": str(exc),
|
||||
"tool_names": tool_names,
|
||||
},
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
_apply_isolation_payload(state, isolation_payload)
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.isolation.selected",
|
||||
payload=isolation_payload,
|
||||
)
|
||||
|
||||
|
||||
def _record_response_usage(state: AgentState, response: Any) -> None:
|
||||
input_tokens, output_tokens = extract_token_usage(response)
|
||||
if not input_tokens and not output_tokens:
|
||||
return
|
||||
|
||||
current_input_tokens = int(state.get("input_tokens") or 0)
|
||||
current_output_tokens = int(state.get("output_tokens") or 0)
|
||||
total_input_tokens = current_input_tokens + input_tokens
|
||||
total_output_tokens = current_output_tokens + output_tokens
|
||||
state["input_tokens"] = total_input_tokens
|
||||
state["output_tokens"] = total_output_tokens
|
||||
state["estimated_cost"] = estimate_token_cost(total_input_tokens, total_output_tokens)
|
||||
|
||||
thresholds = coerce_cost_thresholds(state.get("cost_thresholds"))
|
||||
state["cost_thresholds"] = thresholds
|
||||
budget_warning = is_cost_budget_warning(
|
||||
total_input_tokens,
|
||||
total_output_tokens,
|
||||
state.get("estimated_cost"),
|
||||
thresholds,
|
||||
)
|
||||
previous_budget_warning = bool(state.get("budget_warning") or False)
|
||||
state["budget_warning"] = budget_warning
|
||||
|
||||
agent_id = str(state.get("agent_id") or state.get("current_agent") or AgentRole.MASTER.value)
|
||||
cost_by_agent = {
|
||||
key: dict(value)
|
||||
for key, value in dict(state.get("cost_by_agent") or {}).items()
|
||||
}
|
||||
agent_totals = dict(cost_by_agent.get(agent_id) or {})
|
||||
agent_input_tokens = int(agent_totals.get("input_tokens") or 0) + input_tokens
|
||||
agent_output_tokens = int(agent_totals.get("output_tokens") or 0) + output_tokens
|
||||
agent_estimated_cost = estimate_token_cost(agent_input_tokens, agent_output_tokens)
|
||||
cost_by_agent[agent_id] = {
|
||||
"agent_id": agent_id,
|
||||
"input_tokens": agent_input_tokens,
|
||||
"output_tokens": agent_output_tokens,
|
||||
"total_tokens": agent_input_tokens + agent_output_tokens,
|
||||
"estimated_cost": agent_estimated_cost,
|
||||
"budget_warning": is_cost_budget_warning(
|
||||
agent_input_tokens,
|
||||
agent_output_tokens,
|
||||
agent_estimated_cost,
|
||||
thresholds,
|
||||
),
|
||||
}
|
||||
state["cost_by_agent"] = cost_by_agent
|
||||
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.cost.updated",
|
||||
payload={
|
||||
"agent_id": agent_id,
|
||||
"input_tokens_delta": input_tokens,
|
||||
"output_tokens_delta": output_tokens,
|
||||
"input_tokens": total_input_tokens,
|
||||
"output_tokens": total_output_tokens,
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": budget_warning,
|
||||
},
|
||||
)
|
||||
if budget_warning and not previous_budget_warning:
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.cost.warning",
|
||||
payload={
|
||||
"thresholds": thresholds,
|
||||
"input_tokens": total_input_tokens,
|
||||
"output_tokens": total_output_tokens,
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
},
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def _role_values() -> set[str]:
|
||||
return {role.value for role in AgentRole}
|
||||
|
||||
@@ -1120,6 +1301,43 @@ def _append_event_trace(
|
||||
]
|
||||
|
||||
|
||||
def _set_phase(state: AgentState, phase: str, *, reason: str, payload: dict[str, Any] | None = None) -> None:
|
||||
if state.get("current_phase") == phase:
|
||||
return
|
||||
state["current_phase"] = phase
|
||||
state["phase_history"] = [
|
||||
*(state.get("phase_history") or []),
|
||||
{
|
||||
"phase": phase,
|
||||
"reason": reason,
|
||||
**({"payload": payload} if payload else {}),
|
||||
},
|
||||
]
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.phase.changed",
|
||||
payload={"phase": phase, "reason": reason, **(payload or {})},
|
||||
)
|
||||
|
||||
|
||||
def _record_checkpoint(state: AgentState, checkpoint: str, *, reason: str, payload: dict[str, Any] | None = None) -> None:
|
||||
state["current_checkpoint"] = checkpoint
|
||||
state["checkpoint_history"] = [
|
||||
*(state.get("checkpoint_history") or []),
|
||||
{
|
||||
"checkpoint": checkpoint,
|
||||
"phase": state.get("current_phase"),
|
||||
"reason": reason,
|
||||
**({"payload": payload} if payload else {}),
|
||||
},
|
||||
]
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.checkpoint.recorded",
|
||||
payload={"checkpoint": checkpoint, "phase": state.get("current_phase"), "reason": reason, **(payload or {})},
|
||||
)
|
||||
|
||||
|
||||
def _capability_manifest_for_tool(tool_name: str):
|
||||
indexes = load_builtin_registry_indexes()
|
||||
capability_id = indexes.capability_id_by_tool_name.get(tool_name)
|
||||
@@ -1488,6 +1706,10 @@ async def _execute_tool_calls(
|
||||
"args": normalized_args,
|
||||
"result_preview": _stringify_message_content(result)[:200],
|
||||
"verifier_hints": verifier_hints,
|
||||
"isolation": {
|
||||
"mode": state.get("isolation_mode"),
|
||||
"workspace_path": state.get("isolation_workspace_path"),
|
||||
},
|
||||
}
|
||||
state["tool_outcomes"] = [*(state.get("tool_outcomes") or []), tool_outcome]
|
||||
_append_event_trace(
|
||||
@@ -1549,6 +1771,13 @@ async def _run_sub_commander(
|
||||
_record_sub_commander(state, role, sub_commander, user_query)
|
||||
|
||||
toolset = SUB_COMMANDER_TOOLSETS.get(sub_commander, []) if use_tools else []
|
||||
_prepare_isolation_context(
|
||||
state,
|
||||
role=role,
|
||||
sub_commander=sub_commander,
|
||||
user_query=user_query,
|
||||
toolset=toolset,
|
||||
)
|
||||
if (
|
||||
role == AgentRole.EXECUTOR
|
||||
and _is_short_confirmation(user_query)
|
||||
@@ -1583,6 +1812,7 @@ async def _run_sub_commander(
|
||||
if _guard_sub_commander_budget(state, "iteration_count", "max_iterations", "max_iterations_exceeded"):
|
||||
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
|
||||
response = await _invoke_llm(llm, working_messages)
|
||||
_record_response_usage(state, response)
|
||||
state["final_response"] = _stringify_message_content(response.content)
|
||||
elif capabilities.supports_native_tools:
|
||||
state["tool_strategy_used"] = "native"
|
||||
@@ -1592,6 +1822,7 @@ async def _run_sub_commander(
|
||||
break
|
||||
state["iteration_count"] = int(state.get("iteration_count") or 0) + 1
|
||||
response = await _invoke_llm(bound_llm, working_messages)
|
||||
_record_response_usage(state, response)
|
||||
tool_calls = getattr(response, "tool_calls", None) or []
|
||||
if tool_calls:
|
||||
if not _guard_sub_commander_budget(state, "tool_round_count", "max_tool_rounds", "max_tool_rounds_exceeded"):
|
||||
@@ -1653,6 +1884,7 @@ async def _run_sub_commander(
|
||||
*([retry_instruction] if retry_instruction else []),
|
||||
],
|
||||
)
|
||||
_record_response_usage(state, response)
|
||||
response_text = _stringify_message_content(response.content)
|
||||
parsed = _parse_json_action(response_text, allowed_tools)
|
||||
if parsed is None and response_text.strip() and state.get("tool_round_count"):
|
||||
@@ -1804,6 +2036,27 @@ def _build_task_evidence(state: AgentState, start_index: int) -> list[dict[str,
|
||||
else:
|
||||
evidence = []
|
||||
|
||||
if state.get("isolation_mode") and state.get("isolation_mode") != "none":
|
||||
evidence.append(
|
||||
{
|
||||
"type": "isolation",
|
||||
"mode": state.get("isolation_mode"),
|
||||
"workspace_path": state.get("isolation_workspace_path"),
|
||||
"metadata": dict(state.get("isolation_metadata") or {}),
|
||||
}
|
||||
)
|
||||
|
||||
if state.get("input_tokens") or state.get("output_tokens"):
|
||||
evidence.append(
|
||||
{
|
||||
"type": "cost",
|
||||
"input_tokens": int(state.get("input_tokens") or 0),
|
||||
"output_tokens": int(state.get("output_tokens") or 0),
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": bool(state.get("budget_warning") or False),
|
||||
}
|
||||
)
|
||||
|
||||
if state.get("verification_status") or state.get("verification_summary"):
|
||||
evidence.append(
|
||||
{
|
||||
@@ -1846,6 +2099,10 @@ def _collect_task_result(task: AgentTask, state: AgentState, start_tool_index: i
|
||||
"role": task.role,
|
||||
"sub_commander": state.get("current_sub_commander"),
|
||||
"verification_status": state.get("verification_status"),
|
||||
"isolation_mode": state.get("isolation_mode"),
|
||||
"isolation_workspace_path": state.get("isolation_workspace_path"),
|
||||
"estimated_cost": state.get("estimated_cost"),
|
||||
"budget_warning": bool(state.get("budget_warning") or False),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1959,10 +2216,15 @@ def _verify_collaboration_results(
|
||||
|
||||
|
||||
async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentState:
|
||||
_set_phase(state, "phase_2_controlled_collaboration", reason="collaboration_flow_started")
|
||||
_record_checkpoint(state, "collaboration.tasks_planning", reason="collaboration_flow_started")
|
||||
tasks = _build_collaboration_tasks(user_query)
|
||||
if len(tasks) < 2:
|
||||
state["execution_mode"] = "direct"
|
||||
state["routing_decision"] = {"mode": "direct", "reason": "collaboration_plan_fell_back"}
|
||||
_record_checkpoint(state, "collaboration.fallback_to_direct", reason="insufficient_tasks", payload={"task_count": len(tasks)})
|
||||
_set_phase(state, "phase_1_routing", reason="collaboration_flow_abandoned", payload={"task_count": len(tasks)})
|
||||
_record_checkpoint(state, "routing.direct_resumed", reason="collaboration_flow_abandoned", payload={"task_count": len(tasks)})
|
||||
return state
|
||||
|
||||
base_history = list(state.get("messages", []))
|
||||
@@ -1988,12 +2250,15 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
payload=budget_snapshot,
|
||||
)
|
||||
state["active_tasks"] = [task.model_dump(mode="json") for task in tasks]
|
||||
_record_checkpoint(state, "collaboration.tasks_ready", reason="tasks_built", payload={"task_count": len(tasks)})
|
||||
parent_task_id = next((task.parent_task_id for task in tasks if task.parent_task_id), None) or "root"
|
||||
state["task_hierarchy"] = {parent_task_id: [task.task_id for task in tasks]}
|
||||
state["task_results"] = []
|
||||
state["next_step"] = None
|
||||
|
||||
_set_phase(state, "phase_3_dynamic_collaboration", reason="collaboration_workers_dispatch")
|
||||
for task in tasks:
|
||||
_record_checkpoint(state, "collaboration.task_dispatch", reason="dispatch_task", payload={"task_id": task.task_id, "role": task.role})
|
||||
state["current_agent"] = AgentRole.MASTER.value
|
||||
state["agent_id"] = coordinator_agent_id
|
||||
state["parent_agent_id"] = None
|
||||
@@ -2046,6 +2311,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
)
|
||||
|
||||
task_result = _collect_task_result(task, state, start_tool_index)
|
||||
_record_checkpoint(state, "collaboration.task_result_collected", reason="task_finished", payload={"task_id": task.task_id, "status": task_result.status})
|
||||
_append_message_trace(
|
||||
state,
|
||||
from_agent_id=child_agent_id,
|
||||
@@ -2077,6 +2343,8 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
state["root_agent_id"] = root_agent_id
|
||||
state["collaboration_depth"] = 0
|
||||
state["final_response"] = _build_collaboration_final_response(state.get("task_results") or [])
|
||||
_set_phase(state, "phase_4_visibility_and_verification", reason="collaboration_verification_started")
|
||||
_record_checkpoint(state, "collaboration.verification_started", reason="before_verify")
|
||||
_append_event_trace(
|
||||
state,
|
||||
"agent.verify.started",
|
||||
@@ -2096,6 +2364,7 @@ async def _run_collaboration_flow(state: AgentState, user_query: str) -> AgentSt
|
||||
},
|
||||
severity="error" if state.get("verification_status") == "failed" else "info",
|
||||
)
|
||||
_record_checkpoint(state, "collaboration.completed", reason="collaboration_flow_finished", payload={"verification_status": state.get("verification_status")})
|
||||
state["messages"] = [*base_history, AIMessage(content=state["final_response"])]
|
||||
state["should_respond"] = True
|
||||
return state
|
||||
@@ -2114,6 +2383,8 @@ def _stop_due_to_loop_guard(state: AgentState) -> AgentState:
|
||||
|
||||
async def master_node(state: AgentState) -> AgentState:
|
||||
_maybe_reset_turn_budgets(state)
|
||||
_set_phase(state, "phase_1_routing", reason="master_node_entered")
|
||||
_record_checkpoint(state, "routing.master_entered", reason="master_node_entered")
|
||||
user_messages = _filter_user_messages(state["messages"])
|
||||
user_query = _stringify_message_content(user_messages[-1].content).strip() if user_messages else ""
|
||||
|
||||
@@ -2179,6 +2450,7 @@ async def master_node(state: AgentState) -> AgentState:
|
||||
|
||||
llm = _get_llm_for_state(state)
|
||||
response = await _invoke_llm(llm, [SystemMessage(content=MASTER_SYSTEM_PROMPT), *state["messages"]])
|
||||
_record_response_usage(state, response)
|
||||
content = _stringify_message_content(response.content).strip()
|
||||
|
||||
routed_agent = _route_agent_from_user_query(content)
|
||||
|
||||
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(),
|
||||
},
|
||||
}
|
||||
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": {}
|
||||
}
|
||||
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)
|
||||
)
|
||||
@@ -23,6 +23,12 @@ AgentEventType = Literal[
|
||||
"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"]
|
||||
|
||||
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"],
|
||||
},
|
||||
]
|
||||
@@ -8,6 +8,14 @@ from app.agents.schemas.task import AgentTask, CollaborationBudget, InterruptRec
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
||||
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):
|
||||
MASTER = "master"
|
||||
@@ -75,8 +83,23 @@ class AgentState(TypedDict):
|
||||
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_round_count: int
|
||||
@@ -161,8 +184,34 @@ def initial_state(user_id: str, conversation_id: str) -> AgentState:
|
||||
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,
|
||||
max_tool_rounds=2,
|
||||
|
||||
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
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.conversation import Conversation
|
||||
from app.models.skill import Skill
|
||||
@@ -17,14 +19,21 @@ from app.schemas.agent import (
|
||||
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
|
||||
@@ -153,12 +162,13 @@ def _build_topology_nodes(
|
||||
|
||||
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=None,
|
||||
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),
|
||||
@@ -185,6 +195,153 @@ def _build_topology_nodes(
|
||||
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):
|
||||
_agent_call_counts[agent_id] = _agent_call_counts.get(agent_id, 0) + 1
|
||||
|
||||
@@ -475,6 +632,36 @@ async def get_visibility_verifier(
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
async def create_agent(
|
||||
data: AgentCreate,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""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
|
||||
|
||||
@@ -167,3 +170,53 @@ async def add_to_marketplace(plugin: dict[str, str]) -> dict[str, str]:
|
||||
_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)}")
|
||||
|
||||
@@ -149,3 +149,73 @@ class AgentVisibilityVerifierOut(BaseModel):
|
||||
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.agents.graph import get_agent_graph
|
||||
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.brain_service import BrainService
|
||||
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):
|
||||
return (
|
||||
getattr(capabilities, "provider", None) not in {"openai", "claude"}
|
||||
and any(marker in error_text for marker in markers)
|
||||
return getattr(capabilities, "provider", None) not in {"openai", "claude"} and any(
|
||||
marker in error_text for marker in markers
|
||||
)
|
||||
|
||||
return any(marker in error_text for marker in markers)
|
||||
@@ -153,8 +153,23 @@ _CONTINUITY_SNAPSHOT_FIELDS = (
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
@@ -166,7 +181,11 @@ def _normalize_legacy_turn_context(turn_context: Any, current_agent: Any) -> dic
|
||||
active_sub_flow = normalized.pop("active_sub_flow", None)
|
||||
if isinstance(active_agent, str) and active_agent and "active_agent" not in normalized:
|
||||
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
|
||||
if not normalized.get("active_agent") and isinstance(current_agent, str) and current_agent:
|
||||
normalized["active_agent"] = current_agent
|
||||
@@ -342,11 +361,32 @@ class AgentService:
|
||||
"【当前时间】\n"
|
||||
f"- current_time_utc: {reference['current_time_iso']}\n"
|
||||
f"- current_date_utc: {reference['current_date_iso']}\n"
|
||||
"说明:解析‘今天/明天/后天/本周/下周’等相对时间时,请以 current_time_utc 为准。"
|
||||
"说明:解析'今天/明天/后天/本周/下周'等相对时间时,请以 current_time_utc 为准。"
|
||||
)
|
||||
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 模型配置"""
|
||||
user = await self.db.get(User, user_id)
|
||||
if not user or not user.llm_config:
|
||||
@@ -396,13 +436,15 @@ class AgentService:
|
||||
user_llm_config: dict | None,
|
||||
) -> dict[str, Any]:
|
||||
state = initial_state(user_id, conversation.id)
|
||||
state.update({
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
"user_llm_config": user_llm_config,
|
||||
})
|
||||
state.update(
|
||||
{
|
||||
"messages": [HumanMessage(content=full_message)],
|
||||
"memory_context": memory_context,
|
||||
"current_datetime_context": current_datetime_context,
|
||||
"current_datetime_reference": current_datetime_reference,
|
||||
"user_llm_config": user_llm_config,
|
||||
}
|
||||
)
|
||||
previous_snapshot = await self._load_continuity_snapshot(conversation)
|
||||
if previous_snapshot:
|
||||
state.update(previous_snapshot)
|
||||
@@ -464,6 +506,7 @@ class AgentService:
|
||||
file_context = ""
|
||||
if file_ids:
|
||||
from app.services.document_service import DocumentService
|
||||
|
||||
doc_svc = DocumentService(self.db)
|
||||
for file_id in file_ids:
|
||||
content = await doc_svc.get_document_content(user_id, file_id)
|
||||
@@ -529,7 +572,9 @@ class AgentService:
|
||||
set_current_user(user_id)
|
||||
try:
|
||||
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(
|
||||
user_id=user_id,
|
||||
@@ -542,7 +587,9 @@ class AgentService:
|
||||
)
|
||||
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:
|
||||
async for event in graph.astream_events(state, version="v2"):
|
||||
@@ -551,7 +598,13 @@ class AgentService:
|
||||
metadata = event.get("metadata", {})
|
||||
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 = {
|
||||
"master": ("thinking", "Jarvis 正在理解请求"),
|
||||
"schedule_planner": ("planning", "Jarvis 正在编排日程"),
|
||||
@@ -559,9 +612,13 @@ class AgentService:
|
||||
"librarian": ("tool", "Jarvis 正在检索知识"),
|
||||
"analyst": ("thinking", "Jarvis 正在分析信息"),
|
||||
}
|
||||
stage, label = stage_map.get(event_name, ("thinking", "Jarvis 正在思考"))
|
||||
yield self._build_progress_event(stage, label, agent=event_name, step=label)
|
||||
|
||||
stage, label = stage_map.get(
|
||||
event_name, ("thinking", "Jarvis 正在思考")
|
||||
)
|
||||
yield self._build_progress_event(
|
||||
stage, label, agent=event_name, step=label
|
||||
)
|
||||
|
||||
elif kind == "on_tool_start":
|
||||
yield self._build_progress_event(
|
||||
"tool",
|
||||
@@ -570,7 +627,7 @@ class AgentService:
|
||||
tool_name=event_name,
|
||||
step=f"正在执行 {event_name}",
|
||||
)
|
||||
|
||||
|
||||
elif kind == "on_tool_end":
|
||||
tool_result = data.get("output")
|
||||
step = f"已完成 {event_name}"
|
||||
@@ -583,14 +640,16 @@ class AgentService:
|
||||
tool_name=event_name,
|
||||
step=step,
|
||||
)
|
||||
|
||||
|
||||
elif kind == "on_chat_model_stream":
|
||||
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:
|
||||
collected += content
|
||||
yield {"type": "chunk", "content": content}
|
||||
|
||||
|
||||
elif kind == "on_chain_end":
|
||||
output = data.get("output")
|
||||
final_resp = None
|
||||
@@ -605,7 +664,9 @@ class AgentService:
|
||||
|
||||
elif kind == "on_chat_model_end":
|
||||
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:
|
||||
final_text = final_content
|
||||
if final_text != collected:
|
||||
@@ -614,12 +675,16 @@ class AgentService:
|
||||
|
||||
except Exception as e:
|
||||
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:
|
||||
result_state = await graph.ainvoke(state)
|
||||
if isinstance(result_state, dict):
|
||||
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)
|
||||
yield {"type": "chunk", "content": collected}
|
||||
except Exception:
|
||||
@@ -643,14 +708,24 @@ class AgentService:
|
||||
if collected:
|
||||
assistant_msg.content = collected
|
||||
continuity_snapshot = _build_continuity_snapshot(state or {})
|
||||
assistant_msg.attachments = ([{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}] if continuity_snapshot else None)
|
||||
conv.agent_state = ({
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
} if continuity_snapshot else None)
|
||||
assistant_msg.attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
]
|
||||
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(
|
||||
user_id,
|
||||
**_build_assistant_event_payload(collected),
|
||||
@@ -728,12 +803,16 @@ class AgentService:
|
||||
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)
|
||||
try:
|
||||
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(
|
||||
user_id=user_id,
|
||||
conversation=conv,
|
||||
@@ -745,7 +824,9 @@ class AgentService:
|
||||
)
|
||||
state.update(_derive_role_memory_contexts(memory_ctx))
|
||||
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:
|
||||
logger.exception("agent_chat_simple_failed")
|
||||
response_content = "抱歉,发生错误。"
|
||||
@@ -766,15 +847,27 @@ class AgentService:
|
||||
)
|
||||
|
||||
assistant_msg.content = response_content
|
||||
continuity_snapshot = _build_continuity_snapshot(result_state) if 'result_state' in locals() else None
|
||||
assistant_msg.attachments = ([{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}] if continuity_snapshot else None)
|
||||
conv.agent_state = ({
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
} if continuity_snapshot else None)
|
||||
continuity_snapshot = (
|
||||
_build_continuity_snapshot(result_state) if "result_state" in locals() else None
|
||||
)
|
||||
assistant_msg.attachments = (
|
||||
[
|
||||
{
|
||||
"kind": "agent_continuity_state",
|
||||
**continuity_snapshot,
|
||||
}
|
||||
]
|
||||
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.refresh(assistant_msg)
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ from app.agents.graph import (
|
||||
_create_child_agent,
|
||||
_execute_tool_calls,
|
||||
_parse_json_action,
|
||||
_record_checkpoint,
|
||||
_record_interrupt,
|
||||
_record_recovery,
|
||||
_route_agent_from_user_query,
|
||||
_select_request_mode,
|
||||
_set_phase,
|
||||
_spawn_permission_for_role,
|
||||
_run_collaboration_flow,
|
||||
_run_sub_commander,
|
||||
@@ -78,8 +80,23 @@ def _base_state(message: str, user_llm_config: dict | None = None) -> dict:
|
||||
'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,
|
||||
'max_tool_rounds': 2,
|
||||
@@ -310,6 +327,24 @@ def test_initial_state_sets_structured_continuity_defaults():
|
||||
assert state['clarification_context'] is None
|
||||
assert state['event_trace'] == []
|
||||
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():
|
||||
@@ -627,6 +662,15 @@ async def test_run_collaboration_flow_collects_task_results_and_verifies(monkeyp
|
||||
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'])
|
||||
@@ -637,6 +681,8 @@ async def test_master_node_enters_collaboration_mode_for_complex_multi_role_requ
|
||||
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
|
||||
|
||||
@@ -647,6 +693,31 @@ async def test_master_node_enters_collaboration_mode_for_complex_multi_role_requ
|
||||
|
||||
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):
|
||||
@@ -1404,6 +1475,78 @@ def test_build_verifier_hints_uses_capability_metadata():
|
||||
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')
|
||||
|
||||
@@ -135,6 +135,45 @@ async def visibility_env(tmp_path):
|
||||
'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'},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -396,6 +435,87 @@ async def test_visibility_verifier_returns_verdict(visibility_env):
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.models.conversation import Conversation, Message
|
||||
from app.models.memory import MemorySummary, UserMemory
|
||||
from app.models.user import User
|
||||
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.document_service import DocumentService
|
||||
|
||||
@@ -23,6 +23,32 @@ class FakeGraph:
|
||||
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:
|
||||
async def astream_events(self, state, version="v2"):
|
||||
yield {
|
||||
|
||||
Reference in New Issue
Block a user