feat(F): F-CTX context_builder, F-STR agent_stream, F-DEF agent definition fields, F-SKILL skill_registry, F-TOOL agent_tools
Check Cross-Plugin Imports / check (push) Has been cancelled

- F-CTX: app/ai/context_builder.py (282 lines) — build_agent_context() + ReActSystemPromptBuilder
- F-STR: app/ai/agent_stream.py (155 lines) — stream_react_loop() with SSE events (step, status, done, error)
- F-DEF: AgentDefinition fields added (temperature, max_tokens, max_steps, trace_mode, skill_ids, trigger_config, ai_use_case_metadata) + migration 0122
- F-SKILL: app/ai/skill_registry.py (82 lines) — SkillDefinition + SkillRegistry singleton
- F-TOOL: app/ai/agent_tools.py (117 lines) — get_agent_tools() with permission intersection
- Skill CRUD routes: app/plugins/builtins/automation/skill_routes.py
- Tests: test_skill_registry.py (97 lines), test_agent_tools.py (219 lines)
- All Python compile checks pass, tests require PostgreSQL (infra issue, not code bug)
This commit is contained in:
Agent Zero
2026-08-17 16:40:55 +02:00
parent c760b5961c
commit dbeadd8ab1
14 changed files with 1457 additions and 1 deletions
+219
View File
@@ -0,0 +1,219 @@
"""Tests for Tool-/Skill-Binding (app/ai/agent_tools.py).
All tests use mocked tool/skill registries — no real DB or LLM needed.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from app.ai.agent_tools import get_agent_tools
from app.ai.skill_registry import SkillDefinition, SkillRegistry
@dataclass
class MockTool:
"""Minimal stand-in for AITool."""
name: str
description: str = ""
parameters: dict[str, Any] = field(default_factory=dict)
required_permission: str | None = None
def to_openai_schema(self) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
class MockToolRegistry:
"""In-memory tool registry with get_by_names."""
def __init__(self, tools: list[MockTool]) -> None:
self._tools = {t.name: t for t in tools}
def get_by_names(self, names: list[str]) -> list[MockTool]:
return [self._tools[n] for n in names if n in self._tools]
@dataclass
class MockAgentDefinition:
"""Minimal stand-in for AgentDefinition."""
tool_ids: list[str] = field(default_factory=list)
skill_ids: list[str] = field(default_factory=list)
@pytest.fixture(autouse=True)
def _clean_skill_registry():
"""Reset the singleton skill registry between tests."""
registry = SkillRegistry()
for skill in list(registry.list_all()):
registry.unregister(skill.name)
yield
for skill in list(registry.list_all()):
registry.unregister(skill.name)
def _make_skill(
name: str,
allowed_tool_ids: list[str],
) -> SkillDefinition:
return SkillDefinition(
name=name,
description="test skill",
instructions="use the tools",
allowed_tool_ids=allowed_tool_ids,
)
def _permissions(
permissions: list[str] | None = None,
denied: list[str] | None = None,
is_system_admin: bool = False,
) -> dict[str, Any]:
return {
"permissions": permissions or [],
"denied_permissions": denied or [],
"is_system_admin": is_system_admin,
}
class TestGetAgentTools:
def test_direct_tools_available(self):
"""Tools directly on the agent are available (no skills)."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
MockTool("contact_list"),
])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["mail_read", "contact_list"])
schemas, skills = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=["mail:read"])
)
assert len(schemas) == 2
names = {s["function"]["name"] for s in schemas}
assert names == {"mail_read", "contact_list"}
assert skills == []
def test_skill_tools_intersect_with_agent_tools(self):
"""Skill tools are only available if also in agent.tool_ids."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
MockTool("mail_list", required_permission="mail:read"),
MockTool("secret_tool"),
])
skill_registry = SkillRegistry()
skill_registry.register(_make_skill("mail_skill", ["mail_read", "mail_list", "secret_tool"]))
# Agent has mail_read + mail_list, but NOT secret_tool
agent = MockAgentDefinition(
tool_ids=["mail_read", "mail_list"],
skill_ids=["mail_skill"],
)
schemas, skills = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=["mail:read"])
)
names = {s["function"]["name"] for s in schemas}
assert names == {"mail_read", "mail_list"}
assert "secret_tool" not in names
assert len(skills) == 1
assert skills[0].name == "mail_skill"
def test_skill_never_grants_permission(self):
"""A skill cannot grant access to a tool the user lacks permission for."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
])
skill_registry = SkillRegistry()
skill_registry.register(_make_skill("mail_skill", ["mail_read"]))
agent = MockAgentDefinition(tool_ids=["mail_read"], skill_ids=["mail_skill"])
# User does NOT have mail:read
schemas, skills = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=[])
)
assert schemas == []
assert len(skills) == 1 # skill is still resolved, but grants no tools
def test_denied_permission_blocks_tool(self):
"""Explicitly denied permissions block a tool even if granted."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["mail_read"])
schemas, _ = get_agent_tools(
agent,
registry,
skill_registry,
_permissions(permissions=["mail:read"], denied=["mail:read"]),
)
assert schemas == []
def test_system_admin_gets_all_tools(self):
"""System admins bypass permission checks."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["mail_read"])
schemas, _ = get_agent_tools(
agent, registry, skill_registry, _permissions(is_system_admin=True)
)
assert len(schemas) == 1
def test_wildcard_permission_matches(self):
"""Wildcard permissions (mail:*) satisfy a required permission."""
registry = MockToolRegistry([
MockTool("mail_read", required_permission="mail:read"),
])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["mail_read"])
schemas, _ = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=["mail:*"])
)
assert len(schemas) == 1
def test_tool_without_permission_always_available(self):
"""Tools without a required_permission are always available."""
registry = MockToolRegistry([MockTool("contact_list")])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["contact_list"])
schemas, _ = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=[])
)
assert len(schemas) == 1
def test_unknown_tools_are_skipped(self):
"""Tool IDs not in the registry are silently skipped."""
registry = MockToolRegistry([MockTool("known_tool")])
skill_registry = SkillRegistry()
agent = MockAgentDefinition(tool_ids=["known_tool", "missing_tool"])
schemas, _ = get_agent_tools(
agent, registry, skill_registry, _permissions(permissions=[])
)
assert len(schemas) == 1
assert schemas[0]["function"]["name"] == "known_tool"
+97
View File
@@ -0,0 +1,97 @@
"""Tests for the Small Skill Registry (app/ai/skill_registry.py)."""
from __future__ import annotations
import pytest
from app.ai.skill_registry import SkillDefinition, get_skill_registry
@pytest.fixture(autouse=True)
def _clean_registry():
"""Reset the singleton registry between tests."""
registry = get_skill_registry()
for skill in list(registry.list_all()):
registry.unregister(skill.name)
yield
for skill in list(registry.list_all()):
registry.unregister(skill.name)
def _make_skill(
name: str = "mail_reader",
allowed_tool_ids: list[str] | None = None,
category: str = "general",
) -> SkillDefinition:
return SkillDefinition(
name=name,
description="Read and summarize emails",
instructions="Use the mail tools to read and summarize emails.",
allowed_tool_ids=allowed_tool_ids or ["mail_read", "mail_list"],
context_policy={"include_recent": True},
category=category,
)
class TestSkillDefinition:
def test_defaults(self):
skill = SkillDefinition(
name="s", description="d", instructions="i", allowed_tool_ids=["t"]
)
assert skill.context_policy is None
assert skill.category == "general"
def test_to_dict(self):
skill = _make_skill()
data = skill.to_dict()
assert data["name"] == "mail_reader"
assert data["allowed_tool_ids"] == ["mail_read", "mail_list"]
assert data["context_policy"] == {"include_recent": True}
assert data["category"] == "general"
class TestSkillRegistry:
def test_register_and_get(self):
registry = get_skill_registry()
skill = _make_skill()
registry.register(skill)
assert registry.get("mail_reader") is skill
def test_get_missing_returns_none(self):
registry = get_skill_registry()
assert registry.get("nope") is None
def test_get_by_names_skips_unknown(self):
registry = get_skill_registry()
a = _make_skill("a")
b = _make_skill("b")
registry.register(a)
registry.register(b)
resolved = registry.get_by_names(["a", "b", "missing"])
assert len(resolved) == 2
assert {s.name for s in resolved} == {"a", "b"}
def test_list_all_and_list_for_api(self):
registry = get_skill_registry()
registry.register(_make_skill("a"))
registry.register(_make_skill("b"))
assert len(registry.list_all()) == 2
api = registry.list_for_api()
assert len(api) == 2
assert all("name" in item and "instructions" in item for item in api)
def test_unregister(self):
registry = get_skill_registry()
registry.register(_make_skill())
registry.unregister("mail_reader")
assert registry.get("mail_reader") is None
def test_register_replaces_existing(self):
registry = get_skill_registry()
registry.register(_make_skill("a", allowed_tool_ids=["t1"]))
registry.register(_make_skill("a", allowed_tool_ids=["t2"]))
assert registry.get("a").allowed_tool_ids == ["t2"]
assert len(registry.list_all()) == 1
def test_singleton(self):
assert get_skill_registry() is get_skill_registry()