初始化提交

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

No files matched your search

@@ -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"]