236 lines
8.3 KiB
Python
236 lines
8.3 KiB
Python
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 不抛错。
|