2517 lines
90 KiB
Python
2517 lines
90 KiB
Python
"""
|
|
Unit tests for the Omnigent YAML spec adapter.
|
|
|
|
Covers:
|
|
|
|
- Forward-direction field-family translations (name+prompt,
|
|
executor block, function-type tools).
|
|
- Fail-loud behavior on unsupported concepts (policies,
|
|
``os_env``, MCP tools, ``cancellable_function`` tools).
|
|
- Dispatch detection in :func:`omnigent.spec.load`:
|
|
omnigent YAMLs route to the adapter; omnigent YAMLs
|
|
(identified by ``spec_version``) use the existing parser.
|
|
|
|
These are the phase 2 translation unit tests + fail-loud tests
|
|
called out in ``designs/OMNIGENT_INTEGRATION.md`` under the
|
|
phase 2 test scope.
|
|
|
|
Round-trip tests live in ``test_omnigent_roundtrip.py`` since
|
|
they depend on phase 1's ``agent_spec_to_agent_def`` being
|
|
merged first.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from omnigent.errors import OmnigentError
|
|
from omnigent.spec import load
|
|
from omnigent.spec.omnigent import (
|
|
OMNIGENT_EXECUTOR_TYPE,
|
|
OMNIGENT_TOOL_LANGUAGE,
|
|
agent_def_to_agent_spec,
|
|
)
|
|
from omnigent.spec.types import AgentSpec
|
|
|
|
if TYPE_CHECKING:
|
|
from omnigent.inner.datamodel import AgentDef
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture()
|
|
def hello_world_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Minimal omnigent YAML: ``name`` + ``prompt`` only.
|
|
|
|
Matches ``examples/hello_world.yaml``. Used by detection,
|
|
hello-world translation, and round-trip tests.
|
|
"""
|
|
config = {
|
|
"name": "hello_world",
|
|
"prompt": "You are a friendly assistant. Say hello.",
|
|
}
|
|
path = tmp_path / "hello_world.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def executor_block_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML with an ``executor:`` block declaring
|
|
model, harness, and profile.
|
|
"""
|
|
config = {
|
|
"name": "executor_example",
|
|
"prompt": "Assistant with a fixed executor.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4",
|
|
"harness": "claude-sdk",
|
|
"profile": "test-profile",
|
|
},
|
|
}
|
|
path = tmp_path / "executor.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def function_tools_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML with one function-type tool whose
|
|
``callable:`` points at a real, importable Python function
|
|
(``tests.resources.examples._shared.tool_functions.get_current_time``). The adapter
|
|
recovers the dotted path from the resolved callable's
|
|
``__module__`` + ``__qualname__``.
|
|
"""
|
|
config = {
|
|
"name": "tool_user",
|
|
"prompt": "Use tools when helpful.",
|
|
"executor": {"model": "databricks-claude-sonnet-4"},
|
|
"tools": {
|
|
"get_current_time": {
|
|
"type": "function",
|
|
"description": "Return current time.",
|
|
"callable": "tests.resources.examples._shared.tool_functions.get_current_time",
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "tools.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def policies_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML declaring a ``policies:`` block. The adapter
|
|
lifts this into ``AgentSpec.guardrails.policies`` so the
|
|
omnigent workflow enforces it at the configured phases.
|
|
|
|
The YAML includes an ``executor:`` block so the synthesized
|
|
AgentSpec passes the validator's harness-required check;
|
|
policy translation is orthogonal to executor resolution.
|
|
"""
|
|
config = {
|
|
"name": "policy_example",
|
|
"prompt": "I have policies.",
|
|
"executor": {
|
|
"model": "databricks-gpt-5-mini",
|
|
"harness": "openai-agents",
|
|
},
|
|
"policies": {
|
|
"block_foo": {
|
|
"type": "function",
|
|
"on": ["tool_call"],
|
|
"handler": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "policies.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def os_env_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML declaring a top-level ``os_env:`` block. The
|
|
adapter carries it through the top-level ``AgentSpec.os_env`` field as an
|
|
:class:`OSEnvSpec` dataclass so sub-agents that declare
|
|
``os_env: inherit`` can resolve to it at translation time.
|
|
|
|
Needs a harness on the ``executor`` block so the synthesized
|
|
AgentSpec passes the validator's harness-required check; the
|
|
os_env translation itself is orthogonal to harness selection.
|
|
"""
|
|
config = {
|
|
"name": "os_env_example",
|
|
"prompt": "I touch the filesystem.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"os_env": {
|
|
"type": "caller_process",
|
|
"cwd": ".",
|
|
},
|
|
}
|
|
path = tmp_path / "os_env.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def mcp_tool_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML with a stdio MCP-type tool.
|
|
|
|
Translated to an ``MCPServerConfig(transport="stdio",
|
|
command=..., args=...)`` by the adapter — the
|
|
subprocess is later srt-wrapped (when available) by
|
|
:class:`~omnigent.tools.mcp.McpServerConnection`.
|
|
|
|
Includes ``executor.harness`` so the synthesized AgentSpec
|
|
passes :mod:`omnigent.spec.validator` — the adapter runs
|
|
validation after translation and bails loud on missing
|
|
required fields.
|
|
"""
|
|
config = {
|
|
"name": "mcp_example",
|
|
"prompt": "I use MCP.",
|
|
"executor": {"harness": "claude-sdk", "model": "databricks-claude-sonnet-4"},
|
|
"tools": {
|
|
"glean": {
|
|
"type": "mcp",
|
|
"command": ".venv/bin/python",
|
|
"args": ["-m", "omnigent.inner.databricks_mcps.glean"],
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "mcp.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def mcp_http_tool_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML with an HTTP MCP-type tool (``url`` + headers).
|
|
|
|
Translated to an ``MCPServerConfig(transport="http", url=...,
|
|
headers=...)`` by the adapter. Covers the non-stdio
|
|
branch of :func:`_translate_mcp_tool_from_def`.
|
|
"""
|
|
config = {
|
|
"name": "mcp_http_example",
|
|
"prompt": "I use HTTP MCP.",
|
|
"executor": {"harness": "claude-sdk"},
|
|
"tools": {
|
|
"github": {
|
|
"type": "mcp",
|
|
"url": "https://mcp.example.com/sse",
|
|
"headers": {"Authorization": "Bearer tok_xyz"},
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "mcp_http.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def mcp_databricks_server_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML with the ``databricks_server`` MCP shape —
|
|
omnigent has no resolver for it, so the adapter rejects.
|
|
"""
|
|
config = {
|
|
"name": "mcp_db_example",
|
|
"prompt": "I use a named Databricks MCP.",
|
|
"executor": {"harness": "claude-sdk"},
|
|
"tools": {
|
|
"uc": {
|
|
"type": "mcp",
|
|
"databricks_server": "unity-catalog",
|
|
"profile": "test-profile",
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "mcp_db.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def cancellable_tool_yaml(tmp_path: Path) -> Path:
|
|
"""
|
|
Omnigent YAML declaring a legacy ``cancellable_function``
|
|
tool. Used to verify the adapter REJECTS this shape post-step
|
|
(c) — the runner protocol was retired in favor of plain
|
|
callables dispatched via ``sys_call_async``.
|
|
"""
|
|
config = {
|
|
"name": "cancellable_example",
|
|
"prompt": "I can sleep.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"sleep": {
|
|
"type": "cancellable_function",
|
|
"runner": "tests.resources.examples._shared.tool_functions.sleep_tool",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"seconds": {"type": "number"},
|
|
},
|
|
"required": ["seconds"],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
path = tmp_path / "cancellable.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
return path
|
|
|
|
|
|
@pytest.fixture()
|
|
def omnigent_spec_dir(tmp_path: Path) -> Path:
|
|
"""
|
|
An omnigent spec directory (``spec_version: 1`` in
|
|
``config.yaml``). Routes through the existing parser, not
|
|
the omnigent adapter. Used by the detection tests.
|
|
"""
|
|
config = {
|
|
"spec_version": 1,
|
|
"name": "ap-agent",
|
|
"executor": {"type": "omnigent", "config": {"harness": "claude-sdk"}},
|
|
}
|
|
(tmp_path / "config.yaml").write_text(yaml.dump(config))
|
|
return tmp_path
|
|
|
|
|
|
# ── Translation: direct agent_def_to_agent_spec ──────────────
|
|
|
|
|
|
def test_agent_def_to_agent_spec_hello_world(
|
|
hello_world_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
A minimal YAML (name + prompt only) translates to an
|
|
AgentSpec with name, instructions, spec_version=1, and
|
|
executor.type='omnigent'.
|
|
|
|
What breaks if this fails: the baseline phase 2 dispatch —
|
|
``omnigent chat hello_world.yaml`` can't produce a valid spec
|
|
without this path working.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
agent_def = load_agent_def(hello_world_yaml)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
assert isinstance(spec, AgentSpec)
|
|
assert spec.name == "hello_world"
|
|
# prompt → instructions verbatim.
|
|
assert spec.instructions == "You are a friendly assistant. Say hello."
|
|
# spec_version is synthesized to the current omnigent
|
|
# schema version (no spec_version in omnigent YAMLs).
|
|
assert spec.spec_version == 1
|
|
# executor.type drives the runtime to pick OmnigentExecutor.
|
|
assert spec.executor.type == OMNIGENT_EXECUTOR_TYPE
|
|
# Hello world has no executor block, no llm, no tools.
|
|
assert spec.llm is None
|
|
assert spec.local_tools == []
|
|
|
|
|
|
def test_agent_def_to_agent_spec_accepts_claude_harness_alias(tmp_path: Path) -> None:
|
|
"""Omnigent YAML may use ``harness: claude`` as a spec-level alias."""
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "alias_agent",
|
|
"prompt": "hi",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4",
|
|
"harness": "claude",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
spec = load(yaml_path)
|
|
|
|
assert spec.executor.type == OMNIGENT_EXECUTOR_TYPE
|
|
assert spec.executor.config["harness"] == "claude-sdk"
|
|
|
|
|
|
def test_agent_def_to_agent_spec_executor_block(
|
|
executor_block_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
An ``executor:`` block with model + harness + profile
|
|
populates :attr:`LLMConfig.model` and the harness/profile
|
|
entries in :attr:`ExecutorSpec.config` (the shared
|
|
round-trip contract with phase 1's
|
|
``agent_spec_to_agent_def``).
|
|
|
|
What breaks if this fails: the OmnigentExecutor cannot
|
|
pick a harness or workspace profile at instantiation time,
|
|
so every non-trivial omnigent YAML routes to the wrong
|
|
harness (or fails).
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
agent_def = load_agent_def(executor_block_yaml)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
assert spec.llm is not None
|
|
assert spec.llm.model == "databricks-claude-sonnet-4"
|
|
assert spec.executor.type == OMNIGENT_EXECUTOR_TYPE
|
|
# harness / profile land in executor.config (typed dict),
|
|
# NOT setattr-added attributes. This is the shared wire
|
|
# contract with phase 1's agent_spec_to_agent_def.
|
|
assert spec.executor.config["harness"] == "claude-sdk"
|
|
assert spec.executor.config["profile"] == "test-profile"
|
|
# Mirrored to top-level too: supervisor spawn-env reads
|
|
# spec.executor.profile, not config["profile"].
|
|
assert spec.executor.profile == "test-profile"
|
|
|
|
|
|
def test_agent_def_to_agent_spec_unknown_model_raises(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A YAML with a model that has no known harness prefix raises an
|
|
error — every agent must resolve to a named harness.
|
|
|
|
:param tmp_path: Pytest-provided temporary directory.
|
|
"""
|
|
yaml_path = tmp_path / "kimi.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "kimi",
|
|
"prompt": "You are Kimi.",
|
|
"executor": {"model": "databricks/databricks-kimi-k2-6"},
|
|
}
|
|
)
|
|
)
|
|
|
|
with pytest.raises(Exception, match=r"[Hh]arness"):
|
|
load(yaml_path)
|
|
|
|
|
|
def test_agent_def_to_agent_spec_function_tool(
|
|
function_tools_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
A function-type tool with ``callable:
|
|
tests.resources.examples._shared.tool_functions.get_current_time`` translates to a
|
|
:class:`LocalToolInfo` whose ``path`` is the recovered
|
|
dotted module path.
|
|
|
|
What breaks if this fails: OmnigentExecutor cannot resolve
|
|
the tool callable on the reverse trip, so the harness starts
|
|
without its tools.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
agent_def = load_agent_def(function_tools_yaml)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
assert len(spec.local_tools) == 1
|
|
tool = spec.local_tools[0]
|
|
assert tool.name == "get_current_time"
|
|
# The dotted callable path is recovered from the resolved
|
|
# callable's __module__ + __qualname__ (see
|
|
# _recover_callable_path).
|
|
assert tool.path == "tests.resources.examples._shared.tool_functions.get_current_time"
|
|
# The language sentinel is how the forward direction
|
|
# (agent_spec_to_agent_def, phase 1) knows this tool came
|
|
# from an omnigent YAML and must be re-resolved via
|
|
# importlib.import_module rather than read off disk.
|
|
assert tool.language == OMNIGENT_TOOL_LANGUAGE
|
|
|
|
|
|
def test_agent_def_to_agent_spec_translates_catalog_path_tool(tmp_path: Path) -> None:
|
|
"""
|
|
``catalog_path`` Unity Catalog tools translate into
|
|
``LocalToolInfo`` with ``runtime=UC_FUNCTION``.
|
|
|
|
Failure meaning: UC tools are silently dropped or rejected
|
|
instead of being carried through to the runner for execution
|
|
via the SQL Statement Execution API.
|
|
|
|
:param tmp_path: Pytest temporary directory for the YAML fixture.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
from omnigent.spec.types import ToolRuntime
|
|
|
|
yaml_path = tmp_path / "uc.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "uc_agent",
|
|
"prompt": "Use UC functions.",
|
|
"executor": {"model": "databricks-claude-sonnet-4"},
|
|
"tools": {
|
|
"classify": {
|
|
"type": "function",
|
|
"catalog_path": "main.default.classify",
|
|
"warehouse_id": "wh-abc",
|
|
},
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
agent_def = load_agent_def(yaml_path)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
# UC tool translated into a LocalToolInfo with UC_FUNCTION runtime.
|
|
uc_tools = [t for t in spec.local_tools if t.catalog_path is not None]
|
|
assert len(uc_tools) == 1, (
|
|
f"Expected exactly 1 UC tool, got {len(uc_tools)}. "
|
|
f"If 0, the UC tool was silently dropped during translation."
|
|
)
|
|
tool = uc_tools[0]
|
|
assert tool.name == "classify"
|
|
assert tool.catalog_path == "main.default.classify"
|
|
assert tool.warehouse_id == "wh-abc"
|
|
assert tool.runtime == ToolRuntime.UC_FUNCTION
|
|
# path is None for UC tools (no server-side callable).
|
|
assert tool.path is None
|
|
|
|
|
|
def test_function_tool_parameters_derived_from_callable_signature(
|
|
function_tools_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
When the YAML's function tool declares no ``input_schema:``,
|
|
the Omnigent adapter introspects the resolved Python callable's
|
|
signature and exposes that as the LLM-facing JSON-Schema
|
|
``parameters`` block. Without this fallback, omnigent
|
|
YAMLs that point at plain Python functions ship to the LLM
|
|
with empty parameters and the model invokes the tool with
|
|
zero arguments — surfacing as
|
|
``TypeError: <fn>() missing 1 required positional argument``
|
|
when the harness dispatches the call.
|
|
|
|
The reference YAML's ``get_current_time`` callable signature
|
|
is ``(timezone_name: str = "UTC") -> str``: optional string
|
|
parameter. Verify the schema reflects that exactly.
|
|
|
|
What breaks if this fails:
|
|
- The adapter reverts to forwarding only an explicit
|
|
``input_schema:`` block from YAML (the prior buggy
|
|
behaviour); plain Python tools become unusable under
|
|
Omnigent mode.
|
|
- The schema-derivation helper changes its output shape —
|
|
e.g. drops ``required`` for keyword-only-with-default
|
|
params, or starts emitting required entries for
|
|
defaulted ones.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
agent_def = load_agent_def(function_tools_yaml)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
tool = spec.local_tools[0]
|
|
assert tool.name == "get_current_time"
|
|
# The fallback runs ``_schema_from_callable`` against
|
|
# ``get_current_time``'s ``(timezone_name: str = "UTC")``
|
|
# signature. ``timezone_name`` has a default → not required,
|
|
# but still must appear under ``properties``.
|
|
assert tool.parameters is not None, (
|
|
"parameters must be populated from the callable signature; "
|
|
"if None, the LLM gets zero-arg tool stubs and calls them "
|
|
"with no arguments at runtime (TypeError at dispatch)."
|
|
)
|
|
assert tool.parameters.get("type") == "object"
|
|
properties = tool.parameters.get("properties", {})
|
|
assert "timezone_name" in properties, (
|
|
f"signature param 'timezone_name' missing from derived schema; "
|
|
f"got properties={properties!r}"
|
|
)
|
|
assert properties["timezone_name"] == {"type": "string"}
|
|
# Defaulted param → not required.
|
|
required = tool.parameters.get("required", [])
|
|
assert "timezone_name" not in required, (
|
|
f"defaulted param leaked into 'required'; got {required!r}"
|
|
)
|
|
|
|
|
|
# ── Fail-loud: each unsupported concept gets its own test ─────
|
|
|
|
|
|
def test_load_omnigent_yaml_missing_package_raises_with_install_hint(
|
|
hello_world_yaml: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
When the ``omnigent`` package is not importable (e.g. agent-
|
|
plane pip-installed standalone without the sibling omnigent
|
|
source on PYTHONPATH), loading an omnigent YAML must surface
|
|
a friendly :class:`OmnigentError` with an install hint —
|
|
not a bare ``ModuleNotFoundError`` from deep in the import
|
|
machinery.
|
|
|
|
What breaks if this fails: a user running ``omnigent chat foo.yaml``
|
|
from an env that only has omnigent gets a cryptic
|
|
``ModuleNotFoundError: No module named 'omnigent'`` from
|
|
``_omnigent_compat.py``'s import line, with no clue what
|
|
to install. The rewritten error says what's missing and how
|
|
to fix it.
|
|
"""
|
|
# Simulate ``omnigent`` not being importable by wiping it
|
|
# from ``sys.modules`` and blocking fresh imports. Using a
|
|
# meta-path finder (vs ``sys.modules[...] = None``) catches
|
|
# both cold imports AND any submodule-level import the compat
|
|
# shim might try.
|
|
import sys
|
|
|
|
for mod_name in list(sys.modules):
|
|
if mod_name == "omnigent" or mod_name.startswith("omnigent."):
|
|
monkeypatch.delitem(sys.modules, mod_name, raising=False)
|
|
|
|
from collections.abc import Sequence
|
|
from importlib.machinery import ModuleSpec
|
|
from types import ModuleType
|
|
|
|
class _OmnigentBlocker:
|
|
"""Meta-path finder that refuses to resolve omnigent."""
|
|
|
|
def find_spec(
|
|
self,
|
|
fullname: str,
|
|
path: Sequence[str] | None,
|
|
target: ModuleType | None = None,
|
|
) -> ModuleSpec | None:
|
|
del path, target
|
|
if fullname == "omnigent" or fullname.startswith("omnigent."):
|
|
# Pretend the package doesn't exist at all.
|
|
raise ModuleNotFoundError(
|
|
f"No module named {fullname!r}",
|
|
name=fullname,
|
|
)
|
|
return None
|
|
|
|
blocker = _OmnigentBlocker()
|
|
monkeypatch.setattr(sys, "meta_path", [blocker, *sys.meta_path])
|
|
|
|
with pytest.raises(OmnigentError) as exc_info:
|
|
load(hello_world_yaml)
|
|
|
|
# Error message points at the missing package by name AND
|
|
# gives an actionable install instruction. A bare
|
|
# ModuleNotFoundError would say neither.
|
|
message = str(exc_info.value)
|
|
assert "omnigent" in message
|
|
assert "pip install" in message, (
|
|
f"Expected an install hint in the error message; got: {message!r}"
|
|
)
|
|
|
|
|
|
def test_load_policies_yaml_lifts_into_guardrails(policies_yaml: Path) -> None:
|
|
"""
|
|
Omnigent YAMLs with a ``policies:`` block produce an
|
|
AgentSpec whose ``guardrails.policies`` carries the
|
|
translated policy, preserving ``name``, the dotted callable
|
|
path, and the phase. The omnigent workflow then enforces
|
|
at runtime via the standard :class:`PolicyEngine`.
|
|
|
|
What breaks if this fails: policies in an omnigent YAML
|
|
either silently disappear (unsafe — agent runs without the
|
|
author's declared guardrails) or fail spec-load (regression
|
|
to the pre-lift rejection path).
|
|
"""
|
|
from omnigent.spec.types import FunctionPolicySpec
|
|
|
|
spec = load(policies_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.policies is not None
|
|
# Exactly one policy survived — anything else would mean the
|
|
# translator accidentally duplicated or dropped.
|
|
assert len(spec.guardrails.policies) == 1
|
|
policy = spec.guardrails.policies[0]
|
|
assert isinstance(policy, FunctionPolicySpec)
|
|
assert policy.name == "block_foo"
|
|
# on: is ignored for function policies — callable self-selects.
|
|
assert policy.on is None
|
|
# The author's dotted callable path travels under the shim's
|
|
# ``target`` argument so legacy ``(content, phase)`` callables
|
|
# get adapted at policy-build time.
|
|
assert policy.function is not None
|
|
assert policy.function.path == "omnigent.spec._omnigent_legacy_shim.build"
|
|
assert policy.function.arguments == {
|
|
"target": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
}
|
|
|
|
|
|
def test_load_os_env_yaml_carries_through_top_level_field(
|
|
os_env_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
A top-level ``os_env:`` block on an omnigent YAML
|
|
translates into an :class:`OSEnvSpec` dataclass stashed on
|
|
``AgentSpec.os_env`` (the native top-level field). The
|
|
dataclass flows by reference — no hand-rolled dict
|
|
serialization — because ``AgentSpec`` is never persisted
|
|
to disk on this path.
|
|
|
|
What breaks if this fails: the adapter either regresses to
|
|
the old fail-loud (rejecting every YAML with an os_env
|
|
block), or the ``inherit`` sentinel on inline-AgentTool
|
|
sub-agents has no concrete parent to resolve against and
|
|
sub-agents boot without filesystem access.
|
|
"""
|
|
from omnigent.inner.datamodel import OSEnvSpec
|
|
|
|
spec = load(os_env_yaml)
|
|
|
|
# The dataclass itself is what the adapter carries — a plain
|
|
# dict would mean the serializer re-appeared and the round
|
|
# trip bakes in copy-overhead we don't need.
|
|
assert isinstance(spec.os_env, OSEnvSpec), (
|
|
f"spec.os_env must hold the OSEnvSpec dataclass, got "
|
|
f"{type(spec.os_env).__name__!r}. Hand-rolled dict "
|
|
f"serialization is the antipattern we're avoiding."
|
|
)
|
|
assert spec.os_env.type == "caller_process"
|
|
assert spec.os_env.cwd == "."
|
|
# The legacy executor.config["os_env"] storage is gone; the
|
|
# field flows on the top-level ``AgentSpec.os_env`` only.
|
|
assert "os_env" not in spec.executor.config
|
|
|
|
|
|
def test_load_mcp_stdio_yaml_translates_to_mcp_server(mcp_tool_yaml: Path) -> None:
|
|
"""
|
|
Omnigent YAMLs declaring a subprocess MCP tool translate to
|
|
a native ``MCPServerConfig(transport="stdio", ...)`` entry on
|
|
``AgentSpec.mcp_servers``. At runtime
|
|
:class:`~omnigent.tools.mcp.McpServerConnection` spawns the
|
|
subprocess, srt-wrapped when available.
|
|
|
|
What breaks if this fails: the adapter regresses to the
|
|
old fail-loud rejection, making agents with MCPs
|
|
(e.g. databricks_coding_agent's glean/google) unusable
|
|
under the Omnigent integration path.
|
|
"""
|
|
spec = load(mcp_tool_yaml)
|
|
assert len(spec.mcp_servers) == 1
|
|
mcp = spec.mcp_servers[0]
|
|
# Identity fields come from the YAML key + tool body.
|
|
assert mcp.name == "glean"
|
|
assert mcp.transport == "stdio"
|
|
# Command + args carry through verbatim so the subprocess
|
|
# spawn matches what legacy omnigent ran.
|
|
assert mcp.command == ".venv/bin/python"
|
|
assert mcp.args == ["-m", "omnigent.inner.databricks_mcps.glean"]
|
|
# HTTP fields must stay None / empty on the stdio branch.
|
|
assert mcp.url is None
|
|
assert mcp.headers == {}
|
|
|
|
|
|
def test_load_mcp_http_yaml_translates_to_mcp_server(mcp_http_tool_yaml: Path) -> None:
|
|
"""
|
|
Omnigent YAMLs with an HTTP MCP (``url`` + headers)
|
|
translate to an ``MCPServerConfig(transport="http", ...)``
|
|
entry. Covers the non-stdio branch of
|
|
:func:`_translate_mcp_tool_from_def`.
|
|
|
|
What breaks if this fails: users migrating HTTP MCPs from
|
|
omnigent-legacy to Omnigent mode get either a translator crash
|
|
(``None`` command) or a silently dropped tool.
|
|
"""
|
|
spec = load(mcp_http_tool_yaml)
|
|
assert len(spec.mcp_servers) == 1
|
|
mcp = spec.mcp_servers[0]
|
|
assert mcp.name == "github"
|
|
assert mcp.transport == "http"
|
|
assert mcp.url == "https://mcp.example.com/sse"
|
|
# Headers carry through — provider-auth headers like
|
|
# Authorization must survive translation.
|
|
assert mcp.headers == {"Authorization": "Bearer tok_xyz"}
|
|
# Stdio fields stay empty on the http branch.
|
|
assert mcp.command is None
|
|
assert mcp.args == []
|
|
assert mcp.env == {}
|
|
|
|
|
|
def test_mcp_stdio_yaml_reverse_trip_recovers_mcp_tool(mcp_tool_yaml: Path) -> None:
|
|
"""
|
|
Forward + reverse round-trip: YAML → AgentSpec (with
|
|
MCPServerConfig) → AgentDef (with MCPTool). The reverse
|
|
path is what :meth:`OmnigentExecutor.from_spec` calls
|
|
when wrapping an omnigent spec for an omnigent
|
|
harness; a missing reverse translation drops every MCP
|
|
tool from the AgentDef the harness sees.
|
|
|
|
What breaks if this fails: a live Omnigent mode run with a
|
|
stdio MCP either crashes (reverse path raises
|
|
``unsupported concept``) or silently drops the MCP tool
|
|
(LLM sees no MCP tool, never calls it, agent returns
|
|
"I don't have that tool"). Covers the exact regression
|
|
the live E2E test under tests/e2e/omnigent/ guards
|
|
against.
|
|
"""
|
|
from omnigent.inner.tools import MCPTool
|
|
from omnigent.spec.omnigent import agent_spec_to_agent_def
|
|
|
|
spec = load(mcp_tool_yaml)
|
|
agent_def = agent_spec_to_agent_def(spec)
|
|
tool = agent_def.tools.get("glean")
|
|
assert isinstance(tool, MCPTool), (
|
|
f"Expected reverse trip to recover an MCPTool under 'glean'; "
|
|
f"got {type(tool).__name__!r}. If None, the reverse translator "
|
|
f"dropped the MCP server silently."
|
|
)
|
|
# Transport fields round-trip: command + args must match the
|
|
# originally-declared subprocess, not some lossy approximation.
|
|
assert tool.command == ".venv/bin/python"
|
|
assert tool.args == ["-m", "omnigent.inner.databricks_mcps.glean"]
|
|
|
|
|
|
def test_load_mcp_databricks_server_yaml_raises(mcp_databricks_server_yaml: Path) -> None:
|
|
"""
|
|
Omnigent MCP tools using the ``databricks_server=<name>``
|
|
shape fail loud — Omnigent' MCPServerConfig doesn't
|
|
resolve named Databricks servers. The translator needs a
|
|
concrete ``url`` or ``command`` to emit a functional config.
|
|
|
|
What breaks if this fails: specs with
|
|
``databricks_server: unity-catalog`` would silently translate
|
|
to an MCPServerConfig with neither url nor command — the
|
|
validator would then reject the spec at load, but with a
|
|
less-helpful message than the pinpoint fail here.
|
|
"""
|
|
with pytest.raises(OmnigentError, match="databricks_server"):
|
|
load(mcp_databricks_server_yaml)
|
|
|
|
|
|
def test_load_cancellable_function_yaml_rejected_post_step_c(
|
|
cancellable_tool_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
Omnigent YAMLs declaring ``type: cancellable_function``
|
|
are rejected by the Omnigent adapter with a clear migration hint.
|
|
|
|
Step (c) retired the runner-protocol shape (``runner:`` +
|
|
``CancellableFunctionTool``) in favor of plain callables
|
|
dispatched via ``sys_call_async``. The adapter fails loud
|
|
rather than silently translating, so anyone porting an old
|
|
inner-stack YAML to Omnigent mode gets pointed at the new shape.
|
|
|
|
What breaks if this fails: either the adapter regresses to
|
|
silently accept runner instances (the bug that motivated
|
|
step (c) — non-callable runner instances tripped the
|
|
LocalCallableTool loader at runtime), or the migration
|
|
hint disappears and users hit a confusing internal
|
|
``TypeError`` instead.
|
|
"""
|
|
with pytest.raises(OmnigentError, match="cancellable_function"):
|
|
load(cancellable_tool_yaml)
|
|
|
|
|
|
# ── Dispatch detection in omnigent.spec.load ──────────────
|
|
|
|
|
|
def test_load_omnigent_yaml_routes_to_adapter(
|
|
executor_block_yaml: Path,
|
|
) -> None:
|
|
"""
|
|
A ``.yaml`` file with ``name`` + ``prompt`` and no
|
|
``spec_version`` routes through the omnigent adapter.
|
|
The produced spec has ``executor.type='omnigent'``, which
|
|
no omnigent native spec ever emits.
|
|
|
|
Uses ``executor_block_yaml`` (which declares a harness) rather
|
|
than the bare ``hello_world_yaml`` so the synthesized spec
|
|
passes the validator's harness-required check; the dispatch
|
|
itself doesn't depend on which valid omnigent YAML we feed.
|
|
|
|
What breaks if this fails: ``omnigent chat foo.yaml`` against an
|
|
omnigent YAML would either crash (no config.yaml in a
|
|
file-shaped source) or silently parse as an omnigent
|
|
spec, producing nonsense.
|
|
"""
|
|
spec = load(executor_block_yaml)
|
|
assert spec.executor.type == OMNIGENT_EXECUTOR_TYPE
|
|
assert spec.name == "executor_example"
|
|
assert spec.executor.config["harness"] == "claude-sdk"
|
|
|
|
|
|
def test_load_omnigent_directory_uses_existing_parser(
|
|
omnigent_spec_dir: Path,
|
|
) -> None:
|
|
"""
|
|
An omnigent spec directory (``spec_version`` declared)
|
|
routes through the existing parser unchanged. The resulting
|
|
spec has ``executor.type='omnigent'`` (the default)
|
|
and no omnigent extras.
|
|
|
|
What breaks if this fails: all existing omnigent specs
|
|
would stop loading (regression surfaces in every omnigent
|
|
test suite, so this is a smoke check).
|
|
"""
|
|
spec = load(omnigent_spec_dir)
|
|
# Default executor type, proving the dispatch routed correctly.
|
|
assert spec.executor.type == "omnigent"
|
|
assert spec.name == "ap-agent"
|
|
|
|
|
|
def test_load_yaml_with_spec_version_not_routed_to_adapter(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
A ``.yaml`` file that happens to contain ``name`` +
|
|
``prompt`` but also declares ``spec_version`` is treated as
|
|
an omnigent spec. The detection rule's negative check on
|
|
``spec_version`` prevents misrouting.
|
|
|
|
This case currently still fails (omnigent YAML specs are
|
|
directories, not files), but the dispatch MUST pick the
|
|
non-omnigent branch so the resulting error is an
|
|
omnigent "dest is required" / parser error — NOT an
|
|
omnigent adapter error. The assertion below verifies the
|
|
error shape is NOT an omnigent-adapter error.
|
|
|
|
What breaks if this fails: a future change that starts
|
|
supporting omnigent YAML-file specs would silently route
|
|
to the wrong adapter.
|
|
"""
|
|
config = {
|
|
"spec_version": 1,
|
|
"name": "hybrid",
|
|
"prompt": "Looks omnigent-y but is omnigent.",
|
|
}
|
|
path = tmp_path / "hybrid.yaml"
|
|
path.write_text(yaml.dump(config))
|
|
|
|
# ``spec_version`` marks this file as an omnigent spec, but
|
|
# omnigent specs must live in a directory with a
|
|
# ``config.yaml``, not a single YAML file. ``load()`` rejects with
|
|
# an actionable diagnostic. If detection were wrong, we'd get an
|
|
# omnigent-adapter error (e.g. "missing system-prompt key")
|
|
# instead — the assertion below pins the omnigent shape.
|
|
with pytest.raises(OmnigentError, match="spec_version"):
|
|
load(path)
|
|
|
|
|
|
# ── os_env propagation ───────────────────────────────────────
|
|
|
|
|
|
def test_cancellable_function_parameters_forward_trip_preserves_input_schema() -> None:
|
|
"""
|
|
Forward: a :class:`CancellableFunctionTool` is rejected with
|
|
a clear migration message — the runner protocol was retired
|
|
in step (c) in favor of plain callables dispatched via
|
|
``sys_call_async``.
|
|
|
|
**What breaks if this fails**: the adapter silently translates
|
|
runner-protocol tools (the bug that motivated step (c) — the
|
|
instance is non-callable, so ``LocalCallableTool`` trips at
|
|
runtime with a confusing ``TypeError``). The fail-loud here
|
|
catches the regression at translation time with an actionable
|
|
message.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import CancellableFunctionTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
seconds_schema = {
|
|
"type": "object",
|
|
"properties": {"seconds": {"type": "number"}},
|
|
"required": ["seconds"],
|
|
}
|
|
original = AgentDef(
|
|
name="round_tripper",
|
|
prompt="p",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
tools={
|
|
"sleep": CancellableFunctionTool(
|
|
name="sleep",
|
|
description="Sleep for N seconds",
|
|
runner=_stub_runner_instance,
|
|
input_schema=seconds_schema,
|
|
),
|
|
},
|
|
)
|
|
|
|
with pytest.raises(OmnigentError, match="cancellable_function"):
|
|
agent_def_to_agent_spec(original)
|
|
|
|
|
|
def test_function_tool_parameters_round_trip_preserves_input_schema(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""
|
|
Forward then reverse: a plain :class:`FunctionTool` with an
|
|
explicit ``input_schema`` round-trips through omnigent
|
|
translation and back without losing the schema.
|
|
|
|
Step (c) made plain callables the only supported function-tool
|
|
shape on the Omnigent path. Schema preservation matters because the
|
|
inner harness's ``tool_schema()`` falls back to introspecting
|
|
the callable when ``input_schema`` is absent — fine for
|
|
well-typed functions, but fragile for tools with non-trivial
|
|
parameter shapes (Pydantic models, optional fields, etc.).
|
|
Pinning the round-trip catches regressions where the
|
|
translator drops ``parameters`` somewhere along the way.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import FunctionTool
|
|
from omnigent.spec import omnigent as spec_omni
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
seconds_schema = {
|
|
"type": "object",
|
|
"properties": {"seconds": {"type": "number"}},
|
|
"required": ["seconds"],
|
|
}
|
|
|
|
def _stub_callable(seconds: float) -> dict[str, float]:
|
|
"""Stub used as the resolved callable in the reverse trip."""
|
|
return {"slept": seconds}
|
|
|
|
original = AgentDef(
|
|
name="round_tripper",
|
|
prompt="p",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
tools={
|
|
"sleep": FunctionTool(
|
|
name="sleep",
|
|
description="Sleep for N seconds",
|
|
callable=_stub_callable,
|
|
input_schema=seconds_schema,
|
|
),
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(original)
|
|
assert len(spec.local_tools) == 1
|
|
tool_info = spec.local_tools[0]
|
|
assert tool_info.name == "sleep"
|
|
assert tool_info.parameters == seconds_schema, (
|
|
"FunctionTool lost its input_schema on the forward trip — "
|
|
"the reverse trip will rebuild a no-args tool and the LLM will emit "
|
|
"empty-argument tool calls."
|
|
)
|
|
|
|
# Reverse trip — stub the dotted-path resolver since
|
|
# ``_stub_callable`` is a closure (not module-level) and
|
|
# `_recover_callable_path` would have used the real qualname.
|
|
monkeypatch.setattr(
|
|
spec_omni,
|
|
"_resolve_dotted_attr",
|
|
lambda _path, _name: _stub_callable,
|
|
)
|
|
rebuilt = spec_omni.agent_spec_to_agent_def(spec)
|
|
assert "sleep" in rebuilt.tools
|
|
rebuilt_tool = rebuilt.tools["sleep"]
|
|
assert rebuilt_tool.input_schema == seconds_schema
|
|
advertised = rebuilt_tool.tool_schema()
|
|
assert advertised.get("parameters") == seconds_schema
|
|
|
|
|
|
class _StubCancellableRunner:
|
|
"""
|
|
Module-level runner class kept for the rejection test.
|
|
|
|
Used solely to construct a :class:`CancellableFunctionTool`
|
|
that the forward translator can reject. Never called.
|
|
"""
|
|
|
|
def start(self, args: dict[str, Any], on_complete: Any) -> None:
|
|
"""Stub — never actually called by the tests above."""
|
|
raise NotImplementedError
|
|
|
|
|
|
# Module-level binding so a stable instance exists for the
|
|
# rejection test's CancellableFunctionTool construction.
|
|
_stub_runner_instance = _StubCancellableRunner()
|
|
|
|
|
|
def test_os_env_round_trips_through_translator() -> None:
|
|
"""
|
|
``AgentDef`` → ``AgentSpec`` → ``AgentDef`` preserves the
|
|
:class:`OSEnvSpec` dataclass by reference through the
|
|
top-level ``AgentSpec.os_env`` field.
|
|
|
|
What breaks if this fails: the forward/reverse translator
|
|
stops round-tripping os_env, so agents that declared a
|
|
top-level ``os_env:`` either lose it (sub-agents boot
|
|
without FS access) or crash (hand-rolled dict conversion
|
|
reintroduced and lost a field).
|
|
"""
|
|
from omnigent.inner.datamodel import (
|
|
AgentDef,
|
|
OSEnvSandboxSpec,
|
|
OSEnvSpec,
|
|
)
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.spec.omnigent import (
|
|
agent_def_to_agent_spec,
|
|
agent_spec_to_agent_def,
|
|
)
|
|
|
|
original_os_env = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=".",
|
|
sandbox=OSEnvSandboxSpec(
|
|
type="linux_bwrap",
|
|
write_paths=["."],
|
|
allow_network=False,
|
|
),
|
|
)
|
|
original = AgentDef(
|
|
name="os_user",
|
|
prompt="p",
|
|
tools={},
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-claude-sonnet-4",
|
|
harness="claude-sdk",
|
|
profile="test-profile",
|
|
),
|
|
os_env=original_os_env,
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(original)
|
|
# Reverse trip stores the dataclass by reference on the
|
|
# top-level field — not in executor.config.
|
|
assert spec.os_env is original_os_env
|
|
assert "os_env" not in spec.executor.config
|
|
|
|
forward = agent_spec_to_agent_def(spec)
|
|
# Forward trip reads it back unchanged.
|
|
assert forward.os_env is original_os_env
|
|
|
|
|
|
def test_inline_agent_tool_inherit_resolves_to_parent_os_env() -> None:
|
|
"""
|
|
An inline :class:`AgentTool` that declares
|
|
``os_env: "inherit"`` picks up the parent's concrete
|
|
:class:`OSEnvSpec` at translation time — omnigent spawns
|
|
each sub-agent as an independent task with no live parent
|
|
to consult at runtime.
|
|
|
|
What breaks if this fails: ``coding_supervisor.yaml``-style
|
|
sub-agents (``claude_worker: os_env: inherit``) boot with
|
|
no OS environment and can't run shell/file tools against
|
|
the repo. The whole point of ``os_env: inherit`` — matching
|
|
legacy omnigent semantics — silently breaks.
|
|
"""
|
|
from omnigent.inner.datamodel import (
|
|
AgentDef,
|
|
OSEnvSpec,
|
|
)
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent_os_env = OSEnvSpec(type="caller_process", cwd=".")
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
profile="test-profile",
|
|
),
|
|
os_env=parent_os_env,
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-claude-opus-4",
|
|
harness="claude-sdk",
|
|
),
|
|
# The sentinel the loader produces for
|
|
# ``os_env: inherit`` in YAML.
|
|
os_env="inherit",
|
|
),
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
# Parent still carries the original os_env by reference on
|
|
# the top-level field.
|
|
assert spec.os_env is parent_os_env
|
|
# The single sub-agent inherited it — ``inherit`` resolved
|
|
# at translation time.
|
|
assert len(spec.sub_agents) == 1
|
|
sub = spec.sub_agents[0]
|
|
assert sub.os_env is parent_os_env, (
|
|
f"Sub-agent's os_env should be the parent's OSEnvSpec (by reference); got {sub.os_env!r}"
|
|
)
|
|
|
|
|
|
def test_inline_agent_tool_concrete_os_env_not_overridden_by_parent() -> None:
|
|
"""
|
|
An inline AgentTool that declares its own concrete
|
|
:class:`OSEnvSpec` is preserved — the ``inherit`` fallback
|
|
only fires when the tool uses the string sentinel. Explicit
|
|
always wins, same as the ``profile`` propagation rule.
|
|
"""
|
|
from omnigent.inner.datamodel import (
|
|
AgentDef,
|
|
OSEnvSpec,
|
|
)
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent_os_env = OSEnvSpec(type="caller_process", cwd=".")
|
|
child_os_env = OSEnvSpec(type="caller_process", cwd="/tmp/sandbox")
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
os_env=parent_os_env,
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(harness="claude-sdk"),
|
|
os_env=child_os_env,
|
|
),
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
sub = spec.sub_agents[0]
|
|
# The sub-agent's own os_env wins — parent's is NOT used.
|
|
assert sub.os_env is child_os_env
|
|
assert sub.os_env is not parent_os_env
|
|
|
|
|
|
def test_inline_agent_tool_inherit_with_no_parent_os_env_yields_none() -> None:
|
|
"""
|
|
``os_env: inherit`` with no parent os_env resolves to
|
|
``None`` — matches legacy omnigent behavior when the
|
|
parent itself declares nothing. The sub-spec's
|
|
``executor.config`` omits the ``os_env`` key entirely so
|
|
the forward trip rebuilds an ``AgentDef`` with
|
|
``os_env=None`` (no FS access — same as the
|
|
commented-out ``coding_supervisor.yaml`` state the user
|
|
experienced before this feature landed).
|
|
"""
|
|
from omnigent.inner.datamodel import (
|
|
AgentDef,
|
|
)
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
# No os_env on the parent.
|
|
os_env=None,
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(harness="claude-sdk"),
|
|
os_env="inherit",
|
|
),
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
sub = spec.sub_agents[0]
|
|
assert sub.os_env is None, (
|
|
"An inline ``os_env: inherit`` with no parent os_env "
|
|
"should leave the sub-spec's ``os_env`` field as "
|
|
"``None``, not with a placeholder."
|
|
)
|
|
|
|
|
|
# ── instructions: field (cross-format parity) ────────────────
|
|
|
|
|
|
def test_instructions_field_resolved_path_wins_over_prompt() -> None:
|
|
"""
|
|
When an omnigent YAML declares both ``prompt:`` and
|
|
``instructions: <path>``, the resolved instructions content
|
|
wins on the AgentSpec. Translator precedence rule from
|
|
omnigent/spec/omnigent.py.
|
|
|
|
What breaks if this fails: a user who writes
|
|
``instructions: AGENTS.md`` to point at a long external
|
|
spec, plus a placeholder ``prompt: dummy`` for backwards
|
|
compat, ends up with the placeholder instead of the real
|
|
instructions.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
agent_def = AgentDef(
|
|
name="instr-precedence",
|
|
prompt="placeholder",
|
|
instructions="REAL FROM FILE",
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
assert spec.instructions == "REAL FROM FILE"
|
|
|
|
|
|
def test_instructions_field_falls_back_to_prompt_when_unset() -> None:
|
|
"""
|
|
When ``instructions:`` is absent (None), the translator falls
|
|
back to ``prompt:`` — preserves backward compat for every
|
|
omnigent YAML written before the field existed.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
agent_def = AgentDef(name="prompt-only", prompt="just the prompt")
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
assert spec.instructions == "just the prompt"
|
|
|
|
|
|
def test_instructions_yaml_loads_through_full_pipeline(tmp_path: Path) -> None:
|
|
"""
|
|
End-to-end through ``load_omnigent_yaml`` (the integration
|
|
path the Omnigent server hits when registering an omnigent
|
|
bundle): YAML with ``instructions: AGENTS.md`` produces a
|
|
spec whose ``instructions`` field carries the file's
|
|
contents.
|
|
"""
|
|
from omnigent.spec._omnigent_compat import load_omnigent_yaml
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
"name: full_pipeline\n"
|
|
"prompt: dummy placeholder\n"
|
|
"instructions: AGENTS.md\n"
|
|
"executor:\n"
|
|
" harness: openai-agents\n"
|
|
" model: gpt-4o\n"
|
|
)
|
|
(tmp_path / "AGENTS.md").write_text("FROM AGENTS DOT MD")
|
|
spec = load_omnigent_yaml(yaml_path)
|
|
assert spec.instructions == "FROM AGENTS DOT MD"
|
|
|
|
|
|
# ── Terminals threading (OMNIGENT_TERMINAL_BRIDGE §6.1) ────────────
|
|
|
|
|
|
def test_terminals_thread_through_translator() -> None:
|
|
"""
|
|
A top-level ``AgentDef.terminals`` dict is preserved under
|
|
``AgentSpec.terminals``. This is the load-bearing path that
|
|
makes ``terminals:`` declarations in omnigent YAML reach the
|
|
AP-side ``sys_terminal_*`` tools — the whole feature from
|
|
``designs/OMNIGENT_TERMINAL_BRIDGE.md`` collapses if this breaks.
|
|
|
|
What breaks if this fails: omnigent YAMLs that declare
|
|
``terminals:`` boot under Omnigent mode with
|
|
``AgentSpec.terminals=None``. The AP-side ToolManager doesn't
|
|
register ``sys_terminal_*``, and the LLM gets a "tool not
|
|
available" error mid-conversation.
|
|
"""
|
|
from omnigent.inner.datamodel import (
|
|
AgentDef,
|
|
OSEnvSandboxSpec,
|
|
OSEnvSpec,
|
|
TerminalEnvSpec,
|
|
)
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
bash_terminal = TerminalEnvSpec(
|
|
command="bash",
|
|
os_env=OSEnvSpec(
|
|
type="caller_process",
|
|
cwd="/work",
|
|
sandbox=OSEnvSandboxSpec(type="none"),
|
|
),
|
|
allow_cwd_override=True,
|
|
)
|
|
claude_terminal = TerminalEnvSpec(
|
|
command="claude",
|
|
args=["--dangerously-skip-permissions"],
|
|
scrollback=20000,
|
|
)
|
|
parent = AgentDef(
|
|
name="terminal_user",
|
|
prompt="p",
|
|
tools={},
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
terminals={
|
|
"bash": bash_terminal,
|
|
"claude": claude_terminal,
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
# The dict was copied (translator does ``dict(...)``), so
|
|
# identity differs but contents match.
|
|
assert spec.terminals is not None
|
|
assert set(spec.terminals.keys()) == {"bash", "claude"}
|
|
# The TerminalEnvSpec dataclasses themselves are passed by
|
|
# reference — tools mutate via spec.terminals[name] and would
|
|
# break if the translator deep-copied.
|
|
assert spec.terminals["bash"] is bash_terminal
|
|
assert spec.terminals["claude"] is claude_terminal
|
|
|
|
|
|
def test_terminals_none_when_parent_has_no_terminals() -> None:
|
|
"""
|
|
A parent without a ``terminals`` block produces
|
|
``AgentSpec.terminals=None`` (not ``{}``). The
|
|
:class:`SysTerminalLaunchTool` checks
|
|
``self._spec.terminals is None`` to short-circuit — an empty
|
|
dict would fail the ``is None`` check but still render the
|
|
same "no terminals declared" semantics with a confusingly
|
|
different error message.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="no_terminals",
|
|
prompt="p",
|
|
tools={},
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
# No terminals.
|
|
)
|
|
spec = agent_def_to_agent_spec(parent)
|
|
assert spec.terminals is None
|
|
|
|
|
|
def test_inline_agent_tool_inherits_parent_terminals() -> None:
|
|
"""
|
|
Inline :class:`AgentTool` sub-specs inherit the parent's
|
|
``terminals`` declaration so the sub-agent's
|
|
:class:`ToolManager` registers ``sys_terminal_*`` and the
|
|
sub-agent can spawn its own sessions.
|
|
|
|
Before: this test pinned the opposite behavior ("inline
|
|
sub-agents must NOT inherit terminals"). That made the
|
|
supervisor pattern broken end-to-end — a parent that wanted
|
|
to delegate "open a shell and run X" to a worker had no way
|
|
to grant the worker the launch capability, so workers
|
|
either hallucinated tool calls or fell back to the
|
|
harness's native ``Bash`` (which doesn't show up in the
|
|
REPL's Ctrl+O sidebar). The user-reported repro on
|
|
2026-04-28 hit exactly this.
|
|
|
|
What changed: ``_agent_tool_to_sub_spec`` now threads
|
|
``parent_terminals=agent_def.terminals`` into the
|
|
sub-spec's ``AgentSpec.terminals``. Each sub-agent runs
|
|
in its OWN conversation, so its tmux sessions land in a
|
|
separate ``TerminalRegistry`` — there's no cross-agent
|
|
session leak (the registry is keyed by ``conversation_id``).
|
|
Sharing the terminal CONFIG (``bash: command bash``) is
|
|
fine; what's isolated is the per-conversation session set
|
|
those launches produce.
|
|
|
|
What breaks if this regresses: same as before — the
|
|
sub-agent's ``ToolManager`` short-circuits the
|
|
``sys_terminal_*`` registration (see
|
|
``omnigent/tools/manager.py:426``) and the sub-agent has
|
|
no way to launch a terminal even though the parent has one
|
|
configured. The supervisor pattern stops working again.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef, TerminalEnvSpec
|
|
from omnigent.inner.datamodel import (
|
|
ExecutorSpec as OmniExecutorSpec,
|
|
)
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="p",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
terminals={"bash": TerminalEnvSpec(command="bash")},
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(harness="claude-sdk"),
|
|
),
|
|
},
|
|
)
|
|
|
|
spec = agent_def_to_agent_spec(parent)
|
|
assert spec.terminals is not None
|
|
# Top-level got the terminals.
|
|
assert "bash" in spec.terminals
|
|
# The inline sub-agent inherits them.
|
|
assert len(spec.sub_agents) == 1
|
|
sub = spec.sub_agents[0]
|
|
assert sub.terminals is not None, (
|
|
"Inline AgentTool sub-agent must inherit the parent's "
|
|
"terminals dict so its ToolManager registers "
|
|
"sys_terminal_* and the sub-agent has a path to launch."
|
|
)
|
|
assert "bash" in sub.terminals
|
|
# Verify it's a clone, not the same dict — mutations on
|
|
# the sub-spec mustn't leak back into the parent's
|
|
# declaration.
|
|
assert sub.terminals is not spec.terminals
|
|
|
|
|
|
# ── Harness auto-pick (Gap 1) ─────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model,expected_harness",
|
|
[
|
|
("databricks-claude-sonnet-4", "claude-sdk"),
|
|
("databricks-claude-opus-4-7", "claude-sdk"),
|
|
("anthropic/claude-sonnet-4-20250514", "claude-sdk"),
|
|
("databricks-gpt-5-4", "openai-agents"),
|
|
("databricks-gpt-5-mini", "openai-agents"),
|
|
("openai/gpt-4o", "openai-agents"),
|
|
("gpt-4-turbo", "openai-agents"),
|
|
],
|
|
)
|
|
def test_harness_auto_picks_from_model_prefix(
|
|
model: str,
|
|
expected_harness: str,
|
|
) -> None:
|
|
"""
|
|
When an omnigent YAML declares a model but no harness,
|
|
the adapter fills in the right harness by matching the
|
|
model prefix against
|
|
:data:`~omnigent.spec.omnigent._HARNESS_FOR_MODEL_PREFIX`.
|
|
|
|
Mirrors the auto-pick pure omnigent' CLI does at
|
|
``create_executor`` time, so YAMLs that relied on the
|
|
implicit behavior don't need to be touched to work under
|
|
Omnigent mode.
|
|
|
|
What breaks if this fails: every YAML lacking an explicit
|
|
``harness:`` field trips the validator's
|
|
``executor.config.harness: required`` error at spec-load,
|
|
blocking the entire Omnigent path.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
agent_def = AgentDef(
|
|
name="auto_pick_probe",
|
|
prompt="",
|
|
tools={},
|
|
executor=OmniExecutorSpec(model=model),
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
assert spec.executor.config["harness"] == expected_harness, (
|
|
f"Adapter didn't auto-pick harness for model {model!r}. "
|
|
f"Expected {expected_harness!r}, got "
|
|
f"{spec.executor.config.get('harness')!r}"
|
|
)
|
|
|
|
|
|
def test_harness_auto_pick_doesnt_override_explicit_declaration() -> None:
|
|
"""
|
|
When the YAML explicitly declares a harness, auto-pick must
|
|
NOT override it. Explicit always wins — same precedence
|
|
rule as the profile and os_env fallbacks.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
agent_def = AgentDef(
|
|
name="explicit_probe",
|
|
prompt="",
|
|
tools={},
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-claude-sonnet-4",
|
|
harness="openai-agents",
|
|
),
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
assert spec.executor.config["harness"] == "openai-agents", (
|
|
"Explicit harness in the YAML was overridden by auto-pick "
|
|
"— auto-pick must only fill in *missing* harness values."
|
|
)
|
|
|
|
|
|
def test_harness_auto_pick_unknown_model_raises() -> None:
|
|
"""
|
|
A model string that doesn't match any harness prefix raises
|
|
at translation time — every agent must resolve to a named
|
|
harness.
|
|
|
|
:raises OmnigentError: With a message explaining that the
|
|
model could not be mapped to a harness.
|
|
"""
|
|
from omnigent.errors import OmnigentError
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
agent_def = AgentDef(
|
|
name="unknown_probe",
|
|
prompt="",
|
|
tools={},
|
|
executor=OmniExecutorSpec(model="exotic/some-new-model-v1"),
|
|
)
|
|
with pytest.raises(OmnigentError, match=r"[Hh]arness"):
|
|
agent_def_to_agent_spec(agent_def)
|
|
|
|
|
|
# ── Parent-to-inline harness propagation (Gap 2) ───────────
|
|
|
|
|
|
def test_inline_agent_tool_without_executor_inherits_parent_harness() -> None:
|
|
"""
|
|
An inline :class:`AgentTool` that omits the ``executor:``
|
|
block entirely inherits the parent's harness at translation
|
|
time.
|
|
|
|
Matches the YAML idiom in
|
|
``examples/coding_supervisor_with_forks.yaml`` and
|
|
``examples/agent_with_subagent_session.yaml``: workers
|
|
declare ``prompt:`` + ``os_env:`` + ``tools:`` but skip
|
|
``executor:``, expecting the parent's harness to flow down.
|
|
|
|
What breaks if this fails: those YAMLs fail at spec-load
|
|
with ``sub_agents[...].executor.config.harness: required``
|
|
before any LLM request.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
profile="test-profile",
|
|
),
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="Do a task",
|
|
),
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
assert len(spec.sub_agents) == 1
|
|
sub = spec.sub_agents[0]
|
|
assert sub.executor.config["harness"] == "openai-agents", (
|
|
"Sub-agent's harness should inherit from parent when the "
|
|
"inline AgentTool omits ``executor:``."
|
|
)
|
|
|
|
|
|
def test_inline_agent_tool_explicit_harness_wins_over_parent() -> None:
|
|
"""
|
|
When the inline AgentTool declares its own harness, parent
|
|
inheritance must NOT override it. Explicit always wins.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-claude-opus-4-7",
|
|
harness="claude-sdk",
|
|
),
|
|
),
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
assert spec.sub_agents[0].executor.config["harness"] == "claude-sdk", (
|
|
"Explicit child harness should win over parent inheritance."
|
|
)
|
|
|
|
|
|
def test_inline_agent_tool_falls_through_to_model_auto_pick() -> None:
|
|
"""
|
|
When neither the child NOR the parent declares a harness,
|
|
the adapter's model-prefix auto-pick still fires.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import AgentTool
|
|
from omnigent.spec.omnigent import agent_def_to_agent_spec
|
|
|
|
parent = AgentDef(
|
|
name="supervisor",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(model="databricks-gpt-5-mini"),
|
|
tools={
|
|
"worker": AgentTool(
|
|
name="worker",
|
|
prompt="",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-claude-opus-4-7",
|
|
),
|
|
),
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(parent)
|
|
|
|
assert spec.executor.config["harness"] == "openai-agents"
|
|
assert spec.sub_agents[0].executor.config["harness"] == "claude-sdk", (
|
|
"Child with a Claude model and no harness (and no parent "
|
|
"harness to inherit) should resolve through model auto-pick."
|
|
)
|
|
|
|
|
|
# ── Policy translation (omnigent YAML → GuardrailsSpec) ────
|
|
#
|
|
# These tests exercise the raw-YAML-based translator
|
|
# (:func:`_translate_guardrails_yaml` and its dispatch helpers).
|
|
# Each test hand-constructs the minimal omnigent YAML dict
|
|
# that triggers one translation rule, routes it through
|
|
# :func:`agent_def_to_agent_spec` with ``raw_yaml=``, and
|
|
# asserts the resulting :class:`GuardrailsSpec` has the exact
|
|
# shape the runtime :class:`PolicyEngine` needs.
|
|
#
|
|
# The ``AgentDef`` side is built manually (not through
|
|
# :func:`omnigent.loader.load_agent_def`) because the loader
|
|
# compiles label-policy YAML into synthetic FunctionPolicy
|
|
# callables — for translator unit tests we want to bypass that
|
|
# and drive the translator directly.
|
|
|
|
|
|
class _AgentDefYamlPair(NamedTuple):
|
|
"""
|
|
Two-value bundle for policy-translator tests — an
|
|
:class:`AgentDef` and the raw YAML dict it was built from.
|
|
|
|
Kept as a :class:`typing.NamedTuple` rather than a dataclass
|
|
(per the project's "one opaque value" exception in the no-tuple-
|
|
return rule) so existing callsites can keep ``agent_def,
|
|
raw_yaml = _build_agent_def_with_raw_yaml(...)`` destructuring
|
|
— the pair is conceptually a single "spec fixture" handed to
|
|
``agent_def_to_agent_spec(raw_yaml=...)``.
|
|
"""
|
|
|
|
agent_def: AgentDef
|
|
raw_yaml: dict[str, Any]
|
|
|
|
|
|
def _build_agent_def_with_raw_yaml(
|
|
policies: dict[str, dict[str, Any]] | None = None,
|
|
labels: dict[str, str] | None = None,
|
|
label_schema: dict[str, dict[str, Any]] | None = None,
|
|
ask_timeout: int | None = None,
|
|
) -> _AgentDefYamlPair:
|
|
"""
|
|
Build an :class:`AgentDef` + raw-YAML dict pair for the
|
|
policy translator tests.
|
|
|
|
The two objects are the shape production callers pass to
|
|
:func:`agent_def_to_agent_spec`: an AgentDef parsed by the
|
|
omnigent loader (here stubbed with an empty ``policies``
|
|
registry so the fail-loud path is irrelevant — the real
|
|
translator consumes ``raw_yaml`` for policy fields anyway)
|
|
and the raw YAML dict read alongside.
|
|
|
|
:param policies: Raw omnigent ``policies:`` dict.
|
|
:param labels: Raw omnigent top-level ``labels:`` dict
|
|
(initial values).
|
|
:param label_schema: Raw omnigent top-level
|
|
``label_schema:`` dict.
|
|
:param ask_timeout: Raw omnigent top-level
|
|
``ask_timeout:`` value.
|
|
:returns: Tuple of (AgentDef with a valid executor, raw
|
|
YAML dict suitable for the ``raw_yaml`` kwarg).
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
|
|
agent_def = AgentDef(
|
|
name="polled",
|
|
prompt="p",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
),
|
|
)
|
|
raw: dict[str, Any] = {"name": "polled", "prompt": "p"}
|
|
if policies is not None:
|
|
raw["policies"] = policies
|
|
if labels is not None:
|
|
raw["labels"] = labels
|
|
if label_schema is not None:
|
|
raw["label_schema"] = label_schema
|
|
if ask_timeout is not None:
|
|
raw["ask_timeout"] = ask_timeout
|
|
return _AgentDefYamlPair(agent_def=agent_def, raw_yaml=raw)
|
|
|
|
|
|
def test_function_policy_routes_callable_through_legacy_shim() -> None:
|
|
"""
|
|
A ``type: function`` policy translates to a
|
|
:class:`FunctionPolicySpec` whose ``function.path`` points
|
|
at the legacy-compat shim factory; the author's original
|
|
dotted callable path is preserved in
|
|
``function.arguments["target"]``.
|
|
|
|
The indirection exists so author callables written with the
|
|
legacy omnigent ``(content, phase)`` convention keep
|
|
working under Omnigent' ``(ctx, context)`` convention —
|
|
see ``omnigent.spec._omnigent_legacy_shim``. The shim
|
|
is a runtime no-op for omnigent-native callables, so
|
|
routing everything through it is safe.
|
|
|
|
What breaks if this fails: either the translator regresses
|
|
to emitting the raw callable path (legacy callables silently
|
|
stop working — e.g. ``block_long_sleep`` lets every sleep
|
|
through), or the shim target gets mangled and the
|
|
``importlib.import_module`` call inside ``build()`` can't
|
|
find the author's callable.
|
|
"""
|
|
from omnigent.spec.types import FunctionPolicySpec
|
|
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"block_sleep": {
|
|
"type": "function",
|
|
"on": ["tool_call"],
|
|
"handler": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
},
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.policies is not None
|
|
assert len(spec.guardrails.policies) == 1
|
|
policy = spec.guardrails.policies[0]
|
|
assert isinstance(policy, FunctionPolicySpec)
|
|
assert policy.name == "block_sleep"
|
|
assert policy.function is not None
|
|
# Factory path is the shim — exact string so a typo in the
|
|
# translator can't route to some other builder silently.
|
|
assert policy.function.path == "omnigent.spec._omnigent_legacy_shim.build"
|
|
# The author's original callable travels in factory arguments under
|
|
# the ``target`` key; no ``factory_kwargs`` because the YAML didn't
|
|
# declare ``factory_params``.
|
|
assert policy.function.arguments == {
|
|
"target": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
}
|
|
# on: is ignored for function policies — callable self-selects.
|
|
assert policy.on is None
|
|
|
|
|
|
def test_function_policy_with_factory_params_routes_through_legacy_shim() -> None:
|
|
"""
|
|
``callable:`` + ``factory_params:`` together still route
|
|
through the shim, but the author's factory kwargs land
|
|
under ``factory_kwargs`` in the shim's arguments so the
|
|
shim can forward them when calling the author's factory.
|
|
|
|
What breaks if this fails: factory kwargs silently vanish
|
|
and closure-state policies (rate limits, budgets, etc.)
|
|
revert to their defaults — the policy still loads but
|
|
enforces nothing useful.
|
|
"""
|
|
from omnigent.spec.types import FunctionPolicySpec
|
|
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"rate_limit": {
|
|
"type": "function",
|
|
"on": ["tool_call"],
|
|
"handler": (
|
|
"tests.resources.examples._shared.rate_limit_policy.max_tool_calls_per_turn"
|
|
),
|
|
"factory_params": {"limit": 15},
|
|
},
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.policies is not None
|
|
policy = spec.guardrails.policies[0]
|
|
assert isinstance(policy, FunctionPolicySpec)
|
|
assert policy.function is not None
|
|
assert policy.function.path == "omnigent.spec._omnigent_legacy_shim.build"
|
|
# Both the original target AND the factory kwargs are
|
|
# preserved byte-for-byte on the arguments dict.
|
|
assert policy.function.arguments == {
|
|
"target": "tests.resources.examples._shared.rate_limit_policy.max_tool_calls_per_turn",
|
|
"factory_kwargs": {"limit": 15},
|
|
}
|
|
|
|
|
|
def test_function_policy_callable_alias_resolves_identically_to_handler() -> None:
|
|
"""
|
|
``callable:`` is a legacy alias for ``handler:`` in function policies.
|
|
|
|
Old omnigent YAMLs used ``callable:`` as the key name; current
|
|
YAMLs use ``handler:``. Both must produce the same
|
|
:class:`FunctionPolicySpec` so stored agents written before the
|
|
rename keep working without migration.
|
|
|
|
What breaks if this fails: loading any old-format YAML raises
|
|
``"function policies require a function: field"`` and the agent
|
|
refuses to start — the user has no path to run their agent without
|
|
manually editing every stored YAML.
|
|
"""
|
|
from omnigent.spec.types import FunctionPolicySpec
|
|
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"block_sleep": {
|
|
"type": "function",
|
|
"on": ["tool_call"],
|
|
"callable": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
},
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.policies is not None
|
|
assert len(spec.guardrails.policies) == 1
|
|
policy = spec.guardrails.policies[0]
|
|
assert isinstance(policy, FunctionPolicySpec)
|
|
assert policy.name == "block_sleep"
|
|
assert policy.function is not None
|
|
assert policy.function.path == "omnigent.spec._omnigent_legacy_shim.build"
|
|
assert policy.function.arguments == {
|
|
"target": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
}
|
|
|
|
|
|
def test_function_policy_callable_alias_with_factory_params() -> None:
|
|
"""
|
|
``callable:`` + ``factory_params:`` together behave identically
|
|
to ``handler:`` + ``factory_params:``.
|
|
|
|
What breaks if this fails: old-format policies that include
|
|
factory kwargs (e.g. ``read_all: true``) silently lose their
|
|
configuration and revert to defaults.
|
|
"""
|
|
from omnigent.spec.types import FunctionPolicySpec
|
|
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"google_policy": {
|
|
"type": "function",
|
|
"on": ["tool_call", "tool_result"],
|
|
"callable": (
|
|
"tests.resources.examples._shared.rate_limit_policy.max_tool_calls_per_turn"
|
|
),
|
|
"factory_params": {"limit": 5},
|
|
},
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.policies is not None
|
|
policy = spec.guardrails.policies[0]
|
|
assert isinstance(policy, FunctionPolicySpec)
|
|
assert policy.function is not None
|
|
assert policy.function.path == "omnigent.spec._omnigent_legacy_shim.build"
|
|
assert policy.function.arguments == {
|
|
"target": "tests.resources.examples._shared.rate_limit_policy.max_tool_calls_per_turn",
|
|
"factory_kwargs": {"limit": 5},
|
|
}
|
|
|
|
|
|
def test_databricks_slash_model_without_profile_leaves_connection_none() -> None:
|
|
"""
|
|
When no profile is declared, the translator leaves
|
|
:attr:`LLMConfig.connection` as ``None`` and the
|
|
:class:`DatabricksAdapter` performs its own auto-resolution from
|
|
``~/.databrickscfg`` at call time.
|
|
|
|
**What breaks if this fails**: users who rely on ambient
|
|
``DATABRICKS_HOST`` / ``DATABRICKS_TOKEN`` or the default profile
|
|
(no ``--profile`` flag) suddenly get a spec-load error because the
|
|
translator tries to resolve a profile that was never set.
|
|
"""
|
|
from omnigent.inner.datamodel import AgentDef
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
|
|
agent_def = AgentDef(
|
|
name="slash_model_no_profile",
|
|
prompt="You are helpful.",
|
|
executor=OmniExecutorSpec(
|
|
model="databricks/databricks-gpt-5-mini",
|
|
harness="openai-agents",
|
|
profile=None,
|
|
),
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=None)
|
|
assert spec.llm is not None
|
|
# No profile → no connection injection; adapter auto-resolves at call time.
|
|
assert spec.llm.connection is None
|
|
|
|
|
|
def test_labels_and_schema_merge() -> None:
|
|
"""
|
|
Top-level ``labels:`` (initial values) and ``label_schema:``
|
|
(values) merge into Omnigent's
|
|
:attr:`GuardrailsSpec.labels` as :class:`LabelDef` entries.
|
|
|
|
What breaks if this fails: the workflow runs with the wrong
|
|
schema — initial value lost or the label missing entirely.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
labels={"confidentiality": "0", "integrity": "1"},
|
|
label_schema={
|
|
"confidentiality": {"values": ["0", "1"]},
|
|
"integrity": {"values": ["0", "1"]},
|
|
},
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.labels is not None
|
|
conf = spec.guardrails.labels["confidentiality"]
|
|
integ = spec.guardrails.labels["integrity"]
|
|
assert conf.initial == "0"
|
|
assert conf.values == ["0", "1"]
|
|
assert integ.initial == "1"
|
|
assert integ.values == ["0", "1"]
|
|
|
|
|
|
def test_ask_timeout_top_level_propagates_to_guardrails() -> None:
|
|
"""
|
|
The omnigent top-level ``ask_timeout:`` lands on
|
|
:attr:`GuardrailsSpec.ask_timeout`.
|
|
|
|
What breaks if this fails: ASK-action policies park forever
|
|
(no timeout) or fall back to the omnigent default even
|
|
when the YAML author explicitly set one.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"noop": {
|
|
"type": "function",
|
|
"on": ["request"],
|
|
"handler": "tests.resources.examples._shared.tool_functions.block_long_sleep",
|
|
},
|
|
},
|
|
ask_timeout=60,
|
|
)
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is not None
|
|
assert spec.guardrails.ask_timeout == 60
|
|
|
|
|
|
def test_no_policies_no_guardrails_block() -> None:
|
|
"""
|
|
An omnigent YAML without any policies/labels/ask_timeout
|
|
produces a spec with ``guardrails=None``, matching the
|
|
zero-policy case documented in POLICIES.md §10.
|
|
|
|
What breaks if this fails: omnigent would instantiate a
|
|
non-trivial :class:`PolicyEngine` for a guardrail-free spec,
|
|
wasting work on every enforcement phase (and potentially
|
|
masking real policy gaps downstream).
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.guardrails is None
|
|
|
|
|
|
def test_executor_extra_field_propagates_to_llm_config() -> None:
|
|
"""
|
|
An omnigent YAML declaring ``executor.extra: {max_turns: 3}``
|
|
produces an :class:`AgentSpec` whose ``llm.extra`` carries
|
|
those kwargs byte-for-byte.
|
|
|
|
The downstream chain (``OmnigentExecutor.run_turn`` →
|
|
``OmniExecutorConfig.extra`` → ``cfg.extra.get("max_turns")``
|
|
in the per-harness executor) then reads these kwargs at
|
|
runtime. This field is not part of the omnigent
|
|
``ExecutorSpec`` dataclass — the loader drops it — so we
|
|
read it from the raw YAML here.
|
|
|
|
What breaks if this fails: agent authors lose the ability to
|
|
override per-harness knobs (``max_turns``, ``temperature``,
|
|
``parallel_tool_calls``, etc.) through the Omnigent path, even
|
|
though those knobs work fine via legacy omnigent. Makes
|
|
Omnigent mode a downgrade rather than a compatible integration.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
raw_yaml["executor"] = {
|
|
"model": "databricks-gpt-5-mini",
|
|
"harness": "openai-agents",
|
|
"extra": {"max_turns": 3, "temperature": 0.1},
|
|
}
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.llm is not None
|
|
assert spec.llm.extra == {"max_turns": 3, "temperature": 0.1}
|
|
|
|
|
|
def test_executor_extra_absent_yields_empty_llm_extra() -> None:
|
|
"""
|
|
When the omnigent YAML omits ``executor.extra``, the
|
|
synthesized ``llm.extra`` is an empty dict (not ``None``,
|
|
not missing). Matches the downstream code's assumption
|
|
that ``dict(llm_config.extra)`` is always iterable.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.llm is not None
|
|
assert spec.llm.extra == {}
|
|
|
|
|
|
def test_use_responses_false_propagates_to_executor_config() -> None:
|
|
"""
|
|
``use_responses: false`` in an omnigent YAML executor block lands on
|
|
``spec.executor.config["use_responses"]`` as ``False`` after
|
|
``agent_def_to_agent_spec``.
|
|
|
|
The inner ``ExecutorSpec`` dataclass (``omnigent.inner.datamodel``)
|
|
has no ``use_responses`` field, so the omnigent YAML loader silently
|
|
drops it. We must read it from the raw YAML dict and carry it forward
|
|
explicitly.
|
|
|
|
What breaks if this fails: ``_build_openai_agents_sdk_spawn_env`` finds
|
|
``spec.executor.config.get("use_responses")`` is ``None``, so it skips
|
|
setting ``HARNESS_OPENAI_AGENTS_USE_RESPONSES``. The harness subprocess
|
|
then defaults to ``use_responses=True`` (Responses API), which Databricks
|
|
does not support for models like Kimi K2 — the REPL shows no response.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
raw_yaml["executor"] = {
|
|
"model": "databricks-kimi-k2-6",
|
|
"harness": "openai-agents",
|
|
"use_responses": False,
|
|
}
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.executor.config.get("use_responses") is False
|
|
|
|
|
|
def test_use_responses_true_propagates_to_executor_config() -> None:
|
|
"""
|
|
``use_responses: true`` similarly propagates as ``True``.
|
|
|
|
Complement of ``test_use_responses_false_propagates_to_executor_config``
|
|
— verifies both boolean values are preserved exactly, not coerced.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
raw_yaml["executor"] = {
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"harness": "openai-agents",
|
|
"use_responses": True,
|
|
}
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert spec.executor.config.get("use_responses") is True
|
|
|
|
|
|
def test_use_responses_absent_omits_key_from_executor_config() -> None:
|
|
"""
|
|
When the omnigent YAML omits ``use_responses``, the key is absent
|
|
from ``spec.executor.config`` (not ``None``, not ``True``/``False``).
|
|
|
|
``_build_openai_agents_sdk_spawn_env`` uses ``config.get("use_responses")
|
|
is not None`` to decide whether to set the env var; a missing key means
|
|
the harness default applies unchanged.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml()
|
|
raw_yaml["executor"] = {
|
|
"model": "databricks-gpt-5-4-mini",
|
|
"harness": "openai-agents",
|
|
}
|
|
spec = agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert "use_responses" not in spec.executor.config
|
|
|
|
|
|
def test_unknown_policy_type_rejected_with_clear_message() -> None:
|
|
"""
|
|
A policy with an unrecognized ``type:`` value fails with an
|
|
error that names the policy and the invalid type.
|
|
|
|
What breaks if this fails: the translator either silently
|
|
drops the policy or produces an error deep in the
|
|
downstream parser — authors can't tell which YAML key broke.
|
|
"""
|
|
agent_def, raw_yaml = _build_agent_def_with_raw_yaml(
|
|
policies={
|
|
"weird": {
|
|
"type": "regex",
|
|
"on": ["request"],
|
|
},
|
|
},
|
|
)
|
|
with pytest.raises(OmnigentError, match="weird") as exc_info:
|
|
agent_def_to_agent_spec(agent_def, raw_yaml=raw_yaml)
|
|
assert "regex" in str(exc_info.value)
|
|
|
|
|
|
# ── Self-clone sub-agent (`tools.<name>: self` and `spec: self`) ──
|
|
|
|
|
|
def test_self_clone_string_shorthand_loader_produces_selfagent_tool(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The ``tools.<name>: self`` string shorthand parses to a
|
|
:class:`SelfAgentTool` (not the legacy
|
|
:class:`AgentTool` placeholder with a magic prompt).
|
|
|
|
What breaks if this fails: the translator's isinstance
|
|
dispatch can't find a self-clone branch and the sub-agent
|
|
silently becomes a default-everything AgentTool — the
|
|
cloned-from-parent behavior is lost.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
from omnigent.inner.tools import SelfAgentTool
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "code_assistant",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"subtask": "self",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
agent_def = load_agent_def(yaml_path)
|
|
assert isinstance(agent_def.tools["subtask"], SelfAgentTool)
|
|
assert agent_def.tools["subtask"].name == "subtask"
|
|
|
|
|
|
def test_self_clone_dict_form_loader_produces_selfagent_tool(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The ``tools.<name>: {type: agent, spec: self}`` dict form
|
|
parses to a :class:`SelfAgentTool`. Author-supplied
|
|
``description`` is preserved on the tool for the translator
|
|
to thread into the cloned sub-spec.
|
|
|
|
What breaks if this fails: the dict form would produce a
|
|
regular :class:`AgentTool` with empty fields, and the
|
|
translator would build a default sub-agent instead of
|
|
cloning the parent.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
from omnigent.inner.tools import SelfAgentTool
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "code_assistant",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"subtask": {
|
|
"type": "agent",
|
|
"spec": "self",
|
|
"description": "Spawn a copy of yourself for delegated work.",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
agent_def = load_agent_def(yaml_path)
|
|
sub_tool = agent_def.tools["subtask"]
|
|
assert isinstance(sub_tool, SelfAgentTool)
|
|
assert sub_tool.name == "subtask"
|
|
assert sub_tool.description == "Spawn a copy of yourself for delegated work."
|
|
|
|
|
|
def test_self_clone_dict_form_rejects_conflicting_overrides(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
``spec: self`` cannot be combined with override fields
|
|
(``prompt``, ``tools``, ``executor``, ``os_env``,
|
|
``pass_history``, ``pass_histories``, ``max_sessions``).
|
|
|
|
What breaks if this fails: silently ignoring an override
|
|
field would let an author write a partial-override-on-clone
|
|
that doesn't actually do anything — confusing UX. The error
|
|
message names the conflicting field so the author can fix
|
|
the YAML.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "code_assistant",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"subtask": {
|
|
"type": "agent",
|
|
"spec": "self",
|
|
# Conflicting field — should fail loudly:
|
|
"prompt": "I want my own prompt.",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
with pytest.raises(ValueError, match="prompt") as exc:
|
|
load_agent_def(yaml_path)
|
|
# Message names the conflicting field AND points the author
|
|
# at the right fix (use type: agent with explicit fields).
|
|
msg = str(exc.value)
|
|
assert "spec: self" in msg
|
|
assert "subtask" in msg
|
|
|
|
|
|
def test_agent_def_to_agent_spec_self_clone_propagates_parent_config(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
Translating an omnigent YAML with ``tools.subtask: self``
|
|
produces a sub-agent spec that's a clone of the parent —
|
|
same model, harness, instructions, executor type. The
|
|
parent's ``tools.agents`` lists the sub-agent's name so the
|
|
LLM can dispatch to it via ``sys_session_send(agent="subtask",
|
|
...)``.
|
|
|
|
What breaks if this fails: the LLM either can't see the
|
|
sub-agent (it's missing from ``tools.agents``) OR the
|
|
sub-agent runs with default-everything rather than
|
|
inheriting the parent's harness/model/prompt — both render
|
|
self-clone unusable in practice.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "code_assistant",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"subtask": {
|
|
"type": "agent",
|
|
"spec": "self",
|
|
"description": "Spawn a copy of yourself for delegated work.",
|
|
},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
agent_def = load_agent_def(yaml_path)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
|
|
# Parent surface — sub-agent listed for sys_session_send dispatch.
|
|
assert spec.tools.agents == ["subtask"]
|
|
assert len(spec.sub_agents) == 1
|
|
|
|
sub = spec.sub_agents[0]
|
|
# Sub-agent's name matches the dispatch key.
|
|
assert sub.name == "subtask"
|
|
# Author's description override threads onto the sub-spec.
|
|
assert sub.description == "Spawn a copy of yourself for delegated work."
|
|
# Parent's prompt ports as the sub's instructions.
|
|
assert sub.instructions == "You are a coding assistant."
|
|
# Parent's model + harness propagate.
|
|
assert sub.llm is not None
|
|
assert sub.llm.model == "databricks-claude-sonnet-4-6"
|
|
assert sub.executor.type == OMNIGENT_EXECUTOR_TYPE
|
|
assert sub.executor.config["harness"] == "claude-sdk"
|
|
|
|
|
|
def test_agent_def_to_agent_spec_self_clone_recursion_guard(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""
|
|
The cloned sub-spec does NOT carry its own self-clone tool —
|
|
parser-time recursion is bounded to one level via
|
|
:func:`_self_agent_tool_to_sub_spec`'s strip-then-recurse
|
|
pattern.
|
|
|
|
What breaks if this fails: the parse-time spec graph would
|
|
grow without bound (clone has subtask which has subtask
|
|
which has subtask...) producing an infinite recursion at
|
|
``agent_def_to_agent_spec`` and OOM-killing the process.
|
|
|
|
Note: runtime recursion (a clone spawning another clone) is
|
|
a separate concern and is intentionally bounded by per-
|
|
workflow ``max_iterations``, not by this guard. This test
|
|
pins the parse-time invariant only.
|
|
"""
|
|
from omnigent.inner.loader import load_agent_def
|
|
|
|
yaml_path = tmp_path / "agent.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "code_assistant",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
},
|
|
"tools": {
|
|
"subtask": "self",
|
|
},
|
|
}
|
|
)
|
|
)
|
|
agent_def = load_agent_def(yaml_path)
|
|
spec = agent_def_to_agent_spec(agent_def)
|
|
sub = spec.sub_agents[0]
|
|
# Recursion guard: clone has no self-clone of its own.
|
|
assert sub.tools.agents == []
|
|
assert len(sub.sub_agents) == 0
|
|
|
|
|
|
def test_compat_yaml_executor_auth_is_not_dropped(tmp_path: Path) -> None:
|
|
"""
|
|
``executor.auth:`` declared in an omnigent-compat YAML is preserved
|
|
on the resulting :class:`ExecutorSpec`, not silently dropped.
|
|
|
|
Regression target: ``_translate_executor_from_def`` previously never
|
|
called ``_parse_executor_auth``, so a YAML with
|
|
``executor.auth: {type: databricks, profile: oss}`` produced a spec
|
|
with ``executor.auth = None``, causing it to fall through to the
|
|
global config default (wrong credentials silently).
|
|
|
|
Uses ``load_omnigent_yaml`` — the real production entry point — so
|
|
the ``raw_yaml`` dict is passed through correctly (same as the real
|
|
CLI path does).
|
|
|
|
:param tmp_path: Temporary directory for the test YAML.
|
|
"""
|
|
from omnigent.spec._omnigent_compat import load_omnigent_yaml
|
|
from omnigent.spec.types import DatabricksAuth
|
|
|
|
yaml_path = tmp_path / "agent_with_auth.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "databricks_agent",
|
|
"prompt": "You are a coding assistant.",
|
|
"executor": {
|
|
"model": "databricks-claude-sonnet-4-6",
|
|
"harness": "claude-sdk",
|
|
"auth": {"type": "databricks", "profile": "oss"},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
spec = load_omnigent_yaml(yaml_path)
|
|
|
|
# auth must survive the compat translation — not silently dropped.
|
|
assert isinstance(spec.executor.auth, DatabricksAuth), (
|
|
"executor.auth was not parsed from the compat YAML; "
|
|
"the agent would silently fall through to global config credentials."
|
|
)
|
|
assert spec.executor.auth.profile == "oss"
|
|
|
|
|
|
def test_compat_yaml_executor_api_key_auth_is_not_dropped(tmp_path: Path) -> None:
|
|
"""
|
|
``executor.auth: {type: api_key, …}`` in an omnigent-compat YAML is
|
|
preserved on the resulting :class:`ExecutorSpec`.
|
|
|
|
Uses ``load_omnigent_yaml`` — the real production entry point — so
|
|
the ``raw_yaml`` dict is passed through correctly.
|
|
|
|
:param tmp_path: Temporary directory for the test YAML.
|
|
"""
|
|
from omnigent.spec._omnigent_compat import load_omnigent_yaml
|
|
from omnigent.spec.types import ApiKeyAuth
|
|
|
|
yaml_path = tmp_path / "agent_api_key.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
{
|
|
"name": "openai_agent",
|
|
"prompt": "You are a test agent.",
|
|
"executor": {
|
|
"harness": "openai-agents",
|
|
"auth": {"type": "api_key", "api_key": "sk-test-key"},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
spec = load_omnigent_yaml(yaml_path)
|
|
|
|
assert isinstance(spec.executor.auth, ApiKeyAuth)
|
|
assert spec.executor.auth.api_key == "sk-test-key"
|