"""Tests for the ``web_fetch`` built-in tool.""" from __future__ import annotations import shutil import sys import pytest from omnigent.errors import OmnigentError from omnigent.spec.types import ( AgentSpec, ExecutorSpec, LLMConfig, ProviderAuth, ) from omnigent.tools.builtins.web_fetch import ( RESEARCHER_NAME, WebFetchTool, build_researcher_spec, ) # ── Helpers ────────────────────────────────────────── def _make_parent_spec( model: str = "openai/gpt-5.4", executor_type: str | None = None, ) -> AgentSpec: """ Build a minimal parent AgentSpec for testing. :param model: The LLM model string. :param executor_type: Executor type override, or ``None`` for default (omnigent executor on the claude-sdk harness). :returns: An AgentSpec suitable for constructing WebFetchTool. """ # A real bootable ``type="omnigent"`` agent always carries a harness in # ``executor.config`` — without one ``harness_kind`` is the unspawnable # literal "omnigent". Default the helper to a real harness so fixtures build # bootable parents (and ``build_researcher_spec`` does not fail loud). executor = ExecutorSpec(config={"harness": "claude-sdk"}) if executor_type is not None: executor = ExecutorSpec(type=executor_type) return AgentSpec( spec_version=1, name="test-parent", llm=LLMConfig(model=model), executor=executor, ) @pytest.fixture(autouse=True) def _default_sandbox_binary_present(monkeypatch: pytest.MonkeyPatch) -> None: """ Keep the seed-time sandbox probe host-independent for the suite. ``build_researcher_spec`` now calls ``shutil.which`` against the real host PATH for a no-``os_env`` parent (see ``_ensure_default_sandbox_runnable``). Unit tests must not depend on bubblewrap / ``sandbox-exec`` being installed on the runner, so default the probe to "binary present". The probe-specific tests below override this with their own ``monkeypatch.setattr(shutil, "which", ...)``. """ monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}") # ── Schema ─────────────────────────────────────────── def test_web_fetch_schema_is_function() -> None: """Schema is a standard function schema with query + url params.""" parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) schema = tool.get_schema() assert schema["type"] == "function" func = schema["function"] assert func["name"] == "web_fetch" # query is required, url is optional. assert "query" in func["parameters"]["required"] assert "url" in func["parameters"]["properties"] assert "url" not in func["parameters"]["required"] def test_web_fetch_name() -> None: """Tool name is 'web_fetch'.""" assert WebFetchTool.name() == "web_fetch" # ── Researcher spec ────────────────────────────────── def test_researcher_inherits_parent_model() -> None: """ The __web_researcher sub-agent must use the parent's LLM config. If it used a different model, the web_fetch tool would fail for agents using non-default providers (e.g. anthropic). """ parent = _make_parent_spec(model="anthropic/claude-sonnet-4-20250514") tool = WebFetchTool(parent_spec=parent) researcher = tool.researcher_spec assert researcher.llm is not None, ( "Researcher spec must have an llm block — " "without it, the workflow fails with 'no LLM configuration'." ) assert researcher.llm.model == "anthropic/claude-sonnet-4-20250514", ( f"Researcher should inherit parent model, got {researcher.llm.model!r}." ) def test_researcher_has_os_env_for_sys_os_shell() -> None: """ The researcher must declare an ``os_env`` block — that's what registers ``sys_os_shell``, the only tool the researcher uses to fetch URLs (curl, python3 one-liners). What breaks if this fails: the researcher would have no shell primitive at all, can't fetch any URL, and ``web_fetch`` silently degrades to "I cannot retrieve web content." """ parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) researcher = tool.researcher_spec # ``sys_os_shell`` registers when ``spec.os_env`` is non-None # (see ``ToolManager._register_os_env_tools``). The os_env # block on the researcher spec is what makes that registration # fire. assert researcher.os_env is not None, ( "Researcher must declare os_env (got None) so the runtime " "registers sys_os_shell. Without it, the sub-agent has no " "shell primitive and can't fetch any URL." ) def test_researcher_inherits_parent_sandbox_egress() -> None: """ The researcher must inherit the parent's ``os_env.sandbox`` so the parent's egress policy is enforced on the child's ``sys_os_shell``. Regression: ``build_researcher_spec`` previously hard-coded ``OSEnvSpec(type="caller_process")`` with ``sandbox=None``. Because ``create_os_environment`` only wires the MITM egress proxy from ``spec.sandbox`` (egress_rules / egress_allow_private_destinations), a sandbox-less child silently bypassed an egress-restricted parent's allowlist (e.g. reaching localhost / IMDS the parent blocked). """ from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec sandbox = OSEnvSandboxSpec( egress_rules=["GET api.example.com/**"], egress_allow_private_destinations=False, ) parent = AgentSpec( spec_version=1, name="test-parent", llm=LLMConfig(model="openai/gpt-5.4"), executor=ExecutorSpec(config={"harness": "claude-sdk"}), os_env=OSEnvSpec(type="caller_process", sandbox=sandbox), ) researcher = build_researcher_spec(parent) assert researcher.os_env is not None assert researcher.os_env.sandbox is not None, ( "Researcher dropped the parent's sandbox — egress enforcement " "would be silently disabled for the web_fetch child." ) assert researcher.os_env.sandbox.egress_rules == ["GET api.example.com/**"] assert researcher.os_env.sandbox.egress_allow_private_destinations is False def test_researcher_os_env_without_parent_sandbox() -> None: """ When the parent declares no os_env, the researcher still gets a valid os_env (so ``sys_os_shell`` registers) with no sandbox — matching the parent's (absent) policy rather than inventing one. """ parent = _make_parent_spec() assert parent.os_env is None researcher = build_researcher_spec(parent) assert researcher.os_env is not None assert researcher.os_env.sandbox is None def test_no_os_env_parent_fails_at_build_when_bwrap_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: """ A no-os_env parent's researcher inherits the platform-default ``linux_bwrap`` sandbox, so a missing ``bwrap`` binary must fail at spec-build time pointing at the host dependency. Regression (#2068): the probe only ran at spawn time, deep in the run, and the error told the user to set ``os_env.sandbox.type`` — unreachable for a spawn-only parent, which cannot add an ``os_env`` block without also registering OS tools on itself. """ monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(shutil, "which", lambda cmd: None) parent = _make_parent_spec() assert parent.os_env is None with pytest.raises(OmnigentError, match="bubblewrap") as excinfo: build_researcher_spec(parent) assert "sandbox.type" not in str(excinfo.value) def test_no_os_env_parent_builds_when_bwrap_present( monkeypatch: pytest.MonkeyPatch, ) -> None: """With ``bwrap`` on PATH the no-os_env spec builds as before.""" monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/bwrap") researcher = build_researcher_spec(_make_parent_spec()) assert researcher.os_env is not None def test_parent_with_os_env_skips_bwrap_probe( monkeypatch: pytest.MonkeyPatch, ) -> None: """ A parent that declares its own ``os_env`` keeps the inherit- verbatim path: its sandbox posture is its own to configure, so the probe must not second-guess it. """ from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(shutil, "which", lambda cmd: None) parent = AgentSpec( spec_version=1, name="test-parent", llm=LLMConfig(model="openai/gpt-5.4"), executor=ExecutorSpec(config={"harness": "claude-sdk"}), os_env=OSEnvSpec(type="caller_process", sandbox=OSEnvSandboxSpec(type="none")), ) researcher = build_researcher_spec(parent) assert researcher.os_env is not None assert researcher.os_env.sandbox is not None assert researcher.os_env.sandbox.type == "none" def test_no_os_env_parent_fails_at_build_when_sandbox_exec_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: """The same seed-time probe covers the macOS default sandbox.""" monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(shutil, "which", lambda cmd: None) with pytest.raises(OmnigentError, match="sandbox-exec") as excinfo: build_researcher_spec(_make_parent_spec()) assert "sandbox.type" not in str(excinfo.value) def test_no_os_env_parent_builds_when_sandbox_exec_present( monkeypatch: pytest.MonkeyPatch, ) -> None: """With ``sandbox-exec`` on PATH the no-os_env spec builds on macOS.""" monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/sandbox-exec") researcher = build_researcher_spec(_make_parent_spec()) assert researcher.os_env is not None def test_windows_platform_skips_probe( monkeypatch: pytest.MonkeyPatch, ) -> None: """ ``windows_jobobject`` drives kernel Job Objects through ``ctypes`` with no external binary, so there is nothing to probe on Windows. """ monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(shutil, "which", lambda cmd: None) researcher = build_researcher_spec(_make_parent_spec()) assert researcher.os_env is not None def test_researcher_name_is_internal() -> None: """ The researcher name must use __ prefix to avoid collision with user-declared sub-agent names. """ parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) assert tool.researcher_spec.name == RESEARCHER_NAME assert RESEARCHER_NAME.startswith("__"), ( f"Internal sub-agent name should start with __, got {RESEARCHER_NAME!r}." ) def test_researcher_appended_to_parent_sub_agents() -> None: """ After construction, the researcher spec must be in the parent's sub_agents list so _resolve_agent_spec_for_task can find it. """ parent = _make_parent_spec() # sub_agents starts empty. assert len(parent.sub_agents) == 0 WebFetchTool(parent_spec=parent) # Now it should have the researcher. names = [s.name for s in parent.sub_agents] assert RESEARCHER_NAME in names, f"Researcher should be in parent's sub_agents, got {names}." def test_researcher_not_conversational() -> None: """ The researcher should be non-conversational (one-shot task). """ parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) assert tool.researcher_spec.interaction.conversational is False def test_researcher_has_instructions() -> None: """ The researcher must have non-empty instructions that mention web research. """ parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) instructions = tool.researcher_spec.instructions assert instructions is not None # 100 chars minimum ensures non-trivial instructions. If shorter, # the researcher won't have enough guidance to know how to search # the web and extract content. assert len(instructions) > 100, ( f"Researcher instructions too short ({len(instructions)} chars). " f"If < 100, the sub-agent won't have enough context to perform " f"web research effectively." ) assert "web" in instructions.lower() # ── Runner-side dispatch ───────────────────────────── def test_web_fetch_is_runner_dispatched() -> None: """ ``web_fetch`` must be in the runner's local-dispatch set. The Tool itself owns only the schema and the researcher sub-agent spec; the actual spawn runs through ``_execute_subagent_tool`` from ``omnigent/runner/tool_dispatch.py``. If a future change drops web_fetch from ``_ALL_LOCAL_TOOLS`` the LLM would call ``Tool.invoke`` which now raises ``NotImplementedError`` — a silent regression. Pinning the membership here keeps the two sides honest. """ from omnigent.runner.tool_dispatch import should_dispatch_locally assert should_dispatch_locally("web_fetch") is True def test_runner_handler_validates_query_required() -> None: """ The runner handler returns the standard "query is required" error when the LLM omits ``query``. This is the validation web_fetch's old ``invoke`` used to perform; after the sessions-native migration it lives in ``_execute_web_fetch_tool``. Tested end-to-end here so a future migration that re-routes around the handler can't silently drop the validation. """ import asyncio from omnigent.runner.tool_dispatch import _execute_web_fetch_tool result = asyncio.run( _execute_web_fetch_tool( args={}, server_client=None, conversation_id="conv_t", agent_spec=None, task_id="t1", ) ) assert "query" in result.lower() # ── build_researcher_spec standalone ──────────────── def testbuild_researcher_spec_copies_llm() -> None: """ build_researcher_spec must copy the parent's LLM config exactly — same model string, same object reference for connection details. """ llm = LLMConfig( model="groq/llama-4-scout", connection={"api_key": "test-key"}, ) parent = AgentSpec( spec_version=1, llm=llm, executor=ExecutorSpec(config={"harness": "claude-sdk"}), ) researcher = build_researcher_spec(parent) # Same LLM config object (reference copy, not deep copy — # the researcher doesn't modify it). assert researcher.llm is parent.llm assert researcher.llm.model == "groq/llama-4-scout" def testbuild_researcher_spec_default_executor() -> None: """Researcher inherits the parent's omnigent executor type AND harness.""" parent = _make_parent_spec() researcher = build_researcher_spec(parent) assert researcher.executor.type == "omnigent" # The harness carries from the parent so the child is bootable (not the # unspawnable literal "omnigent"). assert researcher.executor.harness_kind == "claude-sdk" def test_researcher_build_fails_loud_when_parent_has_no_harness() -> None: """ A parent with ``ExecutorSpec(type="omnigent", config={})`` (no harness) must NOT silently produce an unbootable child whose ``harness_kind == "omnigent"`` — it must fail loud at build time. The child ``__web_researcher`` session is created without a per-session ``harness_override``, so the runner resolves its harness solely from this spec. A child carrying the literal ``"omnigent"`` would crash the runner with ``unknown harness 'omnigent'`` (the original Layer-1 failure). The resolved harness (e.g. an API ``harness_override`` on a spec with no ``executor.config["harness"]``) is not visible at this call site, so ``build_researcher_spec`` raises a clear, parent-naming error instead. """ import pytest from omnigent.errors import ErrorCode, OmnigentError parent = AgentSpec( spec_version=1, name="no-harness-leg", llm=LLMConfig(model="openai/gpt-5.4"), executor=ExecutorSpec(type="omnigent", config={}), ) with pytest.raises(OmnigentError) as exc_info: build_researcher_spec(parent) # Actionable: names the offending parent leg and the missing harness. assert exc_info.value.code == ErrorCode.INVALID_INPUT assert "no-harness-leg" in str(exc_info.value) assert "harness" in str(exc_info.value).lower() def test_researcher_inherits_parent_harness_auth_and_model() -> None: """ Regression: the reconstructed ``__web_researcher`` must run on the PARENT LEG's harness with the parent's credentials and model. ``build_researcher_spec`` previously copied only ``llm`` and built a bare ``ExecutorSpec(max_iterations=5)``. That bare spec defaults ``type`` to ``"omnigent"`` with an empty ``config``, so: - Layer 1 (active): ``executor.harness_kind`` resolves to the literal ``"omnigent"`` (no ``config["harness"]``), and the runner aborts the researcher spawn with ``RuntimeError: unknown harness 'omnigent'`` before any model routing — every ``web_fetch`` fails on all legs. - Layer 2 (latent): dropping the parent's ``auth`` and model strips the researcher off the parent's provider, so a gateway model such as ``z-ai/glm-5.2`` hits the native router with ``Unknown provider 'z-ai'`` and the codex / claude legs fail on missing credentials. The harness spawn-env builders read ``executor.config["harness"]`` (harness selection), ``executor.model`` (NOT ``llm.model``), and ``executor.auth`` / ``executor.connection`` (credentials + endpoint overrides), so all of these must carry from the parent. """ parent_auth = ProviderAuth(name="openrouter") parent = AgentSpec( spec_version=1, name="pi-parent", llm=LLMConfig(model="z-ai/glm-5.2"), executor=ExecutorSpec( type="omnigent", config={"harness": "pi"}, model="z-ai/glm-5.2", connection={"base_url": "https://openrouter.ai/api/v1"}, auth=parent_auth, ), ) researcher = build_researcher_spec(parent) # Layer 1: the child must NOT be the bare "unknown harness 'omnigent'" # spec — it carries the parent's harness selector. assert researcher.executor.config.get("harness") == "pi", ( "Researcher dropped the parent's harness — the runner would abort " f"with unknown harness {researcher.executor.harness_kind!r}." ) assert researcher.executor.harness_kind == "pi", ( "harness_kind must resolve to the parent's harness, not the literal " f"executor type; got {researcher.executor.harness_kind!r}." ) assert researcher.executor.harness_kind != "omnigent" # Layer 2: credentials + model must carry so the parent's provider routes # the parent's model. assert researcher.executor.auth is parent_auth, ( "Researcher dropped the parent's auth — the gateway model would hit " "the native router (Unknown provider 'z-ai')." ) assert researcher.executor.model == "z-ai/glm-5.2" assert researcher.executor.connection == {"base_url": "https://openrouter.ai/api/v1"} # The fast-cap is preserved. assert researcher.executor.max_iterations == 5 def test_researcher_drops_inline_os_env_from_executor_config() -> None: """ ``executor.config["os_env"]`` is an inline-sub-spec translation artifact; the researcher declares its own top-level ``os_env``. Carrying the config ``os_env`` would re-introduce a stale sandbox mapping, so it is dropped while every other routing key (e.g. ``harness``) is preserved. """ parent = AgentSpec( spec_version=1, name="codex-parent", llm=LLMConfig(model="openai/gpt-5.4"), executor=ExecutorSpec( type="omnigent", config={"harness": "codex", "os_env": {"type": "caller_process"}}, model="openai/gpt-5.4", ), ) researcher = build_researcher_spec(parent) assert researcher.executor.config.get("harness") == "codex" assert "os_env" not in researcher.executor.config # The explicit top-level os_env (which registers sys_os_shell) is intact. assert researcher.os_env is not None def test_web_fetch_is_sync_in_sessions_native_mode() -> None: """ ``web_fetch.is_async()`` returns ``False`` after the DBOS removal. The previous async-dispatch path spawned a ``kind="tool"`` background DBOS workflow per fetch via ``_dispatch_server_tool_async``; that helper and the workflow were deleted with the durability layer. Until a sessions-native async dispatch surface is wired, ``web_fetch`` runs through the synchronous ``invoke`` path. """ parent = _make_parent_spec() tool = WebFetchTool(parent_spec=parent) assert tool.is_async() is False # ``dispatch_async`` is no longer overridden — the base # ``Tool.dispatch_async`` raises ``NotImplementedError``. # Calling it would be a routing bug because ``is_async`` is # False; we don't exercise that path here.