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

4157 lines
166 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Implementation of the ``omnigent chat`` command.
The CLI always ends by connecting an Omnigent client to a server URL. For
path targets it first ensures the agent is registered on that server
(a local subprocess by default, or ``--server`` when supplied). URL
targets skip setup and use the existing server's registered agents.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from collections.abc import Awaitable, Callable, Generator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, TypeAlias
import click
import httpx
import yaml
from omnigent_client import (
OmnigentClient,
SessionToolCallInfo,
ToolCallable,
ToolCallInfo,
ToolHandler,
)
from omnigent_client import (
OmnigentError as ClientOmnigentError,
)
from omnigent_client._events import (
ErrorEvent,
ResponseCancelled,
ResponseCompleted,
ResponseFailed,
ResponseIncomplete,
TextDelta,
)
from rich.console import Console
from omnigent._wrapper_labels import (
CLAUDE_NATIVE_WRAPPER_VALUE as _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE,
)
from omnigent._wrapper_labels import (
WRAPPER_LABEL_KEY as _CLAUDE_NATIVE_WRAPPER_LABEL_KEY,
)
from omnigent.conversation_browser import open_conversation_link_if_enabled
from omnigent.errors import OmnigentError
from omnigent.harness_aliases import canonicalize_harness
from omnigent.inner import _proc
from omnigent.inner.databricks_executor import _DatabricksBearerAuth, _read_databrickscfg
from omnigent.native_coding_agents import native_coding_agent_for_wrapper_label
from omnigent.spec import load as load_spec
from omnigent.spec._omnigent_compat import OMNIGENT_EXECUTOR_TYPE
from omnigent.spec.parser import discover_host_skills
from omnigent.spec.types import AgentSpec, SkillSpec
if TYPE_CHECKING:
from omnigent._runner_startup import RunnerStartupProgress
console = Console()
# YAML mapping shape — heterogeneous JSON-shaped values
# (strings, ints, lists, nested dicts) so ``Any`` is the
# narrowest safe element type. Used as the parsed-spec
# return / input shape across this module's helpers.
_YamlMapping: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
logger = logging.getLogger(__name__)
# Local server readiness polling: use a short initial interval so
# freshly-launched ``omnigent run`` sessions don't burn a
# fixed 500 ms before noticing the server is ready, then back off
# slightly while still remaining responsive on slower cold starts.
_SERVER_READY_INITIAL_POLL_SECONDS = 0.05
_SERVER_READY_BACKOFF_POLL_SECONDS = 0.1
_SERVER_READY_FAST_POLL_WINDOW_SECONDS = 1.0
# Remote ``--server`` runners are disposable subprocesses created for
# the CLI session. A one-second grace gives SIGTERM enough time to
# flush runner logs and unregister without noticeably slowing CLI exit.
# Grace period before the CLI escalates SIGTERM → SIGKILL on the
# runner subprocess. Must be long enough for the runner's shutdown
# chain to complete: cancel async tasks → app.router.shutdown() →
# _stop_pm() → _terminal_registry.shutdown() → tmux kill-server
# per session → pm.shutdown() → SIGTERM each harness. 1 s was too
# short — the runner was SIGKILL'd before tmux sessions were reaped,
# leaving zombie codex/claude processes.
_REMOTE_RUNNER_STOP_GRACE_SECONDS = 8.0
# Fallback model when the YAML declares neither ``executor.model``
# nor ``executor.harness`` AND no ``--model`` / ``--harness``
# override is supplied. Mirrors the legacy argparse CLI's
# ``_DEFAULT_AD_HOC_MODEL`` so ``omnigent run examples/hello_world.yaml``
# (a spec with no executor block) launches cleanly instead of
# failing the strict omnigent validator with a cryptic
# "executor.config.harness: required" error.
_DEFAULT_AD_HOC_MODEL = "databricks-gpt-5-4"
# How many of the NEWEST transcript items ``_persisted_turn_text``
# fetches when reconciling a headless ``-p`` turn against the durable
# store. The current turn's items are always the newest, and no single
# one-shot turn emits anywhere near this many items, so the latest turn
# is fully captured regardless of how long a resumed session's history
# is. Fetched ``order="desc"`` (newest first) precisely so the window
# tracks the end of the conversation, not its start.
_RECONCILE_ITEMS_LIMIT = 100
# Optional bearer token for remote omnigent servers that sit
# behind an auth proxy (for example Databricks Apps). When set, the
# CLI sends ``Authorization: Bearer <value>`` on every HTTP request it
# makes to the remote server.
_REMOTE_AUTH_TOKEN_ENV = "OMNIGENT_REMOTE_AUTH_TOKEN"
# Env-var override name. ``OMNIGENT_MODEL=foo`` lets a user
# pin a default model per shell session without needing to pass
# ``--model foo`` on every invocation. Resolved once at spec
# materialization time (not at runtime), so the materialized
# bundle stays self-contained — identical behavior on any host
# that runs the bundle, regardless of that host's env. Mirrors
# the legacy ``_default_cli_model`` at
# ``omnigent/inner/cli.py:344``.
_OMNIGENT_MODEL_ENV_VAR = "OMNIGENT_MODEL"
_OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY"
_OPENAI_BASE_URL_ENV_VAR = "OPENAI_BASE_URL"
_OPENAI_AGENTS_HARNESSES = frozenset({"openai-agents", "openai-agents-sdk"})
_MATERIALIZED_OVERRIDE_DIRS: dict[Path, Path] = {}
def _default_cli_model() -> str:
"""
Return the model used when neither YAML nor CLI flag picks one.
Reads ``OMNIGENT_MODEL`` from the environment with
:data:`_DEFAULT_AD_HOC_MODEL` as the final fallback. The read
happens at YAML-materialization time so the resolved model
gets baked into the bundle's executor block — the materialized
spec is self-contained and independent of any later env state.
Mirrors :func:`omnigent.inner.cli._default_cli_model` so
legacy and Omnigent paths agree on the env-var contract.
:returns: The default model identifier, e.g.
``"databricks-gpt-5-4"`` or whatever the user pinned in
``OMNIGENT_MODEL``.
"""
return os.environ.get(_OMNIGENT_MODEL_ENV_VAR, _DEFAULT_AD_HOC_MODEL)
@dataclass(frozen=True)
class ChatOverrides:
"""
CLI overrides from ``omnigent run`` flags.
Applied by materializing a rewritten copy of the agent YAML in a
temp dir and pointing the local server at that copy — the user's
source YAML is never mutated.
:param harness: ``--harness`` value, e.g. ``"claude-sdk"``.
``None`` leaves the YAML value unchanged. Written to the flat
``executor.harness`` key for single-file omnigent YAMLs and to
``executor.config.harness`` for ``spec_version`` bundles (the
only location that format's parser reads).
:param model: ``--model`` value, e.g.
``"databricks-claude-sonnet-4-6"``. ``None`` unchanged.
:param system_prompt: ``--system-prompt`` value — overrides the
YAML's top-level ``prompt`` field (mapped to
``AgentSpec.instructions`` by the adapter). ``None``
unchanged.
"""
harness: str | None = None
model: str | None = None
system_prompt: str | None = None
@property
def has_any(self) -> bool:
"""True when at least one override flag was supplied."""
return any(v is not None for v in (self.harness, self.model, self.system_prompt))
@dataclass(frozen=True)
class LocalServer:
"""
Handle to a locally-launched omnigent server and its sibling runner.
Returned by :func:`_start_local_server` so callers can pass the
handle to :func:`_wait_for_server`, :func:`_stop_local_server`,
and :func:`_raise_server_failed` without losing track of the
subprocess's stdout/stderr log path. The log path is the only
durable record of startup tracebacks (spec parse errors,
unresolved env vars, executor import failures), so the failure
helper surfaces it in its error message.
:param proc: The server subprocess handle.
:param log_path: Path to the file that captures the
subprocess's combined stdout/stderr stream,
e.g. ``Path("~/.omnigent/logs/server/server-abc123.log")``.
:param runner_id: Stable runner id expected to register over
the WebSocket tunnel, e.g. ``"runner_0123456789abcdef"``.
:param runner_proc: The runner subprocess handle, spawned as a
sibling of the server by :func:`_start_local_server`.
``None`` when no runner was started (shouldn't happen in
normal operation).
"""
proc: subprocess.Popen[bytes]
log_path: Path
runner_id: str | None = None
runner_proc: subprocess.Popen[bytes] | None = None
@dataclass(frozen=True)
class _SessionToolAdapter:
"""
Adapt a legacy :class:`ToolHandler` to a sessions-API tool callable.
:param tool_handler: Legacy client-side tool handler from the
responses-API path.
:param agent_name: Agent display name for the legacy
:class:`ToolCallInfo`, e.g. ``"coding_supervisor"``.
"""
tool_handler: ToolHandler
agent_name: str
def __call__(self, info: SessionToolCallInfo) -> Awaitable[str] | str:
"""
Execute the legacy tool handler for a sessions-API tool call.
:param info: Sessions-API tool call context.
:returns: Tool output string or awaitable string.
"""
arguments = dict(info.arguments)
legacy_info = ToolCallInfo(
name=info.name,
arguments=arguments,
call_id=info.call_id,
agent_name=self.agent_name,
response_id=info.item_id if info.item_id is not None else info.call_id,
iteration=0,
)
return self.tool_handler.execute(legacy_info)
def run_chat(
target: str,
client_tools: str | None,
*,
server_url: str | None = None,
harness: str | None = None,
model: str | None = None,
prompt: str | None = None,
system_prompt: str | None = None,
ephemeral: bool = False,
resume_conversation_id: str | None = None,
resume_latest: bool = False,
resume_picker: bool = False,
fork_session_id: str | None = None,
log: bool = False,
debug_events: bool = False,
resume_parts: list[str] | None = None,
auto_open_conversation: bool = False,
) -> None:
"""
Main entry point for ``omnigent run`` (and the ``attach`` client).
:param target: Path to an agent directory/bundle, or a server URL.
:param client_tools: Optional client-side tool set name.
:param server_url: Optional server URL to use with a local
agent path, e.g. ``"https://example.databricksapps.com"``.
``None`` starts a local server for path targets.
:param harness: CLI ``--harness`` override, e.g. ``"claude-sdk"``.
Applied only to local-mode targets (YAML path / directory);
ignored for remote server URLs.
:param model: CLI ``--model`` override, e.g.
``"databricks-claude-sonnet-4-6"``. Local-mode only.
:param prompt: CLI ``-p`` / ``--prompt`` — send one user turn,
print the response, and exit.
:param system_prompt: CLI ``--system-prompt`` — overrides the
YAML's top-level ``prompt`` field. Local-mode only.
:param ephemeral: When ``True``, place the local server's
SQLite DB and artifacts in a per-run tmpdir instead of
the persistent ``~/.omnigent`` location. Maps to
``--no-session`` on the CLI. Local-mode only — passing
this with a remote URL target raises
:class:`click.ClickException` (the remote server owns
its own persistence).
:param resume_conversation_id: When set, the REPL opens
attached to this existing conversation instead of
creating a fresh one — replays recent items and
threads new turns onto the existing
``previous_response_id`` chain. Maps to
``--resume <id>`` on the CLI.
:param resume_latest: When ``True``, resolve "the most
recent conversation for this agent" against the
persistent store after the server boots and attach
the REPL to it. Maps to ``--continue`` on the CLI.
Local-mode only — passing this with a remote URL
target raises :class:`click.ClickException`. Mutually
exclusive with *resume_conversation_id* — the latter
takes precedence if both are set.
:param resume_picker: When ``True``, open the interactive
stderr/stdin picker after the server boots and let
the user choose a conversation. Maps to ``--resume``
/ ``-r`` with no value on the CLI. Local-mode only — passing this
with a remote URL target raises
:class:`click.ClickException` (the picker has no way
to scope to a single agent on a multi-agent remote
server without an explicit hand-off). Precedence:
``resume_conversation_id`` wins over ``resume_picker``
wins over ``resume_latest``; user-cancelled picker
falls through to a fresh conversation.
:param fork_session_id: When set, fork this session before
entering the REPL. The fork creates a deep copy of the
source session's items into a new session; the REPL then
opens attached to the fork. Maps to ``--fork ID`` on the
CLI. Mutually exclusive with ``resume_conversation_id``,
``resume_latest``, and ``resume_picker``.
:param log: When ``True``, write a JSON dump of the active
conversation to ``~/.omnigent/logs/`` on REPL exit.
Maps to ``--log`` on the CLI (default-on for the legacy
path, default-off here so it stays explicit on
Omnigent mode). See ``omnigent.repl._session_log`` for the
schema. Local-mode only — passing this with a remote
URL target raises :class:`click.ClickException`
(no client-side conversation hand-off to dump).
:param debug_events: When ``True``, enable the SSE-to-UI debug
pipeline (event tape overlay via ``Ctrl+E``, JSONL event
logging to ``~/.omnigent/debug/``, and pipeline stage
counters in the toolbar). Maps to ``--debug-events`` on the
CLI.
:param resume_parts: Pre-built argument list prefix for the
resume hint, e.g. ``["omnigent", "run", "agent.yaml",
"--server", "https://example.com"]``. Built from Click's
parsed context at CLI dispatch time. ``None`` omits the
resume hint on exit.
:param auto_open_conversation: When ``True``, open the
browser conversation URL when the session id becomes known.
"""
# Client-side tools are a CLI/TUI convenience (e.g. shell access
# for coding agents). They don't affect agent behavior — the spec
# is self-contained.
tool_handler = _load_tool_handler(client_tools) if client_tools else None
overrides = ChatOverrides(
harness=harness,
model=model,
system_prompt=system_prompt,
)
if server_url is not None and _is_url(target):
raise click.ClickException(
"--server is for binding a local agent YAML to a server. "
"Pass a YAML path as the target (got a URL)."
)
if _is_url(target):
if any(
v is not None for v in (overrides.harness, overrides.model, overrides.system_prompt)
):
raise click.ClickException(
"--harness / --model / --system-prompt only apply to local "
"agent paths. The remote server controls its own agent registrations."
)
# Local-only resume / persistence flags would silently
# vanish on the remote path (the server owns its own
# store; we have no client-side conversation id to feed
# the picker, no client-side log target). Fail loud rather
# than letting a legacy/AP resume mode mismatch appear to work.
if ephemeral or resume_latest or resume_picker or log:
raise click.ClickException(
"--no-session / --continue / --resume / --log only apply to "
"local agent paths. "
"The remote server owns its own persistence and conversation lookup. "
"Pass --resume <id> with a remote URL to attach to a specific conversation."
)
# Discover host-scope skills from cwd so ``/skill-name`` slash
# commands work even when connecting to a remote server with no
# local agent spec.
host_skills = discover_host_skills(Path.cwd(), "all")
_chat_with_server(
target,
tool_handler,
initial_message=prompt,
resume_conversation_id=resume_conversation_id,
fork_session_id=fork_session_id,
debug_events=debug_events,
resume_parts=resume_parts,
skills=host_skills or None,
auto_open_conversation=auto_open_conversation,
)
elif ephemeral:
# ``--no-session`` keeps the legacy in-process ephemeral server: the
# daemon-backed server is persistent + shared and has no per-run DB
# isolation. Not combinable with an explicit ``--server``.
if server_url:
raise click.ClickException(
"--no-session is not supported with --server; the uploaded agent "
"is already scoped to the CLI session."
)
_chat_local(
target,
tool_handler,
overrides=overrides,
initial_message=prompt,
ephemeral=True,
resume_conversation_id=resume_conversation_id,
resume_latest=resume_latest,
resume_picker=resume_picker,
fork_session_id=fork_session_id,
log=log,
debug_events=debug_events,
resume_parts=resume_parts,
auto_open_conversation=auto_open_conversation,
)
else:
# Non-URL target → the host daemon is the backend. It connects to
# the given ``--server`` URL, or starts (and connects to) a persistent
# local Omnigent server when none is provided; this returns that concrete
# URL. The agent is uploaded as a session and the daemon spawns +
# *owns* the runner (the CLI only attaches the REPL), matching
# claude-native.
from omnigent.cli import _ensure_backend
base_url = _ensure_backend(server_url)
_chat_via_daemon(
target,
base_url,
tool_handler,
overrides=overrides,
initial_message=prompt,
resume_conversation_id=resume_conversation_id,
resume_latest=resume_latest,
resume_picker=resume_picker,
fork_session_id=fork_session_id,
log=log,
debug_events=debug_events,
resume_parts=resume_parts,
auto_open_conversation=auto_open_conversation,
)
def run_prompt(
target: str,
client_tools: str | None,
*,
harness: str | None = None,
model: str | None = None,
prompt: str,
system_prompt: str | None = None,
ephemeral: bool = False,
) -> None:
"""Run one prompt headlessly and print only the assistant text.
This is the non-interactive sibling of :func:`run_chat` for
``omnigent run ... -p``. It deliberately bypasses the
Rich/prompt-toolkit REPL startup path so ``-p`` behaves like a
scriptable CLI mode: send one turn, print the assistant response,
and return.
:param target: Path to an agent directory/bundle, or a server URL.
:param client_tools: Optional client-side tool set name.
:param harness: CLI ``--harness`` override for local targets.
:param model: CLI ``--model`` override for local targets.
:param prompt: User prompt to send.
:param system_prompt: CLI ``--system-prompt`` override for local targets.
:param ephemeral: When ``True``, use a fresh per-run local
server database and artifact directory.
"""
tool_handler = _load_tool_handler(client_tools) if client_tools else None
overrides = ChatOverrides(
harness=harness,
model=model,
system_prompt=system_prompt,
)
if _is_url(target):
if any(
v is not None for v in (overrides.harness, overrides.model, overrides.system_prompt)
):
raise click.ClickException(
"--harness / --model / --system-prompt only apply to local "
"agent paths. The remote server controls its own agent registrations."
)
base_url = target.rstrip("/")
agent_name = _pick_agent(base_url, quiet=True)
_run_headless_prompt(
base_url,
agent_name,
tool_handler,
prompt=prompt,
)
return
_run_local_headless_prompt(
target,
tool_handler,
overrides=overrides,
prompt=prompt,
ephemeral=ephemeral,
)
def run_attach(
*,
base_url: str,
conversation_id: str,
client_tools: str | None = None,
debug_events: bool = False,
auto_open_conversation: bool = False,
resume_parts: list[str] | None = None,
) -> None:
"""
Attach the REPL to a LIVE conversation, dispatching to its existing runner.
``attach`` is a pure co-drive client: it never launches OR binds a runner.
Turns post to the runner the host already bound (``POST /v1/sessions/{id}/
events``, which needs only edit access), exactly like the web UI co-drive,
and the server routes them to that runner. Binding a runner is owner-only
server-side — so a teammate attaching to a shared session must NOT re-bind;
post-only is what makes cross-user co-drive work. A read-only pre-flight
confirms the session's host runner is online (``attach`` can't start one),
failing loud otherwise.
:param base_url: Omnigent server hosting the session, e.g.
``"http://127.0.0.1:6767"``.
:param conversation_id: Live conversation/session id to join, e.g.
``"conv_abc123"``.
:param client_tools: Optional client-side tool set name, e.g. ``"coding"``.
:param debug_events: When ``True``, enable the SSE debug pipeline overlay.
:param auto_open_conversation: When ``True``, open the browser conversation
URL once attached.
:param resume_parts: Argument-list prefix for the on-exit resume hint, e.g.
``["cli", "attach", "conv_abc123", "--server", "http://..."]``.
:raises click.ClickException: If the session has no online runner (its host
is offline) — ``attach`` never starts one.
"""
base_url = base_url.rstrip("/")
# Pre-flight (read-only): a co-drive client can only run turns if the
# session's host runner is online; attach never launches one. The same
# snapshot gives the agent name + harness for an honest banner.
info = _attach_session_info(base_url=base_url, conversation_id=conversation_id)
if not info.runner_online:
raise click.ClickException(
f"Session {conversation_id} has no online runner on {base_url} — its "
"host is offline. `attach` never starts a runner; bring the host back "
"(`omnigent run` locally, or reconnect it with `omnigent host`), "
"then attach again."
)
tool_handler = _load_tool_handler(client_tools) if client_tools else None
# Post-only co-drive: no runner_id / recover, ``attach_only`` so the REPL
# adapter never PATCHes the (owner-only) runner binding — turns dispatch to
# the host's already-bound runner. ``agent_name`` is the session's own (so
# we skip the server agent-picker + its "Agent: …" echo); ``attach_harness``
# makes the banner reflect what the host is running.
_chat_with_server(
base_url,
tool_handler,
agent_name=info.agent_name,
resume_conversation_id=conversation_id,
attach_only=True,
attach_harness=info.harness,
debug_events=debug_events,
resume_parts=resume_parts,
auto_open_conversation=auto_open_conversation,
)
# ---------------------------------------------------------------------------
# Mode detection
# ---------------------------------------------------------------------------
def _is_url(target: str) -> bool:
"""
Check if the target looks like a URL.
:param target: The target string.
:returns: True if it starts with http:// or https://.
"""
return target.startswith(("http://", "https://"))
# ---------------------------------------------------------------------------
# Server URL client helpers
# ---------------------------------------------------------------------------
def _remote_headers(
server_url: str | None = None,
) -> dict[str, str]:
"""
Build headers for remote AP-server requests.
Resolution order:
1. explicit ``OMNIGENT_REMOTE_AUTH_TOKEN`` env var
2. stored OIDC token from ``~/.omnigent/auth_tokens.json``
(populated by ``omnigent login``)
3. stored Databricks Apps pointer record for ``server_url``
(populated by ``omnigent login <apps-url>``) — mints a
fresh workspace OAuth token via the SDK
4. ambient Databricks CLI / ``~/.databrickscfg`` credentials
(the SDK's default resolution; no profile is threaded)
This lets ``omnigent run --server <apps-url>`` work against
Databricks Apps after a one-time ``omnigent login <apps-url>``,
without forcing the user to manually copy a bearer into an env var.
:param server_url: Optional remote server URL for looking up
stored OIDC tokens, e.g. ``"http://localhost:6767"``.
:returns: Headers to pass to httpx / OmnigentClient.
"""
# Resolve the bearer in the documented precedence order (one credential
# source per branch), then merge the workspace-routing header.
headers: dict[str, str] = {}
token = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
if token and (token := token.strip()):
# 1. Explicit env-var token.
headers["Authorization"] = f"Bearer {token}"
elif server_url:
from omnigent.cli_auth import load_token
# 2. Stored OIDC session token from `omnigent login`.
oidc_token = load_token(server_url)
if oidc_token:
headers["Authorization"] = f"Bearer {oidc_token}"
else:
# 3. Databricks Apps pointer record → mint a fresh workspace token.
record_token = _stored_databricks_record_token(server_url)
if record_token:
headers["Authorization"] = f"Bearer {record_token}"
if "Authorization" not in headers:
# 4. Ambient ~/.databrickscfg credentials.
creds = _read_databrickscfg(None)
if creds is not None and creds.token:
headers["Authorization"] = f"Bearer {creds.token}"
# Workspace routing: when a ?o= selector was recorded at login, name the
# workspace or the request routes to the account. Merged onto the result
# because these ad-hoc requests carry no httpx Auth.
if server_url:
from omnigent.cli_auth import databricks_request_headers
headers.update(databricks_request_headers(server_url))
return headers
def _stored_databricks_record_token(server_url: str) -> str | None:
"""Mint a workspace token from a stored Databricks Apps record.
``omnigent login <apps-url>`` stores a pointer record naming the
workspace that fronts the app; this resolves it to a fresh bearer
via the Databricks CLI's host-keyed OAuth cache. One-shot — callers
that issue many requests should use :class:`_DatabricksTokenAuth`,
which reuses the SDK config across requests.
:param server_url: The remote server URL, e.g.
``"https://myapp-123.aws.databricksapps.com"``.
:returns: A bearer token, or ``None`` when no pointer record is
stored or the workspace credentials don't resolve.
"""
from omnigent.cli_auth import load_databricks_workspace_host
from omnigent.inner.databricks_executor import (
DatabricksAuthError,
_resolve_databricks_auth,
)
workspace_host = load_databricks_workspace_host(server_url)
if workspace_host is None:
return None
try:
auth, _host = _resolve_databricks_auth(host=workspace_host)
return auth.current_token()
except (DatabricksAuthError, ImportError, ValueError):
return None
class _DatabricksTokenAuth(httpx.Auth):
"""
httpx Auth that authenticates via the Databricks SDK, refreshing
OAuth tokens transparently.
Resolution order:
1. static env-var token (``OMNIGENT_REMOTE_AUTH_TOKEN``)
2. stored OIDC token (from ``omnigent login``)
3. Databricks SDK credentials — resolved ONCE and reused, so the
SDK serves the cached token from memory and only re-runs the
Databricks CLI near expiry (not on every request).
"""
def __init__(
self,
server_url: str | None = None,
) -> None:
"""
:param server_url: Remote server URL for looking up stored
OIDC tokens, e.g. ``"http://localhost:6767"``.
"""
self._server_url = server_url
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
self._static_token = raw.strip() if raw else None
# Lazily-resolved, then reused, SDK auth (one Config → one token
# cache). Resolving per request rebuilt Config and shelled out to
# the Databricks CLI (~0.5s) every time — a heavy tax on the
# long-lived transcript-forwarder client that posts reply items.
self._sdk_auth: _DatabricksBearerAuth | None = None
self._sdk_auth_resolved = False
def _sdk_token(self) -> str | None:
"""
Return a bearer token from the reused SDK auth, or ``None``.
Resolves Databricks SDK auth on first use and reuses it, so
repeat requests hit the SDK's in-memory token cache instead of
re-shelling to the Databricks CLI. A stored Databricks Apps
pointer record for the server (from ``omnigent login
<apps-url>``) takes precedence over profile/ambient resolution
— the record names the exact workspace the Apps edge accepts
tokens from.
:returns: Bearer token string, or ``None`` when no Databricks
credentials resolve.
"""
from omnigent.cli_auth import load_databricks_workspace_host
from omnigent.inner.databricks_executor import (
DatabricksAuthError,
_resolve_databricks_auth,
)
if not self._sdk_auth_resolved:
workspace_host = (
load_databricks_workspace_host(self._server_url) if self._server_url else None
)
try:
if workspace_host is not None:
self._sdk_auth, _host = _resolve_databricks_auth(host=workspace_host)
else:
self._sdk_auth, _host = _resolve_databricks_auth()
except (DatabricksAuthError, ImportError, ValueError):
self._sdk_auth = None
self._sdk_auth_resolved = True
if self._sdk_auth is None:
return None
try:
return self._sdk_auth.current_token()
except DatabricksAuthError:
return None
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
"""
Inject an ``Authorization`` header before each request.
Static env-var token takes precedence, then stored OIDC token,
then the reused Databricks SDK auth (which refreshes expired
OAuth tokens transparently). The stored ``X-Databricks-Org-Id``
selector (if any) is set first so the request routes to the
workspace, regardless of which credential branch sets the bearer.
:param request: The outgoing httpx request.
:yields: The request with auth header set.
"""
# Workspace routing (empty when none recorded); independent of the
# credential branch below.
if self._server_url:
from omnigent.cli_auth import databricks_request_headers
request.headers.update(databricks_request_headers(self._server_url))
if self._static_token:
request.headers["Authorization"] = f"Bearer {self._static_token}"
yield request
return
# Check stored OIDC token from `omnigent login`.
if self._server_url:
from omnigent.cli_auth import load_token
oidc_token = load_token(self._server_url)
if oidc_token:
request.headers["Authorization"] = f"Bearer {oidc_token}"
yield request
return
token = self._sdk_token()
if token:
request.headers["Authorization"] = f"Bearer {token}"
yield request
def _server_headers(
*,
runner_id: str | None = None,
) -> dict[str, str]:
"""
Build non-auth HTTP headers for an Omnigent server client.
Auth is handled separately via :func:`_server_auth` which
returns an ``httpx.Auth`` that refreshes the Databricks OAuth
token on every request.
:param runner_id: Optional runner UUID, e.g.
``"runner_0123456789abcdef"``. Accepted for callers that
already threaded the value here; runner affinity is now
persisted through ``PATCH /v1/sessions/{id}``, not a
request header.
:returns: Static headers for ``httpx`` / ``OmnigentClient``.
"""
del runner_id
return {}
def _server_auth(
server_url: str | None = None,
) -> httpx.Auth | None:
"""
Build an httpx Auth for a remote Omnigent server client.
Returns a :class:`_DatabricksTokenAuth` when any credential
source is available (env var, stored ``omnigent login`` record,
or ambient Databricks credentials). Returns ``None`` for local
servers that don't need auth, so the caller can pass it straight
to ``OmnigentClient(auth=...)``.
:param server_url: Optional remote server URL for looking up
stored OIDC tokens.
:returns: Auth instance, or ``None``.
"""
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
if raw and raw.strip():
return _DatabricksTokenAuth(server_url=server_url)
# Check stored `omnigent login` records: a session JWT or a
# Databricks Apps pointer record.
if server_url:
from omnigent.cli_auth import load_databricks_workspace_host, load_token
if load_token(server_url) or load_databricks_workspace_host(server_url):
return _DatabricksTokenAuth(server_url=server_url)
creds = _read_databrickscfg(None)
if creds is not None and creds.token:
return _DatabricksTokenAuth(server_url=server_url)
return None
def _chat_with_server(
server_url: str,
tool_handler: ToolHandler | None,
*,
initial_message: str | None = None,
resume_conversation_id: str | None = None,
fork_session_id: str | None = None,
agent_name: str | None = None,
runner_id: str | None = None,
runner_recover: Callable[[], str] | None = None,
log: bool = False,
agent_yaml: Path | None = None,
session_bundle: bytes | None = None,
session_bundle_filename: str = "agent.tar.gz",
ephemeral: bool = False,
debug_events: bool = False,
server_log_path: Path | None = None,
runner_log_path: Path | None = None,
resume_parts: list[str] | None = None,
skills: list[SkillSpec] | None = None,
auto_open_conversation: bool = False,
progress: RunnerStartupProgress | None = None,
attach_only: bool = False,
attach_harness: str | None = None,
) -> None:
"""
Connect to a server URL and run a one-shot query or REPL.
Lists available agents and lets the user pick one unless
*agent_name* is supplied by an upstream setup step such as
ephemeral ``--server`` upload.
:param server_url: The server URL.
:param tool_handler: Optional client-side tool handler.
:param initial_message: If set without
``resume_conversation_id``, run one request and exit. If set
with ``resume_conversation_id``, auto-send when the REPL
opens.
:param resume_conversation_id: When set, the REPL opens
attached to this existing conversation on the remote
server instead of creating a fresh one.
:param fork_session_id: When set, fork this session before
entering the REPL. The REPL opens attached to the fork.
:param agent_name: Optional already-selected agent name,
e.g. ``"hello_world"``.
:param runner_id: Optional preferred runner id to send on
requests, e.g. ``"runner_0123456789abcdef"``.
:param runner_recover: Optional callback that restarts a local
runner if it exits and returns the live runner id.
:param log: When ``True``, write a session log on REPL exit.
:param agent_yaml: Optional local agent YAML path for tmux
pane re-launch metadata.
:param session_bundle: Optional gzipped agent bundle bytes used
to create a fresh ``/v1/sessions`` session. Required for
fresh sessions on the sessions API path.
:param session_bundle_filename: Filename for the multipart
``bundle`` part, e.g. ``"agent.tar.gz"``.
:param ephemeral: When ``True``, suppress the resume hint on
exit — the session data lives in a tmpdir that won't
survive process exit.
:param debug_events: When ``True``, enable the SSE-to-UI debug
pipeline. Forwarded to ``_run_repl``.
:param server_log_path: Path to the local server's
stdout/stderr log file, e.g.
``Path("~/.omnigent/logs/server/server-abc123.log")``. Shown in the
Ctrl+O debug overview. ``None`` for remote servers.
:param runner_log_path: Path to the local runner's
stdout/stderr log file, e.g.
``Path("~/.omnigent/logs/runner/runner-abc123.log")``. Shown in the
Ctrl+O debug overview. ``None`` when no local runner is used.
:param resume_parts: Pre-built argument list prefix for the
resume command shown on exit, e.g.
``["omnigent", "run", "agent.yaml", "--harness", "codex"]``.
``None`` uses the current process argv.
:param auto_open_conversation: When ``True``, open the
browser conversation URL when the session id becomes known.
:param skills: Parsed skill list from the agent spec, e.g.
``[SkillSpec(name="code-review", ...)]``. Each skill is
registered as a ``/<name>`` slash command in the REPL.
``None`` (default) means no skill commands are registered.
:param progress: Active startup spinner handed off from the daemon
bring-up path, or ``None``. It stays up (on its last label,
``"Launching your agent…"``) across the wrapper-redirect probe and
REPL setup below — so there's no empty gap there — and is cleared
(``progress.finish()``) the instant before this function produces
terminal output — a native-wrapper redirect notice, the one-shot
reply, or the REPL's first paint.
"""
base_url = server_url.rstrip("/")
# The spinner (still showing the last bring-up phase, "Launching your
# agent…") is intentionally left running through the wrapper-redirect
# probe (a ``GET /v1/sessions/{id}`` that can take a few seconds) and REPL
# setup, so the user never sees a cleared spinner over an empty gap here.
# The label lags the exact step on purpose — better than a vaguer one.
# Wrapper-aware resume redirect: if the conversation we're about to
# resume was originally created by a terminal-native wrapper, the AP
# REPL is the WRONG surface to attach to. Detect via the
# ``omnigent.wrapper`` label on the conversation and re-dispatch
# into the native wrapper carrying ``--server`` through. Without
# this, the REPL renders an empty chat on top of a
# session whose state lives in a tmux terminal it can't see.
if resume_conversation_id is not None and _redirect_native_resume_if_needed(
base_url=base_url,
conversation_id=resume_conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
):
return
selected_agent = agent_name or _pick_agent(base_url)
# Bring-up is done — clear the spinner the instant before we produce
# terminal output (the one-shot reply or the REPL's first paint), so it
# never lingers across the hand-off but also never leaves a gap before it.
if progress is not None:
progress.finish()
if initial_message is not None:
_run_one_shot(
base_url=base_url,
agent_name=selected_agent,
tool_handler=tool_handler,
prompt=initial_message,
runner_id=runner_id,
session_bundle=session_bundle,
session_bundle_filename=session_bundle_filename,
resume_conversation_id=resume_conversation_id,
auto_open_conversation=auto_open_conversation,
)
return
_run_repl(
base_url,
selected_agent,
tool_handler,
initial_message=initial_message,
resume_conversation_id=resume_conversation_id,
fork_session_id=fork_session_id,
log=log,
agent_yaml=agent_yaml,
runner_id=runner_id,
runner_recover=runner_recover,
session_bundle=session_bundle,
session_bundle_filename=session_bundle_filename,
ephemeral=ephemeral,
debug_events=debug_events,
server_log_path=server_log_path,
runner_log_path=runner_log_path,
resume_parts=resume_parts,
skills=skills,
auto_open_conversation=auto_open_conversation,
attach_only=attach_only,
attach_harness=attach_harness,
)
def _is_claude_native_conversation(
*,
base_url: str,
conversation_id: str,
) -> bool:
"""
Return whether *conversation_id* is a claude-native wrapper session.
:param base_url: Omnigent server base URL.
:param conversation_id: Omnigent conversation id.
:returns: ``True`` only when the wrapper label matches Claude native.
"""
return (
_wrapper_label_for_conversation(
base_url=base_url,
conversation_id=conversation_id,
)
== _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE
)
def _redirect_native_resume_if_needed(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None = None,
) -> bool:
"""
Redirect a terminal-native resume before Omnigent attach liveness runs.
:param base_url: Omnigent server base URL, e.g. ``"https://example.com"``.
:param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional startup spinner to finish before redirect.
:returns: ``True`` when a native wrapper handled the resume.
"""
wrapper_label = _wrapper_label_for_conversation(
base_url=base_url, conversation_id=conversation_id
)
native_agent = native_coding_agent_for_wrapper_label(wrapper_label)
if native_agent is None:
return False
if native_agent.key == "claude":
_run_claude_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
if native_agent.key == "codex":
_run_codex_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
if native_agent.key == "pi":
_run_pi_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
if native_agent.key == "kiro":
_run_kiro_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
if native_agent.key == "cursor":
_run_cursor_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
if native_agent.key == "kimi":
_run_kimi_native_resume_redirect(
base_url=base_url,
conversation_id=conversation_id,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
return True
return False
def _finish_native_redirect_progress(
*,
progress: RunnerStartupProgress | None,
conversation_id: str,
wrapper_name: str,
native_command: str,
) -> None:
"""
Finish any Omnigent startup progress and print the native redirect notice.
:param progress: Optional startup spinner to finish before writing.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param wrapper_name: Wrapper label for display, e.g. ``"codex-native"``.
:param native_command: Native command to show, e.g. ``"codex"``.
:returns: None.
"""
if progress is not None:
progress.finish()
click.echo(
(
f"\n Conversation {conversation_id} is a {wrapper_name} "
f"session — redirecting to `omnigent {native_command} --resume`.\n"
),
err=True,
)
def _run_claude_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a claude-native conversation back to ``omnigent claude``.
:param base_url: Omnigent server base URL, e.g.
``"https://example.databricksapps.com"``.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="claude-native",
native_command="claude",
)
from omnigent.claude_native import run_claude_native
run_claude_native(
server=base_url,
session_id=conversation_id,
claude_args=(),
auto_open_conversation=auto_open_conversation,
)
def _run_codex_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a codex-native conversation back to ``omnigent codex``.
:param base_url: Omnigent server base URL, e.g.
``"https://example.databricksapps.com"``.
:param conversation_id: Omnigent conversation id, e.g.
``"conv_abc123"``.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="codex-native",
native_command="codex",
)
from omnigent.codex_native import run_codex_native
run_codex_native(
server=base_url,
session_id=conversation_id,
codex_args=(),
auto_open_conversation=auto_open_conversation,
)
def _run_pi_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a pi-native conversation back to ``omnigent pi``.
:param base_url: Omnigent server base URL.
:param conversation_id: Omnigent conversation id.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="pi-native",
native_command="pi",
)
from omnigent.pi_native import run_pi_native
run_pi_native(
server=base_url,
session_id=conversation_id,
pi_args=(),
auto_open_conversation=auto_open_conversation,
)
def _run_kiro_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""Hand a kiro-native conversation back to ``omnigent kiro``."""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="kiro-native",
native_command="kiro",
)
from omnigent.kiro_native import run_kiro_native
run_kiro_native(
server=base_url,
session_id=conversation_id,
kiro_args=(),
auto_open_conversation=auto_open_conversation,
)
def _run_cursor_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a cursor-native conversation back to ``omnigent cursor``.
The cursor-native session is driven by the ``cursor-agent`` TUI in a
runner-owned tmux pane, and the forwarder mirrors that transcript back
into the conversation. Resuming through the Omnigent REPL would instead
run an Omnigent turn per message (which persists its own user item) *and*
leave the forwarder mirroring the same message from the cursor store —
recording each user message twice. Redirecting to ``omnigent cursor``'s
direct tmux attach keeps the TUI the single source of turns.
:param base_url: Omnigent server base URL.
:param conversation_id: Omnigent conversation id.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="cursor-native",
native_command="cursor",
)
from omnigent.cursor_native import run_cursor_native
run_cursor_native(
server=base_url,
session_id=conversation_id,
cursor_args=(),
auto_open_conversation=auto_open_conversation,
)
def _run_kimi_native_resume_redirect(
*,
base_url: str,
conversation_id: str,
auto_open_conversation: bool,
progress: RunnerStartupProgress | None,
) -> None:
"""
Hand a kimi-native conversation back to ``omnigent kimi``.
The kimi-native session is driven by the ``kimi`` TUI in a runner-owned
tmux pane. Resuming through the Omnigent REPL would run an Omnigent turn
per message instead of attaching to the live TUI; redirecting to
``omnigent kimi``'s direct tmux attach keeps the TUI the single source of
turns. Mirrors :func:`_run_cursor_native_resume_redirect`.
:param base_url: Omnigent server base URL.
:param conversation_id: Omnigent conversation id.
:param auto_open_conversation: Browser-open preference for the wrapper.
:param progress: Optional Omnigent startup spinner to finish before redirect.
:returns: None.
"""
_finish_native_redirect_progress(
progress=progress,
conversation_id=conversation_id,
wrapper_name="kimi-native",
native_command="kimi",
)
from omnigent.kimi_native import run_kimi_native
run_kimi_native(
server=base_url,
session_id=conversation_id,
kimi_args=(),
auto_open_conversation=auto_open_conversation,
)
def _wrapper_label_for_conversation(
*,
base_url: str,
conversation_id: str,
) -> str | None:
"""
Return a conversation's wrapper label, if it can be read.
Single-shot ``GET /v1/sessions/{id}`` against *base_url*, inspecting
the response's ``labels.omnigent.wrapper`` field. ``None`` on any
transport / parse error so a flaky server doesn't silently misroute
the resume — the caller falls back to the normal Omnigent REPL path and
surfaces a clear failure there.
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
:param conversation_id: Omnigent conversation id,
e.g. ``"conv_abc123"``.
:returns: Wrapper label value, or ``None``.
"""
try:
resp = httpx.get(
f"{base_url}/v1/sessions/{conversation_id}",
headers=_remote_headers(server_url=base_url),
timeout=10.0,
)
except httpx.HTTPError as exc:
logger.warning(
"wrapper-label probe failed for %s on %s: %s",
conversation_id,
base_url,
exc,
)
return None
if resp.status_code != 200:
logger.warning(
"wrapper-label probe got %s for %s on %s; treating as no wrapper",
resp.status_code,
conversation_id,
base_url,
)
return None
try:
body = resp.json()
except ValueError as exc:
logger.warning(
"wrapper-label probe for %s returned non-JSON body: %s",
conversation_id,
exc,
)
return None
if not isinstance(body, dict):
logger.warning(
"wrapper-label probe for %s returned non-object body",
conversation_id,
)
return None
labels = body.get("labels")
if not isinstance(labels, dict):
return None
value = labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
return value if isinstance(value, str) else None
@dataclass(frozen=True)
class _AttachSessionInfo:
"""Facts ``attach`` reads from one ``GET /v1/sessions/{id}`` snapshot.
:param runner_online: ``True`` when the session is bound to a runner the
server does not report as offline — i.e. a host is live to dispatch
co-drive turns to. ``attach`` fails loud when ``False``.
:param agent_name: The session's agent name, e.g. ``"polly"``, used as
the REPL display name (so ``attach`` never has to pick from the
server's agent list). ``None`` if the snapshot omits it.
:param harness: The session's harness, e.g. ``"codex"``, shown in the
attach banner so it reflects what the host is actually running.
``None`` if the snapshot omits it.
"""
runner_online: bool
agent_name: str | None
harness: str | None
def _attach_session_info(
*,
base_url: str,
conversation_id: str,
) -> _AttachSessionInfo:
"""
Read the facts ``attach`` needs from one ``GET /v1/sessions/{id}``.
``attach`` dispatches co-drive turns to the host's already-bound runner
(never launching one), so it only needs to know the runner is live plus
the agent name + harness for an honest banner. ``runner_online`` is
``True`` only when a ``runner_id`` is present and the session snapshot
does not report it offline. When ``runner_online`` is absent (older
servers), attach stays optimistic and lets turn dispatch surface any real
liveness failure; probing ``/v1/runners/{id}/status`` would be wrong here
because that endpoint intentionally reports other users' runners as
offline, while attach must support cross-user co-drive on shared sessions.
A missing/unreachable session yields all-empty facts and the caller fails
loud.
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
:param conversation_id: Conversation/session id, e.g. ``"conv_abc123"``.
:returns: The session facts; ``runner_online=False`` on any failure.
"""
empty = _AttachSessionInfo(runner_online=False, agent_name=None, harness=None)
try:
resp = httpx.get(
f"{base_url}/v1/sessions/{conversation_id}",
headers=_remote_headers(server_url=base_url),
timeout=10.0,
)
except httpx.HTTPError as exc:
logger.warning("session probe failed for %s on %s: %s", conversation_id, base_url, exc)
return empty
if resp.status_code != 200:
return empty
try:
body = resp.json()
except ValueError:
return empty
if not isinstance(body, dict):
return empty
runner_id = body.get("runner_id")
snapshot_online = body.get("runner_online")
if not isinstance(runner_id, str) or not runner_id:
runner_online = False
elif isinstance(snapshot_online, bool):
runner_online = snapshot_online
else:
# Older servers omit runner_online on the single-session snapshot. Stay
# optimistic rather than falling back to the owner-scoped runner-status
# endpoint, which reports a teammate's live runner as offline by design.
runner_online = True
agent_name = body.get("agent_name")
harness = body.get("harness")
return _AttachSessionInfo(
runner_online=runner_online,
agent_name=agent_name if isinstance(agent_name, str) and agent_name else None,
harness=harness if isinstance(harness, str) and harness else None,
)
def _pick_agent(base_url: str, *, quiet: bool = False) -> str:
"""
Discover agent names from existing sessions and let the user pick.
If only one agent name is found, selects it automatically.
Falls back to requiring the user to specify ``--agent`` if no
sessions exist yet.
:param base_url: Server base URL.
:param quiet: When ``True``, suppress interactive prompts and
auto-select the first available agent.
:returns: The chosen agent name.
:raises click.ClickException: If no sessions exist or no
agent name can be discovered.
"""
resp = httpx.get(
f"{base_url}/v1/sessions",
headers=_remote_headers(server_url=base_url),
params={"limit": 100},
timeout=10.0,
)
resp.raise_for_status()
sessions = resp.json()["data"]
# Collect unique agent names from sessions.
names: list[str] = []
seen: set[str] = set()
for s in sessions:
name = s.get("agent_name")
if name and name not in seen:
names.append(name)
seen.add(name)
if not names:
raise click.ClickException(
"No sessions found on the server. Start a session first "
"or specify the agent with --agent."
)
if len(names) == 1:
if not quiet:
click.echo(f"\n Agent: {names[0]}")
return names[0]
click.echo("\n Available agents:\n")
for i, name in enumerate(names, 1):
click.echo(f" {i}. {name}")
while True:
raw = str(click.prompt("\n Agent", default="1"))
try:
choice = int(raw)
if 1 <= choice <= len(names):
return names[choice - 1]
except ValueError:
if raw.strip() in seen:
return raw.strip()
click.echo(f" Enter a number between 1 and {len(names)}.")
# ---------------------------------------------------------------------------
# Local agent path bound to an existing server
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _DaemonChatSession:
"""A chat session bound to a daemon-spawned runner.
:param session_id: The created/resolved conversation id, e.g.
``"conv_abc123"``.
:param runner_id: The daemon-spawned runner bound to the session, e.g.
``"runner_abc123"``.
"""
session_id: str
runner_id: str
_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S = 30.0
_DAEMON_CHAT_RUNNER_ONLINE_TIMEOUT_S = 60.0
_ACCOUNTS_SETUP_POLL_INTERVAL_S = 1.0
_ACCOUNTS_SETUP_TIMEOUT_S = 600.0
def _await_accounts_first_run_setup(
base_url: str,
*,
timeout_s: float = _ACCOUNTS_SETUP_TIMEOUT_S,
progress: RunnerStartupProgress | None = None,
) -> None:
"""Block until a fresh accounts-mode local server has its first admin.
When ``omnigent run`` (re)spawns the local Omnigent server in accounts mode on
a machine with no admin yet, the server reports ``needs_setup`` and (by
default) opens a browser to its Create-admin form. Until an admin is
claimed there is no CLI credential, so the first authenticated call would
401. Rather than crash, print the setup URL — so it works whether or not
the browser auto-opened (e.g. ``OMNIGENT_ACCOUNTS_AUTO_OPEN=0``) — and
poll until the admin is created; ``/auth/setup`` then mints this CLI's
loopback token, which we detect and return on.
No-op when the server is not in accounts mode, when this CLI already holds
a token for *base_url*, or when an admin already exists (the server mints
our token at boot in that case).
:param base_url: Resolved local Omnigent server URL, e.g.
``"http://127.0.0.1:6767"``.
:param timeout_s: Max seconds to wait for setup, e.g. ``600.0``.
:param progress: Active startup spinner, if any. Cleared before the
interactive setup prompt below so the spinner doesn't animate over
it. ``None`` (the default) when no spinner is running.
:raises click.ClickException: If setup does not complete in time.
"""
from omnigent import cli_auth
# Already authenticated to this server — nothing to wait for.
if cli_auth.load_token(base_url) is not None:
return
try:
info = httpx.get(f"{base_url}/v1/info", timeout=5.0).json()
except (httpx.HTTPError, ValueError):
# /v1/info unreachable / unparseable: don't block — let the normal
# path run and surface any real error.
return
if not (isinstance(info, dict) and info.get("accounts_enabled") and info.get("needs_setup")):
# Header / OIDC, or an admin already exists (token minted at boot):
# the normal headers/auth path handles it.
return
# We're about to print an interactive prompt and poll — drop the startup
# spinner first so it doesn't render over the message.
if progress is not None:
progress.finish()
setup_url = base_url.rstrip("/")
click.echo(
"\n Accounts mode is enabled and needs a one-time admin account.\n"
f" Open {setup_url} in your browser to create it"
" (it may have opened automatically),\n"
" then come back here. Waiting for setup to complete… (Ctrl-C to cancel)\n"
)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
time.sleep(_ACCOUNTS_SETUP_POLL_INTERVAL_S)
if cli_auth.load_token(base_url) is not None:
click.echo(" ✓ Admin created — signed in. Continuing.\n")
return
raise click.ClickException(
f"Timed out after {timeout_s:.0f}s waiting for admin setup at {setup_url}. "
"Create the admin in the browser, then re-run."
)
async def _prepare_chat_session_via_daemon(
*,
base_url: str,
headers: dict[str, str],
auth: httpx.Auth | None,
host_id: str,
bundle: bytes,
resume_conversation_id: str | None,
fork_session_id: str | None,
workspace: str,
progress: RunnerStartupProgress | None = None,
) -> _DaemonChatSession:
"""
Create/resolve a chat session and launch a daemon-owned runner for it.
Resolves the target session — fork > resume > fresh create — then asks
the daemon to spawn a runner bound to it (the daemon owns the runner;
the CLI only attaches the REPL afterward). Mirrors claude-native's
``_prepare_claude_terminal_via_daemon`` minus the terminal bring-up.
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:8123"``.
:param headers: Static HTTP auth headers (empty for a loopback server).
:param auth: Per-request ``httpx.Auth`` for token refresh on the SDK
client, or ``None`` for a loopback server.
:param host_id: This machine's host id, e.g. ``"host_abc123"``.
:param bundle: Gzipped agent bundle for a fresh session create.
:param resume_conversation_id: Existing conversation id to attach to,
or ``None`` to create a fresh session.
:param fork_session_id: When set, fork this session and bind the runner
to the fork; takes precedence over *resume_conversation_id*.
:param workspace: Absolute host path for the runner cwd, e.g.
``"/Users/me/proj"``.
:param progress: Optional startup-progress handle whose label is
advanced through plain-language phases ("Connecting…",
"Launching your agent…") as the host and runner come online, so a
slow cold start is not silent. ``None`` (the default) runs without
any progress updates.
:returns: The prepared session id + bound runner id.
:raises click.ClickException: If session create/fork or runner launch
fails.
"""
from omnigent_client import OmnigentClient
from omnigent._runner_startup import (
STARTUP_PHASE_CONNECTING,
STARTUP_PHASE_LAUNCHING_AGENT,
)
from omnigent.host.daemon_launch import (
launch_or_reuse_daemon_runner,
wait_for_host_online,
wait_for_runner_online,
)
from omnigent.native_terminal import bind_session_runner
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
if fork_session_id is not None:
fork_result = await sdk.sessions.fork(fork_session_id)
session_id = fork_result["id"]
elif resume_conversation_id is not None:
session_id = resume_conversation_id
else:
created = await sdk.sessions.create(
bundle, filename="agent.tar.gz", workspace=workspace
)
session_id = created.id
# A separate raw httpx client for the host-runner protocol (the daemon
# launch helpers operate on httpx, not the SDK).
timeout = httpx.Timeout(30.0, read=120.0)
async with httpx.AsyncClient(
base_url=base_url, headers=headers, auth=auth, timeout=timeout
) as client:
if progress is not None:
progress.update(STARTUP_PHASE_CONNECTING)
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S)
if progress is not None:
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
runner_id = await launch_or_reuse_daemon_runner(
client, host_id=host_id, session_id=session_id, workspace=workspace
)
await wait_for_runner_online(
client, runner_id, timeout_s=_DAEMON_CHAT_RUNNER_ONLINE_TIMEOUT_S
)
# launch_or_reuse_daemon_runner's atomic-bind / online-reuse paths
# don't pass through replace_runner_id, so re-bind via PATCH to
# clear the ``omnigent.stopped`` marker on resumed sessions. Must run
# AFTER wait_for_runner_online — a freshly launched runner isn't
# registered until then, and replace_runner_id 400s on an unregistered id.
await bind_session_runner(client, session_id, runner_id)
return _DaemonChatSession(session_id=session_id, runner_id=runner_id)
def _chat_via_daemon(
agent_path: str,
base_url: str,
tool_handler: ToolHandler | None,
*,
overrides: ChatOverrides,
initial_message: str | None = None,
resume_conversation_id: str | None = None,
resume_latest: bool = False,
resume_picker: bool = False,
fork_session_id: str | None = None,
log: bool = False,
debug_events: bool = False,
resume_parts: list[str] | None = None,
auto_open_conversation: bool = False,
) -> None:
"""
Run a local agent against a daemon-backed server with a daemon-owned runner.
Uploads the agent as a session, asks the host daemon to spawn the
runner bound to that session (so the daemon owns its lifecycle), then
attaches the REPL — the CLI never spawns or tears down the runner. On a
clean exit the server idle-reaps the runner; if it dies mid-session the
server relaunches it (host-bound auto-relaunch).
:param agent_path: Local YAML path or directory.
:param base_url: Resolved Omnigent server base URL (the daemon is already
ensured for it), e.g. ``"http://127.0.0.1:8123"``.
:param tool_handler: Optional client-side tool handler.
:param overrides: CLI overrides to bake into the uploaded spec.
:param initial_message: Optional one-shot input (``-p``).
:param resume_conversation_id: Explicit conversation id
(``--resume <id>``).
:param resume_latest: ``True`` for ``--continue`` / ``-c``.
:param resume_picker: ``True`` for bare ``--resume`` / ``-r``.
:param fork_session_id: When set, fork this session and attach to the fork.
:param log: When ``True``, write a session log on REPL exit.
:param debug_events: When ``True``, enable the SSE-to-UI debug pipeline.
:param resume_parts: Argument-list prefix for the resume hint on exit.
:param auto_open_conversation: When ``True``, open the browser
conversation URL once the session id is known.
:returns: None.
"""
from omnigent.host.identity import load_or_create_host_identity
path = Path(agent_path)
if not path.exists():
raise click.ClickException(f"Agent path not found: {agent_path}")
path = _canonicalize_local_agent_path(path)
from omnigent._runner_startup import (
STARTUP_PHASE_PREPARING_AGENT,
runner_startup_progress,
)
spec_path = _materialize_override_bundle(path, overrides)
try:
# One spinner spans the entire agent/runner bring-up — bundle prep,
# session upload, host + runner coming online, and the
# wrapper-redirect probe inside ``_chat_with_server`` — and is torn
# down (via ``progress.finish()``) exactly when the REPL is about to
# paint. Holding a single spinner across these steps means there is
# never an empty, cleared gap between them; the label can lag the
# actual step (it stays on the last phase through the tail of
# bring-up), which is the accepted trade for "no blank gaps". The
# local-server cold start before this is covered by a sibling spinner
# in ``_ensure_backend``.
with runner_startup_progress(initial_message=STARTUP_PHASE_PREPARING_AGENT) as progress:
_validate_agent_spec(spec_path)
agent_spec = load_spec(spec_path)
agent_name = agent_spec.name or _fallback_label(spec_path)
all_skills = _merge_host_skills(agent_spec, spec_path)
bundle_bytes = _bundle_agent(spec_path)
# Accounts first-run: if the (re)spawned local server is in
# accounts mode awaiting its one-time admin and this CLI has no
# credential yet, block here with a clear prompt until setup
# completes (the server then mints our CLI token), so we continue
# into the REPL instead of 401-ing on the first authenticated call
# below. Resolved BEFORE the headers/auth below so they pick up the
# freshly-written token. ``progress`` is threaded so it can clear
# the spinner before printing its interactive prompt.
_await_accounts_first_run_setup(base_url, progress=progress)
headers = _remote_headers(server_url=base_url)
auth = _server_auth(server_url=base_url)
host_id = load_or_create_host_identity().host_id
workspace = str(Path.cwd().resolve())
# The interactive resume picker reads stdin, so clear the spinner
# first — it must not animate over the prompt. ``--continue`` and an
# explicit ``--resume <id>`` are silent lookups and keep the spinner.
if resume_picker:
progress.finish()
# Resolve --continue / --resume / picker to a concrete conversation
# id. Fork is resolved server-side inside the prep step, so skip it.
effective_resume_id = (
None
if fork_session_id is not None
else _resolve_resume_target(
base_url=base_url,
agent_name=agent_name,
resume_conversation_id=resume_conversation_id,
resume_latest=resume_latest,
resume_picker=resume_picker,
headers=headers,
)
)
prepared = asyncio.run(
_prepare_chat_session_via_daemon(
base_url=base_url,
headers=headers,
auth=auth,
host_id=host_id,
bundle=bundle_bytes,
resume_conversation_id=effective_resume_id,
fork_session_id=fork_session_id,
workspace=workspace,
progress=progress,
)
)
# Attach the REPL to the prepared session. ``resume_conversation_id``
# makes the sessions adapter attach (get) instead of creating from
# the bundle; the bundle is still passed so the one-shot path takes
# its sessions-API branch. ``runner_recover=None``: the daemon owns
# the runner — a dead runner is relaunched server-side, and the SDK
# client refreshes its own auth per request. ``progress`` is handed
# off so ``_chat_with_server`` clears the spinner the instant before
# the REPL paints (or before it redirects to a native wrapper).
_chat_with_server(
base_url,
tool_handler,
initial_message=initial_message,
resume_conversation_id=prepared.session_id,
fork_session_id=None,
agent_name=agent_name,
runner_id=prepared.runner_id,
runner_recover=None,
log=log,
agent_yaml=spec_path,
session_bundle=bundle_bytes,
debug_events=debug_events,
resume_parts=resume_parts,
skills=all_skills or None,
auto_open_conversation=auto_open_conversation,
progress=progress,
)
finally:
_cleanup_materialized_override_bundle(spec_path)
def _wait_for_remote_runner(
base_url: str,
runner_id: str,
headers: dict[str, str],
runner_proc: subprocess.Popen[bytes],
timeout: float = 60.0,
*,
log_path: Path | None = None,
show_progress: bool = True,
) -> None:
"""Wait until the remote server sees the local runner tunnel.
:param base_url: Remote server base URL with no trailing slash,
e.g. ``"https://example.databricksapps.com"``.
:param runner_id: Runner id the local process advertises, e.g.
``"runner_0123456789abcdef"``.
:param headers: Auth headers for the remote server.
:param runner_proc: Spawned local runner subprocess.
:param timeout: Max seconds to wait for registration.
:param log_path: Optional path to the captured runner log
produced by ``_start_cli_runner_process(capture_logs=True)``,
e.g. ``Path("~/.omnigent/logs/runner/runner-abcd.log")``.
Included (with a tail) in the error message when the
runner fails to register so users can diagnose the root
cause without hunting for the file.
:param show_progress: When ``True`` (default), render a rich
spinner on stderr while polling. Set to ``False`` for
callers running after the terminal has entered raw mode
(e.g. the ``claude-native`` reconnect path) where a
rich-rendered line would corrupt the attached PTY.
Auto-falls back to plain ``click.echo`` updates on a
non-TTY stderr; see :mod:`omnigent._runner_startup`.
:returns: None.
:raises click.ClickException: If the runner exits early or the
server does not report it online before timeout. The
exception message includes the runner log path and a
~20-line tail when ``log_path`` is provided.
"""
from omnigent._runner_startup import (
runner_startup_progress,
)
host_label = base_url.split("://", 1)[-1]
initial_msg = f"Starting local runner (waiting for {host_label})\u2026"
# The two branches return different concrete CMs (rich Status
# vs nullcontext) but both honor the ``with``-statement
# protocol. ``Any`` keeps mypy from forcing a structural cast
# while still allowing the unified branch below.
progress_cm: contextlib.AbstractContextManager[Any] # type: ignore[explicit-any]
if show_progress:
progress_cm = runner_startup_progress(initial_message=initial_msg)
else:
progress_cm = contextlib.nullcontext()
with progress_cm:
_poll_remote_runner(
base_url=base_url,
runner_id=runner_id,
headers=headers,
runner_proc=runner_proc,
timeout=timeout,
log_path=log_path,
)
return
def _poll_remote_runner(
*,
base_url: str,
runner_id: str,
headers: dict[str, str],
runner_proc: subprocess.Popen[bytes],
timeout: float,
log_path: Path | None,
) -> None:
"""
Poll the server's runner-status endpoint until ``online=true``.
Extracted from :func:`_wait_for_remote_runner` so the polling
logic is independent of the progress renderer wrapping it.
Tests that patch ``time.monotonic`` / ``time.sleep`` /
``httpx.get`` target this function directly without needing
to suppress the rich spinner.
:param base_url: Remote server base URL with no trailing slash.
:param runner_id: Runner id to poll for.
:param headers: Auth headers for the remote server.
:param runner_proc: Spawned local runner subprocess. Used to
detect early exit so the caller does not poll a dead
runner for the full timeout.
:param timeout: Max seconds to wait for registration.
:param log_path: Captured runner log path threaded into the
``ClickException`` raised on failure. ``None`` skips the
log-tail block in the error message.
:returns: None on successful registration.
:raises click.ClickException: Same conditions as
:func:`_wait_for_remote_runner`.
"""
from omnigent._runner_startup import format_runner_log_tail
start = time.monotonic()
deadline = start + timeout
status_url = f"{base_url}/v1/runners/{runner_id}/status"
last_error: httpx.HTTPError | None = None
last_status: int | None = None
while time.monotonic() < deadline:
if runner_proc.poll() is not None:
raise click.ClickException(
f"Local runner exited early with code {runner_proc.returncode}."
f"{format_runner_log_tail(log_path)}"
)
try:
resp = httpx.get(status_url, headers=headers, timeout=2.0)
if resp.status_code == 200 and resp.json().get("online") is True:
return
last_status = resp.status_code
if resp.status_code in {401, 403}:
raise click.ClickException(
f"Remote runner status check was rejected ({resp.status_code}); "
"run `omnigent login <server-url>` or check remote auth credentials."
f"{format_runner_log_tail(log_path)}"
)
except httpx.HTTPError as exc:
last_error = exc
elapsed = time.monotonic() - start
poll_interval = (
_SERVER_READY_INITIAL_POLL_SECONDS
if elapsed < _SERVER_READY_FAST_POLL_WINDOW_SECONDS
else _SERVER_READY_BACKOFF_POLL_SECONDS
)
time.sleep(poll_interval)
detail = ""
if last_status is not None:
detail = f" Last status check returned HTTP {last_status}."
elif last_error is not None:
detail = f" Last status check failed: {last_error}."
raise click.ClickException(
f"Local runner did not register with {base_url} within {timeout:.0f}s."
f"{detail}{format_runner_log_tail(log_path)}"
)
def _bundle_agent(agent_path: Path) -> bytes:
"""
Build a gzipped agent bundle for ``POST /v1/sessions``.
Keeps the import of the CLI bundler local to avoid loading the
full click command tree at module import time.
:param agent_path: Local YAML file or agent directory.
:returns: Gzipped tarball bytes suitable for the sessions
multipart ``bundle`` part.
:raises OmnigentError: If bundling fails, for example due to
unresolved environment variables.
"""
from omnigent.cli import _bundle
return _bundle(agent_path)
# ---------------------------------------------------------------------------
# Local mode
# ---------------------------------------------------------------------------
def _chat_local(
agent_path: str,
tool_handler: ToolHandler | None,
*,
overrides: ChatOverrides | None = None,
initial_message: str | None = None,
ephemeral: bool = False,
resume_conversation_id: str | None = None,
resume_latest: bool = False,
resume_picker: bool = False,
fork_session_id: str | None = None,
log: bool = False,
debug_events: bool = False,
resume_parts: list[str] | None = None,
auto_open_conversation: bool = False,
) -> None:
"""
Start a local server with the agent and open the REPL.
The spec is parsed and validated in-process before launching the
server subprocess so that config errors (unresolved env vars,
invalid YAML, missing required fields) surface with the real
exception message instead of being lost to the subprocess's
silenced stderr.
When *overrides* has any non-None field (or the YAML declares
neither harness nor model), the source spec is materialized into
a temp directory with the overrides + default-model fallback
baked into its ``executor`` block, and the server is pointed at
that copy. The user's source YAML is never mutated.
:param agent_path: Path to the agent directory or bundle.
:param tool_handler: Optional client-side tool handler.
:param overrides: CLI overrides to bake into the spec before
starting the server. ``None`` means no override (same shape
as ``ChatOverrides()`` with all-None fields).
:param initial_message: If set without a resume target, run one
request and exit. If set with a resume target, auto-send on
REPL start.
:param ephemeral: When ``True``, point the local server at a
fresh per-run tmpdir for its data store. ``False``
(default) uses the persistent ``~/.omnigent``
location so prior conversations remain reachable —
see designs/RUN_OMNIGENT_SESSION_RESUMPTION.md.
:param resume_conversation_id: When set, open the REPL
attached to this existing conversation rather than
creating a fresh one.
:param resume_latest: When ``True``, resolve "the most
recent conversation for this agent" via the API
after the server boots and use it as the resume
target. Ignored when *resume_conversation_id* is
already set. Maps to ``--continue`` on the CLI.
:param resume_picker: When ``True``, open the
interactive picker after the server boots. Maps to
``--resume`` / ``-r`` with no value on the CLI.
:param log: When ``True``, write a JSON dump of the active
conversation to ``~/.omnigent/logs/`` on REPL exit.
Maps to ``--log`` on the CLI.
:param debug_events: When ``True``, enable the SSE-to-UI debug
pipeline. Forwarded to ``_chat_with_server``.
:param auto_open_conversation: When ``True``, open the
browser conversation URL when the session id becomes known.
"""
path = Path(agent_path)
if not path.exists():
raise click.ClickException(f"Agent path not found: {agent_path}")
path = _canonicalize_local_agent_path(path)
effective_overrides = overrides if overrides is not None else ChatOverrides()
spec_path = _materialize_override_bundle(path, effective_overrides)
try:
# Parse once: validate + extract name + skills in a single pass.
# Wraps the same exceptions as _validate_agent_spec so config
# errors surface as clean ClickExceptions.
try:
agent_spec = load_spec(spec_path)
except (OmnigentError, FileNotFoundError) as exc:
raise click.ClickException(str(exc)) from exc
agent_name = agent_spec.name or _fallback_label(spec_path)
all_skills = _merge_host_skills(agent_spec, spec_path)
port = _find_free_port()
server = _start_local_server(
spec_path,
port,
ephemeral=ephemeral,
)
try:
_wait_for_server(port, server)
base_url = f"http://127.0.0.1:{port}"
_web_ui_dist = Path(__file__).parent / "server" / "static" / "web-ui"
if _web_ui_dist.is_dir() and (_web_ui_dist / "index.html").is_file():
console.print(f"\n Web UI: [bold]{base_url}[/bold]")
console.print(" Open in your browser for a visual interface\n")
effective_resume_id = _resolve_resume_target(
base_url=base_url,
agent_name=agent_name,
resume_conversation_id=resume_conversation_id,
resume_latest=resume_latest,
resume_picker=resume_picker,
)
bundle_bytes = _bundle_agent(spec_path)
_chat_with_server(
base_url,
tool_handler,
agent_name=agent_name,
initial_message=initial_message,
resume_conversation_id=effective_resume_id,
runner_id=server.runner_id,
fork_session_id=fork_session_id,
log=log,
agent_yaml=spec_path,
session_bundle=bundle_bytes,
ephemeral=ephemeral,
debug_events=debug_events,
server_log_path=server.log_path,
resume_parts=resume_parts,
skills=all_skills or None,
auto_open_conversation=auto_open_conversation,
)
finally:
_stop_local_server(server)
finally:
_cleanup_materialized_override_bundle(spec_path)
def _run_local_headless_prompt(
agent_path: str,
tool_handler: ToolHandler | None,
*,
overrides: ChatOverrides,
prompt: str,
ephemeral: bool = False,
) -> None:
"""
Start a local server, run one prompt, print response, and stop.
:param agent_path: Local YAML file or agent directory.
:param tool_handler: Optional client-side tool handler.
:param overrides: CLI overrides to bake into the spec.
:param prompt: User prompt for the single turn.
:param ephemeral: When ``True``, use a fresh per-run local
server database and artifact directory.
:returns: None.
"""
path = Path(agent_path)
if not path.exists():
raise click.ClickException(f"Agent path not found: {agent_path}")
path = _canonicalize_local_agent_path(path)
spec_path = _materialize_override_bundle(path, overrides)
try:
_validate_agent_spec(spec_path)
agent_name = _extract_agent_name(spec_path)
port = _find_free_port()
server = _start_local_server(
spec_path,
port,
ephemeral=ephemeral,
)
try:
_wait_for_server(port, server)
_run_headless_prompt(
f"http://127.0.0.1:{port}",
agent_name,
tool_handler,
prompt=prompt,
runner_id=server.runner_id,
session_bundle=_bundle_agent(spec_path),
)
finally:
_stop_local_server(server)
finally:
_cleanup_materialized_override_bundle(spec_path)
def _run_headless_prompt(
base_url: str,
agent_name: str,
tool_handler: ToolHandler | None,
*,
prompt: str,
runner_id: str | None = None,
session_bundle: bytes | None = None,
session_bundle_filename: str = "agent.tar.gz",
) -> None:
"""
POST one prompt through the SDK and print the final assistant text.
Uses the sessions API: create the session, bind the runner,
send one message, print text, and return.
:param base_url: Server base URL, e.g.
``"http://127.0.0.1:8123"``.
:param agent_name: Agent display name, e.g. ``"hello_world"``.
:param tool_handler: Optional client-side tool handler.
:param prompt: User prompt for the single turn.
:param runner_id: Registered runner id, e.g.
``"runner_0123456789abcdef"``. Required with
*session_bundle*.
:param session_bundle: Optional gzipped agent bundle bytes.
:param session_bundle_filename: Multipart filename, e.g.
``"agent.tar.gz"``.
:raises SystemExit: Exits with code 1 after printing the server
error text when the stream emits ``response.error`` or
returns ``ResponseFailed`` without output text.
:returns: None.
"""
async def _main() -> None:
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
) as client:
if session_bundle is not None:
result_text = await _query_sessions_once(
client=client,
agent_name=agent_name,
tool_handler=tool_handler,
prompt=prompt,
session_bundle=session_bundle,
session_bundle_filename=session_bundle_filename,
runner_id=runner_id,
)
if result_text:
print(result_text)
return
session = client.session(model=agent_name, tool_handler=tool_handler)
chunks: list[str] = []
terminal_text: str | None = None
error_text: str | None = None
async for event in session.send(prompt):
if isinstance(event, TextDelta):
chunks.append(event.delta)
elif isinstance(event, ErrorEvent):
error_text = event.error.message or event.error.code
elif isinstance(
event,
ResponseCompleted | ResponseFailed | ResponseIncomplete | ResponseCancelled,
):
terminal_text = _response_output_text(event.response.output)
streamed_text = "".join(chunks)
# Prefer the real error from a response.error SSE event over the
# generic terminal-event message ("Failed to retrieve final response")
# that _build_terminal_event substitutes when it can't read the task.
if streamed_text:
print(streamed_text)
elif error_text:
print(f"Error: {error_text}", file=sys.stderr)
raise SystemExit(1)
elif terminal_text:
print(terminal_text)
try:
asyncio.run(_main())
except ClientOmnigentError as exc:
# SETUP-phase failure: SessionsChat.send raises on a terminal
# ``session.status: failed`` (no response.failed is emitted).
# Surface it the same way as a response.error event so headless
# ``-p`` exits non-zero with the real message.
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
async def _query_sessions_once(
*,
client: OmnigentClient,
agent_name: str,
tool_handler: ToolHandler | None,
prompt: str,
session_bundle: bytes,
session_bundle_filename: str,
runner_id: str | None,
resume_conversation_id: str | None = None,
on_session_ready: Callable[[str], None] | None = None,
) -> str | None:
"""
Create, bind, and query a sessions-API session for headless ``-p``.
:param client: Connected SDK client.
:param agent_name: Agent display name, e.g. ``"hello_world"``.
Used only for tool-handler validation messages.
:param tool_handler: Optional client-side tool handler.
:param prompt: User prompt for the single turn.
:param session_bundle: Gzipped agent tarball bytes.
:param session_bundle_filename: Multipart filename, e.g.
``"agent.tar.gz"``.
:param runner_id: Registered runner id, e.g.
``"runner_0123456789abcdef"``.
:param resume_conversation_id: When set, resumes an existing
session instead of creating a new one, e.g.
``"conv_abc123"``. ``None`` creates a fresh session.
:param on_session_ready: Optional callback invoked after the
session has been created/resumed and bound to the runner.
:returns: Final assistant text, or ``None`` when no text was
emitted.
:raises RuntimeError: If no runner id was supplied.
"""
from omnigent_client import SessionsChat
if runner_id is None:
raise RuntimeError(
"Sessions API headless prompt requires a registered runner id. "
"Start through `omnigent run <agent>` or pass --server so the CLI "
"can launch and bind a runner."
)
tool_callables = _sessions_tool_callables(tool_handler, agent_name)
if resume_conversation_id is not None:
bound = await client.sessions.get(resume_conversation_id)
await client.sessions.bind_runner(resume_conversation_id, runner_id=runner_id)
else:
created = await client.sessions.create(
session_bundle,
filename=session_bundle_filename,
# Record CLI cwd so the Web UI can show "ran locally
# in <workspace>" for one-shot sessions. CLI sessions
# don't set host_id; this column is purely informational.
workspace=os.getcwd(),
)
bound = await client.sessions.bind_runner(created.id, runner_id=runner_id)
if on_session_ready is not None:
on_session_ready(bound.id)
session_files = client.files.for_session(bound.id)
chat = SessionsChat(
namespace=client.sessions,
files_uploader=session_files.upload,
files_getter=session_files.get,
session=bound,
tool_callables=tool_callables,
agent_tools_getter=client._fetch_agent_tools,
)
del agent_name
# A transport-level runner disconnect publishes ``session.status:
# failed`` for every session pinned to that runner (server
# ``_on_runner_disconnect``), even when the turn already completed
# and its assistant response was persisted. ``SessionsChat.send``
# raises ``OmnigentError`` on that ``failed`` status, and the
# no-replay SSE subscription can additionally miss the terminal
# ``response.completed`` event (subscribe-after-post race), leaving
# the collected text empty. In both cases the runner has still
# persisted the assistant message server-side, so reconcile against
# the transcript before surfacing a failure — only a turn that
# produced no output is a genuine error worth raising. The
# interactive REPL is immune by construction (it renders a ``failed``
# status as a transient error and polls the snapshot as a backstop),
# so this brings headless ``-p`` to parity.
try:
result = await chat.query(prompt)
except ClientOmnigentError:
reconciled = await _persisted_turn_text(client, bound.id)
if reconciled is not None:
return reconciled
raise
all_text_parts: list[str] = []
if result.text:
all_text_parts.append(result.text)
elif (reconciled := await _persisted_turn_text(client, bound.id)) is not None:
all_text_parts.append(reconciled)
# Multi-turn loop for async orchestrators (e.g. polly) that dispatch
# sub-agents and are auto-woken by inbox completions across multiple
# turns.
#
# Why not _collect_query for the fast-exit signal: the runtime emits
# ``session.status: waiting`` AFTER ``response.completed`` (the runner
# finishes dispatching tools, then enters the async drain). _collect_query
# exits at CompletedEvent and never sees the subsequent "waiting".
#
# Why not refresh() for the fast-exit signal: the snapshot API collapses
# the ``"waiting"`` relay status to ``"idle"`` once the turn loop exits,
# even while sub-agents are still running.
#
# Probe approach: subscribe to the live stream for a short window after
# the first turn. The "waiting" event arrives O(mss) after CompletedEvent
# (runner dispatches tools, spawns sub-agents, then parks). The probe
# catches it before sub-agents have a chance to complete.
#
# ``await_turn`` resets ``last_turn_saw_waiting`` to False on
# ``session.status: running`` (synthesis starting), so the flag cleanly
# reflects only the most recent dispatch state after each call.
#
# Single-turn agents: no "waiting" event ever → probe times out in
# _STATUS_PROBE_TIMEOUT_S (~30 s) and the loop exits.
_MAX_EXTRA_TURNS = 30
# The runner emits session.status:waiting (not idle) when a turn ends with
# running sub-agents. The relay cache holds "waiting", which the snapshot
# collapses to "running". refresh() is therefore the authoritative signal:
# "running" → async orchestrator still waiting for inbox; "idle" → done.
#
# A short probe await_turn runs first: it catches synthesis text or the
# status event if the subscription opens before the event arrives. Both
# "waiting" and "idle" break the probe immediately so the generator closes
# cleanly without hitting the timeout.
#
# refresh() is called after every await_turn (probe + loop) — it is correct
# even when await_turn times out (sub-agents still running), unlike the
# last_turn_saw_waiting flag which would incorrectly exit on timeout.
_STATUS_PROBE_TIMEOUT_S = 5.0 # brief window; status events arrive fast
_PER_TURN_TIMEOUT_S = 120.0 # race-window guard per synthesis turn
_LOOP_TIMEOUT_S = 1800.0 # 30 min total
async def _drain_extra_turns() -> None:
# Probe: collect synthesis text or status events that arrive quickly.
probe = await chat.await_turn(timeout=_STATUS_PROBE_TIMEOUT_S)
if probe.text:
all_text_parts.append(probe.text)
# refresh() is the authoritative check: "running" means the runner's
# relay cache holds "waiting" (sub-agents still running); "idle" means
# truly done (single-turn agent, or synthesis completed in the probe).
await chat.refresh()
if chat.status not in ("running", "launching"):
return
# Async orchestrator confirmed. Loop, refreshing after each turn.
for _ in range(_MAX_EXTRA_TURNS):
extra = await chat.await_turn(timeout=_PER_TURN_TIMEOUT_S)
if extra.text:
all_text_parts.append(extra.text)
await chat.refresh()
if chat.status not in ("running", "launching"):
return # Idle: synthesis done or all sub-agents complete.
logger.warning(
"headless -p hit the %d-turn guard for session %s; "
"the orchestrator may still be running",
_MAX_EXTRA_TURNS,
bound.id,
)
try:
async with asyncio.timeout(_LOOP_TIMEOUT_S):
await _drain_extra_turns()
except asyncio.TimeoutError:
logger.warning(
"headless -p timed out after %.0fs waiting for session %s to complete",
_LOOP_TIMEOUT_S,
bound.id,
)
if all_text_parts:
return "\n\n".join(p for p in all_text_parts if p)
# No assistant text at all. If the runner persisted a terminal
# ``error`` item (e.g. a harness start failure like the cursor SDK's
# invalid-model rejection), surface it instead of returning ``None`` —
# otherwise the headless caller renders a failed turn as a silent,
# exit-0 empty success. The callers wrap this in ``except
# ClientOmnigentError`` and print the message to stderr + exit non-zero.
turn_error = await _persisted_turn_error(client, bound.id)
if turn_error is not None:
raise ClientOmnigentError(turn_error)
return None
def _sessions_tool_callables(
tool_handler: ToolHandler | None,
agent_name: str,
) -> dict[str, ToolCallable] | None:
"""
Convert a legacy tool handler into sessions-API callables.
:param tool_handler: Optional legacy client-side tool handler.
:param agent_name: Agent display name for legacy tool context,
e.g. ``"coding_supervisor"``.
:returns: Mapping from declared tool name to callable, or
``None`` when no handler is configured.
"""
if tool_handler is None:
return None
adapter = _SessionToolAdapter(tool_handler=tool_handler, agent_name=agent_name)
callables: dict[str, ToolCallable] = {}
for schema in tool_handler.schemas:
raw_name = schema.get("name")
if not isinstance(raw_name, str):
continue
callables[raw_name] = adapter
return callables
_ResponseOutput: TypeAlias = list[dict[str, Any]] # type: ignore[explicit-any]
def _response_output_text(output: _ResponseOutput) -> str | None:
"""Extract assistant text from an Omnigent response ``output`` list."""
parts: list[str] = []
for item in output:
if not isinstance(item, dict) or item.get("type") != "message":
continue
content = item.get("content")
if not isinstance(content, list):
continue
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") in {"output_text", "text"}:
text = part.get("text")
if isinstance(text, str):
parts.append(text)
return "".join(parts) if parts else None
async def _persisted_turn_text(
client: OmnigentClient,
session_id: str,
) -> str | None:
"""
Read the latest turn's persisted assistant text from a session.
The headless ``-p`` path consumes a turn over a single no-replay
SSE subscription. Two failure modes leave that subscription without
the turn's text even though the runner persisted an assistant
response server-side:
* A transport-level runner disconnect publishes
``session.status: failed`` for the session (server
``_on_runner_disconnect``) after the turn completed;
:meth:`SessionsChat.send` raises ``OmnigentError`` on it.
* The subscriber misses the terminal ``response.completed`` event
(subscribe-after-post race), so the collected text is empty.
This reconciles against the durable transcript via
``GET /v1/sessions/{id}/items``. It anchors on the most recent
user message and returns the concatenated ``output_text`` of every
``completed`` assistant message that follows it, so a resumed
session's earlier-turn output is never mistaken for the current
turn. Only ``completed`` assistant items count: a turn that truly
errored mid-stream persists a non-``completed`` partial item, and
masking that as success would swallow a genuine failure — whereas
the target bug (a completed turn flipped to ``failed`` by a
transport disconnect) always persists a ``completed`` item.
:param client: Connected SDK client bound to the session's server.
:param session_id: Session/conversation identifier, e.g.
``"conv_abc123"``.
:returns: The current turn's assistant text, or ``None`` when this
turn persisted no ``completed`` assistant text (a genuine
failure the caller should surface).
"""
try:
# ``order="desc"`` (newest first) so the window tracks the END
# of the transcript — the current turn — not its start. A long
# resumed session can have far more than the limit of history;
# fetching ``asc`` would return the oldest items and miss this
# turn entirely.
recent: _ResponseOutput = await client.sessions.list_items(
session_id, limit=_RECONCILE_ITEMS_LIMIT, order="desc"
)
except ClientOmnigentError as exc:
# The reconcile read is itself best-effort: if the items
# endpoint is unreachable, fall back to the original outcome
# (the caller re-raises the turn error or prints nothing). Log
# for observability rather than swallowing silently.
logger.debug("reconcile transcript read failed for %s: %r", session_id, exc)
return None
# Walk newest → oldest, collecting ``completed`` assistant messages
# until the current turn's user message is reached. This isolates
# THIS turn's output: a prior turn's assistant text sits on the far
# side of the current user message and is never collected.
this_turn_assistant: _ResponseOutput = []
for item in recent:
if item.get("type") != "message":
continue
role = item.get("role")
if role == "user":
break # reached the start of the current turn
if role == "assistant" and item.get("status") == "completed":
this_turn_assistant.append(item)
# Restore chronological order so multi-message output joins correctly.
this_turn_assistant.reverse()
return _response_output_text(this_turn_assistant)
async def _persisted_turn_error(
client: OmnigentClient,
session_id: str,
) -> str | None:
"""Read the latest turn's persisted terminal error message, if any.
Companion to :func:`_persisted_turn_text`. When a turn produced no
``completed`` assistant text, the runner may still have persisted a
terminal ``error`` item — e.g. a harness *start* failure such as the
cursor SDK rejecting an unknown model. Without this, the headless ``-p``
path renders that as a silent, exit-0 empty success; returning the message
lets the caller surface it and exit non-zero.
Mirrors :func:`_persisted_turn_text`'s walk: newest → oldest, stopping at
the current turn's user message, so a prior turn's error is never
attributed to this turn.
:param client: Connected SDK client bound to the session's server.
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
:returns: The current turn's terminal error message, or ``None``.
"""
try:
recent: _ResponseOutput = await client.sessions.list_items(
session_id, limit=_RECONCILE_ITEMS_LIMIT, order="desc"
)
except ClientOmnigentError as exc:
logger.debug("reconcile error read failed for %s: %r", session_id, exc)
return None
for item in recent:
if item.get("type") == "message" and item.get("role") == "user":
break # reached the start of the current turn
if item.get("type") == "error":
message = item.get("message")
if isinstance(message, str) and message:
return message
return None
def _resolve_resume_target(
*,
base_url: str,
agent_name: str,
resume_conversation_id: str | None,
resume_latest: bool,
resume_picker: bool = False,
headers: dict[str, str] | None = None,
) -> str | None:
"""
Decide which conversation the REPL should resume from.
Doing this here (vs. inside ``run_repl``) gives a clean
fail-fast when ``--continue`` finds no prior conversation:
raise ``ClickException`` before the REPL renders anything,
matching the native shape at
``omnigent/inner/cli.py:3082-3084`` ("No saved sessions
to continue.").
Precedence (highest to lowest):
1. ``resume_conversation_id`` (``--resume <id>``) —
explicit pin always wins.
2. ``resume_picker`` (``--resume`` / ``-r`` with no value) —
interactive picker. Returns ``None`` when the user cancels
(treated as "start fresh"); raises when no conversations
exist.
3. ``resume_latest`` (``--continue`` / ``-c``) —
silent auto-pick of the newest. Raises when no prior.
4. None of the above — fresh conversation.
:param base_url: Server base URL the SDK should target for
the lookup, e.g. ``"http://127.0.0.1:9123"`` or
``"https://example.databricksapps.com"``.
:param agent_name: The agent's registered name from the
YAML's ``name:`` field.
:param resume_conversation_id: An explicit
``--resume <id>``.
:param resume_latest: ``True`` when ``--continue`` was
passed.
:param resume_picker: ``True`` when bare ``--resume`` was
passed. Runs the interactive picker on the agent's
conversations and returns the user's choice. ``None``
return means user cancelled — caller should treat as
"start fresh" rather than a hard error.
:param headers: Optional auth headers for the server,
e.g. ``{"Authorization": "Bearer <token>"}``. Required
for remote servers; ``None`` for localhost.
:returns: The conversation_id to attach to, or ``None``
when no resumption flag matched (or the picker was
cancelled).
:raises click.ClickException: When ``--continue`` /
``--resume`` was requested but the agent has no prior
conversation.
"""
if resume_conversation_id is not None:
# Fail loud on a bogus id instead of booting the REPL, swallowing
# the failed attach, and silently starting fresh (which loses the
# thread the user meant to resume). Mirrors the --continue path.
_assert_resume_conversation_exists(
base_url=base_url,
conversation_id=resume_conversation_id,
headers=headers,
)
return resume_conversation_id
if resume_picker:
# ``None`` from the picker is a clean cancel — pass it
# through so the REPL opens fresh. The empty-list case is
# raised explicitly inside ``_run_picker`` so it lands as
# a ClickException with a parity message.
return _run_picker(
base_url=base_url,
agent_name=agent_name,
headers=headers,
)
if not resume_latest:
return None
resolved = _resolve_latest_conversation_id(
base_url=base_url,
agent_name=agent_name,
headers=headers,
)
if resolved is None:
raise click.ClickException(f"No prior conversation for agent {agent_name!r}.")
return resolved
def _assert_resume_conversation_exists(
*,
base_url: str,
conversation_id: str,
headers: dict[str, str] | None = None,
) -> None:
"""
Fail fast when an explicit ``--resume <id>`` names a conversation
that does not exist on the server.
:param base_url: Server base URL the lookup targets.
:param conversation_id: The id passed to ``--resume``.
:param headers: Optional auth headers for the server.
:raises click.ClickException: When the id is not found (404). Other
errors propagate so a transient failure isn't mislabeled.
"""
async def _lookup() -> None:
async with OmnigentClient(base_url=base_url, headers=headers) as client:
await client.sessions.get(conversation_id)
try:
asyncio.run(_lookup())
except ClientOmnigentError as exc:
if exc.status_code == 404:
raise click.ClickException(f"Conversation {conversation_id!r} not found.") from exc
raise
def _run_picker(
*,
base_url: str,
agent_name: str,
headers: dict[str, str] | None = None,
) -> str | None:
"""
Drive the ``--resume`` picker against a server.
Looks up this agent's id (so the picker only shows THIS
agent's conversations, not pooled across agents that share
the persistent store), fetches the conversation list via the
SDK, and runs the stderr/stdin picker.
:param base_url: Server base URL,
e.g. ``"http://127.0.0.1:9123"`` or
``"https://example.databricksapps.com"``.
:param agent_name: Agent's registered name.
:param headers: Optional auth headers for the server,
e.g. ``{"Authorization": "Bearer <token>"}``. Required
for remote servers; ``None`` for localhost.
:returns: Selected conversation_id, or ``None`` if the user
cancelled.
:raises click.ClickException: When the agent has no prior
conversations — ``--resume`` should fail-loud rather
than silently open a picker the user can only cancel
out of.
"""
from omnigent.repl._resume_picker import pick_conversation_from_sdk
async def _lookup() -> str | None:
async with OmnigentClient(base_url=base_url, headers=headers) as client:
# Multipart ``omnigent run <yaml>`` uploads now create a
# fresh session-scoped agent for every session so users who
# choose the same YAML ``name:`` never share a bundle. Resume
# lookup therefore scopes by the user-authored name across
# those distinct session agents rather than by a template
# agent id returned from ``agents.get_by_name``.
return await pick_conversation_from_sdk(
client,
agent_name=agent_name,
agent_id=None,
agent_name_filter=agent_name,
)
return asyncio.run(_lookup())
def _resolve_latest_conversation_id(
*,
base_url: str,
agent_name: str,
headers: dict[str, str] | None = None,
) -> str | None:
"""
Find the most-recent conversation for *agent_name* on a
server.
Used to translate ``--continue`` into a concrete
``conversation_id`` after the server is reachable. The
server-side filter joins through ``Task.agent_id``, so the
returned conversation is guaranteed to belong to *this
agent* (not pooled across agents that happen to share the
DB).
:param base_url: Server base URL,
e.g. ``"http://127.0.0.1:9123"`` or
``"https://example.databricksapps.com"``.
:param agent_name: The agent's registered name from the
YAML's ``name:`` field.
:param headers: Optional auth headers for the server,
e.g. ``{"Authorization": "Bearer <token>"}``. Required
for remote servers; ``None`` for localhost.
:returns: The conversation_id, or ``None`` if no session
with this agent name has prior conversations.
"""
async def _lookup() -> str | None:
async with OmnigentClient(base_url=base_url, headers=headers) as client:
return await _resolve_latest_conversation_id_async(
client=client,
agent_name=agent_name,
)
return asyncio.run(_lookup())
async def _resolve_latest_conversation_id_async(
*,
client: OmnigentClient,
agent_name: str,
) -> str | None:
"""
Async core of :func:`_resolve_latest_conversation_id`.
Factored out so tests can drive it against an in-process
ASGI test client without spawning a subprocess server +
re-opening a real HTTP connection. The sync entry point
above wraps this with ``asyncio.run`` and an
``OmnigentClient`` connected to a real URL — the path
used in production by ``_chat_local``.
:param client: A connected :class:`OmnigentClient`.
:param agent_name: The agent's registered name.
:returns: The conversation_id of the agent's most-recent
conversation, or ``None`` when the agent has no prior
conversations (first-ever run, or a fresh persistent
store).
"""
sessions = await client.sessions.list(
agent_name=agent_name,
limit=1,
order="desc",
sort_by="updated_at",
)
if not sessions:
return None
return sessions[0].id
def _materialize_override_bundle(source: Path, overrides: ChatOverrides) -> Path:
"""
Copy *source* into a temp dir and apply CLI overrides to its YAML.
Also materializes when the spec is a single-file YAML with no
``executor.harness`` AND no ``executor.model`` — the strict
omnigent validator rejects that shape, and the legacy
argparse CLI used to paper over it by injecting
:data:`_DEFAULT_AD_HOC_MODEL`. This preserves that behavior so
``omnigent run examples/hello_world.yaml`` (minimal spec) still
launches cleanly.
When no override is set and the spec already declares harness or
model, returns *source* unchanged — no temp materialization.
:param source: Path to the agent YAML or directory.
:param overrides: CLI overrides. All-None means "no user
override"; a default-model fallback may still apply.
:returns: Path that the server should register — either the
original *source* or a rewritten copy under a tempdir.
"""
raw_peek = _load_yaml_if_single_file(source)
raw_override_peek = _load_yaml_for_override_peek(source)
needs_fallback = raw_peek is not None and not _spec_declares_harness_or_model(raw_peek)
needs_openai_env_auth = raw_override_peek is not None and _should_materialize_openai_env_auth(
raw_override_peek, overrides
)
if not overrides.has_any and not needs_fallback and not needs_openai_env_auth:
return source
# ``_cleanup_materialized_override_bundle`` removes this tempdir
# once validation, bundling, or the attached REPL/server path no
# longer needs the rewritten spec.
tmpdir = Path(tempfile.mkdtemp(prefix="omnigent-override-"))
try:
if source.is_file():
target = tmpdir / source.name
target.write_bytes(source.read_bytes())
else:
# Copy the whole bundle so bundled tools / sub-agent dirs /
# skills travel with the rewritten config.yaml. The user's
# source tree is never touched.
target_dir = tmpdir / source.name
shutil.copytree(source, target_dir)
config = target_dir / "config.yaml"
if not config.is_file():
raise click.ClickException(f"{source}: directory has no config.yaml to override.")
target = config
raw = yaml.safe_load(target.read_text())
if not isinstance(raw, dict):
raise click.ClickException(
f"{source}: expected YAML mapping at top level, got {type(raw).__name__}"
)
_apply_overrides_to_raw(raw, overrides)
target.write_text(yaml.safe_dump(raw, default_flow_style=False))
materialized = target if source.is_file() else target.parent
_MATERIALIZED_OVERRIDE_DIRS[materialized.resolve()] = tmpdir
return materialized
except Exception:
shutil.rmtree(tmpdir, ignore_errors=True)
raise
def _cleanup_materialized_override_bundle(materialized: Path) -> None:
"""
Remove the temp directory created for a materialized override bundle.
Override materialization can bake provider credentials from the CLI
environment into the copied YAML before bundling. The copy is needed
only while the caller validates, starts a local server, or uploads the
bundle, so delete the tempdir explicitly instead of leaving secrets for
OS temp reaping.
:param materialized: Path returned by
:func:`_materialize_override_bundle`, e.g.
``Path("/tmp/omnigent-override-abc/agent.yaml")``.
:returns: None.
"""
tempdir = _MATERIALIZED_OVERRIDE_DIRS.pop(materialized.resolve(), None)
if tempdir is None:
return
shutil.rmtree(tempdir, ignore_errors=True)
def _load_yaml_for_override_peek(source: Path) -> _YamlMapping | None:
"""
Load the YAML that override materialization would rewrite.
Single-file specs rewrite the file itself. Agent-image
directories rewrite ``config.yaml``. Invalid or non-mapping YAML
returns ``None`` so the normal validation path can surface the
precise user-facing error later.
:param source: Path to a YAML file or agent directory.
:returns: Parsed top-level YAML mapping, or ``None`` when no
rewrite target can be inspected.
"""
if source.is_dir():
config = source / "config.yaml"
if not config.is_file():
return None
parsed = yaml.safe_load(config.read_text())
return parsed if isinstance(parsed, dict) else None
return _load_yaml_if_single_file(source)
def _load_yaml_if_single_file(source: Path) -> _YamlMapping | None:
"""
Load the YAML at *source* if it's a single-file spec; else None.
Directories (omnigent-style with ``config.yaml``) are handled
separately by the materializer — this helper just peeks at the
single-file case so the caller can decide whether the
default-model fallback applies.
:param source: Path to a YAML file or agent directory.
:returns: Parsed top-level dict, or None if *source* is a
directory or the YAML isn't a mapping.
"""
if not source.is_file():
return None
parsed = yaml.safe_load(source.read_text())
return parsed if isinstance(parsed, dict) else None
def _spec_declares_harness_or_model(raw: _YamlMapping) -> bool:
"""
True when the YAML's ``executor:`` block has harness or model.
Either signal is enough for the spec-adapter's harness auto-pick
(``databricks-claude-*`` → ``claude-sdk``, etc.) — the
default-model fallback only kicks in when BOTH are absent.
Recognizes the harness in either shape: a flat ``executor.harness``
or the bundle-style nested ``executor.config.harness`` (e.g.
``examples/polly``). Without the nested check, an unpinned bundle
that declares its harness only under ``config`` would look
harness-less and get force-fed :data:`_DEFAULT_AD_HOC_MODEL` — a
GPT endpoint the claude-sdk harness can't speak.
:param raw: Parsed top-level YAML mapping.
:returns: True if ``executor.harness``, ``executor.model``, or
``executor.config.harness`` is a non-empty value.
"""
executor = raw.get("executor")
if not isinstance(executor, dict):
return False
if executor.get("harness") or executor.get("model"):
return True
config = executor.get("config")
return isinstance(config, dict) and bool(config.get("harness"))
def _should_materialize_openai_env_auth(
raw: _YamlMapping,
overrides: ChatOverrides,
) -> bool:
"""
Return whether materialization would inject OpenAI env credentials.
Daemon-backed local runs launch a daemon-owned runner whose
environment intentionally strips provider secrets. Specs that rely
only on ambient ``OPENAI_API_KEY`` therefore need those credentials
baked into ``executor.auth`` before bundling, but only when the
effective harness is OpenAI-compatible and no explicit spec/provider
auth already wins.
:param raw: Parsed top-level YAML mapping.
:param overrides: CLI overrides that will be applied to ``raw``.
:returns: ``True`` when a rewritten bundle is needed solely to add
``executor.auth`` from ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL``.
"""
executor = raw.get("executor")
executor_block = executor if isinstance(executor, dict) else {}
harness = _effective_openai_auth_harness(raw, executor_block, overrides)
return _should_inject_openai_env_auth_for_executor(executor_block, harness)
def _effective_openai_auth_harness(
raw: _YamlMapping,
executor_block: _YamlMapping,
overrides: ChatOverrides,
) -> str | None:
"""
Resolve the harness relevant to OpenAI env-auth injection.
This mirrors the edge of the normal spec path closely enough for
materialization decisions: explicit CLI harness wins, then YAML
harness, then model-prefix inference from the effective model.
:param raw: Parsed top-level YAML mapping.
:param executor_block: Parsed ``executor`` mapping from ``raw``.
:param overrides: CLI overrides that will be applied.
:returns: Canonical harness name, e.g. ``"openai-agents"``, or
``None`` when no harness can be inferred.
"""
raw_harness = executor_block.get("harness")
harness = overrides.harness if overrides.harness is not None else raw_harness
if isinstance(harness, str) and harness:
return canonicalize_harness(harness) or harness
model = _effective_openai_auth_model(raw, executor_block, overrides)
if model is None:
return None
from omnigent.llms.routing import infer_harness_from_model
inferred = infer_harness_from_model(model)
return inferred or None
def _effective_openai_auth_model(
raw: _YamlMapping,
executor_block: _YamlMapping,
overrides: ChatOverrides,
) -> str | None:
"""
Resolve the model relevant to OpenAI env-auth injection.
:param raw: Parsed top-level YAML mapping.
:param executor_block: Parsed ``executor`` mapping from ``raw``.
:param overrides: CLI overrides that will be applied.
:returns: Effective model string, e.g. ``"databricks-gpt-5-4-mini"``,
or ``None`` when neither CLI nor YAML names a model.
"""
if overrides.model is not None:
return overrides.model
raw_model = executor_block.get("model")
if isinstance(raw_model, str) and raw_model:
return raw_model
llm = raw.get("llm")
if isinstance(llm, dict):
llm_model = llm.get("model")
if isinstance(llm_model, str) and llm_model:
return llm_model
return None
def _should_inject_openai_env_auth_for_executor(
executor_block: _YamlMapping,
harness: str | None,
) -> bool:
"""
Return whether ``executor.auth`` should be populated from env.
:param executor_block: Effective parsed ``executor`` mapping.
:param harness: Effective harness name, e.g. ``"openai-agents"``.
:returns: ``True`` when ambient OpenAI-compatible credentials are
present and no explicit auth/profile/provider declaration
should take precedence.
"""
if harness not in _OPENAI_AGENTS_HARNESSES:
return False
if not os.environ.get(_OPENAI_API_KEY_ENV_VAR):
return False
if executor_block.get("auth") is not None:
return False
if executor_block.get("connection") is not None:
return False
if executor_block.get("profile"):
return False
config = executor_block.get("config")
if isinstance(config, dict) and config.get("profile"):
return False
# A configured credential — a provider default serving this harness's
# family, or the legacy global ``auth:`` block — is the user's explicit
# setup choice; an ambient env key must NOT be baked over it (a shell
# with OPENAI_API_KEY exported would silently hijack the configured
# Databricks/gateway routing). Configured sources reach the runner via
# OMNIGENT_CONFIG_HOME, so skipping injection loses nothing. The env
# bake remains only for users whose SOLE credential is the env key.
from omnigent.onboarding.provider_config import (
default_provider_for_harness,
load_config,
)
from omnigent.runtime.workflow import _load_global_auth
if default_provider_for_harness(load_config(), harness) is not None:
return False
if _load_global_auth() is not None:
return False
return True
def _inject_openai_env_auth_if_needed(raw: _YamlMapping) -> None:
"""
Add explicit OpenAI-compatible auth to ``raw`` when env fallback is unsafe.
Daemon-owned runners do not inherit provider secret environment
variables. For an OpenAI-compatible harness that otherwise relies on
``OPENAI_API_KEY`` / ``OPENAI_BASE_URL``, bake the resolved values into
the materialized spec so the uploaded bundle remains self-contained.
:param raw: Parsed top-level YAML mapping, mutated in place.
:returns: None.
"""
executor = raw.get("executor")
executor_block: _YamlMapping
if isinstance(executor, dict):
executor_block = executor
else:
executor_block = {}
raw["executor"] = executor_block
harness = _effective_openai_auth_harness(raw, executor_block, ChatOverrides())
if not _should_inject_openai_env_auth_for_executor(executor_block, harness):
return
auth: dict[str, str] = {
"type": "api_key",
"api_key": os.environ[_OPENAI_API_KEY_ENV_VAR],
}
base_url = os.environ.get(_OPENAI_BASE_URL_ENV_VAR)
if base_url:
auth["base_url"] = base_url
executor_block["auth"] = auth
def _apply_overrides_to_raw(raw: _YamlMapping, overrides: ChatOverrides) -> None:
"""
Mutate *raw* to reflect CLI overrides + the default-model fallback.
Mirrors the legacy argparse CLI's ``_apply_overrides_to_yaml``
so behavior is unchanged post-unification. The harness override is
format-aware — see :func:`_apply_harness_override_to_executor`.
:param raw: Parsed YAML mapping (mutated in place).
:param overrides: CLI overrides to bake into the ``executor``
block.
"""
executor_block = raw.get("executor")
if not isinstance(executor_block, dict):
executor_block = {}
raw["executor"] = executor_block
if overrides.model is not None:
executor_block["model"] = overrides.model
if overrides.harness is not None:
_apply_harness_override_to_executor(raw, executor_block, overrides.harness)
# When neither harness nor model is declared — after overrides —
# inject the ad-hoc default. Gated on harness absence so a YAML
# like ``claude_code_agent.yaml`` (declares harness, no model)
# doesn't get silently paired with the gpt-5-4 default, which
# the Databricks FM API rejects for Claude-typed entities.
# Uses ``_spec_declares_harness_or_model`` — must agree with the
# ``needs_fallback`` gate in :func:`_materialize_override_bundle`.
# Uses ``_default_cli_model`` (env-var-aware) instead of
# ``_DEFAULT_AD_HOC_MODEL`` directly so ``OMNIGENT_MODEL=foo``
# is honored on the ``omnigent/cli.py`` → ``run_chat`` direct
# path. Without this, that env var was silently dropped on the
# Omnigent path invoked through the ``omnigent`` console
# script (see ``designs/RUN_OMNIGENT_REPL_PARITY.md``).
if not _spec_declares_harness_or_model(raw):
executor_block["model"] = _default_cli_model()
_inject_openai_env_auth_if_needed(raw)
if overrides.system_prompt is not None:
raw["prompt"] = overrides.system_prompt
def _apply_harness_override_to_executor(
raw: _YamlMapping,
executor_block: _YamlMapping,
harness: str,
) -> None:
"""
Write the ``--harness`` override where the spec's format reads it.
Single-file omnigent YAMLs (``name`` + ``prompt``, no
``spec_version``) read the flat ``executor.harness`` key.
``spec_version`` bundles (e.g. ``examples/polly``) read ONLY
``executor.config.harness`` — writing the flat key there is a
silent no-op, which made ``omnigent run examples/polly
--harness pi`` keep the claude-sdk brain.
:param raw: Parsed top-level YAML mapping (used to detect the
spec format via the ``spec_version`` discriminator).
:param executor_block: The ``executor:`` mapping inside *raw*
(mutated in place).
:param harness: The ``--harness`` value, e.g. ``"pi"``.
:raises click.ClickException: If a ``spec_version`` bundle
declares a non-omnigent ``executor.type`` — those executors
have no ``config.harness``, so the override cannot apply.
"""
canonical = canonicalize_harness(harness) or harness
# "spec_version" is the format discriminator (see is_omnigent_yaml).
if "spec_version" not in raw:
executor_block["harness"] = canonical
return
etype = str(executor_block.get("type", OMNIGENT_EXECUTOR_TYPE))
if etype != OMNIGENT_EXECUTOR_TYPE:
raise click.ClickException(
f"--harness only applies to specs with executor.type "
f"{OMNIGENT_EXECUTOR_TYPE!r}; this spec declares executor.type {etype!r}."
)
config = executor_block.get("config")
if not isinstance(config, dict):
config = {}
executor_block["config"] = config
config["harness"] = canonical
def _validate_agent_spec(agent_path: Path) -> None:
"""
Parse and validate the agent spec in this process.
Mirrors the work the server subprocess will do at startup so that
config errors surface as a clean ``ClickException`` here instead
of being swallowed by the server's silenced stderr (see
``_start_local_server``). Both ``OmnigentError`` (parse/
validation/env-expansion failures) and ``FileNotFoundError``
(missing ``config.yaml``) are converted; everything else
propagates so genuine bugs aren't masked.
:param agent_path: Path to the agent directory,
e.g. ``Path("examples/archer")``.
:raises click.ClickException: If the spec is missing, malformed,
or references unresolved environment variables.
"""
try:
load_spec(agent_path)
except (OmnigentError, FileNotFoundError) as exc:
raise click.ClickException(str(exc)) from exc
def _extract_agent_name(agent_path: Path) -> str:
"""
Resolve the display name for the REPL banner.
Accepts both agent-image directories and standalone omnigent
YAML files. The heavy lifting lives in
:func:`omnigent.spec.load`, which dispatches on the source
shape and returns a validated :class:`AgentSpec` whose ``name``
field is the authoritative label. On any load failure (missing
``config.yaml``, malformed YAML, unresolved ``${VAR}``
references) fall back to a filesystem-derived label so the
banner always prints — the server subprocess will surface the
real error moments later.
:param agent_path: Path to an agent directory or standalone
omnigent YAML file.
:returns: The agent name for REPL display.
"""
try:
return load_spec(agent_path).name or _fallback_label(agent_path)
except (OmnigentError, FileNotFoundError):
# Server subprocess will surface the real error; give the
# banner SOMETHING to show in the meantime.
return _fallback_label(agent_path)
def _merge_host_skills(
agent_spec: AgentSpec,
spec_path: Path,
) -> list[SkillSpec]:
"""
Merge bundled skills with host-scope skills for the REPL.
Discovers ``.claude/skills/`` and ``.agents/skills/`` walking
up from the agent root, deduplicates by name (bundled wins),
and returns the combined list.
:param agent_spec: Parsed AgentSpec with ``.skills`` and
``.skills_filter``.
:param spec_path: Path to the agent YAML or directory.
:returns: Combined skill list, or empty list.
"""
bundled: list[SkillSpec] = agent_spec.skills or []
skills_filter = agent_spec.skills_filter
agent_root = spec_path if spec_path.is_dir() else spec_path.parent
host = discover_host_skills(agent_root, skills_filter)
bundled_names = {s.name for s in bundled}
merged = list(bundled)
for hs in host:
if hs.name not in bundled_names:
merged.append(hs)
return merged
def _fallback_label(agent_path: Path) -> str:
"""
Derive a reasonable display label from a path when the spec
didn't supply one.
Directories use the directory name (the standard AGENTSPEC.md
convention). Files use the stem — e.g. ``foo.yaml`` → ``"foo"``
— rather than the full filename so the banner doesn't carry
redundant extensions.
:param agent_path: Path to an agent directory or YAML file.
:returns: A human-readable label.
"""
return agent_path.stem if agent_path.is_file() else agent_path.name
def _canonicalize_local_agent_path(agent_path: Path) -> Path:
"""
Normalize a local agent path before materialization and bundling.
Directory-agent bundles are commonly invoked via their root
``config.yaml``. Treating that file as a standalone YAML would drop
sibling directories such as ``agents/`` and ``skills/`` from the
uploaded bundle, so canonicalize it to the bundle root.
:param agent_path: Existing local path supplied to ``omnigent run``,
e.g. ``Path("examples/polly/config.yaml")``.
:returns: The bundle root for root ``config.yaml`` paths, otherwise
the original path.
"""
if agent_path.is_file() and agent_path.name == "config.yaml":
return agent_path.parent
return agent_path
def _find_free_port() -> int:
"""
Find a free TCP port.
:returns: An available port number.
"""
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def _omnigent_log_dir() -> Path:
"""
Resolve the shared Omnigent process log directory.
Server and captured runner stdout/stderr logs live under the
same per-user state root as session transcripts and CLI
diagnostics, rather than under the system temp directory.
:returns: ``~/.omnigent/logs``, created if needed.
"""
log_dir = Path.home() / ".omnigent" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir
def _omnigent_persistent_dir() -> Path:
"""
Resolve the persistent omnigent data directory.
Honors ``OMNIGENT_DATA_DIR`` (the data-isolation knob a worktree sets
to avoid sharing ``~/.omnigent/chat.db``), else lives at
``~/.omnigent`` alongside the native paths ``sessions/`` and ``logs/``
(see designs/RUN_OMNIGENT_SESSION_RESUMPTION.md). Created on first access;
subsequent calls are idempotent.
Must resolve identically to
:func:`omnigent.host.local_server._local_data_dir` — the local server
writes its DB under that dir while ``omnigent run`` reads the resume DB
from here, so a divergence would silently lose history. ``OMNIGENT_CONFIG_HOME``
is intentionally not consulted (it isolates config, not data).
:returns: The absolute path to the persistent dir,
guaranteed to exist along with the ``artifacts/``
subdir.
"""
override = os.environ.get("OMNIGENT_DATA_DIR")
ap_dir = Path(override).expanduser() if override else Path.home() / ".omnigent"
ap_dir.mkdir(parents=True, exist_ok=True)
(ap_dir / "artifacts").mkdir(exist_ok=True)
return ap_dir
def _start_local_server(
agent_path: Path,
port: int,
*,
ephemeral: bool = False,
) -> LocalServer:
"""
Launch a local Omnigent server.
Server stdout/stderr are routed to ``server.log`` in a
per-run directory under ``~/.omnigent/logs`` so concurrent Omnigent sessions don't
interleave. The log path is returned to the caller (via
:class:`LocalServer`) so :func:`_raise_server_failed`
can surface it in its error message — critical because
the REPL only surfaces the wrapped ``PermanentLLMError``
string, not the underlying cause (e.g. Codex App Server
403s, missing binaries, credential resolution mismatches).
The data store (SQLite DB + artifacts) lives at
``~/.omnigent/{chat.db,artifacts/}`` by default —
persistent across runs so ``--continue`` / ``--resume``
can resume prior conversations
(designs/RUN_OMNIGENT_SESSION_RESUMPTION.md). Pass
``ephemeral=True`` to opt back into a fresh per-run
tmpdir (the ``--no-session`` shape).
:param agent_path: Path to the agent directory,
e.g. ``Path("examples/archer")``.
:param port: Port the server will listen on, e.g. ``8900``.
:param ephemeral: When ``True``, place the SQLite DB and
artifacts in a fresh tmpdir instead of the persistent
``~/.omnigent`` location. Used for ``--no-session``
runs and for tests that want isolation between
invocations.
:returns: The server handle bundling the subprocess and
the path to its captured stdout/stderr log file.
"""
log_dir = _omnigent_log_dir() / "server"
log_dir.mkdir(parents=True, exist_ok=True)
log_fd, log_name = tempfile.mkstemp(prefix="server-", suffix=".log", dir=log_dir)
log_path = Path(log_name)
log_fh = os.fdopen(log_fd, "wb")
if ephemeral:
data_tmpdir = tempfile.mkdtemp(prefix="ap-chat-data-")
db_path = Path(data_tmpdir) / "chat.db"
artifact_path = Path(data_tmpdir) / "artifacts"
else:
data_dir = _omnigent_persistent_dir()
db_path = data_dir / "chat.db"
artifact_path = data_dir / "artifacts"
# Plain file for stdout/stderr — not subprocess.PIPE. PIPE would
# deadlock once the kernel's ~64 KB pipe buffer fills (e.g. under
# the Codex MCP notification firehose) because nothing drains it;
# a file has no such limit. The child dup's the fd at Popen time,
# so we close our parent-side handle immediately after spawn —
# explicit close beats GC ordering for fd lifetime management.
from omnigent.cli import _start_cli_runner_process
from omnigent.runner.identity import token_bound_runner_id
# Generate a binding token and derive the runner_id from it.
# Both the server and runner receive the token so the server's
# tunnel route accepts exactly this runner's WebSocket upgrade.
binding_token = secrets.token_urlsafe(32)
runner_id = token_bound_runner_id(binding_token)
# Build the server's child environment. The tunnel token lets the
# server restrict its runner-tunnel allowlist to the sibling runner
# we spawn below (read by server() via OMNIGENT_RUNNER_TUNNEL_TOKEN).
#
# Accounts opt-in: when the parent shell selects accounts mode
# (OMNIGENT_AUTH_PROVIDER=accounts), inject the per-spawn
# base URL + cookie secret so the spawned server's
# AccountsConfig.from_env() can satisfy its required-fields check.
# Mirrors the same logic in cli.py:_ensure_local_omnigent_server; see
# the comment block there for the full UX explanation. (Two
# spawn paths exist because `omnigent run --server ""` goes
# through _ensure_local_omnigent_server while `omnigent run` with
# an agent spec and no --server flag goes through here.)
child_env = {
**os.environ,
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
# Single-user loopback runtime — see ensure_local_omnigent_server for why
# this lets the host tunnel re-own this machine's host_id across an
# auth-mode flip without weakening the deployed multi-user boundary.
"OMNIGENT_LOCAL_SINGLE_USER": "1",
}
# Mirror create_auth_provider's resolution so this spawn path agrees
# with the daemon path (host/local_server.py::ensure_local_omnigent_server):
# header is the env-unset default; OMNIGENT_AUTH_ENABLED=1 opts into
# accounts (or oidc when OMNIGENT_OIDC_* is set). In header/oidc mode we
# must NOT mint an accounts cookie secret (those modes never read it).
from omnigent.server.auth import resolve_auth_source
_accounts_mode = resolve_auth_source() == "accounts"
if _accounts_mode:
if "OMNIGENT_ACCOUNTS_COOKIE_SECRET" not in os.environ:
child_env["OMNIGENT_ACCOUNTS_COOKIE_SECRET"] = secrets.token_hex(32)
# Always override BASE_URL — the parent's value (if any) almost
# certainly points at a different port than the freshly picked
# one. Surprises here ("why is my magic URL wrong?") are worse
# than discarding an out-of-date setting.
child_env["OMNIGENT_ACCOUNTS_BASE_URL"] = f"http://127.0.0.1:{port}"
# Propagate executor.profile from the spec as DATABRICKS_CONFIG_PROFILE
# (spec self-containment: the YAML's own declaration is the only thing
# that selects a Databricks workspace here — there is no CLI override).
# This ensures the Omnigent server and its runner subprocess resolve credentials
# for the right Databricks workspace (LLM calls, compaction, etc.).
if "DATABRICKS_CONFIG_PROFILE" not in child_env:
_spec = load_spec(agent_path)
if _spec.executor.profile:
child_env["DATABRICKS_CONFIG_PROFILE"] = _spec.executor.profile
try:
server_proc = subprocess.Popen(
[
sys.executable,
"-m",
"omnigent.cli",
"server",
"--host",
"127.0.0.1",
"--port",
str(port),
"--database-uri",
f"sqlite:///{db_path}",
"--artifact-location",
str(artifact_path),
"--agent",
str(agent_path),
],
env=child_env,
stdout=log_fh,
stderr=log_fh,
**_proc.spawn_kwargs(),
)
finally:
log_fh.close()
# Spawn the runner as a sibling subprocess (not a child of the
# server). The runner retries its WS tunnel connection until the
# server is ready, so launching them concurrently is safe.
# If the runner fails to start, kill the already-running server
# so the caller's finally block doesn't orphan it.
_prewarm_spec = agent_path if agent_path.exists() else None
try:
runner = _start_cli_runner_process(
server_url=f"http://127.0.0.1:{port}",
tunnel_token=binding_token,
runner_id=runner_id,
workspace_cwd=Path.cwd(),
prewarm_spec_path=_prewarm_spec,
isolate_session=True,
# Route runner stdio to a log file; otherwise its INFO logs
# paint onto the parent REPL / one-shot stderr.
capture_logs=True,
)
except BaseException:
_stop_server(server_proc)
raise
return LocalServer(
proc=server_proc,
log_path=log_path,
runner_id=runner_id,
runner_proc=runner.proc,
)
def _wait_for_server(port: int, server: LocalServer, timeout: float = 45.0) -> None:
"""
Poll until the server responds.
:param port: The server port.
:param server: The launched server handle. Used to detect
early exit (via ``server.proc.poll()``) and to surface
``server.log_path`` in the failure message.
:param timeout: Max seconds to wait.
:raises click.ClickException: If the server doesn't start.
"""
base_url = f"http://127.0.0.1:{port}"
start = time.monotonic()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if server.proc.poll() is not None:
_raise_server_failed(server)
try:
resp = httpx.get(f"{base_url}/health", timeout=2.0)
if resp.status_code == 200:
runner_id = server.runner_id
if runner_id is None:
return
runner_resp = httpx.get(
f"{base_url}/v1/runners/{runner_id}/status",
timeout=2.0,
)
if runner_resp.status_code == 200 and runner_resp.json()["online"] is True:
return
except httpx.ConnectError:
pass
elapsed = time.monotonic() - start
poll_interval = (
_SERVER_READY_INITIAL_POLL_SECONDS
if elapsed < _SERVER_READY_FAST_POLL_WINDOW_SECONDS
else _SERVER_READY_BACKOFF_POLL_SECONDS
)
time.sleep(poll_interval)
_raise_server_failed(server)
_SERVER_LOG_TAIL_LINES = 50
def _raise_server_failed(server: LocalServer) -> None:
"""
Raise a descriptive error for a failed server startup.
Includes the tail of the server log inline so CI failures (where
the user can't tail the file by hand) carry the underlying
traceback in the test's stderr. The path is still printed for
local runs where the user may want the full file.
:param server: The launched server handle, used to recover
the subprocess command line and log file location.
:raises click.ClickException: Always.
"""
args = server.proc.args
if isinstance(args, list):
parts = [p.decode() if isinstance(p, bytes) else str(p) for p in args]
cmd_display = " ".join(parts)
elif isinstance(args, bytes):
cmd_display = args.decode()
else:
cmd_display = str(args)
try:
lines = server.log_path.read_text(errors="replace").splitlines()
tail = "\n".join(lines[-_SERVER_LOG_TAIL_LINES:]) if lines else "(empty log file)"
except OSError as e:
tail = f"(could not read log file: {e})"
raise click.ClickException(
"Server failed to start.\n"
f" Server log: {server.log_path}\n"
" Re-run the server directly to see its output:\n"
f" {cmd_display}\n"
f"\n Last {_SERVER_LOG_TAIL_LINES} lines of {server.log_path}:\n"
f"{tail}"
)
def _stop_server(proc: subprocess.Popen[bytes]) -> None:
"""
Gracefully stop the server subprocess.
:param proc: The server subprocess.
"""
if proc.poll() is None:
_proc.terminate_tree(proc, grace=5)
if proc.poll() is None:
_proc.kill_tree(proc)
with contextlib.suppress(subprocess.TimeoutExpired):
proc.wait(timeout=5)
def _stop_local_server(server: LocalServer) -> None:
"""
Stop both the server and its sibling runner subprocess.
:param server: The :class:`LocalServer` handle returned by
:func:`_start_local_server`.
"""
from omnigent.cli import _stop_cli_runner_process
if server.runner_proc is not None:
try:
_stop_cli_runner_process(server.runner_proc)
except (OSError, RuntimeError, subprocess.SubprocessError):
logger.warning(
"Failed to stop local runner cleanly",
exc_info=True,
)
_stop_server(server.proc)
# ---------------------------------------------------------------------------
# REPL launch
# ---------------------------------------------------------------------------
def _spec_used_families(agent_yaml: Path | None) -> list[str]:
"""Best-effort: the harness surfaces a local agent's harnesses consume.
Walks the agent's executor harness plus every sub-agent's harness and
maps each to its surface, so the REPL startup header can show a
per-surface creds line for a multi-vendor agent (e.g. polly's
``claude-sdk`` brain + ``claude-native`` / ``codex-native`` sub-agents
yield ``["anthropic", "openai"]``; polly's ``pi`` brain adds the
``pi`` surface). Parsing is done WITHOUT env expansion — only harness
names are needed, so unresolved secrets must not make this fail — and
any error degrades to an empty list (the header simply omits the
creds line).
:param agent_yaml: Path to the agent directory, its ``config.yaml``,
or a standalone YAML file; ``None`` for remote-URL targets.
:returns: Sorted unique surface names, e.g. ``["anthropic", "openai",
"pi"]``; empty when *agent_yaml* is ``None``, points at a
standalone file (no sub-agents), or parsing fails.
"""
# parse() reads an agent *directory* and discovers sub-agents under
# ``<root>/agents/``. Resolve the root whether the caller passed the
# directory itself or its ``config.yaml``. A standalone single-file agent
# has no sub-agent directory to walk, so the launch harness alone drives
# the header — skip it here.
if agent_yaml is None:
return []
if agent_yaml.is_dir():
root = agent_yaml
elif agent_yaml.name in ("config.yaml", "config.yml"):
root = agent_yaml.parent
else:
return []
try:
from omnigent.onboarding.provider_config import PI_SURFACE, harness_family
from omnigent.spec import parse
spec = parse(root, expand_env=False)
except Exception: # noqa: BLE001 — best-effort startup-header hint: a spec parse must never break `run`
logger.debug("startup-header family parse failed for %s", agent_yaml, exc_info=True)
return []
families: set[str] = set()
def _walk(node: AgentSpec) -> None:
"""Accumulate the surface for *node*'s harness and recurse into sub-agents."""
harness = canonicalize_harness(node.executor.harness_kind) or node.executor.harness_kind
fam = harness_family(harness)
if fam is not None:
families.add(fam)
elif harness == PI_SURFACE:
# pi spans both model families, so it has no single family —
# it contributes its own surface, and the header resolves that
# surface's effective credential (explicit pi default, else
# the cross-family fallback).
families.add(PI_SURFACE)
for child in node.sub_agents:
_walk(child)
_walk(spec)
return sorted(families)
def _run_repl(
base_url: str,
agent_name: str,
tool_handler: ToolHandler | None,
*,
initial_message: str | None = None,
resume_conversation_id: str | None = None,
fork_session_id: str | None = None,
log: bool = False,
agent_yaml: Path | None = None,
runner_id: str | None = None,
runner_recover: Callable[[], str] | None = None,
session_bundle: bytes | None = None,
session_bundle_filename: str = "agent.tar.gz",
ephemeral: bool = False,
debug_events: bool = False,
server_log_path: Path | None = None,
runner_log_path: Path | None = None,
resume_parts: list[str] | None = None,
skills: list[SkillSpec] | None = None,
auto_open_conversation: bool = False,
attach_only: bool = False,
attach_harness: str | None = None,
) -> None:
"""
Open the REPL connected to the server.
:param base_url: Server base URL.
:param agent_name: Agent name to chat with.
:param tool_handler: Optional client-side tool handler.
:param initial_message: If set, auto-send on REPL start (maps
to ``run_repl``'s ``initial_message`` kwarg, which the REPL
treats as the first user turn — same hook the onboarding
flow uses to auto-greet the user).
:param resume_conversation_id: When set, the REPL opens
attached to this existing conversation (replays recent
items, threads new turns onto the existing
``previous_response_id``) rather than starting a fresh
one. Resolved upstream from ``--continue`` /
``--resume <id>``.
:param fork_session_id: When set, fork this session before
entering the REPL. The REPL opens attached to the fork.
Resolved upstream from ``--fork ID``.
:param log: When ``True``, write a JSON dump of the active
conversation to ``~/.omnigent/logs/`` on REPL exit.
Maps to ``--log`` on the CLI.
:param agent_yaml: Path to the agent spec on the local
filesystem, when known. Threaded through to the tmux
pane-integration helper so a sibling pane spawned via
``prefix + <split-key>`` can re-launch the same agent.
``None`` for remote-URL targets (the spec lives on the
server, not locally) — the chooser falls back to
``OPT_LAUNCH_ARGV`` in that case.
:param runner_id: Optional preferred runner id to send on
requests, e.g. ``"runner_0123456789abcdef"``.
:param runner_recover: Optional callback that restarts the
local runner if it has exited and returns the live runner
id.
:param session_bundle: Optional gzipped agent bundle bytes used
to create a fresh sessions-API session. Required for fresh
sessions.
:param session_bundle_filename: Multipart filename, e.g.
``"agent.tar.gz"``.
:param ephemeral: When ``True``, suppress the resume hint on
exit — the session data lives in a tmpdir that's gone
after the process exits, so the hint would be misleading.
:param debug_events: When ``True``, enable the SSE-to-UI debug
pipeline (event tape overlay, JSONL log, toolbar counters).
Maps to ``--debug-events`` on the CLI.
:param server_log_path: Path to the local server's
stdout/stderr log file, e.g.
``Path("~/.omnigent/logs/server/server-abc123.log")``. Shown in the
Ctrl+O debug overview. ``None`` for remote servers.
:param runner_log_path: Path to the local runner's
stdout/stderr log file, e.g.
``Path("~/.omnigent/logs/runner/runner-abc123.log")``. Shown in the
Ctrl+O debug overview. ``None`` when no local runner is used.
:param resume_parts: Pre-built argument list prefix for the
resume command shown on exit, e.g.
``["omnigent", "run", "agent.yaml", "--harness", "codex"]``.
``None`` uses the current process argv.
:param skills: Parsed skill list from the agent spec, e.g.
``[SkillSpec(name="code-review", ...)]``. Each skill is
registered as a ``/<name>`` slash command in the REPL.
``None`` (default) means no skill commands are registered.
:param auto_open_conversation: When ``True``, open the
browser conversation URL when the session id becomes known.
"""
from omnigent.repl import run_repl
from omnigent.repl._session_log import DEFAULT_LOG_DIR
from omnigent.repl._tmux_pane import register_pane
log_dir = DEFAULT_LOG_DIR if log else None
# Mark this tmux pane as an omnigent context source and wrap
# the user's prefix-table split bindings. No-op outside tmux.
# ``resume_conversation_id`` (if present) becomes the pane's
# initial conv id; otherwise a placeholder is used until the
# first conversation is created.
register_pane(
conv_id=resume_conversation_id,
agent_name=agent_name,
agent_yaml=agent_yaml,
launch_argv=list(sys.argv),
server_url=base_url,
)
conversation_id: str | None = None
async def _main() -> None:
nonlocal conversation_id
# Route unhandled asyncio exceptions (fire-and-forget tasks,
# "Task exception was never retrieved") to the CLI diagnostics
# log instead of stderr noise.
from omnigent.cli_diagnostics import install_asyncio_exception_handler
install_asyncio_exception_handler(asyncio.get_running_loop())
# Derive the launch harness from the local spec so the REPL's
# `/model` readout knows the right harness (and thus the right
# provider family) before the first turn binds the session. ``None``
# for URL targets / bundles, where the snapshot's harness fills it in.
launch_harness: str | None = None
agent_description: str | None = None
if agent_yaml is not None:
# Resolve the spec's config.yaml whether the user passed the agent
# directory, its config.yaml, or a standalone single-file YAML — so
# the startup header (harness → model + credential, summary)
# populates in every case (a bare directory path would otherwise
# peek at a directory and yield nothing).
spec_config = agent_yaml / "config.yaml" if agent_yaml.is_dir() else agent_yaml
raw_spec = _load_yaml_if_single_file(spec_config)
executor = raw_spec.get("executor") if isinstance(raw_spec, dict) else None
if isinstance(executor, dict):
harness_name = executor.get("harness")
if not harness_name and isinstance(executor.get("config"), dict):
harness_name = executor["config"].get("harness")
if isinstance(harness_name, str) and harness_name:
launch_harness = canonicalize_harness(harness_name) or harness_name
# One-line summary for the startup header (folded scalars are
# normalized to a single line by the header builder).
if isinstance(raw_spec, dict):
desc = raw_spec.get("description")
if isinstance(desc, str) and desc.strip():
agent_description = desc
# Families the agent's harnesses (incl. sub-agents) consume — drives
# the per-family creds line in the startup header for multi-vendor
# agents like polly. Best-effort; empty on any failure.
used_families = _spec_used_families(agent_yaml)
# Attach has no local spec; the host's harness comes from the session
# snapshot so the (lean) attach banner reflects what's actually running.
if attach_harness is not None:
launch_harness = attach_harness
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
) as client:
# When --fork is set, call the fork endpoint before
# entering the REPL so the user lands in the fork.
effective_resume_id = resume_conversation_id
if fork_session_id is not None:
try:
fork_result = await client.sessions.fork(fork_session_id)
except Exception as exc:
raise click.ClickException(f"Fork failed: {exc}") from exc
effective_resume_id = fork_result["id"]
click.echo(
f"Conversation forked. To return to the previous "
f"conversation, run --resume {fork_session_id}",
err=True,
)
conversation_id = await run_repl(
client,
agent_name,
tool_handler,
initial_message=initial_message,
resume_conversation_id=effective_resume_id,
log_dir=log_dir,
debug_events=debug_events,
server_log_path=server_log_path,
runner_log_path=runner_log_path,
session_bundle=session_bundle,
session_bundle_filename=session_bundle_filename,
runner_id=runner_id,
runner_recover=runner_recover,
resume_parts=resume_parts,
ephemeral=ephemeral,
skills=skills,
server_url=base_url,
harness=launch_harness,
agent_description=agent_description,
used_families=used_families,
attach_only=attach_only,
on_session_start=(
lambda session_id: open_conversation_link_if_enabled(
base_url=base_url,
conversation_id=session_id,
enabled=auto_open_conversation,
warn=lambda message: click.echo(message, err=True),
)
),
)
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(_main())
def _run_one_shot(
*,
base_url: str,
agent_name: str,
tool_handler: ToolHandler | None,
prompt: str,
runner_id: str | None = None,
session_bundle: bytes | None = None,
session_bundle_filename: str = "agent.tar.gz",
resume_conversation_id: str | None = None,
auto_open_conversation: bool = False,
) -> None:
"""
Send a single prompt to a remote server and print the final text.
:param base_url: Remote server base URL.
:param agent_name: Registered agent name to invoke.
:param tool_handler: Optional client-side tool handler for local
tool execution.
:param prompt: User prompt to send as the single turn.
:param runner_id: Optional preferred runner id to send on
requests, e.g. ``"runner_0123456789abcdef"``.
:param session_bundle: Optional gzipped agent bundle bytes used
to create a fresh sessions-API session.
:param session_bundle_filename: Multipart filename, e.g.
``"agent.tar.gz"``.
:param resume_conversation_id: When set, resumes an existing
session instead of creating a new one, e.g.
``"conv_abc123"``. ``None`` creates a fresh session.
:param auto_open_conversation: When ``True``, open the
browser conversation URL after the session is created or resumed.
:returns: None.
"""
async def _main() -> None:
"""Run the one-shot SDK query inside an async client context."""
async with OmnigentClient(
base_url=base_url,
headers=_server_headers(runner_id=runner_id),
auth=_server_auth(server_url=base_url),
) as client:
if session_bundle is not None:
text = await _query_sessions_once(
client=client,
agent_name=agent_name,
tool_handler=tool_handler,
prompt=prompt,
session_bundle=session_bundle,
session_bundle_filename=session_bundle_filename,
runner_id=runner_id,
resume_conversation_id=resume_conversation_id,
on_session_ready=(
lambda session_id: open_conversation_link_if_enabled(
base_url=base_url,
conversation_id=session_id,
enabled=auto_open_conversation,
warn=lambda message: click.echo(message, err=True),
)
),
)
if text:
click.echo(text)
return
result = await client.query(
model=agent_name,
input=prompt,
tool_handler=tool_handler,
)
if result.text:
click.echo(result.text)
try:
asyncio.run(_main())
except ClientOmnigentError as exc:
# A turn that fails before the LLM stream starts (SETUP-phase
# failure: spec resolution, spawn-env build) ends with only a
# ``session.status: failed`` event, which SessionsChat.send
# raises as an OmnigentError. Surface its message as a clean
# CLI error instead of an opaque traceback so ``-p`` users see
# why the turn produced no output.
raise click.ClickException(str(exc)) from exc
# ---------------------------------------------------------------------------
# Client-side tools
# ---------------------------------------------------------------------------
def _load_tool_handler(name: str) -> ToolHandler:
"""
Load a client-side tool set by name and wrap it as a ToolHandler.
Prefers the modern ``@tool``-decorated functions (exposed
by the tool set as ``_TOOL_FNS``) so the SDK's D6 lifecycle
can detect ``synchronous: false`` properties on the wire
schema. Falls back to the legacy ``TOOLS`` + ``execute_tool``
surface for tool sets that haven't migrated yet — same
behavior as before, just constructed manually rather than
via ``build_tool_handler``.
:param name: Tool set name, e.g. ``"coding"``.
:returns: A ToolHandler with schemas and execute function.
:raises click.ClickException: If the tool set is not found.
"""
try:
from omnigent.client_tools import get_tool_set
tool_set = get_tool_set(name)
except (ImportError, SystemExit) as exc:
raise click.ClickException(
f"Tool set {name!r} not found. Available: coding, async_demo"
) from exc
# `_TOOL_FNS` is the "modern path" marker on a tool-set module —
# @tool-decorated functions exported as a module-level list.
# Legacy tool-sets instead expose a `build()` callable. Use
# hasattr so mypy can narrow the attribute access below.
if hasattr(tool_set, "_TOOL_FNS"):
fns = tool_set._TOOL_FNS
# Modern path: @tool-decorated functions. The SDK's
# build_tool_handler derives schemas from type hints +
# docstrings, strips ``synchronous`` routing hints
# before invoking the user fn, and bridges sync vs
# async ``execute`` correctly.
from omnigent_client.tools import build_tool_handler
return build_tool_handler(fns)
# Legacy path: hand-written TOOLS dict + sync
# execute_tool dispatcher.
def execute(call: ToolCallInfo) -> str:
"""
Execute a client-side tool call (legacy sync path).
:param call: The tool call info with name and arguments.
:returns: The tool result string.
"""
return str(tool_set.execute_tool(call.name, call.arguments))
return ToolHandler(schemas=tool_set.TOOLS, execute=execute)