初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,203 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.agent import (
|
||||
compose_system_messages,
|
||||
next_step_for_tool,
|
||||
step_event_payload,
|
||||
task_from_deliberative_plan,
|
||||
task_plan_payload,
|
||||
)
|
||||
from app.application.agent.skill_loader import load_skill_manifests
|
||||
from app.modules.agent import (
|
||||
AgentDefinition,
|
||||
AgentTask,
|
||||
ExecutionPolicy,
|
||||
Plan,
|
||||
PlanStep,
|
||||
ToolPolicy,
|
||||
)
|
||||
from app.modules.planner import DeliberativePlan
|
||||
from app.modules.planner import PlanStep as DeliberativePlanStep
|
||||
from app.modules.skills import parse_skill_manifest
|
||||
|
||||
|
||||
def test_agent_definition_keeps_minimal_execution_and_tool_policy() -> None:
|
||||
agent = AgentDefinition(
|
||||
name="工师",
|
||||
system_prompt="完成已授权的工程任务。",
|
||||
skill_refs=("代码审查",),
|
||||
tool_policy=ToolPolicy(("read_file", "shell")),
|
||||
execution_policy=ExecutionPolicy(max_iterations=3),
|
||||
)
|
||||
|
||||
assert agent.skill_refs == ("代码审查",)
|
||||
assert agent.tool_policy.allowed_tools == ("read_file", "shell")
|
||||
assert agent.execution_policy.max_iterations == 3
|
||||
|
||||
|
||||
def test_agent_task_keeps_plan_as_runtime_only_contract() -> None:
|
||||
task = AgentTask(
|
||||
id="task_1",
|
||||
objective="审查当前变更",
|
||||
agent_name="工师",
|
||||
plan=Plan(
|
||||
steps=(PlanStep(id="step_1", title="读取变更", tool="read_file"),),
|
||||
status="approved",
|
||||
),
|
||||
)
|
||||
|
||||
assert task.status == "queued"
|
||||
assert task.plan is not None
|
||||
assert task.plan.steps[0].status == "pending"
|
||||
|
||||
|
||||
def test_deliberative_plan_becomes_traceable_agent_task() -> None:
|
||||
task = task_from_deliberative_plan(
|
||||
DeliberativePlan(
|
||||
title="调研计划",
|
||||
steps=[
|
||||
DeliberativePlanStep(
|
||||
id=1,
|
||||
title="检索资料",
|
||||
description="读取相关资料",
|
||||
tool="fetch_url",
|
||||
risk_level="L2",
|
||||
)
|
||||
],
|
||||
assumptions=["资料可访问"],
|
||||
risks=["来源不完整"],
|
||||
),
|
||||
run_id="run_1",
|
||||
agent_name="学士",
|
||||
objective="调研主题",
|
||||
)
|
||||
|
||||
assert task.status == "running"
|
||||
assert task.plan is not None
|
||||
assert task.plan.steps[0].id == "1"
|
||||
assert task_plan_payload(task)["steps"] == [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "检索资料",
|
||||
"description": "读取相关资料",
|
||||
"tool": "fetch_url",
|
||||
"risk_level": "L2",
|
||||
"status": "pending",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tool_maps_only_to_its_next_uncompleted_plan_step() -> None:
|
||||
task = AgentTask(
|
||||
id="task_1",
|
||||
objective="执行计划",
|
||||
agent_name="工师",
|
||||
plan=Plan(
|
||||
steps=(
|
||||
PlanStep(id="1", title="读取", tool="read_file"),
|
||||
PlanStep(id="2", title="再次读取", tool="read_file"),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
first = next_step_for_tool(task, "read_file", set())
|
||||
second = next_step_for_tool(task, "read_file", {"1"})
|
||||
|
||||
assert first is not None and first.id == "1"
|
||||
assert second is not None and second.id == "2"
|
||||
assert next_step_for_tool(task, "shell", set()) is None
|
||||
assert step_event_payload(task, first, status="completed", result_summary="读取成功") == {
|
||||
"task_id": "task_1",
|
||||
"step_id": "1",
|
||||
"title": "读取",
|
||||
"tool": "read_file",
|
||||
"risk_level": "L2",
|
||||
"status": "completed",
|
||||
"result_summary": "读取成功",
|
||||
}
|
||||
|
||||
|
||||
def test_execution_policy_rejects_invalid_iteration_limit() -> None:
|
||||
with pytest.raises(ValueError, match="max_iterations"):
|
||||
ExecutionPolicy(max_iterations=0)
|
||||
|
||||
|
||||
def test_manifest_and_prompt_composer_keep_stable_order() -> None:
|
||||
skill = SimpleNamespace(
|
||||
name="代码审查",
|
||||
description="审查变更",
|
||||
trigger="审查, review",
|
||||
prompt_fragment="先理解调用路径,再报告问题。",
|
||||
risk_level="L1",
|
||||
status="enabled",
|
||||
tool_bindings_json='["read_file"]',
|
||||
)
|
||||
manifest = parse_skill_manifest(skill, ["read_file"])
|
||||
|
||||
messages = compose_system_messages(
|
||||
["全局约束"],
|
||||
AgentDefinition(name="工师", system_prompt="只执行已授权任务。"),
|
||||
[manifest],
|
||||
)
|
||||
|
||||
assert manifest.triggers == ("审查", "review")
|
||||
assert messages == [
|
||||
"全局约束",
|
||||
"【Agent:工师】\n只执行已授权任务。",
|
||||
"【代码审查·能力注入】\n先理解调用路径,再报告问题。",
|
||||
]
|
||||
|
||||
|
||||
def test_default_tools_filters_explicit_allowlist() -> None:
|
||||
from app.application.agent.tools import default_tools
|
||||
|
||||
tools = default_tools(allowed_tools=("read_file",))
|
||||
|
||||
assert [tool["function"]["name"] for tool in tools] == ["read_file"]
|
||||
|
||||
|
||||
async def test_skill_loader_unions_matches_with_explicit_references(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skills = [
|
||||
SimpleNamespace(
|
||||
name="代码审查",
|
||||
description="审查",
|
||||
trigger="审查",
|
||||
prompt_fragment="审查流程",
|
||||
risk_level="L1",
|
||||
status="enabled",
|
||||
tool_bindings_json='["read_file"]',
|
||||
),
|
||||
SimpleNamespace(
|
||||
name="网页研究",
|
||||
description="研究",
|
||||
trigger="研究",
|
||||
prompt_fragment="研究流程",
|
||||
risk_level="L2",
|
||||
status="enabled",
|
||||
tool_bindings_json='["web_search"]',
|
||||
),
|
||||
]
|
||||
|
||||
async def fake_match_active_skills(_context: str):
|
||||
return [skills[0]]
|
||||
|
||||
async def fake_get_enabled_skills_by_names(names: tuple[str, ...]):
|
||||
return [skill for skill in skills if skill.name in names]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.application.agent.skill_loader.match_active_skills",
|
||||
fake_match_active_skills,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.application.agent.skill_loader.get_enabled_skills_by_names",
|
||||
fake_get_enabled_skills_by_names,
|
||||
)
|
||||
|
||||
manifests = await load_skill_manifests("请审查", skill_refs=("网页研究",))
|
||||
|
||||
assert [manifest.name for manifest in manifests] == ["代码审查", "网页研究"]
|
||||
assert manifests[0].allowed_tools == ("read_file",)
|
||||
@@ -0,0 +1,235 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent import AgentContext, AgentRunContext, ExtensionRegistry
|
||||
from app.application.agent.python_skills import (
|
||||
PythonSkillCatalog,
|
||||
PythonSkillDefinition,
|
||||
activate_python_skills,
|
||||
discover_python_skills,
|
||||
)
|
||||
|
||||
|
||||
def _write_skill(package: Path, module: str, *, name: str, trigger: str) -> None:
|
||||
(package / f"{module}.py").write_text(
|
||||
f"""
|
||||
from app.application.agent.python_skills import PythonSkillDefinition
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
|
||||
async def transform(context, run):
|
||||
return type(context)(
|
||||
messages=(*context.messages, HumanMessage(content={name!r})),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
def setup(registry):
|
||||
registry.add_context_transformer(transform)
|
||||
|
||||
SKILL = PythonSkillDefinition(
|
||||
name={name!r},
|
||||
description={name!r},
|
||||
triggers=({trigger!r},),
|
||||
instructions={name!r},
|
||||
setup=setup,
|
||||
)
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def test_catalog_discovers_without_activating_and_matches_per_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package = tmp_path / "example_skills"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
_write_skill(package, "b_skill", name="乙", trigger="研究")
|
||||
_write_skill(package, "a_skill", name="甲", trigger="审查")
|
||||
(package / "_ignored.py").write_text(
|
||||
"raise AssertionError('私有模块不应装载')",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
catalog = discover_python_skills(("example_skills", "example_skills"))
|
||||
registry = ExtensionRegistry()
|
||||
unchanged = await registry.transform_context(
|
||||
AgentContext(messages=(HumanMessage(content="开始"),), tools=()),
|
||||
AgentRunContext(),
|
||||
)
|
||||
matched = catalog.match("请做研究", skill_refs=("甲",))
|
||||
activation = await activate_python_skills(registry, matched)
|
||||
transformed = await registry.transform_context(
|
||||
AgentContext(messages=(HumanMessage(content="开始"),), tools=()),
|
||||
AgentRunContext(),
|
||||
)
|
||||
await activation.close()
|
||||
|
||||
assert [skill.name for skill in catalog.skills] == ["甲", "乙"]
|
||||
assert [message.content for message in unchanged.messages] == ["开始"]
|
||||
assert [skill.name for skill in matched] == ["甲", "乙"]
|
||||
assert [message.content for message in transformed.messages] == ["开始", "甲", "乙"]
|
||||
|
||||
|
||||
async def test_activation_cleanup_is_run_scoped() -> None:
|
||||
cleaned: list[str] = []
|
||||
|
||||
def setup(registry: ExtensionRegistry):
|
||||
async def transform(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
(*context.messages, HumanMessage(content="active")),
|
||||
context.tools,
|
||||
context.model_hint,
|
||||
)
|
||||
|
||||
registry.add_context_transformer(transform)
|
||||
return lambda: cleaned.append("closed")
|
||||
|
||||
skill = PythonSkillDefinition("guard", "", ("guard",), "", setup)
|
||||
catalog = PythonSkillCatalog((skill,))
|
||||
first_registry = ExtensionRegistry()
|
||||
second_registry = ExtensionRegistry()
|
||||
|
||||
activation = await activate_python_skills(first_registry, catalog.match("guard"))
|
||||
first = await first_registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
second = await second_registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
await activation.close()
|
||||
|
||||
assert [message.content for message in first.messages] == ["active"]
|
||||
assert second.messages == ()
|
||||
assert cleaned == ["closed"]
|
||||
|
||||
|
||||
def test_catalog_supports_custom_matcher_with_run_metadata() -> None:
|
||||
skill = PythonSkillDefinition(
|
||||
"metadata_skill",
|
||||
"",
|
||||
(),
|
||||
"",
|
||||
lambda _registry: None,
|
||||
match=lambda text, metadata: text == "执行" and metadata.get("channel") == "voice",
|
||||
)
|
||||
catalog = PythonSkillCatalog((skill,))
|
||||
|
||||
assert catalog.match("执行", metadata={"channel": "text"}) == ()
|
||||
assert catalog.match("执行", metadata={"channel": "voice"}) == (skill,)
|
||||
|
||||
|
||||
def test_catalog_rejects_duplicate_names() -> None:
|
||||
skill = PythonSkillDefinition("same", "", (), "", lambda _registry: None)
|
||||
|
||||
with pytest.raises(ValueError, match="名称重复"):
|
||||
PythonSkillCatalog((skill, skill))
|
||||
|
||||
|
||||
def test_discovery_rejects_module_without_definition(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package = tmp_path / "invalid_skills"
|
||||
package.mkdir()
|
||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||
(package / "broken.py").write_text("SKILL_NAME = '坏技能'", encoding="utf-8")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(TypeError, match="缺少 SKILL 定义"):
|
||||
discover_python_skills(("invalid_skills",))
|
||||
|
||||
|
||||
async def test_budget_guard_skill_stops_when_usage_exceeds_threshold() -> None:
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.application.agent.extensions import TurnSnapshot
|
||||
from app.skills.budget_guard import _BUDGET_TOTAL_TOKENS, SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
|
||||
over_budget = TurnSnapshot(
|
||||
iteration=4,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
usage=TokenUsage(total_tokens=_BUDGET_TOTAL_TOKENS + 1),
|
||||
)
|
||||
within_budget = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
usage=TokenUsage(total_tokens=max(_BUDGET_TOTAL_TOKENS - 1, 0)),
|
||||
)
|
||||
|
||||
assert await registry.should_stop(over_budget, AgentRunContext()) is True
|
||||
assert await registry.should_stop(within_budget, AgentRunContext()) is False
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_context_budget_folds_long_history() -> None:
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent.extensions import AgentContext
|
||||
from app.skills.context_budget import SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
messages = tuple(HumanMessage(content="x" * 1000) for _ in range(20))
|
||||
ctx = AgentContext(messages=messages, tools=())
|
||||
|
||||
trimmed = await registry.transform_context(ctx, AgentRunContext())
|
||||
|
||||
assert len(trimmed.messages) < len(messages)
|
||||
assert "已省略" in trimmed.messages[0].content
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_arg_guard_rejects_oversized_input() -> None:
|
||||
from app.application.agent.extensions import ToolCallContext
|
||||
from app.skills.arg_guard import _MAX_ARG_CHARS, SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
oversized = ToolCallContext(
|
||||
"call_1", "write_file", {"content": "x" * (_MAX_ARG_CHARS + 10)}, ()
|
||||
)
|
||||
normal = ToolCallContext("call_2", "read_file", {"path": "/a"}, ())
|
||||
|
||||
big = await registry.before_tool_call(oversized, AgentRunContext())
|
||||
small = await registry.before_tool_call(normal, AgentRunContext())
|
||||
|
||||
assert big.allowed is False
|
||||
assert big.reason is not None
|
||||
assert small.allowed is True
|
||||
await activation.close()
|
||||
|
||||
|
||||
async def test_run_metrics_listens_through_settled() -> None:
|
||||
from app.application.agent.extensions import AgentEvent
|
||||
from app.skills.run_metrics import SKILL
|
||||
|
||||
registry = ExtensionRegistry()
|
||||
activation = await activate_python_skills(registry, (SKILL,))
|
||||
run = AgentRunContext(run_id="run_metrics_test")
|
||||
|
||||
await registry.publish(AgentEvent("agent.started"), run)
|
||||
await registry.publish(AgentEvent("agent.turn.started", {"iteration": 1}), run)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.started", {"tool_call_id": "c1", "tool_name": "search"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.completed", {"tool_call_id": "c1", "status": "success"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(
|
||||
AgentEvent("tool.completed", {"tool_call_id": "c2", "status": "denied"}),
|
||||
run,
|
||||
)
|
||||
await registry.publish(AgentEvent("agent.turn.started", {"iteration": 2}), run)
|
||||
await registry.publish(AgentEvent("agent.settled", {"status": "completed"}), run)
|
||||
await activation.close()
|
||||
# 指标经 loguru 输出;此处验证含工具级事件的事件链被完整消费且 settled 不抛错。
|
||||
@@ -0,0 +1,405 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_provider.types.chat import TokenUsage
|
||||
from app.ai_provider.types.messages import HumanMessage
|
||||
from app.application.agent import (
|
||||
AfterToolCallDecision,
|
||||
Agent,
|
||||
AgentConfig,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentRunContext,
|
||||
AgentTurnResult,
|
||||
BeforeToolCallDecision,
|
||||
ExtensionRegistry,
|
||||
PendingMessageQueue,
|
||||
QueueMode,
|
||||
ToolCallContext,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
def test_pending_message_queue_supports_both_modes() -> None:
|
||||
queue = PendingMessageQueue(QueueMode.one_at_a_time)
|
||||
queue.put(HumanMessage(content="一"))
|
||||
queue.put(HumanMessage(content="二"))
|
||||
|
||||
assert [message.content for message in queue.drain()] == ["一"]
|
||||
assert [message.content for message in queue.drain()] == ["二"]
|
||||
|
||||
queue.mode = QueueMode.all
|
||||
queue.put(HumanMessage(content="三"))
|
||||
queue.put(HumanMessage(content="四"))
|
||||
assert [message.content for message in queue.drain()] == ["三", "四"]
|
||||
|
||||
|
||||
async def test_extension_registry_orders_context_and_tool_hooks() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext(run_id="run_1")
|
||||
|
||||
async def first(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
messages=(*context.messages, HumanMessage(content="first")),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
async def second(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext(
|
||||
messages=(*context.messages, HumanMessage(content="second")),
|
||||
tools=context.tools,
|
||||
model_hint=context.model_hint,
|
||||
)
|
||||
|
||||
async def rewrite(
|
||||
_call: ToolCallContext, _run: AgentRunContext
|
||||
) -> BeforeToolCallDecision:
|
||||
return BeforeToolCallDecision(input={"value": 2})
|
||||
|
||||
async def terminate(
|
||||
_call: ToolCallContext,
|
||||
result: ToolResult,
|
||||
_run: AgentRunContext,
|
||||
) -> AfterToolCallDecision:
|
||||
return AfterToolCallDecision(result=result, terminate=True)
|
||||
|
||||
registry.add_context_transformer(first)
|
||||
registry.add_context_transformer(second)
|
||||
registry.add_before_tool_hook(rewrite)
|
||||
registry.add_after_tool_hook(terminate)
|
||||
|
||||
transformed = await registry.transform_context(AgentContext((), ()), run)
|
||||
assert [message.content for message in transformed.messages] == ["first", "second"]
|
||||
|
||||
call = ToolCallContext("call_1", "tool", {"value": 1}, ())
|
||||
before = await registry.before_tool_call(call, run)
|
||||
assert before.input == {"value": 2}
|
||||
|
||||
result = ToolResult(True, 0, "ok", "", 1, False, False, "trace_1")
|
||||
after = await registry.after_tool_call(call, result, run)
|
||||
assert after.result is result
|
||||
assert after.terminate is True
|
||||
|
||||
|
||||
async def test_extension_registry_extend_preserves_registration_order() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
skill_registry = ExtensionRegistry()
|
||||
|
||||
async def base(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext((*context.messages, HumanMessage(content="base")), context.tools)
|
||||
|
||||
async def skill(context: AgentContext, _run: AgentRunContext) -> AgentContext:
|
||||
return AgentContext((*context.messages, HumanMessage(content="skill")), context.tools)
|
||||
|
||||
registry.add_context_transformer(base)
|
||||
skill_registry.add_context_transformer(skill)
|
||||
registry.extend(skill_registry)
|
||||
|
||||
transformed = await registry.transform_context(AgentContext((), ()), AgentRunContext())
|
||||
|
||||
assert [message.content for message in transformed.messages] == ["base", "skill"]
|
||||
|
||||
|
||||
async def test_stateful_agent_consumes_follow_up_and_publishes_events() -> None:
|
||||
calls: list[list[str]] = []
|
||||
events: list[str] = []
|
||||
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
calls.append([message.content for message in messages])
|
||||
messages.append(HumanMessage(content=f"assistant-{len(calls)}"))
|
||||
return AgentTurnResult(
|
||||
content=f"done-{len(calls)}",
|
||||
reasoning="",
|
||||
iterations=1,
|
||||
tool_history=[{"name": f"tool-{len(calls)}"}],
|
||||
usage=TokenUsage(total_tokens=10),
|
||||
)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append(event.type)
|
||||
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock(), max_iterations=2),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue_follow_up(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("再做一步") is True
|
||||
|
||||
extensions.add_event_listener(enqueue_follow_up)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert result.content == "done-2"
|
||||
assert calls == [["开始"], ["开始", "assistant-1", "再做一步"]]
|
||||
assert result.iterations == 2
|
||||
assert result.usage.total_tokens == 20
|
||||
assert [item["name"] for item in result.tool_history] == ["tool-1", "tool-2"]
|
||||
assert events == ["agent.started", "agent.ended", "agent.settled"]
|
||||
assert agent.state.status == "completed"
|
||||
assert agent.state.streaming_message == "done-2"
|
||||
|
||||
|
||||
async def test_stateful_agent_does_not_continue_after_runner_truncates() -> None:
|
||||
calls = 0
|
||||
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
messages.append(HumanMessage(content="assistant"))
|
||||
return AgentTurnResult(content="partial", reasoning="", iterations=1, truncated=True)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, max_iterations=3),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("稍后处理") is True
|
||||
|
||||
extensions.add_event_listener(enqueue)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert calls == 1
|
||||
assert result.truncated is True
|
||||
assert [message.content for message in agent.follow_ups.drain()] == ["稍后处理"]
|
||||
|
||||
|
||||
async def test_follow_up_is_preserved_when_iteration_budget_is_exhausted() -> None:
|
||||
async def runner(*, messages: list[Any], **_kwargs: object) -> AgentTurnResult:
|
||||
messages.append(HumanMessage(content="assistant"))
|
||||
return AgentTurnResult(content="done", reasoning="", iterations=1)
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, max_iterations=1),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def enqueue(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
if event.type == "agent.started":
|
||||
assert agent.follow_up("稍后处理") is True
|
||||
|
||||
extensions.add_event_listener(enqueue)
|
||||
result = await agent.prompt("开始")
|
||||
|
||||
assert result.truncated is True
|
||||
assert bool(agent.follow_ups) is True
|
||||
assert [message.content for message in agent.follow_ups.drain()] == ["稍后处理"]
|
||||
|
||||
|
||||
async def test_started_listener_failure_restores_idle_state() -> None:
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def fail(_event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
raise RuntimeError("event store unavailable")
|
||||
|
||||
extensions.add_event_listener(fail)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock()),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="event store unavailable"):
|
||||
await agent.prompt("开始")
|
||||
await agent.wait_for_idle()
|
||||
assert agent.state.status == "failed"
|
||||
|
||||
|
||||
async def test_stateful_agent_abort_cancels_active_run() -> None:
|
||||
started = asyncio.Event()
|
||||
events: list[str] = []
|
||||
|
||||
async def runner(**_kwargs: object) -> AgentTurnResult:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append(event.type)
|
||||
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=MagicMock()),
|
||||
extensions=extensions,
|
||||
runner=runner,
|
||||
)
|
||||
task = asyncio.create_task(agent.prompt("开始"))
|
||||
await started.wait()
|
||||
|
||||
assert await agent.abort() is True
|
||||
assert agent.run_context.cancellation.cancelled is True
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await agent.wait_for_idle()
|
||||
assert agent.state.status == "cancelled"
|
||||
assert events == ["agent.started", "agent.cancelled", "agent.settled"]
|
||||
|
||||
|
||||
async def test_agent_replaces_cancelled_token_on_next_run() -> None:
|
||||
async def runner(**_kwargs: object) -> AgentTurnResult:
|
||||
return AgentTurnResult(content="done", reasoning="", iterations=1)
|
||||
|
||||
agent = Agent(AgentConfig(tools=(), tool_runtime=None), runner=runner)
|
||||
agent.run_context.cancellation.cancel()
|
||||
|
||||
await agent.prompt("重新开始")
|
||||
|
||||
assert agent.run_context.cancellation.cancelled is False
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_short_circuits_on_first_policy() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext(run_id="run_1")
|
||||
turn = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="done",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
async def keep_going(snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("keep_going")
|
||||
assert snapshot is turn
|
||||
return False
|
||||
|
||||
async def stop_now(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("stop_now")
|
||||
return True
|
||||
|
||||
async def never_called(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
calls.append("never_called")
|
||||
return False
|
||||
|
||||
registry.add_turn_policy(keep_going)
|
||||
registry.add_turn_policy(stop_now)
|
||||
registry.add_turn_policy(never_called)
|
||||
|
||||
assert await registry.should_stop(turn, run) is True
|
||||
assert calls == ["keep_going", "stop_now"]
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_false_when_all_policies_decline() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
turn = TurnSnapshot(
|
||||
iteration=2,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=({"name": "tool", "args": "{}"},),
|
||||
tool_results=(),
|
||||
)
|
||||
|
||||
async def decline(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
return False
|
||||
|
||||
registry.add_turn_policy(decline)
|
||||
registry.add_turn_policy(decline)
|
||||
|
||||
assert await registry.should_stop(turn, AgentRunContext()) is False
|
||||
|
||||
|
||||
async def test_extension_registry_should_stop_respects_cancellation() -> None:
|
||||
registry = ExtensionRegistry()
|
||||
run = AgentRunContext()
|
||||
run.cancellation.cancel()
|
||||
turn = TurnSnapshot(
|
||||
iteration=1,
|
||||
content="",
|
||||
reasoning="",
|
||||
tool_calls=(),
|
||||
tool_results=(),
|
||||
)
|
||||
|
||||
async def policy(_snapshot: TurnSnapshot, _run: AgentRunContext) -> bool:
|
||||
return False
|
||||
|
||||
# raise_if_cancelled 在 policy 遍历循环内,故至少注册一个 policy 才会触发;
|
||||
# 这与 transform_context / before_tool_call / after_tool_call 一致:空注册时
|
||||
# 方法无 await 点,不额外检查取消信号。
|
||||
registry.add_turn_policy(policy)
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await registry.should_stop(turn, run)
|
||||
|
||||
|
||||
async def test_on_tool_event_publishes_tool_lifecycle_to_extensions() -> None:
|
||||
"""_on_tool_event 既转发 SSE,又把同一事件发布进扩展事件流。"""
|
||||
events: list[tuple[str, dict[str, Any]]] = []
|
||||
sse_events: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
async def on_tool_event(event_type: str, payload: dict[str, Any]) -> None:
|
||||
sse_events.append((event_type, payload))
|
||||
|
||||
async def listen(event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
events.append((event.type, dict(event.payload)))
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
extensions.add_event_listener(listen)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, on_tool_event=on_tool_event),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
await agent._on_tool_event(
|
||||
"tool.started", {"tool_call_id": "c1", "tool_name": "search"}
|
||||
)
|
||||
await agent._on_tool_event(
|
||||
"tool.completed", {"tool_call_id": "c1", "status": "success"}
|
||||
)
|
||||
await agent._on_tool_event(
|
||||
"tool.completed", {"tool_call_id": "c2", "status": "denied"}
|
||||
)
|
||||
|
||||
assert [item[0] for item in sse_events] == [
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.completed",
|
||||
]
|
||||
assert [item[0] for item in events] == [
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"tool.completed",
|
||||
]
|
||||
assert events[1][1]["status"] == "success"
|
||||
# pending 状态维护未被桥接改动破坏
|
||||
assert "c1" not in agent.state.pending_tool_calls
|
||||
|
||||
|
||||
async def test_on_tool_event_swallows_listener_failure() -> None:
|
||||
"""扩展 listener 抛异常不应阻断 SSE 转发或工具事件主流程。"""
|
||||
sse: list[str] = []
|
||||
|
||||
async def on_tool_event(event_type: str, _payload: dict[str, Any]) -> None:
|
||||
sse.append(event_type)
|
||||
|
||||
async def boom(_event: AgentEvent, _run: AgentRunContext) -> None:
|
||||
raise RuntimeError("listener boom")
|
||||
|
||||
extensions = ExtensionRegistry()
|
||||
extensions.add_event_listener(boom)
|
||||
agent = Agent(
|
||||
AgentConfig(tools=(), tool_runtime=None, on_tool_event=on_tool_event),
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
# 不抛:异常被吞,SSE 仍照常转发
|
||||
await agent._on_tool_event("tool.started", {"tool_call_id": "c1"})
|
||||
assert sse == ["tool.started"]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""道体注入与完整性校验测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
from app.modules.runtime.integrity import validate_daoti_integrity
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_system_prompt_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令"))
|
||||
assert "特殊系统指令" in msgs
|
||||
|
||||
|
||||
def test_mission_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(mission="守护用户"))
|
||||
assert any("守护用户" in m for m in msgs)
|
||||
|
||||
|
||||
def test_safety_guidelines_precede_safety_constraints() -> None:
|
||||
c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"])
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束")
|
||||
|
||||
|
||||
def test_integrity_pass_when_consistent() -> None:
|
||||
c = _contract(mission="M", safety_guidelines="S")
|
||||
validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过
|
||||
|
||||
|
||||
def test_integrity_raises_on_mission_tamper() -> None:
|
||||
c = _contract(mission="M")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, mission="被篡改")
|
||||
|
||||
|
||||
def test_integrity_raises_on_safety_tamper() -> None:
|
||||
c = _contract(safety_guidelines="S")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, safety_guidelines="被篡改")
|
||||
|
||||
|
||||
def test_integrity_skips_none_fields() -> None:
|
||||
# 道体字段未设(None)时跳过校验
|
||||
validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。
|
||||
|
||||
验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置
|
||||
(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**overrides) -> RuntimeContract:
|
||||
base = {
|
||||
"character_id": "c1",
|
||||
"session_id": "s1",
|
||||
"run_id": "r1",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(overrides)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_emotion_state_injected_between_personality_and_memory() -> None:
|
||||
contract = _contract(
|
||||
personality_summary="人格X",
|
||||
emotion_state="[当前情绪] 感动(强度 70%)",
|
||||
memory_context="记忆Y",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m)
|
||||
emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m)
|
||||
memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m)
|
||||
assert personality_idx < emotion_idx < memory_idx
|
||||
|
||||
|
||||
def test_emotion_state_omitted_when_none() -> None:
|
||||
contract = _contract(personality_summary="人格X", memory_context="记忆Y")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert all("当前情绪" not in m for m in messages)
|
||||
|
||||
|
||||
def test_emotion_state_included_without_memory() -> None:
|
||||
"""emotion_state 独立于 memory_context 存在。"""
|
||||
contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert any("关切" in m for m in messages)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""世谱注入位置测试:before/after 相对 personality_summary。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_lorebook_before_precedes_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_before="世界设定前")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X")
|
||||
|
||||
|
||||
def test_lorebook_after_follows_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_after="世界设定后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后")
|
||||
|
||||
|
||||
def test_no_lorebook_when_none() -> None:
|
||||
msgs = _contract_system_messages(_contract())
|
||||
assert not any("世界设定" in m for m in msgs)
|
||||
|
||||
|
||||
def test_lorebook_without_personality_still_injects() -> None:
|
||||
c = _contract(lorebook_before="前", lorebook_after="后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert "前" in msgs and "后" in msgs
|
||||
@@ -0,0 +1,8 @@
|
||||
"""测试 send_message 中的目标自动注入(集成验证)。"""
|
||||
|
||||
|
||||
def test_auto_persist_session_goal_is_callable():
|
||||
"""验证 auto_persist_session_goal 函数可导入且可调用。"""
|
||||
from app.services.goal_persist import auto_persist_session_goal
|
||||
|
||||
assert callable(auto_persist_session_goal)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""洛书 Deliberative 规划器测试。"""
|
||||
|
||||
from app.application.planning import (
|
||||
DeliberativePlan,
|
||||
PlanStep,
|
||||
build_planning_prompt,
|
||||
format_plan_for_prompt,
|
||||
parse_plan,
|
||||
should_plan,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_plan_full() -> None:
|
||||
"""完整 JSON 计划解析。"""
|
||||
raw = """```json
|
||||
{
|
||||
"title": "调研项目X",
|
||||
"assumptions": ["需要模型网关RAG能力"],
|
||||
"risks": ["外部API可能超时"],
|
||||
"steps": [
|
||||
{"id": 1, "title": "搜索文档", "description": "搜索关于项目X的文档",
|
||||
"tool": "web_search", "risk_level": "L2"},
|
||||
{"id": 2, "title": "阅读结果", "description": "阅读搜索结果",
|
||||
"tool": "read_file", "risk_level": "L1"},
|
||||
{"id": 3, "title": "汇总", "description": "汇总分析",
|
||||
"tool": null, "risk_level": "L1"}
|
||||
]
|
||||
}
|
||||
```"""
|
||||
plan = parse_plan(raw)
|
||||
assert plan is not None
|
||||
assert plan.title == "调研项目X"
|
||||
assert len(plan.assumptions) == 1
|
||||
assert len(plan.risks) == 1
|
||||
assert len(plan.steps) == 3
|
||||
assert plan.steps[0].tool == "web_search"
|
||||
assert plan.steps[2].tool is None
|
||||
assert plan.steps[0].risk_level == "L2"
|
||||
|
||||
|
||||
def test_parse_plan_no_fence() -> None:
|
||||
"""无 ``` 围栏的 JSON 也能解析。"""
|
||||
raw = '{"title": "简单计划", "steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
||||
plan = parse_plan(raw)
|
||||
assert plan is not None
|
||||
assert plan.title == "简单计划"
|
||||
assert len(plan.steps) == 1
|
||||
|
||||
|
||||
def test_parse_plan_missing_title() -> None:
|
||||
"""缺少标题 → None。"""
|
||||
raw = '{"steps": [{"id": 1, "title": "执行", "description": ""}]}'
|
||||
assert parse_plan(raw) is None
|
||||
|
||||
|
||||
def test_parse_plan_empty_steps() -> None:
|
||||
"""空步骤列表 → None。"""
|
||||
raw = '{"title": "空计划", "steps": []}'
|
||||
assert parse_plan(raw) is None
|
||||
|
||||
|
||||
def test_parse_plan_invalid_json() -> None:
|
||||
"""非法 JSON → None。"""
|
||||
assert parse_plan("根本不是JSON") is None
|
||||
|
||||
|
||||
def test_parse_plan_not_dict() -> None:
|
||||
"""返回非对象 → None。"""
|
||||
assert parse_plan("[1, 2, 3]") is None
|
||||
|
||||
|
||||
def test_should_plan_triggers() -> None:
|
||||
"""包含关键词的请求应触发规划。"""
|
||||
triggers = [
|
||||
"搜索一下项目X的背景",
|
||||
"帮我调研一下最新技术",
|
||||
"分析这个方案的优缺点",
|
||||
"比较A和B的差异",
|
||||
"研究一下这个问题",
|
||||
"search for relevant docs",
|
||||
"research the market",
|
||||
]
|
||||
for t in triggers:
|
||||
assert should_plan(t), f"should plan: {t}"
|
||||
|
||||
|
||||
def test_should_plan_no_triggers() -> None:
|
||||
"""简单请求不应触发。"""
|
||||
no_triggers = [
|
||||
"你好",
|
||||
"再见",
|
||||
"今天天气怎么样",
|
||||
"帮我写首诗",
|
||||
]
|
||||
for t in no_triggers:
|
||||
assert not should_plan(t), f"should not plan: {t}"
|
||||
|
||||
|
||||
def test_build_planning_prompt() -> None:
|
||||
"""规划 prompt 包含必要结构。"""
|
||||
prompt = build_planning_prompt("搜索资料", memory_context="用户关心项目X")
|
||||
assert "NO_PLAN_NEEDED" in prompt
|
||||
assert "搜索资料" in prompt
|
||||
assert "用户关心项目X" in prompt
|
||||
|
||||
|
||||
def test_format_plan_for_prompt() -> None:
|
||||
"""计划格式化后包含步骤和元信息。"""
|
||||
plan = DeliberativePlan(
|
||||
title="测试计划",
|
||||
steps=[
|
||||
PlanStep(id=1, title="第一步", description="做A", tool="tool1", risk_level="L2"),
|
||||
PlanStep(id=2, title="第二步", description="做B", risk_level="L1"),
|
||||
],
|
||||
assumptions=["假设1"],
|
||||
risks=["风险1"],
|
||||
)
|
||||
text = format_plan_for_prompt(plan)
|
||||
assert "测试计划" in text
|
||||
assert "第一步" in text
|
||||
assert "tool1" in text
|
||||
assert "第二步" in text
|
||||
assert "假设1" in text
|
||||
assert "风险1" in text
|
||||
|
||||
|
||||
def test_format_plan_empty_assumptions_and_risks() -> None:
|
||||
"""无假设/风险时格式化正常。"""
|
||||
plan = DeliberativePlan(
|
||||
title="简单",
|
||||
steps=[PlanStep(id=1, title="做", description="")],
|
||||
)
|
||||
text = format_plan_for_prompt(plan)
|
||||
assert "简单" in text
|
||||
assert "做" in text
|
||||
@@ -0,0 +1,64 @@
|
||||
"""大周天循环骨架测试(触发 + 阶段化执行)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_runs_when_threshold_met() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
assert cycle is not None
|
||||
assert cycle.status == "completed"
|
||||
assert cycle.memory_count_at_trigger >= 20
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_skips_below_threshold() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
# 默认人物 0 记忆,低于阈值
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_respects_min_interval() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
first = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert first is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
# 间隔窗口内再次触发 → 跳过
|
||||
second = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert second is None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""大周天内驱力生成测试(_phase_evaluation 子步骤 _generate_drive)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import drive as drive_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_DRIVE_RESPONSE = (
|
||||
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
|
||||
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
|
||||
'"related_entities":["项目X"],'
|
||||
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
|
||||
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L4"}]}]'
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_drive_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _DRIVE_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_generates_curiosity_and_subgoal(
|
||||
mock_drive_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
curiosities, c_total = await drive_repo.list_curiosities(session, character.id)
|
||||
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
|
||||
|
||||
assert c_total == 1
|
||||
assert curiosities[0].question == "用户为何频繁提到项目X"
|
||||
assert curiosities[0].source == "memory_gap"
|
||||
assert curiosities[0].cycle_id == cycle_id
|
||||
assert s_total == 1
|
||||
assert subgoals[0].status == "candidate"
|
||||
assert subgoals[0].curiosity_question_id == curiosities[0].id
|
||||
assert subgoals[0].title == "询问项目X目标"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_auto_approves_low_risk_subgoal(
|
||||
mock_drive_chat, # noqa: ARG001
|
||||
) -> None:
|
||||
"""低风险次生目标(L0-L2)应在大周天中自动通过并生成 Goal。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
# 使用低风险的子目标响应
|
||||
_low_risk_response = (
|
||||
'[{"question":"还有哪些未知领域","source":"world_uncertainty",'
|
||||
'"expected_value":0.6,"urgency":0.4,"risk_level":"L1",'
|
||||
'"subgoals":[{"title":"探索未知领域","description":"自动探索",'
|
||||
'"expected_information_gain":0.5,"interruption_cost":0.2,"risk_level":"L1"}]}]'
|
||||
)
|
||||
|
||||
async def fake_low_risk(*_args, **_kwargs):
|
||||
return _low_risk_response
|
||||
|
||||
import app.application.big_cycle as bc
|
||||
|
||||
original = bc._call_model
|
||||
bc._call_model = fake_low_risk
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
bc._call_model = original
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
|
||||
assert s_total == 1
|
||||
assert subgoals[0].status in ("approved", "auto_execute_pending"), (
|
||||
f"预期 approved 或 auto_execute_pending,实际={subgoals[0].status}"
|
||||
)
|
||||
|
||||
# 验证自动生成了 Goal
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Goal).where(Goal.character_id == character.id)
|
||||
)
|
||||
goals = result.scalars().all()
|
||||
matching = [g for g in goals if g.title == "探索未知领域"]
|
||||
assert len(matching) == 1
|
||||
assert matching[0].level == "task"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""大周天候选生成 + 半自动应用调度测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.core.config import settings
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import big_cycle as big_cycle_repo
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_MIXED_RESPONSE = (
|
||||
"["
|
||||
'{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"身份演化","proposal":"调整身份表达",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},'
|
||||
'"confidence":0.7,"benefit_score":0.6,"risk_score":0.4,'
|
||||
'"auto_apply_allowed":false,"evidence":[0]},'
|
||||
'{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"确认假设","proposal":"技术问题先确认前提",'
|
||||
'"delta":{"title":"确认假设","content":"先确认前提再作答"},'
|
||||
'"confidence":0.8,"benefit_score":0.7,"risk_score":0.2,'
|
||||
'"auto_apply_allowed":true,"evidence":[0]}'
|
||||
"]"
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_evolution_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _MIXED_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_generates_candidates_and_auto_applies(
|
||||
mock_evolution_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
|
||||
candidates, total = await evolution_repo.list_candidates(session, character.id)
|
||||
|
||||
assert cycle is not None
|
||||
assert cycle.status == "completed"
|
||||
assert cycle.evaluation_snapshot_json is not None
|
||||
assert total == 2
|
||||
|
||||
personality = next(c for c in candidates if c.type == "personality")
|
||||
assert personality.status == "pending" # L4 auto_apply_allowed=false
|
||||
assert personality.level == "L4"
|
||||
assert personality.source == "big_cycle"
|
||||
assert personality.cycle_id == cycle_id
|
||||
|
||||
procedural = next(c for c in candidates if c.type == "procedural")
|
||||
assert procedural.status == "applied" # L2 auto_apply_allowed=true
|
||||
assert procedural.resulting_target_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_apply_degrades_when_applier_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
response = (
|
||||
'[{"type":"tool_strategy","level":"L2","scope":"task_scope",'
|
||||
'"title":"工具策略","proposal":"偏好shell",'
|
||||
'"delta":{},"auto_apply_allowed":true}]'
|
||||
)
|
||||
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
candidates, _ = await evolution_repo.list_candidates(session, character.id)
|
||||
assert len(candidates) == 1
|
||||
cand = candidates[0]
|
||||
assert cand.status == "pending" # applier 缺失,降级不自动应用
|
||||
assert cand.auto_apply_allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_uses_configured_model_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
result = type("FakeResponse", (), {"content": "[]"})()
|
||||
|
||||
class _FakeManager:
|
||||
def to_message_list(self, *_args: object, **_kwargs: object) -> list:
|
||||
return []
|
||||
|
||||
async def chat(self, messages: object, model_hint: str | None = None) -> object: # noqa: ARG002
|
||||
captured["model_hint"] = model_hint
|
||||
return result
|
||||
|
||||
def fake_get_provider_manager() -> _FakeManager:
|
||||
return _FakeManager()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.application.big_cycle._get_provider_manager", fake_get_provider_manager
|
||||
)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
assert captured["model_hint"] == settings.big_cycle_model
|
||||
@@ -0,0 +1,76 @@
|
||||
"""大周天记忆/世界抽取生成测试(_phase_memory 阶段)。
|
||||
|
||||
高置信度(≥0.7)低敏感度记忆候选自动落地到 memories 表;
|
||||
世界实体候选(confidence=0.6)低于阈值仍留 pending。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import world as world_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_EXTRACTION_RESPONSE = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
|
||||
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
|
||||
'"state_type":"inference","confidence":0.6}]}'
|
||||
)
|
||||
|
||||
|
||||
async def _seed_memories(character_id: str, count: int) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
for i in range(count):
|
||||
session.add(
|
||||
Memory(
|
||||
character_id=character_id,
|
||||
type="user",
|
||||
title=f"记忆{i}",
|
||||
content=f"内容{i}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_extraction_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _EXTRACTION_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_big_cycle_extracts_memory_and_world(
|
||||
mock_extraction_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed_memories(character.id, 20)
|
||||
|
||||
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
|
||||
assert cycle_id is not None
|
||||
await drain_pending_big_cycles()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 高置信度(≥0.7)低敏感度记忆自动落地到 memories 表
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.source == "big_cycle",
|
||||
)
|
||||
)
|
||||
auto_memories = result.scalars().all()
|
||||
# 世界实体候选(confidence=0.6 < 0.7)仍留 pending
|
||||
entities, _ = await world_repo.list_entities(session, character.id, status="pending")
|
||||
|
||||
assert len(auto_memories) == 1
|
||||
assert auto_memories[0].title == "喜欢简洁"
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "项目X"
|
||||
assert entities[0].status == "pending"
|
||||
assert entities[0].state_type == "inference"
|
||||
@@ -0,0 +1,171 @@
|
||||
"""大周天中的目标进度更新测试。
|
||||
|
||||
验证 _phase_goal_progress 在独立环境中按预期工作:
|
||||
- 活跃的 task/session 目标进度 +0.1
|
||||
- 进度上限 1.0
|
||||
- 非活跃目标不受影响
|
||||
- 进度达 1.0 的 task 目标状态标记为 completed
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.big_cycle import _phase_goal_progress
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.governance import Goal
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_increments_active_tasks() -> None:
|
||||
"""task 级别活跃目标在大周天后进度增加 0.1。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg1", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg1",
|
||||
title="测试任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.3,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_caps_at_one() -> None:
|
||||
"""进度达到 1.0 后不再增加。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg2", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg2",
|
||||
title="已满进度",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=1.0,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
# 已达上限,不再增加
|
||||
assert goal.progress == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_skips_non_active() -> None:
|
||||
"""非活跃目标不被更新。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg3", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg3",
|
||||
title="已完成任务",
|
||||
level="task",
|
||||
status="completed",
|
||||
progress=0.5,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_skips_highest_level() -> None:
|
||||
"""highest 级别目标不被大周天自动更新。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg4", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg4",
|
||||
title="最高使命",
|
||||
level="highest",
|
||||
type="mission",
|
||||
status="active",
|
||||
progress=0.5,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal.progress == 0.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_only_updates_own_character() -> None:
|
||||
"""仅更新当前 character 的目标,不影响其他角色。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg5", trigger="test")
|
||||
session.add(cycle)
|
||||
goal_own = Goal(
|
||||
character_id="char_pg5",
|
||||
title="自己任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.0,
|
||||
)
|
||||
goal_other = Goal(
|
||||
character_id="char_other",
|
||||
title="他人任务",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.0,
|
||||
)
|
||||
session.add_all([goal_own, goal_other])
|
||||
await session.flush()
|
||||
|
||||
await _phase_goal_progress(session, cycle)
|
||||
|
||||
assert goal_own.progress == 0.1
|
||||
assert goal_other.progress == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress_completes_task_at_one() -> None:
|
||||
"""task 级别目标进度达到 1.0 后状态变为 completed。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle = BigCycle(character_id="char_pg6", trigger="test")
|
||||
session.add(cycle)
|
||||
goal = Goal(
|
||||
character_id="char_pg6",
|
||||
title="即将完成",
|
||||
level="task",
|
||||
status="active",
|
||||
progress=0.95,
|
||||
)
|
||||
session.add(goal)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
cycle_id = cycle.id
|
||||
goal_id = goal.id
|
||||
|
||||
# 清理可能被前序测试设置的 event_store,避免 emit_goal_event 的独立 session 冲突
|
||||
from app.main import app as _app
|
||||
|
||||
_app.state.__dict__.pop("event_store", None)
|
||||
|
||||
# 关闭原 session 后调用 _phase_goal_progress,避免 emit_goal_event
|
||||
# 在 SQLite 单文件模式下产生写锁冲突时,progress 更新仍应正确提交。
|
||||
async with AsyncSessionLocal() as session:
|
||||
cycle_ref = await session.get(BigCycle, cycle_id)
|
||||
try: # noqa: SIM105 — emit_goal_event 失败不影响 goal 更新
|
||||
await _phase_goal_progress(session, cycle_ref)
|
||||
except Exception:
|
||||
pass
|
||||
await session.commit()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
goal_ref = await session.get(Goal, goal_id)
|
||||
assert goal_ref is not None
|
||||
assert goal_ref.progress == 1.0
|
||||
assert goal_ref.status == "completed"
|
||||
@@ -0,0 +1,100 @@
|
||||
"""小周天每轮记忆候选 + 反思抽取测试。
|
||||
|
||||
高置信度(≥0.7)低敏感度记忆候选直接落地到 memories 表而非 MemoryCandidate。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.reflection import (
|
||||
drain_pending_reflections,
|
||||
start_reflection_after_turn,
|
||||
)
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.memory import Memory
|
||||
from app.repositories import candidates as candidates_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
_REFLECTION_RESPONSE = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
|
||||
'"reflection":"本轮应更主动确认用户意图。"}'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reflection_chat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return _REFLECTION_RESPONSE
|
||||
|
||||
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflection_extracts_memory_and_writes_reflection(
|
||||
mock_reflection_chat, # noqa: ARG001
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
|
||||
start_reflection_after_turn(character.id, "session_test", "你好", "你好,我是魂镜。")
|
||||
await drain_pending_reflections()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 高置信度记忆直接落地到 memories 表(非 MemoryCandidate)
|
||||
auto_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "preference",
|
||||
Memory.source == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 反思文本也落地为 memories 表
|
||||
ref_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
assert auto_mem is not None
|
||||
assert auto_mem.title == "喜欢简洁"
|
||||
assert ref_mem is not None
|
||||
assert ref_mem.source == "reflection"
|
||||
assert ref_mem.status == "active" # Memory 无 status 字段,用 default
|
||||
assert "确认用户意图" in ref_mem.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflection_without_memories_only_writes_reflection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_chat(*_args, **_kwargs):
|
||||
return '{"memories":[],"reflection":"只反思不抽记忆。"}'
|
||||
|
||||
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
|
||||
start_reflection_after_turn(character.id, "session_test", "hi", "你好。")
|
||||
await drain_pending_reflections()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
cands = await candidates_repo.list_memory_candidates(session, character.id, source="cycle")
|
||||
ref_mem = (
|
||||
await session.execute(
|
||||
select(Memory).where(
|
||||
Memory.character_id == character.id,
|
||||
Memory.type == "reflection",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
# 无记忆候选(空 memories 数组),只有反思
|
||||
assert len(cands) == 0
|
||||
assert ref_mem is not None
|
||||
assert "只反思" in ref_mem.content
|
||||
@@ -0,0 +1,179 @@
|
||||
"""对话 temporal_context 注入测试(辰正 §8.1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.main import app
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _make_contract(temporal: str | None) -> RuntimeContract:
|
||||
return RuntimeContract(
|
||||
character_id="char_x",
|
||||
session_id="sess_x",
|
||||
run_id="run_x",
|
||||
model_hint="default",
|
||||
system_instructions=["base"],
|
||||
personality_summary="人格X",
|
||||
memory_context="记忆Y",
|
||||
temporal_context=temporal,
|
||||
trace_id="trace_x",
|
||||
)
|
||||
|
||||
|
||||
def test_contract_system_messages_includes_temporal_context():
|
||||
messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。"))
|
||||
assert any("子时·深夜" in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_no_temporal_context_when_none():
|
||||
messages = _contract_system_messages(_make_contract(None))
|
||||
assert all("子时" not in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_temporal_after_memory_before_safety():
|
||||
"""注入位置:记忆之后、安全约束之前(设计 §8.1)。"""
|
||||
contract = RuntimeContract(
|
||||
character_id="c",
|
||||
session_id="s",
|
||||
run_id="r",
|
||||
model_hint="m",
|
||||
system_instructions=["base"],
|
||||
memory_context="MEMORY_MARKER",
|
||||
temporal_context="TEMPORAL_MARKER",
|
||||
safety_constraints=["SAFETY_MARKER"],
|
||||
trace_id="t",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
joined = "\n".join(messages)
|
||||
assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER")
|
||||
assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §12 降级路径守护(设计 §12:辰正永不阻塞对话)
|
||||
# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成
|
||||
# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_session(client: AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"})
|
||||
assert resp.status_code == 200
|
||||
return str(resp.json()["session_id"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporal_client(
|
||||
auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。
|
||||
|
||||
复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message
|
||||
全链路),让降级路径在生产代码路径中被真实驱动。
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
|
||||
captured["messages"] = messages
|
||||
return AgentTurnResult(content="好的。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
http_client._temporal_captured = captured # type: ignore[attr-defined]
|
||||
yield http_client
|
||||
|
||||
|
||||
def _system_text(messages: list[Any]) -> str:
|
||||
return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_skipped_when_event_store_is_none(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。"""
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
# 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径)
|
||||
# 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时
|
||||
# 创建并在 teardown 时删除(恢复"无属性"原状)。
|
||||
monkeypatch.setattr(app.state, "event_store", None, raising=False)
|
||||
|
||||
# spy:若被调用即标记(不应发生)
|
||||
called: dict[str, bool] = {"hit": False}
|
||||
|
||||
import app.services.time_sense as time_sense_mod
|
||||
|
||||
real_compute = time_sense_mod.compute_live_snapshot
|
||||
|
||||
async def spy_compute(*args: Any, **kwargs: Any) -> Any:
|
||||
called["hit"] = True
|
||||
return await real_compute(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute)
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
await drain_pending_streams()
|
||||
|
||||
# 真实行为断言:辰正未被调用
|
||||
assert called["hit"] is False
|
||||
# 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_swallowed_when_compute_raises(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。"""
|
||||
|
||||
async def boom(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise RuntimeError("辰正服务故障(模拟)")
|
||||
|
||||
monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom)
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
# 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成
|
||||
assert sent.status_code == 200
|
||||
run_id = str(sent.json()["run_id"])
|
||||
await drain_pending_streams()
|
||||
|
||||
# 对话成功落库:run 进入终态 completed(而非 failed)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.runtime import Run
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
|
||||
assert run.status == "completed"
|
||||
|
||||
# temporal_context 未注入:system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "子时" not in sys_text and "时态" not in sys_text
|
||||
@@ -0,0 +1,86 @@
|
||||
"""内置工具规格测试:覆盖风险分级、env 传参与必填校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
|
||||
|
||||
def test_builtin_specs_cover_risk_levels() -> None:
|
||||
specs = builtin_specs()
|
||||
assert {"L0", "L1", "L2", "L3"} <= {s.risk_level for s in specs.values()}
|
||||
assert {
|
||||
"datetime",
|
||||
"read_file",
|
||||
"list_dir",
|
||||
"write_file",
|
||||
"shell",
|
||||
"fetch_url",
|
||||
} == set(specs)
|
||||
|
||||
|
||||
def test_write_file_is_ask_and_side_effect() -> None:
|
||||
spec = builtin_specs()["write_file"]
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
|
||||
|
||||
def test_read_tools_allow_and_no_side_effect() -> None:
|
||||
specs = builtin_specs()
|
||||
for name in ["datetime", "read_file", "list_dir"]:
|
||||
assert specs[name].permission_policy is PermissionPolicy.allow
|
||||
assert specs[name].side_effect is False
|
||||
|
||||
|
||||
def test_file_tools_use_workspace() -> None:
|
||||
req = builtin_specs()["read_file"].build_request({"path": "a.txt"})
|
||||
assert req.workdir == "workspace"
|
||||
assert req.env.get("PATH_ARG") == "a.txt"
|
||||
|
||||
|
||||
def test_read_file_pagination_builds_sed() -> None:
|
||||
"""read_file 传 offset/limit 时用 sed 行分页,缺省时 cat 全文。"""
|
||||
specs = builtin_specs()
|
||||
|
||||
full = specs["read_file"].build_request({"path": "a.txt"})
|
||||
assert full.argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
paged = specs["read_file"].build_request({"path": "a.txt", "offset": 10, "limit": 5})
|
||||
assert paged.argv[2] == "sed -n '10,14p' \"$PATH_ARG\""
|
||||
assert paged.env.get("PATH_ARG") == "a.txt"
|
||||
assert paged.workdir == "workspace"
|
||||
|
||||
tail = specs["read_file"].build_request({"path": "a.txt", "offset": 100})
|
||||
assert tail.argv[2] == "sed -n '100,$p' \"$PATH_ARG\""
|
||||
|
||||
|
||||
def test_read_file_pagination_ignores_bad_input() -> None:
|
||||
"""offset/limit 非正整数时回退全文 cat,不误构造非法 sed 地址。"""
|
||||
spec = builtin_specs()["read_file"]
|
||||
assert (
|
||||
spec.build_request({"path": "a", "offset": "x", "limit": -3}).argv[2]
|
||||
== 'cat -- "$PATH_ARG"'
|
||||
)
|
||||
assert spec.build_request({"path": "a", "offset": 0}).argv[2] == 'cat -- "$PATH_ARG"'
|
||||
|
||||
|
||||
def test_required_inputs_validated() -> None:
|
||||
specs = builtin_specs()
|
||||
with pytest.raises(ValueError):
|
||||
specs["read_file"].build_request({})
|
||||
with pytest.raises(ValueError):
|
||||
specs["write_file"].build_request({"content": "x"})
|
||||
with pytest.raises(ValueError):
|
||||
specs["shell"].build_request({})
|
||||
|
||||
|
||||
def test_fetch_url_uses_executor_not_sandbox() -> None:
|
||||
"""fetch_url 使用 executor 直接执行,不走沙箱。"""
|
||||
specs = builtin_specs()
|
||||
spec = specs["fetch_url"]
|
||||
assert spec.executor is not None
|
||||
assert spec.permission_policy is PermissionPolicy.ask
|
||||
assert spec.side_effect is True
|
||||
assert spec.audit_required is True
|
||||
@@ -0,0 +1,74 @@
|
||||
"""MCP 工具转 ToolSpec 测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.tool_runtime import mcp as mcp_module
|
||||
from app.application.tool_runtime.mcp import make_mcp_tool_specs
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
from app.modules.tool_runtime.definition import ToolResult
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, name, endpoint_url, auth_token=None):
|
||||
self.name = name
|
||||
self.endpoint_url = endpoint_url
|
||||
self.auth_token = auth_token
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_basic() -> None:
|
||||
tools = [{"name": "weather", "description": "查天气", "risk_level": "L1"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
s = specs[0]
|
||||
assert s.name == "mcp_srv_weather"
|
||||
assert s.description == "查天气"
|
||||
assert s.risk_level == "L1"
|
||||
assert s.permission_policy == PermissionPolicy.allow
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_default_risk_l2() -> None:
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert specs[0].risk_level == "L2"
|
||||
assert specs[0].permission_policy == PermissionPolicy.ask
|
||||
|
||||
|
||||
def test_make_mcp_tool_specs_skips_unnamed() -> None:
|
||||
tools = [{"name": "t"}, {"description": "无名"}]
|
||||
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
|
||||
assert len(specs) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_executor_calls_call_tool(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_call_tool(
|
||||
_http_client, endpoint_url, tool_name, input, auth_token=None, **_kwargs
|
||||
):
|
||||
captured.update(tool=tool_name, input=input, endpoint=endpoint_url, auth=auth_token)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
stdout="ok",
|
||||
stderr="",
|
||||
duration_ms=1,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mcp_module, "call_tool", fake_call_tool)
|
||||
|
||||
tools = [{"name": "t"}]
|
||||
specs = make_mcp_tool_specs(
|
||||
_Server("srv", "http://mcp.local", auth_token="tok"), tools, http_client="HTTP"
|
||||
)
|
||||
result = await specs[0].executor({"x": 1}, None)
|
||||
assert result.ok is True
|
||||
assert captured == {
|
||||
"tool": "t",
|
||||
"input": {"x": 1},
|
||||
"endpoint": "http://mcp.local",
|
||||
"auth": "tok",
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.application.agent.tools import to_ai_provider_tools
|
||||
from app.application.skill_executor import SkillExecutor
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
from app.modules.skills.manifest import SkillManifest
|
||||
from app.modules.tool_runtime.definition import ToolResult, ToolSpec
|
||||
|
||||
|
||||
def _spec(name: str) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=f"{name} description",
|
||||
risk_level="L1",
|
||||
permission_policy=MagicMock(),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"value": {"type": "string"}},
|
||||
"required": ["value"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_aliases_are_isolated_and_execute_through_base_runtime() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
runtime.execute = AsyncMock(
|
||||
return_value=ToolResult(True, 0, "ok", "", 1, False, False, "trace_1")
|
||||
)
|
||||
first = RunScopedToolView(runtime)
|
||||
second = RunScopedToolView(runtime)
|
||||
|
||||
assert first.bind_alias("skill:甲/shared_tool", "shared_tool", description_prefix="[甲] ")
|
||||
assert second.bind_alias("skill:乙/shared_tool", "shared_tool", description_prefix="[乙] ")
|
||||
|
||||
first_names = {spec.name for spec in first.list_specs()}
|
||||
second_names = {spec.name for spec in second.list_specs()}
|
||||
result = await first.execute(
|
||||
"skill:甲/shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
)
|
||||
|
||||
assert "skill:甲/shared_tool" in first_names
|
||||
assert "skill:乙/shared_tool" not in first_names
|
||||
assert "skill:乙/shared_tool" in second_names
|
||||
assert "skill:甲/shared_tool" not in second_names
|
||||
assert result.stdout == "ok"
|
||||
runtime.execute.assert_awaited_once_with(
|
||||
"shared_tool",
|
||||
{"value": "x"},
|
||||
run_id="run_1",
|
||||
character_id="char_1",
|
||||
trace_id=None,
|
||||
confirmed=False,
|
||||
context=None,
|
||||
)
|
||||
runtime.register.assert_not_called()
|
||||
runtime.unregister.assert_not_called()
|
||||
|
||||
|
||||
def test_alias_keeps_target_input_schema() -> None:
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("custom_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
view.bind_alias("skill:研究/custom_tool", "custom_tool")
|
||||
|
||||
tools = to_ai_provider_tools(view.list_specs())
|
||||
alias = next(tool for tool in tools if tool["function"]["name"] == "skill:研究/custom_tool")
|
||||
|
||||
assert alias["function"]["parameters"]["required"] == ["value"]
|
||||
|
||||
|
||||
async def test_skill_executor_binds_only_current_run_aliases(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
manifest = SkillManifest(
|
||||
name="研究",
|
||||
description="",
|
||||
triggers=("研究",),
|
||||
prompt="研究说明",
|
||||
allowed_tools=("shared_tool", "missing"),
|
||||
risk_level="L1",
|
||||
)
|
||||
|
||||
async def load(*_args, **_kwargs):
|
||||
return [manifest]
|
||||
|
||||
monkeypatch.setattr("app.application.skill_executor.load_skill_manifests", load)
|
||||
runtime = MagicMock()
|
||||
runtime.list_specs.return_value = [_spec("shared_tool")]
|
||||
view = RunScopedToolView(runtime)
|
||||
messages: list[str] = []
|
||||
|
||||
await SkillExecutor(view).match_and_activate("请研究", messages, run_id="run_1")
|
||||
|
||||
assert messages == ["【研究·能力注入】\n研究说明"]
|
||||
assert "skill:研究/shared_tool" in view.specs_by_name()
|
||||
assert "skill:研究/missing" not in view.specs_by_name()
|
||||
runtime.register.assert_not_called()
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
|
||||
|
||||
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
|
||||
|
||||
|
||||
def _settings() -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response() -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 3,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def sandbox_client() -> AsyncIterator[SandboxClient]:
|
||||
async with httpx.AsyncClient() as http:
|
||||
yield SandboxClient(http, _settings())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_allow_executes_and_records_started_completed(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
|
||||
|
||||
assert result.ok
|
||||
assert result.stdout == "42\n"
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
|
||||
sandbox = MagicMock()
|
||||
started = asyncio.Event()
|
||||
|
||||
async def exec_forever(*_args, **_kwargs):
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
sandbox.exec = AsyncMock(side_effect=exec_forever)
|
||||
sandbox.cancel = AsyncMock(return_value=True)
|
||||
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
sandbox.cancel.assert_awaited_once()
|
||||
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
|
||||
|
||||
# 未确认不应进入执行,故无 started 事件
|
||||
assert all(e.type != "tool.started" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
|
||||
|
||||
assert result.ok
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
governor = SafetyGovernor(
|
||||
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, governor, store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
|
||||
|
||||
failed = [e for e in store.all() if e.type == "tool.failed"]
|
||||
assert len(failed) == 1
|
||||
assert failed[0].payload["error_code"] == "permission_denied"
|
||||
assert failed[0].trace_id == "t1"
|
||||
|
||||
|
||||
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
with pytest.raises(PermissionDenied):
|
||||
await runtime.execute("nope", {}, run_id="run_1")
|
||||
|
||||
assert any(e.type == "tool.failed" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_tool_isolates_workdir_by_character(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
await runtime.execute(
|
||||
"write_file",
|
||||
{"path": "a.txt", "content": "x"},
|
||||
character_id="char_01HABC",
|
||||
run_id="run_42",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
|
||||
assert body["workdir"] == "workspace/char_01HABC"
|
||||
|
||||
|
||||
async def test_isolate_workdir_sanitizes_and_falls_back(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
|
||||
traversed = runtime._isolate_workdir(
|
||||
"workspace", subject_id="../../etc", run_id=None, trace_id=None
|
||||
)
|
||||
key = traversed.removeprefix("workspace/")
|
||||
assert key == "etc"
|
||||
assert "/" not in key and ".." not in key
|
||||
# 正常 subject_id(character_id)原样保留
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
|
||||
== "workspace/char_01HABC"
|
||||
)
|
||||
# 缺 subject_id 时回退 run_id
|
||||
assert (
|
||||
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
|
||||
== "workspace/run_01HABC"
|
||||
)
|
||||
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
|
||||
assert runtime._isolate_workdir(
|
||||
"workspace", subject_id=None, run_id=None, trace_id=None
|
||||
).startswith("workspace/adhoc_")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_forwards_stdout_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
|
||||
sse = (
|
||||
b"event: stdout\n"
|
||||
b"data: aGVsbG8K\n"
|
||||
b"\n"
|
||||
b"event: done\n"
|
||||
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
|
||||
b"\n"
|
||||
)
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse)
|
||||
)
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
|
||||
|
||||
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
|
||||
|
||||
assert [e.kind for e in events] == ["stdout", "done"]
|
||||
assert events[-1].payload["exit_code"] == 0
|
||||
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_1" for e in store.all())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_execute_stream_requires_confirmation_for_high_risk(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# executor 工具集成测试(fetch_url 走直接执行路径)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_runs_directly_and_records_events(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
|
||||
respx.get("https://example.test").mock(
|
||||
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
|
||||
)
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
store = InMemoryEventStore()
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
store,
|
||||
http_client=tools_http,
|
||||
)
|
||||
# fetch_url permission=ask,confirmed=True 绕过确认。
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
run_id="run_fu",
|
||||
trace_id="t_fu",
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "Hello" in result.stdout
|
||||
assert result.trace_id == "t_fu"
|
||||
# 沙箱未被调用(直接执行路径)
|
||||
events = store.all()
|
||||
assert [e.type for e in events] == ["tool.started", "tool.completed"]
|
||||
assert all(e.run_id == "run_fu" for e in events)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_executor_tool_requires_confirmation_when_ask(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
|
||||
async with httpx.AsyncClient() as tools_http:
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
http_client=tools_http,
|
||||
)
|
||||
with pytest.raises(RequiresConfirmation):
|
||||
await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
)
|
||||
|
||||
|
||||
async def test_executor_tool_without_http_client_returns_error(
|
||||
sandbox_client: SandboxClient,
|
||||
) -> None:
|
||||
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
|
||||
runtime = ToolRuntime(
|
||||
sandbox_client,
|
||||
SafetyGovernor(),
|
||||
InMemoryEventStore(),
|
||||
# 故意不传 http_client
|
||||
)
|
||||
result = await runtime.execute(
|
||||
"fetch_url",
|
||||
{"url": "https://example.test"},
|
||||
confirmed=True,
|
||||
)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_builtin_changes_reflected_in_list_specs_without_reinit(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
|
||||
original = builtins_module.builtin_specs()
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
|
||||
# 基线:包含全部 builtin 工具
|
||||
baseline_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in baseline_names
|
||||
assert "shell" in baseline_names
|
||||
|
||||
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
|
||||
pruned = {k: v for k, v in original.items() if k != "shell"}
|
||||
|
||||
def _pruned_specs() -> dict:
|
||||
return dict(pruned)
|
||||
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
|
||||
|
||||
after_names = {s.name for s in runtime.list_specs()}
|
||||
assert "datetime" in after_names
|
||||
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
|
||||
|
||||
|
||||
def test_register_overrides_builtin_and_survives_builtin_change(
|
||||
sandbox_client: SandboxClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
|
||||
from app.application.tool_runtime import builtins as builtins_module
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
custom_shell = ToolSpec(
|
||||
name="shell",
|
||||
description="自定义 shell(覆盖 builtin)",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=lambda _input: _ok_exec_request(),
|
||||
side_effect=False,
|
||||
)
|
||||
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
|
||||
runtime.register(custom_shell)
|
||||
|
||||
registered = {s.name: s for s in runtime.list_specs()}
|
||||
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
|
||||
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
|
||||
|
||||
after = {s.name: s for s in runtime.list_specs()}
|
||||
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
|
||||
|
||||
|
||||
def _ok_exec_request():
|
||||
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
|
||||
return ExecRequest(argv=["sh", "-c", "echo ok"])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""fetch_url executor 单元测试。
|
||||
|
||||
mock 边界:
|
||||
- ``respx`` 模拟 httpx.AsyncClient 的 GET(不真实联网)。
|
||||
- executor 直接调用,不经过 ToolRuntime(runtime 集成路径由 test_runtime 覆盖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.application.tool_runtime.web_tools import _fetch_url_executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _ctx(http_client: httpx.AsyncClient, **extra: Any) -> dict[str, Any]:
|
||||
"""构造 executor 上下文。"""
|
||||
base: dict[str, Any] = {"http_client": http_client, "trace_id": "trace_test"}
|
||||
base.update(extra)
|
||||
return base
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_converts_html_to_markdown(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""fetch_url 把 HTML 转成 markdown 并截断。"""
|
||||
html = (
|
||||
"<html><head><title>T</title></head>"
|
||||
"<body><h1>标题</h1><p>这是一段正文。</p>"
|
||||
'<a href="https://example.com">链接</a></body></html>'
|
||||
)
|
||||
respx.get("https://example.com/page").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/page"}, _ctx(http_client))
|
||||
|
||||
assert result.ok
|
||||
assert "标题" in result.stdout
|
||||
assert "这是一段正文。" in result.stdout
|
||||
# html2text 默认保留链接
|
||||
assert "example.com" in result.stdout
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_truncates_to_max_chars(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""max_chars 截断生效。"""
|
||||
long_text = "A" * 500
|
||||
html = f"<html><body><p>{long_text}</p></body></html>"
|
||||
respx.get("https://example.com/big").mock(return_value=httpx.Response(200, text=html))
|
||||
|
||||
result = await _fetch_url_executor(
|
||||
{"url": "https://example.com/big", "max_chars": 50}, _ctx(http_client)
|
||||
)
|
||||
|
||||
assert result.ok
|
||||
assert "已截断" in result.stdout
|
||||
assert len(result.stdout) < 500
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_http_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""HTTP 错误返回 ok=False。"""
|
||||
respx.get("https://example.com/missing").mock(
|
||||
return_value=httpx.Response(404, text="not found")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/missing"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "404" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_url_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""缺 url 输入时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "url" in result.stderr
|
||||
|
||||
|
||||
async def test_fetch_url_missing_http_client_returns_error() -> None:
|
||||
"""缺注入的 http_client 时返回 ok=False。"""
|
||||
result = await _fetch_url_executor({"url": "https://example.com"}, None)
|
||||
|
||||
assert not result.ok
|
||||
assert "http_client" in result.stderr
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_url_network_error_returns_error(
|
||||
http_client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""网络错误(HTTPError)返回 ok=False 而不抛异常。"""
|
||||
respx.get("https://example.com/neterr").mock(
|
||||
side_effect=httpx.ConnectError("connection refused")
|
||||
)
|
||||
|
||||
result = await _fetch_url_executor({"url": "https://example.com/neterr"}, _ctx(http_client))
|
||||
|
||||
assert not result.ok
|
||||
assert "请求失败" in result.stderr
|
||||
Reference in new issue
Block a user