1992 lines
86 KiB
Python
1992 lines
86 KiB
Python
"""Bidirectional translator between omnigent ``AgentSpec`` and omnigent ``AgentDef``.
|
|
|
|
Two functions live here:
|
|
|
|
- :func:`agent_spec_to_agent_def` — forward direction, consumed by
|
|
``OmnigentExecutor`` at executor-construction time.
|
|
- :func:`agent_def_to_agent_spec` — reverse direction, consumed by
|
|
:func:`omnigent.spec.load` when handed an omnigent YAML so
|
|
``omnigent chat foo.yaml`` works transparently.
|
|
|
|
The bidirectional invariant
|
|
``agent_spec_to_agent_def(agent_def_to_agent_spec(d)) == d`` must
|
|
hold for every representative omnigent YAML; the round-trip test
|
|
under ``tests/spec/test_omnigent_roundtrip.py`` enforces it.
|
|
|
|
Design invariants (see designs/OMNIGENT_INTEGRATION.md §1 and §2):
|
|
|
|
- Tool callables are encoded as dotted import paths in ``AgentSpec``
|
|
(e.g. ``"examples._shared.tool_functions.get_current_time"``). The forward
|
|
direction resolves them via :func:`importlib.import_module`; the
|
|
reverse direction recovers them from
|
|
``__module__`` + ``__qualname__``. ``AgentSpec`` itself stays a
|
|
clean serializable native type — no opaque payloads.
|
|
- Unsupported concepts **fail loud** with an :class:`OmnigentError`
|
|
naming the specific field. Currently unsupported (each is an
|
|
omnigent spec gap to fix, not a translation to paper over):
|
|
policies, OSEnv sandbox, MCP-type tools,
|
|
cancellable_function-type tools.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib
|
|
from collections.abc import Callable
|
|
from typing import Any, TypeAlias, cast
|
|
|
|
from omnigent.errors import ErrorCode, OmnigentError
|
|
from omnigent.harness_aliases import canonicalize_harness
|
|
from omnigent.inner.datamodel import AgentDef, OSEnvSpec, TerminalEnvSpec
|
|
from omnigent.inner.datamodel import ExecutorSpec as OmniExecutorSpec
|
|
from omnigent.inner.tools import (
|
|
AgentTool,
|
|
CancellableFunctionTool,
|
|
FunctionTool,
|
|
MCPTool,
|
|
SelfAgentTool,
|
|
Tool,
|
|
_schema_from_callable,
|
|
)
|
|
from omnigent.llms.routing import infer_harness_from_model as _infer_harness_from_model
|
|
from omnigent.spec.types import (
|
|
AgentSpec,
|
|
ApiKeyAuth,
|
|
DatabricksAuth,
|
|
ExecutorSpec,
|
|
GuardrailsSpec,
|
|
LLMConfig,
|
|
LocalToolInfo,
|
|
MCPServerConfig,
|
|
SharePolicy,
|
|
ToolRuntime,
|
|
ToolsConfig,
|
|
)
|
|
|
|
# Dynamically-loaded Python callable resolved from a YAML ``callable:
|
|
# mod.func`` dotted path. Signatures vary; both callers and this module
|
|
# treat the return value opaquely.
|
|
DynamicCallable: TypeAlias = Callable[..., object]
|
|
|
|
# Value placed in :attr:`AgentSpec.executor.type` so the runtime
|
|
# selects ``OmnigentExecutor``. Both directions of the translator
|
|
# treat this as the discriminator between native omnigent specs
|
|
# and omnigent-sourced ones.
|
|
OMNIGENT_EXECUTOR_TYPE = "omnigent"
|
|
|
|
# Value placed in :attr:`LocalToolInfo.language` for tools that were
|
|
# sourced from an omnigent YAML. Distinguishes them from native
|
|
# omnigent tools (``"python"`` / ``"typescript"``) which live as
|
|
# files on disk under ``tools/python/``.
|
|
OMNIGENT_TOOL_LANGUAGE = "omnigent-python-callable"
|
|
|
|
# Version stamped on AgentSpecs synthesized from omnigent YAMLs.
|
|
# Omnigent YAMLs do not declare ``spec_version`` (it's an
|
|
# omnigent concept), so the adapter writes ``1`` — the currently
|
|
# valid omnigent schema version (see omnigent/spec/validator.py).
|
|
_SYNTHETIC_SPEC_VERSION = 1
|
|
|
|
# Sentinel value used when an inline ``AgentTool`` declares
|
|
# ``os_env: inherit`` in its YAML. Resolved against the parent's
|
|
# concrete :class:`OSEnvSpec` at translation time inside
|
|
# :func:`_resolve_inline_agent_tool_os_env`; the sub-spec stores
|
|
# the resolved dataclass on its top-level ``AgentSpec.os_env``
|
|
# field and the literal string is never persisted past
|
|
# translation. We resolve eagerly because omnigent spawns
|
|
# each child as an independent task — no live parent session
|
|
# exists at runtime to walk.
|
|
_OS_ENV_INHERIT_SENTINEL = "inherit"
|
|
|
|
# Omnigent loader policy-type discriminators. Used to dispatch
|
|
# per-policy-type translation from the raw YAML dict.
|
|
_POLICY_TYPE_FUNCTION = "function"
|
|
_POLICY_TYPE_PROMPT = "prompt"
|
|
_KNOWN_POLICY_TYPES: frozenset[str] = frozenset(
|
|
{_POLICY_TYPE_FUNCTION, _POLICY_TYPE_PROMPT},
|
|
)
|
|
|
|
|
|
# Harnesses that route through locally installed CLIs backed by the user's
|
|
# subscription plan (Claude Code, Codex). These must NOT inherit a parent
|
|
# agent's Databricks profile; doing so would trigger Databricks routing
|
|
# and bypass subscription auth.
|
|
_SUBSCRIPTION_AUTH_HARNESSES: frozenset[str] = frozenset({"claude-native", "claude-sdk", "codex"})
|
|
|
|
# ── Forward direction: AgentSpec → AgentDef ────────────────────
|
|
|
|
|
|
def agent_spec_to_agent_def(spec: AgentSpec) -> AgentDef:
|
|
"""
|
|
Translate an omnigent ``AgentSpec`` into an omnigent
|
|
``AgentDef`` suitable for
|
|
:func:`omnigent.executor_factory.create_executor`.
|
|
|
|
Handles: name, instructions / prompt, ``llm.model``,
|
|
``executor.config`` (``harness``, ``profile``), and
|
|
function-type local tools encoded as dotted module paths.
|
|
|
|
:param spec: The omnigent spec to translate. ``spec.llm``
|
|
must be set (the omnigent harness needs a model name);
|
|
``spec.executor.type`` must be ``"omnigent"`` with
|
|
``executor.config.harness`` populated.
|
|
:returns: A populated :class:`omnigent.datamodel.AgentDef`
|
|
with ``name``, ``prompt``, ``executor``, and ``tools``
|
|
filled in. ``policies`` and ``os_env`` are left at their
|
|
dataclass defaults — those concepts are unsupported and
|
|
rejected upstream.
|
|
:raises OmnigentError: If the spec uses an unsupported
|
|
concept (``guardrails.policies``, sandbox, MCP server, or
|
|
``cancellable_function`` tool). The error message names
|
|
the specific field.
|
|
"""
|
|
_reject_unsupported_concepts(spec)
|
|
|
|
if spec.executor.model is None:
|
|
raise OmnigentError(
|
|
"executor.type='omnigent' requires a model (set executor.model)",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
|
|
# For native Omnigent v1 specs ``executor.config`` is empty; infer the
|
|
# harness from the model prefix so Claude/GPT models get the right
|
|
# executor instead of falling back to DatabricksExecutor.
|
|
_raw_harness: str | None = spec.executor.config.get("harness")
|
|
if not _raw_harness:
|
|
_raw_harness = _infer_harness_from_model(spec.executor.model) or None
|
|
_raw_harness = canonicalize_harness(_raw_harness)
|
|
executor_spec = OmniExecutorSpec(
|
|
model=spec.executor.model,
|
|
harness=_raw_harness,
|
|
profile=spec.executor.config.get("profile"),
|
|
)
|
|
|
|
# ``AgentDef.name`` / ``AgentDef.prompt`` are ``str | None`` — pass
|
|
# the spec's values (also ``str | None``) through unchanged.
|
|
# ``spec.os_env`` is the parsed :class:`OSEnvSpec` dataclass
|
|
# (or ``None``); :func:`_agent_tool_to_sub_spec` resolves the
|
|
# ``"inherit"`` sentinel at translation time so it never
|
|
# reaches the forward path as a string.
|
|
# Bundle root: derived from any bundled skill's ``skill_dir``
|
|
# (each lives at ``<bundle>/skills/<name>/`` per AGENTSPEC.md).
|
|
# Without it the Claude SDK harness can't expose bundled skills
|
|
# via ``--plugin-dir``. ``None`` when the spec has no skills —
|
|
# nothing to expose, nothing to set.
|
|
bundle_dir = (
|
|
spec.skills[0].skill_dir.parents[1]
|
|
if spec.skills and spec.skills[0].skill_dir is not None
|
|
else None
|
|
)
|
|
return AgentDef(
|
|
name=spec.name,
|
|
prompt=spec.instructions,
|
|
tools=_translate_tools_to_omnigent(spec),
|
|
executor=executor_spec,
|
|
os_env=spec.os_env,
|
|
bundle_dir=bundle_dir,
|
|
skills_filter=spec.skills_filter,
|
|
)
|
|
|
|
|
|
def _reject_unsupported_concepts(spec: AgentSpec) -> None:
|
|
"""
|
|
Fail loud when the spec uses an unsupported concept.
|
|
|
|
Each branch names the specific field so the caller knows what
|
|
to remove from the spec (or what omnigent bug to file to
|
|
close the gap).
|
|
|
|
:param spec: The omnigent spec to check.
|
|
:raises OmnigentError: On any of:
|
|
``executor.config.sandbox`` set, an MCP server declared,
|
|
or a ``cancellable_function`` in ``local_tools``.
|
|
"""
|
|
# ``guardrails.policies`` is intentionally NOT rejected here.
|
|
# Policies are enforced by the omnigent workflow layer
|
|
# (see ``omnigent.runtime.policies.build_policy_engine``)
|
|
# BEFORE the harness receives a turn — the
|
|
# :class:`OmnigentExecutor` is invoked with tool calls
|
|
# already gated through INPUT/TOOL_CALL, so the harness-side
|
|
# AgentDef doesn't need to carry the policy metadata. This
|
|
# pass-through (drop guardrails from the forward
|
|
# translation) is the one place we accept "silently dropping
|
|
# a spec field" because the field is consumed upstream and
|
|
# has no meaning to the harness.
|
|
|
|
# Sandbox declarations (omnigent ``tools.sandbox.container_image``
|
|
# and the omnigent OSEnvSandboxSpec) are unsupported. Fail loud
|
|
# if either is populated.
|
|
if spec.tools.sandbox.container_image is not None:
|
|
raise OmnigentError(
|
|
"tools.sandbox translation to omnigent OSEnvSpec is unsupported; "
|
|
"the adapter rejects specs with sandbox rather than silently dropping it",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
|
|
# ``cancellable_function`` is an omnigent concept — Omnigent'
|
|
# LocalToolInfo has no cancellation flag, so this branch currently
|
|
# only triggers when a caller constructs an AgentSpec with a tool
|
|
# path that we later discover is a cancellable-function runner.
|
|
for tool in spec.local_tools:
|
|
# Client-runtime tools carry no path — skip the
|
|
# cancellable-function detection entirely. ``path is None``
|
|
# is only legal for ``runtime: client`` (the validator
|
|
# enforces this on every other code path).
|
|
if tool.path is None:
|
|
continue
|
|
if _is_cancellable_function_path(tool.path):
|
|
raise OmnigentError(
|
|
f"cancellable_function tool {tool.name!r} cannot be translated to "
|
|
"omnigent; cancellation support is an omnigent spec extension",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
|
|
|
|
def _is_cancellable_function_path(path: str) -> bool:
|
|
"""
|
|
Detect whether a tool dotted path names a cancellable-function
|
|
runner.
|
|
|
|
Agent-plane's ``LocalToolInfo.path`` normally points at a plain
|
|
Python function file. Cancellable-function tools are flagged
|
|
explicitly in the omnigent spec; omnigent has no spec
|
|
surface for that flag yet, so this helper returns ``False``
|
|
today and exists as the hook where future work will wire the
|
|
detection once ``LocalToolInfo`` grows a ``cancellable``
|
|
attribute.
|
|
|
|
:param path: The tool's dotted / filesystem path, e.g.
|
|
``"tools/python/arxiv_search.py"``.
|
|
:returns: ``True`` if the tool is known to be a
|
|
cancellable-function runner. Always ``False`` today.
|
|
"""
|
|
del path
|
|
return False
|
|
|
|
|
|
def _translate_tools_to_omnigent(spec: AgentSpec) -> dict[str, Tool]:
|
|
"""
|
|
Build the ``AgentDef.tools`` dict from Omnigent' tool model.
|
|
|
|
Function-type local tools only: each :class:`LocalToolInfo`
|
|
whose ``path`` is a dotted Python module path (e.g.
|
|
``"examples._shared.tool_functions.get_current_time"``) is resolved via
|
|
:func:`importlib.import_module` and wrapped in a
|
|
:class:`omnigent.tools.FunctionTool`. Filesystem paths
|
|
(``"tools/python/foo.py"``) are not supported — the YAML adapter
|
|
that produces them must encode dotted paths.
|
|
|
|
:param spec: The omnigent spec. ``spec.local_tools`` lists
|
|
the tools to translate.
|
|
:returns: A ``dict[str, Tool]`` mapping tool name to
|
|
:class:`FunctionTool`, ready for assignment to
|
|
``AgentDef.tools``. Empty dict if the spec declares no
|
|
local tools.
|
|
:raises OmnigentError: If a tool path is a filesystem path
|
|
(not importable via :func:`importlib.import_module`) or if
|
|
the resolved attribute is not callable.
|
|
"""
|
|
tools: dict[str, Tool] = {}
|
|
for tool_info in spec.local_tools:
|
|
# Client-runtime tools have no server-side callable to
|
|
# resolve; the SDK consumer implements them at stream-start
|
|
# time. Round-trip them as a ``FunctionTool`` with no
|
|
# callable and ``runtime="client"`` so the discriminator
|
|
# survives a forward→reverse pass through the spec layer.
|
|
if tool_info.runtime == ToolRuntime.CLIENT:
|
|
tools[tool_info.name] = FunctionTool(
|
|
name=tool_info.name,
|
|
input_schema=tool_info.parameters,
|
|
runtime="client",
|
|
)
|
|
continue
|
|
# UC function tools have a catalog_path and no server-side
|
|
# callable. Round-trip them as a ``FunctionTool`` with the
|
|
# catalog_path preserved so the inner stack can delegate to
|
|
# the runner's UC executor at call time.
|
|
if tool_info.runtime == ToolRuntime.UC_FUNCTION:
|
|
tools[tool_info.name] = FunctionTool(
|
|
name=tool_info.name,
|
|
catalog_path=tool_info.catalog_path,
|
|
input_schema=tool_info.parameters,
|
|
warehouse_id=tool_info.warehouse_id,
|
|
)
|
|
continue
|
|
# Step (c): every ``LocalToolInfo`` resolves to a plain
|
|
# callable. The runner-protocol fallback (instances with
|
|
# ``.start(args, on_complete)``) was removed once
|
|
# ``sys_call_async`` superseded ``CancellableFunctionTool``
|
|
# — see ``designs/SERVER_HARNESS_CONTRACT.md`` §"Async
|
|
# work + inbox" for the rationale and migration notes.
|
|
if tool_info.path is None:
|
|
raise OmnigentError(
|
|
f"tool {tool_info.name!r}: server-runtime tool has no "
|
|
f"path. Server-runtime tools must declare a dotted "
|
|
f"callable path; only client-runtime tools may omit it.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
resolved = _resolve_dotted_attr(tool_info.path, tool_info.name)
|
|
if not callable(resolved):
|
|
raise OmnigentError(
|
|
f"tool {tool_info.name!r}: {tool_info.path!r} resolved "
|
|
f"to a non-callable {type(resolved).__name__}. Inner-stack "
|
|
f"runner-protocol tools (``CancellableFunctionTool``) were "
|
|
f"retired post-step-11; declare a plain callable and let "
|
|
f"the LLM dispatch it asynchronously via ``sys_call_async`` "
|
|
f"when cancellation is needed.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
# ``parameters`` was populated on the forward trip from
|
|
# the omnigent tool's ``input_schema``. Re-attach it so
|
|
# the inner harness advertises the same JSON Schema to
|
|
# the LLM that the YAML declared.
|
|
tools[tool_info.name] = FunctionTool(
|
|
name=tool_info.name,
|
|
callable=resolved,
|
|
input_schema=tool_info.parameters,
|
|
)
|
|
# Sub-agents named in tools.agents get an AgentTool entry so the
|
|
# inner harness surfaces them as callable tools to the LLM.
|
|
# Without this, coding_supervisor-style YAMLs lose their
|
|
# claude_worker / codex_worker tools on the reverse trip.
|
|
exposed = set(spec.tools.agents)
|
|
for sub in spec.sub_agents:
|
|
if sub.name and sub.name in exposed:
|
|
tools[sub.name] = _sub_spec_to_agent_tool(sub)
|
|
# MCP servers round-trip back to inner :class:`MCPTool` entries
|
|
# so the omnigent harness surfaces them to the LLM via its
|
|
# own MCP machinery (``omnigent.inner.mcp_tools``). The
|
|
# forward direction translated ``MCPTool`` →
|
|
# :class:`MCPServerConfig` (stdio or http); this is the inverse.
|
|
# ``OmnigentExecutor.from_spec`` calls this reverse path when
|
|
# wrapping an omnigent spec for an omnigent harness; if the
|
|
# reverse trip dropped MCP entries the harness would lose every
|
|
# MCP tool the spec declared.
|
|
for mcp in spec.mcp_servers:
|
|
tools[mcp.name] = _mcp_server_to_mcp_tool(mcp)
|
|
return tools
|
|
|
|
|
|
def _mcp_server_to_mcp_tool(config: MCPServerConfig) -> MCPTool:
|
|
"""
|
|
Build an inner :class:`MCPTool` from a native
|
|
:class:`MCPServerConfig` — the reverse of
|
|
:func:`_translate_mcp_tool_from_def`.
|
|
|
|
Transport dispatch:
|
|
|
|
- ``transport == "stdio"`` → ``MCPTool(command=...,
|
|
args=..., env=...)``. Lists and dicts are copied so the
|
|
inner tool doesn't share mutable state with the outer spec.
|
|
- ``transport == "http"`` → ``MCPTool(url=..., headers=...)``.
|
|
|
|
The spec validator rejects mixed-transport fields upstream, so
|
|
the required-field check per branch here is a belt-and-
|
|
suspenders for programmatic paths that bypass validation.
|
|
|
|
:param config: The native :class:`MCPServerConfig` to invert.
|
|
:returns: An :class:`MCPTool` with the equivalent transport
|
|
shape for the inner omnigent runtime.
|
|
:raises OmnigentError: If the config is missing the
|
|
required field for its declared transport (a programmatic
|
|
construction path that bypassed the validator).
|
|
"""
|
|
if config.transport == "stdio":
|
|
if config.command is None:
|
|
raise OmnigentError(
|
|
f"MCP server {config.name!r} transport='stdio' but command is None; "
|
|
f"validator should have rejected this upstream",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return MCPTool(
|
|
command=config.command,
|
|
args=list(config.args) if config.args else None,
|
|
env=dict(config.env) if config.env else None,
|
|
tools=list(config.tools) if config.tools else None,
|
|
)
|
|
if config.url is None:
|
|
raise OmnigentError(
|
|
f"MCP server {config.name!r} transport='http' but url is None; "
|
|
f"validator should have rejected this upstream",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return MCPTool(
|
|
url=config.url,
|
|
headers=dict(config.headers) if config.headers else None,
|
|
tools=list(config.tools) if config.tools else None,
|
|
)
|
|
|
|
|
|
def _sub_spec_to_agent_tool(sub: AgentSpec) -> AgentTool:
|
|
"""
|
|
Rebuild an omnigent :class:`AgentTool` from a nested
|
|
omnigent :class:`AgentSpec`.
|
|
|
|
Inverse of :func:`_agent_tool_to_sub_spec`. Reads the sub-spec's
|
|
``llm.model`` and ``executor.config`` (harness / profile) to
|
|
reconstruct the omnigent :class:`ExecutorSpec`. Lossy fields
|
|
(``max_sessions``, ``os_env``, ``pass_history``,
|
|
``pass_histories``) are left at omnigent defaults.
|
|
|
|
:param sub: The nested :class:`AgentSpec` representing a
|
|
sub-agent exposed to the parent as a tool.
|
|
:returns: A populated :class:`omnigent.tools.AgentTool`.
|
|
"""
|
|
# Infer harness from model prefix when executor.config is empty
|
|
# (native Omnigent v1 specs). Also forward os_env so sub-sessions get
|
|
# the filesystem tools the spec declared.
|
|
model = sub.llm.model if sub.llm is not None else None
|
|
harness: str | None = sub.executor.config.get("harness")
|
|
if not harness and model:
|
|
harness = _infer_harness_from_model(model) or None
|
|
harness = canonicalize_harness(harness)
|
|
profile = sub.executor.config.get("profile")
|
|
return AgentTool(
|
|
name=sub.name,
|
|
description=sub.description,
|
|
prompt=sub.instructions,
|
|
os_env=sub.os_env,
|
|
executor=OmniExecutorSpec(
|
|
model=model,
|
|
harness=harness,
|
|
profile=profile,
|
|
),
|
|
)
|
|
|
|
|
|
def _resolve_dotted_attr(dotted_path: str, tool_name: str) -> Any:
|
|
"""
|
|
Resolve a dotted import path to whatever object it names.
|
|
|
|
Unlike :func:`_resolve_dotted_callable`, this does NOT require
|
|
the resolved object to be callable — it's used by the tool-
|
|
translation path where the resolved object may be either a
|
|
function (→ wrap in :class:`FunctionTool`) OR a
|
|
cancellable-runner instance (→ wrap in
|
|
:class:`CancellableFunctionTool`). Callers are responsible
|
|
for the shape check.
|
|
|
|
:param dotted_path: Dotted import path to the callable, e.g.
|
|
``"examples._shared.tool_functions.get_current_time"``.
|
|
:param tool_name: Tool name used only for error messages, e.g.
|
|
``"get_current_time"``.
|
|
:returns: The resolved attribute object (callable or
|
|
runner or other).
|
|
:raises OmnigentError: If the path is not a dotted import
|
|
path, the module cannot be imported, or the attribute is
|
|
missing.
|
|
"""
|
|
if "/" in dotted_path or dotted_path.endswith(".py"):
|
|
raise OmnigentError(
|
|
f"tool {tool_name!r} has filesystem path {dotted_path!r}; "
|
|
"omnigent translator requires dotted import paths like "
|
|
"'examples._shared.tool_functions.get_current_time'",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
if "." not in dotted_path:
|
|
raise OmnigentError(
|
|
f"tool {tool_name!r} path {dotted_path!r} must be a dotted path "
|
|
"of the form 'module.attribute'",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
module_name, _, attr_name = dotted_path.rpartition(".")
|
|
try:
|
|
module = importlib.import_module(module_name)
|
|
except (ImportError, ValueError) as exc:
|
|
# ``ValueError`` from importlib fires on empty module
|
|
# names — what you get when the caller hands us a string
|
|
# with no dots, e.g. ``"foo"`` → ``rpartition`` yields
|
|
# ``("", "", "foo")``. Wrap both failure modes in a
|
|
# single OmnigentError so the spec author sees a
|
|
# consistent "use a dotted import path" message.
|
|
raise OmnigentError(
|
|
f"tool {tool_name!r}: cannot resolve {dotted_path!r} — "
|
|
f"expected a dotted import path like "
|
|
f"'examples.tool_functions.get_current_time'. "
|
|
f"Underlying error: {exc}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
) from exc
|
|
if not hasattr(module, attr_name):
|
|
raise OmnigentError(
|
|
f"tool {tool_name!r}: module {module_name!r} has no attribute {attr_name!r}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return getattr(module, attr_name)
|
|
|
|
|
|
# Callable return type for tool callables. ``Callable[..., object]``
|
|
# is the best we can do because the tool functions have arbitrary
|
|
# signatures — the ``...`` args-form is the standard "we don't care
|
|
# about the args" spelling. Annotation carries a type-ignore because
|
|
# ``...`` expands to ``Any`` in mypy's internal representation; the
|
|
# alternative (listing every possible signature) isn't tractable.
|
|
def _resolve_dotted_callable(
|
|
dotted_path: str,
|
|
tool_name: str,
|
|
) -> Callable[..., Any]:
|
|
"""
|
|
Resolve a dotted import path to a callable.
|
|
|
|
Thin wrapper around :func:`_resolve_dotted_attr` that adds a
|
|
callability check — the only difference between the two is
|
|
the shape requirement on the resolved object.
|
|
|
|
:param dotted_path: Dotted import path to the callable, e.g.
|
|
``"examples.tool_functions.get_current_time"``.
|
|
:param tool_name: Tool name used only for error messages.
|
|
:returns: The resolved callable.
|
|
:raises OmnigentError: If the module can't be imported,
|
|
the attribute is missing, or the resolved attribute is
|
|
not callable.
|
|
"""
|
|
resolved = _resolve_dotted_attr(dotted_path, tool_name)
|
|
if not callable(resolved):
|
|
raise OmnigentError(
|
|
f"tool {tool_name!r}: {dotted_path!r} resolves to non-callable "
|
|
f"{type(resolved).__name__}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
# ``resolved`` is typed ``object`` from _resolve_dotted_attr;
|
|
# the runtime ``callable`` guard above narrows it. The cast
|
|
# is only needed for mypy's return-type check.
|
|
return cast(Callable[..., object], resolved)
|
|
|
|
|
|
# ── Policy translation (omnigent YAML dict → omnigent
|
|
# GuardrailsSpec) ────────────────────────────────────────────────
|
|
#
|
|
# We translate from the RAW YAML dict (read alongside the AgentDef)
|
|
# rather than from the parsed ``Policy`` objects. Agent-plane's own
|
|
# parser (``omnigent.spec.parser._parse_guardrails``) is invoked
|
|
# on the translated dict so the field-level validation rules (e.g.
|
|
# PhaseSelector-on-INPUT rejection, action enum coercion, label
|
|
# schema value checks) land in one place.
|
|
|
|
|
|
def _translate_guardrails_yaml(
|
|
raw_policies: dict[str, Any] | None,
|
|
raw_labels: dict[str, Any] | None,
|
|
raw_label_schema: dict[str, Any] | None,
|
|
raw_ask_timeout: Any,
|
|
*,
|
|
parent_profile: str | None = None,
|
|
) -> GuardrailsSpec | None:
|
|
"""
|
|
Translate the guardrails-related top-level fields of an
|
|
omnigent YAML into an omnigent :class:`GuardrailsSpec`.
|
|
|
|
Omnigent declares labels/policies at the top level
|
|
(``labels:``, ``label_schema:``, ``policies:``,
|
|
``ask_timeout:``); omnigent groups them under a single
|
|
``guardrails:`` block. This helper transforms the omnigent
|
|
shape into the omnigent dict shape and delegates to
|
|
:func:`omnigent.spec.parser._parse_guardrails` for
|
|
validation — so every field-level rule (phase/tool validity,
|
|
action enum coercion, label schema constraints) lands in the
|
|
shared parser rather than being reimplemented here.
|
|
|
|
Returns ``None`` when all four inputs are empty / absent —
|
|
the synthesized :class:`AgentSpec` then carries
|
|
``guardrails=None`` and the runtime builds a no-op
|
|
:class:`PolicyEngine` (per POLICIES.md §10 zero-policy case).
|
|
|
|
:param raw_policies: Omnigent YAML ``policies:`` mapping,
|
|
keyed by policy name, e.g.
|
|
``{"block_long_sleep": {"type": "function",
|
|
"on": ["tool_call"],
|
|
"handler": "examples.tool_functions.block_long_sleep"}}``.
|
|
``None`` or empty means no policies.
|
|
:param raw_labels: Omnigent YAML top-level ``labels:`` map
|
|
of ``{label_key: initial_value}``. ``None`` or empty means
|
|
no labels — a schema-only label (``label_schema`` entry
|
|
without a ``labels`` entry) is still allowed and gets
|
|
``initial: None``.
|
|
:param raw_label_schema: Omnigent YAML top-level
|
|
``label_schema:`` map of
|
|
``{label_key: {values: [...], monotonic: "max"|"min"|"none"}}``.
|
|
:param raw_ask_timeout: Omnigent YAML top-level
|
|
``ask_timeout:`` (seconds), e.g. ``30``. Passed through
|
|
to ``guardrails.ask_timeout``. ``None`` means use the
|
|
omnigent default (see
|
|
:data:`omnigent.spec.types.DEFAULT_ASK_TIMEOUT`).
|
|
:param parent_profile: Databricks profile from the parent
|
|
agent's executor (``agent_def.executor.profile``). Used
|
|
to resolve ``type: prompt`` policy LLM credentials at
|
|
translation time — see
|
|
:func:`_translate_prompt_policy_yaml`. ``None`` skips
|
|
injection (policies must then carry their own
|
|
``connection:`` or fail at classifier call time).
|
|
:returns: A validated :class:`GuardrailsSpec`, or ``None``
|
|
when no guardrails-related fields were declared in the
|
|
source YAML.
|
|
:raises OmnigentError: On malformed entries — same error
|
|
shape the omnigent YAML parser produces for a native
|
|
``guardrails:`` block, so YAML authors see consistent
|
|
error messages whether their spec came from omnigent
|
|
or omnigent.
|
|
"""
|
|
if not raw_policies and not raw_labels and not raw_label_schema and raw_ask_timeout is None:
|
|
return None
|
|
# Build the omnigent-shaped guardrails dict, then hand it to
|
|
# the native parser. Import locally to avoid a circular import
|
|
# at module load time (parser imports from spec.types, which is
|
|
# also this module's caller chain).
|
|
from omnigent.spec.parser import _parse_guardrails
|
|
|
|
translated: dict[str, Any] = {}
|
|
labels_yaml = _translate_labels_yaml(raw_labels, raw_label_schema)
|
|
if labels_yaml:
|
|
translated["labels"] = labels_yaml
|
|
policies_yaml = _translate_policies_yaml(raw_policies, parent_profile=parent_profile)
|
|
if policies_yaml:
|
|
translated["policies"] = policies_yaml
|
|
if raw_ask_timeout is not None:
|
|
translated["ask_timeout"] = raw_ask_timeout
|
|
# ``expand_env`` is False because the upstream omnigent
|
|
# loader has already expanded env vars in the dict we're
|
|
# handed (per its own conventions). Running the expansion a
|
|
# second time would double-expand ``$`` characters that happen
|
|
# to appear in a legitimately-resolved value.
|
|
return _parse_guardrails(translated, expand_env=False)
|
|
|
|
|
|
def _translate_labels_yaml(
|
|
raw_labels: dict[str, Any] | None,
|
|
raw_label_schema: dict[str, Any] | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""
|
|
Merge omnigent' separate ``labels:`` (initial values) and
|
|
``label_schema:`` (schemas) into Omnigent' unified
|
|
``guardrails.labels:`` shape.
|
|
|
|
Agent-plane's :class:`LabelDef` bundles ``initial`` and
|
|
``values`` into one entry per key.
|
|
|
|
:param raw_labels: Initial values map, e.g.
|
|
``{"integrity": "1", "confidentiality": "0"}``.
|
|
:param raw_label_schema: Schema map, e.g.
|
|
``{"integrity": {"values": ["0", "1"]}}``.
|
|
:returns: Agent-plane-shaped labels dict, e.g.
|
|
``{"integrity": {"initial": "1", "values": ["0", "1"]}}``.
|
|
Empty dict when both inputs are empty.
|
|
"""
|
|
initials = raw_labels or {}
|
|
schemas = raw_label_schema or {}
|
|
keys = set(initials.keys()) | set(schemas.keys())
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for key in keys:
|
|
entry: dict[str, Any] = {}
|
|
if key in initials:
|
|
entry["initial"] = initials[key]
|
|
schema = schemas.get(key, {})
|
|
if isinstance(schema, dict):
|
|
if "values" in schema:
|
|
entry["values"] = schema["values"]
|
|
out[key] = entry
|
|
return out
|
|
|
|
|
|
def _translate_policies_yaml(
|
|
raw_policies: dict[str, Any] | None,
|
|
*,
|
|
parent_profile: str | None = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""
|
|
Translate the omnigent ``policies:`` mapping entry-by-entry
|
|
into the omnigent shape.
|
|
|
|
:param raw_policies: Raw ``policies:`` map from the omnigent
|
|
YAML, keyed by policy name.
|
|
:param parent_profile: Databricks profile from the parent
|
|
agent's executor — threaded down so ``type: prompt``
|
|
policies without their own ``connection:`` can resolve
|
|
credentials at translation time. See
|
|
:func:`_translate_prompt_policy_yaml`.
|
|
:returns: Agent-plane-shaped ``policies:`` map — same keys,
|
|
transformed values. Empty dict when *raw_policies* is
|
|
``None`` or empty.
|
|
"""
|
|
if not raw_policies:
|
|
return {}
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for policy_name, raw_entry in raw_policies.items():
|
|
if not isinstance(raw_entry, dict):
|
|
# Let the downstream parser raise its own message for
|
|
# malformed entries — it already produces a specific
|
|
# error naming the field.
|
|
out[policy_name] = raw_entry
|
|
continue
|
|
out[policy_name] = _translate_policy_entry_yaml(
|
|
policy_name,
|
|
raw_entry,
|
|
parent_profile=parent_profile,
|
|
)
|
|
return out
|
|
|
|
|
|
def _translate_policy_entry_yaml(
|
|
policy_name: str,
|
|
raw_entry: dict[str, Any],
|
|
*,
|
|
parent_profile: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Dispatch a single policy entry to its type-specific
|
|
translator.
|
|
|
|
:param policy_name: YAML key under ``policies:``, e.g.
|
|
``"block_long_sleep"`` — included in error messages.
|
|
:param raw_entry: Raw YAML mapping for this policy.
|
|
:param parent_profile: Databricks profile from the parent
|
|
agent's executor. Only consumed by the ``type: prompt``
|
|
branch — function policies don't carry LLM configs so
|
|
the profile is irrelevant there.
|
|
:returns: Agent-plane-shaped dict.
|
|
:raises OmnigentError: When the entry declares a policy
|
|
``type`` the translator doesn't recognize.
|
|
"""
|
|
policy_type = raw_entry.get("type", _POLICY_TYPE_FUNCTION)
|
|
if policy_type not in _KNOWN_POLICY_TYPES:
|
|
raise OmnigentError(
|
|
f"omnigent policy {policy_name!r}: unknown type "
|
|
f"{policy_type!r} (must be one of {sorted(_KNOWN_POLICY_TYPES)})",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
if policy_type == _POLICY_TYPE_FUNCTION:
|
|
return _translate_function_policy_yaml(raw_entry)
|
|
# policy_type == _POLICY_TYPE_PROMPT (exhaustive after the
|
|
# _KNOWN_POLICY_TYPES guard above).
|
|
return _translate_prompt_policy_yaml(raw_entry, parent_profile=parent_profile)
|
|
|
|
|
|
def _translate_function_policy_yaml(
|
|
raw_entry: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Translate an omnigent ``type: function`` policy to the
|
|
omnigent shape.
|
|
|
|
Field mapping:
|
|
|
|
- ``handler: path.to.fn`` → ``function: path.to.fn``
|
|
- ``handler: path.to.fn`` + ``factory_params: {...}`` →
|
|
``function: {path: path.to.fn, arguments: {...}}``
|
|
- Other fields (``condition``, ``action``, ``set_labels``,
|
|
``ask_timeout``) pass through unchanged. ``on:`` is
|
|
intentionally excluded — ``type: function`` policies no
|
|
longer accept ``on:`` on the omnigent side; the handler
|
|
self-selects which events to act on by returning ``None`` to
|
|
abstain.
|
|
|
|
:param raw_entry: Raw YAML mapping for one ``type: function``
|
|
policy, e.g. ``{"type": "function",
|
|
"handler": "examples.block_long_sleep"}``.
|
|
:returns: Agent-plane-shaped dict, e.g.
|
|
``{"type": "function",
|
|
"function": "examples.block_long_sleep"}``.
|
|
"""
|
|
out: dict[str, Any] = {}
|
|
# Copy passthrough fields first so the order stays stable for
|
|
# readability. ``type`` is kept for dispatch on the
|
|
# omnigent parser side. ``on:`` is intentionally omitted —
|
|
# function policies are now always called for all phases; the
|
|
# handler self-selects by returning ``None`` to abstain.
|
|
for key in ("type", "condition", "action", "set_labels", "ask_timeout"):
|
|
if key in raw_entry:
|
|
out[key] = raw_entry[key]
|
|
# If ``function:`` is already present in omnigent native
|
|
# format, pass through as-is (no shim wrapping needed).
|
|
if "function" in raw_entry:
|
|
out["function"] = raw_entry["function"]
|
|
if "on" in raw_entry:
|
|
out["on"] = raw_entry["on"]
|
|
return out
|
|
# Accept both ``handler:`` (current name) and ``callable:`` (legacy alias).
|
|
callable_path = raw_entry.get("handler") or raw_entry.get("callable")
|
|
factory_params = raw_entry.get("factory_params")
|
|
if callable_path is None:
|
|
# Let the omnigent parser surface the "function:
|
|
# required" error — it already does so with a clear
|
|
# message referencing the policy name.
|
|
return out
|
|
# Route every omnigent-sourced function policy through the
|
|
# legacy-compat shim so author callables written with the
|
|
# omnigent ``(content, phase)`` / ``(content, phase, context)``
|
|
# convention keep working under Omnigent'
|
|
# ``(ctx, context)`` convention. For omnigent-native
|
|
# callables the shim is a pass-through (detected by parameter
|
|
# names at policy-build time; see _omnigent_legacy_shim).
|
|
# The wrapper lives at load time — zero cost in the engine's
|
|
# hot evaluate() loop.
|
|
shim_args: dict[str, Any] = {"target": callable_path}
|
|
if factory_params:
|
|
shim_args["factory_kwargs"] = factory_params
|
|
out["function"] = {
|
|
"path": "omnigent.spec._omnigent_legacy_shim.build",
|
|
"arguments": shim_args,
|
|
}
|
|
return out
|
|
|
|
|
|
def _resolve_profile_to_connection(profile: str) -> dict[str, str] | None:
|
|
"""
|
|
Resolve a Databricks profile name to a
|
|
``{base_url, api_key}`` dict by reading ``~/.databrickscfg``.
|
|
|
|
Used at spec-translation time to make omnigent LLMConfig
|
|
self-contained — Omnigent' LLM adapters take credentials
|
|
via explicit ``connection:`` fields (per the "spec
|
|
self-containment" design principle) and do not read env vars.
|
|
Meanwhile omnigent declares Databricks creds via the
|
|
``profile:`` convenience shortcut. The translator closes the
|
|
gap by resolving profile → connection once at load time.
|
|
|
|
:param profile: Profile name in ``~/.databrickscfg``, e.g.
|
|
``"<your-profile>"``.
|
|
:returns: ``{"base_url": "<host>/serving-endpoints", "api_key":
|
|
"<token>"}`` when the profile is readable and has both
|
|
host + token; ``None`` when the profile is missing or
|
|
malformed (callers treat ``None`` as "skip injection" —
|
|
the policy then falls back to whatever connection the
|
|
YAML declared explicitly, or errors loud at call time).
|
|
"""
|
|
# Import locally to avoid a top-level dependency on omnigent
|
|
# runtime details from the spec module — only this single
|
|
# translator path needs it.
|
|
from omnigent.inner.databricks_executor import _read_databrickscfg
|
|
|
|
creds = _read_databrickscfg(profile)
|
|
if creds is None:
|
|
return None
|
|
return {
|
|
"base_url": creds.host.rstrip("/") + "/serving-endpoints",
|
|
"api_key": creds.token,
|
|
}
|
|
|
|
|
|
def _translate_prompt_policy_yaml(
|
|
raw_entry: dict[str, Any],
|
|
*,
|
|
parent_profile: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Translate an omnigent ``type: prompt`` policy to the
|
|
omnigent shape.
|
|
|
|
Field mapping:
|
|
|
|
- ``executor: {model: X}`` → ``llm: {model: X}``. The
|
|
omnigent prompt policy uses ``executor`` to override
|
|
the classifier LLM; omnigent uses ``llm``.
|
|
- When *parent_profile* is set AND the policy has no
|
|
explicit ``connection:``, resolve the profile to a
|
|
``connection: {base_url, api_key}`` dict so the
|
|
omnigent classifier actually reaches the Databricks
|
|
gateway. Without this, a policy declaring
|
|
``model: databricks-claude-sonnet-4`` parses as provider
|
|
``"openai"`` and the request hits ``api.openai.com``.
|
|
- Other fields (``on``, ``condition``, ``prompt``,
|
|
``set_labels``, ``ask_timeout``) pass through unchanged.
|
|
|
|
:param raw_entry: Raw YAML mapping for one ``type: prompt``
|
|
policy, e.g. ``{"type": "prompt", "on": ["request"],
|
|
"executor": {"model": "databricks-claude-sonnet-4"},
|
|
"prompt": "Deny Canada-related requests."}``.
|
|
:param parent_profile: Databricks profile from the parent
|
|
agent's ``executor.profile`` — usually set by the
|
|
``--profile`` CLI flag or the YAML's top-level
|
|
``executor.profile:`` field. When present and the
|
|
policy has no explicit connection, the translator bakes
|
|
the resolved ``{base_url, api_key}`` into the
|
|
omnigent LLMConfig so the classifier call at runtime
|
|
routes correctly. ``None`` means no profile — policies
|
|
must carry their own credentials.
|
|
:returns: Agent-plane-shaped dict with ``executor:`` renamed
|
|
to ``llm:``.
|
|
"""
|
|
del parent_profile # No longer needed — prompt_policy uses event["llm_client"]
|
|
# Translate type:prompt → type:function backed by the
|
|
# prompt_policy builtin factory. The prompt and action fields
|
|
# become factory_params; other fields pass through.
|
|
factory_params: dict[str, Any] = {}
|
|
prompt_text = raw_entry.get("prompt")
|
|
if isinstance(prompt_text, str):
|
|
factory_params["prompt"] = prompt_text
|
|
out: dict[str, Any] = {"type": "function"}
|
|
for key in ("on", "condition", "ask_timeout"):
|
|
if key in raw_entry:
|
|
out[key] = raw_entry[key]
|
|
out["function"] = {
|
|
"path": "omnigent.policies.builtins.prompt.prompt_policy",
|
|
"arguments": factory_params,
|
|
}
|
|
return out
|
|
|
|
|
|
# ── Reverse direction: AgentDef → AgentSpec ────────────────────
|
|
|
|
|
|
def agent_def_to_agent_spec(
|
|
agent_def: AgentDef,
|
|
*,
|
|
raw_yaml: dict[str, Any] | None = None,
|
|
) -> AgentSpec:
|
|
"""
|
|
Translate an omnigent :class:`AgentDef` into an omnigent
|
|
:class:`AgentSpec`.
|
|
|
|
Reverse direction of :func:`agent_spec_to_agent_def`. Used by
|
|
:func:`omnigent.spec.load` when handed an omnigent YAML
|
|
so ``omnigent chat foo.yaml`` routes the spec through
|
|
``OmnigentExecutor`` at runtime.
|
|
|
|
The produced spec sets ``executor.type = "omnigent"`` so the
|
|
runtime picks ``OmnigentExecutor``, with ``harness`` and
|
|
``profile`` carried in ``executor.config``.
|
|
|
|
:param agent_def: Parsed omnigent agent definition,
|
|
typically from :func:`omnigent.loader.load_agent_def`.
|
|
Example: ``AgentDef(name="hello_world",
|
|
prompt="You are a friendly assistant.", ...)``.
|
|
:param raw_yaml: Optional raw YAML dict the ``agent_def`` was
|
|
parsed from. Required to preserve label-policy YAML-level
|
|
fields (``condition``, ``match_tools``, ``action``,
|
|
``reason``, ``set_labels``) and to translate top-level
|
|
``labels:`` / ``label_schema:`` / ``ask_timeout:`` into
|
|
Omnigent' ``guardrails:`` block — the omnigent
|
|
loader compiles these into synthetic FunctionPolicy
|
|
callables and drops the YAML-level fields, so we can't
|
|
recover them from the parsed ``AgentDef`` alone. When
|
|
``None``, policies / labels are ignored (policy-less
|
|
YAMLs continue to translate fine). Production callers
|
|
(``load_omnigent_yaml``) read the raw YAML themselves
|
|
and pass it through.
|
|
:returns: A synthesized :class:`AgentSpec` equivalent. The
|
|
round-trip ``agent_spec_to_agent_def(
|
|
agent_def_to_agent_spec(d)) == d`` must hold for every
|
|
representative fixture.
|
|
:raises OmnigentError: When *agent_def* uses an omnigent
|
|
concept Omnigent' :class:`AgentSpec` cannot currently
|
|
represent. Each such case names the specific field.
|
|
Current unsupported concepts: MCP-type tools.
|
|
"""
|
|
_fail_on_unsupported_concepts_def(agent_def)
|
|
name = _translate_name_from_def(agent_def.name)
|
|
# ``instructions:`` (resolved from a sibling file or inline text by
|
|
# ``omnigent.inner.loader.load_agent_def``) wins over ``prompt:``
|
|
# when both are present. Authors who write ``instructions:
|
|
# AGENTS.md`` are deliberately pointing at a file, and the
|
|
# alternative — silently dropping it in favor of an inline
|
|
# ``prompt:`` — was the bug from kasey_uhlenhuth's report.
|
|
instructions: str | None
|
|
if agent_def.instructions is not None:
|
|
instructions = agent_def.instructions
|
|
else:
|
|
instructions = _translate_prompt_from_def(agent_def.prompt)
|
|
raw_executor = raw_yaml.get("executor") if raw_yaml else None
|
|
if not isinstance(raw_executor, dict):
|
|
raw_executor = None
|
|
llm_config = _translate_llm_from_def(agent_def.executor, raw_executor=raw_executor)
|
|
executor_spec = _translate_executor_from_def(agent_def.executor, raw_executor=raw_executor)
|
|
# Consolidate: ensure executor carries the authoritative model and
|
|
# connection so downstream code reads from one place. The
|
|
# translation functions above populate executor.model already;
|
|
# sync connection from llm_config when present (rare in practice
|
|
# — connection is usually resolved at runtime, not baked into the
|
|
# translated spec).
|
|
if llm_config is not None and llm_config.connection is not None:
|
|
if executor_spec.connection is None:
|
|
executor_spec.connection = llm_config.connection
|
|
|
|
# Parent's Databricks profile (from ``--profile`` CLI arg or
|
|
# YAML ``executor.profile``) propagates down to inline
|
|
# sub-agents that don't declare one of their own. Without
|
|
# this, a ``claude_worker`` inline AgentTool whose YAML
|
|
# omits ``profile:`` inherits empty string — the inner
|
|
# ClaudeSDKExecutor then resolves credentials with no
|
|
# profile and 403s against the Databricks workspace. Pure
|
|
# omnigent gets this "for free" via Session → factory; the
|
|
# omnigent spawn path runs each sub-agent as an
|
|
# independent task, so we have to bake the inheritance into
|
|
# the spec.
|
|
parent_profile = agent_def.executor.profile if agent_def.executor is not None else None
|
|
# Same deal for the harness. Inline AgentTools in YAMLs like
|
|
# ``coding_supervisor_with_forks.yaml`` declare ``prompt:`` +
|
|
# ``os_env:`` + ``tools:`` but omit the ``executor:`` block,
|
|
# relying on the parent's harness to flow down. Without this
|
|
# propagation the sub-spec's harness is empty and the
|
|
# validator rejects with ``sub_agents[...].executor.config.
|
|
# harness: required``.
|
|
#
|
|
# We propagate the *effective* parent harness — i.e. the
|
|
# harness after model-prefix auto-pick has run — not the raw
|
|
# YAML value. A parent YAML like ``agent_with_subagent_session.yaml``
|
|
# declares only ``executor.model: databricks-gpt-5-4-mini`` and
|
|
# relies on auto-pick to resolve it to ``openai-agents``; if we
|
|
# passed the raw ``""`` down, the child (which has no model of
|
|
# its own) would end up with an empty harness and fail
|
|
# validation.
|
|
parent_harness: str | None = None
|
|
if executor_spec.type == "omnigent":
|
|
# executor_spec.config is ``dict[str, Any]`` by design
|
|
# (kept widened by the tech-debt docstring on
|
|
# :class:`ExecutorSpec`). We narrow here to a string
|
|
# before handing off to the child; an empty string means
|
|
# "no effective parent harness" so the child falls
|
|
# through to its own model-prefix auto-pick / fail with
|
|
# a clear validator error.
|
|
resolved = executor_spec.config["harness"]
|
|
assert isinstance(resolved, str)
|
|
parent_harness = resolved if resolved else None
|
|
# Same deal for the OS environment. Inline AgentTool sub-agents
|
|
# that declare ``os_env: inherit`` need to see the parent's
|
|
# concrete :class:`OSEnvSpec` baked into their own sub-spec at
|
|
# translation time — the Omnigent path spawns each child as an
|
|
# independent task so there's no live parent session to resolve
|
|
# ``inherit`` from at runtime. ``None`` is fine: the sub-spec's
|
|
# own os_env (or lack thereof) wins.
|
|
parent_os_env = agent_def.os_env if agent_def.os_env is not None else None
|
|
|
|
# Split omnigent tools by subtype:
|
|
# FunctionTool / CancellableFunctionTool
|
|
# → AgentSpec.local_tools (omnigent treats every
|
|
# tool as cancellable, so no subtype distinction).
|
|
# AgentTool
|
|
# → AgentSpec.sub_agents (one nested AgentSpec each)
|
|
# + ToolsConfig.agents (list of exposed names).
|
|
# MCPTool (stdio / subprocess)
|
|
# → AgentSpec.mcp_servers (one native stdio-transport
|
|
# MCPServerConfig each; subprocess is spawned by
|
|
# Omnigent' ToolManager, srt-wrapped when
|
|
# available — see omnigent/tools/mcp.py).
|
|
# MCPTool (HTTP) → same, but transport='http'.
|
|
local_tools: list[LocalToolInfo] = []
|
|
sub_agents: list[AgentSpec] = []
|
|
agent_tool_names: list[str] = []
|
|
mcp_servers: list[MCPServerConfig] = []
|
|
for tool_name, tool in agent_def.tools.items():
|
|
if isinstance(tool, FunctionTool | CancellableFunctionTool):
|
|
local_tools.append(_translate_function_tool_from_def(tool_name, tool))
|
|
elif isinstance(tool, SelfAgentTool):
|
|
sub_agents.append(
|
|
_self_agent_tool_to_sub_spec(
|
|
tool_name,
|
|
tool,
|
|
agent_def,
|
|
raw_yaml=raw_yaml,
|
|
),
|
|
)
|
|
agent_tool_names.append(tool_name)
|
|
elif isinstance(tool, AgentTool):
|
|
sub_agents.append(
|
|
_agent_tool_to_sub_spec(
|
|
tool_name,
|
|
tool,
|
|
parent_profile=parent_profile,
|
|
parent_harness=parent_harness,
|
|
parent_os_env=parent_os_env,
|
|
# Inherit parent's terminals so inline
|
|
# sub-agents have a path to launch them — see
|
|
# the comment on the recipient parameter for
|
|
# why this is the simplest fix to the
|
|
# "supervisor delegates terminal work to
|
|
# workers" pattern.
|
|
parent_terminals=agent_def.terminals,
|
|
),
|
|
)
|
|
agent_tool_names.append(tool_name)
|
|
elif isinstance(tool, MCPTool):
|
|
mcp_servers.append(_translate_mcp_tool_from_def(tool_name, tool))
|
|
|
|
tools_config = ToolsConfig(agents=agent_tool_names) if agent_tool_names else ToolsConfig()
|
|
|
|
# Translate the guardrails block if the caller handed us the
|
|
# raw YAML. Policies/labels are lifted from the omnigent
|
|
# top-level YAML fields into Omnigent' ``guardrails:``
|
|
# block; enforcement then happens in the omnigent workflow
|
|
# (outside the omnigent harness) via the standard
|
|
# :class:`PolicyEngine`. When ``raw_yaml`` is ``None`` we
|
|
# don't have the YAML shape needed to translate label
|
|
# policies, so we skip — callers in that position are either
|
|
# unit tests that know their fixture has no policies or
|
|
# legacy code paths that haven't been updated yet.
|
|
guardrails: GuardrailsSpec | None = None
|
|
if raw_yaml is not None:
|
|
guardrails = _translate_guardrails_yaml(
|
|
raw_policies=raw_yaml.get("policies"),
|
|
raw_labels=raw_yaml.get("labels"),
|
|
raw_label_schema=raw_yaml.get("label_schema"),
|
|
raw_ask_timeout=raw_yaml.get("ask_timeout"),
|
|
# Baked-in at translation time so Omnigent'
|
|
# classifier LLM calls honor Databricks routing
|
|
# (omnigent adapters don't read env vars per the
|
|
# spec-self-containment design principle).
|
|
parent_profile=parent_profile,
|
|
)
|
|
|
|
# Per designs/OMNIGENT_TERMINAL_BRIDGE.md §6.1 step 2 + §3 non-goal
|
|
# bullet on sub-agent inheritance: thread the top-level agent's
|
|
# ``terminals:`` declaration into ``AgentSpec.terminals``. Inline
|
|
# AgentTool sub-specs (synthesized in ``_agent_tool_to_sub_spec``)
|
|
# leave ``AgentSpec.terminals=None`` because ``AgentTool`` has no
|
|
# ``terminals`` field in the omnigent grammar — matches legacy
|
|
# non-AP mode exactly.
|
|
terminals = dict(agent_def.terminals) if agent_def.terminals else None
|
|
|
|
# Top-level ``skills:`` from the raw YAML (the omnigent
|
|
# loader doesn't carry this field on AgentDef — it's
|
|
# omnigent spec config that controls Claude SDK harness
|
|
# host-skill loading). Validate the same shapes the
|
|
# spec-side parser does so a typo here surfaces the same
|
|
# error regardless of which spec format the user is on.
|
|
skills_filter = _translate_skills_filter_from_yaml(raw_yaml)
|
|
|
|
return AgentSpec(
|
|
spec_version=_SYNTHETIC_SPEC_VERSION,
|
|
name=name,
|
|
llm=llm_config,
|
|
instructions=instructions,
|
|
local_tools=local_tools,
|
|
sub_agents=sub_agents,
|
|
tools=tools_config,
|
|
executor=executor_spec,
|
|
guardrails=guardrails,
|
|
mcp_servers=mcp_servers,
|
|
os_env=agent_def.os_env,
|
|
terminals=terminals,
|
|
timers=agent_def.timers,
|
|
spawn=agent_def.spawn,
|
|
# AgentDef.agent_session_sharing is the raw YAML string
|
|
# ("none"/"non-public"/"public"); map it to the SharePolicy enum
|
|
# AgentSpec expects.
|
|
agent_session_sharing=SharePolicy(agent_def.agent_session_sharing),
|
|
skills_filter=skills_filter,
|
|
)
|
|
|
|
|
|
def _translate_skills_filter_from_yaml(
|
|
raw_yaml: dict[str, Any] | None,
|
|
) -> str | list[str]:
|
|
"""
|
|
Pull the top-level YAML ``skills:`` field out of a raw
|
|
omnigent-format YAML mapping and validate the same shapes
|
|
the omnigent spec parser accepts (``"all"`` / ``"none"`` /
|
|
list of names).
|
|
|
|
The omnigent loader (``inner.loader.load_agent_def``) does
|
|
NOT read this field — it's omnigent spec config that
|
|
controls the Claude SDK harness's host-skill loading, not an
|
|
omnigent runtime concept. So when the YAML is consumed via
|
|
the legacy compat path, we re-read the raw YAML here to
|
|
recover it.
|
|
|
|
:param raw_yaml: The raw parsed YAML mapping the ``AgentDef``
|
|
was synthesized from. ``None`` (no raw YAML available)
|
|
falls back to ``"all"``.
|
|
:returns: ``"all"``, ``"none"``, or a non-empty
|
|
``list[str]``. Normalizes ``[]`` → ``"none"`` to match
|
|
the spec-side parser.
|
|
:raises OmnigentError: When the value isn't one of the
|
|
supported shapes (boolean, dict, integer), or list items
|
|
are non-strings, or a string isn't ``"all"`` or
|
|
``"none"``.
|
|
"""
|
|
if raw_yaml is None:
|
|
return "all"
|
|
raw = raw_yaml.get("skills")
|
|
if raw is None:
|
|
return "all"
|
|
if isinstance(raw, str):
|
|
if raw not in ("all", "none"):
|
|
raise OmnigentError(
|
|
f'top-level skills: must be "all", "none", or a list of '
|
|
f"skill names; got string {raw!r}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return raw
|
|
if isinstance(raw, list):
|
|
if len(raw) == 0:
|
|
return "none"
|
|
names: list[str] = []
|
|
for item in raw:
|
|
if not isinstance(item, str):
|
|
raise OmnigentError(
|
|
f"top-level skills: list items must be strings; "
|
|
f"got {type(item).__name__} {item!r}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
names.append(item)
|
|
return names
|
|
raise OmnigentError(
|
|
f'top-level skills: must be "all", "none", or a list of skill '
|
|
f"names; got {type(raw).__name__}",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
|
|
|
|
def _self_agent_tool_to_sub_spec(
|
|
tool_name: str,
|
|
self_tool: SelfAgentTool,
|
|
parent_agent_def: AgentDef,
|
|
*,
|
|
raw_yaml: dict[str, Any] | None,
|
|
) -> AgentSpec:
|
|
"""
|
|
Materialize a self-clone sub-spec by re-translating the parent.
|
|
|
|
Deep-copies the parent's :class:`AgentDef`, removes any
|
|
:class:`SelfAgentTool` entries from the copy (recursion guard
|
|
— without this, re-translating would re-enter this branch and
|
|
loop forever at parse time), assigns the cloned def the
|
|
sub-agent's tool name, and re-runs
|
|
:func:`agent_def_to_agent_spec` to produce a fully-translated
|
|
:class:`AgentSpec`. The result is a self-contained nested spec
|
|
the Omnigent runtime spawns when the parent's LLM dispatches
|
|
``sys_session_send(tool=tool_name, ...)``.
|
|
|
|
The clone inherits everything: model, system prompt /
|
|
instructions, tools (minus the self-clone tools), executor,
|
|
os_env, terminals, guardrails, timers, async_enabled, etc.
|
|
Other AP-side concepts that the parent has (e.g.,
|
|
``previous_response_id`` chaining, conversation linkage) apply
|
|
at runtime through the standard sub-agent dispatch path —
|
|
nothing extra needed here.
|
|
|
|
The ``raw_yaml`` is forwarded to the recursive call so the
|
|
parent's ``policies`` / ``labels`` / ``label_schema`` /
|
|
``ask_timeout`` translate into the clone's
|
|
:attr:`AgentSpec.guardrails` block too — clones share the
|
|
parent's guardrails by design.
|
|
|
|
Recursion at runtime (a clone spawning another clone) IS
|
|
supported by AP's standard sub-agent dispatch path, but the
|
|
parse-time tree is bounded to one level of cloning here. If a
|
|
deeper parse-time clone tree is ever needed, lift the
|
|
recursion guard and add a depth cap; not needed for v1.
|
|
|
|
:param tool_name: The LLM-facing sub-agent name (e.g.,
|
|
``"subtask"``). Becomes the cloned spec's ``name`` and the
|
|
value the parent's LLM passes as
|
|
``sys_session_send(tool=tool_name, ...)``.
|
|
:param self_tool: The :class:`SelfAgentTool` instance, used
|
|
only for its optional ``description`` (LLM-facing hint
|
|
about what this sub-agent does, distinct from the
|
|
parent's overall description).
|
|
:param parent_agent_def: The parent's parsed
|
|
:class:`AgentDef`. Deep-copied so the original isn't
|
|
mutated.
|
|
:param raw_yaml: Forwarded to the recursive translation so
|
|
guardrails translate identically for parent + clone.
|
|
:returns: A nested :class:`AgentSpec` representing the cloned
|
|
sub-agent. Drops into the parent spec's
|
|
:attr:`AgentSpec.sub_agents`.
|
|
"""
|
|
cloned = copy.deepcopy(parent_agent_def)
|
|
# Recursion guard: strip self-clone tools from the copy so the
|
|
# recursive translation doesn't re-enter the SelfAgentTool branch.
|
|
cloned.tools = {n: t for n, t in cloned.tools.items() if not isinstance(t, SelfAgentTool)}
|
|
cloned.name = tool_name
|
|
sub_spec = agent_def_to_agent_spec(cloned, raw_yaml=raw_yaml)
|
|
if self_tool.description is not None:
|
|
sub_spec.description = self_tool.description
|
|
return sub_spec
|
|
|
|
|
|
def _agent_tool_to_sub_spec(
|
|
tool_name: str,
|
|
tool: AgentTool,
|
|
*,
|
|
parent_profile: str | None = None,
|
|
parent_harness: str | None = None,
|
|
parent_os_env: OSEnvSpec | None = None,
|
|
parent_terminals: dict[str, TerminalEnvSpec] | None = None,
|
|
) -> AgentSpec:
|
|
"""
|
|
Translate an omnigent inline :class:`AgentTool` (sub-agent
|
|
exposed as a tool) into a nested omnigent :class:`AgentSpec`.
|
|
|
|
The parent's :attr:`ToolsConfig.agents` carries ``tool_name`` so
|
|
the runtime surfaces this sub-agent to the parent LLM as a
|
|
callable tool (matching the legacy ``functions.<tool_name>``
|
|
visibility). The sub-spec itself is a regular
|
|
:class:`AgentSpec` with ``executor.type == "omnigent"`` so the
|
|
:class:`OmnigentExecutor` runs it when spawned.
|
|
|
|
Lossy fields (not modeled on Omnigent' AgentSpec yet):
|
|
``max_sessions``, ``pass_history``, ``pass_histories``.
|
|
omnigent' runtime falls back to its defaults for these on
|
|
the reverse trip.
|
|
|
|
:param tool_name: The YAML key under which this AgentTool is
|
|
declared on the parent, e.g. ``"claude_worker"``.
|
|
:param tool: The parsed omnigent :class:`AgentTool`.
|
|
:param parent_profile: Databricks profile carried on the
|
|
parent agent's executor. Used as the sub-spec's profile
|
|
when the inline AgentTool omits one — inline agents in
|
|
YAMLs like ``coding_supervisor.yaml`` typically declare
|
|
``harness: claude-sdk`` + ``model: ...`` but leave
|
|
``profile:`` out, relying on the CLI's ``--profile`` flag
|
|
to flow down. Agent-plane spawns each sub-agent as an
|
|
independent task, so we bake the inheritance into the
|
|
spec here. Empty string means "no parent profile known";
|
|
the sub-spec then falls through the same way its own
|
|
empty field does.
|
|
:param parent_harness: Omnigent harness carried on the
|
|
parent agent's executor. Used as the sub-spec's harness
|
|
when the inline AgentTool's ``executor:`` block is
|
|
absent. Same pattern as *parent_profile* — YAMLs like
|
|
``coding_supervisor_with_forks.yaml`` declare just
|
|
``prompt:`` + ``os_env:`` + ``tools:`` on their workers,
|
|
expecting the parent's harness to flow down. Pure
|
|
omnigent resolves this at ``create_executor`` time;
|
|
omnigent has to do it at spec-translation time because
|
|
each child runs as an independent task.
|
|
:param parent_os_env: OS environment on the parent agent.
|
|
Used to resolve the ``os_env: inherit`` sentinel on the
|
|
inline AgentTool — legacy omnigent resolves it from the
|
|
live Session at runtime, but omnigent runs each child
|
|
as an independent task with no live parent to consult,
|
|
so we resolve at translation time. ``None`` means "no
|
|
parent os_env known": if the tool said ``inherit``, its
|
|
resolved os_env is also ``None`` and the sub-agent boots
|
|
without filesystem access (matching legacy behavior when
|
|
the parent itself has no os_env).
|
|
:param raw_executor: The raw ``executor:`` dict for this inline
|
|
AgentTool taken directly from the parent's YAML (before
|
|
omnigent' dataclass parsing dropped unknown keys). When set,
|
|
fields the omnigent :class:`~omnigent.inner.datamodel.ExecutorSpec`
|
|
datamodel does not expose — ``auth`` and ``use_responses`` —
|
|
are read from this dict and forwarded into the child's
|
|
:class:`ExecutorSpec` via :func:`_translate_executor_from_def`.
|
|
Without this, an ``executor.auth`` block declared on an inline
|
|
AgentTool is silently ignored, causing child sub-agents to fall
|
|
back to the ambient ``OPENAI_BASE_URL`` rather than the explicitly
|
|
declared mock/gateway URL.
|
|
:returns: A nested :class:`AgentSpec` representing the
|
|
sub-agent.
|
|
"""
|
|
oa_executor = tool.executor if isinstance(tool.executor, OmniExecutorSpec) else None
|
|
sub_os_env = _resolve_inline_agent_tool_os_env(tool, parent_os_env)
|
|
# Inherit the parent's ``terminals:`` declaration into the
|
|
# sub-spec so inline sub-agents can launch ``sys_terminal_*``
|
|
# in their own conversation. Without this, the sub-agent's
|
|
# ``ToolManager`` short-circuits the sys_terminal_* registration
|
|
# (see ``omnigent/tools/manager.py:426`` — "if not
|
|
# self._spec.terminals: return") and the sub-agent has no way
|
|
# to spawn a terminal even though the parent has one configured.
|
|
# Each sub-agent's launches land in its OWN conversation registry
|
|
# (registries are keyed by ``conversation_id``), so the REPL's
|
|
# cross-conversation walker (``_collect_terminals_for_conversations``)
|
|
# surfaces them under the sub-agent target in the sidebar.
|
|
# ``dict()`` clones so mutations to the sub-spec's terminals
|
|
# don't ripple back to the parent's declaration.
|
|
sub_terminals = dict(parent_terminals) if parent_terminals else None
|
|
# Recurse into the tool's own nested tools to translate
|
|
# sub-sub-agents and local callables. Without this, inline
|
|
# sub-agents more than one level deep (e.g. researcher →
|
|
# fact_checker) are silently dropped from the spec tree.
|
|
child_sub_agents: list[AgentSpec] = []
|
|
child_local_tools: list[LocalToolInfo] = []
|
|
child_agent_names: list[str] = []
|
|
effective_profile = (oa_executor.profile if oa_executor else None) or parent_profile
|
|
effective_harness = (oa_executor.harness if oa_executor else None) or parent_harness
|
|
for nested_name, nested_tool in (tool.tools or {}).items():
|
|
if isinstance(nested_tool, AgentTool):
|
|
child_sub_agents.append(
|
|
_agent_tool_to_sub_spec(
|
|
nested_name,
|
|
nested_tool,
|
|
parent_profile=effective_profile,
|
|
parent_harness=effective_harness,
|
|
parent_os_env=sub_os_env,
|
|
parent_terminals=sub_terminals,
|
|
),
|
|
)
|
|
child_agent_names.append(nested_name)
|
|
elif isinstance(nested_tool, FunctionTool | CancellableFunctionTool):
|
|
child_local_tools.append(
|
|
_translate_function_tool_from_def(nested_name, nested_tool),
|
|
)
|
|
child_tools_config = (
|
|
ToolsConfig(agents=child_agent_names) if child_agent_names else ToolsConfig()
|
|
)
|
|
return AgentSpec(
|
|
spec_version=_SYNTHETIC_SPEC_VERSION,
|
|
name=tool_name,
|
|
description=tool.description if tool.description else None,
|
|
instructions=tool.prompt if tool.prompt else None,
|
|
llm=_translate_llm_from_def(oa_executor),
|
|
executor=_translate_executor_from_def(
|
|
oa_executor,
|
|
parent_profile=parent_profile,
|
|
parent_harness=parent_harness,
|
|
),
|
|
os_env=sub_os_env,
|
|
terminals=sub_terminals,
|
|
sub_agents=child_sub_agents,
|
|
local_tools=child_local_tools,
|
|
tools=child_tools_config,
|
|
)
|
|
|
|
|
|
def _resolve_inline_agent_tool_os_env(
|
|
tool: AgentTool,
|
|
parent_os_env: OSEnvSpec | None,
|
|
) -> OSEnvSpec | None:
|
|
"""
|
|
Resolve the ``os_env`` field on an inline :class:`AgentTool`
|
|
declaration against the parent's concrete os_env.
|
|
|
|
Omnigent' :class:`AgentTool.os_env` is
|
|
``OSEnvSpec | str | None`` with ``"inherit"`` as the sentinel
|
|
string. Agent-plane spawns each inline child as an
|
|
independent task at runtime, so we can't defer the
|
|
resolution — we bake in the parent's os_env here so the
|
|
sub-spec is self-contained.
|
|
|
|
:param tool: The parsed :class:`AgentTool`.
|
|
:param parent_os_env: The parent agent's own
|
|
:class:`OSEnvSpec`, or ``None`` if the parent also has
|
|
none.
|
|
:returns: The :class:`OSEnvSpec` the sub-agent should use
|
|
at runtime, or ``None`` when no os_env is declared at
|
|
either level.
|
|
"""
|
|
tool_os_env = tool.os_env
|
|
if tool_os_env is None:
|
|
return None
|
|
if isinstance(tool_os_env, str):
|
|
# Only the ``"inherit"`` sentinel is meaningful; other
|
|
# strings would be an omnigent YAML that the loader
|
|
# already rejects, so we don't invent a behavior for
|
|
# them and return None.
|
|
if tool_os_env == _OS_ENV_INHERIT_SENTINEL:
|
|
return parent_os_env
|
|
return None
|
|
return tool_os_env
|
|
|
|
|
|
def _fail_on_unsupported_concepts_def(agent_def: AgentDef) -> None:
|
|
"""
|
|
Raise :class:`OmnigentError` for every omnigent concept
|
|
Omnigent' :class:`AgentSpec` cannot currently represent.
|
|
|
|
Each unsupported concept gets its own clear error message
|
|
naming the specific field.
|
|
|
|
:param agent_def: Parsed omnigent agent definition.
|
|
:raises OmnigentError: On the first unsupported concept
|
|
encountered. Currently the only rejection is MCP-type
|
|
tools; ``policies`` are lifted into
|
|
``AgentSpec.guardrails.policies`` and enforced by the
|
|
omnigent workflow (see
|
|
:func:`_translate_guardrails_yaml`), and ``os_env`` is
|
|
translated and placed on ``AgentSpec.os_env``.
|
|
"""
|
|
for tool_name, tool in agent_def.tools.items():
|
|
_fail_on_unsupported_tool(agent_def.name, tool_name, tool)
|
|
|
|
|
|
def _fail_on_unsupported_tool(
|
|
agent_name: str | None,
|
|
tool_name: str,
|
|
tool: Tool,
|
|
) -> None:
|
|
"""
|
|
Raise :class:`OmnigentError` when *tool* uses an omnigent
|
|
tool concept omnigent cannot currently represent.
|
|
|
|
:param agent_name: Enclosing agent name, for error messages,
|
|
e.g. ``"coder"``. ``None`` when the agent was not given a
|
|
name — the error message then renders ``None`` verbatim.
|
|
:param tool_name: The YAML key for this tool, e.g.
|
|
``"glean_search"``.
|
|
:param tool: The parsed omnigent tool instance.
|
|
:raises OmnigentError: On MCP tools or cancellable_function
|
|
tools.
|
|
"""
|
|
# FunctionTool, CancellableFunctionTool, and AgentTool are all
|
|
# supported — the caller dispatches them appropriately.
|
|
# Agent-plane treats every tool as cancellable at the runtime
|
|
# layer so FunctionTool vs CancellableFunctionTool isn't a
|
|
# meaningful distinction at the spec level.
|
|
if isinstance(tool, FunctionTool | CancellableFunctionTool | AgentTool):
|
|
return
|
|
if isinstance(tool, MCPTool):
|
|
# MCPTool is translated into ``AgentSpec.mcp_servers`` by
|
|
# :func:`_translate_mcp_tool_from_def` — except for the
|
|
# ``databricks_server=<name>`` shape, which references a
|
|
# Databricks-managed MCP endpoint that Omnigent'
|
|
# runtime has no resolver for yet. Reject that shape loud;
|
|
# HTTP + stdio MCPTools fall through and translate below.
|
|
if tool.databricks_server is not None:
|
|
raise OmnigentError(
|
|
f"omnigent agent {agent_name!r} declares tool "
|
|
f"{tool_name!r} of type `mcp` with "
|
|
f"``databricks_server={tool.databricks_server!r}`` — "
|
|
f"Omnigent' MCPServerConfig doesn't understand the "
|
|
f"named-Databricks-server shape. Translate to an "
|
|
f"explicit HTTP ``url`` or stdio ``command``+``args`` "
|
|
f"MCP first.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return
|
|
|
|
|
|
def _translate_name_from_def(raw_name: str | None) -> str | None:
|
|
"""
|
|
Translate omnigent ``AgentDef.name`` to :attr:`AgentSpec.name`.
|
|
|
|
``AgentDef.name`` is ``str | None`` on the omnigent side;
|
|
omnigent also uses ``None`` for absence. An empty string on
|
|
either side collapses to ``None`` so the round-trip invariant
|
|
holds.
|
|
|
|
:param raw_name: The raw ``name`` field from
|
|
:class:`AgentDef`, e.g. ``"hello_world"``, ``""``, or
|
|
``None``.
|
|
:returns: The omnigent name, or ``None`` when *raw_name*
|
|
is absent or empty.
|
|
"""
|
|
return raw_name if raw_name else None
|
|
|
|
|
|
def _translate_prompt_from_def(raw_prompt: str | None) -> str | None:
|
|
"""
|
|
Translate omnigent ``AgentDef.prompt`` to
|
|
:attr:`AgentSpec.instructions`.
|
|
|
|
``AgentDef.prompt`` is ``str | None`` on the omnigent side;
|
|
omnigent also uses ``None`` for absence.
|
|
|
|
:param raw_prompt: The raw ``prompt`` field from
|
|
:class:`AgentDef`, e.g. ``"You are a friendly
|
|
assistant."``, ``""``, or ``None``.
|
|
:returns: The omnigent instructions text, or ``None``
|
|
when *raw_prompt* is absent or empty.
|
|
"""
|
|
return raw_prompt if raw_prompt else None
|
|
|
|
|
|
def _translate_llm_from_def(
|
|
oa_executor: OmniExecutorSpec | None,
|
|
*,
|
|
raw_executor: dict[str, Any] | None = None,
|
|
) -> LLMConfig | None:
|
|
"""
|
|
Translate omnigent ``executor.model`` into an omnigent
|
|
:class:`LLMConfig`.
|
|
|
|
omnigent stores the model string on its own
|
|
:class:`omnigent.datamodel.ExecutorSpec`. omnigent splits
|
|
model selection (``llm.model``) from executor selection
|
|
(``executor.type``). We surface the model in
|
|
:attr:`AgentSpec.llm.model`; the executor block keeps
|
|
``type = "omnigent"`` plus the ``harness`` / ``profile``
|
|
config.
|
|
|
|
:param oa_executor: The omnigent
|
|
:class:`~omnigent.datamodel.ExecutorSpec`, or ``None``
|
|
when the YAML omitted the ``executor:`` block. Example:
|
|
``OmniExecutorSpec(model="databricks-claude-sonnet-4")``.
|
|
:param raw_executor: Optional raw omnigent YAML ``executor:``
|
|
mapping. When present and it carries an ``extra:`` dict,
|
|
those kwargs flow into :attr:`LLMConfig.extra` so harness-
|
|
specific overrides (``max_turns`` for the openai-agents
|
|
harness, ``temperature`` for any harness, etc.) propagate
|
|
through to :class:`omnigent.executor.ExecutorConfig.extra`.
|
|
The omnigent loader drops unknown fields on
|
|
``ExecutorSpec`` at parse time, so we read them back from
|
|
the raw dict here.
|
|
:returns: An :class:`LLMConfig` with ``model`` populated, or
|
|
``None`` when omnigent declared no model.
|
|
"""
|
|
if oa_executor is None or not oa_executor.model:
|
|
return None
|
|
extra: dict[str, Any] = {}
|
|
if raw_executor is not None:
|
|
raw_extra = raw_executor.get("extra")
|
|
if isinstance(raw_extra, dict):
|
|
extra = dict(raw_extra)
|
|
return LLMConfig(model=oa_executor.model, extra=extra)
|
|
|
|
|
|
def _translate_executor_from_def(
|
|
oa_executor: OmniExecutorSpec | None,
|
|
*,
|
|
parent_profile: str | None = None,
|
|
parent_harness: str | None = None,
|
|
raw_executor: dict[str, Any] | None = None,
|
|
) -> ExecutorSpec:
|
|
"""
|
|
Build the omnigent :class:`ExecutorSpec` for an omnigent
|
|
agent.
|
|
|
|
Returns ``type="omnigent"`` when a harness can be resolved,
|
|
routing the agent through ``OmnigentExecutor``. Raises
|
|
:class:`OmnigentError` when a model is declared but no
|
|
harness can be inferred — the spec must explicitly declare a
|
|
harness or use a model whose prefix maps to a known harness
|
|
(e.g. ``databricks-claude-*`` maps to ``claude-sdk``).
|
|
|
|
The ``harness`` and ``profile`` fields from omnigent land in
|
|
``executor.config`` so :meth:`OmnigentExecutor.from_spec` can
|
|
read them back via the typed dict (no setattr-on-dataclass
|
|
trickery). ``os_env`` is NOT carried here — callers populate
|
|
:attr:`AgentSpec.os_env` directly.
|
|
|
|
Harness-resolution precedence (first non-empty wins):
|
|
|
|
1. ``oa_executor.harness`` — explicit declaration in the YAML's
|
|
``executor:`` block.
|
|
2. *parent_harness* — the parent agent's harness, threaded
|
|
through by :func:`_agent_tool_to_sub_spec` for inline
|
|
AgentTools that omit their own ``executor:`` block.
|
|
3. :func:`_infer_harness_from_model` on the resolved model
|
|
string — mirrors pure omnigent' CLI auto-pick
|
|
(``databricks-claude-*`` → ``claude-sdk``, etc.) so YAMLs
|
|
that declare only a model continue to work under Omnigent mode.
|
|
4. Error — when all of the above yield an empty string and a
|
|
model is declared, the spec is invalid (no harness can be
|
|
inferred for the model).
|
|
|
|
Empty-string coercion mirrors omnigent' own dataclass
|
|
defaults (``ExecutorSpec.harness: str = ""``,
|
|
``ExecutorSpec.profile: str = ""``) so the round-trip is
|
|
bit-exact against the omnigent side.
|
|
|
|
:param oa_executor: The omnigent ``ExecutorSpec`` or
|
|
``None``.
|
|
:param parent_profile: Profile to fall back to when
|
|
*oa_executor* has no profile of its own. Used by
|
|
inline-AgentTool sub-specs so the parent's CLI
|
|
``--profile`` flows into the child task's executor —
|
|
otherwise Databricks-backed harnesses 403 on the empty
|
|
profile. Ignored when the inline spec already carries a
|
|
profile (explicit always wins).
|
|
:param parent_harness: Harness to fall back to when
|
|
*oa_executor* has no harness of its own. Matches the
|
|
profile fallback's semantics — inline-AgentTool sub-
|
|
specs that omit ``executor:`` rely on the parent's
|
|
harness, and the validator requires harness to be one
|
|
of the supported set so an empty string fails
|
|
hard there.
|
|
:param raw_executor: Optional raw YAML ``executor:`` mapping.
|
|
When present, ``use_responses`` (``bool | None``) is read
|
|
from it and forwarded into ``executor.config["use_responses"]``
|
|
so the openai-agents harness subprocess reads the correct
|
|
API surface (chat/completions vs. responses). The omnigent
|
|
loader silently drops unknown fields on its own
|
|
:class:`~omnigent.inner.datamodel.ExecutorSpec`, so we
|
|
have to recover this field from the raw dict here.
|
|
:returns: An :class:`ExecutorSpec` with ``type="omnigent"``
|
|
when a harness is known.
|
|
:raises OmnigentError: When a model is declared but no
|
|
harness can be inferred from it.
|
|
"""
|
|
# Omnigent' :class:`ExecutorSpec.{harness,profile,model}`
|
|
# fields are ``str | None`` on the dataclass (post-graduation
|
|
# from the old empty-string-as-absent convention). The rest of
|
|
# this function and its downstream callers treat ``""`` as
|
|
# "field absent" (``if not harness:`` etc.), so coerce ``None``
|
|
# to ``""`` at the top of the function — one normalization
|
|
# point instead of sprinkling ``is None`` checks through every
|
|
# branch below. The config dict that ends up in
|
|
# :attr:`ExecutorSpec.config` uses ``""`` as its sentinel too;
|
|
# the forward-direction reader at line ~1120
|
|
# (``assert isinstance(resolved, str)``) fails loud if a
|
|
# ``None`` ever leaks through.
|
|
harness = oa_executor.harness if oa_executor is not None else None
|
|
if harness is None:
|
|
harness = ""
|
|
harness = canonicalize_harness(harness) or ""
|
|
profile = oa_executor.profile if oa_executor is not None else None
|
|
if profile is None:
|
|
profile = ""
|
|
# Harness resolution precedence:
|
|
# (1) explicit child ``executor.harness`` — already in
|
|
# ``harness`` above.
|
|
# (2) child's own model auto-picks — if the child declared
|
|
# its own model, that model's prefix is a strong intent
|
|
# signal and dominates parent inheritance (a Claude-model
|
|
# child under a GPT-model parent should run on
|
|
# claude-sdk, not inherit the parent's openai-agents).
|
|
# (3) parent harness — inline AgentTools that omit their
|
|
# own ``executor:`` block entirely inherit from parent.
|
|
# (4) auto-pick on whatever model happens to be present as
|
|
# a last-ditch effort (empty model → empty harness,
|
|
# validator rejects with a clear error).
|
|
model = oa_executor.model if oa_executor is not None else None
|
|
if model is None:
|
|
model = ""
|
|
if not harness and model:
|
|
harness = _infer_harness_from_model(model)
|
|
if not harness and parent_harness:
|
|
harness = parent_harness
|
|
if not harness and model:
|
|
# Belt-and-suspenders fallback after the parent_harness
|
|
# branch. ``and model`` matches the first branch's guard
|
|
# so mypy narrows ``model`` to ``str`` for the call.
|
|
harness = _infer_harness_from_model(model)
|
|
# Inherit the parent's Databricks profile when the child doesn't
|
|
# specify its own, EXCEPT for subscription-auth harnesses
|
|
# (claude-sdk, codex). Those route through the locally installed
|
|
# CLI's subscription auth; inheriting a parent profile would
|
|
# trigger Databricks routing and bypass subscription auth.
|
|
# This check runs AFTER harness resolution so that children that
|
|
# omit ``executor.harness`` but resolve to codex/claude-sdk via
|
|
# model inference or parent inheritance are still excluded.
|
|
if not profile and parent_profile and harness not in _SUBSCRIPTION_AUTH_HARNESSES:
|
|
profile = parent_profile
|
|
config: dict[str, Any] = {
|
|
"harness": harness,
|
|
"profile": profile,
|
|
}
|
|
# ``use_responses`` is not a field on the omnigent inner
|
|
# ExecutorSpec (the loader drops unknown keys), so we read it
|
|
# from the raw YAML dict and carry it forward explicitly.
|
|
# The openai-agents harness spawn-env builder reads
|
|
# ``spec.executor.config["use_responses"]`` to set
|
|
# ``HARNESS_OPENAI_AGENTS_USE_RESPONSES``, which controls
|
|
# whether the inner executor uses /responses or /chat/completions.
|
|
# ``use_responses`` is carried via raw_executor when present
|
|
if raw_executor is not None:
|
|
use_responses_raw = raw_executor.get("use_responses")
|
|
if use_responses_raw is not None:
|
|
config["use_responses"] = bool(use_responses_raw)
|
|
# ``auth`` is now parsed by the loader into OmniExecutorSpec.auth;
|
|
# fall back to raw_executor for the top-level agent path that still
|
|
# goes through _translate_executor_from_def(raw_executor=...).
|
|
auth: ApiKeyAuth | DatabricksAuth | None = None
|
|
if oa_executor is not None and oa_executor.auth is not None:
|
|
auth = oa_executor.auth # type: ignore[assignment]
|
|
elif raw_executor is not None:
|
|
from omnigent.spec.parser import _parse_executor_auth
|
|
|
|
auth = _parse_executor_auth(raw_executor)
|
|
if not harness and model:
|
|
raise OmnigentError(
|
|
f"no harness can be inferred for model {model!r}. "
|
|
f"Declare an explicit 'harness:' in the executor block "
|
|
f"(e.g. 'openai-agents', 'claude-sdk') or use a model "
|
|
f"whose prefix maps to a known harness.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
return ExecutorSpec(
|
|
type=OMNIGENT_EXECUTOR_TYPE,
|
|
config=config,
|
|
model=model or None,
|
|
profile=profile or None,
|
|
auth=auth,
|
|
)
|
|
|
|
|
|
def _translate_mcp_tool_from_def(
|
|
tool_name: str,
|
|
tool: MCPTool,
|
|
) -> MCPServerConfig:
|
|
"""
|
|
Translate one omnigent :class:`MCPTool` into a native
|
|
:class:`MCPServerConfig`.
|
|
|
|
Transport dispatch:
|
|
|
|
- ``tool.url`` populated → ``transport="http"`` with optional
|
|
``headers`` (used by Databricks-profile MCPs that hit an
|
|
SSE endpoint with a Bearer token).
|
|
- ``tool.command`` populated → ``transport="stdio"`` with
|
|
``args`` + ``env`` carried through verbatim. The
|
|
subprocess spawns unsandboxed (matching legacy omnigent,
|
|
which never sandboxed MCPs); the AP-side ``sandbox: bool``
|
|
field that wrapped the spawn with srt was removed in
|
|
step 7 of the harness contract migration.
|
|
|
|
:param tool_name: YAML key the tool was registered under,
|
|
e.g. ``"glean"``. Used as :attr:`MCPServerConfig.name`
|
|
when the tool doesn't override it.
|
|
:param tool: The parsed omnigent :class:`MCPTool` instance.
|
|
:returns: A fully populated :class:`MCPServerConfig`.
|
|
:raises OmnigentError: If *tool* has neither ``url`` nor
|
|
``command`` (shouldn't happen for normal MCPTools, but the
|
|
``databricks_server`` shape is caught upstream by
|
|
:func:`_fail_on_unsupported_tool`).
|
|
"""
|
|
if tool.url is not None:
|
|
return MCPServerConfig(
|
|
name=tool_name,
|
|
transport="http",
|
|
url=tool.url,
|
|
headers=dict(tool.headers) if tool.headers else {},
|
|
databricks_profile=tool.profile,
|
|
tools=list(tool.tools) if tool.tools else None,
|
|
)
|
|
if tool.command is not None:
|
|
return MCPServerConfig(
|
|
name=tool_name,
|
|
transport="stdio",
|
|
command=tool.command,
|
|
args=list(tool.args) if tool.args else [],
|
|
env=dict(tool.env) if tool.env else {},
|
|
tools=list(tool.tools) if tool.tools else None,
|
|
)
|
|
raise OmnigentError(
|
|
f"omnigent MCP tool {tool_name!r} has neither 'url' nor "
|
|
f"'command' — cannot translate to an MCPServerConfig",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
|
|
|
|
def _translate_function_tool_from_def(
|
|
tool_name: str,
|
|
tool: Tool,
|
|
) -> LocalToolInfo:
|
|
"""
|
|
Translate one omnigent function tool into a
|
|
:class:`LocalToolInfo`.
|
|
|
|
The dotted callable path is stored in
|
|
:attr:`LocalToolInfo.path` (e.g.
|
|
``"examples._shared.tool_functions.get_current_time"``). The forward
|
|
translator (``agent_spec_to_agent_def``) resolves this via
|
|
:func:`importlib.import_module` at executor-construction time.
|
|
|
|
:param tool_name: The YAML key for this tool, e.g.
|
|
``"get_current_time"``.
|
|
:param tool: The parsed omnigent tool. Must be a
|
|
:class:`FunctionTool` at this point (fail-loud in
|
|
:func:`_fail_on_unsupported_tool` rejects the other
|
|
concrete subtypes).
|
|
:returns: A :class:`LocalToolInfo` with ``name``, ``path``
|
|
(dotted callable), and ``language`` =
|
|
:data:`OMNIGENT_TOOL_LANGUAGE`.
|
|
:raises OmnigentError: When *tool* is not a
|
|
:class:`FunctionTool` (defensive guard).
|
|
"""
|
|
if isinstance(tool, CancellableFunctionTool):
|
|
raise OmnigentError(
|
|
f"omnigent tool {tool_name!r}: "
|
|
f"``CancellableFunctionTool`` (YAML ``type: "
|
|
f"cancellable_function`` + ``runner:``) was retired in "
|
|
f"step (c) of the harness contract migration. Replace "
|
|
f"with ``type: function`` + ``callable:`` pointing at "
|
|
f"a plain Python function; the LLM dispatches it "
|
|
f"asynchronously via ``sys_call_async`` when "
|
|
f"cancellation is needed. See "
|
|
f'``designs/SERVER_HARNESS_CONTRACT.md`` §"Async work '
|
|
f'+ inbox" for the rationale.',
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
if not isinstance(tool, FunctionTool):
|
|
raise OmnigentError(
|
|
f"omnigent tool {tool_name!r}: expected FunctionTool, got {type(tool).__name__}.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
# Client-runtime tools have no server-side callable — the SDK
|
|
# consumer implements them at stream-start time. Skip path
|
|
# recovery (would fail: there's nothing to import) and
|
|
# introspection (no callable to inspect — the YAML
|
|
# ``parameters:`` block is the only schema source for these,
|
|
# which is why the validator requires it on client tools).
|
|
if tool.runtime == "client":
|
|
return LocalToolInfo(
|
|
name=tool_name,
|
|
path=None,
|
|
language=OMNIGENT_TOOL_LANGUAGE,
|
|
parameters=dict(tool.input_schema) if tool.input_schema else None,
|
|
runtime=ToolRuntime.CLIENT,
|
|
)
|
|
# Unity Catalog tools declare ``catalog_path:`` instead of
|
|
# ``callable:``. They are executed at tool-call time via
|
|
# ``WorkspaceClient.statement_execution.execute_statement()``.
|
|
# No server-side callable to resolve or introspect — the
|
|
# parameter schema must come from the YAML ``parameters:``
|
|
# block or be fetched from UC metadata at registration time
|
|
# (future enhancement).
|
|
catalog_path = getattr(tool, "catalog_path", None)
|
|
if catalog_path:
|
|
return LocalToolInfo(
|
|
name=tool_name,
|
|
path=None,
|
|
language=OMNIGENT_TOOL_LANGUAGE,
|
|
parameters=dict(tool.input_schema) if tool.input_schema else None,
|
|
runtime=ToolRuntime.UC_FUNCTION,
|
|
catalog_path=catalog_path,
|
|
warehouse_id=getattr(tool, "warehouse_id", None),
|
|
description=tool.description,
|
|
)
|
|
callable_path = _recover_callable_path(tool_name, tool)
|
|
# Mirror the legacy ``Tool.tool_schema()`` precedence: an
|
|
# explicit ``input_schema:`` declared in YAML wins; otherwise
|
|
# introspect the resolved Python callable's signature so the
|
|
# LLM gets ``{"properties": {<arg>: ...}, "required": [...]}``
|
|
# for typed-parameter tools. Without this fallback, omnigent
|
|
# YAMLs that declare a function tool with no ``input_schema:``
|
|
# block (the common case for plain Python callables — e.g.
|
|
# ``examples/_shared/tool_functions.web_search``) ship to the
|
|
# LLM with empty parameters and the model invokes the tool
|
|
# zero-arg, producing
|
|
# ``TypeError: web_search() missing 1 required positional
|
|
# argument`` at call time.
|
|
parameters = (
|
|
dict(tool.input_schema)
|
|
if tool.input_schema
|
|
else _schema_from_callable(tool.callable)
|
|
if tool.callable is not None
|
|
else None
|
|
)
|
|
return LocalToolInfo(
|
|
name=tool_name,
|
|
path=callable_path,
|
|
language=OMNIGENT_TOOL_LANGUAGE,
|
|
parameters=parameters,
|
|
runtime=ToolRuntime.SERVER,
|
|
)
|
|
|
|
|
|
def _recover_callable_path(
|
|
tool_name: str,
|
|
tool: FunctionTool,
|
|
) -> str:
|
|
"""
|
|
Recover the dotted import path for a function-type tool's
|
|
callable.
|
|
|
|
omnigent' loader resolves the YAML's ``callable:`` field
|
|
into a real Python object and drops the original string. We
|
|
rehydrate the dotted path from the resolved object's
|
|
``__module__`` + ``__qualname__``. The legacy fallback that
|
|
scanned a module for an attribute bound to a runner-instance
|
|
was removed in step (c) — only plain callables are supported
|
|
now, and ``__module__`` / ``__qualname__`` always recover the
|
|
path correctly for them.
|
|
|
|
:param tool_name: The YAML key for this tool (used in error
|
|
messages).
|
|
:param tool: A :class:`FunctionTool` whose ``callable``
|
|
attribute is a resolved Python object.
|
|
:returns: The dotted module path, e.g.
|
|
``"examples._shared.tool_functions.get_current_time"``.
|
|
:raises OmnigentError: When the callable is missing
|
|
(unresolved ``callable:``) or has no
|
|
``__module__``/``__qualname__`` to recover from.
|
|
"""
|
|
callable_obj = tool.callable
|
|
if callable_obj is None:
|
|
raise OmnigentError(
|
|
f"omnigent tool {tool_name!r}: function-type tool has "
|
|
f"no resolved callable (the YAML's `callable:` field was "
|
|
f"missing or could not be imported at load time). The "
|
|
f"omnigent adapter needs a resolved callable to "
|
|
f"recover its dotted path.",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|
|
module = getattr(callable_obj, "__module__", None)
|
|
qualname = getattr(callable_obj, "__qualname__", None)
|
|
if module and qualname:
|
|
return f"{module}.{qualname}"
|
|
raise OmnigentError(
|
|
f"omnigent tool {tool_name!r}: callable {callable_obj!r} "
|
|
f"has no recoverable dotted path (no __module__/__qualname__ "
|
|
f"on a {type(callable_obj).__name__}).",
|
|
code=ErrorCode.INVALID_INPUT,
|
|
)
|