初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,82 @@
|
||||
"""非流式 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",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""混合确认注册表 — 同进程 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"]
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""通用 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
|
||||
@@ -0,0 +1,132 @@
|
||||
"""受信任 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",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""活动 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"]
|
||||
@@ -0,0 +1,840 @@
|
||||
"""流式 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",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
"""从既有 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]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""将洛书规划结果映射为通用 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
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,602 @@
|
||||
"""大周天:长期演化循环(architecture §3.2;v1-scope §3.3)。
|
||||
|
||||
双触发:记忆阈值 + 每轮对话后检查。循环阶段化执行(自我评估 → 演化策略 →
|
||||
候选生成),候选生成调神识模型产出统一 ``evolution_candidates``,按 ``level``
|
||||
半自动应用——人格特质/程序记忆等已成熟下游立即派生,L4 核心变更留人工审核
|
||||
(见 [大周天演化候选落地设计] ../../docs/domain/core-domains.md)。
|
||||
单进程 asyncio,多 worker 需任务队列。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.ids import new_goal_id
|
||||
from app.core.logging import logger
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.candidates import MemoryCandidate
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.governance import Goal
|
||||
from app.models.memory import Memory
|
||||
from app.models.world import CharacterWorldEntity
|
||||
from app.modules.big_cycle.extract import parse_evolution_candidates
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
from app.modules.drive.extract import parse_curiosities
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
from app.modules.evolution.policies import should_auto_apply
|
||||
from app.modules.extraction.extract import parse_extraction
|
||||
from app.modules.extraction.prompts import build_extraction_messages
|
||||
from app.modules.runtime.events import Event
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.repositories import memory as memory_repo
|
||||
from app.repositories import runtime as runtime_repo # 新增:活跃 run 检查
|
||||
from app.repositories import world as world_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.services import evolution as evolution_service
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.event_store import DbEventStore
|
||||
from app.services.personality import build_personality_summary
|
||||
|
||||
_running_big_cycle_tasks: set[asyncio.Task[None]] = set()
|
||||
_RECENT_MEMORY_LIMIT = 10
|
||||
# Token 预算估算(各阶段大致消耗,用于超预算时跳过非关键阶段)
|
||||
_ESTIMATED_EVALUATION_COST = 4000
|
||||
_ESTIMATED_CANDIDATE_COST = 16000
|
||||
_ESTIMATED_MEMORY_COST = 8000
|
||||
|
||||
_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
|
||||
|
||||
|
||||
def _get_event_store() -> DbEventStore:
|
||||
"""创建临时 EventStore 实例用于推送大周天事件(自管 session 工厂)。"""
|
||||
return DbEventStore(AsyncSessionLocal)
|
||||
|
||||
|
||||
async def _emit_big_cycle_event(
|
||||
event_type: str,
|
||||
character_id: str,
|
||||
cycle_id: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
"""推送大周天事件到 EventStore(自管 Session,失败静默)。"""
|
||||
try:
|
||||
store = _get_event_store()
|
||||
await store.append(
|
||||
Event(
|
||||
type=event_type,
|
||||
source="big_cycle",
|
||||
visibility="public",
|
||||
character_id=character_id,
|
||||
payload={"cycle_id": cycle_id, **(payload or {})},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning(f"推送大周天事件失败: {event_type}", cycle_id=cycle_id)
|
||||
|
||||
|
||||
async def drain_pending_big_cycles() -> None:
|
||||
"""等待所有进行中的大周天循环完成(测试断言 / 优雅停机用)。"""
|
||||
if _running_big_cycle_tasks:
|
||||
await asyncio.gather(*_running_big_cycle_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def maybe_trigger_big_cycle(character_id: str, trigger: str = "session_turn") -> str | None:
|
||||
"""检查触发条件,满足则创建 cycle 记录并启动后台循环;返回 cycle_id 或 None。"""
|
||||
if not settings.enable_big_cycle:
|
||||
return None
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 任务中断防护:如果有活跃运行中的 run,跳过大周天避免干扰对话
|
||||
if await runtime_repo.has_active_runs(session, character_id):
|
||||
logger.debug(f"大周天跳过:{character_id} 有活跃 run")
|
||||
return None
|
||||
|
||||
memory_count = await character_repo.count_memories(session, character_id)
|
||||
if memory_count < settings.big_cycle_memory_threshold:
|
||||
return None
|
||||
last = await big_cycle_repo.get_last_completed(session, character_id)
|
||||
if last is not None and last.completed_at is not None:
|
||||
last_completed = last.completed_at
|
||||
if last_completed.tzinfo is None:
|
||||
last_completed = last_completed.replace(tzinfo=UTC)
|
||||
if (
|
||||
utc_now() - last_completed
|
||||
).total_seconds() < settings.big_cycle_min_interval_seconds:
|
||||
return None
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger=trigger,
|
||||
status="pending",
|
||||
memory_count_at_trigger=memory_count,
|
||||
)
|
||||
await big_cycle_repo.add_cycle(session, cycle)
|
||||
await session.commit()
|
||||
cycle_id = cycle.id
|
||||
|
||||
start_big_cycle(cycle_id, character_id)
|
||||
return cycle_id
|
||||
|
||||
|
||||
def start_big_cycle(cycle_id: str, character_id: str) -> None:
|
||||
task = asyncio.create_task(_run_big_cycle(cycle_id, character_id))
|
||||
_running_big_cycle_tasks.add(task)
|
||||
task.add_done_callback(_running_big_cycle_tasks.discard)
|
||||
|
||||
|
||||
async def _phase_goal_progress(session: AsyncSession, cycle: BigCycle) -> None:
|
||||
"""大周天目标进度更新:根据演化结果增加关联目标进度。
|
||||
|
||||
当前为简单版本:每完成一轮大周天,character 的活跃 task/session 目标进度 +0.1。
|
||||
后续可改为 LLM 评估更精确的进度。
|
||||
"""
|
||||
from app.repositories import governance as governance_repo
|
||||
|
||||
active_goals = await governance_repo.list_goals_by_character(
|
||||
session, cycle.character_id, status_filter="active"
|
||||
)
|
||||
for goal in active_goals:
|
||||
if goal.level in ("task", "session") and goal.progress < 1.0:
|
||||
new_progress = min(1.0, goal.progress + 0.1)
|
||||
await governance_repo.update_goal_progress(session, goal.id, new_progress)
|
||||
if new_progress >= 1.0 and goal.level == "task":
|
||||
goal.status = "completed"
|
||||
from app.services.governance import emit_goal_event
|
||||
|
||||
await emit_goal_event(
|
||||
goal,
|
||||
"completed",
|
||||
extra_payload={"trigger": "big_cycle"},
|
||||
)
|
||||
|
||||
|
||||
def _score_autonomy(subgoal) -> float:
|
||||
"""按 information_gain / interruption_cost 比值排序。"""
|
||||
gain = getattr(subgoal, "expected_information_gain", None) or 0.0
|
||||
cost = getattr(subgoal, "interruption_cost", None) or 1.0
|
||||
return gain / max(cost, 0.1)
|
||||
|
||||
|
||||
async def _phase_autonomous_exploration(
|
||||
session: AsyncSession, cycle: BigCycle,
|
||||
) -> None:
|
||||
"""自主探索执行:将最高优先级的已批准次生目标标记为待叩心枢执行。
|
||||
|
||||
选择 expected_information_gain / interruption_cost 比值最高的已批准次生目标,
|
||||
标记为 auto_execute_pending,由叩心枢的下一个 tick 拾取。
|
||||
每次大周天最多标记 2 个。
|
||||
"""
|
||||
from app.repositories import drive as drive_repo
|
||||
|
||||
approved = await drive_repo.list_approved_subgoals(
|
||||
session, cycle.character_id, limit=5,
|
||||
)
|
||||
if not approved:
|
||||
return
|
||||
|
||||
sorted_subgoals = sorted(approved, key=_score_autonomy, reverse=True)
|
||||
to_execute = sorted_subgoals[:2]
|
||||
for sub in to_execute:
|
||||
sub.status = "auto_execute_pending"
|
||||
|
||||
await session.flush()
|
||||
logger.info(
|
||||
f"大周天自主探索标记 cycle={cycle.id} "
|
||||
f"subgoals={len(to_execute)}/{len(approved)}"
|
||||
)
|
||||
|
||||
|
||||
async def _run_big_cycle(cycle_id: str, character_id: str) -> None:
|
||||
"""大周天主循环:阶段化执行,超预算时跳过非关键阶段。"""
|
||||
budget_spent = 0
|
||||
|
||||
await _emit_big_cycle_event("big_cycle.started", character_id, cycle_id)
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
if cycle is None:
|
||||
return
|
||||
cycle.status = "running"
|
||||
cycle.started_at = utc_now()
|
||||
await session.commit()
|
||||
|
||||
# 阶段 1:自我评估(关键阶段,始终执行)
|
||||
snapshot = await _phase_evaluation(session, cycle)
|
||||
budget_spent += _ESTIMATED_EVALUATION_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "evaluation", "status": "completed"})
|
||||
|
||||
# 阶段 2:演化策略(轻量,始终执行)
|
||||
await _phase_evolution(session, cycle)
|
||||
|
||||
# 预算检查:超过 token 预算则跳过候选/记忆/探索阶段
|
||||
if budget_spent >= settings.big_cycle_token_budget:
|
||||
logger.warning(f"大周天 token 预算耗尽 cycle={cycle_id} spent={budget_spent}")
|
||||
cycle.summary = "token 预算耗尽,跳过后续非关键阶段"
|
||||
await session.commit()
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.phase", character_id, cycle_id,
|
||||
payload={"phase": "candidate", "status": "skipped", "reason": "budget_exhausted"},
|
||||
)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.phase", character_id, cycle_id,
|
||||
payload={"phase": "memory", "status": "skipped", "reason": "budget_exhausted"},
|
||||
)
|
||||
else:
|
||||
# 阶段 3:候选生成
|
||||
await _phase_candidates(session, cycle, snapshot)
|
||||
budget_spent += _ESTIMATED_CANDIDATE_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "candidate", "status": "completed"})
|
||||
|
||||
# 阶段 4:记忆/世界抽取
|
||||
await _phase_memory(session, cycle, snapshot)
|
||||
budget_spent += _ESTIMATED_MEMORY_COST
|
||||
await _emit_big_cycle_event("big_cycle.phase", character_id, cycle_id, payload={"phase": "memory", "status": "completed"})
|
||||
|
||||
# 阶段 5:目标进度更新
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
# 阶段 6:自主探索执行(轻量,始终执行)
|
||||
await _phase_autonomous_exploration(session, cycle)
|
||||
|
||||
cycle.status = "completed"
|
||||
cycle.phase = "done"
|
||||
cycle.completed_at = utc_now()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="big_cycle.completed",
|
||||
character_id=character_id,
|
||||
actor="system",
|
||||
decision="auto",
|
||||
input_summary=(cycle.summary or "大周天循环完成")[:200],
|
||||
resource_type="big_cycle",
|
||||
resource_id=cycle.id,
|
||||
)
|
||||
await session.commit()
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.completed", character_id, cycle_id,
|
||||
payload={"summary": cycle.summary},
|
||||
)
|
||||
except Exception as exc:
|
||||
await _fail_cycle(session, cycle_id, exc)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.failed", character_id, cycle_id,
|
||||
payload={"error": str(exc)[:200]},
|
||||
)
|
||||
|
||||
|
||||
async def _phase_evaluation(session: AsyncSession, cycle: BigCycle) -> EvaluationSnapshot:
|
||||
"""自我评估:汇总记忆/人格/世界态势,写入 cycle 快照供候选生成消费。"""
|
||||
cycle.phase = "evaluation"
|
||||
await session.commit()
|
||||
character = await character_repo.get_character(session, cycle.character_id)
|
||||
if character is None:
|
||||
raise AppError("not_found", "人物不存在。")
|
||||
memory_count = await character_repo.count_memories(session, cycle.character_id)
|
||||
recent, _ = await memory_repo.list_memories(
|
||||
session, cycle.character_id, page=1, page_size=_RECENT_MEMORY_LIMIT
|
||||
)
|
||||
personality = await build_personality_summary(session, character)
|
||||
_, world_count = await world_repo.list_entities(
|
||||
session, cycle.character_id, page=1, page_size=1
|
||||
)
|
||||
snapshot = EvaluationSnapshot(
|
||||
character_id=cycle.character_id,
|
||||
memory_count=memory_count,
|
||||
recent_memories=[(m.title, m.content) for m in recent],
|
||||
personality_summary=personality.text or "",
|
||||
world_entity_count=world_count,
|
||||
conclusion=_build_conclusion(memory_count, world_count, personality.text),
|
||||
evidence_ids=[m.id for m in recent],
|
||||
)
|
||||
|
||||
# 人格权重向量:查询最近大周天的累积漂移,注入态势快照供 LLM 感知
|
||||
from app.repositories import evolution as evolution_repo
|
||||
|
||||
recent_candidates, _ = await evolution_repo.list_candidates(
|
||||
session, cycle.character_id, status=None, page=1, page_size=100,
|
||||
)
|
||||
if recent_candidates:
|
||||
recent_drift = sum(c.drift_score or 0.0 for c in recent_candidates)
|
||||
drift_budget_remaining = max(0.0, settings.big_cycle_max_drift - recent_drift)
|
||||
snapshot.drift_budget_remaining = drift_budget_remaining
|
||||
snapshot.cumulative_drift = recent_drift
|
||||
cycle.evaluation_snapshot_json = dumps(asdict(snapshot))
|
||||
await session.commit()
|
||||
logger.info(f"大周天自我评估 cycle={cycle.id} memory={memory_count} world={world_count}")
|
||||
await _generate_drive(session, cycle, snapshot)
|
||||
return snapshot
|
||||
|
||||
|
||||
async def _generate_drive(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""内驱力生成(C 子阶段):基于态势生成好奇问题与探索性次生目标。"""
|
||||
system, user = build_drive_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
drafts = parse_curiosities(raw)[: settings.big_cycle_max_candidates]
|
||||
curiosities = [
|
||||
CuriosityQuestion(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
question=draft.question,
|
||||
source=draft.source,
|
||||
related_entities_json=dumps(draft.related_entities),
|
||||
expected_value=draft.expected_value,
|
||||
urgency=draft.urgency,
|
||||
risk_level=draft.risk_level,
|
||||
)
|
||||
for draft in drafts
|
||||
]
|
||||
if curiosities:
|
||||
await drive_repo.add_curiosities(session, curiosities)
|
||||
await session.flush()
|
||||
subgoals: list[ExploratorySubgoal] = []
|
||||
for curiosity, draft in zip(curiosities, drafts, strict=True):
|
||||
for sub in draft.subgoals:
|
||||
subgoals.append(
|
||||
ExploratorySubgoal(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curiosity.id,
|
||||
title=sub.title,
|
||||
description=sub.description,
|
||||
expected_information_gain=sub.expected_information_gain,
|
||||
interruption_cost=sub.interruption_cost,
|
||||
risk_level=sub.risk_level,
|
||||
status="candidate",
|
||||
)
|
||||
)
|
||||
if subgoals:
|
||||
await drive_repo.add_subgoals(session, subgoals)
|
||||
await session.flush()
|
||||
# 自动通过低风险次生目标(L0-L2)→ 创建 Goal
|
||||
auto_approve_levels = {"L0", "L1", "L2"}
|
||||
auto_count = 0
|
||||
for sub in subgoals:
|
||||
if sub.risk_level in auto_approve_levels:
|
||||
sub.status = "approved"
|
||||
goal = Goal(
|
||||
id=new_goal_id(),
|
||||
character_id=cycle.character_id,
|
||||
title=sub.title,
|
||||
description=sub.description or "由次生目标自动生成",
|
||||
level="task",
|
||||
type="exploration",
|
||||
status="active",
|
||||
)
|
||||
session.add(goal)
|
||||
auto_count += 1
|
||||
await session.flush()
|
||||
if auto_count:
|
||||
logger.info(f"大周天自动通过 {auto_count}/{len(subgoals)} 个低风险次生目标")
|
||||
logger.info(
|
||||
f"大周天内驱力生成 cycle={cycle.id} curiosities={len(curiosities)} subgoals={len(subgoals)}"
|
||||
)
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.drive_generated", cycle.character_id, cycle.id,
|
||||
payload={
|
||||
"curiosity_count": len(curiosities),
|
||||
"subgoal_count": len(subgoals),
|
||||
"auto_approved_subgoals": auto_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _phase_evolution(session: AsyncSession, cycle: BigCycle) -> None:
|
||||
"""演化策略阶段(轻量):演化策略融合进候选生成 prompt,此处仅标记阶段。"""
|
||||
cycle.phase = "evolution"
|
||||
await session.commit()
|
||||
logger.info(f"大周天演化策略 cycle={cycle.id}")
|
||||
|
||||
|
||||
async def _phase_candidates(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""候选生成:调神识模型产出演化候选,按 level 半自动应用。"""
|
||||
cycle.phase = "candidate"
|
||||
await session.commit()
|
||||
system, user = build_evolution_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
drafts = parse_evolution_candidates(raw)[: settings.big_cycle_max_candidates]
|
||||
candidates = [
|
||||
EvolutionCandidate(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
type=draft.type,
|
||||
source=draft.source,
|
||||
level=draft.level,
|
||||
scope=draft.scope,
|
||||
title=draft.title,
|
||||
proposal=draft.proposal,
|
||||
delta_json=dumps(draft.delta),
|
||||
evidence_ids_json=dumps(_resolve_evidence(snapshot, draft.evidence)),
|
||||
confidence=draft.confidence,
|
||||
benefit_score=draft.benefit_score,
|
||||
risk_score=draft.risk_score,
|
||||
drift_score=draft.drift_score,
|
||||
auto_apply_allowed=draft.auto_apply_allowed,
|
||||
status="pending",
|
||||
)
|
||||
for draft in drafts
|
||||
]
|
||||
await evolution_repo.add_candidates(session, candidates)
|
||||
await session.flush()
|
||||
|
||||
# 反漂移检测:高漂移候选不自动应用 + 累积漂移跟踪
|
||||
cumulative_drift = 0.0
|
||||
drift_blocked = 0
|
||||
for candidate in candidates:
|
||||
if candidate.drift_score >= settings.big_cycle_drift_threshold:
|
||||
# 高漂移候选强制标记为 pending(人工审核),不自动应用
|
||||
candidate.auto_apply_allowed = False
|
||||
candidate.status = "pending"
|
||||
candidate.error_message = f"漂移 {candidate.drift_score} >= 阈值 {settings.big_cycle_drift_threshold},需人工审核"
|
||||
drift_blocked += 1
|
||||
cumulative_drift += candidate.drift_score
|
||||
|
||||
# 累积漂移超过上限 → 全部候选降为 pending
|
||||
if cumulative_drift >= settings.big_cycle_max_drift:
|
||||
logger.warning(f"累积漂移 {cumulative_drift:.2f} >= 上限 {settings.big_cycle_max_drift},全部候选降为人工审核")
|
||||
for candidate in candidates:
|
||||
if candidate.status != "applied":
|
||||
candidate.auto_apply_allowed = False
|
||||
candidate.status = "pending"
|
||||
candidate.error_message = f"累积漂移 {cumulative_drift:.2f} 超限,暂停自动演化"
|
||||
|
||||
for candidate in candidates:
|
||||
if should_auto_apply(candidate):
|
||||
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
|
||||
if target_id is not None:
|
||||
candidate.status = "applied"
|
||||
candidate.resulting_target_id = target_id
|
||||
else:
|
||||
candidate.error_message = "该类型派生待后续阶段,已降级为人工审核"
|
||||
applied = sum(1 for c in candidates if c.status == "applied")
|
||||
pending = sum(1 for c in candidates if c.status == "pending")
|
||||
cycle.summary = (
|
||||
f"大周天生成 {len(candidates)} 个演化候选,"
|
||||
f"{applied} 个已自动应用,{pending} 个待审核"
|
||||
f"{f'({drift_blocked} 个因漂移拦截)' if drift_blocked else ''}"
|
||||
f"{f',累积漂移 {cumulative_drift:.2f}' if cumulative_drift > 0 else ''}"
|
||||
)
|
||||
if drift_blocked:
|
||||
await _emit_big_cycle_event(
|
||||
"big_cycle.drift_blocked", cycle.character_id, cycle.id,
|
||||
payload={
|
||||
"drift_blocked": drift_blocked,
|
||||
"cumulative_drift": round(cumulative_drift, 2),
|
||||
"threshold": settings.big_cycle_drift_threshold,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(f"大周天候选生成 cycle={cycle.id} count={len(candidates)} applied={applied}")
|
||||
|
||||
|
||||
async def _phase_memory(
|
||||
session: AsyncSession, cycle: BigCycle, snapshot: EvaluationSnapshot
|
||||
) -> None:
|
||||
"""记忆/世界抽取(D 子阶段):从态势抽取记忆候选与世界实体候选。"""
|
||||
cycle.phase = "memory"
|
||||
await session.commit()
|
||||
system, user = build_extraction_messages(snapshot)
|
||||
raw = await _call_model(system, user, cycle.character_id)
|
||||
memory_drafts, entity_drafts = parse_extraction(raw)
|
||||
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
|
||||
entity_drafts = entity_drafts[: settings.big_cycle_max_candidates]
|
||||
memory_cands = [
|
||||
MemoryCandidate(
|
||||
character_id=cycle.character_id,
|
||||
cycle_id=cycle.id,
|
||||
type=draft.type,
|
||||
title=draft.title,
|
||||
content=draft.content,
|
||||
confidence=draft.confidence,
|
||||
importance=draft.importance,
|
||||
sensitivity=draft.sensitivity,
|
||||
reason=draft.reason,
|
||||
status="pending",
|
||||
)
|
||||
for draft in memory_drafts
|
||||
]
|
||||
entities = [
|
||||
CharacterWorldEntity(
|
||||
character_id=cycle.character_id,
|
||||
type=draft.type,
|
||||
name=draft.name,
|
||||
attributes_json=dumps(draft.attributes),
|
||||
state_type=draft.state_type,
|
||||
confidence=draft.confidence,
|
||||
status="pending",
|
||||
)
|
||||
for draft in entity_drafts
|
||||
]
|
||||
if memory_cands:
|
||||
session.add_all(memory_cands)
|
||||
if entities:
|
||||
session.add_all(entities)
|
||||
await session.flush()
|
||||
|
||||
# 自动落地高置信度(≥0.7)低敏感度的记忆候选到 memories 表
|
||||
auto_memories = 0
|
||||
for cand in memory_cands:
|
||||
if cand.confidence >= 0.7 and cand.sensitivity in ("low", "none"):
|
||||
mem = Memory(
|
||||
character_id=cycle.character_id,
|
||||
type=cand.type,
|
||||
title=cand.title,
|
||||
content=cand.content,
|
||||
source="big_cycle",
|
||||
importance=cand.importance,
|
||||
sensitivity=cand.sensitivity,
|
||||
confidence=cand.confidence,
|
||||
)
|
||||
session.add(mem)
|
||||
cand.status = "applied"
|
||||
auto_memories += 1
|
||||
# 自动落地高置信度世界实体候选
|
||||
auto_entities = 0
|
||||
for ent in entities:
|
||||
if ent.confidence >= 0.7:
|
||||
ent.status = "active"
|
||||
auto_entities += 1
|
||||
await session.flush()
|
||||
if auto_memories or auto_entities:
|
||||
logger.info(
|
||||
f"大周天记忆/世界自动落地 cycle={cycle.id} "
|
||||
f"memories={auto_memories} entities={auto_entities}"
|
||||
)
|
||||
logger.info(
|
||||
f"大周天记忆/世界抽取 cycle={cycle.id} "
|
||||
f"memories={len(memory_cands)} entities={len(entities)}"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_evidence(snapshot: EvaluationSnapshot, indices: list[int]) -> list[str]:
|
||||
return [snapshot.evidence_ids[i] for i in indices if 0 <= i < len(snapshot.evidence_ids)]
|
||||
|
||||
|
||||
def _build_conclusion(memory_count: int, world_count: int, personality_text: str | None) -> str:
|
||||
parts = [f"记忆 {memory_count} 条", f"世界实体 {world_count} 个"]
|
||||
parts.append("已具备人格基线" if personality_text else "尚无人格基线")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
async def _call_model(system: str, user: str, _character_id: str) -> str:
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system], [("user", user)])
|
||||
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
|
||||
return response.content
|
||||
|
||||
|
||||
async def _fail_cycle(session: AsyncSession, cycle_id: str, exc: Exception) -> None:
|
||||
logger.error(f"大周天循环失败 cycle={cycle_id}: {exc}")
|
||||
try:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
if cycle is not None:
|
||||
cycle.status = "failed"
|
||||
cycle.error_code = "big_cycle_failed"
|
||||
cycle.error_message = str(exc)[:500]
|
||||
cycle.completed_at = utc_now()
|
||||
await session.commit()
|
||||
except Exception: # noqa: BLE001 — 失败处理本身不能再抛
|
||||
pass
|
||||
Whitespace-only changes.
@@ -0,0 +1,970 @@
|
||||
"""发送聊天消息用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent import (
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
AgentRunRegistry,
|
||||
ConfirmationRegistry,
|
||||
ExtensionRegistry,
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
compose_system_messages,
|
||||
default_tools,
|
||||
next_step_for_tool,
|
||||
run_agent_turn,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError, current_trace_id
|
||||
from app.core.logging import logger
|
||||
from app.core.time import utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage
|
||||
from app.modules.agent import DEFAULT_CHAT_AGENT
|
||||
from app.modules.chat.history import history_pairs
|
||||
from app.modules.chat.policies import validate_text_message
|
||||
from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive
|
||||
from app.modules.memory.context import build_memory_context
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
from app.modules.runtime.events import EventStore
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.repositories import chat as chat_repo
|
||||
from app.repositories import runtime as runtime_repo
|
||||
from app.repositories.json_utils import dumps, loads_dict
|
||||
from app.schemas.chat import SendMessageRequest, SendMessageResponse
|
||||
from app.services import runtime as runtime_service
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.characters import default_model_hint, resolve_active_character
|
||||
from app.services.chat import recent_chat_messages
|
||||
from app.services.memory import retrieve_memories
|
||||
from app.services.personality import build_personality_summary
|
||||
from app.services.runtime import TERMINAL_RUN_STATUS
|
||||
from app.services.safety import decide_chat_safety
|
||||
|
||||
# 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。
|
||||
_running_stream_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
# 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s)
|
||||
# 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。
|
||||
_REASONING_FLUSH_CHARS = 8
|
||||
_CONTENT_FLUSH_CHARS = 8
|
||||
|
||||
_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
|
||||
|
||||
|
||||
async def drain_pending_streams() -> None:
|
||||
"""等待所有进行中的后台流式任务完成。
|
||||
|
||||
``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保
|
||||
运行终态时(测试断言、优雅停机)await 此函数。
|
||||
"""
|
||||
if _running_stream_tasks:
|
||||
await asyncio.gather(*_running_stream_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
def _contract_system_messages(contract: RuntimeContract) -> list[str]:
|
||||
messages = [*contract.system_instructions]
|
||||
if contract.system_prompt:
|
||||
messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6)
|
||||
if contract.session_goal:
|
||||
messages.append(format_goal_directive(contract.session_goal))
|
||||
if contract.active_goals:
|
||||
messages.append(contract.active_goals) # 在 session_goal 之后注入
|
||||
if contract.mission:
|
||||
messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层
|
||||
if contract.lorebook_before:
|
||||
messages.append(contract.lorebook_before)
|
||||
if contract.personality_summary:
|
||||
messages.append(f"当前人格:\n{contract.personality_summary}")
|
||||
if contract.lorebook_after:
|
||||
messages.append(contract.lorebook_after)
|
||||
if contract.emotion_state:
|
||||
messages.append(contract.emotion_state)
|
||||
if contract.memory_context:
|
||||
messages.append(contract.memory_context)
|
||||
if contract.temporal_context:
|
||||
messages.append(contract.temporal_context)
|
||||
if contract.safety_guidelines:
|
||||
messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束)
|
||||
if contract.safety_constraints:
|
||||
messages.append(
|
||||
"安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints)
|
||||
)
|
||||
# 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp)
|
||||
# 导致 macOS 上沙箱执行时 cd 失败。
|
||||
messages.append(
|
||||
"当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录"
|
||||
"是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。"
|
||||
"不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。"
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
# 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。
|
||||
_TITLE_MAX_CHARS = 40
|
||||
_TITLE_STRIP_CHARS = "\"'“”‘’「」『』"
|
||||
|
||||
|
||||
|
||||
|
||||
async def _call_model(system_prompt: str, model_hint: str) -> str:
|
||||
"""调用 LLM 返回纯文本。"""
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system_prompt], [('user', system_prompt)])
|
||||
response = await manager.chat(messages, model_hint=model_hint)
|
||||
return response.content
|
||||
|
||||
async def _generate_session_title(user_content: str, assistant_content: str) -> str:
|
||||
"""用默认模型根据首轮对话内容生成简短会话标题。"""
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list(
|
||||
[
|
||||
"你会根据一段对话生成简短的中文会话标题。"
|
||||
"要求:不超过 16 个字;概括对话主题;直接输出标题文字;"
|
||||
"不要加引号、书名号、句号,不要解释。"
|
||||
],
|
||||
[("user", user_content), ("assistant", assistant_content)],
|
||||
)
|
||||
response = await manager.chat(messages, model_hint=settings.default_model_hint)
|
||||
raw = response.content.strip().split("\n", 1)[0].strip()
|
||||
return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS]
|
||||
|
||||
|
||||
class SendChatMessageUseCase:
|
||||
"""完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
owner_scope: str = settings.default_owner_scope,
|
||||
tool_runtime: ToolRuntime | None = None,
|
||||
confirmation_registry: ConfirmationRegistry | None = None,
|
||||
confirmation_store: ConfirmationStore | None = None,
|
||||
event_store: EventStore | None = None,
|
||||
agent_run_registry: AgentRunRegistry | None = None,
|
||||
python_skill_catalog: PythonSkillCatalog | None = None,
|
||||
) -> None:
|
||||
self._session = session
|
||||
self._owner_scope = owner_scope
|
||||
self._tool_runtime = tool_runtime
|
||||
self._confirmation_registry = confirmation_registry
|
||||
self._confirmation_store = confirmation_store
|
||||
self._event_store = event_store
|
||||
self._agent_run_registry = agent_run_registry
|
||||
self._python_skill_catalog = python_skill_catalog
|
||||
|
||||
async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse:
|
||||
validate_text_message(modality=payload.modality, content=payload.content)
|
||||
|
||||
character = await resolve_active_character(self._session, owner_scope=self._owner_scope)
|
||||
chat_session = await chat_repo.get_session(
|
||||
self._session, session_id, owner_scope=self._owner_scope
|
||||
)
|
||||
if chat_session is None or chat_session.status == "deleted":
|
||||
raise AppError("not_found", "会话不存在。")
|
||||
|
||||
if payload.prior_versions:
|
||||
# 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。
|
||||
recent = await chat_repo.list_recent_messages(
|
||||
self._session,
|
||||
chat_session.id,
|
||||
limit=10,
|
||||
)
|
||||
existing_user = None
|
||||
for msg in reversed(recent):
|
||||
if msg.role == "user" and msg.deleted_at is None:
|
||||
existing_user = msg
|
||||
break
|
||||
if existing_user is None:
|
||||
raise AppError("not_found", "要重答的原始用户消息不存在。")
|
||||
user_message = existing_user
|
||||
else:
|
||||
user_message = ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content=payload.content,
|
||||
modality="text",
|
||||
metadata_json=dumps({}),
|
||||
sensitivity="low",
|
||||
)
|
||||
self._session.add(user_message)
|
||||
await self._session.flush()
|
||||
|
||||
model_hint = payload.model_hint or default_model_hint(character)
|
||||
session_goal = extract_session_goal(payload.content)
|
||||
run = await runtime_service.create_run(
|
||||
self._session,
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
model_hint=model_hint,
|
||||
goal=session_goal or "回复用户消息",
|
||||
)
|
||||
user_message.run_id = run.id
|
||||
|
||||
await runtime_service.append_event(
|
||||
self._session,
|
||||
run_id=run.id,
|
||||
character_id=character.id,
|
||||
event_type="run.started",
|
||||
payload={"run_id": run.id, "session_id": chat_session.id},
|
||||
)
|
||||
# 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE;
|
||||
# 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。
|
||||
await self._session.commit()
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._stream_assistant_response(
|
||||
run_id=run.id,
|
||||
character_id=character.id,
|
||||
chat_session_id=chat_session.id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
_running_stream_tasks.add(task)
|
||||
task.add_done_callback(_running_stream_tasks.discard)
|
||||
|
||||
from app.core.config import settings as _settings
|
||||
|
||||
return SendMessageResponse(
|
||||
message_id=user_message.id,
|
||||
assistant_message_id=None,
|
||||
run_id=run.id,
|
||||
session_id=chat_session.id,
|
||||
character_id=character.id,
|
||||
status=run.status,
|
||||
stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream",
|
||||
)
|
||||
|
||||
async def _stream_assistant_response(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
chat_session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
) -> None:
|
||||
"""后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。"""
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await self._produce_assistant_stream(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
chat_session_id=chat_session_id,
|
||||
payload=payload,
|
||||
)
|
||||
except TimeoutError:
|
||||
await self._fail_stream(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
code="run_timeout",
|
||||
message="运行超时。",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("chat_stream_failed", run_id=run_id)
|
||||
await self._fail_stream(
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
code="run_failed",
|
||||
message="运行失败,请稍后重试。",
|
||||
)
|
||||
|
||||
async def _produce_assistant_stream(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
chat_session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
) -> None:
|
||||
character = await resolve_active_character(session, owner_scope=self._owner_scope)
|
||||
model_hint = payload.model_hint or default_model_hint(character)
|
||||
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
session_goal = run.goal or ""
|
||||
|
||||
# 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息,
|
||||
# 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。
|
||||
if payload.prior_versions:
|
||||
latest = await chat_repo.list_recent_messages(
|
||||
session,
|
||||
chat_session_id,
|
||||
limit=5,
|
||||
)
|
||||
# latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant
|
||||
for i in range(len(latest) - 1, -1, -1):
|
||||
if latest[i].role == "assistant" and latest[i].deleted_at is None:
|
||||
latest[i].deleted_at = utc_now()
|
||||
break
|
||||
await session.commit()
|
||||
|
||||
safety = await decide_chat_safety(
|
||||
session,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
personality = await build_personality_summary(session, character)
|
||||
memories = await retrieve_memories(
|
||||
session,
|
||||
character_id,
|
||||
limit=settings.memory_injection_limit,
|
||||
)
|
||||
memory_context = build_memory_context(memories)
|
||||
|
||||
# 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流
|
||||
if memories.memories:
|
||||
memory_items = []
|
||||
for m in memories.memories:
|
||||
memory_items.append(
|
||||
{
|
||||
"id": m.id,
|
||||
"title": m.title,
|
||||
"content": m.content,
|
||||
"source": m.source,
|
||||
"confidence": m.confidence,
|
||||
"importance": m.importance,
|
||||
"type": m.type,
|
||||
}
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="memory.retrieved",
|
||||
payload={"memories": memory_items},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
recent_messages = await recent_chat_messages(session, chat_session_id)
|
||||
trace_id = current_trace_id()
|
||||
|
||||
# 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。
|
||||
# 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。
|
||||
temporal_context: str | None = None
|
||||
if self._event_store is not None:
|
||||
try:
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
time_result = await compute_live_snapshot(
|
||||
session,
|
||||
self._event_store,
|
||||
character_id,
|
||||
ModulationInput(),
|
||||
run_id=run_id,
|
||||
owner_scope=self._owner_scope,
|
||||
)
|
||||
temporal_context = time_result.context_text or None
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("temporal_context 计算失败,已跳过", run_id=run_id)
|
||||
|
||||
# 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。
|
||||
# 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。
|
||||
emotion_state: str | None = None
|
||||
try:
|
||||
from app.services.personality_emotion import compute_emotion_prompt
|
||||
|
||||
emotion_state = await compute_emotion_prompt(session, character_id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("emotion_state 计算失败,已跳过", run_id=run_id)
|
||||
|
||||
# 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7)
|
||||
# 降级原则:任何失败 → 不注入,不阻塞对话。
|
||||
lorebook_before: str | None = None
|
||||
lorebook_after: str | None = None
|
||||
try:
|
||||
from app.services.lorebook import build_lorebook_injection
|
||||
|
||||
scan_parts = [payload.content]
|
||||
scan_parts.extend(
|
||||
m.content for m in recent_messages[-8:] if m.role in ("user", "assistant")
|
||||
)
|
||||
lorebook_before, lorebook_after = await build_lorebook_injection(
|
||||
session,
|
||||
character_id,
|
||||
self._owner_scope,
|
||||
"\n".join(scan_parts),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("世谱注入失败,已跳过", run_id=run_id)
|
||||
|
||||
profile_data = loads_dict(character.profile_json or "{}")
|
||||
contract = RuntimeContract(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
run_id=run_id,
|
||||
model_hint=model_hint,
|
||||
agent=DEFAULT_CHAT_AGENT,
|
||||
system_instructions=[],
|
||||
system_prompt=profile_data.get("system_prompt") or None,
|
||||
session_goal=session_goal or None,
|
||||
personality_summary=personality.text,
|
||||
mission=character.mission,
|
||||
safety_guidelines=character.safety_guidelines,
|
||||
lorebook_before=lorebook_before,
|
||||
lorebook_after=lorebook_after,
|
||||
emotion_state=emotion_state,
|
||||
memory_context=memory_context,
|
||||
temporal_context=temporal_context,
|
||||
recent_messages=recent_messages,
|
||||
safety_constraints=safety.constraints,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
# 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖
|
||||
from app.modules.runtime.integrity import validate_daoti_integrity
|
||||
|
||||
validate_daoti_integrity(
|
||||
contract,
|
||||
mission=character.mission,
|
||||
safety_guidelines=character.safety_guidelines,
|
||||
)
|
||||
|
||||
# —— 活跃目标注入 ——
|
||||
try:
|
||||
from app.services.governance import load_active_goals_context
|
||||
|
||||
active_goals = await load_active_goals_context(character.id)
|
||||
if active_goals:
|
||||
contract.active_goals = active_goals
|
||||
except Exception:
|
||||
logger.warning("活跃目标加载失败,跳过注入")
|
||||
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="model.request.started",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
actor="system",
|
||||
decision="auto",
|
||||
input_summary=model_hint,
|
||||
resource_type="run",
|
||||
resource_id=run_id,
|
||||
)
|
||||
system_messages = compose_system_messages(
|
||||
_contract_system_messages(contract),
|
||||
contract.agent,
|
||||
[],
|
||||
)
|
||||
|
||||
# ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ──
|
||||
from app.application.skill_executor import SkillExecutor
|
||||
|
||||
tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None
|
||||
skill_exec = SkillExecutor(
|
||||
tool_view=tool_view,
|
||||
event_store=self._event_store,
|
||||
)
|
||||
try:
|
||||
await skill_exec.match_and_activate(
|
||||
payload.content,
|
||||
system_messages,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
skill_refs=contract.agent.skill_refs if contract.agent else (),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("skill_executor 匹配失败,已跳过")
|
||||
|
||||
agent_task = None
|
||||
completed_plan_step_ids: set[str] = set()
|
||||
|
||||
# ── 洛书 Deliberative 规划 ──
|
||||
if settings.enable_deliberative_planning:
|
||||
from app.application.planning import (
|
||||
build_planning_prompt,
|
||||
format_plan_for_prompt,
|
||||
parse_plan,
|
||||
should_plan,
|
||||
)
|
||||
|
||||
if should_plan(payload.content):
|
||||
try:
|
||||
planner_prompt = build_planning_prompt(
|
||||
payload.content,
|
||||
memory_context=memory_context[:300] if memory_context else '',
|
||||
)
|
||||
planner_raw = await _call_model(planner_prompt, model_hint)
|
||||
plan = parse_plan(planner_raw)
|
||||
if plan:
|
||||
agent_task = task_from_deliberative_plan(
|
||||
plan,
|
||||
run_id=run_id,
|
||||
agent_name=contract.agent.name if contract.agent else "默认 Agent",
|
||||
objective=payload.content,
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="plan.created",
|
||||
source="planner",
|
||||
payload=task_plan_payload(agent_task),
|
||||
)
|
||||
await session.commit()
|
||||
plan_text = format_plan_for_prompt(plan)
|
||||
system_messages.append(
|
||||
"【当前计划】\n请按以下计划逐步执行。"
|
||||
"每一步完成后标记完成,再进入下一步。"
|
||||
"全部完成后回复总结。\n\n" + plan_text
|
||||
)
|
||||
logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)")
|
||||
except Exception:
|
||||
logger.warning('deliberative 规划生成失败,回退 ReAct')
|
||||
|
||||
python_skills: tuple[PythonSkillDefinition, ...] = ()
|
||||
if self._python_skill_catalog is not None:
|
||||
python_skills = self._python_skill_catalog.match(
|
||||
payload.content,
|
||||
skill_refs=contract.agent.skill_refs if contract.agent else (),
|
||||
metadata={
|
||||
"run_id": run_id,
|
||||
"session_id": chat_session_id,
|
||||
"character_id": character_id,
|
||||
"model_hint": model_hint,
|
||||
},
|
||||
)
|
||||
for skill in python_skills:
|
||||
if skill.instructions:
|
||||
system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}")
|
||||
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list(
|
||||
system_messages,
|
||||
history_pairs(recent_messages),
|
||||
)
|
||||
|
||||
reasoning_buf = ""
|
||||
content_buf = ""
|
||||
full_content = ""
|
||||
had_reasoning = False
|
||||
reasoning_finalized = False
|
||||
last_model = model_hint
|
||||
|
||||
async def flush_reasoning(*, force: bool = False) -> None:
|
||||
nonlocal reasoning_buf
|
||||
while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force):
|
||||
cut = (
|
||||
_REASONING_FLUSH_CHARS
|
||||
if len(reasoning_buf) >= _REASONING_FLUSH_CHARS
|
||||
else len(reasoning_buf)
|
||||
)
|
||||
delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:]
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="reasoning.delta",
|
||||
source="model",
|
||||
payload={"delta": delta},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def flush_content(*, force: bool = False) -> None:
|
||||
nonlocal content_buf
|
||||
while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force):
|
||||
cut = (
|
||||
_CONTENT_FLUSH_CHARS
|
||||
if len(content_buf) >= _CONTENT_FLUSH_CHARS
|
||||
else len(content_buf)
|
||||
)
|
||||
delta, content_buf = content_buf[:cut], content_buf[cut:]
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="message.delta",
|
||||
source="model",
|
||||
payload={"delta": delta},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def finalize_reasoning() -> None:
|
||||
"""收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。
|
||||
|
||||
在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。
|
||||
多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized
|
||||
标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning
|
||||
仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。
|
||||
|
||||
思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件,
|
||||
防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。
|
||||
"""
|
||||
nonlocal reasoning_finalized
|
||||
if reasoning_finalized or not payload.thinking:
|
||||
return
|
||||
reasoning_finalized = True
|
||||
await flush_reasoning(force=True)
|
||||
if had_reasoning:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="reasoning.completed",
|
||||
source="model",
|
||||
payload={},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def on_chunk(chunk: ChatChunk) -> None:
|
||||
"""把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。"""
|
||||
nonlocal reasoning_buf, content_buf, full_content, had_reasoning
|
||||
# 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示
|
||||
if payload.thinking and chunk.delta_reasoning:
|
||||
had_reasoning = True
|
||||
reasoning_buf += chunk.delta_reasoning
|
||||
await flush_reasoning()
|
||||
if chunk.delta_content:
|
||||
# 正文开始前先收尾思考,保证思考摘要完整先于正文
|
||||
await finalize_reasoning()
|
||||
content_buf += chunk.delta_content
|
||||
full_content += chunk.delta_content
|
||||
await flush_content()
|
||||
|
||||
# 收集工具调用信息,最终存入消息 metadata 供前端持久化展示
|
||||
tool_calls: list[dict[str, object]] = []
|
||||
_started_tool_calls: dict[str, dict[str, object]] = {}
|
||||
_plan_steps_by_tool_call = {}
|
||||
|
||||
async def _reset_turn_state() -> None:
|
||||
"""每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。
|
||||
|
||||
Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。
|
||||
若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。
|
||||
"""
|
||||
nonlocal had_reasoning, reasoning_finalized
|
||||
had_reasoning = False
|
||||
reasoning_finalized = False
|
||||
|
||||
async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None:
|
||||
"""工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。"""
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=event_type,
|
||||
source="agent",
|
||||
payload=payload_dict,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 收集工具调用信息供前端持久化展示
|
||||
if event_type == "tool.started":
|
||||
tc_id = str(payload_dict.get("tool_call_id", ""))
|
||||
tool_name = str(payload_dict.get("tool_name", ""))
|
||||
if agent_task is not None:
|
||||
step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids)
|
||||
if step is not None:
|
||||
_plan_steps_by_tool_call[tc_id] = step
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="plan.step.started",
|
||||
source="planner",
|
||||
payload=step_event_payload(agent_task, step, status="running"),
|
||||
)
|
||||
await session.commit()
|
||||
_started_tool_calls[tc_id] = {
|
||||
"tool_name": str(payload_dict.get("tool_name", "")),
|
||||
"tool_call_id": tc_id,
|
||||
"args": str(payload_dict.get("args", "")),
|
||||
"status": "running",
|
||||
}
|
||||
elif event_type in ("tool.completed", "tool.failed"):
|
||||
tc_id = str(payload_dict.get("tool_call_id", ""))
|
||||
entry = _started_tool_calls.pop(tc_id, None)
|
||||
step = _plan_steps_by_tool_call.pop(tc_id, None)
|
||||
if step is not None and agent_task is not None:
|
||||
tool_status = str(payload_dict.get("status", "error"))
|
||||
step_status = "completed" if tool_status == "success" else "failed"
|
||||
if step_status == "completed":
|
||||
completed_plan_step_ids.add(step.id)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=f"plan.step.{step_status}",
|
||||
source="planner",
|
||||
payload=step_event_payload(
|
||||
agent_task,
|
||||
step,
|
||||
status=step_status,
|
||||
result_summary=str(payload_dict.get("result", ""))[:500] or None,
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
if entry is not None:
|
||||
entry["status"] = str(payload_dict.get("status", "error"))
|
||||
entry["result"] = str(payload_dict.get("result", ""))
|
||||
tool_calls.append(entry)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
python_skill_activation = await activate_python_skills(extensions, python_skills)
|
||||
if python_skills:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="skill.activated",
|
||||
source="agent",
|
||||
payload={"skills": [skill.name for skill in python_skills], "kind": "python"},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None:
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type=event.type,
|
||||
source="agent",
|
||||
payload=event.payload,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
extensions.add_event_listener(persist_agent_event)
|
||||
on_confirm_timeout = None
|
||||
if self._confirmation_store is not None:
|
||||
confirmation_store = self._confirmation_store
|
||||
|
||||
async def on_confirm_timeout(confirmation_id: str) -> None:
|
||||
await confirmation_store.mark_timed_out(confirmation_id)
|
||||
|
||||
agent = Agent(
|
||||
AgentConfig(
|
||||
tools=tuple(
|
||||
default_tools(
|
||||
tool_view.specs_by_name(),
|
||||
allowed_tools=contract.agent.tool_policy.allowed_tools
|
||||
if contract.agent
|
||||
else (),
|
||||
)
|
||||
if tool_view is not None
|
||||
else ()
|
||||
),
|
||||
tool_runtime=tool_view,
|
||||
model_hint=(contract.agent.model_hint or model_hint)
|
||||
if contract.agent
|
||||
else model_hint,
|
||||
thinking_enabled=payload.thinking,
|
||||
reasoning_effort=payload.reasoning_effort,
|
||||
max_iterations=contract.agent.execution_policy.max_iterations
|
||||
if contract.agent
|
||||
else settings.agent_max_iterations,
|
||||
confirmation_registry=self._confirmation_registry,
|
||||
confirmation_timeout=settings.confirmation_timeout_seconds,
|
||||
on_turn=_reset_turn_state,
|
||||
on_chunk=on_chunk,
|
||||
on_tool_event=on_tool_event,
|
||||
on_confirm_timeout=on_confirm_timeout,
|
||||
),
|
||||
messages=messages,
|
||||
run_context=AgentRunContext(
|
||||
run_id=run_id,
|
||||
session_id=chat_session_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
),
|
||||
extensions=extensions,
|
||||
# 保留模块级符号作为注入点,现有测试与外部集成无需重写。
|
||||
runner=run_agent_turn,
|
||||
)
|
||||
if self._agent_run_registry is not None:
|
||||
await self._agent_run_registry.register(run_id, agent)
|
||||
try:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
async with asyncio.timeout(settings.run_timeout_seconds):
|
||||
agent_result = await agent.continue_run()
|
||||
finally:
|
||||
if self._agent_run_registry is not None:
|
||||
await self._agent_run_registry.unregister(run_id, agent)
|
||||
await python_skill_activation.close()
|
||||
|
||||
# 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。
|
||||
await finalize_reasoning()
|
||||
await flush_content(force=True)
|
||||
|
||||
# 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
|
||||
# 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致;
|
||||
# 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。
|
||||
final_content = agent_result.content or full_content
|
||||
|
||||
version_ts = utc_now().isoformat()
|
||||
# 构造版本历史:prior_versions 前置 + 当前回复作为最新版本
|
||||
prior = payload.prior_versions or []
|
||||
all_versions = [*prior, {"content": final_content, "created_at": version_ts}]
|
||||
active_idx = len(all_versions) - 1 # 始终最新
|
||||
|
||||
assistant = ChatMessage(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
role="assistant",
|
||||
content=final_content,
|
||||
modality="text",
|
||||
run_id=run_id,
|
||||
metadata_json=dumps(
|
||||
{
|
||||
"model_hint": model_hint,
|
||||
"model": last_model,
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
),
|
||||
sensitivity="low",
|
||||
versions_json=dumps(all_versions),
|
||||
active_version_idx=active_idx,
|
||||
)
|
||||
session.add(assistant)
|
||||
await session.flush()
|
||||
await record_audit_event(
|
||||
session,
|
||||
action="model.response.completed",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
actor="model",
|
||||
decision="auto",
|
||||
output_summary="模型响应已完成。",
|
||||
resource_type="message",
|
||||
resource_id=assistant.id,
|
||||
)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="message.completed",
|
||||
source="model",
|
||||
payload={"message_id": assistant.id, "content": final_content},
|
||||
)
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is not None and run.status not in TERMINAL_RUN_STATUS:
|
||||
await runtime_service.complete_run(session, run)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="run.completed",
|
||||
payload={"run_id": run_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程)
|
||||
await self._maybe_rename_session(
|
||||
session,
|
||||
chat_session_id=chat_session_id,
|
||||
user_content=payload.content,
|
||||
assistant_content=final_content,
|
||||
)
|
||||
|
||||
# 每轮对话后检查大周天触发条件(失败不影响主流程)
|
||||
try:
|
||||
from app.application.big_cycle import maybe_trigger_big_cycle
|
||||
|
||||
await maybe_trigger_big_cycle(character_id, trigger="session_turn")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("大周天触发检查失败,已跳过")
|
||||
|
||||
# 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程)
|
||||
try:
|
||||
from app.application.reflection import start_reflection_after_turn
|
||||
|
||||
start_reflection_after_turn(
|
||||
character_id=character_id,
|
||||
session_id=chat_session_id,
|
||||
user_content=payload.content,
|
||||
assistant_content=final_content,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("小周天反思触发失败,已跳过")
|
||||
|
||||
# —— 会话目标自动持久化 ——
|
||||
try:
|
||||
from app.db.session import AsyncSessionLocal as GoalSession
|
||||
from app.services.goal_persist import auto_persist_session_goal
|
||||
|
||||
async with GoalSession() as goal_session:
|
||||
await auto_persist_session_goal(goal_session, character.id, payload.content)
|
||||
await goal_session.commit()
|
||||
except Exception:
|
||||
logger.warning("会话目标自动持久化失败,跳过")
|
||||
|
||||
async def _maybe_rename_session(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
chat_session_id: str,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
) -> None:
|
||||
"""首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。"""
|
||||
try:
|
||||
existing = await chat_repo.get_session(session, chat_session_id)
|
||||
if existing is None or existing.title:
|
||||
return
|
||||
assistant_count = await chat_repo.count_messages_by_role(
|
||||
session, chat_session_id, role="assistant"
|
||||
)
|
||||
if assistant_count != 1:
|
||||
return
|
||||
title = await _generate_session_title(user_content, assistant_content)
|
||||
if not title:
|
||||
return
|
||||
await chat_repo.update_session_title(session, chat_session_id, title)
|
||||
await session.commit()
|
||||
logger.info("session_auto_renamed", session_id=chat_session_id, title=title)
|
||||
except Exception:
|
||||
logger.warning("session_title_generate_failed", session_id=chat_session_id)
|
||||
|
||||
async def _fail_stream(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
character_id: str,
|
||||
code: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None or run.status in TERMINAL_RUN_STATUS:
|
||||
return
|
||||
await runtime_service.fail_run(session, run, code=code, message=message)
|
||||
await runtime_service.append_event(
|
||||
session,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
event_type="run.failed",
|
||||
payload={"code": code, "message": message},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""资料导入候选抽取用例(异步后台任务,跨 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
|
||||
@@ -0,0 +1,23 @@
|
||||
"""洛书枢 deliberative 规划器(编排层)。
|
||||
|
||||
领域数据结构与纯函数归位到 ``app/modules/planner/``,
|
||||
本模块为薄壳,从 modules 导入并重导出以保持向后兼容。
|
||||
"""
|
||||
|
||||
from app.modules.planner import (
|
||||
DeliberativePlan,
|
||||
PlanStep,
|
||||
build_planning_prompt,
|
||||
format_plan_for_prompt,
|
||||
parse_plan,
|
||||
should_plan,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeliberativePlan",
|
||||
"PlanStep",
|
||||
"build_planning_prompt",
|
||||
"format_plan_for_prompt",
|
||||
"parse_plan",
|
||||
"should_plan",
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""小周天每轮记忆候选 + 返照反思(异步后台任务,跨 modules + ai_provider)。
|
||||
|
||||
言灵表达(流式回复完成)后触发:从本轮对话抽取记忆候选(待审核)+ 生成反思
|
||||
(直接建 memory type=reflection)。失败静默,不影响主流程。仅适用单进程;
|
||||
多 worker 部署需替换为跨进程任务队列。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.ai_provider import ProviderManager, ProviderManagerFactory
|
||||
from app.core.config import settings
|
||||
from app.core.logging import logger
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.candidates import MemoryCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.modules.reflection.extract import parse_reflection
|
||||
from app.modules.reflection.prompts import build_reflection_messages
|
||||
|
||||
_running_reflection_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
|
||||
|
||||
|
||||
async def drain_pending_reflections() -> None:
|
||||
"""等待所有进行中的反思任务完成(测试断言 / 优雅停机用)。"""
|
||||
if _running_reflection_tasks:
|
||||
await asyncio.gather(*_running_reflection_tasks, return_exceptions=True)
|
||||
|
||||
|
||||
def start_reflection_after_turn(
|
||||
character_id: str,
|
||||
session_id: str,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
) -> None:
|
||||
"""每轮对话后启动反思抽取(后台任务);session_id 留作未来事件回写。"""
|
||||
task = asyncio.create_task(
|
||||
_run_reflection(character_id, session_id, user_content, assistant_content)
|
||||
)
|
||||
_running_reflection_tasks.add(task)
|
||||
task.add_done_callback(_running_reflection_tasks.discard)
|
||||
|
||||
|
||||
async def _run_reflection(
|
||||
character_id: str,
|
||||
session_id: str,
|
||||
user_content: str,
|
||||
assistant_content: str,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
system, user = build_reflection_messages(user_content, assistant_content)
|
||||
raw = await _call_model(system, user, character_id)
|
||||
memory_drafts, reflection_text = parse_reflection(raw)
|
||||
memory_drafts = memory_drafts[: settings.big_cycle_max_candidates]
|
||||
|
||||
# 小周天记忆候选:高置信度(≥0.7)低敏感度直接落地为 Memory,
|
||||
# 其余留 pending 待大周天统一审核
|
||||
auto_memories = 0
|
||||
pending_count = 0
|
||||
for draft in memory_drafts:
|
||||
if draft.confidence >= 0.7 and draft.sensitivity in ("low", "none"):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type=draft.type,
|
||||
title=draft.title,
|
||||
content=draft.content,
|
||||
source="reflection",
|
||||
importance=draft.importance,
|
||||
sensitivity=draft.sensitivity,
|
||||
confidence=draft.confidence,
|
||||
)
|
||||
)
|
||||
auto_memories += 1
|
||||
else:
|
||||
session.add(
|
||||
MemoryCandidate(
|
||||
character_id=character_id,
|
||||
type=draft.type,
|
||||
title=draft.title,
|
||||
content=draft.content,
|
||||
confidence=draft.confidence,
|
||||
importance=draft.importance,
|
||||
sensitivity=draft.sensitivity,
|
||||
reason=draft.reason,
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
pending_count += 1
|
||||
if reflection_text:
|
||||
title = reflection_text[:60] + ("…" if len(reflection_text) > 60 else "")
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="reflection",
|
||||
title=title,
|
||||
content=reflection_text,
|
||||
source="reflection",
|
||||
importance=0.5,
|
||||
sensitivity="low",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"小周天反思 character={character_id} session={session_id} "
|
||||
f"auto={auto_memories} pending={pending_count} "
|
||||
f"reflection={'yes' if reflection_text else 'no'}"
|
||||
)
|
||||
|
||||
# 推送反思完成事件(供叩心枢/前端观测)
|
||||
try:
|
||||
from app.modules.runtime.events import Event
|
||||
from app.services.event_store import DbEventStore
|
||||
|
||||
store = DbEventStore(AsyncSessionLocal)
|
||||
await store.append(
|
||||
Event(
|
||||
type="reflection.completed",
|
||||
source="reflection",
|
||||
visibility="public",
|
||||
character_id=character_id,
|
||||
payload={
|
||||
"session_id": session_id,
|
||||
"auto_memories": auto_memories,
|
||||
"pending_count": pending_count,
|
||||
"has_reflection": bool(reflection_text),
|
||||
"reflection_preview": (reflection_text or "")[:100],
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("推送 reflection.completed 事件失败")
|
||||
|
||||
except Exception as exc: # noqa: BLE001 — 后台任务失败静默
|
||||
logger.warning(f"小周天反思失败 character={character_id}: {exc}")
|
||||
|
||||
|
||||
async def _call_model(system: str, user: str, _character_id: str) -> str:
|
||||
manager = _get_provider_manager()
|
||||
messages = manager.to_message_list([system], [("user", user)])
|
||||
response = await manager.chat(messages, model_hint=settings.big_cycle_model)
|
||||
return response.content
|
||||
@@ -0,0 +1,83 @@
|
||||
"""声明式 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"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""百工工具运行时编排层。
|
||||
|
||||
司命评估 → 直接执行/沙箱执行 → 一炁事件回写 的编排核心。从 ``modules/tool_runtime`` 迁入
|
||||
``application``,因为编排涉及沙箱(integrations)、司命(modules)、事件存储(services)
|
||||
等多层协调,属于跨模块用例编排(见后端分层架构设计 §5.3)。
|
||||
|
||||
工具规格数据结构(``ToolSpec`` / ``ToolResult``)仍在 ``modules/tool_runtime/definition``。
|
||||
"""
|
||||
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
|
||||
__all__ = ["RunScopedToolView", "ToolRuntime", "builtin_specs"]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""内置工具集合。覆盖 L0–L3 风险等级,供编排闭环、司命分级演示与前端调用。
|
||||
|
||||
文件类工具(read_file / list_dir / write_file)使用沙箱 base 下的持久子目录
|
||||
``workspace``,并通过 ``isolated_workspace=True`` 由 runtime 改写为按角色隔离的
|
||||
``workspace/<character_id>``,使同一角色跨会话/跨 run 文件互通、不同角色互不可见
|
||||
(workdir 相对 base,受沙箱路径逃逸保护)。多用户上线后隔离主体将切换为真实用户标识。
|
||||
|
||||
执行路径:
|
||||
- 沙箱路径(datetime/read_file/list_dir/write_file/shell):``build_request`` 产出 ExecRequest,
|
||||
由 SandboxClient 执行。参数默认用环境变量传参(避免命令注入);write_file 的 content 因可能
|
||||
很大(单个 env 受 MAX_ARG_STRLEN≈128KB 限制)改经 stdin 写入——既无大小上限,又不经 shell 解释。
|
||||
shell 保留直接拼接——本就是任意命令,且经沙箱隔离。
|
||||
- 直接执行路径(fetch_url):``executor`` 直接发起 HTTP 调用(见 ``web_tools.py``),
|
||||
不走沙箱;http_client 与密钥由 runtime 注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.tool_runtime.web_tools import _fetch_url_executor
|
||||
from app.core.config import settings
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolSpec
|
||||
|
||||
# 沙箱 base 下的持久工作子目录(文件类工具共享,由 runtime 按角色隔离为 workspace/<character_id>)
|
||||
_WORKSPACE = "workspace"
|
||||
|
||||
|
||||
def _need_str(input: ToolInput, key: str) -> str:
|
||||
value = input.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"工具需要非空字符串 '{key}' 输入")
|
||||
return value
|
||||
|
||||
|
||||
def _datetime_request(_input: ToolInput) -> ExecRequest:
|
||||
return ExecRequest(argv=["sh", "-c", "date '+%Y-%m-%d %H:%M:%S %Z'"])
|
||||
|
||||
|
||||
def _as_positive_int(value: object, *, default: int) -> int:
|
||||
"""把 offset/limit 容错解析为 >=1 的整数;非数字或缺省返回 default。"""
|
||||
try:
|
||||
n = int(value) # str/int/float 均可;bool(True)=1 由 >=1 兜底
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return n if n >= 1 else default
|
||||
|
||||
|
||||
def _read_file_request(input: ToolInput) -> ExecRequest:
|
||||
path = _need_str(input, "path")
|
||||
# 行分页(offset 起始行 1-based / limit 行数):大文件可分段读取,避免整体 cat 后被回显
|
||||
# 截断、模型拿不到完整内容而反复试错。offset/limit 经 int() 规整为纯数字后拼入 sed 地址
|
||||
# (数字无注入风险),行范围用单引号包裹(末行 $ 字面、不经 shell 展开),PATH_ARG 仍走 env。
|
||||
# 缺省 offset/limit 时读全部(行为不变)。
|
||||
# 使用绝对路径(/bin/sed, /bin/cat):避免 env_clear 后 PATH 失效。
|
||||
start = _as_positive_int(input.get("offset"), default=1)
|
||||
row_limit = _as_positive_int(input.get("limit"), default=0)
|
||||
if start > 1 or row_limit > 0:
|
||||
range_expr = f"{start},{start + row_limit - 1}" if row_limit > 0 else f"{start},$"
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", f"sed -n '{range_expr}p' \"$PATH_ARG\""],
|
||||
env={"PATH_ARG": path},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'cat -- "$PATH_ARG"'],
|
||||
env={"PATH_ARG": path},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _list_dir_request(input: ToolInput) -> ExecRequest:
|
||||
path = input.get("path", ".")
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'ls -la -- "$PATH_ARG"'],
|
||||
env={"PATH_ARG": str(path)},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _write_file_request(input: ToolInput) -> ExecRequest:
|
||||
path = _need_str(input, "path")
|
||||
content = input.get("content", "")
|
||||
# content 经 stdin 写入(cat > file),不经环境变量:单个 env 受 MAX_ARG_STRLEN≈128KB
|
||||
# 限制,大文档经 env 会让 execve 返回 E2BIG、沙箱 ok=False,模型反复重试直到截断;
|
||||
# stdin 既无大小上限、又完全不经过 shell 解释(比 env/argv 更安全,内容里的 `$`、
|
||||
# 反引号、`%` 等都原样落盘)。PATH_ARG 仍用 env(短、需拼进重定向目标)。
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'cat > "$PATH_ARG"'],
|
||||
env={"PATH_ARG": path},
|
||||
stdin=str(content),
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _shell_request(input: ToolInput) -> ExecRequest:
|
||||
command = _need_str(input, "command")
|
||||
raw_timeout = input.get("timeout_ms")
|
||||
timeout_ms = (
|
||||
int(raw_timeout)
|
||||
if isinstance(raw_timeout, int | float)
|
||||
else settings.sandbox_default_timeout_ms
|
||||
)
|
||||
stdin = input.get("stdin")
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", command],
|
||||
stdin=stdin if isinstance(stdin, str) else None,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
DATETIME = ToolSpec(
|
||||
name="datetime",
|
||||
description="获取当前时间(只读,L0)。",
|
||||
risk_level="L0",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_datetime_request,
|
||||
side_effect=False,
|
||||
)
|
||||
READ_FILE = ToolSpec(
|
||||
name="read_file",
|
||||
description="读取 workspace 内文件(只读,L1)。",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_read_file_request,
|
||||
side_effect=False,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
LIST_DIR = ToolSpec(
|
||||
name="list_dir",
|
||||
description="列出 workspace 内目录(只读,L1)。",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_list_dir_request,
|
||||
side_effect=False,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
WRITE_FILE = ToolSpec(
|
||||
name="write_file",
|
||||
description="写入 workspace 内文件(有副作用,L2 需确认)。",
|
||||
risk_level="L2",
|
||||
permission_policy=PermissionPolicy.ask,
|
||||
build_request=_write_file_request,
|
||||
side_effect=True,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
SHELL = ToolSpec(
|
||||
name="shell",
|
||||
description="在沙箱中执行任意 shell 命令(高风险,L3 需确认)。",
|
||||
risk_level="L3",
|
||||
permission_policy=PermissionPolicy.always_ask,
|
||||
build_request=_shell_request,
|
||||
side_effect=True,
|
||||
# 与文件类工具统一默认工作目录(workspace/<角色>):否则 shell 跑在沙箱根目录,cat/ls
|
||||
# 看不到 read_file/write_file 产生的文件,模型会反复试不同路径直到循环截断。shell 本是
|
||||
# 任意命令,cd 仍可改目录(沙箱路径逃逸保护另兜底),这里只统一默认起点。
|
||||
isolated_workspace=True,
|
||||
)
|
||||
FETCH_URL = ToolSpec(
|
||||
name="fetch_url",
|
||||
description="抓取指定 URL 的网页正文(转 markdown)。",
|
||||
risk_level="L2",
|
||||
permission_policy=PermissionPolicy.ask,
|
||||
side_effect=True,
|
||||
audit_required=True,
|
||||
executor=_fetch_url_executor,
|
||||
)
|
||||
|
||||
|
||||
def builtin_specs() -> dict[str, ToolSpec]:
|
||||
"""返回内置工具规格字典。"""
|
||||
return {
|
||||
spec.name: spec for spec in [DATETIME, READ_FILE, LIST_DIR, WRITE_FILE, SHELL, FETCH_URL]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MCP 工具转 ToolSpec(executor 绑定 http_client 调 MCP server tools/call)。
|
||||
|
||||
MCP 工具命名 ``mcp_{server_name}_{tool_name}``(下划线分隔,冒号不含 OpenAI 命名规范),``risk_level`` 缺省 L2(外部工具),
|
||||
``permission_policy`` 由 risk 经 ``default_policy`` 推导。executor 闭包捕获
|
||||
http_client + server endpoint + auth_token。
|
||||
|
||||
协议:标准 MCP Streamable HTTP(JSON-RPC 2.0 over POST /message)
|
||||
"""
|
||||
|
||||
from app.integrations.mcp.client import call_tool
|
||||
from app.modules.safety.governor import default_policy
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
|
||||
def make_mcp_tool_specs(
|
||||
server,
|
||||
tools: list[dict],
|
||||
http_client,
|
||||
meta: dict | None = None,
|
||||
transport=None,
|
||||
) -> list[ToolSpec]:
|
||||
"""把 MCP server 工具清单(list[dict])转为 ToolSpec 列表。
|
||||
|
||||
Args:
|
||||
meta: 可选业务上下文
|
||||
transport: 可选传输层实例(stdio 用,传给 executor 替代 HTTP call_tool)
|
||||
"""
|
||||
specs: list[ToolSpec] = []
|
||||
for tool in tools:
|
||||
name = tool.get("name")
|
||||
if not name:
|
||||
continue
|
||||
risk_level = tool.get("risk_level", "L2")
|
||||
input_schema = tool.get("inputSchema") or tool.get("input_schema")
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=f"mcp_{server.name}_{name}",
|
||||
description=tool.get("description", ""),
|
||||
risk_level=risk_level,
|
||||
permission_policy=default_policy(risk_level),
|
||||
executor=_make_mcp_executor(
|
||||
http_client,
|
||||
server.endpoint_url,
|
||||
name,
|
||||
server.auth_token,
|
||||
meta=meta,
|
||||
transport=transport,
|
||||
),
|
||||
input_schema=input_schema,
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _make_mcp_executor(
|
||||
http_client,
|
||||
endpoint_url,
|
||||
tool_name,
|
||||
auth_token,
|
||||
meta: dict | None = None,
|
||||
transport=None,
|
||||
):
|
||||
async def executor(input, context): # noqa: ARG001
|
||||
call_meta = dict(meta or {})
|
||||
if context:
|
||||
user_role = context.get("user_role") or "free"
|
||||
call_meta.setdefault("user_role", user_role)
|
||||
call_meta.setdefault("caller", "hunjing")
|
||||
if context.get("trace_id"):
|
||||
call_meta["trace_id"] = context["trace_id"]
|
||||
if transport is not None:
|
||||
return await transport.call_tool(tool_name, input)
|
||||
return await call_tool(
|
||||
http_client,
|
||||
endpoint_url,
|
||||
tool_name,
|
||||
input,
|
||||
auth_token,
|
||||
meta=call_meta or None,
|
||||
)
|
||||
|
||||
return executor
|
||||
@@ -0,0 +1,529 @@
|
||||
"""百工 Tool Runtime:司命评估 → 直接执行/沙箱执行 → 一炁事件回写 的编排核心。
|
||||
|
||||
调用链:
|
||||
ToolRuntime.execute / execute_stream
|
||||
→ SafetyGovernor.evaluate(deny→记 tool.failed 并抛 PermissionDenied;
|
||||
ask→抛 RequiresConfirmation 交上层确认后重试)
|
||||
→ 执行路径二选一:
|
||||
- ToolSpec.executor 非 None:构造执行上下文(含 http_client 等)
|
||||
直接调用 executor,跳过沙箱(HTTP 类工具如 fetch_url)。
|
||||
- executor 为 None:ToolSpec.build_request 构造沙箱请求(isolated_workspace 工具按
|
||||
run 隔离 workdir)→ SandboxClient.exec / exec_stream(携带司命授权上下文;
|
||||
事件经 EventStoreSink 落 EventStore)。
|
||||
→ ToolResult / 流式 ToolStreamEvent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.integrations.sandbox.audit_sink import AuditEventSink
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.bridge import EventStoreSink
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.events import (
|
||||
CompositeEventSink,
|
||||
EventSink,
|
||||
completed_event,
|
||||
failed_event,
|
||||
started_event,
|
||||
)
|
||||
from app.integrations.sandbox.exceptions import SandboxError
|
||||
from app.integrations.sandbox.models import (
|
||||
DirEntry,
|
||||
ExecResponse,
|
||||
FileReadRequest,
|
||||
FileReadResponse,
|
||||
)
|
||||
from app.modules.runtime.events import Event, EventStore
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.modules.safety.governor import SafetyDecision, SafetyGovernor
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolStreamEvent:
|
||||
"""工具流式事件:stdout/stderr 增量(base64 str,透传给前端解码)或 done(结束汇总 dict)。"""
|
||||
|
||||
kind: str # "stdout" | "stderr" | "done"
|
||||
payload: str | dict[str, Any]
|
||||
|
||||
|
||||
class ToolRuntime:
|
||||
"""百工工具运行时。"""
|
||||
|
||||
_ISOLATE_SAFE = re.compile(r"[^A-Za-z0-9_.-]")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox: SandboxClient,
|
||||
governor: SafetyGovernor,
|
||||
event_store: EventStore,
|
||||
specs: dict[str, ToolSpec] | None = None,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._sandbox = sandbox
|
||||
self._governor = governor
|
||||
self._store = event_store
|
||||
self._http_client = http_client
|
||||
# 不在启动期快照 builtin_specs()——那样删除/修改内置工具后需重启后端才生效。
|
||||
# builtin 工具每次经 _resolve_specs() 实时取(builtin_specs 是纯函数,返回新 dict)。
|
||||
# 调用方显式传入的 specs 视为"覆盖层",与 register 的工具一起叠加在 builtin 之上。
|
||||
self._custom_specs: dict[str, ToolSpec] = dict(specs) if specs else {}
|
||||
self._registered_specs: dict[str, ToolSpec] = {}
|
||||
|
||||
@property
|
||||
def http_client(self) -> httpx.AsyncClient | None:
|
||||
"""共享 HTTP 客户端(供 MCP executor 等复用,生命周期同 runtime)。"""
|
||||
return self._http_client
|
||||
|
||||
def _resolve_specs(self) -> dict[str, ToolSpec]:
|
||||
"""合并 builtin(实时)+ custom(调用方传入)+ registered(动态注册)。
|
||||
|
||||
后者覆盖前者同名,保证覆盖语义;builtin 每次现取,builtin_specs() 的任何变更
|
||||
(如新增/删除/修改内置工具)立即对当前 ToolRuntime 实例生效,无需重启。
|
||||
"""
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
|
||||
merged: dict[str, ToolSpec] = {}
|
||||
merged.update(builtin_specs())
|
||||
merged.update(self._custom_specs)
|
||||
merged.update(self._registered_specs)
|
||||
return merged
|
||||
|
||||
def register(self, spec: ToolSpec) -> None:
|
||||
"""注册或覆盖工具规格(叠加在 builtin 与调用方传入的 specs 之上)。"""
|
||||
self._registered_specs[spec.name] = spec
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""移除动态注册的工具(builtin / custom 不受影响)。"""
|
||||
self._registered_specs.pop(name, None)
|
||||
|
||||
async def reload_governor(self) -> None:
|
||||
"""从 tool_policies 表重新加载司命策略(写操作后调用,使变更即时生效)。
|
||||
|
||||
读取所有 enabled 策略构造 SafetyGovernor 覆盖当前实例。governor 的 policy
|
||||
字典作为覆盖层(见 SafetyGovernor.evaluate):命中的工具用表里的 risk_level /
|
||||
permission_policy,未命中的用 ToolSpec 自带 risk_level 推导默认策略。
|
||||
"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import governance as governance_repo
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
policies = await governance_repo.list_tool_policies(session)
|
||||
data = {
|
||||
"tools": {
|
||||
p.name: {
|
||||
"risk_level": p.risk_level,
|
||||
"permission_policy": p.permission_policy,
|
||||
}
|
||||
for p in policies
|
||||
if p.enabled
|
||||
}
|
||||
}
|
||||
self._governor = SafetyGovernor.from_policy_dict(data)
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
"""返回当前可用工具规格(用于 API 暴露工具元数据)。
|
||||
|
||||
builtin 实时合并:删除/修改 builtin_specs() 中的工具定义后无需重启即可生效。
|
||||
"""
|
||||
return list(self._resolve_specs().values())
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult:
|
||||
cancellation = (context or {}).get("cancellation")
|
||||
if cancellation is not None:
|
||||
cancellation.raise_if_cancelled()
|
||||
spec = self._resolve_specs().get(tool)
|
||||
if spec is None:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", f"未知工具: {tool}"
|
||||
)
|
||||
raise PermissionDenied(f"未知工具: {tool}")
|
||||
|
||||
decision = self._governor.evaluate(
|
||||
tool=tool, risk_level=spec.risk_level, confirmed=confirmed
|
||||
)
|
||||
if decision.decision is SafetyDecision.deny:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", decision.reason
|
||||
)
|
||||
raise PermissionDenied(decision.reason)
|
||||
if decision.decision is SafetyDecision.ask:
|
||||
# 不执行;上层确认后以 confirmed=True 重试。携带 risk_level 供前端展示。
|
||||
raise RequiresConfirmation(decision.reason, risk_level=decision.risk_level)
|
||||
|
||||
# ---- 直接执行路径(executor 工具,如 fetch_url)----
|
||||
if spec.executor is not None:
|
||||
exec_context = self._build_exec_context(context, trace_id=trace_id)
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=spec.name,
|
||||
argv=[],
|
||||
risk_level=spec.risk_level,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await spec.executor(input, exec_context)
|
||||
except Exception as exc: # noqa: BLE001 - executor 任意错误统一兜底
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=spec.name,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
error_code="executor_failed",
|
||||
error_message=str(exc),
|
||||
)
|
||||
)
|
||||
raise SandboxError(f"工具 {spec.name} 直接执行失败: {exc}") from exc
|
||||
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=spec.name,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
exit_code=result.exit_code if result.exit_code is not None else 0,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
stdout_len=len(result.stdout),
|
||||
stderr_len=len(result.stderr),
|
||||
)
|
||||
)
|
||||
# 补齐 trace_id(executor 可能未填)
|
||||
if not result.trace_id:
|
||||
result = ToolResult(
|
||||
ok=result.ok,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
trace_id=trace_id or "",
|
||||
)
|
||||
return result
|
||||
|
||||
# ---- 沙箱执行路径 ----
|
||||
request = spec.build_request(input)
|
||||
if trace_id and not request.trace_id:
|
||||
request.trace_id = trace_id
|
||||
# 文件类工具按角色隔离工作目录(同一角色跨 run 文件互通,不同角色互不可见)
|
||||
if spec.isolated_workspace:
|
||||
request.workdir = self._isolate_workdir(
|
||||
request.workdir, subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=request.trace_id or trace_id or "",
|
||||
risk_level=spec.risk_level,
|
||||
tool=spec.name,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
# 本次调用绑定带 run/character 上下文的 sink,使沙箱事件落入一炁事件存储与审计表
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
|
||||
try:
|
||||
resp: ExecResponse = await self._sandbox.exec(request, auth_ctx, event_sink=sink)
|
||||
except asyncio.CancelledError:
|
||||
# 取消 HTTP await 不代表远端子进程已停止;尽力调用沙箱 cancel,随后保持取消语义。
|
||||
if request.trace_id:
|
||||
with suppress(Exception):
|
||||
await asyncio.shield(self._sandbox.cancel(request.trace_id, auth_ctx))
|
||||
raise
|
||||
except SandboxError:
|
||||
# 沙箱错误事件已由 sink 记录(tool.failed),此处不重复写入
|
||||
raise
|
||||
|
||||
return ToolResult(
|
||||
ok=resp.ok,
|
||||
exit_code=resp.exit_code,
|
||||
stdout=resp.stdout,
|
||||
stderr=resp.stderr,
|
||||
duration_ms=resp.duration_ms,
|
||||
timeout=resp.timeout,
|
||||
truncated=resp.truncated,
|
||||
trace_id=resp.trace_id,
|
||||
)
|
||||
|
||||
async def execute_stream(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
) -> AsyncIterator[ToolStreamEvent]:
|
||||
"""流式执行:司命评估后转发沙箱 stdout/stderr 增量,末尾一个 done。
|
||||
|
||||
started/completed/failed 落一炁事件存储;stdout/stderr 增量只透传不落库。
|
||||
司命 deny 记 tool.failed 并抛 PermissionDenied;ask 抛 RequiresConfirmation
|
||||
(流式端点要求调用方先经非流式 invoke 完成 confirmation,再以 confirmed=True 调用)。
|
||||
"""
|
||||
spec = self._resolve_specs().get(tool)
|
||||
if spec is None:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", f"未知工具: {tool}"
|
||||
)
|
||||
raise PermissionDenied(f"未知工具: {tool}")
|
||||
|
||||
decision = self._governor.evaluate(
|
||||
tool=tool, risk_level=spec.risk_level, confirmed=confirmed
|
||||
)
|
||||
if decision.decision is SafetyDecision.deny:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", decision.reason
|
||||
)
|
||||
raise PermissionDenied(decision.reason)
|
||||
if decision.decision is SafetyDecision.ask:
|
||||
raise RequiresConfirmation(decision.reason, risk_level=decision.risk_level)
|
||||
|
||||
request = spec.build_request(input)
|
||||
if trace_id and not request.trace_id:
|
||||
request.trace_id = trace_id
|
||||
if spec.isolated_workspace:
|
||||
request.workdir = self._isolate_workdir(
|
||||
request.workdir, subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
|
||||
effective_trace = request.trace_id or trace_id or ""
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level=spec.risk_level,
|
||||
tool=spec.name,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=spec.name,
|
||||
argv=request.argv,
|
||||
risk_level=spec.risk_level,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
final: dict[str, Any] = {}
|
||||
try:
|
||||
async for ev in self._sandbox.exec_stream(request, auth_ctx):
|
||||
if ev.kind in ("stdout", "stderr"):
|
||||
yield ToolStreamEvent(ev.kind, ev.payload)
|
||||
elif ev.kind == "done" and isinstance(ev.payload, dict):
|
||||
final = ev.payload
|
||||
except SandboxError:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=spec.name,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
error_code="sandbox_error",
|
||||
error_message="沙箱流式执行失败",
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=spec.name,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
exit_code=final.get("exit_code"),
|
||||
duration_ms=int(final.get("duration_ms", 0)),
|
||||
timeout=bool(final.get("timeout", False)),
|
||||
truncated=bool(final.get("truncated", False)),
|
||||
stdout_len=0, # 流式不收集完整文本
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
yield ToolStreamEvent("done", final)
|
||||
|
||||
def _make_sink(self, run_id: str | None, character_id: str | None) -> EventSink:
|
||||
"""构造本次执行的事件 sink 组合:一炁流程事件 + 治理审计,互不感知。
|
||||
|
||||
EventStoreSink 落 events 表(流程,run 内 sequence),AuditEventSink 落 audit_events
|
||||
表(治理审计,含 decision / risk_level / argv 脱敏摘要)。两者经 CompositeEventSink 扇出。
|
||||
"""
|
||||
return CompositeEventSink(
|
||||
[
|
||||
EventStoreSink(self._store, run_id=run_id, character_id=character_id),
|
||||
AuditEventSink(AsyncSessionLocal, run_id=run_id, character_id=character_id),
|
||||
]
|
||||
)
|
||||
|
||||
def _isolate_workdir(
|
||||
self,
|
||||
base: str | None,
|
||||
*,
|
||||
subject_id: str | None,
|
||||
run_id: str | None,
|
||||
trace_id: str | None,
|
||||
) -> str:
|
||||
"""把工具 workspace 根目录隔离到「主体」级子目录,防跨主体信息泄漏。
|
||||
|
||||
隔离主体 subject_id:当前为 character_id(一个角色一个持久 workspace,跨会话/
|
||||
跨 run 文件互通,符合"一个角色拥有其全部文件"的语义);多用户上线后应改为真实
|
||||
用户标识(owner_scope / user_id),届时仅需调用方改传参,本函数逻辑不变。
|
||||
|
||||
run_id / trace_id 仅在 subject_id 缺失时回退(如脱离聊天流程的直接调用),
|
||||
保证既有隔离又不至于因缺主体而落到共享根目录。
|
||||
|
||||
subject_id 经 sanitize(仅保留 ``[A-Za-z0-9_.-]``)从源头杜绝路径注入;
|
||||
沙箱端另有 canonicalize + ``starts_with(base)`` 兜底。
|
||||
"""
|
||||
root = base or "workspace"
|
||||
key = subject_id or run_id or trace_id or f"adhoc_{uuid.uuid4().hex[:8]}"
|
||||
safe = self._ISOLATE_SAFE.sub("_", key).strip("._-") or "adhoc"
|
||||
return f"{root}/{safe}"
|
||||
|
||||
def _build_exec_context(
|
||||
self, context: dict[str, Any] | None, *, trace_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""构造 executor 执行上下文:合并 runtime 持有的 http_client、settings 中的密钥等。
|
||||
|
||||
调用方传入的 context(per-request 覆盖)优先于 runtime 默认值。
|
||||
"""
|
||||
exec_context: dict[str, Any] = {"trace_id": trace_id}
|
||||
if self._http_client is not None:
|
||||
exec_context["http_client"] = self._http_client
|
||||
if context:
|
||||
exec_context.update(context)
|
||||
return exec_context
|
||||
|
||||
async def read_sandbox_file(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
isolated: bool = True,
|
||||
) -> FileReadResponse:
|
||||
"""读取沙箱内文件(仅供文件预览端点调用)。
|
||||
|
||||
Args:
|
||||
path: 文件路径。
|
||||
isolated=True 时视为工作区相对路径(write_file 传入的 path),
|
||||
自动补全 ``workspace/<character_id>/`` 隔离前缀;
|
||||
isolated=False 时作为沙箱 base 下的完整相对路径。
|
||||
run_id: 关联的 run ID(可选,用于授权上下文)。
|
||||
character_id: 人物 ID(可选)。提供时自动构造隔离工作区路径。
|
||||
trace_id: 追踪 ID(可选,自动生成)。
|
||||
isolated: 是否按 isolated_workspace 规则补全路径前缀。
|
||||
|
||||
Returns:
|
||||
FileReadResponse,含 base64url-no-pad 编码的文件内容。
|
||||
"""
|
||||
effective_path = path
|
||||
if isolated:
|
||||
workdir = self._isolate_workdir(
|
||||
"workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
effective_path = f"{workdir}/{path.lstrip('/')}"
|
||||
|
||||
effective_trace = trace_id or str(uuid.uuid4())
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level="L0",
|
||||
tool="file_read",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return await self._sandbox.file_read(
|
||||
FileReadRequest(path=effective_path),
|
||||
auth_ctx,
|
||||
)
|
||||
|
||||
async def list_sandbox_dir(
|
||||
self,
|
||||
path: str = ".",
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
isolated: bool = True,
|
||||
) -> list[DirEntry]:
|
||||
"""列出沙箱工作区目录(仅供文件浏览端点调用)。
|
||||
|
||||
Args:
|
||||
path: 目录路径,默认工作区根。
|
||||
isolated: True 时自动应用 isolated_workspace 隔离前缀。
|
||||
|
||||
返回排序后的目录条目列表。
|
||||
"""
|
||||
from app.integrations.sandbox.models import FileListRequest
|
||||
|
||||
effective_path = path
|
||||
if isolated:
|
||||
workdir = self._isolate_workdir(
|
||||
"workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
effective_path = f"{workdir}/{path.lstrip('/')}"
|
||||
|
||||
effective_trace = trace_id or str(uuid.uuid4())
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level="L0",
|
||||
tool="file_list",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
result = await self._sandbox.file_list(
|
||||
FileListRequest(path=effective_path),
|
||||
auth_ctx,
|
||||
)
|
||||
return result.entries
|
||||
|
||||
async def _record_failed(
|
||||
self,
|
||||
tool: str,
|
||||
run_id: str | None,
|
||||
character_id: str | None,
|
||||
trace_id: str | None,
|
||||
error_code: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
await self._store.append(
|
||||
Event(
|
||||
type="tool.failed",
|
||||
source="tool",
|
||||
visibility="public",
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
payload={
|
||||
"tool": tool,
|
||||
"phase": "safety",
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""单个 Agent Run 可见的工具视图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
|
||||
class RunScopedToolView:
|
||||
"""保存 Run 级工具别名;实际执行始终委托共享 ToolRuntime。"""
|
||||
|
||||
def __init__(self, runtime: ToolRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
self._aliases: dict[str, tuple[str, ToolSpec]] = {}
|
||||
|
||||
def bind_alias(self, alias: str, target: str, *, description_prefix: str = "") -> bool:
|
||||
"""将已有工具作为 Run 级别名暴露;目标不存在时返回 False。"""
|
||||
spec = next((item for item in self._runtime.list_specs() if item.name == target), None)
|
||||
if spec is None:
|
||||
return False
|
||||
description = f"{description_prefix}{spec.description}" if description_prefix else spec.description
|
||||
self._aliases[alias] = (target, replace(spec, name=alias, description=description))
|
||||
return True
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return [*self._runtime.list_specs(), *(spec for _, spec in self._aliases.values())]
|
||||
|
||||
def specs_by_name(self) -> dict[str, ToolSpec]:
|
||||
return {spec.name: spec for spec in self.list_specs()}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult:
|
||||
target = self._aliases.get(tool, (tool,))[0]
|
||||
return await self._runtime.execute(
|
||||
target,
|
||||
input,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
confirmed=confirmed,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RunScopedToolView"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""HTTP 类工具的直接执行器(非沙箱路径)。
|
||||
|
||||
``fetch_url``:抓取指定 URL 的 HTML 并用 html2text 转成 markdown。
|
||||
|
||||
executor 上下文由 ``ToolRuntime._build_exec_context`` 注入,至少包含:
|
||||
- ``http_client``:长生命周期的 ``httpx.AsyncClient``(禁止裸 ``async with httpx.AsyncClient()``)。
|
||||
- ``trace_id``:本次执行的 trace_id(可空)。
|
||||
|
||||
设计约束(见 backend/CLAUDE.md):
|
||||
- 禁止在调用处裸 ``async with httpx.AsyncClient()``,必须复用注入的 client。
|
||||
- HTTP 错误统一返回 ``ToolResult(ok=False, stderr=...)``,不抛异常(让模型自决策)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import html2text
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult
|
||||
|
||||
# 默认参数
|
||||
_DEFAULT_MAX_CHARS = 8000
|
||||
# 合理的浏览器 UA,避免被部分站点拒绝
|
||||
_USER_AGENT = "Mozilla/5.0 (compatible; HunjingBot/1.0; +https://github.com/hunjing/hunjing)"
|
||||
|
||||
# 模块级 html2text 配置(避免每次调用都构造配置对象)
|
||||
_H2T = html2text.HTML2Text()
|
||||
_H2T.ignore_links = False
|
||||
_H2T.ignore_images = True
|
||||
_H2T.body_width = 0 # 不折行,交给上层截断
|
||||
|
||||
|
||||
def _get_http_client(context: dict[str, Any] | None) -> httpx.AsyncClient | None:
|
||||
"""从 context 取注入的 httpx.AsyncClient;缺失返回 None。"""
|
||||
if context is None:
|
||||
return None
|
||||
client = context.get("http_client")
|
||||
return client if isinstance(client, httpx.AsyncClient) else None
|
||||
|
||||
|
||||
def _ok_result(stdout: str, trace_id: str = "") -> ToolResult:
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=0,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
def _err_result(stderr: str, trace_id: str = "") -> ToolResult:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
exit_code=1,
|
||||
stdout="",
|
||||
stderr=stderr,
|
||||
duration_ms=0,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_url_executor(input: ToolInput, context: dict[str, Any] | None) -> ToolResult:
|
||||
"""抓取指定 URL 的网页正文并转 markdown。
|
||||
|
||||
input:
|
||||
url (str, required): 要抓取的 URL。
|
||||
max_chars (int, optional): 正文最大字符数,默认 8000。
|
||||
context:
|
||||
http_client (httpx.AsyncClient): 注入的 HTTP 客户端。
|
||||
trace_id (str): 本次执行 trace_id。
|
||||
"""
|
||||
trace_id = (context or {}).get("trace_id", "") or ""
|
||||
url = input.get("url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return _err_result("fetch_url 需要 'url' 字符串输入。", trace_id)
|
||||
|
||||
raw_max = input.get("max_chars", _DEFAULT_MAX_CHARS)
|
||||
try:
|
||||
max_chars = int(raw_max) if raw_max is not None else _DEFAULT_MAX_CHARS
|
||||
except (TypeError, ValueError):
|
||||
max_chars = _DEFAULT_MAX_CHARS
|
||||
if max_chars < 1:
|
||||
max_chars = _DEFAULT_MAX_CHARS
|
||||
|
||||
client = _get_http_client(context)
|
||||
if client is None:
|
||||
return _err_result("fetch_url 缺少注入的 http_client。", trace_id)
|
||||
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=settings.tool_web_http_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return _err_result(f"fetch_url 请求失败:{exc}", trace_id)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
return _err_result(f"fetch_url 返回 HTTP {resp.status_code}", trace_id)
|
||||
|
||||
content_type = resp.headers.get("content-type", "").lower()
|
||||
html_text = resp.text
|
||||
# 无论 content-type 如何都尝试转 markdown(部分站点 content-type 不规范)
|
||||
try:
|
||||
markdown = _H2T.handle(html_text)
|
||||
except Exception: # noqa: BLE001 - html2text 解析失败时回退原文
|
||||
markdown = html_text
|
||||
|
||||
# 规范化多余空白并截断
|
||||
cleaned = "\n".join(line.rstrip() for line in markdown.splitlines())
|
||||
cleaned = cleaned.strip()
|
||||
if len(cleaned) > max_chars:
|
||||
cleaned = f"{cleaned[:max_chars]}\n\n[...已截断,共 {len(cleaned)} 字符]"
|
||||
|
||||
if not cleaned:
|
||||
return _err_result(f"fetch_url 未抓取到正文内容(content-type={content_type})。", trace_id)
|
||||
|
||||
return _ok_result(cleaned, trace_id)
|
||||
Reference in new issue
Block a user