M4b:ai_provider 删除手术①-⑥——神识全面接管模型出口
- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件) - ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好 - ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied) - ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知 - ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_* - ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
This commit is contained in:
1 parent
0d383725b5
commit
462d570751
129 files changed
+81
-15867
No files matched your search
@@ -1,82 +0,0 @@
|
||||
"""非流式 Agent 应用层:工具调用闭环编排。
|
||||
|
||||
入口:
|
||||
- ``run_agent_turn`` 执行一轮带工具调用的对话循环
|
||||
- ``AgentTurnResult`` 循环产出
|
||||
- ``to_ai_provider_tools`` 把 ToolSpec 转成 OpenAI function schema dict
|
||||
- ``default_tools`` 返回全部内置工具(依赖司命 + 确认流控风险)
|
||||
"""
|
||||
|
||||
from app.application.agent.agent import (
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentState,
|
||||
AgentStatus,
|
||||
PendingMessageQueue,
|
||||
QueueMode,
|
||||
)
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AfterToolCallDecision,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
BeforeToolCallDecision,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.application.agent.prompt_composer import compose_system_messages
|
||||
from app.application.agent.python_skills import (
|
||||
PythonSkillActivation,
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
discover_python_skills,
|
||||
)
|
||||
from app.application.agent.run_registry import AgentRunRegistry
|
||||
from app.application.agent.runtime import AgentTurnResult, run_agent_turn
|
||||
from app.application.agent.skill_loader import load_skill_manifests
|
||||
from app.application.agent.tasks import (
|
||||
next_step_for_tool,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.agent.tools import default_tools, to_ai_provider_tools
|
||||
|
||||
__all__ = [
|
||||
"AfterToolCallDecision",
|
||||
"Agent",
|
||||
"AgentConfig",
|
||||
"AgentContext",
|
||||
"AgentEvent",
|
||||
"AgentRunContext",
|
||||
"AgentRunRegistry",
|
||||
"AgentState",
|
||||
"AgentStatus",
|
||||
"AgentTurnResult",
|
||||
"BeforeToolCallDecision",
|
||||
"CancellationToken",
|
||||
"ConfirmationRegistry",
|
||||
"ExtensionRegistry",
|
||||
"PendingMessageQueue",
|
||||
"PythonSkillActivation",
|
||||
"PythonSkillCatalog",
|
||||
"PythonSkillDefinition",
|
||||
"QueueMode",
|
||||
"ToolCallContext",
|
||||
"TurnSnapshot",
|
||||
"activate_python_skills",
|
||||
"compose_system_messages",
|
||||
"default_tools",
|
||||
"discover_python_skills",
|
||||
"load_skill_manifests",
|
||||
"next_step_for_tool",
|
||||
"run_agent_turn",
|
||||
"step_event_payload",
|
||||
"task_from_deliberative_plan",
|
||||
"task_plan_payload",
|
||||
"to_ai_provider_tools",
|
||||
]
|
||||
@@ -1,303 +0,0 @@
|
||||
"""Pi agent-core 风格的有状态 Agent 门面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk, TokenUsage
|
||||
from app.ai_provider.types.messages import BaseMessage, HumanMessage
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
)
|
||||
from app.application.agent.runtime import AgentTurnResult, run_agent_turn
|
||||
from app.modules.tool_runtime.definition import ToolRuntimeLike
|
||||
|
||||
|
||||
class AgentStatus(StrEnum):
|
||||
idle = "idle"
|
||||
running = "running"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class QueueMode(StrEnum):
|
||||
all = "all"
|
||||
one_at_a_time = "one-at-a-time"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentState:
|
||||
"""有状态 Agent 的进程内快照。"""
|
||||
|
||||
messages: list[BaseMessage] = field(default_factory=list)
|
||||
status: AgentStatus = AgentStatus.idle
|
||||
streaming_message: str = ""
|
||||
pending_tool_calls: set[str] = field(default_factory=set)
|
||||
error_message: str | None = None
|
||||
usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
|
||||
|
||||
class PendingMessageQueue:
|
||||
"""steering/follow-up 共用的小型 FIFO。"""
|
||||
|
||||
def __init__(self, mode: QueueMode = QueueMode.one_at_a_time) -> None:
|
||||
self.mode = mode
|
||||
self._messages: list[BaseMessage] = []
|
||||
|
||||
def put(self, message: BaseMessage) -> None:
|
||||
self._messages.append(message)
|
||||
|
||||
def drain(self) -> list[BaseMessage]:
|
||||
if not self._messages:
|
||||
return []
|
||||
if self.mode is QueueMode.all:
|
||||
messages, self._messages = self._messages, []
|
||||
return messages
|
||||
return [self._messages.pop(0)]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._messages.clear()
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._messages)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
"""Stateful Agent 的稳定运行配置。"""
|
||||
|
||||
tools: tuple[dict[str, Any], ...]
|
||||
tool_runtime: ToolRuntimeLike | None
|
||||
model_hint: str | None = None
|
||||
thinking_enabled: bool | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_iterations: int = 8
|
||||
confirmation_registry: ConfirmationRegistry | None = None
|
||||
confirmation_timeout: float = 300.0
|
||||
on_turn: Callable[[], Awaitable[None]] | None = None
|
||||
on_chunk: Callable[[ChatChunk], Awaitable[None]] | None = None
|
||||
on_tool_event: Callable[[str, dict[str, Any]], Awaitable[None]] | None = None
|
||||
on_confirm_timeout: Callable[[str], Awaitable[None]] | None = None
|
||||
|
||||
|
||||
class Agent:
|
||||
"""管理状态、消息队列、取消与扩展 Hook;低层循环仍由 run_agent_turn 负责。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AgentConfig,
|
||||
*,
|
||||
messages: list[BaseMessage] | None = None,
|
||||
run_context: AgentRunContext | None = None,
|
||||
extensions: ExtensionRegistry | None = None,
|
||||
runner: Callable[..., Awaitable[AgentTurnResult]] = run_agent_turn,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.state = AgentState(messages=list(messages or []))
|
||||
self.run_context = run_context or AgentRunContext()
|
||||
self.extensions = extensions or ExtensionRegistry()
|
||||
self._runner = runner
|
||||
self.steering = PendingMessageQueue()
|
||||
self.follow_ups = PendingMessageQueue()
|
||||
self._active_task: asyncio.Task[AgentTurnResult] | None = None
|
||||
self._accepting_messages = False
|
||||
self._idle = asyncio.Event()
|
||||
self._idle.set()
|
||||
|
||||
def steer(self, message: str | BaseMessage) -> bool:
|
||||
if not self._accepting_messages:
|
||||
return False
|
||||
self.steering.put(self._as_message(message))
|
||||
return True
|
||||
|
||||
def follow_up(self, message: str | BaseMessage) -> bool:
|
||||
if not self._accepting_messages:
|
||||
return False
|
||||
self.follow_ups.put(self._as_message(message))
|
||||
return True
|
||||
|
||||
async def prompt(self, message: str | BaseMessage) -> AgentTurnResult:
|
||||
if self.state.status is AgentStatus.running:
|
||||
raise RuntimeError("Agent 正在运行,请使用 steer 或 follow_up")
|
||||
self.state.messages.append(self._as_message(message))
|
||||
return await self._run()
|
||||
|
||||
async def continue_run(self) -> AgentTurnResult:
|
||||
if self.state.status is AgentStatus.running:
|
||||
raise RuntimeError("Agent 正在运行")
|
||||
return await self._run()
|
||||
|
||||
async def abort(self) -> bool:
|
||||
task = self._active_task
|
||||
if task is None or task.done():
|
||||
return False
|
||||
self.run_context.cancellation.cancel()
|
||||
task.cancel()
|
||||
return True
|
||||
|
||||
async def wait_for_idle(self) -> None:
|
||||
await self._idle.wait()
|
||||
|
||||
async def reset(self) -> None:
|
||||
await self.abort()
|
||||
await self.wait_for_idle()
|
||||
self.state = AgentState()
|
||||
self.steering.clear()
|
||||
self.follow_ups.clear()
|
||||
|
||||
async def _run(self) -> AgentTurnResult:
|
||||
if self.run_context.cancellation.cancelled:
|
||||
self.run_context = replace(self.run_context, cancellation=CancellationToken())
|
||||
self.state.status = AgentStatus.running
|
||||
self.state.streaming_message = ""
|
||||
self.state.pending_tool_calls.clear()
|
||||
self.state.error_message = None
|
||||
self._accepting_messages = True
|
||||
self._idle.clear()
|
||||
try:
|
||||
await self.extensions.publish(AgentEvent("agent.started"), self.run_context)
|
||||
task = asyncio.create_task(self._run_loop())
|
||||
self._active_task = task
|
||||
result = await task
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.completed
|
||||
self.state.streaming_message = result.content
|
||||
self.state.usage = result.usage
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.ended", {"iterations": result.iterations}), self.run_context
|
||||
)
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.cancelled
|
||||
await self.extensions.publish(AgentEvent("agent.cancelled"), self.run_context)
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._accepting_messages = False
|
||||
self.state.status = AgentStatus.failed
|
||||
self.state.error_message = str(exc)
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.failed", {"error": str(exc)}), self.run_context
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._accepting_messages = False
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent("agent.settled", {"status": self.state.status.value}),
|
||||
self.run_context,
|
||||
)
|
||||
self._active_task = None
|
||||
self._idle.set()
|
||||
|
||||
async def _run_loop(self) -> AgentTurnResult:
|
||||
remaining = self.config.max_iterations
|
||||
total_iterations = 0
|
||||
total_usage = TokenUsage()
|
||||
total_tool_history: list[dict[str, Any]] = []
|
||||
final = AgentTurnResult(content="", reasoning="")
|
||||
while remaining > 0:
|
||||
working_messages = list(self.state.messages)
|
||||
final = await self._runner(
|
||||
messages=working_messages,
|
||||
tools=list(self.config.tools),
|
||||
tool_runtime=self.config.tool_runtime,
|
||||
model_hint=self.config.model_hint,
|
||||
thinking_enabled=self.config.thinking_enabled,
|
||||
reasoning_effort=self.config.reasoning_effort,
|
||||
max_iterations=remaining,
|
||||
run_id=self.run_context.run_id,
|
||||
character_id=self.run_context.character_id,
|
||||
trace_id=self.run_context.trace_id,
|
||||
on_turn=self.config.on_turn,
|
||||
on_chunk=self._on_chunk,
|
||||
on_tool_event=self._on_tool_event,
|
||||
confirmation_registry=self.config.confirmation_registry,
|
||||
confirmation_timeout=self.config.confirmation_timeout,
|
||||
on_confirm_timeout=self.config.on_confirm_timeout,
|
||||
extensions=self.extensions,
|
||||
run_context=self.run_context,
|
||||
get_steering_messages=self.steering.drain,
|
||||
)
|
||||
self.state.messages = working_messages
|
||||
total_iterations += final.iterations
|
||||
total_usage += final.usage
|
||||
total_tool_history.extend(final.tool_history)
|
||||
remaining -= final.iterations
|
||||
if final.truncated:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=True,
|
||||
usage=total_usage,
|
||||
)
|
||||
if remaining <= 0:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=bool(self.follow_ups),
|
||||
usage=total_usage,
|
||||
)
|
||||
follow_ups = self.follow_ups.drain()
|
||||
if not follow_ups:
|
||||
return AgentTurnResult(
|
||||
content=final.content,
|
||||
reasoning=final.reasoning,
|
||||
tool_history=total_tool_history,
|
||||
iterations=total_iterations,
|
||||
truncated=final.truncated,
|
||||
usage=total_usage,
|
||||
)
|
||||
self.state.messages.extend(follow_ups)
|
||||
return final
|
||||
|
||||
async def _on_chunk(self, chunk: ChatChunk) -> None:
|
||||
if chunk.delta_content:
|
||||
self.state.streaming_message += chunk.delta_content
|
||||
if self.config.on_chunk is not None:
|
||||
await self.config.on_chunk(chunk)
|
||||
|
||||
async def _on_tool_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
||||
call_id = str(payload.get("tool_call_id", ""))
|
||||
if event_type == "tool.started" and call_id:
|
||||
self.state.pending_tool_calls.add(call_id)
|
||||
elif event_type in ("tool.completed", "tool.failed") and call_id:
|
||||
self.state.pending_tool_calls.discard(call_id)
|
||||
# 先转发 SSE(用户可见流优先),再把同一事件发布进扩展事件流,
|
||||
# 让 AgentEventListener 也能观测工具级生命周期。
|
||||
# 扩展 listener 异常被吞掉,避免观测副菜阻断工具事件主流程。
|
||||
if self.config.on_tool_event is not None:
|
||||
await self.config.on_tool_event(event_type, payload)
|
||||
with suppress(Exception):
|
||||
await self.extensions.publish(
|
||||
AgentEvent(event_type, payload), self.run_context
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_message(message: str | BaseMessage) -> BaseMessage:
|
||||
return HumanMessage(content=message) if isinstance(message, str) else message
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Agent",
|
||||
"AgentConfig",
|
||||
"AgentState",
|
||||
"AgentStatus",
|
||||
"PendingMessageQueue",
|
||||
"QueueMode",
|
||||
]
|
||||
@@ -1,148 +0,0 @@
|
||||
"""混合确认注册表 — 同进程 Event 唤醒 + 重启后 DB polling 兜底。
|
||||
|
||||
Agent 循环在工具需要确认时:
|
||||
1. 写 PendingConfirmationRecord 到 DB(持久化挂起状态)
|
||||
2. 调 ``ConfirmationRegistry.wait(run_id)`` — 同进程用 Event 0 延迟唤醒,
|
||||
重启后 polling DB 兜底
|
||||
|
||||
approve/deny 端点统一调 ``registry.resolve(run_id, approved)``:
|
||||
- 更新 PendingConfirmationRecord.status + resolved_at
|
||||
- 同一进程直接 set Event
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
|
||||
class ConfirmationRegistry:
|
||||
"""混合确认注册表。
|
||||
|
||||
- 同进程:``asyncio.Event`` 直接唤醒,0 延迟
|
||||
- 重启后:Event 不存在 → polling ``PendingConfirmationRecord.status`` 兜底
|
||||
- approve/deny 端点统一调用 ``resolve(run_id, approved)``
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: dict[str, asyncio.Event] = {}
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
timeout: float = 60,
|
||||
confirmation_id: str | None = None,
|
||||
) -> bool:
|
||||
"""等待确认。
|
||||
|
||||
同一进程的事件直接唤醒;跨进程或重启后 polling 兜底。
|
||||
|
||||
``confirmation_id`` 可选,传入时会同时注册为该 Event 的别名,
|
||||
使得外部以 ``confirmation_id`` 调 ``resolve`` 时也能立即唤醒同进程的 Event
|
||||
(无需等 DB polling 兜底)。
|
||||
|
||||
返回:
|
||||
True=批准, False=拒绝 ; 超时抛出 TimeoutError
|
||||
"""
|
||||
event = asyncio.Event()
|
||||
self._events[run_id] = event
|
||||
if confirmation_id:
|
||||
self._events[confirmation_id] = event
|
||||
|
||||
try:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
# 1) polling DB 获取真实状态(Event 只用于唤醒,不携带批准/拒绝值)
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
if confirmation_id is None:
|
||||
stmt = select(PendingConfirmationRecord).where(
|
||||
or_(
|
||||
PendingConfirmationRecord.id == run_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = select(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is not None and record.status != "pending":
|
||||
return record.status == "approved"
|
||||
|
||||
# 2) 等一个间隔,同时监听 Event(Event set 只表示「有变化」,实际值靠 DB)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(event.wait()),
|
||||
timeout=poll_interval,
|
||||
)
|
||||
# Event 被唤醒 → 回到循环顶部重新查 DB
|
||||
continue
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
# 超时
|
||||
raise TimeoutError(f"确认超时(等待 {timeout:.0f}s 未裁决)")
|
||||
finally:
|
||||
self._events.pop(run_id, None)
|
||||
if confirmation_id:
|
||||
self._events.pop(confirmation_id, None)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
run_id: str,
|
||||
approved: bool,
|
||||
*,
|
||||
confirmation_id: str | None = None,
|
||||
) -> bool:
|
||||
"""精确裁决一个待确认工具调用,并唤醒对应等待者。"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
target_id = confirmation_id or run_id
|
||||
async with AsyncSessionLocal() as session:
|
||||
status = "approved" if approved else "denied"
|
||||
stmt = update(PendingConfirmationRecord).where(
|
||||
PendingConfirmationRecord.status == "pending"
|
||||
)
|
||||
if confirmation_id is None:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
PendingConfirmationRecord.id == run_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.run_id == run_id,
|
||||
)
|
||||
result = await session.execute(
|
||||
stmt.values(status=status, resolved_at=datetime.now(UTC))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
return False
|
||||
|
||||
event = self._events.get(target_id) or self._events.get(run_id)
|
||||
if event is not None:
|
||||
event.set()
|
||||
return True
|
||||
|
||||
def has(self, run_id: str) -> bool:
|
||||
"""是否仍在等待(同进程检查)。"""
|
||||
event = self._events.get(run_id)
|
||||
return event is not None and not event.is_set()
|
||||
|
||||
|
||||
__all__ = ["ConfirmationRegistry"]
|
||||
@@ -1,229 +0,0 @@
|
||||
"""Agent 扩展契约与注册表。
|
||||
|
||||
扩展只接触本次运行的快照与 ToolResult,不持有数据库会话、密钥或安全治理对象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.ai_provider.types.messages import BaseMessage
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
class CancellationToken:
|
||||
"""协作式取消信号;阻塞 await 仍由活动 asyncio.Task.cancel() 负责中断。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cancelled = False
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def raise_if_cancelled(self) -> None:
|
||||
if self._cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentRunContext:
|
||||
"""扩展可见的最小运行上下文。"""
|
||||
|
||||
run_id: str | None = None
|
||||
session_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
cancellation: CancellationToken = field(default_factory=CancellationToken)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentContext:
|
||||
"""每轮发送给模型前可转换的上下文快照。"""
|
||||
|
||||
messages: tuple[BaseMessage, ...]
|
||||
tools: tuple[dict[str, Any], ...]
|
||||
model_hint: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentEvent:
|
||||
"""Agent Core 内部生命周期事件。"""
|
||||
|
||||
type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallContext:
|
||||
"""工具调用 Hook 的稳定输入。"""
|
||||
|
||||
tool_call_id: str
|
||||
name: str
|
||||
input: dict[str, Any]
|
||||
messages: tuple[BaseMessage, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeforeToolCallDecision:
|
||||
"""执行前决策;扩展只能收紧或改参,不能绕过 ToolRuntime。"""
|
||||
|
||||
allowed: bool = True
|
||||
reason: str | None = None
|
||||
input: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AfterToolCallDecision:
|
||||
"""执行后结果覆盖与提前终止提示。"""
|
||||
|
||||
result: ToolResult | None = None
|
||||
terminate: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnSnapshot:
|
||||
"""一轮模型调用及其工具执行结果摘要。"""
|
||||
|
||||
iteration: int
|
||||
content: str
|
||||
reasoning: str
|
||||
tool_calls: tuple[dict[str, Any], ...]
|
||||
tool_results: tuple[dict[str, Any], ...]
|
||||
usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
|
||||
|
||||
ContextTransformer = Callable[[AgentContext, AgentRunContext], Awaitable[AgentContext]]
|
||||
BeforeToolCallHook = Callable[
|
||||
[ToolCallContext, AgentRunContext], Awaitable[BeforeToolCallDecision | None]
|
||||
]
|
||||
AfterToolCallHook = Callable[
|
||||
[ToolCallContext, ToolResult, AgentRunContext], Awaitable[AfterToolCallDecision | None]
|
||||
]
|
||||
TurnPolicy = Callable[[TurnSnapshot, AgentRunContext], Awaitable[bool]]
|
||||
AgentEventListener = Callable[[AgentEvent, AgentRunContext], Awaitable[None]]
|
||||
|
||||
|
||||
class ExtensionRegistry:
|
||||
"""受约束的 Agent 扩展注册表;按注册顺序执行 Hook。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._context_transformers: list[ContextTransformer] = []
|
||||
self._before_tool_hooks: list[BeforeToolCallHook] = []
|
||||
self._after_tool_hooks: list[AfterToolCallHook] = []
|
||||
self._turn_policies: list[TurnPolicy] = []
|
||||
self._event_listeners: list[AgentEventListener] = []
|
||||
|
||||
def add_context_transformer(self, transformer: ContextTransformer) -> None:
|
||||
self._context_transformers.append(transformer)
|
||||
|
||||
def add_before_tool_hook(self, hook: BeforeToolCallHook) -> None:
|
||||
self._before_tool_hooks.append(hook)
|
||||
|
||||
def add_after_tool_hook(self, hook: AfterToolCallHook) -> None:
|
||||
self._after_tool_hooks.append(hook)
|
||||
|
||||
def add_turn_policy(self, policy: TurnPolicy) -> None:
|
||||
self._turn_policies.append(policy)
|
||||
|
||||
def add_event_listener(self, listener: AgentEventListener) -> Callable[[], None]:
|
||||
self._event_listeners.append(listener)
|
||||
|
||||
def unsubscribe() -> None:
|
||||
if listener in self._event_listeners:
|
||||
self._event_listeners.remove(listener)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def extend(self, registry: ExtensionRegistry) -> None:
|
||||
"""按原注册顺序合并另一组扩展。"""
|
||||
self._context_transformers.extend(registry._context_transformers)
|
||||
self._before_tool_hooks.extend(registry._before_tool_hooks)
|
||||
self._after_tool_hooks.extend(registry._after_tool_hooks)
|
||||
self._turn_policies.extend(registry._turn_policies)
|
||||
self._event_listeners.extend(registry._event_listeners)
|
||||
|
||||
async def transform_context(
|
||||
self, context: AgentContext, run: AgentRunContext
|
||||
) -> AgentContext:
|
||||
for transformer in self._context_transformers:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
context = await transformer(context, run)
|
||||
return context
|
||||
|
||||
async def before_tool_call(
|
||||
self, call: ToolCallContext, run: AgentRunContext
|
||||
) -> BeforeToolCallDecision:
|
||||
current = call
|
||||
for hook in self._before_tool_hooks:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
decision = await hook(current, run)
|
||||
if decision is None:
|
||||
continue
|
||||
if not decision.allowed:
|
||||
return decision
|
||||
if decision.input is not None:
|
||||
current = replace(current, input=decision.input)
|
||||
return BeforeToolCallDecision(input=current.input)
|
||||
|
||||
async def after_tool_call(
|
||||
self,
|
||||
call: ToolCallContext,
|
||||
result: ToolResult,
|
||||
run: AgentRunContext,
|
||||
) -> AfterToolCallDecision:
|
||||
current = result
|
||||
terminate = False
|
||||
for hook in self._after_tool_hooks:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
decision = await hook(call, current, run)
|
||||
if decision is None:
|
||||
continue
|
||||
if decision.result is not None:
|
||||
current = decision.result
|
||||
terminate = terminate or decision.terminate
|
||||
return AfterToolCallDecision(result=current, terminate=terminate)
|
||||
|
||||
async def should_stop(self, turn: TurnSnapshot, run: AgentRunContext) -> bool:
|
||||
for policy in self._turn_policies:
|
||||
run.cancellation.raise_if_cancelled()
|
||||
if await policy(turn, run):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def publish(self, event: AgentEvent, run: AgentRunContext) -> None:
|
||||
for listener in tuple(self._event_listeners):
|
||||
await listener(event, run)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return any(
|
||||
(
|
||||
self._context_transformers,
|
||||
self._before_tool_hooks,
|
||||
self._after_tool_hooks,
|
||||
self._turn_policies,
|
||||
self._event_listeners,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AfterToolCallDecision",
|
||||
"AgentContext",
|
||||
"AgentEvent",
|
||||
"AgentRunContext",
|
||||
"BeforeToolCallDecision",
|
||||
"CancellationToken",
|
||||
"ExtensionRegistry",
|
||||
"ToolCallContext",
|
||||
"TurnSnapshot",
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
"""通用 Agent 提示组合。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent import AgentDefinition
|
||||
from app.modules.skills import SkillManifest
|
||||
|
||||
|
||||
def compose_system_messages(
|
||||
base_messages: list[str],
|
||||
agent: AgentDefinition | None,
|
||||
skills: list[SkillManifest],
|
||||
) -> list[str]:
|
||||
"""按稳定基础提示、Agent 身份、技能说明的顺序组合提示。"""
|
||||
messages = list(base_messages)
|
||||
if agent is not None and agent.system_prompt:
|
||||
messages.append(f"【Agent:{agent.name}】\n{agent.system_prompt}")
|
||||
for skill in skills:
|
||||
if skill.prompt:
|
||||
messages.append(f"【{skill.name}·能力注入】\n{skill.prompt}")
|
||||
return messages
|
||||
@@ -1,132 +0,0 @@
|
||||
"""受信任 Python Skill 的发现、匹配与 Run 级激活。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import pkgutil
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from app.application.agent.extensions import ExtensionRegistry
|
||||
|
||||
SkillCleanup = Callable[[], Awaitable[None] | None]
|
||||
SkillSetup = Callable[[ExtensionRegistry], SkillCleanup | None]
|
||||
SkillMatcher = Callable[[str, dict[str, Any]], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PythonSkillDefinition:
|
||||
"""部署期 Python Skill 契约。"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
triggers: tuple[str, ...]
|
||||
instructions: str
|
||||
setup: SkillSetup
|
||||
version: str = "1"
|
||||
enabled: bool = True
|
||||
match: SkillMatcher | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PythonSkillActivation:
|
||||
"""单个 Run 的 Skill 激活结果。"""
|
||||
|
||||
skills: tuple[PythonSkillDefinition, ...]
|
||||
cleanups: list[SkillCleanup]
|
||||
|
||||
async def close(self) -> None:
|
||||
"""按激活逆序释放 Skill 资源。"""
|
||||
while self.cleanups:
|
||||
result = self.cleanups.pop()()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
class PythonSkillCatalog:
|
||||
"""只保存 Skill 描述与 setup,不持有 Run 状态。"""
|
||||
|
||||
def __init__(self, skills: Iterable[PythonSkillDefinition] = ()) -> None:
|
||||
self._skills: dict[str, PythonSkillDefinition] = {}
|
||||
for skill in skills:
|
||||
if skill.name in self._skills:
|
||||
raise ValueError(f"Python Skill 名称重复:{skill.name}")
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
@property
|
||||
def skills(self) -> tuple[PythonSkillDefinition, ...]:
|
||||
return tuple(self._skills.values())
|
||||
|
||||
def match(
|
||||
self,
|
||||
context_text: str,
|
||||
*,
|
||||
skill_refs: tuple[str, ...] = (),
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> tuple[PythonSkillDefinition, ...]:
|
||||
"""返回显式引用、关键词或自定义匹配器命中的 enabled Skills。"""
|
||||
refs = set(skill_refs)
|
||||
text = context_text.casefold()
|
||||
match_metadata = metadata or {}
|
||||
return tuple(
|
||||
skill
|
||||
for skill in self._skills.values()
|
||||
if skill.enabled
|
||||
and (
|
||||
skill.name in refs
|
||||
or any(trigger.casefold() in text for trigger in skill.triggers if trigger)
|
||||
or (skill.match is not None and skill.match(context_text, match_metadata))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def activate_python_skills(
|
||||
registry: ExtensionRegistry,
|
||||
skills: Iterable[PythonSkillDefinition],
|
||||
) -> PythonSkillActivation:
|
||||
"""把命中的 Skills 注册到当前 Run,并在部分失败时清理已激活项。"""
|
||||
activation = PythonSkillActivation(tuple(skills), [])
|
||||
try:
|
||||
for skill in activation.skills:
|
||||
cleanup = skill.setup(registry)
|
||||
if cleanup is not None:
|
||||
activation.cleanups.append(cleanup)
|
||||
except Exception:
|
||||
await activation.close()
|
||||
raise
|
||||
return activation
|
||||
|
||||
|
||||
def discover_python_skills(packages: Iterable[str] = ()) -> PythonSkillCatalog:
|
||||
"""从受信任 packages 发现定义,但不执行 Skill setup。"""
|
||||
modules: list[ModuleType] = []
|
||||
for package_name in dict.fromkeys(packages):
|
||||
package = importlib.import_module(package_name)
|
||||
package_path = getattr(package, "__path__", None)
|
||||
if package_path is None:
|
||||
raise TypeError(f"Python Skill 包必须是 package:{package_name}")
|
||||
modules.extend(
|
||||
importlib.import_module(module.name)
|
||||
for module in pkgutil.iter_modules(package_path, f"{package.__name__}.")
|
||||
if not module.name.rsplit(".", 1)[-1].startswith("_")
|
||||
)
|
||||
|
||||
definitions: list[PythonSkillDefinition] = []
|
||||
for module in sorted(modules, key=lambda item: item.__name__):
|
||||
skill = getattr(module, "SKILL", None)
|
||||
if not isinstance(skill, PythonSkillDefinition):
|
||||
raise TypeError(f"Python Skill 缺少 SKILL 定义:{module.__name__}")
|
||||
definitions.append(skill)
|
||||
return PythonSkillCatalog(definitions)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PythonSkillActivation",
|
||||
"PythonSkillCatalog",
|
||||
"PythonSkillDefinition",
|
||||
"activate_python_skills",
|
||||
"discover_python_skills",
|
||||
]
|
||||
@@ -1,49 +0,0 @@
|
||||
"""活动 Agent Run 注册表:为 steer、follow-up、abort 提供进程内控制面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.application.agent.agent import Agent
|
||||
|
||||
|
||||
class AgentRunRegistry:
|
||||
"""跟踪当前进程中的活动 Agent;持久状态仍以 Run/EventStore 为准。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._agents: dict[str, Agent] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def register(self, run_id: str, agent: Agent) -> None:
|
||||
async with self._lock:
|
||||
self._agents[run_id] = agent
|
||||
|
||||
async def unregister(self, run_id: str, agent: Agent) -> None:
|
||||
async with self._lock:
|
||||
if self._agents.get(run_id) is agent:
|
||||
self._agents.pop(run_id, None)
|
||||
|
||||
def get(self, run_id: str) -> Agent | None:
|
||||
return self._agents.get(run_id)
|
||||
|
||||
def steer(self, run_id: str, content: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None:
|
||||
return False
|
||||
return agent.steer(content)
|
||||
|
||||
def follow_up(self, run_id: str, content: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None:
|
||||
return False
|
||||
return agent.follow_up(content)
|
||||
|
||||
async def abort(self, run_id: str) -> bool:
|
||||
agent = self.get(run_id)
|
||||
if agent is None or not await agent.abort():
|
||||
return False
|
||||
await agent.wait_for_idle()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["AgentRunRegistry"]
|
||||
@@ -1,840 +0,0 @@
|
||||
"""流式 Agent 循环:stream → 累积 tool_calls → execute → 回填 ToolMessage → 再 stream。
|
||||
|
||||
阶段2b 步骤A:把 Agent 工具调用闭环从非流式 ``router.chat`` 改成流式 ``router.stream``,
|
||||
并在主循环中暴露两个可选 async 回调(``on_chunk`` / ``on_tool_event``)供上层(步骤B
|
||||
send_message)接入 SSE 增量冲刷与工具事件。本模块不感知 SSE / 前端,回调签名仅约定:
|
||||
|
||||
- ``on_chunk(chunk: ChatChunk)``:每个流式 chunk 透传(含 delta_content / delta_reasoning
|
||||
/ tool_call_chunks / usage 等),上层可据此写 ``message.delta``。
|
||||
- ``on_tool_event(event_type: str, payload: dict)``:工具生命周期事件:
|
||||
- ``"tool.started"``:执行前
|
||||
- ``"tool.completed"``:执行后,含 status(success / error / denied)
|
||||
- ``"tool.requires_confirmation"``(阶段3 新增):高风险工具抛 RequiresConfirmation 时
|
||||
发出,带 confirmation_id / risk_level / args,供前端展示确认卡片、approve/deny 端点
|
||||
据此恢复循环。
|
||||
|
||||
阶段3 步骤1:工具确认挂起/恢复机制。
|
||||
- 主循环遇到 ``RequiresConfirmation`` 时,通过 ``confirmation_registry.register(cid)`` 取
|
||||
一个 ``Future[bool]`` 并 ``await``;外部 approve/deny 端点(步骤2)调
|
||||
``registry.resolve(cid, approved)`` 触发恢复。
|
||||
- 批准:以 ``confirmed=True`` 重试 execute;拒绝:回填"用户拒绝"ToolMessage,循环继续。
|
||||
- 超时:``confirmation_timeout``(默认 300s)未裁决按拒绝处理;该超时受外层
|
||||
``run_timeout``(send_message 的 ``asyncio.timeout``)约束——确认必须在 run 超时内完成。
|
||||
- ``confirmation_registry`` 为 None 时降级:回填"需确认但未启用"消息,循环不卡(向下兼容)。
|
||||
|
||||
设计要点:
|
||||
- 流式 ``router.stream(...)`` 返回 ``AsyncIterator[ChatChunk]``;流结束(async for 正常
|
||||
退出)即本轮模型输出完毕,``ToolCallAccumulator.drain()`` 给出完整 tool_calls——不依赖
|
||||
``finish_reason`` 判断。
|
||||
- 工具执行经 ``ToolRuntime.execute``——司命评估与事件落库由其内部完成,本层只负责调用与
|
||||
错误兜底。
|
||||
- 工具失败**不中断循环**:异常被捕获、错误信息以 ToolMessage content 回填,让模型自行决策。
|
||||
RequiresConfirmation 单独处理(不走错误兜底分支)。
|
||||
- usage 累计每轮 prompt/completion/total_tokens。
|
||||
- ``on_chunk`` / ``on_tool_event`` 默认 None;不传时本循环与纯后端逻辑等价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.ai_provider import ProviderManagerFactory, TokenUsage
|
||||
from app.ai_provider.llm import ToolCallAccumulator
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.ai_provider.types.messages import AIMessage, BaseMessage, ToolMessage
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.extensions import (
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
CancellationToken,
|
||||
ExtensionRegistry,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.core.logging import logger
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.modules.safety.confirmation import new_confirmation_id
|
||||
from app.modules.safety.exceptions import RequiresConfirmation
|
||||
from app.modules.tool_runtime.definition import ToolResult, ToolRuntimeLike
|
||||
|
||||
# 默认最大循环次数:覆盖常见多步工具调用,超限则截断返回,避免无限循环。
|
||||
DEFAULT_MAX_ITERATIONS = 8
|
||||
|
||||
# 默认确认超时:高风险工具挂起等待用户裁决的最长时间(秒);超时按拒绝处理。
|
||||
DEFAULT_CONFIRMATION_TIMEOUT = 300.0
|
||||
|
||||
# 回调类型别名:给上层(步骤B send_message)类型检查用
|
||||
OnChunkCallback = Callable[[ChatChunk], Awaitable[None]]
|
||||
OnToolEventCallback = Callable[[str, dict[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentTurnResult:
|
||||
"""单轮 Agent 循环的产出。"""
|
||||
|
||||
content: str # 最终回复正文(最后一次模型输出)
|
||||
reasoning: str # 最终回复的思考内容(如有)
|
||||
tool_history: list[dict[str, Any]] = field(
|
||||
default_factory=list
|
||||
) # [{name, args, result, status, tool_call_id?}]
|
||||
iterations: int = 0 # 实际循环次数
|
||||
truncated: bool = False # 是否因达上限截断
|
||||
usage: TokenUsage = field(default_factory=TokenUsage) # 累计 token 用量
|
||||
|
||||
|
||||
def _sum_usage(acc: TokenUsage, delta: TokenUsage) -> TokenUsage:
|
||||
return acc + delta
|
||||
|
||||
|
||||
def _parse_tool_args(args_str: str) -> dict[str, Any]:
|
||||
"""把累积到的 args JSON 字符串解析成 dict;解析失败时回退 ``{"_raw": ...}``。"""
|
||||
if not args_str:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
return {"_raw": args_str}
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
# 非对象 JSON(数组/字符串/数字)——包成 _raw 让模型自己理解
|
||||
return {"_raw": args_str}
|
||||
|
||||
|
||||
def _ai_message_from_stream(
|
||||
content: str,
|
||||
reasoning: str,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
raw_content_blocks: list[dict[str, Any]] | None = None,
|
||||
) -> AIMessage:
|
||||
"""把流式累积的 content/reasoning/tool_calls 还原成 AIMessage 以便追加到 messages 历史。
|
||||
|
||||
``tool_calls`` 元素结构为 ``{"index", "id"?, "name"?, "args": str}``(由
|
||||
``ToolCallAccumulator.drain`` 产出,``args`` 为完整 JSON 字符串)。AIMessage 期望
|
||||
``{"name": ..., "args": dict, "id": ...}``,故需反序列化 args 并补全缺失 id。
|
||||
"""
|
||||
lc_tool_calls: list[dict[str, Any]] = []
|
||||
for entry in tool_calls:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
# 没有 name 的 tool_call 无法执行,跳过(保留在累积器视图但不进历史)
|
||||
continue
|
||||
tc_id = entry.get("id") or f"call_{name}"
|
||||
lc_tool_calls.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": _parse_tool_args(entry.get("args", "")),
|
||||
"id": tc_id,
|
||||
}
|
||||
)
|
||||
return AIMessage(
|
||||
content=content,
|
||||
tool_calls=lc_tool_calls,
|
||||
reasoning=reasoning,
|
||||
raw_content_blocks=raw_content_blocks,
|
||||
)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 4000) -> str:
|
||||
"""截断超长文本(沙箱 stdout/stderr 可能很大),避免喂回模型时爆 token。
|
||||
|
||||
措辞明确这是「回显截断」而非数据损坏,并告知取后续内容的方法:否则模型读到大文件
|
||||
被 read_file 截断的结果,会反复换方法重读,陷入循环直到 ``max_iterations`` 截断。
|
||||
阈值 4000 兼顾 token 成本与单次可读量;大文件配合 read_file 的 offset/limit 分段读取。
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return (
|
||||
f"{text[:limit]}\n…[输出过长,仅回显前 {limit} 字符;原始内容完整。"
|
||||
f"如需后续内容:read_file 用 offset/limit 分段,或 shell 用 sed -n / tail / head 限定输出]"
|
||||
)
|
||||
|
||||
|
||||
async def _publish_message_lifecycle(
|
||||
registry: ExtensionRegistry,
|
||||
run: AgentRunContext,
|
||||
message: BaseMessage,
|
||||
) -> None:
|
||||
"""发布一条完整消息的 start/end 生命周期。"""
|
||||
if not registry.enabled:
|
||||
return
|
||||
payload = {"role": message.role, "message": message.to_dict()}
|
||||
await registry.publish(AgentEvent("message.started", payload), run)
|
||||
await registry.publish(AgentEvent("message.ended", payload), run)
|
||||
|
||||
|
||||
def _tool_result_text(result: ToolResult) -> str:
|
||||
"""把 ToolResult 转成回填给模型的紧凑文本。
|
||||
|
||||
显式标注 ``status=成功/失败``:否则成功路径(如 write_file 落盘后 stdout/stderr 全空)
|
||||
只剩孤零零的 ``exit_code=0``,模型缺乏「已成功」的明确语义,倾向再调 read_file 验证,
|
||||
叠加回显截断极易陷入反复重试。
|
||||
"""
|
||||
status = "成功" if result.ok else "失败"
|
||||
parts = [f"status={status} exit_code={result.exit_code}"]
|
||||
if result.stdout:
|
||||
parts.append(f"stdout:\n{_truncate(result.stdout)}")
|
||||
if result.stderr:
|
||||
parts.append(f"stderr:\n{_truncate(result.stderr)}")
|
||||
if result.timeout:
|
||||
parts.append("timeout=true")
|
||||
if result.truncated:
|
||||
parts.append("truncated=true(仅截断回显给模型的输出,不影响已落盘等实际副作用)")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# 控制流哨兵异常:只在本模块内传递,不泄漏到 run_agent_turn 调用方。
|
||||
class _ToolDenied(Exception):
|
||||
"""用户拒绝 / 确认超时。``message`` 为回填文本,``reason`` 为内部归因(denied/timeout)。"""
|
||||
|
||||
def __init__(self, message: str, *, reason: str = "denied") -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class _ToolSkipped(Exception):
|
||||
"""confirmation_registry 未启用时的降级:回填"需确认但未启用"消息。"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
async def _execute_tool_with_confirmation(
|
||||
*,
|
||||
tool_runtime: ToolRuntimeLike | None,
|
||||
name: str,
|
||||
tool_input: dict[str, Any],
|
||||
args_str: str,
|
||||
tool_call_id: str,
|
||||
run_id: str | None,
|
||||
character_id: str | None,
|
||||
trace_id: str | None,
|
||||
base_confirmed: bool,
|
||||
confirmation_registry: ConfirmationRegistry | None,
|
||||
confirmation_timeout: float,
|
||||
on_tool_event: OnToolEventCallback | None,
|
||||
cancellation: CancellationToken | None = None,
|
||||
on_timeout: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> ToolResult:
|
||||
"""执行单次工具调用,处理 RequiresConfirmation 挂起/恢复。
|
||||
|
||||
- 正常返回 ToolResult;任意终态(拒绝/超时/降级)以本模块哨兵异常向调用方信号。
|
||||
- 批准路径以 ``confirmed=True`` 重试 execute(绕过司命 ask),失败仍正常返回 ToolResult
|
||||
(由调用方按 ok=False 回填 error 文本)。
|
||||
- ``on_timeout(confirmation_id)``:确认超时时回调,供调用方标记超时。
|
||||
"""
|
||||
# base_confirmed=True 表示调用方已保证确认(如 API 已先确认),直接执行
|
||||
use_confirmed = base_confirmed
|
||||
if tool_runtime is None:
|
||||
raise _ToolSkipped("工具运行时未启用,已跳过工具调用。")
|
||||
|
||||
while True:
|
||||
try:
|
||||
return await tool_runtime.execute(
|
||||
name,
|
||||
tool_input,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
confirmed=use_confirmed,
|
||||
context={"cancellation": cancellation} if cancellation is not None else None,
|
||||
)
|
||||
except RequiresConfirmation as exc:
|
||||
if use_confirmed:
|
||||
raise _ToolSkipped(f"工具 {name} 在已确认状态下仍要求确认,已跳过。") from exc
|
||||
|
||||
# ---- registry 未启用:降级 ----
|
||||
if confirmation_registry is None:
|
||||
raise _ToolSkipped(
|
||||
f"工具 {name} 需要用户确认(风险等级 {exc.risk_level or 'L2'}),"
|
||||
"但当前未启用确认流程,已跳过。"
|
||||
) from exc
|
||||
|
||||
# ---- registry 已启用:登记 + 持久化待确认 + 发事件 + await ----
|
||||
confirmation_id = new_confirmation_id()
|
||||
|
||||
# 先持久化待确认记录(多 worker 共享确认状态),再发事件。
|
||||
# 顺序保证:resolve/deny 回调(on_tool_event 触发的 create_task)看到记录时,
|
||||
# DB 里已有该行,不会空转超时。
|
||||
if run_id:
|
||||
from app.db.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=confirmation_id,
|
||||
tool=name,
|
||||
input_json=json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args_str": args_str,
|
||||
}
|
||||
),
|
||||
risk_level=exc.risk_level or "L2",
|
||||
reason=exc.message,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.requires_confirmation",
|
||||
{
|
||||
"confirmation_id": confirmation_id,
|
||||
"run_id": run_id,
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"risk_level": exc.risk_level,
|
||||
"reason": exc.message,
|
||||
"args": args_str,
|
||||
},
|
||||
)
|
||||
|
||||
# 使用混合确认注册表(同进程 Event + 重启后 polling)。无 run_id 的
|
||||
# 独立调用仍可等待到超时,避免把“未裁决”误报成“确认机制未启用”。
|
||||
confirmation_run_id = run_id or confirmation_id
|
||||
try:
|
||||
approved = await confirmation_registry.wait(
|
||||
confirmation_run_id,
|
||||
confirmation_id=confirmation_id,
|
||||
timeout=confirmation_timeout,
|
||||
)
|
||||
except TimeoutError:
|
||||
if on_timeout is not None:
|
||||
await on_timeout(confirmation_id)
|
||||
raise _ToolDenied(
|
||||
f"工具 {name} 的确认超时(等待 {confirmation_timeout:.0f}s 未裁决),已按拒绝处理。",
|
||||
reason="timeout",
|
||||
) from None
|
||||
|
||||
if not approved:
|
||||
raise _ToolDenied(f"用户拒绝了工具 {name} 的执行。") from exc
|
||||
|
||||
# 批准:以 confirmed=True 重试 execute(循环回到顶部 try)
|
||||
logger.info(
|
||||
"agent_tool_confirmed",
|
||||
tool=name,
|
||||
confirmation_id=confirmation_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
use_confirmed = True
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args": args_str,
|
||||
"confirmed": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def run_agent_turn(
|
||||
*,
|
||||
messages: list[BaseMessage],
|
||||
tools: list[dict[str, Any]],
|
||||
tool_runtime: ToolRuntimeLike | None,
|
||||
model_hint: str | None = None,
|
||||
thinking_enabled: bool | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
# 可选上下文:透传给 ToolRuntime.execute 做事件归属与工作目录隔离
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
# 可选:高风险工具是否已由上层确认(透传给 ToolRuntime.execute)
|
||||
confirmed: bool = False,
|
||||
# 可选:每轮开始时回调(供外层重置 per-turn 状态如 reasoning_finalized)
|
||||
on_turn: Callable[[], Awaitable[None]] | None = None,
|
||||
# 可选回调:把流式 chunk 透传给上层做 SSE 增量冲刷(默认 None 表示不回调)
|
||||
on_chunk: OnChunkCallback | None = None,
|
||||
# 可选回调:工具生命周期事件("tool.started" / "tool.completed" /
|
||||
# "tool.requires_confirmation")
|
||||
on_tool_event: OnToolEventCallback | None = None,
|
||||
# 可选:确认 Future 注册表(阶段3 步骤1)。缺省 None 时遇 RequiresConfirmation 降级。
|
||||
confirmation_registry: ConfirmationRegistry | None = None,
|
||||
# 可选:单次确认挂起超时(秒),默认 300s;超时按拒绝处理。
|
||||
confirmation_timeout: float = DEFAULT_CONFIRMATION_TIMEOUT,
|
||||
# 可选:确认超时回调(confirmation_id → None),供调用方标记 DB 超时状态。
|
||||
on_confirm_timeout: Callable[[str], Awaitable[None]] | None = None,
|
||||
# 受约束扩展 Hook 与本次运行上下文。
|
||||
extensions: ExtensionRegistry | None = None,
|
||||
run_context: AgentRunContext | None = None,
|
||||
# Stateful Agent 的 steering 队列;每轮工具批次完成后消费。
|
||||
get_steering_messages: Callable[[], list[BaseMessage]] | None = None,
|
||||
) -> AgentTurnResult:
|
||||
"""执行一轮带工具调用的 Agent 循环(流式版)。
|
||||
|
||||
循环逻辑见模块 docstring;返回值始终是 ``AgentTurnResult``,工具失败不抛异常。
|
||||
``max_iterations`` 为硬上限:达到上限且模型仍返回 tool_calls 时返回 truncated=True。
|
||||
``on_chunk`` / ``on_tool_event`` 缺省 None 时与纯后端逻辑等价。
|
||||
``on_confirm_timeout(confirmation_id)``:确认超时时回调,用于标记 DB 记录。
|
||||
|
||||
确认挂起/恢复(阶段3 步骤1):``confirmation_registry`` 缺省 None 时,遇
|
||||
``RequiresConfirmation`` 走降级路径(回填"需确认但未启用"消息,循环继续);否则按
|
||||
register → 发 ``tool.requires_confirmation`` 事件 → ``await Future``(带超时)→
|
||||
批准重试 / 拒绝回填 的流程处理。``confirmation_timeout`` 须小于上层 run_timeout,否则
|
||||
run 先超时。
|
||||
"""
|
||||
if max_iterations < 1:
|
||||
raise ValueError("max_iterations 必须 >= 1")
|
||||
|
||||
manager = ProviderManagerFactory.create()
|
||||
extension_registry = extensions or ExtensionRegistry()
|
||||
extension_context = run_context or AgentRunContext(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
usage = TokenUsage()
|
||||
tool_history: list[dict[str, Any]] = []
|
||||
iterations = 0
|
||||
# 最后一轮累积值(用于 truncated 分支返回 content/reasoning)
|
||||
last_content = ""
|
||||
last_reasoning = ""
|
||||
|
||||
while iterations < max_iterations:
|
||||
iterations += 1
|
||||
if on_turn is not None:
|
||||
await on_turn()
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.started", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
|
||||
# ---- 本轮流式消费 ----
|
||||
acc = ToolCallAccumulator()
|
||||
content_buf = ""
|
||||
reasoning_buf = ""
|
||||
finish_reason: str | None = None
|
||||
raw_blocks: dict[int, dict[str, Any]] = {}
|
||||
params: dict[str, object] = {}
|
||||
if thinking_enabled is not None:
|
||||
params["thinking_enabled"] = thinking_enabled
|
||||
if reasoning_effort is not None:
|
||||
params["reasoning_effort"] = reasoning_effort
|
||||
extension_context.cancellation.raise_if_cancelled()
|
||||
request_context = AgentContext(tuple(messages), tuple(tools), model_hint)
|
||||
if extension_registry.enabled:
|
||||
request_context = await extension_registry.transform_context(
|
||||
request_context,
|
||||
extension_context,
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("message.started", {"role": "assistant", "iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
stream = manager.chat_stream(
|
||||
list(request_context.messages),
|
||||
model_hint=request_context.model_hint,
|
||||
params=params,
|
||||
tools=list(request_context.tools) or None,
|
||||
)
|
||||
async for chunk in stream:
|
||||
if on_chunk is not None:
|
||||
await on_chunk(chunk)
|
||||
if extension_registry.enabled and (chunk.delta_content or chunk.delta_reasoning):
|
||||
await extension_registry.publish(
|
||||
AgentEvent(
|
||||
"message.updated",
|
||||
{
|
||||
"role": "assistant",
|
||||
"iteration": iterations,
|
||||
"delta": chunk.delta_content,
|
||||
"reasoning_delta": chunk.delta_reasoning,
|
||||
},
|
||||
),
|
||||
extension_context,
|
||||
)
|
||||
if chunk.delta_content:
|
||||
content_buf += chunk.delta_content
|
||||
if chunk.delta_reasoning:
|
||||
reasoning_buf += chunk.delta_reasoning
|
||||
if chunk.finish_reason is not None:
|
||||
finish_reason = chunk.finish_reason
|
||||
if chunk.content_block_start is not None and chunk.content_block_index is not None:
|
||||
raw_blocks[chunk.content_block_index] = dict(chunk.content_block_start)
|
||||
if chunk.content_block_delta is not None and chunk.content_block_index is not None:
|
||||
block = raw_blocks.get(chunk.content_block_index)
|
||||
if block is not None:
|
||||
delta = chunk.content_block_delta
|
||||
delta_type = delta.get("type")
|
||||
if delta_type == "text_delta":
|
||||
block["text"] = f"{block.get('text', '')}{delta.get('text', '')}"
|
||||
elif delta_type == "thinking_delta":
|
||||
block["thinking"] = (
|
||||
f"{block.get('thinking', '')}{delta.get('thinking', '')}"
|
||||
)
|
||||
elif delta_type == "signature_delta":
|
||||
block["signature"] = (
|
||||
f"{block.get('signature', '')}{delta.get('signature', '')}"
|
||||
)
|
||||
elif delta_type == "input_json_delta":
|
||||
block["_partial_json"] = (
|
||||
f"{block.get('_partial_json', '')}{delta.get('partial_json', '')}"
|
||||
)
|
||||
if chunk.usage is not None:
|
||||
usage = _sum_usage(usage, chunk.usage)
|
||||
acc.add_chunk(chunk)
|
||||
|
||||
# 流结束即本轮模型输出完毕
|
||||
tool_calls = acc.drain()
|
||||
last_content = content_buf
|
||||
last_reasoning = reasoning_buf
|
||||
|
||||
raw_content_blocks: list[dict[str, Any]] = []
|
||||
for index in sorted(raw_blocks):
|
||||
block = raw_blocks[index]
|
||||
partial_json = block.pop("_partial_json", None)
|
||||
if partial_json is not None:
|
||||
block["input"] = _parse_tool_args(partial_json)
|
||||
raw_content_blocks.append(block)
|
||||
|
||||
# 把模型输出(含 thinking signature / tool_calls)原样追加到历史。
|
||||
ai_message = _ai_message_from_stream(
|
||||
content_buf,
|
||||
reasoning_buf,
|
||||
tool_calls,
|
||||
raw_content_blocks or None,
|
||||
)
|
||||
messages.append(ai_message)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent(
|
||||
"message.ended",
|
||||
{
|
||||
"role": "assistant",
|
||||
"iteration": iterations,
|
||||
"message": ai_message.to_dict(),
|
||||
"finish_reason": finish_reason,
|
||||
},
|
||||
),
|
||||
extension_context,
|
||||
)
|
||||
|
||||
# 无工具调用:先消费 steering,再决定是否结束。
|
||||
if not any(tc.get("name") for tc in tool_calls):
|
||||
steering = get_steering_messages() if get_steering_messages is not None else []
|
||||
turn = TurnSnapshot(
|
||||
iteration=iterations,
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_calls=(),
|
||||
tool_results=tuple(tool_history),
|
||||
usage=usage,
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.ended", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
# TurnPolicy 在此分支不参与终止决策:模型无工具调用时已由 steering /
|
||||
# finish_reason / 默认 return 收尾,强行接入会覆盖 pause_turn / refusal
|
||||
# 既有处理;其真实终止作用在下方工具分支(should_stop or terminate_after_tools)。
|
||||
await extension_registry.should_stop(turn, extension_context)
|
||||
if steering:
|
||||
messages.extend(steering)
|
||||
continue
|
||||
if finish_reason == "pause_turn":
|
||||
continue
|
||||
if finish_reason == "refusal":
|
||||
raise RuntimeError("模型拒绝了当前请求。")
|
||||
return AgentTurnResult(
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=finish_reason == "length",
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# 逐个执行 tool_call 并把结果以 ToolMessage 回填。工具可能有副作用或需要确认,
|
||||
# 在 ToolSpec 未声明并行安全前保持确定性串行执行。
|
||||
turn_history_start = len(tool_history)
|
||||
terminate_after_tools = False
|
||||
for call_index, entry in enumerate(tool_calls):
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
args_str = entry.get("args", "")
|
||||
tool_input = _parse_tool_args(args_str)
|
||||
tool_call_id = entry.get("id") or f"call_{iterations}_{call_index}_{name}"
|
||||
hook_call = ToolCallContext(
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
input=tool_input,
|
||||
messages=tuple(messages),
|
||||
)
|
||||
if extension_registry.enabled:
|
||||
decision = await extension_registry.before_tool_call(
|
||||
hook_call,
|
||||
extension_context,
|
||||
)
|
||||
if not decision.allowed:
|
||||
denied_text = decision.reason or f"扩展阻止了工具 {name} 的执行。"
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": denied_text,
|
||||
"status": "denied",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "denied",
|
||||
"result": denied_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
if decision.input is not None:
|
||||
tool_input = decision.input
|
||||
args_str = json.dumps(tool_input, ensure_ascii=False)
|
||||
hook_call = ToolCallContext(
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
input=tool_input,
|
||||
messages=tuple(messages),
|
||||
)
|
||||
|
||||
# 执行前事件
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"args": args_str,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
tool_result = await _execute_tool_with_confirmation(
|
||||
tool_runtime=tool_runtime,
|
||||
name=name,
|
||||
tool_input=tool_input,
|
||||
args_str=args_str,
|
||||
tool_call_id=tool_call_id,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
base_confirmed=confirmed,
|
||||
confirmation_registry=confirmation_registry,
|
||||
confirmation_timeout=confirmation_timeout,
|
||||
on_tool_event=on_tool_event,
|
||||
cancellation=extension_context.cancellation,
|
||||
on_timeout=on_confirm_timeout,
|
||||
)
|
||||
except _ToolDenied as denied:
|
||||
# 用户拒绝或确认超时:回填拒绝消息,循环继续让模型自决策
|
||||
logger.info(
|
||||
"agent_tool_denied",
|
||||
tool=name,
|
||||
reason=denied.reason,
|
||||
run_id=run_id,
|
||||
)
|
||||
denied_text = denied.message
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": denied_text,
|
||||
"status": "denied",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=denied_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "denied",
|
||||
"result": denied_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
except _ToolSkipped as skipped:
|
||||
# registry 未启用:回填"需确认但未启用",循环继续
|
||||
skipped_text = skipped.message
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": skipped_text,
|
||||
"status": "skipped",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=skipped_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "skipped",
|
||||
"result": skipped_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 - 任意工具错误都回填、不中断循环
|
||||
# 司命 deny / 沙箱错误 / 未知工具等:错误信息回填,循环继续
|
||||
logger.warning(
|
||||
"agent_tool_failed",
|
||||
tool=name,
|
||||
error=str(exc),
|
||||
run_id=run_id,
|
||||
)
|
||||
error_text = f"工具执行失败:{type(exc).__name__}: {_truncate(str(exc))}"
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": error_text,
|
||||
"status": "error",
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=error_text, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(
|
||||
extension_registry, extension_context, tool_message
|
||||
)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": "error",
|
||||
"result": error_text[:500],
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
# 成功路径:扩展可变换结果或提示终止;安全裁定已在 ToolRuntime 内完成。
|
||||
if extension_registry.enabled:
|
||||
after = await extension_registry.after_tool_call(
|
||||
hook_call,
|
||||
tool_result,
|
||||
extension_context,
|
||||
)
|
||||
tool_result = after.result or tool_result
|
||||
terminate_after_tools = terminate_after_tools or after.terminate
|
||||
outcome = _tool_result_text(tool_result)
|
||||
status = "success" if tool_result.ok else "error"
|
||||
if not tool_result.ok:
|
||||
# 沙箱执行返回非 ok(exit_code != 0 等)——不抛异常,回填让模型自决策
|
||||
logger.info(
|
||||
"agent_tool_returned_error",
|
||||
tool=name,
|
||||
exit_code=tool_result.exit_code,
|
||||
stderr=tool_result.stderr[:500] if tool_result.stderr else "",
|
||||
run_id=run_id,
|
||||
)
|
||||
tool_history.append(
|
||||
{
|
||||
"name": name,
|
||||
"args": tool_input,
|
||||
"result": outcome,
|
||||
"status": status,
|
||||
"tool_call_id": tool_call_id,
|
||||
}
|
||||
)
|
||||
tool_message = ToolMessage(content=outcome, tool_call_id=tool_call_id)
|
||||
messages.append(tool_message)
|
||||
await _publish_message_lifecycle(extension_registry, extension_context, tool_message)
|
||||
if on_tool_event is not None:
|
||||
await on_tool_event(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"status": status,
|
||||
"result": outcome[:500],
|
||||
},
|
||||
)
|
||||
|
||||
steering = get_steering_messages() if get_steering_messages is not None else []
|
||||
if steering:
|
||||
messages.extend(steering)
|
||||
turn = TurnSnapshot(
|
||||
iteration=iterations,
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_calls=tuple(tool_calls),
|
||||
tool_results=tuple(tool_history[turn_history_start:]),
|
||||
usage=usage,
|
||||
)
|
||||
should_stop = terminate_after_tools
|
||||
if extension_registry.enabled:
|
||||
await extension_registry.publish(
|
||||
AgentEvent("agent.turn.ended", {"iteration": iterations}),
|
||||
extension_context,
|
||||
)
|
||||
should_stop = should_stop or await extension_registry.should_stop(
|
||||
turn,
|
||||
extension_context,
|
||||
)
|
||||
if should_stop and not steering:
|
||||
return AgentTurnResult(
|
||||
content=content_buf,
|
||||
reasoning=reasoning_buf,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=False,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# 达上限仍带 tool_calls:截断返回,content 用最后一次模型输出或说明已截断
|
||||
content = last_content or f"Agent 循环达到最大次数 {max_iterations},已截断。"
|
||||
return AgentTurnResult(
|
||||
content=content,
|
||||
reasoning=last_reasoning,
|
||||
tool_history=tool_history,
|
||||
iterations=iterations,
|
||||
truncated=True,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CONFIRMATION_TIMEOUT",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"AgentTurnResult",
|
||||
"OnChunkCallback",
|
||||
"OnToolEventCallback",
|
||||
"run_agent_turn",
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
"""从既有 Skill 存储加载运行时 manifest。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.skills import SkillManifest, parse_skill_manifest
|
||||
from app.repositories.json_utils import loads_list
|
||||
from app.services.skills import get_enabled_skills_by_names, match_active_skills
|
||||
|
||||
|
||||
async def load_skill_manifests(
|
||||
context_text: str,
|
||||
*,
|
||||
skill_refs: tuple[str, ...] = (),
|
||||
) -> list[SkillManifest]:
|
||||
"""加载关键词命中或显式引用的 enabled Skill。"""
|
||||
skills = await match_active_skills(context_text)
|
||||
if skill_refs:
|
||||
by_name = {skill.name: skill for skill in skills}
|
||||
for skill in await get_enabled_skills_by_names(skill_refs):
|
||||
by_name.setdefault(skill.name, skill)
|
||||
skills = list(by_name.values())
|
||||
return [parse_skill_manifest(skill, loads_list(skill.tool_bindings_json)) for skill in skills]
|
||||
@@ -1,95 +0,0 @@
|
||||
"""将洛书规划结果映射为通用 Agent 任务,并写入 Run 事件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.agent import AgentTask, Plan, PlanStep
|
||||
from app.modules.planner import DeliberativePlan
|
||||
|
||||
|
||||
def task_from_deliberative_plan(
|
||||
plan: DeliberativePlan,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_name: str,
|
||||
objective: str,
|
||||
) -> AgentTask:
|
||||
"""把一次规划结果转换为仅在当前 Run 存活的 AgentTask。"""
|
||||
return AgentTask(
|
||||
id=f"task_{run_id}",
|
||||
objective=objective,
|
||||
agent_name=agent_name,
|
||||
run_id=run_id,
|
||||
status="running",
|
||||
plan=Plan(
|
||||
steps=tuple(
|
||||
PlanStep(
|
||||
id=str(step.id),
|
||||
title=step.title,
|
||||
description=step.description,
|
||||
tool=step.tool,
|
||||
risk_level=step.risk_level,
|
||||
)
|
||||
for step in plan.steps
|
||||
),
|
||||
assumptions=tuple(plan.assumptions),
|
||||
risks=tuple(plan.risks),
|
||||
status="running",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def next_step_for_tool(task: AgentTask, tool_name: str, completed_step_ids: set[str]) -> PlanStep | None:
|
||||
"""返回尚未处理且明确绑定到工具的首个计划步骤。"""
|
||||
if task.plan is None:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
step
|
||||
for step in task.plan.steps
|
||||
if step.id not in completed_step_ids and step.tool == tool_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def step_event_payload(
|
||||
task: AgentTask,
|
||||
step: PlanStep,
|
||||
*,
|
||||
status: str,
|
||||
result_summary: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""生成计划步骤生命周期事件 payload。"""
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"step_id": step.id,
|
||||
"title": step.title,
|
||||
"tool": step.tool,
|
||||
"risk_level": step.risk_level,
|
||||
"status": status,
|
||||
**({"result_summary": result_summary} if result_summary else {}),
|
||||
}
|
||||
|
||||
|
||||
def task_plan_payload(task: AgentTask) -> dict[str, object]:
|
||||
"""生成安全且可直接展示的计划事件 payload。"""
|
||||
assert task.plan is not None
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"agent_name": task.agent_name,
|
||||
"objective": task.objective,
|
||||
"status": task.plan.status,
|
||||
"assumptions": list(task.plan.assumptions),
|
||||
"risks": list(task.plan.risks),
|
||||
"steps": [
|
||||
{
|
||||
"id": step.id,
|
||||
"title": step.title,
|
||||
"description": step.description,
|
||||
"tool": step.tool,
|
||||
"risk_level": step.risk_level,
|
||||
"status": step.status,
|
||||
}
|
||||
for step in task.plan.steps
|
||||
],
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
"""ToolSpec → OpenAI 函数 schema 的转换与默认工具集选取。
|
||||
|
||||
直接产出 OpenAI function schema dict(``{"type": "function", "function": {...}}``),
|
||||
与 ChatResult.tool_calls 里的 name/args 回填闭环天然对齐。
|
||||
|
||||
ToolSpec 的 name/description 直接映射;参数 schema 因 ToolSpec 未内嵌 JSON Schema,
|
||||
这里按工具 name 查表给出最小参数 schema(builtins 内工具参数固定,可选参数也显式列出)。
|
||||
未知工具默认空参数 schema(``{"type": "object", "properties": {}}``),模型按需传参。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
# 内置工具的参数 schema(name → JSON Schema)。新增工具时扩展此表即可。
|
||||
# 保持最小集合:只声明 known 工具的参数;调用方传入未登记工具时回退空对象 schema。
|
||||
_PARAM_SCHEMAS: dict[str, dict[str, Any]] = {
|
||||
"datetime": {"type": "object", "properties": {}, "required": []},
|
||||
"read_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "workspace 内的文件相对路径。"},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "起始行号(1-based),默认 1。大文件分段读取后续内容时使用。",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "读取的行数上限;不传则读到末尾。大文件分段读取时使用。",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"list_dir": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "workspace 内的目录相对路径,缺省为 '.'。",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"write_file": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "workspace 内文件相对路径。"},
|
||||
"content": {"type": "string", "description": "要写入的文本内容。"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "要在沙箱中执行的 shell 命令。工作目录为沙箱隔离的 workspace 目录(并非 /home/user 或 /tmp 等 Linux 路径,这些路径不存在,不要 cd 到它们)。read_file/write_file 操作的也是这个 workspace 目录,shell 里 cd 到别的目录将看不到这些文件。",
|
||||
},
|
||||
"timeout_ms": {"type": "integer", "description": "可选超时(毫秒),默认 30000。"},
|
||||
"stdin": {"type": "string", "description": "可选标准输入。"},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
"fetch_url": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "要抓取的网页 URL。"},
|
||||
"max_chars": {
|
||||
"type": "integer",
|
||||
"description": "正文最大字符数,默认 8000。",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
|
||||
_EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
|
||||
def to_ai_provider_tools(tool_specs: list[ToolSpec]) -> list[dict[str, Any]]:
|
||||
"""把 ToolSpec 列表转成 OpenAI function schema dict 列表。
|
||||
|
||||
先查 ``_PARAM_SCHEMAS``(内置工具),未命中则用 ``spec.input_schema``(MCP 工具),
|
||||
仍为空则回退无参占位 ``_EMPTY_SCHEMA``。
|
||||
"""
|
||||
tools: list[dict[str, Any]] = []
|
||||
for spec in tool_specs:
|
||||
params = _PARAM_SCHEMAS.get(spec.name)
|
||||
if not params and spec.name.startswith("skill:"):
|
||||
params = _PARAM_SCHEMAS.get(spec.name.rsplit("/", 1)[-1])
|
||||
if not params:
|
||||
params = spec.input_schema or _EMPTY_SCHEMA
|
||||
tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": spec.name,
|
||||
"description": spec.description,
|
||||
"parameters": params,
|
||||
},
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
|
||||
# 核心工具恒绑(沙箱类,始终可用)。
|
||||
_CORE_TOOL_NAMES = ["datetime", "read_file", "list_dir", "write_file", "shell"]
|
||||
|
||||
|
||||
def default_tools(
|
||||
tool_specs_by_name: dict[str, ToolSpec] | None = None,
|
||||
*,
|
||||
allowed_tools: tuple[str, ...] = (),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回工具对应的 OpenAI function schema 列表。
|
||||
|
||||
- 未传 ``tool_specs_by_name`` 时,从 ``builtin_specs()`` 加载仅返回核心工具
|
||||
(datetime/read_file/list_dir/write_file/shell),共 5 个。
|
||||
- 传入自定义工具池时(如 ``tool_runtime._resolve_specs()``),返回池中所有工具,
|
||||
含 MCP 注册的工具,不限于核心工具。
|
||||
"""
|
||||
if tool_specs_by_name is None:
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
|
||||
tool_specs_by_name = builtin_specs()
|
||||
chosen = [
|
||||
tool_specs_by_name[name] for name in _CORE_TOOL_NAMES if name in tool_specs_by_name
|
||||
]
|
||||
else:
|
||||
chosen = list(tool_specs_by_name.values())
|
||||
|
||||
if allowed_tools:
|
||||
allowed = set(allowed_tools)
|
||||
chosen = [spec for spec in chosen if spec.name in allowed]
|
||||
return to_ai_provider_tools(chosen)
|
||||
@@ -1,198 +0,0 @@
|
||||
"""资料导入候选抽取用例(异步后台任务,跨 services + ai_provider)。
|
||||
|
||||
触发端点在校验并置 job 为 ``extracting_*`` 后即返回;实际模型调用在后台任务中
|
||||
进行(独立 session),完成后落候选并置 job 为 ``reviewing`` / ``parsed``。
|
||||
仅适用单进程;多 worker 部署需替换为跨进程任务队列。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.core.logging import logger
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||
from app.models.imports import CharacterImportJob, ImportItem
|
||||
from app.models.personality import PersonalityProposal
|
||||
from app.modules.imports.extract import (
|
||||
parse_memory_candidates,
|
||||
parse_personality_proposals,
|
||||
parse_profile_candidates,
|
||||
)
|
||||
from app.modules.imports.prompts import (
|
||||
build_memory_extraction_messages,
|
||||
build_personality_extraction_messages,
|
||||
build_profile_extraction_messages,
|
||||
)
|
||||
from app.repositories import candidates as candidates_repo
|
||||
from app.repositories import imports as imports_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
|
||||
# 后台抽取任务强引用:asyncio.create_task 仅保留弱引用,需强引用防 GC。
|
||||
_running_extraction_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
_provider_manager: ProviderManager | None = None
|
||||
|
||||
|
||||
def _get_provider_manager() -> ProviderManager:
|
||||
global _provider_manager
|
||||
if _provider_manager is None:
|
||||
_provider_manager = ProviderManagerFactory.create()
|
||||
return _provider_manager
|
||||
|
||||
|
||||
_PROFILE_MODEL_HINT = "personality-analysis"
|
||||
_MEMORY_MODEL_HINT = "memory-extraction"
|
||||
_PERSONALITY_MODEL_HINT = "personality-analysis"
|
||||
|
||||
|
||||
async def drain_pending_extractions() -> None:
|
||||
"""等待所有进行中的后台抽取任务完成(测试断言 / 优雅停机用)。"""
|
||||
if _running_extraction_tasks:
|
||||
await asyncio.gather(*_running_extraction_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
def start_profile_extraction(job_id: str) -> None:
|
||||
task = asyncio.create_task(_run_profile_extraction(job_id))
|
||||
_running_extraction_tasks.add(task)
|
||||
task.add_done_callback(_running_extraction_tasks.discard)
|
||||
|
||||
|
||||
def start_memory_extraction(job_id: str) -> None:
|
||||
task = asyncio.create_task(_run_memory_extraction(job_id))
|
||||
_running_extraction_tasks.add(task)
|
||||
task.add_done_callback(_running_extraction_tasks.discard)
|
||||
|
||||
|
||||
def start_personality_extraction(job_id: str) -> None:
|
||||
task = asyncio.create_task(_run_personality_extraction(job_id))
|
||||
_running_extraction_tasks.add(task)
|
||||
task.add_done_callback(_running_extraction_tasks.discard)
|
||||
|
||||
|
||||
async def _load_job_and_items(
|
||||
session: AsyncSession, job_id: str
|
||||
) -> tuple[CharacterImportJob | None, list[ImportItem]]:
|
||||
job = await imports_repo.get_job(session, job_id)
|
||||
if job is None:
|
||||
return None, []
|
||||
result = await session.execute(
|
||||
select(ImportItem)
|
||||
.where(ImportItem.import_job_id == job_id)
|
||||
.order_by(ImportItem.sequence.asc())
|
||||
)
|
||||
return job, list(result.scalars().all())
|
||||
|
||||
|
||||
async def _run_profile_extraction(job_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
job, items = await _load_job_and_items(session, job_id)
|
||||
if job is None:
|
||||
return
|
||||
system, user = build_profile_extraction_messages(_pairs(items))
|
||||
raw = await _call_model(system, user, _PROFILE_MODEL_HINT, job.character_id)
|
||||
candidates = [
|
||||
ProfileCandidate(
|
||||
character_id=job.character_id,
|
||||
import_job_id=job.id,
|
||||
field=draft.field_name,
|
||||
value=draft.value,
|
||||
confidence=draft.confidence,
|
||||
reason=draft.reason,
|
||||
status="pending",
|
||||
evidence_ids_json=dumps(_evidence_ids(items, draft.evidence)),
|
||||
)
|
||||
for draft in parse_profile_candidates(raw)
|
||||
]
|
||||
await candidates_repo.add_profile_candidates(session, candidates)
|
||||
job.status = "reviewing" if candidates else "parsed"
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await _fail_job(session, job_id, exc)
|
||||
|
||||
|
||||
async def _run_memory_extraction(job_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
job, items = await _load_job_and_items(session, job_id)
|
||||
if job is None:
|
||||
return
|
||||
system, user = build_memory_extraction_messages(_pairs(items))
|
||||
raw = await _call_model(system, user, _MEMORY_MODEL_HINT, job.character_id)
|
||||
candidates = [
|
||||
MemoryCandidate(
|
||||
character_id=job.character_id,
|
||||
import_job_id=job.id,
|
||||
type=draft.type,
|
||||
title=draft.title,
|
||||
content=draft.content,
|
||||
confidence=0.6,
|
||||
importance=draft.importance,
|
||||
sensitivity=draft.sensitivity,
|
||||
reason=draft.reason,
|
||||
status="pending",
|
||||
evidence_ids_json=dumps(_evidence_ids(items, draft.evidence)),
|
||||
)
|
||||
for draft in parse_memory_candidates(raw)
|
||||
]
|
||||
await candidates_repo.add_memory_candidates(session, candidates)
|
||||
job.status = "reviewing" if candidates else "parsed"
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await _fail_job(session, job_id, exc)
|
||||
|
||||
|
||||
async def _run_personality_extraction(job_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
job, items = await _load_job_and_items(session, job_id)
|
||||
if job is None:
|
||||
return
|
||||
system, user = build_personality_extraction_messages(_pairs(items), "")
|
||||
raw = await _call_model(system, user, _PERSONALITY_MODEL_HINT, job.character_id)
|
||||
proposals = [
|
||||
PersonalityProposal(
|
||||
character_id=job.character_id,
|
||||
fields_json=dumps({draft.field_name: draft.change}),
|
||||
reason=draft.reason or "资料导入抽取的人格完善建议",
|
||||
status="pending",
|
||||
)
|
||||
for draft in parse_personality_proposals(raw)
|
||||
]
|
||||
session.add_all(proposals)
|
||||
await session.flush()
|
||||
job.status = "reviewing" if proposals else "parsed"
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
await _fail_job(session, job_id, exc)
|
||||
|
||||
|
||||
def _pairs(items: list[ImportItem]) -> list[tuple[str, str]]:
|
||||
return [(item.role, item.content) for item in items]
|
||||
|
||||
|
||||
def _evidence_ids(items: list[ImportItem], indices: list[int]) -> list[str]:
|
||||
return [items[i].id for i in indices if 0 <= i < len(items)]
|
||||
|
||||
|
||||
async def _call_model(system: str, user: str, model_hint: str, _character_id: str) -> str:
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system], [("user", user)])
|
||||
response = await manager.chat(messages, model_hint=model_hint)
|
||||
return response.content
|
||||
|
||||
|
||||
async def _fail_job(session: AsyncSession, job_id: str, exc: Exception) -> None:
|
||||
logger.error(f"导入候选抽取失败 job={job_id}: {exc}")
|
||||
try:
|
||||
job = await imports_repo.get_job(session, job_id)
|
||||
if job is not None:
|
||||
job.status = "failed"
|
||||
job.error_code = "extraction_failed"
|
||||
job.error_message = str(exc)[:500]
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
|
||||
pass
|
||||
@@ -1,83 +0,0 @@
|
||||
"""声明式 Skill 激活:匹配、提示注入、Run 级工具别名和事件。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.application.agent.prompt_composer import compose_system_messages
|
||||
from app.application.agent.skill_loader import load_skill_manifests
|
||||
from app.modules.runtime.events import Event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.modules.runtime.events import EventStore
|
||||
|
||||
|
||||
class SkillExecutor:
|
||||
"""把数据库 Skill 激活到当前 Run 的工具视图。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tool_view: RunScopedToolView | None = None,
|
||||
event_store: EventStore | None = None,
|
||||
) -> None:
|
||||
self._tool_view = tool_view
|
||||
self._event_store = event_store
|
||||
|
||||
async def match_and_activate(
|
||||
self,
|
||||
context_text: str,
|
||||
system_messages: list[str],
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
skill_refs: tuple[str, ...] = (),
|
||||
) -> list[str]:
|
||||
"""匹配技能并把提示与别名写入当前 Run。"""
|
||||
if not context_text:
|
||||
return system_messages
|
||||
|
||||
manifests = await load_skill_manifests(context_text, skill_refs=skill_refs)
|
||||
system_messages.extend(compose_system_messages([], None, manifests))
|
||||
activated_names: list[str] = []
|
||||
for manifest in manifests:
|
||||
if self._tool_view is not None:
|
||||
for tool_name in manifest.allowed_tools:
|
||||
self._tool_view.bind_alias(
|
||||
f"skill:{manifest.name}/{tool_name}",
|
||||
tool_name,
|
||||
description_prefix=f"[{manifest.name}] ",
|
||||
)
|
||||
activated_names.append(manifest.name)
|
||||
|
||||
if activated_names and self._event_store:
|
||||
await self._emit_activation_event(
|
||||
activated_names,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
)
|
||||
return system_messages
|
||||
|
||||
async def _emit_activation_event(
|
||||
self,
|
||||
skill_names: list[str],
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
) -> None:
|
||||
event_store = self._event_store
|
||||
if event_store is None:
|
||||
return
|
||||
await event_store.append(
|
||||
Event(
|
||||
type="skill.activated",
|
||||
source="skill_executor",
|
||||
visibility="public",
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
payload={"skills": skill_names},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SkillExecutor"]
|
||||
Reference in new issue
Block a user