213 lines
9.4 KiB
Python
213 lines
9.4 KiB
Python
"""Regression: web_fetch's ``__web_researcher`` must resolve on a bundle re-parse.
|
|
|
|
``WebFetchTool`` synthesizes the ``__web_researcher`` sub-agent spec in memory
|
|
and appends it to the parent's live ``sub_agents`` list, but that spec is never
|
|
serialized into the parent's persisted bundle. A child ``__web_researcher``
|
|
session boots by re-parsing the bundle fresh (``runner/_entry.py`` spec
|
|
resolver), so the researcher is absent from the re-parsed tree.
|
|
|
|
Before the fix, :func:`_find_spec_by_name` returned ``None`` for that
|
|
resolve-miss, and every swap site (``runner/app.py`` POST /v1/sessions,
|
|
``_run_turn_bg``, ``_resolve_session_spec_entry``,
|
|
``_resolve_harness_and_spawn_env``; ``server/routes/sessions.py``) swaps only
|
|
``if ... is not None``, so the child silently booted as a full clone of the
|
|
parent. When the parent is a coordinator, every ``__web_researcher`` became a
|
|
coordinator clone that re-ran the whole panel: runaway recursion / fan-out via
|
|
``sys_session_send`` (the failure mode ``runner/app.py`` calls out by name).
|
|
|
|
The fix reconstructs the lean researcher from the parent on a resolve-miss, so
|
|
the child boots as the intended ``max_iterations=5`` curl helper instead.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from omnigent.runtime.workflow import _find_spec_by_name
|
|
from omnigent.spec.types import (
|
|
AgentSpec,
|
|
BuiltinToolConfig,
|
|
ExecutorSpec,
|
|
LLMConfig,
|
|
ToolsConfig,
|
|
)
|
|
from omnigent.tools.builtins.web_fetch import RESEARCHER_NAME, build_researcher_spec
|
|
|
|
|
|
def _coordinator_parent(*, web_fetch: bool = True) -> AgentSpec:
|
|
"""A coordinator-style parent as re-parsed from its persisted bundle.
|
|
|
|
Critically, it does NOT contain ``__web_researcher`` in ``sub_agents`` --
|
|
``WebFetchTool`` only appends that spec in memory at runtime, and it is
|
|
never serialized, so a fresh bundle parse lacks it. The ``panelist`` child
|
|
stands in for the real sub-agents a grounded coordinator declares.
|
|
|
|
:param web_fetch: When ``True`` (default), the parent declares the
|
|
``web_fetch`` builtin in ``tools.builtins`` -- the authored config
|
|
that is the sole reason ``__web_researcher`` exists, and which IS
|
|
serialized into the bundle. When ``False``, the parent never enabled
|
|
web_fetch, so it has no researcher child.
|
|
:returns: A parent :class:`AgentSpec` without the researcher in its tree.
|
|
"""
|
|
panelist = AgentSpec(spec_version=1, name="panelist")
|
|
builtins = [BuiltinToolConfig(name="web_fetch")] if web_fetch else []
|
|
return AgentSpec(
|
|
spec_version=1,
|
|
name="concordia",
|
|
llm=LLMConfig(model="openai/gpt-5.4"),
|
|
# A real bootable omnigent-executor coordinator carries a harness; the
|
|
# researcher inherits it so the reconstructed child is spawnable.
|
|
executor=ExecutorSpec(max_iterations=40, config={"harness": "claude-sdk"}),
|
|
tools=ToolsConfig(builtins=builtins),
|
|
sub_agents=[panelist],
|
|
)
|
|
|
|
|
|
def _nested_web_fetch_parent() -> AgentSpec:
|
|
"""A root whose ``web_fetch`` owner is a NESTED sub-agent, not the root.
|
|
|
|
The root declares no ``web_fetch`` builtin; a nested child does. This is
|
|
the topology #817 missed: the resolver gate inspected only the root's
|
|
builtins, so a nested owner failed the gate and the resolve fell back to a
|
|
parent clone. The root and the nested owner use DIFFERENT LLM models so a
|
|
test can prove reconstruction walks to the owner rather than lazily
|
|
building from the root.
|
|
|
|
:returns: A root :class:`AgentSpec` without ``web_fetch``, whose nested
|
|
``researcher_host`` child owns it.
|
|
"""
|
|
host = AgentSpec(
|
|
spec_version=1,
|
|
name="researcher_host",
|
|
llm=LLMConfig(model="anthropic/claude-nested-7"),
|
|
# The web_fetch owner needs a real harness so the reconstructed
|
|
# researcher (built from THIS owner) is bootable.
|
|
executor=ExecutorSpec(max_iterations=40, config={"harness": "claude-sdk"}),
|
|
tools=ToolsConfig(builtins=[BuiltinToolConfig(name="web_fetch")]),
|
|
)
|
|
return AgentSpec(
|
|
spec_version=1,
|
|
name="concordia",
|
|
llm=LLMConfig(model="openai/gpt-5.4"),
|
|
executor=ExecutorSpec(max_iterations=40),
|
|
tools=ToolsConfig(builtins=[]),
|
|
sub_agents=[host],
|
|
)
|
|
|
|
|
|
def test_web_researcher_resolves_when_nested_subagent_owns_web_fetch() -> None:
|
|
"""A nested ``web_fetch`` owner must still synthesize the lean researcher.
|
|
|
|
Fails before the fix: the gate inspected only the root's builtins, so a
|
|
nested owner failed the gate and the resolver returned ``None``. The
|
|
researcher must be rebuilt from the OWNER node, inheriting the owner's LLM
|
|
(not the root's).
|
|
"""
|
|
root = _nested_web_fetch_parent()
|
|
# Precondition: the root itself neither declares web_fetch nor carries the
|
|
# researcher; only the nested child owns web_fetch.
|
|
assert "web_fetch" not in [b.name for b in root.tools.builtins]
|
|
assert RESEARCHER_NAME not in [s.name for s in root.sub_agents]
|
|
|
|
resolved = _find_spec_by_name(root, RESEARCHER_NAME)
|
|
|
|
assert resolved is not None, (
|
|
"nested web_fetch owner failed the gate; resolver returned None and "
|
|
"every swap site falls back to a parent clone."
|
|
)
|
|
assert resolved.name == RESEARCHER_NAME
|
|
assert resolved.executor.max_iterations == 5
|
|
assert resolved.interaction.conversational is False
|
|
# Built from the nested OWNER, not the root → inherits the owner's LLM.
|
|
assert resolved.llm is not None
|
|
assert resolved.llm.model == "anthropic/claude-nested-7", (
|
|
"researcher inherited the root's LLM, not the nested owner's; the gate "
|
|
"rebuilt from root instead of walking to the web_fetch owner."
|
|
)
|
|
|
|
|
|
def test_web_researcher_resolves_to_lean_researcher_on_bundle_reparse() -> None:
|
|
"""A resolve-miss for ``__web_researcher`` must rebuild the lean researcher.
|
|
|
|
Fails before the fix (the resolver returns ``None``, so every swap site
|
|
falls back to the parent spec and boots the child as a parent clone).
|
|
"""
|
|
parent = _coordinator_parent(web_fetch=True)
|
|
# Precondition: the re-parsed bundle does not carry the researcher...
|
|
assert RESEARCHER_NAME not in [s.name for s in parent.sub_agents]
|
|
# ...but it DOES declare web_fetch, the authored builtin that is the sole
|
|
# reason the researcher exists and which is serialized into the bundle.
|
|
assert "web_fetch" in [b.name for b in parent.tools.builtins]
|
|
|
|
resolved = _find_spec_by_name(parent, RESEARCHER_NAME)
|
|
|
|
assert resolved is not None, (
|
|
"resolve-miss returned None; every swap site falls back to the parent "
|
|
"spec, booting __web_researcher as a parent clone (runaway recursion "
|
|
"via sys_session_send when the parent is a coordinator)."
|
|
)
|
|
assert resolved.name == RESEARCHER_NAME
|
|
# The lean researcher, not the coordinator clone: capped iterations + one-shot.
|
|
assert resolved.executor.max_iterations == 5, (
|
|
f"expected the lean researcher (max_iterations=5), got "
|
|
f"{resolved.executor.max_iterations} -- that is the parent's executor, "
|
|
"i.e. the child booted as a parent clone."
|
|
)
|
|
assert resolved.interaction.conversational is False
|
|
assert resolved.name != parent.name
|
|
# Inherits the parent's LLM so panel grounding keeps working on the
|
|
# parent's provider.
|
|
assert resolved.llm is not None
|
|
assert resolved.llm.model == "openai/gpt-5.4"
|
|
|
|
|
|
def test_web_researcher_not_synthesized_without_web_fetch_builtin() -> None:
|
|
"""Boundary regression: a parent that never enabled ``web_fetch`` must NOT
|
|
get a synthesized ``__web_researcher``.
|
|
|
|
``__web_researcher`` only exists because ``WebFetchTool.__init__`` appends
|
|
it, so a parent without the ``web_fetch`` builtin has no such child.
|
|
Resolving the researcher name against that parent must fall through to
|
|
normal resolution and return ``None`` rather than reconstruct a
|
|
shell-capable child (``build_researcher_spec`` synthesizes an ``OSEnvSpec``)
|
|
from a caller-controlled ``sub_agent_name``.
|
|
"""
|
|
parent = _coordinator_parent(web_fetch=False)
|
|
# Precondition: this parent genuinely lacks the web_fetch builtin.
|
|
assert "web_fetch" not in [b.name for b in parent.tools.builtins]
|
|
|
|
assert _find_spec_by_name(parent, RESEARCHER_NAME) is None
|
|
|
|
|
|
def test_declared_sub_agent_still_resolves_from_tree() -> None:
|
|
"""Bundle-declared sub-agents must still resolve by tree search.
|
|
|
|
Guards against the researcher fallback shadowing real specs.
|
|
"""
|
|
parent = _coordinator_parent()
|
|
found = _find_spec_by_name(parent, "panelist")
|
|
assert found is not None
|
|
assert found.name == "panelist"
|
|
|
|
|
|
def test_missing_non_researcher_name_still_returns_none() -> None:
|
|
"""The fallback is scoped to ``__web_researcher`` only.
|
|
|
|
Any other unknown name must still resolve to ``None`` so a genuine
|
|
misconfiguration fails loud rather than silently synthesizing a spec.
|
|
"""
|
|
parent = _coordinator_parent()
|
|
assert _find_spec_by_name(parent, "does-not-exist") is None
|
|
|
|
|
|
def test_declared_web_researcher_is_returned_verbatim() -> None:
|
|
"""When the researcher IS present in the tree it is returned verbatim.
|
|
|
|
Covers the in-process parent where ``WebFetchTool`` already appended the
|
|
researcher: the tree search must win over reconstruction so there is no
|
|
divergence between the appended spec and a freshly rebuilt one.
|
|
"""
|
|
parent = _coordinator_parent()
|
|
declared = build_researcher_spec(parent)
|
|
parent.sub_agents.append(declared)
|
|
found = _find_spec_by_name(parent, RESEARCHER_NAME)
|
|
assert found is declared
|