M4b:ai_provider 删除手术①-⑥——神识全面接管模型出口

- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件)
- ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好
- ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied)
- ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知
- ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_*
- ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
This commit is contained in:
chenxin committed 2026-08-27 10:02:44 +08:00
1 parent 0d383725b5
commit 462d570751
129 files changed
+81 -15867

No files matched your search

@@ -1,203 +0,0 @@
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",)
@@ -1,235 +0,0 @@
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 不抛错。
@@ -1,405 +0,0 @@
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"]
@@ -1,101 +0,0 @@
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()