1425 lines
50 KiB
Python
1425 lines
50 KiB
Python
"""Tests for meta_invoke tool registration and Agent dispatch interception.
|
|
|
|
This file accumulates tests across Tasks 1, 3, 5, 6 of the
|
|
meta_invoke-soft-activation plan. Task 1 covers registration only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def test_meta_invoke_registered_in_default_registry() -> None:
|
|
"""meta_invoke appears in the registry after importing the builtin
|
|
module."""
|
|
# Importing the builtin package triggers all registrations.
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401 — import side-effect
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
assert get_default_registry().get("meta_invoke") is not None
|
|
|
|
|
|
def test_meta_invoke_spec_shape() -> None:
|
|
"""meta_invoke advertises a single required string parameter 'name',
|
|
and the description mentions meta-skill semantics."""
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registered = get_default_registry().get("meta_invoke")
|
|
assert registered is not None
|
|
spec = registered.spec
|
|
assert spec.name == "meta_invoke"
|
|
assert "name" in spec.parameters
|
|
assert spec.required == ["name"]
|
|
# Description must mention meta-skill semantics for the LLM
|
|
desc = spec.description.lower()
|
|
assert "meta-skill" in desc
|
|
assert "playbook" in desc or "multi-step" in desc
|
|
|
|
|
|
def test_meta_invoke_not_exposed_by_default() -> None:
|
|
"""meta_invoke must not appear in default tool catalogues. It is
|
|
conditionally surfaced by SkillInjector when meta-skills are present."""
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registered = get_default_registry().get("meta_invoke")
|
|
assert registered is not None # exists in registry
|
|
assert registered.spec.exposed_by_default is False, (
|
|
"meta_invoke should be conditionally surfaced, not always exposed"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_invoke_handler_raises_routing_error() -> None:
|
|
"""If the standard dispatcher ever invokes the meta_invoke handler,
|
|
that's a configuration bug — the Agent's dispatch loop should have
|
|
intercepted it. Raise a clear RuntimeError naming the expected
|
|
interception point."""
|
|
from opensquilla.tools.builtin.meta_tools import meta_invoke
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await meta_invoke(name="any")
|
|
msg = str(exc_info.value).lower()
|
|
assert "agent" in msg or "_run_one_streaming" in msg or "intercept" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 3: ToolResult.terminates_turn field + preservation through
|
|
# Agent._compress_tool_result rebuild sites.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_tool_result_has_terminates_turn_field() -> None:
|
|
"""ToolResult.terminates_turn defaults to False; can be set True."""
|
|
from opensquilla.tool_boundary import ToolResult
|
|
|
|
r = ToolResult(tool_use_id="u1", tool_name="t", content="ok")
|
|
assert r.terminates_turn is False
|
|
|
|
r2 = ToolResult(
|
|
tool_use_id="u1", tool_name="t", content="ok", terminates_turn=True,
|
|
)
|
|
assert r2.terminates_turn is True
|
|
|
|
|
|
class _NullProvider:
|
|
"""Minimal LLMProvider stand-in: never called by _compress_tool_result."""
|
|
|
|
provider_name = "null"
|
|
|
|
def chat(self, *args: object, **kwargs: object) -> object: # pragma: no cover
|
|
raise AssertionError("provider.chat must not be called by _compress_tool_result")
|
|
|
|
async def list_models(self) -> list[object]: # pragma: no cover
|
|
return []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compress_tool_result_preserves_terminates_turn_when_short() -> None:
|
|
"""When content is short enough to not need compression, the rebuild
|
|
must still carry terminates_turn through."""
|
|
from opensquilla.engine import Agent, AgentConfig
|
|
from opensquilla.tool_boundary import ToolResult
|
|
|
|
agent = Agent(provider=_NullProvider(), config=AgentConfig())
|
|
|
|
original = ToolResult(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
content="small content",
|
|
is_error=False,
|
|
terminates_turn=True,
|
|
)
|
|
compressed = await agent._compress_tool_result(original)
|
|
assert compressed.terminates_turn is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compress_tool_result_preserves_terminates_turn_when_compressed() -> None:
|
|
"""When content IS large enough to trigger compression, the rebuild
|
|
must STILL carry terminates_turn through (the other code path)."""
|
|
from opensquilla.engine import Agent, AgentConfig
|
|
from opensquilla.tool_boundary import ToolResult
|
|
|
|
# Shrink context_window_tokens so 50_000 chars (~12500 tokens) exceeds
|
|
# the compression budget (context_window_tokens * max_share = 1000 * 0.25
|
|
# = 250 tokens). truncate mode keeps compression purely local — no
|
|
# provider call needed.
|
|
config = AgentConfig(
|
|
context_window_tokens=1000,
|
|
tool_result_compression_enabled=True,
|
|
tool_result_compression_mode="truncate",
|
|
)
|
|
agent = Agent(provider=_NullProvider(), config=config)
|
|
|
|
big_content = "x" * 50_000
|
|
original = ToolResult(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
content=big_content,
|
|
is_error=False,
|
|
terminates_turn=True,
|
|
)
|
|
compressed = await agent._compress_tool_result(original)
|
|
# Sanity-check the compression path actually fired (content shrunk).
|
|
assert len(compressed.content) < len(big_content), (
|
|
"test setup error: compression did not trigger; "
|
|
"second rebuild site would not be exercised"
|
|
)
|
|
# The FLAG must survive the rebuild.
|
|
assert compressed.terminates_turn is True, (
|
|
"terminates_turn lost during ToolResult compression rebuild"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 5: Agent._run_one_streaming for meta_invoke
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_success_yields_events_then_terminating_result(
|
|
tmp_path,
|
|
) -> None:
|
|
"""Agent._run_one_streaming for a successful meta-skill yields nested
|
|
events then a ToolResult with terminates_turn=True and is_error=False."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401 — registers meta_invoke
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
# Synthesize a tiny meta-skill using kind: meta directly (bypassing
|
|
# the SOP markdown compiler so llm_classify is supported).
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: tiny meta-skill\n"
|
|
"triggers: [tiny-meta-trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n"
|
|
"# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
spec = loader.get_by_name("meta-tiny")
|
|
assert spec is not None
|
|
assert getattr(spec, "kind", None) == "meta"
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_args, **_kwargs):
|
|
raise AssertionError("provider.chat must not be called in this test")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
registry = get_default_registry()
|
|
assert registry.get("meta_invoke") is not None
|
|
|
|
config = AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=1,
|
|
system_prompt="",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
)
|
|
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=config,
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=registry,
|
|
)
|
|
|
|
async def fake_llm_chat(_s: str, _u: str) -> str:
|
|
return "A"
|
|
|
|
agent._test_llm_chat_override = fake_llm_chat # type: ignore[attr-defined]
|
|
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
tool_ctx = ToolContext(workspace_dir=str(tmp_path), is_owner=True)
|
|
|
|
events = []
|
|
final = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
else:
|
|
events.append(ev)
|
|
|
|
assert final is not None, "should yield a final ToolResult"
|
|
assert final.is_error is False, f"expected success but got: {final.content!r}"
|
|
assert final.terminates_turn is True
|
|
# Permissive content check — the deliverable should mention or carry the
|
|
# classifier output, but exact wording depends on orchestrator framing.
|
|
assert final.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_invoke_llm_chat_step_records_usage(tmp_path) -> None:
|
|
"""Meta-skill llm_chat steps call the provider outside the normal Agent
|
|
loop, but their tokens still belong to the parent session usage."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.engine.usage import UsageTracker
|
|
from opensquilla.provider.types import DoneEvent as ProviderDoneEvent
|
|
from opensquilla.provider.types import TextDeltaEvent as ProviderTextDeltaEvent
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-usage"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-usage\n"
|
|
"kind: meta\n"
|
|
"description: usage accounting meta-skill\n"
|
|
"final_text_mode: raw\n"
|
|
"triggers: [usage accounting]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: write\n"
|
|
" kind: llm_chat\n"
|
|
" with:\n"
|
|
" system: s\n"
|
|
" task: t\n"
|
|
"---\n"
|
|
"# meta-usage\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _UsageProvider:
|
|
provider_name = "stub"
|
|
|
|
async def chat(self, *_args, **_kwargs):
|
|
yield ProviderTextDeltaEvent(text="done")
|
|
yield ProviderDoneEvent(
|
|
input_tokens=11,
|
|
output_tokens=7,
|
|
cached_tokens=3,
|
|
cache_write_tokens=2,
|
|
model="stub/meta",
|
|
)
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
usage = UsageTracker()
|
|
agent = Agent(
|
|
provider=_UsageProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub/base",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
usage_tracker=usage,
|
|
session_key="agent:main:test-usage",
|
|
)
|
|
|
|
final = None
|
|
async for ev in agent._run_one_streaming(
|
|
ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-usage"},
|
|
),
|
|
ToolContext(workspace_dir=str(tmp_path), is_owner=True),
|
|
):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
|
|
assert final is not None
|
|
assert final.is_error is False
|
|
tracked = usage.get("agent:main:test-usage")
|
|
assert tracked is not None
|
|
assert tracked.input_tokens == 11
|
|
assert tracked.output_tokens == 7
|
|
assert tracked.cache_read_tokens == 3
|
|
assert tracked.cache_write_tokens == 2
|
|
assert tracked.model_id == "stub/meta"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_unknown_meta_skill_returns_error_result(
|
|
tmp_path,
|
|
) -> None:
|
|
"""meta_invoke with an unknown name yields ToolResult(is_error=True,
|
|
terminates_turn=False)."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401 — registers meta_invoke
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_args, **_kwargs):
|
|
raise AssertionError("provider.chat must not be called in this test")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
registry = get_default_registry()
|
|
assert registry.get("meta_invoke") is not None
|
|
|
|
config = AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=1,
|
|
system_prompt="",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
)
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=config,
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=registry,
|
|
)
|
|
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "nonexistent-meta-skill"},
|
|
)
|
|
tool_ctx = ToolContext(workspace_dir=str(tmp_path), is_owner=True)
|
|
|
|
final = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
|
|
assert final is not None
|
|
assert final.is_error is True
|
|
assert final.terminates_turn is False
|
|
assert "not a registered meta-skill" in final.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_rejects_disabled_meta_skill(tmp_path) -> None:
|
|
"""meta_invoke must not bypass disable-model-invocation."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-hidden"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-hidden\n"
|
|
"kind: meta\n"
|
|
"description: hidden meta-skill\n"
|
|
"disable-model-invocation: true\n"
|
|
"triggers: [hidden trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n"
|
|
"# meta-hidden\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_args, **_kwargs):
|
|
raise AssertionError("disabled meta-skill must not execute")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
)
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-hidden"},
|
|
)
|
|
tool_ctx = ToolContext(
|
|
workspace_dir=str(tmp_path),
|
|
is_owner=True,
|
|
allowed_tools={"meta_invoke"},
|
|
surfaced_tools={"meta_invoke"},
|
|
)
|
|
|
|
final = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
|
|
assert final is not None
|
|
assert final.is_error is True
|
|
assert "not available for model invocation" in final.content
|
|
assert final.terminates_turn is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_rejects_meta_invoke_when_meta_skill_config_disabled(
|
|
tmp_path,
|
|
) -> None:
|
|
"""meta_invoke must not bypass the global meta-skill switch."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-visible"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-visible\n"
|
|
"kind: meta\n"
|
|
"description: visible meta-skill\n"
|
|
"triggers: [visible trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n"
|
|
"# meta-visible\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_args, **_kwargs):
|
|
raise AssertionError("globally disabled meta-skill must not execute")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
"meta_skill_enabled": False,
|
|
},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
)
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-visible"},
|
|
)
|
|
tool_ctx = ToolContext(
|
|
workspace_dir=str(tmp_path),
|
|
is_owner=True,
|
|
allowed_tools={"meta_invoke"},
|
|
surfaced_tools={"meta_invoke"},
|
|
)
|
|
|
|
final = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
|
|
assert final is not None
|
|
assert final.is_error is True
|
|
assert "meta-skill is disabled" in final.content
|
|
assert final.terminates_turn is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_propagates_current_turn_message_to_inputs(
|
|
tmp_path,
|
|
) -> None:
|
|
"""The user's run_turn(message=...) text must flow into MetaMatch.inputs
|
|
as user_message — otherwise the meta-skill's first step (e.g.
|
|
multi-search-engine reading {{ inputs.user_message }}) gets an empty
|
|
query and the whole DAG produces an empty deliverable.
|
|
|
|
The Agent stores message in self._current_turn_message at the top of
|
|
_turn_generator; _run_one_streaming reads it back from there. This test
|
|
sets the attribute directly (without going through run_turn) and
|
|
verifies the value reaches MetaOrchestrator via a captured iter_events
|
|
spy.
|
|
"""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: t\n"
|
|
"triggers: [t]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: x}\n"
|
|
"---\n# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_a, **_kw):
|
|
raise AssertionError("provider.chat must not fire")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
registry = get_default_registry()
|
|
config = AgentConfig(
|
|
model_id="stub", max_iterations=1, system_prompt="outer system prompt",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
)
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=config,
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=registry,
|
|
)
|
|
# Simulate what _turn_generator does on its first line.
|
|
agent._current_turn_message = "RAG in low-resource settings" # type: ignore[attr-defined]
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
# Patch MetaOrchestrator.iter_events to capture the MetaMatch then
|
|
# yield a successful MetaResult sentinel without running real steps.
|
|
import opensquilla.skills.meta.orchestrator as orch_mod
|
|
from opensquilla.skills.meta.types import MetaResult
|
|
|
|
original_iter_events = orch_mod.MetaOrchestrator.iter_events
|
|
|
|
async def fake_iter_events(self, match): # noqa: ARG001
|
|
captured["inputs"] = dict(match.inputs)
|
|
yield MetaResult(ok=True, final_text="captured")
|
|
|
|
orch_mod.MetaOrchestrator.iter_events = fake_iter_events # type: ignore[assignment]
|
|
try:
|
|
tc = ToolCall(
|
|
tool_use_id="u1", tool_name="meta_invoke",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
tool_ctx = ToolContext(workspace_dir=str(tmp_path), is_owner=True)
|
|
|
|
final: ToolResult | None = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
finally:
|
|
orch_mod.MetaOrchestrator.iter_events = original_iter_events # type: ignore[assignment]
|
|
|
|
assert final is not None
|
|
assert final.is_error is False
|
|
assert final.content == "meta-skill 'meta-tiny' completed."
|
|
assert captured.get("inputs", {}).get("user_message") == "RAG in low-resource settings", (
|
|
f"expected user_message to propagate from _current_turn_message; got {captured!r}"
|
|
)
|
|
assert captured.get("inputs", {}).get("system_prompt") == "outer system prompt", (
|
|
f"expected system_prompt to propagate into meta-skill inputs; got {captured!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_streaming_reuses_resolved_meta_match_control_inputs(
|
|
tmp_path,
|
|
) -> None:
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.skills.meta.parser import parse_meta_plan
|
|
from opensquilla.skills.meta.types import MetaMatch, MetaResult
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: t\n"
|
|
"triggers: [t]\n"
|
|
"composition:\n"
|
|
" request:\n"
|
|
" mode: confirm\n"
|
|
" fields:\n"
|
|
" - name: audience\n"
|
|
" required: true\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: x}\n"
|
|
"---\n# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
specs = loader.load_all()
|
|
plan = parse_meta_plan(next(spec for spec in specs if spec.name == "meta-tiny"))
|
|
assert plan is not None
|
|
resolved = MetaMatch(
|
|
plan=plan,
|
|
inputs={
|
|
"user_message": "Visible request only",
|
|
"audience": "decision owner",
|
|
"meta_preflight_confirmed": True,
|
|
"meta_preflight_run_id": "01CONTROL",
|
|
},
|
|
run_id="01CONTROL",
|
|
)
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_a, **_kw):
|
|
raise AssertionError("provider.chat must not fire")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=1,
|
|
system_prompt="outer system prompt",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
"meta_match": resolved,
|
|
},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
)
|
|
agent._current_turn_message = "Visible request only" # type: ignore[attr-defined]
|
|
|
|
captured: dict[str, object] = {}
|
|
import opensquilla.skills.meta.orchestrator as orch_mod
|
|
|
|
original_iter_events = orch_mod.MetaOrchestrator.iter_events
|
|
|
|
async def fake_iter_events(self, match): # noqa: ARG001
|
|
captured["inputs"] = dict(match.inputs)
|
|
captured["run_id"] = match.run_id
|
|
yield MetaResult(ok=True, final_text="captured")
|
|
|
|
orch_mod.MetaOrchestrator.iter_events = fake_iter_events # type: ignore[assignment]
|
|
try:
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
tool_ctx = ToolContext(workspace_dir=str(tmp_path), is_owner=True)
|
|
|
|
final: ToolResult | None = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
finally:
|
|
orch_mod.MetaOrchestrator.iter_events = original_iter_events # type: ignore[assignment]
|
|
|
|
assert final is not None
|
|
assert final.is_error is False
|
|
assert captured["run_id"] == "01CONTROL"
|
|
assert captured["inputs"] == {
|
|
"user_message": "Visible request only",
|
|
"audience": "decision owner",
|
|
"meta_preflight_confirmed": True,
|
|
"meta_preflight_run_id": "01CONTROL",
|
|
"system_prompt": "outer system prompt",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 6: Dispatch loop intercepts meta_invoke and terminates turn on success
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_intercepts_meta_invoke_and_terminates_turn(
|
|
tmp_path,
|
|
) -> None:
|
|
"""When the LLM emits tool_use(meta_invoke, ...), the dispatch loop
|
|
must intercept BEFORE the standard handler (which would raise
|
|
RuntimeError from the Task 1 guard) and call _run_one_streaming
|
|
inline. On success, terminates_turn=True must propagate to the
|
|
Agent's turn_yielded flag so the outer loop exits."""
|
|
from collections.abc import AsyncIterator
|
|
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import (
|
|
AgentConfig,
|
|
DoneEvent,
|
|
ErrorEvent,
|
|
TextDeltaEvent,
|
|
ToolResultEvent,
|
|
)
|
|
from opensquilla.provider.types import (
|
|
DoneEvent as ProviderDoneEvent,
|
|
)
|
|
from opensquilla.provider.types import (
|
|
ToolUseDeltaEvent as ProviderToolUseDelta,
|
|
)
|
|
from opensquilla.provider.types import (
|
|
ToolUseEndEvent as ProviderToolUseEnd,
|
|
)
|
|
from opensquilla.provider.types import (
|
|
ToolUseStartEvent as ProviderToolUseStart,
|
|
)
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401 — registers meta_invoke
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
# Synthesize a tiny meta-skill (same trick as Task 5 happy path).
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: tiny meta-skill for dispatch test\n"
|
|
"triggers: [tiny-meta-trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n"
|
|
"# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
# Stub provider that emits ONE tool_use(meta_invoke, name="meta-tiny")
|
|
# then DoneEvent. If the dispatch loop ever lets the meta_invoke
|
|
# tool reach the standard handler, the registered guard raises
|
|
# RuntimeError and the turn ends with an error.
|
|
class _StubProvider:
|
|
provider_name = "stub"
|
|
|
|
async def chat(
|
|
self, messages, tools=None, config=None,
|
|
) -> AsyncIterator:
|
|
yield ProviderToolUseStart(
|
|
tool_use_id="tu_1",
|
|
tool_name="meta_invoke",
|
|
)
|
|
yield ProviderToolUseDelta(
|
|
tool_use_id="tu_1",
|
|
json_fragment='{"name": "meta-tiny"}',
|
|
)
|
|
yield ProviderToolUseEnd(
|
|
tool_use_id="tu_1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
yield ProviderDoneEvent(stop_reason="tool_use")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
registry = get_default_registry()
|
|
assert registry.get("meta_invoke") is not None
|
|
|
|
config = AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=4,
|
|
system_prompt="",
|
|
metadata={"skill_loader": loader, "bootstrap_workspace_dir": str(tmp_path)},
|
|
)
|
|
|
|
agent = Agent(
|
|
provider=_StubProvider(), # type: ignore[arg-type]
|
|
config=config,
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=registry,
|
|
tool_context=ToolContext(workspace_dir=str(tmp_path), is_owner=True),
|
|
)
|
|
|
|
# Override the llm_classify path
|
|
async def fake_llm_chat(_s: str, _u: str) -> str:
|
|
return "A"
|
|
|
|
agent._test_llm_chat_override = fake_llm_chat # type: ignore[attr-defined]
|
|
|
|
# Drive the turn
|
|
events = []
|
|
async for ev in agent.run_turn("trigger meta-tiny somehow"):
|
|
events.append(ev)
|
|
|
|
# The Task 1 guard handler raises a RuntimeError that mentions
|
|
# "_run_one_streaming" or "intercept". If interception failed and
|
|
# the handler was hit, that error would surface in the
|
|
# ToolResultEvent emitted afterward. Search for it.
|
|
error_texts: list[str] = []
|
|
for e in events:
|
|
if isinstance(e, ToolResultEvent):
|
|
error_texts.append(e.result or "")
|
|
if isinstance(e, ErrorEvent):
|
|
error_texts.append(e.message or "")
|
|
flat = " | ".join(error_texts)
|
|
assert "_run_one_streaming" not in flat, (
|
|
f"Dispatch loop did NOT intercept meta_invoke — guard handler "
|
|
f"was reached. Events: {flat[:500]}"
|
|
)
|
|
|
|
# Turn must terminate cleanly with a DoneEvent (terminates_turn drives
|
|
# the outer-loop break, after which the agent emits DoneEvent).
|
|
assert any(isinstance(e, DoneEvent) for e in events), (
|
|
"Expected DoneEvent at end of turn"
|
|
)
|
|
|
|
# And critically — the ToolResultEvent for meta_invoke must show
|
|
# is_error=False (success path). If interception failed, the
|
|
# standard handler would have raised RuntimeError and the
|
|
# ToolResultEvent would carry is_error=True.
|
|
meta_invoke_results = [
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name == "meta_invoke"
|
|
]
|
|
assert meta_invoke_results, "Expected at least one ToolResultEvent for meta_invoke"
|
|
assert all(not r.is_error for r in meta_invoke_results), (
|
|
f"meta_invoke ToolResultEvent must be success; got error contents: "
|
|
f"{[r.result for r in meta_invoke_results if r.is_error]}"
|
|
)
|
|
|
|
# Positive evidence: the meta-skill's single llm_classify step is
|
|
# overridden to return "A" (see fake_llm_chat). On success that text
|
|
# must surface in the meta_invoke ToolResultEvent content — proves
|
|
# the orchestrator actually ran the composition, not just that the
|
|
# dispatch interceptor silently returned an empty success.
|
|
streamed_text = "".join(e.text for e in events if isinstance(e, TextDeltaEvent))
|
|
assert "A" in streamed_text, (
|
|
"Expected llm_classify result 'A' to stream as final answer; "
|
|
f"got: {streamed_text[:300]!r}"
|
|
)
|
|
success_contents = " | ".join(r.result or "" for r in meta_invoke_results)
|
|
assert "meta-skill 'meta-tiny' completed." in success_contents
|
|
|
|
# The orchestrator emits ToolUseStartEvent / ToolResultEvent for each
|
|
# meta-step (tool_name="meta-step:<step_id>"). The dispatch interceptor
|
|
# must forward these to the outer turn stream so the WebUI can render
|
|
# each step as a tool-call card — same visual treatment as the
|
|
# hard-takeover path. If these don't appear, soft-path turns look
|
|
# like a single opaque "meta_invoke" tool call to the UI, even though
|
|
# internally a multi-step DAG ran.
|
|
from opensquilla.engine.types import ToolUseStartEvent
|
|
step_starts = [
|
|
e for e in events
|
|
if isinstance(e, ToolUseStartEvent) and e.tool_name.startswith("meta-step:")
|
|
]
|
|
step_results = [
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name.startswith("meta-step:")
|
|
]
|
|
assert step_starts, (
|
|
"Expected at least one ToolUseStartEvent with tool_name='meta-step:<id>' "
|
|
"in the parent turn stream — dispatch interceptor must forward nested "
|
|
f"orchestrator events. Got event types: "
|
|
f"{sorted({type(e).__name__ for e in events})}"
|
|
)
|
|
assert step_results, (
|
|
"Expected at least one ToolResultEvent with tool_name='meta-step:<id>'."
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_coerces_meta_skill_view_to_meta_invoke(
|
|
tmp_path,
|
|
) -> None:
|
|
"""If the model calls skill_view for a meta-skill, treat it as
|
|
meta_invoke so reading the meta SKILL.md cannot silently bypass the
|
|
orchestrator."""
|
|
from collections.abc import AsyncIterator
|
|
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig, DoneEvent, TextDeltaEvent, ToolResultEvent
|
|
from opensquilla.provider.types import DoneEvent as ProviderDoneEvent
|
|
from opensquilla.provider.types import ToolUseDeltaEvent as ProviderToolUseDelta
|
|
from opensquilla.provider.types import ToolUseEndEvent as ProviderToolUseEnd
|
|
from opensquilla.provider.types import ToolUseStartEvent as ProviderToolUseStart
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: tiny meta-skill for skill_view coercion\n"
|
|
"triggers: [tiny-meta-trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n"
|
|
"# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _StubProvider:
|
|
provider_name = "stub"
|
|
|
|
async def chat(
|
|
self, messages, tools=None, config=None,
|
|
) -> AsyncIterator:
|
|
yield ProviderToolUseStart(tool_use_id="tu_1", tool_name="skill_view")
|
|
yield ProviderToolUseDelta(
|
|
tool_use_id="tu_1",
|
|
json_fragment='{"name": "meta-tiny"}',
|
|
)
|
|
yield ProviderToolUseEnd(
|
|
tool_use_id="tu_1",
|
|
tool_name="skill_view",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
yield ProviderDoneEvent(stop_reason="tool_use")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_StubProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=4,
|
|
system_prompt="",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
tool_context=ToolContext(workspace_dir=str(tmp_path), is_owner=True),
|
|
)
|
|
|
|
async def fake_llm_chat(_s: str, _u: str) -> str:
|
|
return "A"
|
|
|
|
agent._test_llm_chat_override = fake_llm_chat # type: ignore[attr-defined]
|
|
|
|
events = []
|
|
async for ev in agent.run_turn("trigger meta-tiny via skill_view"):
|
|
events.append(ev)
|
|
|
|
assert any(isinstance(e, DoneEvent) for e in events)
|
|
meta_invoke_results = [
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name == "meta_invoke"
|
|
]
|
|
assert meta_invoke_results, (
|
|
"skill_view(name=<meta-skill>) must be coerced into meta_invoke"
|
|
)
|
|
assert all(not r.is_error for r in meta_invoke_results)
|
|
assert any(
|
|
"meta-skill 'meta-tiny' completed." in (r.result or "")
|
|
for r in meta_invoke_results
|
|
)
|
|
assert "A" in "".join(e.text for e in events if isinstance(e, TextDeltaEvent))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_repairs_malformed_meta_invoke_from_matched_meta_skill(
|
|
tmp_path,
|
|
) -> None:
|
|
"""A deterministic meta match may force ``meta_invoke`` on small models
|
|
that emit raw/non-JSON arguments. Repair that to the matched skill name
|
|
instead of letting dispatch reject the tool call."""
|
|
from collections.abc import AsyncIterator
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig, DoneEvent, TextDeltaEvent, ToolResultEvent
|
|
from opensquilla.provider.types import DoneEvent as ProviderDoneEvent
|
|
from opensquilla.provider.types import ToolUseEndEvent as ProviderToolUseEnd
|
|
from opensquilla.provider.types import ToolUseStartEvent as ProviderToolUseStart
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: tiny meta-skill for malformed meta_invoke coercion\n"
|
|
"triggers: [tiny-meta-trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _StubProvider:
|
|
provider_name = "stub"
|
|
|
|
async def chat(self, messages, tools=None, config=None) -> AsyncIterator:
|
|
yield ProviderToolUseStart(tool_use_id="tu_1", tool_name="meta_invoke")
|
|
yield ProviderToolUseEnd(
|
|
tool_use_id="tu_1",
|
|
tool_name="meta_invoke",
|
|
arguments={"_raw": "meta-tiny"},
|
|
)
|
|
yield ProviderDoneEvent(stop_reason="tool_use")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_StubProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=4,
|
|
system_prompt="",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
"meta_match": SimpleNamespace(
|
|
plan=SimpleNamespace(name="meta-tiny"),
|
|
),
|
|
"meta_match_tool_choice": {
|
|
"type": "function",
|
|
"function": {"name": "meta_invoke"},
|
|
},
|
|
},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
tool_context=ToolContext(workspace_dir=str(tmp_path), is_owner=True),
|
|
)
|
|
|
|
async def fake_llm_chat(_s: str, _u: str) -> str:
|
|
return "A"
|
|
|
|
agent._test_llm_chat_override = fake_llm_chat # type: ignore[attr-defined]
|
|
|
|
events = [ev async for ev in agent.run_turn("tiny-meta-trigger")]
|
|
|
|
assert any(isinstance(e, DoneEvent) for e in events)
|
|
meta_invoke_results = [
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name == "meta_invoke"
|
|
]
|
|
assert meta_invoke_results
|
|
assert all(not r.is_error for r in meta_invoke_results)
|
|
assert "A" in "".join(e.text for e in events if isinstance(e, TextDeltaEvent))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_rewrites_other_tool_after_forced_meta_match(
|
|
tmp_path,
|
|
) -> None:
|
|
"""If a forced deterministic meta match is present, do not let an ordinary
|
|
tool call bypass the matched meta DAG."""
|
|
from collections.abc import AsyncIterator
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig, ToolResultEvent
|
|
from opensquilla.provider.types import DoneEvent as ProviderDoneEvent
|
|
from opensquilla.provider.types import ToolUseEndEvent as ProviderToolUseEnd
|
|
from opensquilla.provider.types import ToolUseStartEvent as ProviderToolUseStart
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: tiny meta-skill for forced rewrite\n"
|
|
"triggers: [tiny-meta-trigger]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: \"x\"}\n"
|
|
"---\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _StubProvider:
|
|
provider_name = "stub"
|
|
|
|
async def chat(self, messages, tools=None, config=None) -> AsyncIterator:
|
|
yield ProviderToolUseStart(tool_use_id="tu_1", tool_name="memory_search")
|
|
yield ProviderToolUseEnd(
|
|
tool_use_id="tu_1",
|
|
tool_name="memory_search",
|
|
arguments={"query": "x"},
|
|
)
|
|
yield ProviderDoneEvent(stop_reason="tool_use")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
agent = Agent(
|
|
provider=_StubProvider(), # type: ignore[arg-type]
|
|
config=AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=4,
|
|
system_prompt="",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
"meta_match": SimpleNamespace(
|
|
plan=SimpleNamespace(name="meta-tiny"),
|
|
),
|
|
"meta_match_tool_choice": {
|
|
"type": "function",
|
|
"function": {"name": "meta_invoke"},
|
|
},
|
|
},
|
|
),
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=get_default_registry(),
|
|
tool_context=ToolContext(workspace_dir=str(tmp_path), is_owner=True),
|
|
)
|
|
|
|
async def fake_llm_chat(_s: str, _u: str) -> str:
|
|
return "A"
|
|
|
|
agent._test_llm_chat_override = fake_llm_chat # type: ignore[attr-defined]
|
|
|
|
events = [ev async for ev in agent.run_turn("tiny-meta-trigger")]
|
|
meta_invoke_results = [
|
|
e for e in events
|
|
if isinstance(e, ToolResultEvent) and e.tool_name == "meta_invoke"
|
|
]
|
|
|
|
assert meta_invoke_results
|
|
assert all(not r.is_error for r in meta_invoke_results)
|
|
assert not any(
|
|
isinstance(e, ToolResultEvent) and e.tool_name == "memory_search"
|
|
for e in events
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Task 5C: Soft path wires meta_run_writer + triggered_by="soft_meta_invoke"
|
|
# into the MetaOrchestrator ctor when AgentConfig.metadata carries the writer.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_invoke_passes_writer_with_soft_trigger(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Soft path constructs MetaOrchestrator with triggered_by='soft_meta_invoke'
|
|
and forwards the writer from AgentConfig.metadata['meta_run_writer']."""
|
|
from opensquilla.engine.agent import Agent
|
|
from opensquilla.engine.types import AgentConfig
|
|
from opensquilla.persistence.meta_run_writer import open_meta_run_writer
|
|
from opensquilla.persistence.migrator import apply_pending
|
|
from opensquilla.skills.loader import SkillLoader
|
|
from opensquilla.skills.meta.types import MetaResult
|
|
from opensquilla.tool_boundary import ToolCall, ToolResult
|
|
from opensquilla.tools.builtin import meta_tools # noqa: F401 — registers meta_invoke
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import ToolContext
|
|
|
|
# Apply migrations + open writer against tmp_path DB.
|
|
db = str(tmp_path / "t.db")
|
|
migrations_dir = Path(__file__).resolve().parents[1].parent / "migrations"
|
|
apply_pending(db, migrations_dir)
|
|
writer = open_meta_run_writer(db)
|
|
|
|
# Synthesize a tiny meta-skill so plan parsing succeeds.
|
|
bundled = tmp_path / "skills" / "bundled"
|
|
bundled.mkdir(parents=True)
|
|
skill_dir = bundled / "meta-tiny"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: meta-tiny\n"
|
|
"kind: meta\n"
|
|
"description: t\n"
|
|
"triggers: [t]\n"
|
|
"composition:\n"
|
|
" steps:\n"
|
|
" - id: c\n"
|
|
" kind: llm_classify\n"
|
|
" output_choices: [A, B]\n"
|
|
" with: {text: x}\n"
|
|
"---\n# meta-tiny\n",
|
|
encoding="utf-8",
|
|
)
|
|
loader = SkillLoader(bundled_dir=bundled, snapshot_path=tmp_path / "snap.json")
|
|
loader.invalidate_cache()
|
|
loader.load_all()
|
|
|
|
class _NullProvider:
|
|
provider_name = "null"
|
|
|
|
async def chat(self, *_a, **_kw):
|
|
raise AssertionError("provider.chat must not fire")
|
|
|
|
async def list_models(self):
|
|
return []
|
|
|
|
registry = get_default_registry()
|
|
|
|
config = AgentConfig(
|
|
model_id="stub",
|
|
max_iterations=1,
|
|
system_prompt="",
|
|
metadata={
|
|
"skill_loader": loader,
|
|
"bootstrap_workspace_dir": str(tmp_path),
|
|
"meta_run_writer": writer,
|
|
},
|
|
)
|
|
agent = Agent(
|
|
provider=_NullProvider(), # type: ignore[arg-type]
|
|
config=config,
|
|
tool_definitions=[],
|
|
tool_handler=None,
|
|
tool_registry=registry,
|
|
session_key="sess-soft-1",
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class _StubOrch:
|
|
def __init__(self, *args, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
async def iter_events(self, _match):
|
|
yield MetaResult(ok=True, final_text="captured")
|
|
|
|
monkeypatch.setattr(
|
|
"opensquilla.skills.meta.orchestrator.MetaOrchestrator",
|
|
_StubOrch,
|
|
)
|
|
|
|
tc = ToolCall(
|
|
tool_use_id="u1",
|
|
tool_name="meta_invoke",
|
|
arguments={"name": "meta-tiny"},
|
|
)
|
|
tool_ctx = ToolContext(workspace_dir=str(tmp_path), is_owner=True)
|
|
|
|
final: ToolResult | None = None
|
|
async for ev in agent._run_one_streaming(tc, tool_ctx):
|
|
if isinstance(ev, ToolResult):
|
|
final = ev
|
|
|
|
try:
|
|
assert final is not None
|
|
assert final.is_error is False
|
|
assert captured.get("triggered_by") == "soft_meta_invoke"
|
|
assert captured.get("run_writer") is writer
|
|
assert captured.get("session_key") == "sess-soft-1"
|
|
finally:
|
|
writer.close()
|