Files
2026-07-13 13:12:00 +08:00

557 lines
21 KiB
Python

"""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.