feat(agents): Phase 8.4-10.5 built-in plugins, bundled skills, coordinator

This commit is contained in:
2026-04-04 23:24:34 +08:00
parent 88955ed550
commit d18167826e
105 changed files with 14780 additions and 15685 deletions

View 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

View 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

View 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

View File

@@ -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)

View 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",
]

View 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",
},
}

View 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,
)

View 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(),
},
}

View 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]

View File

@@ -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": {}
}

View File

@@ -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]

View File

@@ -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": {}
}

View 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]

View 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": {}
}

View 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]

View 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": {}
}

View 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)
)

View File

@@ -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"]

View 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"],
},
]

View File

@@ -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,

View 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

View File

@@ -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,

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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)