初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

View File
Whitespace-only changes.
+82
View File
@@ -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",
]
+303
View File
@@ -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"]
+229
View File
@@ -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"]
+840
View File
@@ -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]
+95
View File
@@ -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
],
}
+137
View File
@@ -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)
+602
View File
@@ -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()
+198
View File
@@ -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",
]
+150
View File
@@ -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
+83
View File
@@ -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)