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

2738 lines
121 KiB
Python

"""Agent execution workflow — the core agent loop.
Load agent → build prompt → call LLM → execute tools → repeat.
All durably checkpointed for crash recovery.
"""
from __future__ import annotations
import json
import logging
import os
import shlex
from dataclasses import dataclass, replace
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
import yaml
if TYPE_CHECKING:
# Lazy: PolicyEngine + PolicyResult are pulled in at runtime via
# the function-local imports inside ``_handle_policy_ask`` and
# ``_enforce_policy``-using helpers (see uses below). The
# TYPE_CHECKING import is only here so static type-checkers + ruff
# can resolve the names in deferred annotations (``from __future__
# import annotations`` is in effect).
from omnigent.inner.datamodel import OSEnvSpec
from omnigent.entities import (
NON_CONTENT_ITEM_TYPES,
CompactionData,
ConversationItem,
NewConversationItem,
)
from omnigent.env_credentials import expand_envvars_with_omnigent_prefix
from omnigent.errors import ErrorCode, OmnigentError
from omnigent.llms import Client as LLMClient
from omnigent.onboarding.databricks_config import (
DATABRICKS_CLAUDE_DEFAULT_MODEL,
get_workspace_url_for_profile,
)
from omnigent.onboarding.detected import (
codex_config_provider_dismissed,
effective_config_with_detected,
)
from omnigent.onboarding.provider_config import (
ANTHROPIC_FAMILY,
BEDROCK_KIND,
CLI_CONFIG_KIND,
DATABRICKS_KIND,
OPENAI_FAMILY,
RESPONSES_WIRE_API,
SUBSCRIPTION_KIND,
FamilyConfig,
ProviderEntry,
default_provider_for_harness,
first_available_provider,
harness_family,
load_config,
load_providers,
)
from omnigent.onboarding.ucode_state import UcodeAgentState, read_ucode_state
from omnigent.runtime import (
get_artifact_store,
get_conversation_store,
get_file_store,
)
from omnigent.runtime.compaction import (
CompactionResult,
SummaryMetadata,
_CompactionState,
compact,
compaction_to_history_items,
count_tokens,
)
from omnigent.runtime.content_resolver import resolve_content_references
from omnigent.runtime.prompt import build_instructions, history_to_input_items
from omnigent.spec import AgentSpec
from omnigent.spec.parser import check_unresolved_env_vars
from omnigent.spec.types import (
ApiKeyAuth,
DatabricksAuth,
LLMConfig,
ProviderAuth,
RetryPolicy,
)
from omnigent.stores import ConversationStore
# ── Module-level constants ────────────────────────────────────
_logger = logging.getLogger(__name__)
# Task kind for background `@tool(synchronous=False)` work items —
# the unit the parent loop separates from the polling-based
# sub-agent path so each kind uses the right collection mechanism.
# Kinds whose completion arrives via the async_work_complete drain
# and that can block the parent turn from finalizing. Tools,
# sub-agents, and client tools belong here: they represent jobs the
# parent expects to finish, and blocking the turn so the LLM sees
# the result in-line is a real UX win.
# Per-payload character cap for sub-agent output piggy-backed on
# the async_work_complete signal (matches the @tool path's
# ``truncate_for_llm`` budget — keeps the LLM-facing system
# message under control regardless of which kind produced it).
# G20: cadence for `response.heartbeat` SSE events emitted while
# the parent loop is blocked on the async-tool drain. 15 s keeps
# proxies that close idle connections at 30 s safely under their
# threshold without flooding the channel with pings.
# How often the blocking drain wakes to poll the conversation
# store for steering messages. Decoupled from
# ``_HEARTBEAT_INTERVAL_S`` because they answer different
# questions: heartbeat is for SSE-proxy keepalive (ceiling on
# how long a connection can stay idle), steering poll is for
# user-perceived latency (floor on how quickly the agent
# reacts to a mid-flight "hello"). 1 s is fast enough that
# steering feels instant while keeping conv_store load to
# 1 QPS per blocked workflow.
# Generic type variable used by ``_to_thread`` (pure helper).
# Hard upper bound on LLM turns per execution. Prevents runaway
# loops. See designs/AGENTLOOP.md "Not Yet" for making this
# configurable.
# SSE event types emitted for reasoning content (set by the
# streaming accumulator and consumed by the terminal frontend).
# Executor storage layout — each (conversation, agent) gets a
# stable subdir under ``_EXECUTOR_STORAGE_BASE`` that persists
# across tasks. The artifact-store key prefix mirrors the disk
# layout so snapshots round-trip cleanly.
# Client-side tool result polling — used by
# ``_build_await_tool_output`` while waiting for a PATCH'd
# function_call_output to arrive. 50 ms keeps interactive REPL
# turns snappy (a 5-tool turn loses at most 250 ms to polling
# instead of 2.5 s) while keeping the DB query cost negligible
# (~20 short SELECTs/sec while waiting). See
# designs/REPL_STREAMING_THROUGHPUT.md bottleneck #2.
# Translates Omnigent spec executor types (underscore) to harness subprocess names
# (hyphen), e.g. ``"claude_sdk"`` → ``"claude-sdk"`` used by ``_HARNESS_MODULES``.
AgentHarnessType = Literal[
"claude-sdk", "codex", "pi", "openai-agents-sdk", "antigravity", "kimi", "qwen", "goose"
]
@dataclass(frozen=True)
class UcodeHarnessConfig:
"""Env-var mapping for one harness's ucode agent state.
:param agent_name: ucode agent key, e.g. ``"claude"`` or ``"codex"``.
:param model_key: Harness model env var.
:param base_url_key: Harness gateway base URL env var.
:param base_url_family: Optional provider key to use when the ucode agent
entry only has provider-specific base URLs, e.g. ``"claude"``.
:param base_urls_key: Optional harness gateway base URLs env var for
agents with multiple provider URLs.
:param host_key: Harness gateway workspace host env var.
:param auth_key: Optional harness gateway auth command env var.
:param refresh_key: Optional harness gateway auth refresh interval env var.
:param databricks_default_model: Fallback model id to use on the
Databricks gateway path when neither the spec nor the ucode
state names a model, e.g. ``"databricks-claude-opus-4-8"``.
``None`` for harnesses with no confirmed Databricks default.
Required because the Databricks AI gateway only routes
``databricks-*`` endpoint names, so the CLI's own host-config
default (an Anthropic-direct id) is not a usable fallback there.
"""
agent_name: str
model_key: str
base_url_key: str
base_url_family: str | None
base_urls_key: str | None
host_key: str
auth_key: str | None
refresh_key: str | None
databricks_default_model: str | None = None
_UCODE_HARNESS_CONFIGS: dict[AgentHarnessType, UcodeHarnessConfig] = {
"claude-sdk": UcodeHarnessConfig(
agent_name="claude",
model_key="HARNESS_CLAUDE_SDK_MODEL",
base_url_key="HARNESS_CLAUDE_SDK_GATEWAY_BASE_URL",
base_url_family="claude",
base_urls_key=None,
host_key="HARNESS_CLAUDE_SDK_GATEWAY_HOST",
auth_key="HARNESS_CLAUDE_SDK_GATEWAY_AUTH_COMMAND",
refresh_key="HARNESS_CLAUDE_SDK_GATEWAY_AUTH_REFRESH_INTERVAL_MS",
# The executor only applies this on the profile-derived gateway path,
# so the producer must supply it on the ucode-cached path.
databricks_default_model=DATABRICKS_CLAUDE_DEFAULT_MODEL,
),
"codex": UcodeHarnessConfig(
agent_name="codex",
model_key="HARNESS_CODEX_MODEL",
base_url_key="HARNESS_CODEX_GATEWAY_BASE_URL",
base_url_family="codex",
base_urls_key=None,
host_key="HARNESS_CODEX_GATEWAY_HOST",
auth_key="HARNESS_CODEX_GATEWAY_AUTH_COMMAND",
refresh_key="HARNESS_CODEX_GATEWAY_AUTH_REFRESH_INTERVAL_MS",
),
"pi": UcodeHarnessConfig(
agent_name="pi",
model_key="HARNESS_PI_MODEL",
base_url_key="HARNESS_PI_GATEWAY_BASE_URL",
base_url_family="claude",
base_urls_key="HARNESS_PI_GATEWAY_BASE_URLS",
host_key="HARNESS_PI_GATEWAY_HOST",
auth_key="HARNESS_PI_GATEWAY_AUTH_COMMAND",
refresh_key="HARNESS_PI_GATEWAY_AUTH_REFRESH_INTERVAL_MS",
# Same parity as claude-sdk: the executor only defaults on the
# profile-derived gateway path, so the producer must supply it here.
databricks_default_model=DATABRICKS_CLAUDE_DEFAULT_MODEL,
),
"openai-agents-sdk": UcodeHarnessConfig(
agent_name="codex",
model_key="HARNESS_OPENAI_AGENTS_MODEL",
base_url_key="HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL",
base_url_family="codex",
base_urls_key=None,
host_key="HARNESS_OPENAI_AGENTS_GATEWAY_HOST",
auth_key="HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND",
refresh_key=None,
),
"qwen": UcodeHarnessConfig(
agent_name="qwen",
model_key="HARNESS_QWEN_MODEL",
base_url_key="HARNESS_QWEN_GATEWAY_BASE_URL",
base_url_family="openai",
base_urls_key=None,
host_key="HARNESS_QWEN_GATEWAY_HOST",
auth_key="HARNESS_QWEN_GATEWAY_AUTH_COMMAND",
refresh_key=None,
),
# NB: ``antigravity`` is intentionally absent. Unlike the gateway
# harnesses above, the Antigravity SDK authenticates Gemini-natively
# (API key or Vertex AI) and has no OpenAI-compatible ``base_url``, so it
# has no ucode gateway entry — ``_build_antigravity_spawn_env`` threads
# ``HARNESS_ANTIGRAVITY_API_KEY`` / ``_VERTEX`` directly.
}
# Lazy singleton — created on first LLM call so import doesn't
# fail when provider API keys are not yet set.
_llm_client: LLMClient | None = None
def _get_llm_client() -> LLMClient:
"""Return the shared LLM client, creating it on first use."""
global _llm_client
if _llm_client is None:
_llm_client = LLMClient()
return _llm_client
def _get_runner_client_for_compaction(
conversation_id: str | None,
) -> Any | None:
"""
Return the httpx client for the runner handling *conversation_id*.
Used by compaction call sites so Layer 2 summarization is routed
through the runner's own credentials (e.g. the user's Databricks
profile) instead of the Omnigent server's.
Returns ``None`` when:
- ``conversation_id`` is ``None`` (in-process / no-server mode).
- No ``RunnerRouter`` is wired up (unit tests, direct execution).
- The conversation is not yet pinned to a runner.
Propagates :class:`OmnigentError` when the pinned runner is
offline so the caller can surface the error cleanly.
:param conversation_id: Conversation id, e.g.
``"conv_0123456789abcdef"``, or ``None``.
:returns: An :class:`httpx.AsyncClient` tunneled to the runner,
or ``None`` when no runner is available.
"""
if conversation_id is None:
return None
from omnigent.runtime import get_runner_router
router = get_runner_router()
if router is None:
return None
routed = router.client_for_existing_conversation(conversation_id)
return routed.client if routed else None
def configure_agent_harness_with_ucode(
env: dict[str, str],
profile: str | None,
*,
harness_type: AgentHarnessType,
) -> None:
"""Inject per-harness model, URL, and auth values from ucode state.
The harness-specific constants live here so callers only declare which
agent harness they are configuring. ucode's per-agent ``agents`` entries
are the source of truth for gateway URLs and auth commands.
:param env: Mutable spawn-env dict, modified in place.
:param profile: The ``executor.profile`` / provider-config value,
e.g. ``"oss"``. ``None`` short-circuits the entire lookup.
:param harness_type: Canonical harness type, e.g. ``"claude-sdk"``.
"""
if not profile:
return
workspace_url = get_workspace_url_for_profile(profile)
if workspace_url is None:
return
state = read_ucode_state(workspace_url)
if state is None:
return
config = _UCODE_HARNESS_CONFIGS[harness_type]
agent_state = state.agent(config.agent_name)
if agent_state is None:
return
_inject_ucode_agent_state(
env,
agent_state,
model_key=config.model_key,
base_url_key=config.base_url_key,
base_url_family=config.base_url_family,
base_urls_key=config.base_urls_key,
host_key=config.host_key,
auth_key=config.auth_key,
refresh_key=config.refresh_key,
workspace_url=state.workspace_host,
)
# When ucode caches no model, default it so the CLI doesn't fall back to
# its host-config model (an Anthropic-direct id the gateway rejects).
if config.model_key not in env and config.databricks_default_model:
env[config.model_key] = config.databricks_default_model
def _inject_ucode_agent_state(
env: dict[str, str],
state: UcodeAgentState,
*,
model_key: str,
base_url_key: str,
base_url_family: str | None,
base_urls_key: str | None,
host_key: str,
auth_key: str | None,
refresh_key: str | None,
workspace_url: str,
) -> None:
"""Copy one ucode agent entry into harness env vars.
:param env: Mutable spawn-env dict, modified in place.
:param state: Parsed ucode per-agent state.
:param model_key: Harness model env var, e.g. ``"HARNESS_CODEX_MODEL"``.
:param base_url_key: Harness gateway base URL env var.
:param base_url_family: Optional provider key to use when ``state`` has
provider-specific base URLs instead of a single base URL.
:param base_urls_key: Optional harness gateway base URLs env var for
agents with multiple provider URLs.
:param host_key: Harness gateway workspace host env var.
:param auth_key: Optional harness gateway auth command env var.
:param refresh_key: Optional harness gateway auth refresh interval env var.
:param workspace_url: Workspace URL for token refresh commands.
"""
if model_key not in env and state.model:
env[model_key] = state.model
base_url = state.base_url
if base_url is None and base_url_family is not None:
base_url = state.base_urls.get(base_url_family)
if base_url:
env[base_url_key] = base_url
if base_urls_key and state.base_urls:
env[base_urls_key] = json.dumps(state.base_urls, sort_keys=True)
env[host_key] = workspace_url
if auth_key and state.auth_command:
env[auth_key] = state.auth_command
if refresh_key and state.auth_refresh_interval_ms is not None:
env[refresh_key] = str(state.auth_refresh_interval_ms)
# Maps single-family harnesses to the generic-provider family they consume.
# (``pi`` is handled separately — it consumes both families.) The keys are
# the canonical harness names used by the Chunk-1a provider-config layer
# (``omnigent/onboarding/provider_config.py`` ``_HARNESS_FAMILY``);
# ``openai-agents`` (no ``-sdk``) is that layer's name for the
# openai-agents-sdk harness, so :func:`_provider_harness_name` translates.
_PROVIDER_HARNESS_FAMILY: dict[AgentHarnessType, str] = {
"claude-sdk": ANTHROPIC_FAMILY,
"codex": OPENAI_FAMILY,
"openai-agents-sdk": OPENAI_FAMILY,
# Antigravity is Gemini-native but routes generic-provider traffic over
# the OpenAI-compatible wire (OpenRouter / LiteLLM / Databricks gateway),
# so it consumes the ``openai`` family like openai-agents-sdk.
"antigravity": OPENAI_FAMILY,
# Qwen Code routes through OpenAI-compatible providers (like Kimi v1).
"qwen": OPENAI_FAMILY,
}
# Maps harnesses that gate the vendor-neutral gateway transport on a
# ``HARNESS_*_GATEWAY`` truthy flag to that env var name. The flag enables
# the executor's gateway path (base URL + token command + model) regardless
# of which producer fed it — generic providers or the Databricks AI gateway.
# ``openai-agents-sdk`` is absent: its executor takes the API key / base URL
# directly with no such gate (see :func:`_apply_provider_to_openai_agents`).
_HARNESS_GATEWAY_FLAG: dict[AgentHarnessType, str] = {
"claude-sdk": "HARNESS_CLAUDE_SDK_GATEWAY",
"codex": "HARNESS_CODEX_GATEWAY",
"pi": "HARNESS_PI_GATEWAY",
"qwen": "HARNESS_QWEN_GATEWAY",
}
# Maps a generic-provider family to the key pi uses in its
# ``HARNESS_PI_GATEWAY_BASE_URLS`` JSON object (pi's own family naming).
_PI_FAMILY_KEY: dict[str, str] = {
ANTHROPIC_FAMILY: "claude",
OPENAI_FAMILY: "openai",
}
# Per-harness ``HARNESS_*_DATABRICKS_PROFILE`` env var name, used by the
# databricks-kind provider branch (which delegates to the existing ucode
# path). This stays Databricks-named: it is a ``~/.databrickscfg`` profile
# the executor uses for Databricks-specific credential resolution / token
# refresh, not part of the vendor-neutral gateway transport.
# ``openai-agents-sdk`` uses the same var name but has no enable flag.
_HARNESS_DATABRICKS_PROFILE: dict[AgentHarnessType, str] = {
"claude-sdk": "HARNESS_CLAUDE_SDK_DATABRICKS_PROFILE",
"codex": "HARNESS_CODEX_DATABRICKS_PROFILE",
"pi": "HARNESS_PI_DATABRICKS_PROFILE",
"openai-agents-sdk": "HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE",
"qwen": "HARNESS_QWEN_DATABRICKS_PROFILE",
# NB: no ``antigravity`` — it has no Databricks/gateway path (Gemini-native).
# NB: no ``kimi`` — upstream kimi has no per-spawn provider override flag,
# so Omnigent cannot thread a Databricks gateway through. Users configure
# providers via ``kimi provider add`` in ``~/.kimi/config.toml``
# (Omnigent-side provider injection is a deferred follow-up).
}
def _provider_harness_name(harness_type: AgentHarnessType) -> str:
"""Translate a workflow harness type to the provider-config harness name.
The Chunk-1a provider-config layer keys harness→family with
``"openai-agents"`` (no ``-sdk`` suffix), whereas this module's
:data:`AgentHarnessType` uses ``"openai-agents-sdk"``. Every other
harness name matches verbatim, so only that one differs.
:param harness_type: Canonical workflow harness type, e.g.
``"openai-agents-sdk"`` or ``"claude-sdk"``.
:returns: The provider-config harness name, e.g. ``"openai-agents"`` or
``"claude-sdk"``.
"""
return "openai-agents" if harness_type == "openai-agents-sdk" else harness_type
def _provider_auth_command(family: FamilyConfig) -> str:
"""Return a bearer-token shell command for *family*, failing loud if absent.
Mirrors the executors' transport contract: the executors' gateway path
invokes a shell command that prints the bearer token. A static
``api_key`` (already resolved to plaintext by
:meth:`ProviderEntry.family`) becomes ``printf %s <shlex-quoted-key>``;
a user-supplied dynamic ``auth_command`` passes through verbatim.
:param family: The resolved provider family (``base_url`` + secret
expanded by :meth:`ProviderEntry.family`).
:returns: A shell command that prints the bearer token to stdout, e.g.
``"printf %s sk-or-abc"`` or the literal ``auth_command``.
:raises OmnigentError: If the family carries neither a static
``api_key`` nor an ``auth_command`` (should not happen post-parse).
"""
if family.api_key is not None:
# printf %s avoids the trailing newline ``echo`` would add and is
# shell-safe for keys with special characters via shlex.quote.
return f"printf %s {shlex.quote(family.api_key)}"
if family.auth_command is not None:
return family.auth_command
raise OmnigentError(
"provider family has no credential (neither 'api_key' nor 'auth_command') "
"to build a bearer-token command from.",
code=ErrorCode.INVALID_INPUT,
)
def _origin_of(base_url: str) -> str:
"""Return the scheme://host[:port] origin of *base_url*.
The gateway executors expect a ``HARNESS_*_GATEWAY_HOST`` workspace
origin separate from the full base URL (which carries the API path).
:param base_url: The endpoint base URL, e.g.
``"https://openrouter.ai/api/v1"`` or ``"http://localhost:4000/v1"``.
:returns: The origin, e.g. ``"https://openrouter.ai"`` or
``"http://localhost:4000"``.
"""
from urllib.parse import urlparse
parsed = urlparse(base_url)
return f"{parsed.scheme}://{parsed.netloc}"
def configure_agent_harness_with_provider(
env: dict[str, str],
entry: ProviderEntry,
*,
harness_type: AgentHarnessType,
) -> None:
"""Inject per-harness model, URL, and auth from a generic provider.
The open-source counterpart to :func:`configure_agent_harness_with_ucode`:
it takes a resolved :class:`ProviderEntry` (from the ``providers:`` block
of ``~/.omnigent/config.yaml``) and emits the **same** vendor-neutral
``HARNESS_*_GATEWAY_*`` env vars the Databricks producer emits (base URL,
host, a bearer-token command, model default), so the executors' existing
gateway path handles a
LiteLLM / OpenRouter / local endpoint with no executor changes. Dispatch
is on :attr:`ProviderEntry.kind`:
- ``key`` / ``gateway`` / ``local`` — resolve the harness's family and
emit the ``HARNESS_*_GATEWAY_*`` env vars (see
:func:`_apply_provider_family`).
- ``subscription`` — the native/CLI harness carries its own login; no
gateway vars. For codex, pin the built-in ``openai`` provider
(``HARNESS_CODEX_MODEL_PROVIDER``) so a custom default in the user's
``~/.codex/config.toml`` cannot shadow the subscription.
- ``cli-config`` — pin the entry's ``model_provider``
(``HARNESS_CODEX_MODEL_PROVIDER``); the provider table + credential
come from the user's ``~/.codex/config.toml``, which the executor
bridges into the per-session ``CODEX_HOME``. Codex harness only.
- ``databricks`` — delegate to the existing ucode path keyed on the
provider's profile, reusing :func:`configure_agent_harness_with_ucode`
so the ``polly`` / Databricks coding-agent flow is unchanged.
- ``bedrock`` — rejected (raises): AWS Bedrock mode is wired only into
the native ``omnigent claude`` launch, not the in-process / gateway
harnesses.
:param env: Mutable spawn-env dict, modified in place.
:param entry: The resolved provider entry to apply.
:param harness_type: Canonical harness type, e.g. ``"claude-sdk"``.
:raises OmnigentError: If an inline-family provider lacks the family
the harness requires, no model can be resolved for it, or the harness
is ``antigravity`` (which is Gemini-native and has no gateway path).
"""
if harness_type == "antigravity":
# The Antigravity SDK authenticates Gemini-natively (a direct API key
# or Vertex AI) and has no OpenAI-compatible base_url, so it cannot
# consume a generic provider / gateway. ``_build_antigravity_spawn_env``
# threads HARNESS_ANTIGRAVITY_API_KEY / _VERTEX directly instead, so
# this path must never run for it — fail loud rather than emit inert
# gateway env vars the executor no longer reads.
raise OmnigentError(
"The 'antigravity' harness authenticates Gemini-natively (API key "
"or Vertex AI) and does not support generic providers or gateway "
"routing. Set executor.auth to an api_key, or executor.config "
"vertex/project/location, instead of a 'providers:' entry.",
code=ErrorCode.INVALID_INPUT,
)
if entry.kind == BEDROCK_KIND:
# Bedrock mode is wired only into the native ``omnigent claude`` launch
# (:func:`omnigent.claude_native._bedrock_config_for_native_claude`),
# which sets CLAUDE_CODE_USE_BEDROCK + AWS_BEARER_TOKEN_BEDROCK directly.
# The in-process / gateway harnesses have no Bedrock path, so emitting
# the generic ``HARNESS_*_GATEWAY_*`` vars would silently point the
# harness at the Bedrock endpoint as if it spoke the Anthropic Messages
# API and fail at request time. Fail loud instead.
raise OmnigentError(
f"provider {entry.name!r} (kind 'bedrock') is only supported by the "
f"native 'omnigent claude' terminal, not the {harness_type!r} harness. "
"For agents / 'omnigent run', use a 'gateway' provider "
"(OpenAI/Anthropic-compatible endpoint), or a 'databricks' / 'key' "
"provider.",
code=ErrorCode.INVALID_INPUT,
)
if entry.kind == SUBSCRIPTION_KIND:
# A logged-in CLI (claude / codex) carries its own auth; the
# native/CLI harness reads its own login. Emitting inline-family
# gateway vars here would point the harness at a non-existent
# endpoint. (Chunk 1b routes only the inline-family + databricks
# kinds; subscription routing — toggling the CLI's logged-in model
# — is a later chunk.)
if harness_type == "codex":
# The codex executor symlinks the user's ~/.codex/config.toml
# into the per-session CODEX_HOME, so a custom default
# ``model_provider`` there (e.g. isaac's Databricks AI Gateway)
# would silently hijack a Subscription selection. Pin codex's
# built-in ``openai`` provider so "Subscription" always means
# the ChatGPT login — a no-op when the user's config sets no
# custom default.
env["HARNESS_CODEX_MODEL_PROVIDER"] = "openai"
return
if entry.kind == CLI_CONFIG_KIND:
# The pi harness consumes both families and can route a cli-config
# Databricks AI Gateway (the gateway's Anthropic Messages surface is one
# Pi speaks natively) — the same provider pi-native routes via
# ``_cli_config_pi_provider``. Translate it into the pi gateway
# transport rather than failing loud; a non-Databricks cli-config is
# never selected for pi (see ``default_provider_for_harness``), so it
# won't reach here.
if harness_type == "pi":
_apply_cli_config_databricks_to_pi(env, entry)
return
# A custom model provider defined (and authenticated) by the codex
# CLI's own config.toml: pin it by name; the executor's bridged
# config.toml carries the provider table + credential. Only the
# codex harness reads that file — openai-agents-sdk / claude-sdk
# cannot consume a codex provider table, so fail loud rather than
# launch them credential-less.
if harness_type != "codex":
raise OmnigentError(
f"provider {entry.name!r} (kind 'cli-config') pins a provider in "
f"~/.codex/config.toml and can only drive the 'codex' harness, "
f"not {harness_type!r}. Configure a key/gateway provider for this "
"harness in ~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
# entry.model_provider is required by the cli-config parse branch.
env["HARNESS_CODEX_MODEL_PROVIDER"] = str(entry.model_provider)
return
if entry.kind == DATABRICKS_KIND:
# A Databricks profile: reuse the existing ucode path so the
# Databricks coding agent / polly keep working unchanged. The
# profile name drives model + base URL + auth-command lookup from
# ~/.databrickscfg + ucode state. This mirrors the legacy
# DatabricksAuth branch: enable the neutral gateway transport (the
# Databricks AI gateway is one producer of that transport), record
# the Databricks profile (Databricks-specific, used by the executor
# for token refresh), then delegate gateway enrichment to ucode.
profile = entry.profile
flag = _HARNESS_GATEWAY_FLAG.get(harness_type)
if flag is not None:
env[flag] = "true"
if profile:
env[_HARNESS_DATABRICKS_PROFILE[harness_type]] = profile
configure_agent_harness_with_ucode(env, profile, harness_type=harness_type)
return
# Inline-family kinds: key / gateway / local.
if harness_type == "pi":
_apply_provider_to_pi(env, entry)
return
family_name = _PROVIDER_HARNESS_FAMILY[harness_type]
family = entry.family(family_name)
if family is None:
raise OmnigentError(
f"provider {entry.name!r} has no {family_name!r} family, required by the "
f"{harness_type!r} harness. Add a '{family_name}:' block to that provider in "
f"~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
if harness_type == "openai-agents-sdk":
_apply_provider_to_openai_agents(env, family)
else:
_apply_provider_family(env, harness_type, family)
# Maps an omnigent provider family to the bundled catalog provider name
# whose default model serves it. The two happen to share names today
# (``anthropic`` family ⇄ ``anthropic`` catalog, ``openai`` family ⇄
# ``openai`` catalog), but routing through this map keeps the family→catalog
# coupling explicit and one place to change if a family ever fans out to a
# differently-named catalog (e.g. an openai-compatible vendor).
_FAMILY_CATALOG_PROVIDER: dict[str, str] = {
ANTHROPIC_FAMILY: "anthropic",
OPENAI_FAMILY: "openai",
}
def _catalog_default_model(family_name: str) -> str | None:
"""Return the bundled catalog's default model for a provider family.
Used as the model-resolution fallback for a ``key`` / ``gateway`` /
``local`` provider on a KNOWN family (anthropic / openai) when neither
the spec nor the provider's ``models.default`` names a model: rather than
fail loud, resolve a sensible vendor model from the catalog. The neutral
gateway path never falls back to a ``databricks-*`` model. This is a real,
designed default — see
:func:`omnigent.onboarding.providers.default_chat_model` for the rule
(newest general-purpose chat model for that vendor) — not an invented
one masking missing data: it only applies on a family the catalog knows.
:param family_name: The omnigent family, ``"anthropic"`` or
``"openai"``.
:returns: The catalog default model id, e.g. ``"claude-opus-4-6-20260205"``
or ``"gpt-5.4-2026-03-05"``, or ``None`` when the family has no
catalog mapping or the catalog has no chat model for it (genuinely
unknown — the caller then fails loud).
"""
from omnigent.onboarding.providers import default_chat_model
catalog_provider = _FAMILY_CATALOG_PROVIDER.get(family_name)
if catalog_provider is None:
return None
return default_chat_model(catalog_provider)
def _apply_provider_family(
env: dict[str, str],
harness_type: AgentHarnessType,
family: FamilyConfig,
) -> None:
"""Apply a provider family to a gateway-style harness (claude-sdk / codex).
Emits the same vendor-neutral ``HARNESS_*_GATEWAY_*`` env vars the
Databricks producer emits so the executor's existing gateway path is
reused unchanged. The model default is only applied when the spec did
not already set the model env var.
:param env: Mutable spawn-env dict, modified in place.
:param harness_type: ``"claude-sdk"`` or ``"codex"``.
:param family: The resolved provider family for this harness.
:raises OmnigentError: If no model is resolvable (neither the spec nor
the family declares one).
"""
cfg = _UCODE_HARNESS_CONFIGS[harness_type]
env[_HARNESS_GATEWAY_FLAG[harness_type]] = "true"
env[cfg.base_url_key] = family.base_url
env[cfg.host_key] = _origin_of(family.base_url)
if cfg.auth_key is not None:
env[cfg.auth_key] = _provider_auth_command(family)
# Model precedence: spec model (already in env via _resolve_spec_model) >
# provider ``models.default`` > catalog family default > fail loud.
if cfg.model_key not in env and family.default_model:
env[cfg.model_key] = family.default_model
if cfg.model_key not in env:
# Neither the spec nor the provider names a model. On a KNOWN family
# (anthropic / openai) fall back to the bundled catalog's default for
# that vendor — a real designed default — rather than failing loud.
# The neutral gateway path never selects a ``databricks-*`` model;
# the executor's old flag-triggered ``databricks-*`` fallback is gone.
catalog_default = _catalog_default_model(_PROVIDER_HARNESS_FAMILY[harness_type])
if catalog_default is not None:
env[cfg.model_key] = catalog_default
if cfg.model_key not in env:
# Fail loud only when the catalog also has nothing for this family
# (a genuinely unknown family — should not happen for the two known
# ones, but keeps the resolution total).
raise OmnigentError(
f"No model resolved for the {harness_type!r} harness on a generic provider: "
"the agent spec sets no model, the provider's family has no "
"'models.default', and the bundled catalog has no default for that family. "
"Set 'executor.model' in the agent YAML, or add a "
"'models: {default: ...}' to that provider family in ~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
if harness_type == "codex":
# Codex defaults to the Responses wire API; OpenRouter-style
# chat-only gateways set wire_api: chat. See codex_harness.py.
env["HARNESS_CODEX_WIRE_API"] = family.wire_api or RESPONSES_WIRE_API
def _apply_provider_to_openai_agents(env: dict[str, str], family: FamilyConfig) -> None:
"""Apply a provider family to the openai-agents-sdk harness.
Unlike the gateway harnesses, the OpenAI-Agents executor takes the API
key and base URL directly (no ``GATEWAY`` enable flag). A static key uses
``HARNESS_OPENAI_AGENTS_API_KEY``; a dynamic token command uses the
auth-command + host pair. ``wire_api`` maps to ``USE_RESPONSES``.
:param env: Mutable spawn-env dict, modified in place.
:param family: The resolved ``openai`` provider family.
:raises OmnigentError: If no model is resolvable (neither the spec nor
the family declares one).
"""
env["HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL"] = family.base_url
if family.api_key:
env["HARNESS_OPENAI_AGENTS_API_KEY"] = family.api_key
else:
# Dynamic token command: the executor wraps httpx with a shell-command
# bearer auth, refreshing the token from this command + host.
env["HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND"] = _provider_auth_command(family)
env["HARNESS_OPENAI_AGENTS_GATEWAY_HOST"] = _origin_of(family.base_url)
# Model precedence: spec model > provider ``models.default`` > catalog
# family default > fail loud. The openai-agents harness always consumes
# the ``openai`` family.
if "HARNESS_OPENAI_AGENTS_MODEL" not in env and family.default_model:
env["HARNESS_OPENAI_AGENTS_MODEL"] = family.default_model
if "HARNESS_OPENAI_AGENTS_MODEL" not in env:
catalog_default = _catalog_default_model(OPENAI_FAMILY)
if catalog_default is not None:
env["HARNESS_OPENAI_AGENTS_MODEL"] = catalog_default
if "HARNESS_OPENAI_AGENTS_MODEL" not in env:
# Fail loud only when the catalog has no default for the openai family
# (genuinely unknown — should not happen for a known family).
raise OmnigentError(
"No model resolved for the 'openai-agents-sdk' harness on a generic provider: "
"the agent spec sets no model, the provider's 'openai' family has no "
"'models.default', and the bundled catalog has no default for it. "
"Set 'executor.model' in the agent YAML, or add a "
"'models: {default: ...}' to that provider family in ~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
if family.wire_api is not None:
env["HARNESS_OPENAI_AGENTS_USE_RESPONSES"] = (
"true" if family.wire_api == RESPONSES_WIRE_API else "false"
)
def _optional_provider_family(entry: ProviderEntry, family_name: str) -> FamilyConfig | None:
"""Return a provider family, or ``None`` if absent *or* its key env var is unset.
For the ``pi`` harness, which carries a single credential but probes
both families: a family whose ``$VAR`` is unresolved is treated as
unavailable rather than fatal, so e.g. a user who only exported
``ANTHROPIC_API_KEY`` can still run pi on the anthropic family.
The only :class:`OmnigentError` raised at family-access time comes from
the deferred ``$VAR`` expansion (structural validation already happened
at parse time), so catching it here narrowly means "this family's
credential is not configured". A ``keychain:`` ref raises ``ValueError``
(deferred — see :func:`resolve_secret`); that propagates so the user
sees the clear "not yet supported" message rather than a silent skip.
:param entry: The resolved provider entry.
:param family_name: Family key, e.g. ``"openai"`` or ``"anthropic"``.
:returns: The :class:`FamilyConfig`, or ``None`` when the family is
absent or its credential env var is unset.
"""
try:
return entry.family(family_name)
except OmnigentError:
return None
def _apply_provider_to_pi(env: dict[str, str], entry: ProviderEntry) -> None:
"""Apply a provider to the pi harness, which consumes both families.
pi reads ``HARNESS_PI_GATEWAY_BASE_URLS`` (a JSON object keyed by pi's
own family names) and a single auth command. When both families are
configured with different credentials pi can only carry one — it uses the
``anthropic`` family's auth when present, else the ``openai`` family's.
For a single-key gateway (e.g. a LiteLLM proxy) this is exact.
A family whose credential env var is unset is skipped (not fatal) so a
user who exported only one vendor's key can still run pi on that family.
If neither family resolves, this fails loud.
:param env: Mutable spawn-env dict, modified in place.
:param entry: The resolved provider entry (at least one inline family).
:raises OmnigentError: If no configured family's credentials resolve,
or no model can be resolved for the chosen family.
"""
anthropic = _optional_provider_family(entry, ANTHROPIC_FAMILY)
openai = _optional_provider_family(entry, OPENAI_FAMILY)
base_urls: dict[str, str] = {}
if anthropic is not None:
base_urls[_PI_FAMILY_KEY[ANTHROPIC_FAMILY]] = anthropic.base_url
if openai is not None:
base_urls[_PI_FAMILY_KEY[OPENAI_FAMILY]] = openai.base_url
if not base_urls:
raise OmnigentError(
f"pi harness: provider {entry.name!r} configures no family whose "
"credentials resolve — set the api_key env var for its 'anthropic' or "
"'openai' family in your shell, then retry.",
code=ErrorCode.INVALID_INPUT,
)
# pi carries a single credential: anthropic's when present, else openai's.
# The model fallback must match the family that supplied that credential.
auth_source: FamilyConfig | None
if anthropic is not None:
auth_source = anthropic
auth_family = ANTHROPIC_FAMILY
else:
auth_source = openai
auth_family = OPENAI_FAMILY
assert auth_source is not None # base_urls non-empty ⇒ one family resolved
env[_HARNESS_GATEWAY_FLAG["pi"]] = "true"
env["HARNESS_PI_GATEWAY_BASE_URLS"] = json.dumps(base_urls, sort_keys=True)
env["HARNESS_PI_GATEWAY_HOST"] = _origin_of(next(iter(base_urls.values())))
env["HARNESS_PI_GATEWAY_AUTH_COMMAND"] = _provider_auth_command(auth_source)
# Model precedence: spec model > provider ``models.default`` > catalog
# family default (of the auth-source family) > fail loud.
if "HARNESS_PI_MODEL" not in env and auth_source.default_model:
env["HARNESS_PI_MODEL"] = auth_source.default_model
if "HARNESS_PI_MODEL" not in env:
catalog_default = _catalog_default_model(auth_family)
if catalog_default is not None:
env["HARNESS_PI_MODEL"] = catalog_default
if "HARNESS_PI_MODEL" not in env:
# Fail loud only when the catalog has no default for the chosen family
# (genuinely unknown — should not happen for a known family).
raise OmnigentError(
"No model resolved for the 'pi' harness on a generic provider: the agent "
"spec sets no model, the provider family has no 'models.default', and the "
"bundled catalog has no default for it. Set 'executor.model' in the agent "
"YAML, or add a 'models: {default: ...}' to that provider family in "
"~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
def _apply_cli_config_databricks_to_pi(env: dict[str, str], entry: ProviderEntry) -> None:
"""Apply a cli-config Databricks AI Gateway to the pi (gateway-harness) path.
The gateway-harness pi launch (``omnigent run`` / agents) and pi-native
(the terminal) both resolve the same default provider
(:func:`default_provider_for_harness`), so when that default is a
``cli-config`` Databricks AI Gateway, this path must route it rather than
fail loud. We reuse the pi-native translation
(:func:`omnigent.pi_native_credentials._cli_config_pi_provider`) — which
reads the codex ``[model_providers.X]`` transport, rewrites the base URL to
the gateway's Anthropic Messages surface (``/anthropic``) Pi speaks
natively, and builds the per-request bearer-token ``!command`` apiKey — then
maps its fields onto the ``HARNESS_PI_GATEWAY_*`` env vars the pi harness
wrap reads (the same vars :func:`_apply_provider_to_pi` emits).
:param env: Mutable spawn-env dict, modified in place.
:param entry: The resolved ``cli-config`` provider entry (a Databricks
gateway — selection guarantees a non-Databricks cli-config never
reaches here).
:raises OmnigentError: If the cli-config entry cannot be translated into a
Pi gateway provider (its codex table can't be resolved or it is not a
recognized Databricks AI Gateway) — selection should prevent this, so a
failure here is a real misconfiguration worth surfacing.
"""
# Imported lazily: pi_native_credentials is on the runner's session-create
# hot path and pulls onboarding-only deps; keep this off workflow import.
from omnigent.pi_native_credentials import _cli_config_pi_provider
# The spec model (if any) is already in HARNESS_PI_MODEL; thread it so the
# gateway translation honors an explicit override, else its default.
model_override = env.get("HARNESS_PI_MODEL")
provider = _cli_config_pi_provider(entry, model=model_override)
if provider is None:
raise OmnigentError(
f"provider {entry.name!r} (kind 'cli-config') was selected for the 'pi' "
"harness but its codex [model_providers] table could not be resolved as a "
"Databricks AI Gateway. Check the [model_providers] base_url + auth in "
"~/.codex/config.toml, or configure a key/gateway provider for pi in "
"~/.omnigent/config.yaml.",
code=ErrorCode.INVALID_INPUT,
)
# Pi speaks the gateway's Anthropic Messages surface — register it under
# pi's "claude" family key (mirrors _apply_provider_to_pi's anthropic path).
base_urls = {_PI_FAMILY_KEY[ANTHROPIC_FAMILY]: provider.base_url}
env[_HARNESS_GATEWAY_FLAG["pi"]] = "true"
env["HARNESS_PI_GATEWAY_BASE_URLS"] = json.dumps(base_urls, sort_keys=True)
env["HARNESS_PI_GATEWAY_HOST"] = _origin_of(provider.base_url)
# provider.api_key is a "!command" form (Pi's models.json convention); the
# gateway transport env var wants the bare shell command, so strip the "!".
env["HARNESS_PI_GATEWAY_AUTH_COMMAND"] = provider.api_key.lstrip("!")
env["HARNESS_PI_MODEL"] = provider.model
def _synthesize_databricks_provider(profile: str | None) -> ProviderEntry:
"""
Build an in-memory ``databricks``-kind provider for a legacy credential.
Legacy Databricks credentials — a spec ``DatabricksAuth`` /
``executor.profile``, the global ``auth: {type: databricks}`` block, or a
``databricks-`` model name — are folded into the generic provider path by
wrapping them in a synthesized :class:`ProviderEntry`, so the single
:func:`configure_agent_harness_with_provider` databricks branch wires the
gateway transport instead of a per-builder ``else``. Never persisted;
``profile`` ``None`` enables the gateway with no pinned profile (the
executor resolves the default ``~/.databrickscfg``).
:param profile: The ``~/.databrickscfg`` profile, or ``None``.
:returns: A ``databricks``-kind :class:`ProviderEntry`.
"""
return ProviderEntry(name="databricks", kind=DATABRICKS_KIND, profile=profile)
def _legacy_databricks_provider(
profile: str | None,
*,
harness_type: AgentHarnessType,
for_launch: bool,
) -> ProviderEntry | None:
"""
Synthesize a databricks provider for a legacy credential, when applicable.
Returns a synthesized ``databricks`` :class:`ProviderEntry` only for a
launch (*for_launch*) of a gateway-flag harness (claude-sdk / codex / pi /
qwen) — the harnesses whose databricks apply branch reproduces the legacy
``else`` env exactly. Returns ``None`` otherwise (the ``/model`` readout /
cost / native paths and the openai-agents harness), so those keep their own
handling byte-for-byte.
:param profile: The legacy ``~/.databrickscfg`` profile, or ``None``.
:param harness_type: Canonical harness type, e.g. ``"codex"``.
:param for_launch: Whether this resolution feeds an actual spawn.
:returns: A synthesized databricks provider, or ``None``.
"""
if for_launch and _HARNESS_GATEWAY_FLAG.get(harness_type) is not None:
return _synthesize_databricks_provider(profile)
return None
def _resolve_provider_for_build(
spec: AgentSpec,
*,
harness_type: AgentHarnessType,
for_launch: bool = False,
) -> ProviderEntry | None:
"""Resolve the provider that should route *harness_type*, if any.
The single credential resolver, shared by the spawn-env builders (with
*for_launch*) and the readout / cost / native paths (without). Precedence,
most explicit first:
1. ``spec.executor.auth`` is a :class:`ProviderAuth` → that named provider
(fails loud when undeclared).
2. A legacy Databricks credential — ``executor.auth: {type: databricks}``,
a legacy ``executor.profile``, the global ``auth: {type: databricks}``
block, or a ``databricks-`` model name — resolves to a *synthesized*
``databricks`` provider so the one
:func:`configure_agent_harness_with_provider` databricks branch wires it
(no per-builder ``else``). Folded only ``for_launch`` of a gateway-flag
harness; elsewhere it returns ``None`` so the readout / native /
openai-agents paths keep their own handling.
3. An :class:`ApiKeyAuth` (spec or global) → ``None`` (the claude-sdk /
openai-agents builders thread the key themselves).
4. The per-family global default (``providers: … default: true``), then an
ambient-detected default.
5. (``for_launch`` only) the first credential that can serve the family even
though it is not marked default — so a launch credentials the head (e.g.
Debby's codex head with only a never-defaulted Databricks workspace)
rather than failing with "Invalid API key". Off for the readout / cost
paths so they never show a provider the user did not choose.
:param spec: The agent spec.
:param harness_type: Canonical workflow harness type, e.g. ``"codex"``.
:param for_launch: ``True`` for the spawn-env builders (permissive: fold
legacy Databricks credentials into the provider path and fall back to
the first available credential). ``False`` (readout / cost / native)
keeps strict, config-only resolution with no synthesis or fallback.
:returns: The :class:`ProviderEntry` to route through, or ``None``.
:raises OmnigentError: If a named :class:`ProviderAuth` references a
provider absent from the ``providers:`` block.
"""
explicit_config = load_config()
harness = _provider_harness_name(harness_type)
auth = spec.executor.auth
if isinstance(auth, ProviderAuth):
# A named provider is resolved against the explicit config merged with
# ambient detections, so a spec may name a detected provider too.
providers = load_providers(effective_config_with_detected(explicit_config))
entry = providers.get(auth.name)
if entry is None:
raise OmnigentError(
f"executor.auth references provider {auth.name!r}, but no such provider is "
"configured under 'providers:' in ~/.omnigent/config.yaml. "
"Run `omnigent setup --no-internal-beta` to configure one.",
code=ErrorCode.INVALID_INPUT,
)
return entry
if isinstance(auth, DatabricksAuth):
# Spec databricks auth → synthesized provider for a gateway-harness
# launch, else None so the builder's own DatabricksAuth branch runs.
return _legacy_databricks_provider(
auth.profile or None, harness_type=harness_type, for_launch=for_launch
)
if auth is not None:
# ApiKeyAuth — threaded by the claude-sdk / openai-agents builders.
return None
legacy_profile = spec.executor.profile or spec.executor.config.get("profile")
if legacy_profile:
# A legacy profile is a Databricks credential and wins over a configured
# default, exactly as before — folded into the synthesized provider for
# a gateway-harness launch, else None so the legacy ``else`` runs.
return _legacy_databricks_provider(
str(legacy_profile), harness_type=harness_type, for_launch=for_launch
)
# No spec auth. Most explicit wins, ambient last, then a launch-only fallback.
explicit_default = default_provider_for_harness(explicit_config, harness)
if explicit_default is not None:
return explicit_default
global_auth = _load_global_auth()
if isinstance(global_auth, DatabricksAuth):
# None (readout / non-gateway) → defer to the builder's global-auth path.
return _legacy_databricks_provider(
global_auth.profile or None, harness_type=harness_type, for_launch=for_launch
)
if global_auth is not None:
# Global ApiKeyAuth — threaded by the builder's global-auth branch.
return None
model = _resolve_spec_model(spec)
if model is not None and model.startswith(("databricks-", "databricks/")):
# The model name itself signals Databricks intent (no pinned profile).
return _legacy_databricks_provider(None, harness_type=harness_type, for_launch=for_launch)
effective = effective_config_with_detected(explicit_config)
ambient_default = default_provider_for_harness(effective, harness)
if ambient_default is not None:
return ambient_default
# Launch-only last resort: no default anywhere, but a credential that serves
# this family is configured (e.g. a Databricks workspace the user added but
# never set as the default). The runner is the one chokepoint every head
# (CLI, web UI, or a remote host) funnels through, so this credentials the
# head on every surface, for any agent. Resolved per spawn — nothing is
# persisted; the startup creds line names the same provider via
# :func:`first_available_provider`, so the readout cannot disagree.
if for_launch:
family = harness_family(harness)
if family is not None:
return first_available_provider(effective, family)
return None
def _resolve_spec_model(spec: AgentSpec) -> str | None:
"""
Return the model identifier from the spec's executor block.
:param spec: The agent spec.
:returns: The model identifier, or ``None`` when the spec
declares no model.
"""
return spec.executor.model
def _add_claude_sdk_skills_env(
env: dict[str, str],
spec: AgentSpec,
workdir: Path | None,
) -> None:
"""
Populate the skills-related ``HARNESS_CLAUDE_SDK_*`` env vars.
Threads ``spec.skills_filter`` (always — the harness wrap
falls back to ``"all"`` on a missing var, which would
silently override an explicit ``skills: none``), ``spec.name``,
and the bundle's extracted on-disk path so the harness wrap
can wire ``ClaudeAgentOptions.plugins`` for agent-bundled
skills.
:param env: Dict mutated in-place with the new keys.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path (extracted by the
agent cache). ``None`` skips the plugin-dir wiring.
"""
# ``skills_filter`` is JSON-encoded because the value can be
# ``"all"`` / ``"none"`` / a list of skill names. Always set:
# the harness wrap's ``_resolve_skills_filter`` falls back to
# ``"all"`` on a missing var, which would silently override
# an explicit ``skills: none`` from the spec. (The original
# regression that motivated this whole bridge.)
env["HARNESS_CLAUDE_SDK_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
if spec.name:
env["HARNESS_CLAUDE_SDK_AGENT_NAME"] = spec.name
if workdir is not None:
env["HARNESS_CLAUDE_SDK_BUNDLE_DIR"] = str(workdir)
def _build_claude_sdk_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the env-var dict the claude-sdk harness wrap reads.
Maps spec.executor fields → the ``HARNESS_CLAUDE_SDK_*`` env
vars defined in ``omnigent/inner/claude_sdk_harness.py``.
Per the v1 spec-config-flow design (see §Step 5b in the
design doc), per-spawn env overrides are how Omnigent threads
per-spec config into the subprocess without polluting
``os.environ``.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path (extracted by the
agent cache). Threaded through as
``HARNESS_CLAUDE_SDK_BUNDLE_DIR`` so the harness wrap can
wire the SDK ``--plugin-dir`` for agent-bundled skills.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_CLAUDE_SDK_MODEL"] = model
# ── Auth resolution ────────────────────────────────────────────────
# Priority (highest first):
# 0. Generic provider — spec.executor.auth: {type: provider, name: X},
# OR (no spec auth) the per-family global default from the
# ``providers:`` config block (:func:`_resolve_provider_for_build`).
# Routes a LiteLLM / OpenRouter / local / Databricks-profile provider.
# 1. spec.executor.auth — explicit typed auth in the agent YAML.
# 2. Legacy spec.executor.profile / executor.config["profile"] (deprecated).
# 3. Global config ~/.omnigent/config.yaml auth: — only when spec has
# no auth at all (same guard as openai-agents to prevent global defaults
# from silently overriding YAML-declared legacy profiles).
# 4. Auto-Databricks: databricks-* model prefix triggers Databricks routing.
provider = _resolve_provider_for_build(spec, harness_type="claude-sdk", for_launch=True)
if provider is not None:
configure_agent_harness_with_provider(env, provider, harness_type="claude-sdk")
else:
# No provider resolved → the only remaining credential is an ApiKeyAuth
# (spec ``executor.auth`` or the global ``auth:`` block). The databricks /
# legacy-profile / databricks-model cases were folded into the
# synthesized-provider path above, so no profile or ucode wiring remains
# here. The executor strips ANTHROPIC_API_KEY to force subscription auth
# inside Claude Code, so the key is threaded via the CLI's apiKeyHelper
# (a shell command the CLI invokes; shlex.quote keeps it shell-safe).
auth_from_spec = spec.executor.auth
if auth_from_spec is None:
auth_from_spec = _load_global_auth()
if isinstance(auth_from_spec, ApiKeyAuth) and auth_from_spec.api_key:
_key_cmd = f"printf %s {shlex.quote(auth_from_spec.api_key)}"
env["HARNESS_CLAUDE_SDK_API_KEY_HELPER"] = _key_cmd
if auth_from_spec.base_url:
env["HARNESS_CLAUDE_SDK_GATEWAY_BASE_URL"] = auth_from_spec.base_url
env["HARNESS_CLAUDE_SDK_GATEWAY_AUTH_COMMAND"] = _key_cmd
# Enable the gateway for an ApiKeyAuth ``base_url`` (a custom endpoint) or
# a ``databricks-`` model; without the flag the executor ignores the base
# URL and falls through to api.anthropic.com.
if (isinstance(auth_from_spec, ApiKeyAuth) and bool(auth_from_spec.base_url)) or (
model is not None and model.startswith(("databricks-", "databricks/"))
):
env["HARNESS_CLAUDE_SDK_GATEWAY"] = "true"
_add_claude_sdk_skills_env(env, spec, workdir)
# OS env: enabling this in the inner ClaudeSDKExecutor is
# what gates the SDK-native ``Bash/Read/Edit/Write/Glob/Grep``
# tools. The legacy non-AP path enables them by default
# for omnigent-style specs (the inner CLI auto-creates a
# ``caller_process`` os_env when ``--os`` is set, and the
# SDK's bundled CLI exposes the natives unconditionally in
# some configurations); routing through Omnigent mode without a
# similar default leaves Omnigent mode users staring at a
# tool list that's ~80% smaller. Forward the spec's
# OSEnvSpec verbatim when present; default to
# ``caller_process + sandbox=none`` otherwise so the parity
# holds.
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_CLAUDE_SDK_OS_ENV"] = os_env_payload
# Phase 1f: thread the spec's RetryPolicy through to the
# claude-sdk subprocess so the inner ``ClaudeSDKExecutor``
# picks up custom retry budgets (``ANTHROPIC_MAX_RETRIES``,
# ``ANTHROPIC_REQUEST_TIMEOUT_SECONDS``) instead of falling
# back to the executor's hard-coded ``RetryPolicy()`` default.
# Omitted when the policy matches defaults — see
# :func:`_serialize_retry_policy`.
retry_payload = _serialize_retry_policy(_resolve_retry_policy(spec))
if retry_payload is not None:
env["HARNESS_CLAUDE_SDK_RETRY_POLICY"] = retry_payload
# Permission mode: controls whether Claude asks for approval before
# calling native tools. When set to anything other than the default
# ``"bypassPermissions"``, the SDK's ``can_use_tool`` callback is
# active and approval requests surface via Omnigent elicitation. Read from
# Omitted when not set — harness falls back to ``"bypassPermissions"``.
permission_mode = spec.executor.config.get("permission_mode")
if permission_mode is not None:
env["HARNESS_CLAUDE_SDK_PERMISSION_MODE"] = str(permission_mode)
return env
def _build_codex_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the env-var dict the codex harness wrap reads.
Maps spec.executor fields → the ``HARNESS_CODEX_*`` env vars
defined in ``omnigent/inner/codex_harness.py``. Mirrors
:func:`_build_claude_sdk_spawn_env` — same per-spawn env-var
pattern from §Step 5a. The codex-specific env vars
(``HARNESS_CODEX_PATH``, ``HARNESS_CODEX_ENABLE_WEB_SEARCH``,
``HARNESS_CODEX_DISABLE_NATIVE_TOOLS``) are not threaded
through here in v1: the legacy
:func:`omnigent.inner.executor_factory.create_executor`
path doesn't surface them either, so AP-side parity is
preserved by leaving them at the inner executor's defaults.
Operators who want non-default values set those env vars
on the Omnigent server directly (they propagate to the subprocess
through normal env inheritance — the wrap's per-spawn
overrides only override, they don't filter).
:param spec: The agent spec.
:param workdir: The bundle's on-disk path (extracted by the
agent cache). Threaded through as
``HARNESS_CODEX_BUNDLE_DIR`` so the harness wrap's executor
can also source bundled skills from
``<bundle>/skills/<name>/``.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_CODEX_MODEL"] = model
# Generic-provider branch (slotted ahead of the legacy-profile /
# databricks-prefix path): a ProviderAuth on the spec, or — when the spec
# declares no auth — the per-family global default. See
# :func:`_resolve_provider_for_build`. Otherwise the existing path is
# unchanged.
provider = _resolve_provider_for_build(spec, harness_type="codex", for_launch=True)
if provider is not None:
configure_agent_harness_with_provider(env, provider, harness_type="codex")
elif codex_config_provider_dismissed(load_config()):
# No credential resolved. If the user Removed codex's custom
# ~/.codex/config.toml provider (dismissed), pin the built-in ``openai``
# provider so the dismissal holds at run time — the executor's bridged
# config.toml would otherwise still route this launch through that removed
# default model_provider. (Gateway mode never reaches here: it resolves a
# provider above, and the executor rejects a double pin.)
env["HARNESS_CODEX_MODEL_PROVIDER"] = "openai"
# Skills bridge — same shape as the claude-sdk variant. Always
# set so the harness wrap doesn't fall back to its ``"all"``
# default and override an explicit ``skills: none`` spec.
env["HARNESS_CODEX_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
if spec.name:
env["HARNESS_CODEX_AGENT_NAME"] = spec.name
if workdir is not None:
env["HARNESS_CODEX_BUNDLE_DIR"] = str(workdir)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_CODEX_OS_ENV"] = os_env_payload
# Phase 1f: thread the spec's RetryPolicy through to the
# codex subprocess. See :func:`_build_claude_sdk_spawn_env`
# for the rationale.
retry_payload = _serialize_retry_policy(_resolve_retry_policy(spec))
if retry_payload is not None:
env["HARNESS_CODEX_RETRY_POLICY"] = retry_payload
return env
def _build_pi_spawn_env(
spec: AgentSpec,
*,
cwd: Path | None = None,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the env-var dict the pi harness wrap reads.
Maps spec.executor fields → the ``HARNESS_PI_*`` env vars
defined in ``omnigent/inner/pi_harness.py``. Mirrors
:func:`_build_claude_sdk_spawn_env` /
:func:`_build_codex_spawn_env` — same per-spawn env-var
pattern from §Step 5a.
:param spec: The agent spec.
:param cwd: Runtime working directory for the Pi CLI. This is the
session workspace, not the agent bundle workdir.
:param workdir: The bundle's on-disk path (extracted by the
agent cache). Threaded through as ``HARNESS_PI_BUNDLE_DIR``
so the harness wrap's executor can source bundled skills
from ``<bundle>/skills/<name>/``.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_PI_MODEL"] = model
# Generic-provider branch (slotted ahead of the legacy-profile /
# databricks-prefix path): a ProviderAuth on the spec, or — when the spec
# declares no auth — the per-family global default. pi consumes both
# families (see :func:`_apply_provider_to_pi`). Otherwise the existing
# path is unchanged.
provider = _resolve_provider_for_build(spec, harness_type="pi", for_launch=True)
if provider is not None:
configure_agent_harness_with_provider(env, provider, harness_type="pi")
# Skills bridge — same shape as the claude-sdk + codex variants.
# Always set so the harness wrap doesn't fall back to ``"all"``
# and override an explicit ``skills: none`` from the spec.
env["HARNESS_PI_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
if spec.name:
env["HARNESS_PI_AGENT_NAME"] = spec.name
if cwd is not None:
env["HARNESS_PI_CWD"] = str(cwd)
if workdir is not None:
env["HARNESS_PI_BUNDLE_DIR"] = str(workdir)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_PI_OS_ENV"] = os_env_payload
return env
def _build_qwen_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the env-var dict the qwen harness wrap reads.
Maps spec.executor fields → the ``HARNESS_QWEN_*`` env vars
defined in ``omnigent/inner/qwen_harness.py``. Mirrors
:func:`_build_claude_sdk_spawn_env` /
:func:`_build_codex_spawn_env`.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path (extracted by the agent
cache). Accepted for signature parity with the other
``_build_*_spawn_env`` builders; the qwen wrap does not yet
consume a bundle dir (no skills bridge — see docs/QWEN_FOLLOWUPS.md).
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_QWEN_MODEL"] = model
# Generic-provider branch (slotted ahead of the legacy-profile /
# databricks-prefix path): a ProviderAuth on the spec, or — when the spec
# declares no auth — the per-family global default. qwen routes through
# OpenAI-compatible providers.
provider = _resolve_provider_for_build(spec, harness_type="qwen", for_launch=True)
if provider is not None:
configure_agent_harness_with_provider(env, provider, harness_type="qwen")
# NB: no skills bridge for qwen yet. Unlike the claude-sdk / codex
# variants, the qwen wrap (omnigent/inner/qwen_harness.py) and
# QwenExecutor have no skills concept, so emitting
# HARNESS_QWEN_SKILLS_FILTER / _AGENT_NAME / _BUNDLE_DIR would set env
# nothing reads. Wire those through when skills land — see
# docs/QWEN_FOLLOWUPS.md.
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_QWEN_OS_ENV"] = os_env_payload
return env
def _build_goose_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the env-var dict the headless goose harness wrap reads.
Maps spec.executor fields → the ``HARNESS_GOOSE_*`` env vars defined in
``omnigent/inner/goose_harness.py``. Unlike the SDK harnesses, Goose owns its
own auth via ``goose configure`` (keyring / ``~/.config/goose/config.yaml``),
so this builder wires **no** provider/gateway credential — it forwards only an
optional model override and the os_env/sandbox spec. A ``databricks-*`` model
is dropped (not a valid Goose model id; the provider/model then come from the
user's Goose config), mirroring how the native CLIs handle gateway ids.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path. Accepted for signature parity with
the other ``_build_*_spawn_env`` builders; the goose wrap consumes no
bundle dir yet (no skills bridge).
:returns: A dict of env-var overrides for the harness process spawn.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None and not model.startswith(("databricks-", "databricks/")):
env["HARNESS_GOOSE_MODEL"] = model
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_GOOSE_OS_ENV"] = os_env_payload
return env
def _build_acp_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""Build the env-var dict the generic ACP harness wrap reads.
Resolves the picked ``acp:<slug>`` (carried in ``spec.executor.config`` — the
slug is the addressable half of the harness id) to a user-configured agent in
the ``acp:`` config block, and forwards its command + protocol knobs as the
``HARNESS_ACP_*`` env vars defined in ``omnigent/inner/acp_harness.py``.
Like Goose, a generic ACP agent owns its own auth, so this wires **no**
provider/gateway credential. A ``databricks-*`` model is dropped (not a valid
third-party model id); the agent's own configured model (or a flag in its
command) then applies. When the slug is missing/unknown, falls back to the
first configured agent so a bare ``acp`` id still launches something.
:param spec: The agent spec.
:param workdir: Accepted for signature parity with the other builders; the
ACP wrap consumes no bundle dir.
:returns: A dict of ``HARNESS_ACP_*`` env-var overrides for the spawn.
"""
env: dict[str, str] = {}
raw_harness = ""
cfg = getattr(spec.executor, "config", None)
if isinstance(cfg, dict):
raw_harness = str(cfg.get("harness") or "")
slug = raw_harness.split(":", 1)[1] if raw_harness.startswith("acp:") else ""
# Lazily import the config reader — the hot spawn-env path shouldn't pull in
# the onboarding/config stack eagerly (mirrors the cursor builder).
from omnigent.onboarding.acp_auth import acp_agents, resolve_acp_agent
agent = resolve_acp_agent(slug) if slug else None
if agent is None:
agents = acp_agents()
agent = agents[0] if agents else None
if agent is not None:
env["HARNESS_ACP_COMMAND"] = agent.command
env["HARNESS_ACP_NAME"] = agent.name
env["HARNESS_ACP_SESSION_ID_MODE"] = agent.session_id_mode
if agent.send_model:
env["HARNESS_ACP_SEND_MODEL"] = "1"
model = _resolve_spec_model(spec)
if model is not None and not model.startswith(("databricks-", "databricks/")):
env["HARNESS_ACP_MODEL"] = model
elif agent.model:
env["HARNESS_ACP_MODEL"] = agent.model
# else: no agent configured — leave HARNESS_ACP_COMMAND unset so the wrap
# raises a clear request-time error pointing the user at `omnigent setup`.
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_ACP_OS_ENV"] = os_env_payload
return env
def _load_global_auth() -> ApiKeyAuth | DatabricksAuth | None:
"""
Load the ``auth:`` block from ``~/.omnigent/config.yaml``.
Reads the user-level global config file (respecting
``$OMNIGENT_CONFIG_HOME`` for test isolation) and parses the
optional ``auth:`` mapping into a typed auth dataclass. Returns
``None`` when the file does not exist, the ``auth:`` key is absent,
or the block is not a recognized shape.
This provides a user-level auth default: agents that do not declare
``executor.auth`` in their own spec inherit credentials from here,
so the user only configures auth once during ``omnigent setup``
rather than in every agent YAML.
:returns: A :class:`ApiKeyAuth` or :class:`DatabricksAuth`, or
``None`` when the global config has no ``auth:`` block or the
file is missing.
"""
config_home = os.environ.get("OMNIGENT_CONFIG_HOME")
path = (
Path(config_home) / "config.yaml"
if config_home
else Path.home() / ".omnigent" / "config.yaml"
)
if not path.exists():
return None
try:
with open(path) as f:
raw: dict[str, Any] = yaml.safe_load(f) or {}
except Exception:
return None
raw_auth = raw.get("auth")
if not isinstance(raw_auth, dict):
return None
auth_type = str(raw_auth.get("type", ""))
if auth_type == "api_key":
api_key = str(raw_auth.get("api_key") or "")
if not api_key:
return None
# Expand $VAR references (the config file may store the literal
# env-var reference; expand at use-time so the secret never
# needs to live in the YAML file itself).
api_key = expand_envvars_with_omnigent_prefix(api_key)
check_unresolved_env_vars("auth.api_key", api_key)
raw_base_url = raw_auth.get("base_url")
base_url: str | None = None
if raw_base_url:
base_url = expand_envvars_with_omnigent_prefix(str(raw_base_url))
check_unresolved_env_vars("auth.base_url", base_url)
return ApiKeyAuth(api_key=api_key, base_url=base_url)
if auth_type == "databricks":
profile_val = str(raw_auth.get("profile") or "")
return DatabricksAuth(profile=profile_val) if profile_val else None
return None
def _build_openai_agents_sdk_spawn_env(spec: AgentSpec) -> dict[str, str]:
"""
Build the env-var dict the openai-agents harness wrap reads.
Maps spec.executor fields → the ``HARNESS_OPENAI_AGENTS_*``
env vars defined in
``omnigent/inner/openai_agents_sdk_harness.py``. Threads
model + auth + use_responses.
Auth resolution order (highest priority first):
1. ``spec.executor.auth`` — explicit typed auth in the agent YAML.
2. Legacy ``spec.executor.profile`` / ``spec.executor.config["profile"]``
(**deprecated** — use ``executor.auth: {type: databricks, …}``).
Both 1 and 2 are spec-level declarations; the spec always wins.
3. Global config ``~/.omnigent/config.yaml`` ``auth:`` block —
**only consulted when the spec declares no auth at all** (neither
new nor legacy style). This prevents the user's global default
from silently overriding a YAML that uses the old profile field.
4. Auto-Databricks: ``databricks-`` / ``databricks/`` model prefix
with no auth → fall back to the SDK ``"DEFAULT"`` profile so
ambient OPENAI_API_KEY doesn't short-circuit Databricks routing.
:param spec: The agent spec.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`. May
be empty when no auth is configured and the model name does not
start with ``databricks-``; in that case the wrap falls back to
``OPENAI_BASE_URL`` / ``OPENAI_API_KEY`` env vars and the
``use_responses=True`` default.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_OPENAI_AGENTS_MODEL"] = model
# ── Auth resolution ────────────────────────────────────────────────
# Priority: generic provider → spec.executor.auth → global config auth →
# legacy profile in config dict → auto-Databricks for databricks-* models.
#
# 0. Generic provider — spec.executor.auth: {type: provider, name: X},
# OR (no spec auth) the per-family global default. Sets API key /
# base URL / model and maps the openai family's wire_api to
# USE_RESPONSES. No ucode enrichment (no Databricks profile to look
# up), so it returns early. A spec's explicit ``use_responses`` still
# wins over the provider's wire_api.
provider = _resolve_provider_for_build(spec, harness_type="openai-agents-sdk", for_launch=True)
if provider is not None:
configure_agent_harness_with_provider(env, provider, harness_type="openai-agents-sdk")
use_responses = spec.executor.config.get("use_responses")
if use_responses is not None:
env["HARNESS_OPENAI_AGENTS_USE_RESPONSES"] = "true" if use_responses else "false"
return env
# Global config auth is only consulted when the spec declares NO
# auth at all — neither the new executor.auth block nor the legacy
# executor.profile / executor.config["profile"]. A spec that uses
# the old profile style is still an explicit spec-level auth
# declaration and must not be silently overridden by the user's
# global default (which may be a different auth type entirely).
#
# ProviderAuth is fully handled by the early-return block above (it
# resolves a provider or fails loud), so spec.executor.auth is narrowed
# to ApiKeyAuth / DatabricksAuth / None here.
spec_auth = spec.executor.auth
auth: ApiKeyAuth | DatabricksAuth | None = (
spec_auth if isinstance(spec_auth, (ApiKeyAuth, DatabricksAuth)) else None
)
_spec_has_legacy_profile = bool(spec.executor.profile or spec.executor.config.get("profile"))
if auth is None and not _spec_has_legacy_profile:
auth = _load_global_auth()
if isinstance(auth, ApiKeyAuth):
env["HARNESS_OPENAI_AGENTS_API_KEY"] = auth.api_key
if auth.base_url:
env["HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL"] = auth.base_url
elif isinstance(auth, DatabricksAuth):
env["HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE"] = auth.profile
else:
# Legacy path: executor.config["profile"] (deprecated — use executor.auth instead).
# DEPRECATED: config["profile"] will be removed once all specs migrate to auth:.
profile = spec.executor.config.get("profile")
if not profile and model and model.startswith(("databricks-", "databricks/")):
# databricks- / databricks/ prefix: route to Databricks (avoiding the
# OPENAI_API_KEY short-circuit) via the SDK's DEFAULT profile. The
# ambient DATABRICKS_CONFIG_PROFILE env var is deliberately NOT
# consulted — credentials are controlled by the spec or by
# `omnigent setup` provider config, never by shell environment.
profile = "DEFAULT"
if profile:
# Single canonical env var: ``DATABRICKS_PROFILE``. No
# ``GATEWAY=true`` gate (unlike claude-sdk / codex /
# pi) because :class:`OpenAIAgentsSDKExecutor` takes the
# profile name directly and resolves credentials itself —
# a separate truthy gate would be dead surface.
env["HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE"] = str(profile)
# Resolve the effective profile for ucode state lookup (model/base-URL enrichment).
# For api_key auth there is no profile to look up, so ucode enrichment is skipped.
ucode_profile: str | None = None
if isinstance(auth, DatabricksAuth):
ucode_profile = auth.profile
elif "HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE" in env:
ucode_profile = env["HARNESS_OPENAI_AGENTS_DATABRICKS_PROFILE"]
use_responses = spec.executor.config.get("use_responses")
if use_responses is not None:
env["HARNESS_OPENAI_AGENTS_USE_RESPONSES"] = "true" if use_responses else "false"
configure_agent_harness_with_ucode(
env,
ucode_profile,
harness_type="openai-agents-sdk",
)
return env
def _build_cursor_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the ``HARNESS_CURSOR_*`` env-var dict the cursor harness wrap reads.
Maps spec.executor fields → the ``HARNESS_CURSOR_*`` env vars defined
in ``omnigent/inner/cursor_harness.py``. Unlike the gateway-backed
builders (claude-sdk / codex / pi / openai-agents), there is NO gateway or
Databricks-profile resolution: the Cursor SDK talks only to Cursor's own
backend (``CURSOR_API_KEY``) and has no custom API base-URL override, so it
never routes through the Databricks AI gateway. That is also why cursor is
intentionally absent from :data:`AgentHarnessType` and the gateway/ucode
dicts above.
Auth: an explicit ``executor.auth: {type: api_key, api_key: ...}`` is
forwarded as ``HARNESS_CURSOR_API_KEY`` (the cursor harness passes it to the
Cursor SDK as its ``api_key``). When the spec declares no auth at all, a
``CURSOR_API_KEY`` registered once via ``omnigent setup`` (the dedicated
``cursor:`` config block — see :mod:`omnigent.onboarding.cursor_auth`) is
used instead, so a user need not export it in every shell. With neither, the
harness falls back to an inherited ``CURSOR_API_KEY`` — a ``DatabricksAuth``
profile does not apply to cursor and is ignored.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path, threaded as
``HARNESS_CURSOR_BUNDLE_DIR``.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_CURSOR_MODEL"] = model
# Auth precedence: an explicit api-key auth on the spec wins; with NO spec
# auth at all, fall back to a CURSOR_API_KEY registered once via
# ``omnigent setup`` (the dedicated ``cursor:`` config block), else an
# ambient CURSOR_API_KEY (an exported key / a host launched with one). A
# Databricks / provider auth has no cursor equivalent and never silently
# adopts a stored or ambient cursor key.
if isinstance(spec.executor.auth, ApiKeyAuth):
env["HARNESS_CURSOR_API_KEY"] = spec.executor.auth.api_key
elif spec.executor.auth is None:
# Imported lazily — the onboarding layer pulls in the secret store /
# keyring, which the hot spawn-env path shouldn't import eagerly.
from omnigent.onboarding.cursor_auth import resolve_cursor_api_key
stored_key = resolve_cursor_api_key()
if stored_key:
env["HARNESS_CURSOR_API_KEY"] = stored_key
else:
ambient_key = os.environ.get("CURSOR_API_KEY")
if ambient_key and ambient_key.strip():
env["HARNESS_CURSOR_API_KEY"] = ambient_key.strip()
# Always set so the wrap doesn't fall back to ``"all"`` and override an
# explicit ``skills: none`` from the spec (parity with the peer builders).
env["HARNESS_CURSOR_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
if spec.name:
env["HARNESS_CURSOR_AGENT_NAME"] = spec.name
if workdir is not None:
env["HARNESS_CURSOR_BUNDLE_DIR"] = str(workdir)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_CURSOR_OS_ENV"] = os_env_payload
return env
def _build_kimi_spawn_env(
spec: AgentSpec,
*,
cwd: Path | None = None,
) -> dict[str, str]:
"""Build the env-var dict the kimi harness wrap reads.
Maps ``spec.executor`` fields → the ``HARNESS_KIMI_*`` env vars
defined in :mod:`omnigent.inner.kimi_harness`.
The upstream Kimi Code CLI has no per-spawn provider override flag
(no ``--config-file`` / ``--mcp-config-file``), so this builder
only threads the model, working directory, and ``os_env`` sandbox
spec. Provider routing for kimi lives in ``~/.kimi/config.toml``
and is managed out-of-band via ``kimi provider add``. Unlike the
sibling builders, ``_build_kimi_spawn_env`` never calls
:func:`configure_agent_harness_with_provider` (there is no env-var
surface to translate a provider into), so the rejection of declared
auth has to live here: a spec that declares an explicit
provider / Databricks / api_key auth raises directly so the user
understands why their auth didn't take effect rather than silently
routing through whatever default kimi already had.
:param spec: The agent spec.
:param cwd: Runtime working directory for the kimi subprocess — the
session workspace (the folder the user launched in), NOT the agent
bundle dir. Threaded as ``HARNESS_KIMI_CWD`` so kimi's tools operate on
the user's project rather than the /tmp bundle (upstream kimi has no
``--work-dir`` flag, so the subprocess ``cwd=`` is the only lever).
When unset, the harness wrap falls back to ``OMNIGENT_RUNNER_WORKSPACE``.
Mirrors :func:`_build_pi_spawn_env`'s ``cwd`` handling.
:returns: A dict of env-var overrides.
:raises OmnigentError: If the spec declares ``executor.auth`` —
upstream kimi has no per-spawn provider override, so the
declared auth cannot be honored and we fail loud rather than
launch against an unrelated ambient provider.
"""
if spec.executor.auth is not None:
raise OmnigentError(
"The 'kimi' harness does not support per-invocation provider / "
"auth injection: upstream kimi has no per-spawn config override "
"(no ``--config-file`` / ``--mcp-config-file``). Remove "
"``executor.auth`` from the spec and configure the provider once "
"via `kimi provider add` in ~/.kimi/config.toml, then pin the "
"resulting model id in the agent spec. Omnigent-side provider "
"injection is a deferred follow-up.",
code=ErrorCode.INVALID_INPUT,
)
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_KIMI_MODEL"] = model
if cwd is not None:
env["HARNESS_KIMI_CWD"] = str(cwd)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_KIMI_OS_ENV"] = os_env_payload
return env
def _build_antigravity_spawn_env(spec: AgentSpec) -> dict[str, str]:
"""
Map ``spec.executor`` fields → the ``HARNESS_ANTIGRAVITY_*`` env vars the
antigravity harness wrap reads.
Antigravity is Gemini-native with no OpenAI-compatible ``base_url``, so there
is no gateway / ucode / Databricks path — only a direct API key or Vertex AI.
API-key resolution (first wins): (1) spec ``executor.auth`` api_key; (2) the
dedicated ``antigravity:`` config block from ``omnigent setup``; (3) an
ambient ``GEMINI_API_KEY`` / ``ANTIGRAVITY_API_KEY``. The legacy global
``auth:`` block is deliberately NOT consulted: it carries the OpenAI/gateway
key the other SDK harnesses inherit (an ``sk-…`` key), which the Gemini-native
SDK can't use — adopting it would guarantee an auth failure / mis-billing and
shadow the user's ambient ``GEMINI_API_KEY``. Any ``base_url`` is dropped (the
SDK has no such field). Vertex AI is opt-in via ``executor.config``
vertex/project/location, independent of the key path. A ``DatabricksAuth`` is
unsupported — warned and ignored.
:param spec: The agent spec.
:returns: Env-var overrides; may be empty (the wrap then uses the SDK's
ambient creds and default model).
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_ANTIGRAVITY_MODEL"] = model
spec_auth = spec.executor.auth
if spec_auth is not None and not isinstance(spec_auth, ApiKeyAuth):
# Non-api_key auth implies gateway/base_url routing the SDK can't do.
# Warn (don't drop silently — that looks like "my auth didn't take").
_logger.warning(
"antigravity harness: spec executor.auth is %s, but the Antigravity "
"SDK only supports a direct API key or Vertex AI. Ignoring it and "
"falling back to ambient Gemini credentials — configure an api_key, "
"or executor.config vertex/project/location, instead.",
type(spec_auth).__name__,
)
# Spec api-key wins; with no spec auth, fall back to the dedicated
# ``antigravity:`` block, then an ambient Gemini key (see docstring). The
# global ``auth:`` block is intentionally NOT consulted — it holds the
# OpenAI/gateway key the SDK can't use. A non-api-key auth never adopts a key.
if isinstance(spec_auth, ApiKeyAuth):
# base_url intentionally dropped — the SDK has no such field.
env["HARNESS_ANTIGRAVITY_API_KEY"] = spec_auth.api_key
elif spec_auth is None:
# Lazy import — the onboarding layer pulls in the secret store / keyring.
from omnigent.onboarding.antigravity_auth import (
ANTIGRAVITY_ENV_VARS,
resolve_antigravity_api_key,
)
stored_key = resolve_antigravity_api_key()
if stored_key is not None:
env["HARNESS_ANTIGRAVITY_API_KEY"] = stored_key
else:
for _env_var in ANTIGRAVITY_ENV_VARS:
if os.environ.get(_env_var):
env["HARNESS_ANTIGRAVITY_API_KEY"] = os.environ[_env_var]
break
# Vertex AI: opt-in via executor.config (authenticated by GCP ADC).
config = spec.executor.config
if config.get("vertex"):
env["HARNESS_ANTIGRAVITY_VERTEX"] = "1"
project = config.get("project")
if project:
env["HARNESS_ANTIGRAVITY_PROJECT"] = str(project)
location = config.get("location")
if location:
env["HARNESS_ANTIGRAVITY_LOCATION"] = str(location)
return env
def _build_copilot_spawn_env(
spec: AgentSpec,
*,
workdir: Path | None = None,
) -> dict[str, str]:
"""
Build the ``HARNESS_COPILOT_*`` env-var dict the copilot harness wrap reads.
Maps spec.executor fields → the ``HARNESS_COPILOT_*`` env vars defined in
``omnigent/inner/copilot_harness.py``. Like the cursor / antigravity
builders there is NO gateway or Databricks-profile resolution: the GitHub
Copilot SDK talks only to GitHub's Copilot backend (a GitHub token) and has
no custom API base-URL override, so it never routes through the Databricks
AI gateway. That is also why copilot is intentionally absent from
:data:`AgentHarnessType` and the gateway/ucode dicts above.
Auth: an explicit ``executor.auth: {type: api_key, api_key: ...}`` carries
the GitHub token, forwarded as ``HARNESS_COPILOT_GITHUB_TOKEN`` (the copilot
harness passes it to the SDK as its ``github_token``). When the spec declares
no auth at all, a GitHub token registered once via ``omnigent setup`` (the
dedicated ``copilot:`` config block — see
:mod:`omnigent.onboarding.copilot_auth`) is used instead, so a user need not
export it in every shell. With neither, the harness falls back to an
inherited ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN`` — a
``DatabricksAuth`` profile does not apply to copilot and is ignored.
:param spec: The agent spec.
:param workdir: The bundle's on-disk path, threaded as
``HARNESS_COPILOT_BUNDLE_DIR``.
:returns: A dict of env-var overrides for
:meth:`HarnessProcessManager.get_client(env=...)`.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_COPILOT_MODEL"] = model
# Auth precedence: an explicit api-key auth on the spec wins (its ``api_key``
# is the GitHub token); with NO spec auth at all, fall back to a token
# registered once via ``omnigent setup`` (the dedicated ``copilot:`` config
# block), else an ambient ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` /
# ``GITHUB_TOKEN``. A Databricks / provider auth has no copilot equivalent
# and never silently adopts a stored or ambient copilot token.
if isinstance(spec.executor.auth, ApiKeyAuth):
env["HARNESS_COPILOT_GITHUB_TOKEN"] = spec.executor.auth.api_key
elif spec.executor.auth is None:
# Imported lazily — the onboarding layer pulls in the secret store /
# keyring, which the hot spawn-env path shouldn't import eagerly.
from omnigent.onboarding.copilot_auth import (
COPILOT_TOKEN_ENV_VARS,
resolve_copilot_github_token,
)
stored_token = resolve_copilot_github_token()
if stored_token is not None:
env["HARNESS_COPILOT_GITHUB_TOKEN"] = stored_token
else:
for _env_var in COPILOT_TOKEN_ENV_VARS:
if os.environ.get(_env_var):
env["HARNESS_COPILOT_GITHUB_TOKEN"] = os.environ[_env_var]
break
# Always set so the wrap doesn't fall back to ``"all"`` and override an
# explicit ``skills: none`` from the spec (parity with the peer builders).
env["HARNESS_COPILOT_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
if spec.name:
env["HARNESS_COPILOT_AGENT_NAME"] = spec.name
if workdir is not None:
env["HARNESS_COPILOT_BUNDLE_DIR"] = str(workdir)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_COPILOT_OS_ENV"] = os_env_payload
return env
def _serialize_os_env(value: OSEnvSpec | None) -> str | None:
"""
Encode an :class:`OSEnvSpec` for the wrap's env-var input.
JSON-encodes :func:`dataclasses.asdict` of the OSEnvSpec so
the wrap can :func:`json.loads` it back on the harness side
(per the per-spawn env-var pattern from §Step 5a). When
*value* is ``None`` (no os_env declared on the spec), this
returns ``None`` and ``_build_claude_sdk_spawn_env`` omits
the env var entirely — the wrap then falls back to its
enable-natives-by-default rule.
:param value: ``spec.os_env`` — an :class:`OSEnvSpec`
instance or ``None``.
:returns: JSON string encoding the OSEnvSpec, or ``None``
when *value* is ``None``.
"""
import dataclasses
if value is None:
return None
return json.dumps(dataclasses.asdict(value))
def _serialize_retry_policy(value: RetryPolicy | None) -> str | None:
"""
Encode a :class:`RetryPolicy` for the wrap's env-var input.
Phase 1f of ``designs/RETRY_ACROSS_HARNESSES.md``: the spec's
``LLMConfig.retry`` must take effect inside the CLI-harness
subprocess. Serializing the whole policy as one JSON env var
keeps the wire format compact (~150 bytes) and lets the
harness round-trip via :func:`json.loads` →
``RetryPolicy(**dict)``. JSON over a flat fan-out of
``HARNESS_*_RETRY_MAX_RETRIES`` / ``..._BACKOFF_BASE_S`` /
etc. because (a) ``RetryPolicy`` has 6 fields including a
tuple, and a flat fan-out would multiply boilerplate at
every wrap; (b) future field additions stay
backwards-compatible — older wraps just ignore unknown
keys via the ``__init__`` filter below.
Returns ``None`` when the policy matches
``RetryPolicy()`` defaults so the env var is omitted —
saves the harness wrap an unnecessary parse step on the
common path.
:param value: ``llm_config.retry`` — a :class:`RetryPolicy`
or ``None``. ``None`` and ``RetryPolicy()`` (defaults)
both produce ``None`` so the wrap falls back to the
same baked-in default.
:returns: JSON string encoding the policy's fields, or
``None`` when *value* is ``None`` or matches the
defaults.
"""
import dataclasses
import json
if value is None or value == RetryPolicy():
return None
payload = dataclasses.asdict(value)
# ``retryable_status_codes`` is a tuple in the dataclass;
# ``asdict`` converts it to a list. JSON has no tuple type —
# the harness side reconstructs the tuple in
# ``_deserialize_retry_policy``.
return json.dumps(payload)
def _resolve_retry_policy(spec: AgentSpec) -> RetryPolicy | None:
"""
Read the retry policy off a spec.
Used by the per-harness ``_build_*_spawn_env`` builders to
decide whether to thread a ``HARNESS_*_RETRY_POLICY`` env
var through to the subprocess. Returns ``None`` when the
spec has no ``llm`` block (e.g. a CLI-harness spec where
the retry policy is implicit) so the harness wrap falls
back to its baked-in :class:`RetryPolicy()` default.
:param spec: The agent spec.
:returns: ``spec.llm.retry`` if set; ``None`` otherwise.
"""
if spec.llm is None:
return None
return spec.llm.retry
# ── Responses API helpers ─────────────────────────────────
def _apply_request_model_override(
llm_config: LLMConfig,
model_override: str | None,
) -> LLMConfig:
"""
Substitute the per-request LLM model into the agent's LLM config.
The override is also stashed in ``extra["model_override"]`` so the
harness path can forward it to the subprocess — the harness body's
``model`` field carries the agent name, distinct from the LLM
model. Returns a new :class:`LLMConfig`; original is not mutated.
:param llm_config: The agent spec's LLM config (already merged
with any per-request reasoning override).
:param model_override: Per-request LLM model identifier, e.g.
``"databricks-claude-sonnet-4-6"``, or ``None`` to pass
``llm_config`` through unchanged.
:returns: A (possibly new) :class:`LLMConfig`.
"""
if model_override is None:
return llm_config
merged_extra = {**llm_config.extra, "model_override": model_override}
return LLMConfig(
model=model_override,
extra=merged_extra,
connection=llm_config.connection,
request_timeout=llm_config.request_timeout,
retry=llm_config.retry,
)
# ── Checkpointed steps ───────────────────────────────────
# ── Executor @step wrapper ────────────────────────────────
# Maps executor reasoning event_type to SSE event type string.
# ── Executor turn → response dict bridge ──────────────────
def _prepare_messages(
spec: AgentSpec,
llm_config: LLMConfig,
history: list[ConversationItem],
instructions: str | None,
tool_schemas: list[dict[str, Any]],
compaction_state: _CompactionState,
content_cache: dict[str, str] | None,
*,
conversation_id: str | None = None,
) -> tuple[str, list[dict[str, Any]], int]:
"""
Build system instructions and Responses API input items.
Resolves content references and counts system token budget.
Extracted from ``_call_llm_maybe_compact`` for reuse by the
executor path.
:param spec: The parsed AgentSpec.
:param llm_config: LLM configuration.
:param history: Conversation history as persisted items.
:param instructions: Optional per-request instructions.
:param tool_schemas: OpenAI-format tool schemas.
:param compaction_state: Per-execution compaction state.
:param content_cache: Per-task content reference cache.
:param conversation_id: Optional owning conversation/session id
used to verify session-scoped file ownership.
:returns: Tuple of (system_instructions, messages, sys_tokens).
"""
sys_instructions = build_instructions(spec, instructions, tool_schemas)
file_store = get_file_store()
artifact_store = get_artifact_store()
resolved = history
if file_store is not None and artifact_store is not None:
resolved = resolve_content_references(
history,
file_store,
artifact_store,
content_cache,
session_id=conversation_id,
)
messages = history_to_input_items(resolved)
sys_tokens = count_tokens(
[{"role": "system", "content": sys_instructions}],
compaction_state.model,
)
return sys_instructions, messages, sys_tokens
# ── Output helpers ────────────────────────────────────────
# ── Pagination helper ─────────────────────────────────────
def fetch_all_items(
conv_store: ConversationStore,
conversation_id: str,
after: str | None = None,
) -> list[ConversationItem]:
"""
Fetch all conversation items starting after the given
cursor, paginating through every page until ``has_more``
is ``False``.
:param conv_store: The ConversationStore to query.
:param conversation_id: The conversation to fetch items
from, e.g. ``"conv_abc123"``.
:param after: Cursor item ID to start after, or ``None``
to fetch from the beginning.
:returns: All items in chronological order after the
cursor.
"""
all_items: list[ConversationItem] = []
cursor = after
while True:
page = conv_store.list_items(conversation_id, after=cursor)
all_items.extend(page.data)
if not page.has_more:
break
# Advance cursor to the last item of this page
cursor = page.last_id
return all_items
# ── Extracted helpers ─────────────────────────────────────
def _strip_mcp_tool_prefix(name: str) -> str:
"""Strip ``mcp__<server>__`` prefix from *name*; preserve bare ``__``."""
if name.startswith("mcp__"):
parts = name.split("__", 2)
if len(parts) == 3:
return parts[2]
return name
# ─── Async tool dispatch ───────────────────────────────────
@dataclass
class _AsyncToolHandle:
"""
Handle returned to the LLM when an async tool is dispatched.
Replaces the inline tool result string from a sync invocation.
The LLM gets back a structured task handle and waits for
auto-delivery (D7/G12); explicit polling was dropped per
design step 11.
:param task_id: The newly created task's ID, e.g.
``"tsk_async_xyz"``. Identical to the
the dispatching task's id (G56).
:param tool_name: The dispatched tool's name, included in the
handle so the LLM can correlate the handle to its own
tool_calls field.
:param status: Always ``"in_progress"`` at handle-creation
time — terminal status arrives via the
``async_work_complete`` signal.
:param message: A self-explanatory instruction for the LLM
(G12). Names the task_id explicitly so the LLM can
copy-paste it into ``sys_cancel_task`` if it wants
to abort.
"""
task_id: str
tool_name: str
status: str
message: str
def to_handle_json(self) -> str:
"""
Serialize the handle as JSON for the tool-call return path.
The runner contract returns strings, so the handle ships
as a JSON-encoded dict. The LLM treats the result like
any other tool output.
:returns: JSON string with ``task_id``, ``tool_name``,
``status``, and ``message`` keys.
"""
return json.dumps(
{
"task_id": self.task_id,
"tool_name": self.tool_name,
"status": self.status,
"message": self.message,
}
)
def _async_handle_message(task_id: str, tool_name: str) -> str:
"""
Build the LLM-facing instruction text on a fresh async handle.
Every word here is load-bearing — the message is the LLM's
only signal that the result is NOT in this string. Without
"asynchronous" + "auto-deliver" + the literal task_id, the
LLM tends to either treat the handle as the result and
hallucinate completion, or repeatedly poll for status.
:param task_id: The async task's ID, included verbatim so
the LLM can pass it to ``sys_cancel_task`` if it wants
to abort.
:param tool_name: The dispatched tool's name.
:returns: A compact instruction string.
"""
return (
f"Tool {tool_name!r} dispatched asynchronously. "
f"The result will be auto-delivered as a system message "
f"when ready. To abort, call sys_cancel_task with "
f"task_id={task_id!r}."
)
# ── Phase 6: TOOL_CALL / TOOL_RESULT / OUTPUT enforcement ───
# Builtin tools that dispatch a sub-agent by its declared
# ``tools.agents`` name. Listed here so the TOOL_CALL enforcement
# site can narrow ``tool_name`` to the sub-agent's declared name
# for ``match_tools`` matching — YAML authors think of a sub-agent
# as a tool named after its ``tools.agents`` entry, not as a call
# to the generic spawn / send builtin. Names come from the tool
# classes' ``.name()`` classmethods so a rename in one of them
# can't silently desync this constant.
def _find_latest_compaction_item(
conv_store: ConversationStore,
conversation_id: str,
) -> ConversationItem | None:
"""
Return the most recently appended compaction item for a
conversation, or ``None`` if none exists.
Uses a descending ``limit=1`` query so only one row is read
regardless of total conversation length.
:param conv_store: The ConversationStore to query.
:param conversation_id: The conversation to search,
e.g. ``"conv_abc123"``.
:returns: The latest compaction item, or ``None``.
"""
page = conv_store.list_items(
conversation_id,
type="compaction",
order="desc",
limit=1,
)
return page.data[0] if page.data else None
@dataclass(frozen=True)
class _LoadedHistory:
"""
Result of :func:`_load_initial_history`.
Bundles the conversation items with metadata about the latest
compaction so callers can make decisions (e.g. seed invalidation)
without re-querying the store.
:param items: History items ready for prompt construction.
May begin with a synthetic summary pair from
:func:`compaction_to_history_items`.
:param last_compaction_created_at: ``created_at`` of the latest
compaction item used as a history cursor, or ``None`` when
no valid compaction item exists.
"""
items: list[ConversationItem]
last_compaction_created_at: int | None = None
def _load_initial_history(
conv_store: ConversationStore,
conversation_id: str,
) -> _LoadedHistory:
"""
Load the conversation history for the start of an execution.
When a compaction item exists, only the items AFTER the
summary's coverage boundary are loaded — the synthetic
summary pair replaces the older items the LLM does not need
to see verbatim. This bounds the load to O(items since last
compaction), not O(total conversation length).
When no compaction item exists, the full conversation is
loaded (existing behaviour).
:param conv_store: The ConversationStore to query.
:param conversation_id: The conversation to load,
e.g. ``"conv_abc123"``.
:returns: A :class:`_LoadedHistory` with items ready for
prompt construction and the latest compaction timestamp.
"""
compaction_item = _find_latest_compaction_item(conv_store, conversation_id)
if compaction_item is None:
return _LoadedHistory(
items=[
item
for item in fetch_all_items(conv_store, conversation_id)
if item.type not in NON_CONTENT_ITEM_TYPES
]
)
assert isinstance(compaction_item.data, CompactionData)
# Validate the compaction item before trusting it as a cursor.
# A broken item (empty summary, missing/bogus last_item_id) would
# make the after= query return nothing, leaving history as just
# the empty synthetic pair — the Omnigent thinks context is ~0 tokens
# and never triggers compaction while the executor's real context
# keeps growing.
last_id = compaction_item.data.last_item_id
if not compaction_item.data.summary or not last_id or last_id.startswith("synthetic_"):
_logger.warning(
"Ignoring broken compaction item %s for %s: empty summary or bogus last_item_id=%r",
compaction_item.id,
conversation_id,
last_id,
)
return _LoadedHistory(
items=[
item
for item in fetch_all_items(conv_store, conversation_id)
if item.type not in NON_CONTENT_ITEM_TYPES
]
)
# Load after last_item_id, NOT after the compaction item itself.
# The compaction item may be appended after additional output
# items that the summary does not cover — using last_item_id
# ensures those post-summary items are included.
recent_items = fetch_all_items(
conv_store,
conversation_id,
after=compaction_item.data.last_item_id,
)
# Filter metadata items — they are not conversation content the
# LLM should receive verbatim.
content_items = [i for i in recent_items if i.type not in NON_CONTENT_ITEM_TYPES]
return _LoadedHistory(
items=compaction_to_history_items(compaction_item) + content_items,
last_compaction_created_at=compaction_item.created_at,
)
async def compact_conversation_now(
*,
task_id: str,
conversation_id: str,
spec: AgentSpec,
llm_config: LLMConfig,
instructions: str | None = None,
tool_schemas: list[dict[str, Any]] | None = None,
model_override: str | None = None,
preserve_recent_window: int | None = None,
) -> CompactionResult:
"""
Force a compaction pass for an existing conversation.
This is the runtime entry point used by the sessions ``/compact``
control event. It builds the same prompt input that a normal agent
iteration would use, runs compaction with ``force=True`` so Layer 2
summarisation is attempted even below the automatic threshold, and
persists a compaction item when a summary is produced.
:param task_id: Synthetic response id for this compaction operation.
:param conversation_id: Conversation/session id to compact.
:param spec: Agent spec bound to the conversation.
:param llm_config: Effective LLM config used for summarisation.
:param instructions: Optional per-request instructions.
:param tool_schemas: Tool schemas to include in system-token budget.
:param model_override: Optional model override. Applied to
``llm_config`` before counting and summarising.
:param preserve_recent_window: Override for how many recent LLM
response groups to keep verbatim. ``/compact`` passes ``1``;
this is mapped to the compaction helper's inclusive boundary
semantics below so the explicit command reduces context immediately.
``None`` uses the agent's configured compaction policy.
:returns: The :class:`CompactionResult` returned by
:func:`omnigent.runtime.compaction.compact`.
"""
conv_store = get_conversation_store()
loaded = _load_initial_history(conv_store, conversation_id)
history = loaded.items
if not history:
return CompactionResult(messages=[], summary_metadata=None)
effective_llm_config = _apply_request_model_override(llm_config, model_override)
effective_llm_config = _route_bare_model_for_compaction(effective_llm_config)
compaction_config = spec.compaction
if preserve_recent_window is not None:
# The compaction helper's boundary is inclusive: recent_window=1
# protects the latest assistant/function-call boundary itself, which
# often means preserving the entire final turn. For explicit /compact,
# use 0 to summarize through the latest completed item while still
# keeping the command opt-in/user-driven.
from omnigent.spec.types import CompactionConfig
trigger_threshold = compaction_config.trigger_threshold if compaction_config else 0.8
compaction_config = CompactionConfig(
trigger_threshold=trigger_threshold,
recent_window=max(preserve_recent_window - 1, 0),
)
compaction_state = _CompactionState(
context_window=None,
last_summary=None,
config=compaction_config,
model=effective_llm_config.model,
connection=effective_llm_config.connection,
conversation_id=conversation_id,
)
_sys_instructions, messages, sys_tokens = _prepare_messages(
spec,
effective_llm_config,
history,
instructions,
tool_schemas or [],
compaction_state,
content_cache={},
conversation_id=conversation_id,
)
from omnigent.llms.context_window import get_model_context_window
context_window = get_model_context_window(effective_llm_config.model)
result = await compact(
messages,
history,
config=compaction_state.config,
context_window=context_window,
system_token_budget=sys_tokens,
model=compaction_state.model,
task_id=task_id,
llm_client=_get_llm_client(),
connection=compaction_state.connection,
runner_client=_get_runner_client_for_compaction(conversation_id),
force=True,
fail_on_summary_error=True,
conversation_id=conversation_id,
)
if result.summary_metadata is None:
raise OmnigentError(
"Compaction did not produce a persisted summary. The conversation was "
"left unchanged; check server logs for the summarization failure.",
code=ErrorCode.INTERNAL_ERROR,
)
_maybe_persist_compaction_item(
result.summary_metadata,
task_id,
conversation_id,
conv_store,
)
return result
def _route_bare_model_for_compaction(llm_config: LLMConfig) -> LLMConfig:
"""
Prefix bare model ids so compaction's generic client picks the right provider.
Normal harness execution infers the provider from the harness (e.g.
``claude-sdk`` → Anthropic, ``openai-agents`` → Databricks/OpenAI).
Explicit ``/compact`` instead uses the generic runtime LLM client,
whose :func:`~omnigent.llms.routing.parse_model_string` defaults any
prefix-less id to OpenAI — so a bare ``databricks-*`` or Anthropic
``claude-*`` id gets sent to ``api.openai.com`` and the summarization
call fails with a 500 (issue #1950). Already-prefixed ids
(``anthropic/…``, ``openai/…``) and bare ``gpt-*`` (correctly OpenAI)
are left untouched.
:param llm_config: Effective LLM config for the session.
:returns: ``llm_config`` unchanged, or a copy with a provider-prefixed model.
"""
model = llm_config.model
if "/" in model:
# Already provider-prefixed (e.g. "anthropic/claude-…") — trust it.
return llm_config
if model.startswith("databricks-"):
return replace(llm_config, model=f"databricks/{model}")
if model.startswith("claude-"):
# Bare Anthropic id (e.g. "claude-haiku-4-5-20251001") — route to
# Anthropic instead of the prefix-less OpenAI default.
return replace(llm_config, model=f"anthropic/{model}")
# ponytail: only databricks + anthropic here — the /compact failures seen
# in the wild. Other bare non-OpenAI prefixes (deepseek-, moonshot-, …)
# would need the same nudge if they ever surface.
return llm_config
def _maybe_persist_compaction_item(
summary: SummaryMetadata,
task_id: str,
conversation_id: str,
conv_store: ConversationStore,
) -> None:
"""
Persist a compaction item for the current execution, unless one
already exists (idempotent append for crash-recovery safety).
The ``response_id`` on the item is the task ID, which is unique
per execution. On replay the runner re-emits any pending tail —
the check-before-write prevents a duplicate compaction item from
being appended.
:param summary: The :class:`SummaryMetadata` from Layer 2.
:param task_id: The task identifier used as the item's
``response_id``, e.g. ``"task_abc123"``.
:param conversation_id: The conversation to append to,
e.g. ``"conv_abc123"``.
:param conv_store: The ConversationStore to append to.
"""
# Guard: never persist a broken compaction item. An empty summary
# or a missing last_item_id would poison the history cursor so
# _load_initial_history returns an empty history on every
# subsequent turn — the executor keeps running on its internal
# session while the Omnigent thinks context is near-zero and never
# triggers compaction again.
if (
not summary.text
or not summary.last_item_id
or summary.last_item_id.startswith("synthetic_")
):
_logger.warning(
"Skipping compaction persist for task %s: empty summary "
"or bogus last_item_id (text=%r, last_item_id=%r)",
task_id,
summary.text[:80] if summary.text else None,
summary.last_item_id,
)
return
existing = conv_store.list_items(
conversation_id,
type="compaction",
order="desc",
limit=1,
)
if existing.data and existing.data[0].response_id == task_id:
# Already persisted — idempotent on crash recovery replay.
return
conv_store.append(
conversation_id,
[
NewConversationItem(
type="compaction",
response_id=task_id,
data=CompactionData(
summary=summary.text,
last_item_id=summary.last_item_id,
model=summary.model,
token_count=summary.token_count,
),
)
],
)
# ── await_tool_output implementation ───────────────────────
# ── The agent loop ────────────────────────────────────────
def _find_spec_by_name(
spec: AgentSpec,
name: str,
) -> AgentSpec | None:
"""
Resolve a sub-agent spec by name within the parent spec tree.
Recursively searches ``spec.sub_agents`` (names are validated unique
across the tree, so at most one matches).
The one exception is the built-in ``__web_researcher`` sub-agent that
backs the ``web_fetch`` tool: ``WebFetchTool`` synthesizes its spec in
memory and appends it to the parent's live ``sub_agents`` list, but
that spec is never serialized into the parent's persisted bundle. A
child ``__web_researcher`` session boots by re-parsing the bundle
fresh (``runner/_entry.py`` spec resolver), so the researcher is
absent from the re-parsed tree and a plain search returns ``None``.
Every caller swaps to the resolved sub-spec only ``if ... is not
None`` and otherwise keeps the parent spec, which boots the child as
a full clone of the parent (runaway recursion via ``sys_session_send``
when the parent is a coordinator). To keep that fallback safe, the
researcher is reconstructed deterministically from the parent (the
same pure builder ``WebFetchTool`` uses) instead of returning ``None``,
but only when some node in the tree actually declares the ``web_fetch``
builtin. That builtin is the sole reason the researcher ever exists, so a
tree without it anywhere has no such child and the name falls through to
normal resolution (``None``). Reconstructing unconditionally would let a
caller-controlled ``sub_agent_name`` coerce any parent into a
shell-capable researcher (``build_researcher_spec`` synthesizes an
``OSEnvSpec``), widening the parent's tool boundary.
The owning node need not be the root: a nested sub-agent may own
``web_fetch`` while the handed-in root does not. The gate locates the
``web_fetch`` owner via a root-first pre-order walk
(:func:`_find_web_fetch_owner`) and reconstructs from THAT owner, not the
root, so the researcher inherits the owner's LLM and sandbox/egress
boundary (``build_researcher_spec`` derives both from its argument). When
several nodes own ``web_fetch`` the first pre-order owner wins; this is a
deliberate limitation tied to the ``__web_researcher`` name not being
unique per owner (plumbing an "effective parent" through every call site
is out of scope). The root-owner case is unchanged: the owner is the
root, so the output is identical to before.
:param spec: The root agent spec to search.
:param name: The sub-agent name to find,
e.g. ``"researcher"``.
:returns: The matching sub-agent spec, the reconstructed
``__web_researcher`` spec when the parent declares ``web_fetch``
and the name matches, or ``None`` otherwise.
"""
found = _search_sub_agent_tree(spec, name)
if found is not None:
return found
# Built-in web_fetch researcher: derived from its owner, never persisted
# in the bundle, so reconstruct it on a resolve-miss rather than letting
# callers fall back to the parent spec. Gated on some node in the tree
# declaring the ``web_fetch`` builtin, the only reason the researcher
# exists (``WebFetchTool.__init__`` appends it). The owner may be nested,
# so reconstruct from the owner (root-first pre-order) — never the root —
# to inherit the owner's LLM and sandbox/egress boundary. Imported lazily
# to keep the tools layer off this module's import path.
from omnigent.tools.builtins.web_fetch import RESEARCHER_NAME
if name == RESEARCHER_NAME:
owner = _find_web_fetch_owner(spec)
if owner is not None:
from omnigent.tools.builtins.web_fetch import build_researcher_spec
return build_researcher_spec(owner)
return None
def _find_web_fetch_owner(spec: AgentSpec) -> AgentSpec | None:
"""
Find the first node owning the ``web_fetch`` builtin, root-first.
Pre-order DFS (root, then children left-to-right), mirroring
:func:`_search_sub_agent_tree`. The ``web_fetch`` owner is the node whose
``tools.builtins`` carries an entry named ``web_fetch``; that node's spec
is the correct parent for ``build_researcher_spec`` (its LLM + sandbox).
:param spec: The agent spec whose sub-tree to search.
:returns: The first node (root-first) owning ``web_fetch``, or ``None``.
"""
if any(entry.name == "web_fetch" for entry in spec.tools.builtins):
return spec
for sa in spec.sub_agents:
owner = _find_web_fetch_owner(sa)
if owner is not None:
return owner
return None
def _search_sub_agent_tree(
spec: AgentSpec,
name: str,
) -> AgentSpec | None:
"""
Recursively search ``spec.sub_agents`` for a sub-agent named ``name``.
The pure tree search backing :func:`_find_spec_by_name`; kept separate
so the ``__web_researcher`` reconstruction in that function fires once
at the root rather than on every recursive frame.
:param spec: The agent spec whose sub-tree to search.
:param name: The sub-agent name to find.
:returns: The matching sub-agent spec, or ``None`` if not found.
"""
for sa in spec.sub_agents:
if sa.name == name:
return sa
found = _search_sub_agent_tree(sa, name)
if found is not None:
return found
return None