"""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:"). 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:' " "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:'." ) @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=) 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()