omnigent-ai--omnigent
2711 行
124 KiB
Python
2711 行
124 KiB
Python
"""ClaudeSDKExecutor: run agents using the Claude Agent SDK.
|
|
|
|
Uses the ``claude-agent-sdk`` Python package to run Claude Code as the
|
|
underlying agent harness. Omnigent tools are bridged into the SDK session
|
|
as MCP tools so Claude can call them alongside its built-in capabilities.
|
|
|
|
The SDK manages its own internal agent loop (tool calls, retries, context).
|
|
This executor translates the SDK message stream into Omnigent ExecutorEvents
|
|
and builds up the session History from observed tool-use blocks.
|
|
|
|
Requirements:
|
|
pip install claude-agent-sdk # optional dependency
|
|
|
|
Environment (direct Anthropic):
|
|
ANTHROPIC_API_KEY – API key for Claude
|
|
|
|
Environment (Databricks-hosted Claude via native Anthropic Messages API):
|
|
DATABRICKS_CONFIG_PROFILE – optional Databricks profile selector
|
|
~/.databrickscfg – host + token profile for workspace access
|
|
(or ~/.databrickscfg with a profile containing host + token)
|
|
|
|
The executor builds ANTHROPIC_BASE_URL plus an invocation-local
|
|
apiKeyHelper setting from Databricks credentials so Claude Code can
|
|
refresh auth through ``databricks auth token`` while routing through
|
|
the Databricks gateway.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
from contextlib import contextmanager, suppress
|
|
from dataclasses import dataclass
|
|
from types import ModuleType
|
|
from typing import Any, Protocol, TypeAlias, cast
|
|
|
|
from omnigent._platform import stable_user_id
|
|
from omnigent.inner import _proc
|
|
from omnigent.inner.bundle_skills import ensure_bundle_plugin_manifest
|
|
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
|
|
from omnigent.onboarding.databricks_config import DATABRICKS_CLAUDE_DEFAULT_MODEL
|
|
from omnigent.reasoning_effort import CLAUDE_EFFORTS, validate_effort
|
|
from omnigent.spec.types import RetryPolicy
|
|
|
|
from ._subprocess_lifecycle import close_anyio_subprocess_transport
|
|
from .claude_gateway_shim import DATABRICKS_CLAUDE_ADAPTIVE_THINKING_PREFIXES, ClaudeGatewayShim
|
|
from .datamodel import OSEnvSandboxSpec, OSEnvSpec
|
|
from .executor import (
|
|
Executor,
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ExecutorEvent,
|
|
Message,
|
|
ReasoningChunk,
|
|
TextChunk,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolCallStatus,
|
|
ToolSpec,
|
|
TurnComplete,
|
|
classify_tool_result,
|
|
)
|
|
from .sandbox import (
|
|
create_exec_launcher,
|
|
resolve_sandbox,
|
|
with_additional_read_roots,
|
|
with_additional_write_files,
|
|
with_additional_write_roots,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default auth-token refresh cadence (ms) for the vendor-neutral gateway
|
|
# transport when ``HARNESS_CLAUDE_SDK_GATEWAY_AUTH_REFRESH_INTERVAL_MS`` is
|
|
# unset. Not Databricks-specific: the same fallback applies to any gateway
|
|
# producer (Databricks AI gateway or a generic key/gateway provider).
|
|
_GATEWAY_AUTH_REFRESH_MS = 900_000
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TypeAliases for Omnigent JSON-shaped boundary values. The SDK exchanges
|
|
# heterogeneous dicts at the transport and tool boundaries — named aliases
|
|
# here keep the executor ``object``-free while isolating the justified
|
|
# ``explicit-any`` boundary to a single place, mirroring the peer
|
|
# ``openai_agents_sdk_executor`` / ``databricks_executor`` conventions.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Parsed tool arguments / tool result dict — JSON-shaped bags exchanged
|
|
# with the Omnigent tool executor and the SDK's MCP bridge.
|
|
ToolArgs: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
ToolResult: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
# MCP response payload (``content`` + optional ``isError``) returned to the
|
|
# Claude SDK from each MCP tool handler.
|
|
McpResponse: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
# Tool executor callable wired in by ``omnigent.Session``.
|
|
ToolExecutor: TypeAlias = Callable[[str, ToolArgs], Awaitable[ToolResult]]
|
|
|
|
# Elicitation handler wired in by :class:`ExecutorAdapter`. Kept SDK-agnostic
|
|
# so the adapter does not import ``claude_agent_sdk`` types.
|
|
ElicitationHandler: TypeAlias = Callable[ # type: ignore[explicit-any]
|
|
[str, ToolArgs],
|
|
Awaitable[bool],
|
|
]
|
|
|
|
# Opaque SDK artifacts whose concrete shape we don't touch directly:
|
|
# - ``SdkMcpTool``: returned by ``sdk.tool(...)`` decorator and passed back
|
|
# to ``sdk.create_sdk_mcp_server(tools=...)`` without field access.
|
|
# - ``ClaudeAgentOptions``: the SDK's dataclass; fields set via attribute
|
|
# assignment after construction rather than typed kwargs.
|
|
SdkMcpTool: TypeAlias = Any # type: ignore[explicit-any]
|
|
SdkOptions: TypeAlias = Any # type: ignore[explicit-any]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SDK-private reach Protocols.
|
|
#
|
|
# ``claude_agent_sdk.*`` is listed as ``ignore_missing_imports`` in mypy
|
|
# config, so every SDK-typed value mypy sees is ``Any``. We recover types
|
|
# locally with Protocols for the handful of public and private attributes
|
|
# this executor touches.
|
|
#
|
|
# The private reaches (``_query``, ``_transport``, ``_process``,
|
|
# ``_stderr_task`` / ``_stderr_task_group``, etc.) are necessary to tear
|
|
# down the CLI subprocess tree when the SDK's own ``disconnect()`` path is unsafe
|
|
# (different event loop / task) or hangs. The SDK does not expose a
|
|
# supported equivalent, so we treat the private attributes as part of
|
|
# our integration contract and document them here.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _Process(Protocol):
|
|
"""Subset of ``anyio.abc.Process`` / ``asyncio.subprocess.Process``.
|
|
|
|
These fields are standard on both process abstractions — the SDK's
|
|
transport uses an anyio process but the shape matches the asyncio one
|
|
for the attributes we touch.
|
|
"""
|
|
|
|
pid: int | None
|
|
returncode: int | None
|
|
|
|
def terminate(self) -> None: ...
|
|
def kill(self) -> None: ...
|
|
async def wait(self) -> int: ...
|
|
|
|
|
|
class _CancelScope(Protocol):
|
|
def cancel(self) -> None: ...
|
|
|
|
|
|
class _TaskGroup(Protocol):
|
|
cancel_scope: _CancelScope
|
|
|
|
|
|
class _TaskHandle(Protocol):
|
|
"""Private view of the SDK's detached stderr-reader task.
|
|
|
|
Current ``claude-agent-sdk`` (>=0.2.x) runs the stderr reader as a
|
|
single task exposed as ``_stderr_task`` with a ``cancel()`` method;
|
|
older revs used an anyio task group (``_stderr_task_group``). The
|
|
executor probes both shapes during force-close, so both are typed
|
|
optional on ``_ClaudeTransport`` below.
|
|
"""
|
|
|
|
def cancel(self) -> None: ...
|
|
|
|
|
|
class _ClaudeQuery(Protocol):
|
|
"""Private view of ``claude_agent_sdk._internal.query.Query``.
|
|
|
|
``_closed`` is the SDK's "stop accepting messages" flag. ``_tg`` was a
|
|
per-query task group in older SDK revs — absent in current revs but
|
|
still probed so this executor handles both shapes.
|
|
"""
|
|
|
|
_closed: bool
|
|
_tg: _TaskGroup | None
|
|
|
|
|
|
class _Stream(Protocol):
|
|
"""Structural view of an anyio text stream. Only ``aclose`` is actually
|
|
available on the real ``TextReceiveStream`` / ``TextSendStream``; the
|
|
``close`` / ``transport`` attributes probed during teardown are
|
|
historical belt-and-suspenders cleanup and no-op on the current SDK.
|
|
"""
|
|
|
|
async def aclose(self) -> None: ...
|
|
|
|
|
|
class _ClaudeTransport(Protocol):
|
|
"""Private view of ``SubprocessCLITransport`` internals we tear down.
|
|
|
|
Kept minimal — only the attributes ``_force_close_client`` touches.
|
|
"""
|
|
|
|
_process: _Process | None
|
|
_stdout_stream: _Stream | None
|
|
_stdin_stream: _Stream | None
|
|
_stderr_stream: _Stream | None
|
|
_stderr_task: _TaskHandle | None
|
|
_stderr_task_group: _TaskGroup | None
|
|
_ready: bool
|
|
|
|
|
|
class _ClaudeClient(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.ClaudeSDKClient``.
|
|
|
|
Covers the public methods the executor calls plus the two private
|
|
attributes it clears during a force-close. Test doubles (see
|
|
``tests/test_claude_sdk_executor.py``) satisfy this Protocol
|
|
structurally via ``SimpleNamespace`` / custom classes.
|
|
"""
|
|
|
|
_query: _ClaudeQuery | None
|
|
_transport: _ClaudeTransport | None
|
|
|
|
async def connect(self) -> None: ...
|
|
async def disconnect(self) -> None: ...
|
|
async def query(self, prompt: str, session_id: str = ...) -> None: ...
|
|
async def set_model(self, model: str | None) -> None: ...
|
|
async def interrupt(self) -> None: ...
|
|
|
|
# receive_response yields heterogeneous SDK message objects — kept
|
|
# as ``Any`` so the caller can ``isinstance``-narrow onto the SDK's
|
|
# real types via ``_ClaudeSDK`` below without needing a union here.
|
|
def receive_response(self) -> AsyncIterator[Any]: ... # type: ignore[explicit-any]
|
|
|
|
|
|
class _StreamEventObj(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.StreamEvent``."""
|
|
|
|
event: dict[str, Any] # type: ignore[explicit-any] # SDK declares this as dict[str, Any]
|
|
|
|
|
|
class _AssistantMessageObj(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.AssistantMessage``."""
|
|
|
|
# Each content block is one of the SDK block classes; isinstance-narrowed
|
|
# at the read sites below.
|
|
content: list[Any] # type: ignore[explicit-any]
|
|
# The model the SDK actually used for this message, e.g.
|
|
# ``"claude-opus-4-8"``. The only place the executor learns the concrete
|
|
# model when the spec pins none and the gateway resolves it internally.
|
|
model: str | None
|
|
|
|
|
|
class _UserMessageObj(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.UserMessage``."""
|
|
|
|
content: str | list[Any] # type: ignore[explicit-any]
|
|
|
|
|
|
class _ResultMessageObj(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.ResultMessage``."""
|
|
|
|
result: str | None
|
|
usage: dict[str, Any] | None # type: ignore[explicit-any]
|
|
|
|
|
|
class _SystemMessageObj(Protocol):
|
|
"""Structural view of ``claude_agent_sdk.SystemMessage``."""
|
|
|
|
subtype: str
|
|
data: dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
|
|
class _TextBlockObj(Protocol):
|
|
text: str
|
|
|
|
|
|
class _ToolUseBlockObj(Protocol):
|
|
id: str
|
|
name: str
|
|
input: ToolArgs
|
|
|
|
|
|
class _ToolResultBlockObj(Protocol):
|
|
tool_use_id: str
|
|
content: str | list[dict[str, Any]] | None # type: ignore[explicit-any]
|
|
is_error: bool | None
|
|
|
|
|
|
class _ClaudeSDK(Protocol):
|
|
"""Structural view of the ``claude_agent_sdk`` module.
|
|
|
|
Tests swap in a fake with matching attributes, so we mirror what the
|
|
executor actually pulls off the module. The ``*Message`` / ``*Block``
|
|
attributes are declared as ``type`` so they can be used both as
|
|
``isinstance`` second args and as Protocol-implementing factories.
|
|
"""
|
|
|
|
# Factories / callables the executor invokes. ``Callable[..., X]``
|
|
# expands to an implicit ``Any`` arg spec under
|
|
# ``disallow_any_explicit`` — the SDK's construction kwargs are opaque
|
|
# at our boundary so that's the right abstraction level here.
|
|
ClaudeSDKClient: Callable[..., _ClaudeClient] # type: ignore[explicit-any]
|
|
ClaudeAgentOptions: Callable[..., Any] # type: ignore[explicit-any]
|
|
tool: Callable[..., Any] # type: ignore[explicit-any]
|
|
create_sdk_mcp_server: Callable[..., Any] # type: ignore[explicit-any]
|
|
|
|
# Classes used as isinstance second args. Declared as ``type`` so the
|
|
# checker accepts them in isinstance() while the real attributes are
|
|
# the SDK's concrete classes. Test doubles assign plain ``type``
|
|
# objects which satisfy this shape.
|
|
AssistantMessage: type
|
|
UserMessage: type
|
|
SystemMessage: type
|
|
ResultMessage: type
|
|
TextBlock: type
|
|
ToolUseBlock: type
|
|
ToolResultBlock: type
|
|
|
|
|
|
_CONNECT_TIMEOUT_SECONDS = 60.0
|
|
_QUERY_START_TIMEOUT_SECONDS = 30.0
|
|
# When the response stream is quiet for this long we emit a warning,
|
|
# but keep waiting — a long-running native tool can legitimately block
|
|
# the stream far longer than any fixed deadline.
|
|
_STREAM_IDLE_WARN_SECONDS = 600.0
|
|
|
|
# ── Multimodal content block conversion ──────────────────────
|
|
|
|
|
|
def _parse_data_uri(uri: str) -> tuple[str, str]:
|
|
"""
|
|
Parse a ``data:`` URI into ``(media_type, base64_data)``.
|
|
|
|
:param uri: A data URI, e.g.
|
|
``"data:image/png;base64,iVBOR..."``.
|
|
:returns: Tuple of ``(media_type, base64_payload)``.
|
|
:raises ValueError: If the URI is not a valid ``data:`` URI.
|
|
"""
|
|
if not uri.startswith("data:"):
|
|
raise ValueError(f"Not a data URI: {uri[:40]!r}")
|
|
header, _, payload = uri[5:].partition(",")
|
|
media_type = header.replace(";base64", "")
|
|
return media_type, payload
|
|
|
|
|
|
def _to_anthropic_content_blocks(
|
|
blocks: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Convert Responses API content blocks to Anthropic Messages
|
|
API content block format.
|
|
|
|
Mapping:
|
|
|
|
- ``input_text`` / ``output_text`` → ``{"type": "text", ...}``
|
|
- ``input_image`` (with ``image_url`` data URI) →
|
|
``{"type": "image", "source": {"type": "base64", ...}}``
|
|
- ``input_file`` (with ``file_data`` data URI) →
|
|
``{"type": "document", "source": {"type": "base64", ...}}``
|
|
|
|
:param blocks: Responses API content block dicts.
|
|
:returns: Anthropic API content block dicts.
|
|
"""
|
|
result: list[dict[str, Any]] = []
|
|
for block in blocks:
|
|
block_type = block.get("type")
|
|
if block_type in ("input_text", "output_text", "text"):
|
|
result.append({"type": "text", "text": block["text"]})
|
|
elif block_type == "input_image":
|
|
image_url = block.get("image_url")
|
|
if not isinstance(image_url, str) or not image_url:
|
|
raise ValueError(
|
|
"input_image block is missing the 'image_url' field. "
|
|
"Upload the image via the session files API and reference "
|
|
"it by file_id so the content resolver can inline it."
|
|
)
|
|
if not image_url.startswith("data:"):
|
|
raise ValueError(
|
|
"input_image block has a URL instead of a data URI. "
|
|
"Upload the image via the session files API and reference "
|
|
"it by file_id instead."
|
|
)
|
|
media_type, data = _parse_data_uri(image_url)
|
|
result.append(
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": media_type,
|
|
"data": data,
|
|
},
|
|
}
|
|
)
|
|
elif block_type == "input_file":
|
|
file_data = block.get("file_data")
|
|
if not isinstance(file_data, str) or not file_data:
|
|
raise ValueError(
|
|
"input_file block is missing the 'file_data' field. "
|
|
"Upload the file via the session files API and reference "
|
|
"it by file_id so the content resolver can inline it."
|
|
)
|
|
if not file_data.startswith("data:"):
|
|
raise ValueError(
|
|
"input_file block has a URL instead of a data URI. "
|
|
"Upload the file via the session files API and reference "
|
|
"it by file_id instead."
|
|
)
|
|
media_type, data = _parse_data_uri(file_data)
|
|
if media_type == "application/pdf":
|
|
# Anthropic's base64 document source only accepts PDF.
|
|
result.append(
|
|
{
|
|
"type": "document",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "application/pdf",
|
|
"data": data,
|
|
},
|
|
}
|
|
)
|
|
else:
|
|
# All other text files (markdown, plain text, code, etc.)
|
|
# must use Anthropic's "text" source type with decoded content.
|
|
text_content = base64.b64decode(data).decode("utf-8", errors="replace")
|
|
result.append(
|
|
{
|
|
"type": "document",
|
|
"source": {
|
|
"type": "text",
|
|
"media_type": "text/plain",
|
|
"data": text_content,
|
|
},
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
async def _multimodal_message_iter(
|
|
content_blocks: list[dict[str, Any]],
|
|
*,
|
|
session_id: str,
|
|
) -> AsyncIterator[dict[str, Any]]:
|
|
"""
|
|
Yield a single structured user message dict for the Claude
|
|
SDK's ``AsyncIterable[dict]`` query path.
|
|
|
|
The SDK transport writes each yielded dict as a JSONL line to
|
|
the CLI's stdin. The CLI forwards the content blocks to the
|
|
Anthropic Messages API, which supports multimodal input.
|
|
|
|
:param content_blocks: Anthropic API content block dicts
|
|
(output of :func:`_to_anthropic_content_blocks`).
|
|
:param session_id: The SDK session identifier.
|
|
:yields: A single message dict.
|
|
"""
|
|
yield {
|
|
"type": "user",
|
|
"message": {"role": "user", "content": content_blocks},
|
|
"parent_tool_use_id": None,
|
|
"session_id": session_id,
|
|
}
|
|
|
|
|
|
# Diagnostic knob: when set (any truthy value), skip wrapping the CLI
|
|
# via ``create_exec_launcher``. Used to isolate whether the silent
|
|
# connect hang is sandbox-related vs. inside the binary itself.
|
|
_NO_SANDBOX_ENV = "OMNIGENT_CLAUDE_SDK_NO_SANDBOX"
|
|
|
|
|
|
def _sandbox_disabled_by_env() -> bool:
|
|
"""``True`` when the diagnostic bypass env var is set to a truthy
|
|
value. Emits a WARNING on activation so CI output unambiguously
|
|
confirms the bypass was in effect for a given run.
|
|
"""
|
|
if os.environ.get(_NO_SANDBOX_ENV):
|
|
logger.warning(
|
|
"Sandbox bypass active (%s is set); skipping create_exec_launcher.",
|
|
_NO_SANDBOX_ENV,
|
|
)
|
|
return True
|
|
return False
|
|
|
|
|
|
def _terminate_process_tree(process: _Process | None) -> None:
|
|
_proc.terminate_tree(process)
|
|
|
|
|
|
def _kill_process_tree(process: _Process | None) -> None:
|
|
_proc.kill_tree(process)
|
|
|
|
|
|
@contextmanager
|
|
def _unset_env_var(name: str) -> Iterator[None]:
|
|
"""
|
|
Temporarily remove an env var from ``os.environ`` for the duration of
|
|
the ``with`` block, then restore it (or leave it absent if it was not
|
|
set before).
|
|
|
|
Used around the claude-cli subprocess spawn to strip ``CLAUDECODE``
|
|
when our own Python process is itself running under Claude Code — the
|
|
child cli otherwise reports a "nested session" error. The SDK builds
|
|
the child env as ``{**os.environ, **options.env, ...}``, so the only
|
|
way to *remove* (not just override with ``""``) a key is to unset it
|
|
in ``os.environ`` during the spawn.
|
|
|
|
:param name: Env var name to remove for the block, e.g. ``"CLAUDECODE"``.
|
|
:yields: Nothing; restores ``os.environ[name]`` on exit if it was set.
|
|
"""
|
|
previous = os.environ.pop(name, None)
|
|
try:
|
|
yield
|
|
finally:
|
|
if previous is not None:
|
|
os.environ[name] = previous
|
|
|
|
|
|
_CLOSE_ATTR: str = "close"
|
|
_TRANSPORT_ATTR: str = "transport"
|
|
_ACLOSE_ATTR: str = "aclose"
|
|
|
|
|
|
def _call_optional_method(obj: Any, name: str) -> None: # type: ignore[explicit-any]
|
|
"""Call ``obj.<name>()`` if it exists and is callable, swallowing errors.
|
|
|
|
Uses a runtime attribute name so this stays out of the
|
|
``getattr(..., "<literal>", ...)`` lint's crosshairs while still giving
|
|
mypy a known shape (``Any`` at the boundary — the caller's concrete
|
|
types don't declare the sync ``close`` hook we probe here).
|
|
"""
|
|
method = getattr(obj, name, None)
|
|
if callable(method):
|
|
with suppress(Exception):
|
|
method()
|
|
|
|
|
|
def _best_effort_close(resource: _Stream | _Process) -> None:
|
|
"""Invoke a best-effort synchronous close on an SDK-internal handle.
|
|
|
|
The current SDK exposes ``aclose`` (async) on streams and a no-``close``
|
|
anyio ``Process``; older revs and test doubles may still ship a sync
|
|
``close`` method. We probe for it via ``hasattr``-style helpers and
|
|
swallow any failures — this runs only on the force-close teardown path
|
|
where the alternative is leaking the handle.
|
|
"""
|
|
_call_optional_method(resource, _CLOSE_ATTR)
|
|
transport_obj = getattr(resource, _TRANSPORT_ATTR, None)
|
|
if transport_obj is not None:
|
|
_call_optional_method(transport_obj, _CLOSE_ATTR)
|
|
|
|
|
|
# Default model for the Databricks-profile gateway path (no gateway base URL
|
|
# supplied directly), used when no spec/cfg model is set. On the ucode-cached
|
|
# path the Omnigent producer resolves the model instead (see workflow.py).
|
|
_DATABRICKS_CLAUDE_DEFAULT_MODEL = DATABRICKS_CLAUDE_DEFAULT_MODEL
|
|
|
|
_CLAUDE_API_KEY_HELPER_ENV_KEY = "OMNIGENT_CLAUDE_API_KEY_HELPER"
|
|
|
|
|
|
@dataclass
|
|
class _ClaudeClientState:
|
|
client: _ClaudeClient
|
|
model: str | None
|
|
loop: asyncio.AbstractEventLoop | None = None
|
|
task: asyncio.Task[None] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PreparedClaudeCli:
|
|
"""Result of wrapping the Claude CLI in an Omnigent sandbox.
|
|
|
|
:param cli_path: Path the SDK should exec for the Claude CLI. May be the
|
|
original system CLI or a generated wrapper script that applies the
|
|
sandbox before exec-ing the real binary. ``None`` when no CLI is
|
|
available (no system ``claude`` on PATH).
|
|
:param enable_native_tools: ``True`` when the sandbox is active and it is
|
|
safe for the SDK to enable the built-in native OS tools (Bash, Read,
|
|
Edit, …). ``False`` when the sandbox cannot be applied (e.g. network
|
|
denied, unsupported platform) — the caller should then leave native
|
|
tools disabled.
|
|
"""
|
|
|
|
cli_path: str | None
|
|
enable_native_tools: bool
|
|
|
|
|
|
def _ensure_sdk() -> ModuleType:
|
|
"""Import and return the claude_agent_sdk module, raising a clear error if missing."""
|
|
try:
|
|
import claude_agent_sdk
|
|
|
|
return claude_agent_sdk
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"ClaudeSDKExecutor requires the 'claude-agent-sdk' package. "
|
|
"Install it with: pip install claude-agent-sdk"
|
|
) from exc
|
|
|
|
|
|
def _build_mcp_tools(
|
|
tool_schemas: list[ToolSpec],
|
|
tool_executor: ToolExecutor | None,
|
|
) -> list[SdkMcpTool]:
|
|
"""Build SdkMcpTool objects from Omnigent tool schemas.
|
|
|
|
Each tool is backed by a handler that calls the Omnigent tool_executor
|
|
callback, which routes through the Session's tool registry (and thus
|
|
respects policies, history recording, etc.).
|
|
"""
|
|
sdk = cast(_ClaudeSDK, _ensure_sdk())
|
|
|
|
mcp_tools: list[SdkMcpTool] = []
|
|
for schema in tool_schemas:
|
|
raw_name = schema.get("name")
|
|
raw_desc = schema.get("description")
|
|
# ``sdk.tool()`` requires ``str`` for name/description — the SDK
|
|
# itself does not accept ``None``. Omnigent tool schemas always
|
|
# carry a ``name`` (see ``Tool.tool_schema``); fall back to ``""``
|
|
# only for the description, which is legitimately optional.
|
|
tname: str = raw_name if isinstance(raw_name, str) else ""
|
|
tdesc: str = raw_desc if isinstance(raw_desc, str) else ""
|
|
tparams = schema.get("parameters", {"type": "object", "properties": {}})
|
|
|
|
def _make_handler(tool_name: str) -> Callable[[ToolArgs], Awaitable[McpResponse]]:
|
|
async def handler(args: ToolArgs) -> McpResponse:
|
|
if tool_executor is None:
|
|
return {
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": json.dumps(
|
|
{"error": f"No tool executor for '{tool_name}'"}
|
|
),
|
|
}
|
|
],
|
|
}
|
|
try:
|
|
# ``tool_executor`` is declared as ``Awaitable[ToolResult]``
|
|
# so we always await. The isinstance guard below preserves
|
|
# the pre-refactor safety net for unexpected non-dict
|
|
# runtime returns without confusing the type checker.
|
|
raw = await tool_executor(tool_name, args)
|
|
result: ToolResult = raw if isinstance(raw, dict) else {"result": raw}
|
|
response: McpResponse = {
|
|
"content": [{"type": "text", "text": json.dumps(result)}],
|
|
}
|
|
if result.get("blocked") is True or (
|
|
"error" in result and result.get("error")
|
|
):
|
|
response["isError"] = True
|
|
return response
|
|
except Exception as exc: # noqa: BLE001 — tool handler converts any error to MCP error response
|
|
return {
|
|
"content": [{"type": "text", "text": json.dumps({"error": str(exc)})}],
|
|
"isError": True,
|
|
}
|
|
|
|
return handler
|
|
|
|
decorated = sdk.tool(tname, tdesc, tparams)(_make_handler(tname))
|
|
mcp_tools.append(decorated)
|
|
return mcp_tools
|
|
|
|
|
|
def _augment_system_prompt_for_omnigent_mcp_tools(
|
|
system_prompt: str,
|
|
tool_schemas: list[ToolSpec],
|
|
) -> str:
|
|
"""
|
|
Add Claude SDK-specific MCP tool-name guidance to the system prompt.
|
|
|
|
Omnigent schemas use bare names such as ``sys_session_send``. The
|
|
Claude SDK exposes tools from our in-process MCP server to the model
|
|
as ``mcp__omnigent__<bare_name>``. Bundled agent prompts and skills use
|
|
bare names because other executors call those directly, so the SDK needs
|
|
a bridge note to stop the model from trying a non-existent bare tool first.
|
|
"""
|
|
tool_names = [
|
|
name for schema in tool_schemas if isinstance((name := schema.get("name")), str) and name
|
|
]
|
|
if not tool_names:
|
|
return system_prompt
|
|
|
|
examples = [name for name in ("sys_session_send", "sys_session_create") if name in tool_names]
|
|
if examples:
|
|
example_text = "; ".join(
|
|
f"use `mcp__omnigent__{name}` when instructions say `{name}`" for name in examples
|
|
)
|
|
note = (
|
|
"Claude SDK tool naming: Omnigent tools are exposed as MCP tools. "
|
|
f"{example_text}. For any other Omnigent tool, use "
|
|
"`mcp__omnigent__<tool_name>` rather than the bare name."
|
|
)
|
|
else:
|
|
note = (
|
|
"Claude SDK tool naming: Omnigent tools are exposed as MCP tools. "
|
|
"When instructions mention a bare Omnigent tool name, invoke "
|
|
"`mcp__omnigent__<tool_name>` rather than the bare name."
|
|
)
|
|
|
|
if not system_prompt:
|
|
return note
|
|
return f"{system_prompt.rstrip()}\n\n{note}"
|
|
|
|
|
|
def _find_system_claude() -> str | None:
|
|
"""Find a system-installed ``claude`` CLI binary on PATH.
|
|
|
|
Returns the absolute path, or None if not found. Prefers the system
|
|
install over the SDK's bundled CLI because the bundled version may be
|
|
older and send beta flags the Databricks gateway doesn't support.
|
|
"""
|
|
return shutil.which("claude")
|
|
|
|
|
|
def _resolve_gateway_env(
|
|
profile: str | None = None,
|
|
*,
|
|
host_override: str | None = None,
|
|
base_url_override: str | None = None,
|
|
auth_command_override: str | None = None,
|
|
auth_refresh_interval_ms: int | None = None,
|
|
) -> dict[str, str]:
|
|
"""Build Claude Code gateway env from the gateway transport values.
|
|
|
|
The vendor-neutral gateway transport is a base URL + a bearer-token
|
|
command + a refresh TTL. When the gateway base URL and auth command are
|
|
supplied directly (the generic-provider producer, or ucode), they are
|
|
used verbatim. When only a Databricks profile is supplied (no override
|
|
values), the Databricks-specific fallback derives both from
|
|
``~/.databrickscfg``:
|
|
1. ~/.databrickscfg profile credentials
|
|
2. ~/.databrickscfg (explicit profile, DEFAULT, or first valid section)
|
|
Returns an empty dict if no credentials are available.
|
|
|
|
The bearer token itself is not returned. Claude Code receives an
|
|
invocation-local ``apiKeyHelper`` setting and refresh TTL instead, so
|
|
the CLI can periodically re-run the auth command during long sessions
|
|
instead of inheriting a one-hour token snapshot.
|
|
|
|
:param profile: Optional Databricks profile name from
|
|
``~/.databrickscfg`` (used only on the profile-derivation fallback).
|
|
:param host_override: Gateway workspace host origin, e.g.
|
|
``"https://example.databricks.com"``. When set, skips
|
|
``~/.databrickscfg`` host lookup and requires the gateway base URL
|
|
and auth command values.
|
|
:param base_url_override: When set, use this as ``ANTHROPIC_BASE_URL``
|
|
instead of deriving it from the profile host. Populated from
|
|
``HARNESS_CLAUDE_SDK_GATEWAY_BASE_URL``.
|
|
:param auth_command_override: Shell command that prints a bearer token,
|
|
e.g. ``"databricks auth token --host ..."`` or ``"printf %s sk-..."``.
|
|
:param auth_refresh_interval_ms: Refresh TTL in milliseconds, e.g.
|
|
``900000``.
|
|
:returns: Environment values plus an internal apiKeyHelper command
|
|
consumed by :meth:`ClaudeSDKExecutor.run_turn`, or ``{}`` when
|
|
no credentials are available.
|
|
:raises OSError: If a gateway host is present but missing the
|
|
corresponding base URL or auth command.
|
|
"""
|
|
host = host_override.rstrip("/") if host_override else None
|
|
if host is None and base_url_override is not None and auth_command_override is not None:
|
|
# Generic-provider gateway: explicit base_url + auth command,
|
|
# no Databricks host or profile required (e.g. ApiKeyAuth with
|
|
# a mock LLM server URL).
|
|
return {
|
|
"ANTHROPIC_BASE_URL": base_url_override,
|
|
"CLAUDE_CODE_API_KEY_HELPER_TTL_MS": str(
|
|
auth_refresh_interval_ms or _GATEWAY_AUTH_REFRESH_MS
|
|
),
|
|
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
|
|
_CLAUDE_API_KEY_HELPER_ENV_KEY: auth_command_override,
|
|
}
|
|
if host is None:
|
|
try:
|
|
from .databricks_executor import _read_databrickscfg
|
|
|
|
creds = _read_databrickscfg(profile)
|
|
except ImportError:
|
|
creds = None
|
|
|
|
if creds is None:
|
|
return {}
|
|
host = creds.host.rstrip("/")
|
|
base_url = (
|
|
base_url_override if base_url_override is not None else f"{host}/ai-gateway/anthropic"
|
|
)
|
|
auth_command = (
|
|
auth_command_override
|
|
if auth_command_override is not None
|
|
else _databricks_claude_auth_command(host, profile)
|
|
)
|
|
else:
|
|
if base_url_override is None:
|
|
raise OSError(
|
|
"ClaudeSDKExecutor(gateway=True) with a gateway workspace host "
|
|
"requires HARNESS_CLAUDE_SDK_GATEWAY_BASE_URL."
|
|
)
|
|
if auth_command_override is None:
|
|
raise OSError(
|
|
"ClaudeSDKExecutor(gateway=True) with a gateway workspace host "
|
|
"requires HARNESS_CLAUDE_SDK_GATEWAY_AUTH_COMMAND."
|
|
)
|
|
base_url = base_url_override
|
|
auth_command = auth_command_override
|
|
|
|
return {
|
|
"ANTHROPIC_BASE_URL": base_url,
|
|
"CLAUDE_CODE_API_KEY_HELPER_TTL_MS": str(
|
|
auth_refresh_interval_ms or _GATEWAY_AUTH_REFRESH_MS
|
|
),
|
|
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
|
|
_CLAUDE_API_KEY_HELPER_ENV_KEY: auth_command,
|
|
}
|
|
|
|
|
|
def _databricks_claude_auth_command(host: str, profile: str | None = None) -> str:
|
|
"""Return the legacy Databricks CLI auth helper command for Claude.
|
|
|
|
:param host: Databricks workspace host, e.g.
|
|
``"https://example.databricks.com"``.
|
|
:param profile: Optional ``~/.databrickscfg`` profile name, e.g.
|
|
``"oss"``. Preferred over ``--host`` when known: two profiles can
|
|
share one host, which makes ``databricks auth token --host`` fail
|
|
("Use --profile to specify which profile") → empty token → 401.
|
|
``--profile`` is always unambiguous.
|
|
:returns: Shell command that prints a bearer token.
|
|
"""
|
|
# --profile is unambiguous; --host fails when two profiles share a host.
|
|
selector = f"--profile {json.dumps(profile)}" if profile else f"--host {json.dumps(host)}"
|
|
# `--force-refresh` proactively refreshes a still-valid cached token
|
|
# (guards against a mid-session 401 on long gateway connections) but
|
|
# only exists in Databricks CLI >= v0.296.0. Probe `--help` and pass it
|
|
# only when supported: older CLIs reject the unknown flag → empty token
|
|
# → silent 401. Plain `auth token` still auto-refreshes expired tokens.
|
|
return (
|
|
'if [ -n "${DATABRICKS_BEARER:-}" ]; then '
|
|
'printf "%s\\n" "$DATABRICKS_BEARER"; '
|
|
"else force=''; "
|
|
"if databricks auth token --help 2>&1 | grep -q force-refresh; "
|
|
"then force=--force-refresh; fi; "
|
|
"env -u DATABRICKS_CONFIG_PROFILE "
|
|
f"databricks auth token {selector} "
|
|
"$force --output json | jq -r '.access_token'; fi"
|
|
)
|
|
|
|
|
|
def _parse_optional_int(value: str | None) -> int | None:
|
|
"""Parse an optional integer env-var value.
|
|
|
|
:param value: Raw env-var value, e.g. ``"900000"``.
|
|
:returns: Parsed integer, or ``None`` when unset or invalid.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
logger.warning("Ignoring invalid integer value %r", value)
|
|
return None
|
|
|
|
|
|
def _claude_internal_write_roots() -> list[pathlib.Path]:
|
|
"""Writable roots the Claude CLI needs for its own local session state."""
|
|
|
|
roots = [
|
|
pathlib.Path.home() / ".claude" / "backups",
|
|
pathlib.Path.home() / ".claude" / "plugins",
|
|
pathlib.Path.home() / ".claude" / "session-env",
|
|
pathlib.Path.home() / ".claude" / "sessions",
|
|
pathlib.Path.home() / ".npm" / "_logs",
|
|
pathlib.Path(tempfile.gettempdir()) / f"claude-{stable_user_id()}",
|
|
]
|
|
for root in roots:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return roots
|
|
|
|
|
|
def _claude_internal_write_files() -> list[pathlib.Path]:
|
|
"""Exact files the Claude CLI updates outside its writable roots."""
|
|
|
|
# .credentials.json holds the Claude CLI's OAuth token on Linux.
|
|
candidates = [
|
|
pathlib.Path.home() / ".claude.json",
|
|
pathlib.Path.home() / ".claude" / ".credentials.json",
|
|
]
|
|
return [path for path in candidates if path.exists()]
|
|
|
|
|
|
def prepare_claude_cli_path(
|
|
real_cli_path: str | None,
|
|
spec: OSEnvSpec | None,
|
|
) -> PreparedClaudeCli:
|
|
"""Wrap the Claude CLI in the agent's configured sandbox when possible.
|
|
|
|
:param real_cli_path: Absolute path to the system-installed Claude CLI
|
|
binary, or ``None`` when no CLI is available.
|
|
:param spec: The agent's ``os_env`` spec. Only ``caller_process`` specs
|
|
with a compatible sandbox are eligible for wrapping.
|
|
:returns: A :class:`PreparedClaudeCli` naming the effective CLI path and
|
|
whether native tools should be enabled.
|
|
"""
|
|
|
|
if real_cli_path is None or spec is None or spec.type != "caller_process":
|
|
return PreparedClaudeCli(cli_path=real_cli_path, enable_native_tools=False)
|
|
|
|
if _sandbox_disabled_by_env():
|
|
return PreparedClaudeCli(cli_path=real_cli_path, enable_native_tools=False)
|
|
|
|
sandbox_spec = spec.sandbox or OSEnvSandboxSpec()
|
|
if sandbox_spec.type == "none":
|
|
return PreparedClaudeCli(cli_path=real_cli_path, enable_native_tools=True)
|
|
|
|
cwd = pathlib.Path(spec.cwd or os.getcwd()).resolve(strict=False)
|
|
sandbox = resolve_sandbox(spec, cwd)
|
|
if not sandbox.active:
|
|
return PreparedClaudeCli(cli_path=real_cli_path, enable_native_tools=False)
|
|
if not sandbox.allow_network:
|
|
# The Claude CLI itself must reach the provider, so we cannot run the
|
|
# whole native-tool process tree inside a network-denying sandbox.
|
|
return PreparedClaudeCli(cli_path=real_cli_path, enable_native_tools=False)
|
|
|
|
sandbox = with_additional_read_roots(sandbox, _claude_internal_write_roots())
|
|
sandbox = with_additional_write_roots(sandbox, _claude_internal_write_roots())
|
|
sandbox = with_additional_write_files(sandbox, _claude_internal_write_files())
|
|
return PreparedClaudeCli(
|
|
cli_path=create_exec_launcher(real_cli_path, sandbox),
|
|
enable_native_tools=True,
|
|
)
|
|
|
|
|
|
def prepare_tight_cli_process_path(
|
|
real_cli_path: str | None,
|
|
*,
|
|
cwd: str | None = None,
|
|
) -> str | None:
|
|
"""Wrap the Claude CLI in a tight default sandbox without enabling tools."""
|
|
|
|
if real_cli_path is None:
|
|
return None
|
|
|
|
if _sandbox_disabled_by_env():
|
|
return real_cli_path
|
|
|
|
# Skip silently on non-Linux: the implicit default sandbox here is
|
|
# ``linux_bwrap`` and ``resolve_sandbox`` would raise
|
|
# NotImplementedError / OSError on every macOS / Windows run. The
|
|
# operator's only recourse is to either accept the no-op or
|
|
# set ``os_env.sandbox.type='none'`` explicitly — both
|
|
# already produce the same behavior we land on here, so
|
|
# logging a warning every run is just noise (and breaks
|
|
# tests that assert ``stderr_is_clean``).
|
|
if sys.platform != "linux":
|
|
return real_cli_path
|
|
|
|
spec = OSEnvSpec(
|
|
type="caller_process",
|
|
cwd=cwd,
|
|
sandbox=OSEnvSandboxSpec(
|
|
type="linux_bwrap",
|
|
write_paths=[],
|
|
allow_network=True,
|
|
),
|
|
)
|
|
try:
|
|
resolved_cwd = pathlib.Path(cwd or os.getcwd()).resolve(strict=False)
|
|
sandbox = resolve_sandbox(spec, resolved_cwd)
|
|
except (OSError, NotImplementedError) as exc:
|
|
logger.warning(
|
|
"Could not apply default local CLI sandbox; continuing without it: %s",
|
|
exc,
|
|
)
|
|
return real_cli_path
|
|
|
|
if not sandbox.active:
|
|
return real_cli_path
|
|
sandbox = with_additional_write_roots(sandbox, _claude_internal_write_roots())
|
|
sandbox = with_additional_write_files(sandbox, _claude_internal_write_files())
|
|
return create_exec_launcher(real_cli_path, sandbox)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ResolvedSkills:
|
|
"""
|
|
Pair of SDK options derived from a single ``skills_filter``
|
|
value: ``ClaudeAgentOptions.skills`` and
|
|
``ClaudeAgentOptions.setting_sources``.
|
|
|
|
Both are needed because the SDK's ``_apply_skills_defaults``
|
|
auto-defaults ``setting_sources`` to ``["user", "project"]``
|
|
whenever ``skills`` is non-None — including when ``skills=[]``.
|
|
That auto-default loads ``~/.claude/skills/`` and the cwd's
|
|
ancestor ``.claude/skills/`` chain into the system prompt
|
|
listing even when the ``Skill`` tool itself is suppressed.
|
|
Hermetic agents need to explicitly override
|
|
``setting_sources=[]`` to actually hide host skills from the
|
|
model's view of its own skill listing.
|
|
|
|
:param skills: Value for ``ClaudeAgentOptions.skills``:
|
|
``"all"`` / list of names / empty list for hermetic mode.
|
|
:param setting_sources: Value for
|
|
``ClaudeAgentOptions.setting_sources``: ``None`` to let
|
|
the SDK pick its default (``["user", "project"]``), or
|
|
an explicit list (e.g. ``[]`` for hermetic mode where we
|
|
don't want any scope-based discovery).
|
|
"""
|
|
|
|
skills: str | list[str]
|
|
setting_sources: list[str] | None
|
|
|
|
|
|
def _resolve_skills_option(
|
|
skills_filter: str | list[str],
|
|
) -> _ResolvedSkills | None:
|
|
"""
|
|
Translate the spec's ``skills_filter`` into the pair of SDK
|
|
options ``ClaudeAgentOptions.skills`` and
|
|
``ClaudeAgentOptions.setting_sources``.
|
|
|
|
Three meaningful filter values produce three distinct SDK
|
|
configurations:
|
|
|
|
- ``"all"`` → ``skills="all"``, ``setting_sources=None`` (SDK
|
|
auto-defaults to ``["user", "project"]``). All host skills
|
|
from ``~/.claude/skills/`` and ``<cwd>/.claude/skills/``
|
|
(walking up the cwd tree) appear in the model's listing.
|
|
- ``"none"`` → ``skills=[]``, ``setting_sources=[]``. Both
|
|
the ``Skill`` tool listing AND the scope-based discovery
|
|
are suppressed: no host skills appear in the system
|
|
prompt or as invokable. Bundled skills (loaded via
|
|
``--plugin-dir``) are unaffected by ``setting_sources``
|
|
and remain visible.
|
|
- ``list[str]`` → ``skills=[names]``, ``setting_sources=None``.
|
|
Only the named subset is in the model's listing; the SDK's
|
|
auto-default still loads user and project sources for
|
|
CLAUDE.md and other settings.
|
|
|
|
:param skills_filter: ``"all"`` / ``"none"`` / list of skill
|
|
names from :class:`AgentSpec.skills_filter`.
|
|
:returns: The :class:`_ResolvedSkills` pair, or ``None`` when
|
|
*skills_filter* is malformed — the caller falls back to
|
|
``"all"`` semantics.
|
|
"""
|
|
if skills_filter == "all":
|
|
return _ResolvedSkills(skills="all", setting_sources=None)
|
|
if skills_filter == "none":
|
|
# Empty ``skills`` suppresses the listing AND empty
|
|
# ``setting_sources`` skips the SDK's auto-default that
|
|
# would otherwise load ``~/.claude/skills/`` for the
|
|
# system prompt anyway.
|
|
return _ResolvedSkills(skills=[], setting_sources=[])
|
|
if isinstance(skills_filter, list):
|
|
return _ResolvedSkills(skills=list(skills_filter), setting_sources=None)
|
|
return None
|
|
|
|
|
|
class ClaudeSDKExecutor(Executor):
|
|
"""Execute agent turns using the Claude Agent SDK.
|
|
|
|
The SDK runs Claude Code's full agent loop internally. Omnigent tools
|
|
are registered as MCP tools so the model can call them. Built-in OS tools
|
|
(Bash, Read, Edit, …) are only enabled when the agent's ``os_env`` flag
|
|
is set. Even without ``os_env``, Omnigent tries to place the Claude CLI
|
|
itself in a tight default sandbox on supported Linux hosts.
|
|
|
|
Unlike DatabricksExecutor, the SDK manages its own tool-call loop. This
|
|
executor yields events reconstructed from the SDK message stream:
|
|
- ToolCallRequest for each ToolUseBlock (for history building)
|
|
- TextChunk for streaming text
|
|
- TurnComplete with the final result
|
|
|
|
Multi-turn: call ``run_turn()`` repeatedly. The executor maintains a
|
|
persistent ``ClaudeSDKClient`` that preserves conversation context across
|
|
turns by keeping a live Claude SDK client per Omnigent session.
|
|
|
|
Gateway support: pass ``gateway=True`` to route through a vendor-neutral
|
|
gateway (base URL + bearer-token command + model). The Databricks AI
|
|
gateway is one producer of this transport (credentials resolved from
|
|
~/.databrickscfg via ``databricks_profile``); generic ``key`` / ``gateway``
|
|
providers are another (base URL + auth command supplied directly).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cwd: str | None = None,
|
|
os_env: OSEnvSpec | None = None,
|
|
model: str | None = None,
|
|
permission_mode: str = "auto",
|
|
gateway: bool = False,
|
|
databricks_profile: str | None = None,
|
|
gateway_host: str | None = None,
|
|
base_url_override: str | None = None,
|
|
gateway_auth_command: str | None = None,
|
|
gateway_auth_refresh_interval_ms: str | None = None,
|
|
retry_policy: RetryPolicy | None = None,
|
|
bundle_dir: pathlib.Path | None = None,
|
|
agent_name: str | None = None,
|
|
skills_filter: str | list[str] = "all",
|
|
api_key_helper: str | None = None,
|
|
) -> None:
|
|
"""Create a ClaudeSDKExecutor.
|
|
|
|
Args:
|
|
cwd: Working directory for Claude Code.
|
|
os_env: If set, enable built-in OS tools (Bash, Read, Edit, …)
|
|
and align them with the provided OS environment. When the
|
|
spec's sandbox is enabled, Omnigent wraps the Claude CLI
|
|
in the same sandbox. If omitted, Omnigent still tries to
|
|
sandbox the Claude CLI process itself on supported Linux
|
|
hosts, but does not enable native OS tools.
|
|
model: Override the model name.
|
|
permission_mode: SDK permission mode (default: auto
|
|
so the agent runs autonomously with background safety checks).
|
|
gateway: If True, route through a vendor-neutral gateway
|
|
(base URL + bearer-token command + model). Enables the
|
|
gateway path regardless of which producer fed it (the
|
|
Databricks AI gateway or a generic provider).
|
|
databricks_profile: Databricks-specific config profile from
|
|
~/.databrickscfg, e.g. ``"<your-profile>"``. Only used by the
|
|
Databricks producer path (deriving base URL / auth command
|
|
from the profile when ucode did not supply them, and for
|
|
``databricks auth token`` refresh). ``None`` falls back to the
|
|
SDK's own profile resolution (``DATABRICKS_CONFIG_PROFILE``
|
|
then the first valid section of ``~/.databrickscfg``).
|
|
gateway_host: Gateway workspace host origin, e.g.
|
|
``"https://example.databricks.com"``. Set from
|
|
``HARNESS_CLAUDE_SDK_GATEWAY_HOST`` (written by the AP
|
|
workflow layer). When set, skips profile host lookup and
|
|
requires the gateway base URL and auth command values.
|
|
base_url_override: Override the Anthropic base URL instead of
|
|
constructing it from the Databricks profile host. Set from
|
|
``HARNESS_CLAUDE_SDK_GATEWAY_BASE_URL`` (written by the AP
|
|
workflow layer). Required whenever ``gateway_host`` is set.
|
|
gateway_auth_command: Shell command that prints a bearer token,
|
|
e.g.
|
|
``"databricks auth token --host https://example.databricks.com ..."``
|
|
or ``"printf %s sk-or-..."``. Set from
|
|
``HARNESS_CLAUDE_SDK_GATEWAY_AUTH_COMMAND``.
|
|
gateway_auth_refresh_interval_ms: Refresh TTL as a string,
|
|
e.g. ``"900000"``. Set from
|
|
``HARNESS_CLAUDE_SDK_GATEWAY_AUTH_REFRESH_INTERVAL_MS``.
|
|
bundle_dir: Materialized agent-bundle root, when the agent
|
|
ships its own ``skills/`` directory. Used to expose
|
|
bundled skills to Claude via ``--plugin-dir <bundle>``
|
|
(the SDK's plugin convention loads SKILL.md files from
|
|
``<plugin>/skills/<name>/``). ``None`` for agents
|
|
without a bundled-skill directory — the harness skips
|
|
the plugin-dir wiring.
|
|
agent_name: Optional agent display name. When *bundle_dir*
|
|
is set, used to write a one-line ``.claude-plugin/
|
|
plugin.json`` manifest so bundled skills get clean
|
|
``<agent-name>:<skill-name>`` namespaced labels in
|
|
Claude's skill listing (instead of being labeled by
|
|
the bundle's tmp-dir basename).
|
|
skills_filter: Host-skill filter (``"all"`` / ``"none"`` /
|
|
``list[str]``). Maps to the SDK's ``skills`` option:
|
|
``"all"`` → enable every host-discovered skill,
|
|
``"none"`` → empty list (no host skills exposed),
|
|
list of names → only the named skills. Bundled
|
|
skills loaded via ``bundle_dir`` are subject to the
|
|
same listing filter (so ``"none"`` hides every skill
|
|
from the model, bundled or host); agents that want
|
|
bundled skills always visible while opting out of
|
|
host skills should set this to a list naming their
|
|
bundled skills explicitly. Defaults to ``"all"``.
|
|
api_key_helper: Shell command the Claude CLI will invoke to
|
|
retrieve a bearer token, e.g.
|
|
``"printf %s sk-ant-..."`` (set by the harness when
|
|
``executor.auth: {type: api_key, …}`` is declared).
|
|
Injected into ``_extra_env`` as
|
|
:data:`_CLAUDE_API_KEY_HELPER_ENV_KEY` so it reaches
|
|
the SDK's ``settings.apiKeyHelper`` option at turn time.
|
|
"""
|
|
# Fail loud: a ``databricks-*`` model requires the gateway transport.
|
|
if not gateway and model is not None and model.startswith("databricks-"):
|
|
raise ValueError(
|
|
f"Model {model!r} is a Databricks-hosted model but gateway "
|
|
"routing is disabled (gateway=False). "
|
|
"Set executor.profile in the agent spec, or configure a "
|
|
"Databricks provider with `omnigent setup`, to route through "
|
|
"the Databricks Anthropic gateway."
|
|
)
|
|
self._cwd = cwd
|
|
self._os_env_spec = os_env
|
|
self._os_env = os_env is not None
|
|
self._model_override = model
|
|
self._permission_mode = permission_mode
|
|
self._gateway = gateway
|
|
self._databricks_profile = databricks_profile
|
|
self._gateway_host = gateway_host.rstrip("/") if gateway_host else None
|
|
self._base_url_override = base_url_override
|
|
self._gateway_auth_command = gateway_auth_command
|
|
self._gateway_auth_refresh_interval_ms = _parse_optional_int(
|
|
gateway_auth_refresh_interval_ms
|
|
)
|
|
self._bundle_dir = bundle_dir
|
|
self._agent_name = agent_name
|
|
self._skills_filter = skills_filter
|
|
# Write the bundle's plugin manifest now (idempotent) so that
|
|
# ``--plugin-dir <bundle>`` produces clean
|
|
# ``<agent-name>:<skill-name>`` labels in Claude's skill
|
|
# listing instead of the auto-derived tmpdir basename.
|
|
if self._bundle_dir is not None:
|
|
try:
|
|
ensure_bundle_plugin_manifest(self._bundle_dir, self._agent_name)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"could not write bundle plugin manifest at %s: %s",
|
|
self._bundle_dir,
|
|
exc,
|
|
)
|
|
self._tool_executor: ToolExecutor | None = None
|
|
# Elicitation handler wired in by ExecutorAdapter. When set
|
|
# (and permission_mode is not bypassPermissions), each tool
|
|
# call is gated by an async approve/deny round-trip through
|
|
# the Omnigent elicitation system rather than silently allowed.
|
|
# ``None`` until the adapter installs it on first use.
|
|
self._elicitation_handler: ElicitationHandler | None = None
|
|
# Live Claude SDK clients keyed by Omnigent session id.
|
|
self._clients: dict[str, _ClaudeClientState] = {}
|
|
# Session keys whose Claude harness process crashed and must not be reused.
|
|
self._crashed_sessions: dict[str, str] = {}
|
|
|
|
# Prefer system-installed claude over the SDK's bundled CLI.
|
|
# The bundled CLI may be older and send beta flags that the
|
|
# Databricks gateway doesn't support.
|
|
self._cli_wrapper_path: str | None = None
|
|
self._cli_path: str | None = _find_system_claude()
|
|
if self._cli_path:
|
|
if self._os_env_spec is not None:
|
|
prepared = prepare_claude_cli_path(
|
|
self._cli_path,
|
|
self._os_env_spec,
|
|
)
|
|
self._os_env = prepared.enable_native_tools
|
|
if prepared.cli_path != self._cli_path:
|
|
self._cli_wrapper_path = prepared.cli_path
|
|
self._cli_path = prepared.cli_path
|
|
else:
|
|
wrapped_cli = prepare_tight_cli_process_path(
|
|
self._cli_path,
|
|
cwd=self._cwd,
|
|
)
|
|
if wrapped_cli != self._cli_path:
|
|
self._cli_wrapper_path = wrapped_cli
|
|
self._cli_path = wrapped_cli
|
|
if self._os_env_spec is not None and self._cwd is None:
|
|
self._cwd = self._os_env_spec.cwd
|
|
logger.info("Using system claude CLI: %s", self._cli_path)
|
|
else:
|
|
logger.info("No system claude found; SDK will use bundled CLI")
|
|
|
|
# True when the gateway transport was derived from a ~/.databrickscfg
|
|
# profile (no gateway host or base URL supplied directly). Gates the
|
|
# Databricks-specific default model in :meth:`run_turn`; the neutral
|
|
# generic-provider gateway path leaves this False so it never selects
|
|
# a ``databricks-*`` model.
|
|
self._gateway_uses_databricks_profile = bool(
|
|
gateway and self._gateway_host is None and base_url_override is None
|
|
)
|
|
|
|
# Lazily-started local proxy that restores request fields the
|
|
# Claude CLI strips on the gateway path (thinking.display).
|
|
# Started on the first gateway turn — __init__ has no event loop.
|
|
self._gateway_shim: ClaudeGatewayShim | None = None
|
|
|
|
# Eagerly resolve the gateway transport env so errors surface at
|
|
# construction time.
|
|
self._extra_env: dict[str, str] = {}
|
|
if gateway:
|
|
self._extra_env = _resolve_gateway_env(
|
|
databricks_profile,
|
|
host_override=self._gateway_host,
|
|
base_url_override=base_url_override,
|
|
auth_command_override=self._gateway_auth_command,
|
|
auth_refresh_interval_ms=self._gateway_auth_refresh_interval_ms,
|
|
)
|
|
if not self._extra_env:
|
|
raise OSError(
|
|
"ClaudeSDKExecutor(gateway=True) requires gateway credentials "
|
|
"from the gateway base URL / auth command or a valid "
|
|
"~/.databrickscfg profile."
|
|
)
|
|
|
|
# Retry policy → Anthropic SDK env vars passed to the Claude
|
|
# CLI subprocess. ``ANTHROPIC_MAX_RETRIES`` and
|
|
# ``ANTHROPIC_REQUEST_TIMEOUT_SECONDS`` are speculative — the
|
|
# CLI's retry budget isn't publicly documented as env-tunable.
|
|
# See ``RetryPolicy.claude_cli.env()``.
|
|
self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy()
|
|
self._extra_env.update(self._retry_policy.claude_cli.env())
|
|
|
|
# api_key_helper: shell command the Claude CLI invokes to retrieve a
|
|
# bearer token (``executor.auth: {type: api_key, …}`` path). Must
|
|
# be injected here (not read from os.environ) because
|
|
# ``_get_or_create_client`` strips ``ANTHROPIC_API_KEY`` from
|
|
# os.environ before connecting, and the executor reads
|
|
# ``_CLAUDE_API_KEY_HELPER_ENV_KEY`` from ``_extra_env`` only.
|
|
if api_key_helper:
|
|
self._extra_env[_CLAUDE_API_KEY_HELPER_ENV_KEY] = api_key_helper
|
|
|
|
def __del__(self) -> None:
|
|
if getattr(self, "_cli_wrapper_path", None):
|
|
with suppress(Exception):
|
|
pathlib.Path(self._cli_wrapper_path).unlink(missing_ok=True)
|
|
|
|
async def _route_options_through_gateway_shim(self, options: SdkOptions) -> None:
|
|
"""
|
|
Point a new client's ``ANTHROPIC_BASE_URL`` at the local shim.
|
|
|
|
On the gateway path the Claude CLI strips ``thinking.display``
|
|
from its requests (experimental betas are disabled there),
|
|
which silences opus thinking; the shim restores the field. See
|
|
the :mod:`~omnigent.inner.claude_gateway_shim` module
|
|
docstring for the full failure chain. No-op off the gateway
|
|
path.
|
|
|
|
:param options: SDK options about to be passed to
|
|
``ClaudeSDKClient``; ``options.env["ANTHROPIC_BASE_URL"]``
|
|
is rewritten in place to the shim's loopback URL.
|
|
:raises RuntimeError: If the gateway path produced options
|
|
without an ``ANTHROPIC_BASE_URL`` — a config bug that
|
|
would silently bypass the shim.
|
|
"""
|
|
if not self._gateway:
|
|
return
|
|
env = getattr(options, "env", None)
|
|
if not isinstance(env, dict) or "ANTHROPIC_BASE_URL" not in env:
|
|
raise RuntimeError(
|
|
"ClaudeSDKExecutor(gateway=True) built SDK options without "
|
|
"env['ANTHROPIC_BASE_URL']; cannot route through the gateway shim."
|
|
)
|
|
if self._gateway_shim is None:
|
|
self._gateway_shim = ClaudeGatewayShim(upstream_base_url=env["ANTHROPIC_BASE_URL"])
|
|
await self._gateway_shim.start()
|
|
env["ANTHROPIC_BASE_URL"] = self._gateway_shim.base_url
|
|
|
|
async def _get_or_create_client(
|
|
self,
|
|
sdk: _ClaudeSDK,
|
|
*,
|
|
session_key: str,
|
|
options: SdkOptions,
|
|
model: str | None,
|
|
) -> _ClaudeClient:
|
|
state = self._clients.get(session_key)
|
|
if state is None:
|
|
await self._route_options_through_gateway_shim(options)
|
|
# Tee CLI stderr so the connect timeout error carries the
|
|
# tail; ``_on_stderr`` alone only logs at DEBUG.
|
|
connect_stderr: list[str] = []
|
|
original_stderr = getattr(options, "stderr", None)
|
|
|
|
def _tee_stderr(line: str) -> None:
|
|
connect_stderr.append(line)
|
|
if original_stderr is not None:
|
|
original_stderr(line)
|
|
|
|
options.stderr = _tee_stderr
|
|
client = sdk.ClaudeSDKClient(options)
|
|
try:
|
|
# CLAUDECODE must be absent (not just empty) in the child
|
|
# env — otherwise the claude cli reports a nested-session
|
|
# error. The SDK merges ``os.environ`` with ``options.env``,
|
|
# so we unset in ``os.environ`` for the spawn window.
|
|
#
|
|
# ANTHROPIC_API_KEY is also stripped so the CLI uses its
|
|
# subscription auth rather than a developer API key that
|
|
# would charge separately. Safe even in Databricks mode:
|
|
# ``options.settings`` explicitly sets apiKeyHelper and
|
|
# ``options.env`` sets the Databricks base URL, so the
|
|
# Claude CLI does not need an inherited Anthropic key.
|
|
with _unset_env_var("CLAUDECODE"), _unset_env_var("ANTHROPIC_API_KEY"):
|
|
await asyncio.wait_for(client.connect(), timeout=_CONNECT_TIMEOUT_SECONDS)
|
|
except asyncio.TimeoutError as exc:
|
|
await self._force_close_client(client)
|
|
tail = "\n".join(line.rstrip() for line in connect_stderr[-40:])
|
|
detail = tail or "(no CLI stderr captured)"
|
|
logger.warning(
|
|
"Claude SDK connect timed out after %ds; CLI stderr tail:\n%s",
|
|
int(_CONNECT_TIMEOUT_SECONDS),
|
|
detail,
|
|
)
|
|
raise TimeoutError(
|
|
f"Claude SDK client connect timed out after "
|
|
f"{int(_CONNECT_TIMEOUT_SECONDS)}s. CLI stderr tail:\n{detail}"
|
|
) from exc
|
|
except Exception as exc:
|
|
# The CLI may exit immediately (e.g. ``unknown option``) before
|
|
# the SDK's async stderr reader has flushed all output. A brief
|
|
# yield lets pending reader callbacks deliver remaining lines so
|
|
# the error message is useful rather than just "Command failed
|
|
# with exit code 1; Check stderr output for details".
|
|
await asyncio.sleep(0.1)
|
|
await self._force_close_client(client)
|
|
tail = "\n".join(line.rstrip() for line in connect_stderr[-40:])
|
|
detail = tail or "(no CLI stderr captured)"
|
|
logger.error(
|
|
"Claude SDK connect failed: %s\nCLI stderr tail:\n%s",
|
|
exc,
|
|
detail,
|
|
)
|
|
raise RuntimeError(
|
|
f"Claude SDK connect failed: {exc}\nCLI stderr:\n{detail}"
|
|
) from exc
|
|
finally:
|
|
# Restore on both paths so post-connect stderr flows
|
|
# directly to the original callback and ``connect_stderr``
|
|
# can be GC'd instead of growing for the session lifetime.
|
|
options.stderr = original_stderr
|
|
current_task: asyncio.Task[None] | None = cast(
|
|
"asyncio.Task[None] | None", asyncio.current_task()
|
|
)
|
|
state = _ClaudeClientState(
|
|
client=client,
|
|
model=model,
|
|
loop=asyncio.get_running_loop(),
|
|
task=current_task,
|
|
)
|
|
self._clients[session_key] = state
|
|
return client
|
|
|
|
if state.model != model:
|
|
await state.client.set_model(model)
|
|
state.model = model
|
|
return state.client
|
|
|
|
async def close_session(self, session_key: str) -> None:
|
|
self._crashed_sessions.pop(session_key, None)
|
|
await self._close_live_client(session_key)
|
|
|
|
async def _close_live_client(self, session_key: str) -> None:
|
|
state = self._clients.pop(session_key, None)
|
|
if state is None:
|
|
return
|
|
try:
|
|
current_loop = asyncio.get_running_loop()
|
|
current_task = asyncio.current_task()
|
|
except RuntimeError:
|
|
current_loop = None
|
|
current_task = None
|
|
|
|
same_loop = state.loop is None or current_loop is state.loop
|
|
same_task = state.task is None or current_task is state.task
|
|
if not (same_loop and same_task):
|
|
logger.debug(
|
|
"Force-closing Claude SDK client for session %s (different event loop/task; "
|
|
"expected once the connecting turn has finished, e.g. idle reap / shutdown)",
|
|
session_key,
|
|
)
|
|
await self._force_close_client(state.client)
|
|
return
|
|
try:
|
|
await state.client.disconnect()
|
|
except RuntimeError as exc:
|
|
if "different task" not in str(exc):
|
|
raise
|
|
logger.debug(
|
|
"Force-closing Claude SDK client for session %s (different task; "
|
|
"expected once the connecting turn has finished, e.g. idle reap / shutdown)",
|
|
session_key,
|
|
)
|
|
await self._force_close_client(state.client)
|
|
return
|
|
# SDK's disconnect() doesn't close the asyncio transport;
|
|
# call _force_close_client to flip transport._closed before
|
|
# the loop tears down.
|
|
await self._force_close_client(state.client)
|
|
|
|
async def close(self) -> None:
|
|
session_keys = list(self._clients)
|
|
for session_key in session_keys:
|
|
await self.close_session(session_key)
|
|
if self._gateway_shim is not None:
|
|
await self._gateway_shim.aclose()
|
|
self._gateway_shim = None
|
|
|
|
async def interrupt_session(self, session_key: str) -> bool:
|
|
state = self._clients.get(session_key)
|
|
if state is None:
|
|
return False
|
|
# Interrupt is best-effort and fast; a failure just falls through to
|
|
# the close below.
|
|
try:
|
|
await asyncio.wait_for(state.client.interrupt(), timeout=0.5)
|
|
except Exception as exc: # noqa: BLE001 — interrupt is best-effort
|
|
logger.warning(
|
|
"Claude SDK interrupt failed for session %s: %s",
|
|
session_key,
|
|
exc,
|
|
)
|
|
# Always drop the live session after an interrupt. Its transcript
|
|
# still holds the abandoned prompt, and resumed turns send only the
|
|
# latest user message (see _build_prompt), so the runner's
|
|
# "[System: interrupted]" marker would never reach the model. Closing
|
|
# forces the next turn to rebuild full history (marker included) in a
|
|
# fresh session — the abandoned request is then visible-but-superseded
|
|
# rather than silently continued.
|
|
try:
|
|
await self.close_session(session_key)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 — close failures surface via False return
|
|
logger.warning(
|
|
"Claude SDK session close after interrupt failed for session %s: %s",
|
|
session_key,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
async def enqueue_session_message(
|
|
self,
|
|
session_key: str,
|
|
content: str | Message,
|
|
) -> bool:
|
|
state = self._clients.get(session_key)
|
|
if state is None:
|
|
return False
|
|
try:
|
|
if isinstance(content, str):
|
|
prompt = content
|
|
else:
|
|
prompt = json.dumps(content, ensure_ascii=True)
|
|
await state.client.query(prompt, session_id=session_key)
|
|
return True
|
|
except Exception as exc: # noqa: BLE001 — enqueue returns False on any SDK failure
|
|
logger.warning(
|
|
"Claude SDK live message enqueue failed for session %s: %s",
|
|
session_key,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _force_close_client(client: _ClaudeClient) -> None:
|
|
# getattr defensively: runs on the success path too, so must
|
|
# no-op against test fakes that only stub `disconnect`.
|
|
query = getattr(client, "_query", None)
|
|
if query is not None:
|
|
query._closed = True
|
|
tg = getattr(query, "_tg", None)
|
|
if tg is not None:
|
|
with suppress(Exception):
|
|
tg.cancel_scope.cancel()
|
|
|
|
transport = getattr(client, "_transport", None)
|
|
if transport is not None:
|
|
# The SDK's stderr reader changed shape across revs: current
|
|
# claude-agent-sdk (>=0.2.x) exposes a single ``_stderr_task``
|
|
# TaskHandle with ``cancel()``; older revs an anyio
|
|
# ``_stderr_task_group``. Probe both via getattr so a force-close
|
|
# never raises AttributeError out of lifespan shutdown (which
|
|
# crashed the runner on session stop).
|
|
stderr_task = getattr(transport, "_stderr_task", None)
|
|
if stderr_task is not None:
|
|
with suppress(Exception):
|
|
stderr_task.cancel()
|
|
else:
|
|
stderr_tg = getattr(transport, "_stderr_task_group", None)
|
|
if stderr_tg is not None:
|
|
with suppress(Exception):
|
|
stderr_tg.cancel_scope.cancel()
|
|
|
|
for stream in (
|
|
transport._stdout_stream,
|
|
transport._stdin_stream,
|
|
transport._stderr_stream,
|
|
):
|
|
if stream is not None:
|
|
_best_effort_close(stream)
|
|
|
|
process = transport._process
|
|
if process is not None:
|
|
if process.returncode is None:
|
|
_terminate_process_tree(process)
|
|
try:
|
|
with suppress(Exception):
|
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
except asyncio.TimeoutError:
|
|
_kill_process_tree(process)
|
|
with suppress(Exception):
|
|
await process.wait()
|
|
except asyncio.CancelledError:
|
|
_kill_process_tree(process)
|
|
with suppress(Exception):
|
|
await process.wait()
|
|
|
|
_best_effort_close(process)
|
|
# The SDK's `transport._process` is an anyio Process;
|
|
# `_best_effort_close` can't reach its asyncio
|
|
# transport (no sync `close`). Close it explicitly.
|
|
close_anyio_subprocess_transport(process)
|
|
|
|
transport._process = None
|
|
transport._stdout_stream = None
|
|
transport._stdin_stream = None
|
|
transport._stderr_stream = None
|
|
if getattr(transport, "_stderr_task", None) is not None:
|
|
transport._stderr_task = None
|
|
if getattr(transport, "_stderr_task_group", None) is not None:
|
|
transport._stderr_task_group = None
|
|
transport._ready = False
|
|
|
|
client._query = None
|
|
client._transport = None
|
|
|
|
def supports_streaming(self) -> bool:
|
|
return True
|
|
|
|
def supports_tool_calling(self) -> bool:
|
|
return True
|
|
|
|
def handles_tools_internally(self) -> bool:
|
|
return True
|
|
|
|
def supports_live_message_queue(self) -> bool:
|
|
return True
|
|
|
|
def supports_tool_boundary_interrupt(self) -> bool:
|
|
return True
|
|
|
|
def max_context_tokens(self) -> int | None:
|
|
return None # SDK manages its own context
|
|
|
|
def _session_key(self, messages: list[Message]) -> str:
|
|
for msg in reversed(messages):
|
|
session_id = msg.get("session_id")
|
|
if session_id:
|
|
return str(session_id)
|
|
metadata = msg.get("metadata", {})
|
|
if isinstance(metadata, dict) and metadata.get("session_id"):
|
|
return str(metadata["session_id"])
|
|
return "default"
|
|
|
|
async def _can_use_tool_for_permission(
|
|
self,
|
|
tool_name: str,
|
|
tool_input: ToolArgs,
|
|
perm_ctx: Any, # type: ignore[explicit-any] # ToolPermissionContext — avoid hard sdk import
|
|
) -> Any: # type: ignore[explicit-any] # PermissionResult
|
|
"""
|
|
Route a Claude SDK permission request through the Omnigent elicitation system.
|
|
|
|
Installed as ``options.can_use_tool`` when ``permission_mode`` is
|
|
not ``"bypassPermissions"`` and an elicitation handler has been wired
|
|
in by :class:`omnigent.runtime.harnesses._executor_adapter.ExecutorAdapter`.
|
|
Called by the SDK before each tool invocation.
|
|
|
|
:param tool_name: Name of the tool Claude wants to call,
|
|
e.g. ``"Bash"``.
|
|
:param tool_input: Arguments dict for the tool call,
|
|
e.g. ``{"command": "ls -la"}``.
|
|
:param perm_ctx: :class:`claude_agent_sdk.ToolPermissionContext`
|
|
carrying ``tool_use_id`` (the SDK-assigned id for this specific
|
|
invocation), ``agent_id`` (non-None inside sub-agents), and
|
|
``suggestions`` (permission hints from the CLI). Used here for
|
|
diagnostic logging so unexpected permission requests are traceable.
|
|
:returns: :class:`claude_agent_sdk.PermissionResultAllow` when the
|
|
user approves, or :class:`claude_agent_sdk.PermissionResultDeny`
|
|
with a ``message`` when they deny.
|
|
"""
|
|
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
|
|
tool_use_id: str | None = getattr(perm_ctx, "tool_use_id", None)
|
|
logger.debug(
|
|
"permission request: tool=%s tool_use_id=%s",
|
|
tool_name,
|
|
tool_use_id,
|
|
)
|
|
if self._elicitation_handler is None:
|
|
# No handler installed — grant by default. This branch is
|
|
# unreachable in normal operation (run_turn only sets
|
|
# can_use_tool when _elicitation_handler is not None), but
|
|
# is included for safety if the SDK ever calls the callback
|
|
# after the handler was cleared.
|
|
return PermissionResultAllow()
|
|
allowed = await self._elicitation_handler(tool_name, tool_input)
|
|
if allowed:
|
|
return PermissionResultAllow()
|
|
return PermissionResultDeny(message="Denied via Omnigent elicitation")
|
|
|
|
async def _evaluate_tool_call_policy(
|
|
self,
|
|
tool_name: str,
|
|
tool_input: ToolArgs,
|
|
) -> Any: # type: ignore[explicit-any] # PermissionResult | None
|
|
"""
|
|
Run a pre-execution TOOL_CALL policy evaluation for one tool call.
|
|
|
|
This is the policy half of the ``can_use_tool`` gate. It exists
|
|
so connector-native MCP tools — ones injected by the Claude Agent
|
|
SDK / claude.ai connector layer (e.g. ``mcp__github__*``,
|
|
``mcp__atlassian__*``) that are NOT part of the agent spec's
|
|
``mcp_servers`` and execute INSIDE the CLI subprocess — get
|
|
evaluated against Omnigent TOOL_CALL-phase policies BEFORE they
|
|
run. Without this gate those calls bypass policy entirely (the
|
|
executor only OBSERVES them in the message stream, which posts no
|
|
policy event).
|
|
|
|
Double-evaluation guard: Omnigent's OWN tools are exposed as the
|
|
single ``omnigent`` SDK MCP server (the model sees
|
|
``mcp__omnigent__*``). When the model calls one, the SDK wrapper
|
|
routes it back through Omnigent's dispatch bridge
|
|
(``_stable_tool_executor`` -> ``TurnContext.dispatch_tool`` ->
|
|
``action_required``), and the runner re-dispatches it via
|
|
``ProxyMcpManager``, which enforces TOOL_CALL + TOOL_RESULT
|
|
policies server-side before forwarding to ``/mcp/execute``
|
|
(see ``omnigent/runner/app.py`` "All tool calls go through AP:/mcp
|
|
... which enforces TOOL_CALL + TOOL_RESULT policies server-side").
|
|
Spec-declared MCP tools are surfaced through that same
|
|
``mcp__omnigent__*`` server, so they are covered there too.
|
|
Evaluating ``mcp__omnigent__*`` here as well would double-count
|
|
the same call (and could double-charge a cost-budget checkpoint),
|
|
so we SKIP that prefix and only gate the connector-native /
|
|
out-of-band tools the dispatch path never sees.
|
|
|
|
:param tool_name: Full SDK tool name, e.g.
|
|
``"mcp__github__issue_write"``.
|
|
:param tool_input: Arguments dict for the tool call.
|
|
:returns: :class:`claude_agent_sdk.PermissionResultDeny` when a
|
|
policy denies the call. ``POLICY_ACTION_ASK`` is normally
|
|
collapsed to a hard ALLOW/DENY by the server-side
|
|
``/policies/evaluate`` route. If a raw ASK reaches this callback
|
|
(for example from a read-only evaluation path), this hook runs the
|
|
existing Omnigent elicitation handler before returning
|
|
:class:`claude_agent_sdk.PermissionResultAllow` or DENY; without
|
|
a handler it fails closed. Returns ``None`` when the call should
|
|
be allowed to proceed (no policy evaluator wired, an
|
|
``mcp__omnigent__*`` tool already gated on the dispatch path, or
|
|
an ALLOW / no-match verdict). Returning ``None`` lets the caller
|
|
fall through to its remaining gate logic (elicitation) without
|
|
forcing an allow.
|
|
"""
|
|
_policy_eval = getattr(self, "_policy_evaluator", None)
|
|
if _policy_eval is None:
|
|
return None
|
|
# Omnigent's own tools are already TOOL_CALL-gated server-side via
|
|
# the dispatch bridge / ProxyMcpManager — don't evaluate them twice.
|
|
if tool_name.startswith("mcp__omnigent__"):
|
|
return None
|
|
_verdict = await _policy_eval(
|
|
"PHASE_TOOL_CALL",
|
|
{"name": tool_name, "arguments": tool_input},
|
|
)
|
|
_action = getattr(_verdict, "action", None)
|
|
if _action in ("POLICY_ACTION_ALLOW", "POLICY_ACTION_UNSPECIFIED"):
|
|
# ALLOW / no-match — fall through (caller decides whether to also
|
|
# run the human-consent elicitation gate).
|
|
return None
|
|
if _action == "POLICY_ACTION_ASK":
|
|
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
|
|
reason = _verdict.reason or "Approval required by Omnigent TOOL_CALL policy"
|
|
if self._elicitation_handler is None:
|
|
logger.warning(
|
|
"TOOL_CALL policy ASK had no elicitation handler; denying tool=%s reason=%s",
|
|
tool_name,
|
|
reason,
|
|
)
|
|
return PermissionResultDeny(message=reason)
|
|
logger.info("TOOL_CALL policy requested approval tool=%s reason=%s", tool_name, reason)
|
|
if await self._elicitation_handler(tool_name, tool_input):
|
|
return PermissionResultAllow()
|
|
return PermissionResultDeny(message=reason)
|
|
if _action == "POLICY_ACTION_DENY":
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
reason = _verdict.reason or "Denied by Omnigent TOOL_CALL policy"
|
|
logger.info("TOOL_CALL policy denied tool=%s reason=%s", tool_name, reason)
|
|
return PermissionResultDeny(message=reason)
|
|
from claude_agent_sdk import PermissionResultDeny
|
|
|
|
reason = f"Unexpected Omnigent TOOL_CALL policy verdict: {_action!r}"
|
|
logger.warning("TOOL_CALL policy failed closed tool=%s reason=%s", tool_name, reason)
|
|
return PermissionResultDeny(message=reason)
|
|
|
|
async def _can_use_tool_gate(
|
|
self,
|
|
tool_name: str,
|
|
tool_input: ToolArgs,
|
|
perm_ctx: Any, # type: ignore[explicit-any] # ToolPermissionContext
|
|
) -> Any: # type: ignore[explicit-any] # PermissionResult
|
|
"""
|
|
Unified ``options.can_use_tool`` callback for the claude-sdk path.
|
|
|
|
Composes two independent gates, in order:
|
|
|
|
1. **TOOL_CALL policy** (always, when a ``_policy_evaluator`` is
|
|
wired): a hard DENY short-circuits to
|
|
:class:`~claude_agent_sdk.PermissionResultDeny`. This runs in
|
|
EVERY permission mode — including ``bypassPermissions`` — so
|
|
connector-native MCP tools can't slip past policy. ALLOW /
|
|
no-match falls through with no human interaction, preserving
|
|
``bypassPermissions`` ergonomics for un-gated tools.
|
|
2. **Human-consent elicitation** (only when the permission mode is
|
|
NOT ``bypassPermissions`` and an elicitation handler is wired):
|
|
the pre-existing per-tool approval prompt. Under
|
|
``bypassPermissions`` this step is skipped entirely, so the
|
|
model still acts autonomously for anything policy allows.
|
|
|
|
:param tool_name: Full SDK tool name, e.g. ``"Bash"`` or
|
|
``"mcp__github__issue_write"``.
|
|
:param tool_input: Arguments dict for the tool call.
|
|
:param perm_ctx: :class:`claude_agent_sdk.ToolPermissionContext`.
|
|
:returns: A :class:`~claude_agent_sdk.PermissionResult`.
|
|
"""
|
|
from claude_agent_sdk import PermissionResultAllow
|
|
|
|
policy_result = await self._evaluate_tool_call_policy(tool_name, tool_input)
|
|
if policy_result is not None:
|
|
# Hard DENY from policy — block before execution.
|
|
return policy_result
|
|
# Policy allowed (or no policy gate). Under bypassPermissions we
|
|
# never prompt; otherwise defer to the elicitation gate.
|
|
if self._permission_mode == "bypassPermissions" or self._elicitation_handler is None:
|
|
return PermissionResultAllow()
|
|
return await self._can_use_tool_for_permission(tool_name, tool_input, perm_ctx)
|
|
|
|
async def run_turn(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
config: ExecutorConfig | None = None,
|
|
) -> AsyncIterator[ExecutorEvent]:
|
|
"""Run one turn via the Claude Agent SDK.
|
|
|
|
The SDK receives the latest user message as a prompt and runs its full
|
|
agent loop (which may include multiple internal tool calls). We
|
|
observe the message stream and yield ExecutorEvents for the Session
|
|
to record in History.
|
|
"""
|
|
sdk = cast(_ClaudeSDK, _ensure_sdk())
|
|
from claude_agent_sdk.types import StreamEvent as _StreamEvent
|
|
|
|
cfg = config or ExecutorConfig()
|
|
|
|
session_key = self._session_key(messages)
|
|
crashed_reason = self._crashed_sessions.get(session_key)
|
|
if crashed_reason is not None:
|
|
yield ExecutorError(
|
|
message=(
|
|
"Claude SDK session crashed and cannot continue in this Session. "
|
|
f"Start a new Session. Cause: {crashed_reason}"
|
|
)
|
|
)
|
|
return
|
|
prompt = self._build_prompt(
|
|
messages,
|
|
resume_session=session_key in self._clients,
|
|
)
|
|
if not prompt:
|
|
# Resumed sessions can have nothing new to say; signal turn
|
|
# completion with no assistant text instead of an empty string.
|
|
yield TurnComplete(response=None)
|
|
return
|
|
|
|
# Build MCP tools from Omnigent tool schemas
|
|
mcp_tools = _build_mcp_tools(tools, self._tool_executor) if tools else []
|
|
|
|
# Create MCP server config for Omnigent tools. The SDK's
|
|
# ``McpServerConfig`` union is opaque to us — we pass through
|
|
# whatever ``create_sdk_mcp_server`` returns.
|
|
mcp_servers: dict[str, Any] = {} # type: ignore[explicit-any]
|
|
if mcp_tools:
|
|
mcp_servers["omnigent"] = sdk.create_sdk_mcp_server(
|
|
name="omnigent",
|
|
version="1.0.0",
|
|
tools=mcp_tools,
|
|
)
|
|
system_prompt = _augment_system_prompt_for_omnigent_mcp_tools(
|
|
system_prompt,
|
|
tools,
|
|
)
|
|
|
|
# Build allowed_tools list. OS-environment operations route
|
|
# through Omnigent ``sys_os_*`` MCP tools rather than the
|
|
# SDK's native Bash/Read/Edit/Write — MCP tools flow through
|
|
# the scaffold's ``dispatch_tool`` path, giving the runner
|
|
# visibility, timeouts, and error recovery.
|
|
#
|
|
# In ``auto`` and ``bypassPermissions`` modes, pre-approve all
|
|
# MCP tools so the agent can act autonomously without a per-call
|
|
# human-consent gate. ``auto`` still runs background safety
|
|
# checks; ``bypassPermissions`` skips all gates entirely.
|
|
# In any other mode (``default``, ``acceptEdits``, etc.), leave
|
|
# ``allowed_tools`` empty so every tool call goes through the
|
|
# SDK's ``can_use_tool`` callback — which routes to the AP
|
|
# elicitation system when an elicitation handler is wired in.
|
|
# When ``allowed_tools`` is empty the SDK omits ``--allowedTools``
|
|
# entirely, letting Claude's normal permission flow apply.
|
|
allowed_tools: list[str] = []
|
|
if self._permission_mode in ("auto", "bypassPermissions"):
|
|
# Allow all Omnigent MCP tools (no per-call human gate needed)
|
|
for schema in tools:
|
|
raw_tname = schema.get("name")
|
|
# Claude SDK's ``allowed_tools`` requires concrete strings;
|
|
# Omnigent tool schemas always carry a name (see
|
|
# ``Tool.tool_schema``), but defend against malformed specs
|
|
# by skipping unnamed entries rather than producing a
|
|
# bogus ``mcp__omnigent__`` allow-entry.
|
|
if not isinstance(raw_tname, str) or not raw_tname:
|
|
continue
|
|
allowed_tools.append(f"mcp__omnigent__{raw_tname}")
|
|
|
|
# cfg.model > spec model > Databricks default (only on the
|
|
# Databricks-profile gateway path) > None (lets the SDK pick its own
|
|
# default). The neutral generic-provider gateway path never falls back
|
|
# to a ``databricks-*`` model: the Omnigent producer always resolves a
|
|
# concrete model (spec > provider default > catalog default) before
|
|
# spawning, so no ``databricks-*`` default is injected there.
|
|
model = cfg.model or self._model_override
|
|
if model is None and self._gateway_uses_databricks_profile:
|
|
model = _DATABRICKS_CLAUDE_DEFAULT_MODEL
|
|
|
|
# Build env: Databricks gateway settings derived from profile-backed
|
|
# creds. CLAUDECODE removal happens around the subprocess spawn in
|
|
# ``_get_or_create_client`` via ``_unset_env_var`` — setting it to
|
|
# ``""`` here would still leave an empty key in the child env.
|
|
env = dict(self._extra_env)
|
|
api_key_helper = env.pop(_CLAUDE_API_KEY_HELPER_ENV_KEY, None)
|
|
settings_payload = (
|
|
json.dumps({"apiKeyHelper": api_key_helper}, separators=(",", ":"))
|
|
if api_key_helper
|
|
else None
|
|
)
|
|
|
|
# Capture stderr from the CLI subprocess for diagnostics
|
|
stderr_lines: list[str] = []
|
|
|
|
def _on_stderr(line: str) -> None:
|
|
stderr_lines.append(line)
|
|
logger.debug("Claude CLI stderr: %s", line)
|
|
|
|
# Build options.
|
|
#
|
|
# ``skills="all"`` makes the Claude Agent SDK auto-configure
|
|
# the ``Skill`` tool in ``allowed_tools`` and default
|
|
# ``setting_sources`` to ``["user", "project"]`` so the CLI
|
|
# discovers user-installed skills under ``~/.claude/skills/``
|
|
# and project-local skills under ``<cwd>/.claude/skills/``.
|
|
# See ``claude_agent_sdk._internal.transport.subprocess_cli.
|
|
# _apply_skills_defaults`` for the auto-derivation.
|
|
#
|
|
# ``tools`` is the model's BASE tool set; ``allowed_tools``
|
|
# is just a permission filter on it. ``skills="all"`` only
|
|
# adds ``Skill`` to ``allowed_tools`` — to actually expose
|
|
# the tool to the model we have to put it in ``tools`` too.
|
|
# Without this, the SDK passes ``--tools ""`` to the CLI
|
|
# which ZEROS the base set, and the agent never sees a
|
|
# ``Skill`` tool even with ``--allowedTools=Skill`` set
|
|
# (the live regression that makes the agent answer "I
|
|
# don't have a Skill tool exposed in this session").
|
|
#
|
|
# ``--bare`` (formerly in ``extra_args``) is intentionally
|
|
# NOT passed: bare mode skips CLAUDE.md auto-discovery,
|
|
# plugin sync, and auto-memory — exactly the host config
|
|
# users expect to leak through to a ``claude-sdk`` harness
|
|
# they explicitly opted into. ``no-session-persistence``
|
|
# stays because omnigent owns conversation persistence
|
|
# via its own conversation store.
|
|
# OS-environment tools are provided via Omnigent ``sys_os_*``
|
|
# MCP tools (declared via ``os_env`` in the spec), not the
|
|
# SDK's native Bash/Read/Edit/Write. Only the Skill tool
|
|
# needs to be in the SDK's base set.
|
|
base_tools: list[str] = ["Skill"]
|
|
# Translate the spec's host-skill filter into the SDK
|
|
# options. Falls back to ``"all"`` semantics when the
|
|
# field is malformed (the parser already validates, so
|
|
# this is belt-and-suspenders).
|
|
resolved = _resolve_skills_option(self._skills_filter) or _ResolvedSkills(
|
|
skills="all", setting_sources=None
|
|
)
|
|
# Bundle skills are exposed via the SDK's plugin mechanism.
|
|
# The bundle's ``<bundle>/skills/<name>/SKILL.md`` files are
|
|
# discovered as plugin skills (no ``.claude/`` prefix needed
|
|
# under the plugin convention — see plugin discovery test in
|
|
# tests/inner/test_claude_sdk_executor.py). The plugin's
|
|
# ``name`` (and thus the skill-listing prefix) comes from
|
|
# the manifest written at construction time.
|
|
bundle_plugins: list[Any] = [] # type: ignore[explicit-any] # SdkPluginConfig is a TypedDict — typed Any here to keep the import lazy
|
|
if self._bundle_dir is not None:
|
|
bundle_plugins.append({"type": "local", "path": str(self._bundle_dir)})
|
|
options_kwargs: dict[str, Any] = { # type: ignore[explicit-any] # ClaudeAgentOptions accepts mixed-typed kwargs (str / list / dict / callable / etc.)
|
|
"tools": base_tools,
|
|
"system_prompt": system_prompt or None,
|
|
"mcp_servers": mcp_servers if mcp_servers else {},
|
|
"allowed_tools": allowed_tools,
|
|
"permission_mode": self._permission_mode,
|
|
"max_turns": cfg.extra.get("max_turns"),
|
|
"env": env,
|
|
"settings": settings_payload,
|
|
"stderr": _on_stderr,
|
|
"include_partial_messages": True,
|
|
"include_hook_events": True,
|
|
"skills": resolved.skills,
|
|
"plugins": bundle_plugins,
|
|
"extra_args": {"no-session-persistence": None},
|
|
"max_buffer_size": 10 * 1024 * 1024,
|
|
}
|
|
# Only forward ``setting_sources`` when explicitly set.
|
|
# ``None`` lets the SDK apply its default
|
|
# (``["user", "project"]`` when ``skills`` is non-None).
|
|
# An empty list — produced by ``"none"`` — is forwarded
|
|
# verbatim so the SDK doesn't auto-default it back to
|
|
# ``["user", "project"]``, which would re-load host
|
|
# skills into the model's system prompt despite
|
|
# ``skills=[]`` (the live regression that prompted this
|
|
# branch).
|
|
if resolved.setting_sources is not None:
|
|
options_kwargs["setting_sources"] = resolved.setting_sources
|
|
try:
|
|
reasoning_effort = validate_effort(
|
|
cfg.extra.get("reasoning_effort"), "Claude Agent SDK", CLAUDE_EFFORTS
|
|
)
|
|
except ValueError as exc:
|
|
yield ExecutorError(message=str(exc), retryable=False)
|
|
return
|
|
if reasoning_effort is not None:
|
|
options_kwargs["effort"] = reasoning_effort
|
|
# Databricks opus/fable endpoints reject thinking.type="enabled"
|
|
# (HTTP 400); they require "adaptive" instead (those tiers are
|
|
# adaptive-only). Sonnet is fine, so scope to those tiers only.
|
|
# This is Databricks-specific: it is gated on the ``databricks-
|
|
# claude-opus-`` / ``databricks-claude-fable-`` model prefixes, so
|
|
# it never fires for a generic gateway model.
|
|
if (
|
|
self._gateway
|
|
and isinstance(model, str)
|
|
and model.startswith(DATABRICKS_CLAUDE_ADAPTIVE_THINKING_PREFIXES)
|
|
and "thinking" not in options_kwargs
|
|
):
|
|
# display="summarized" so Opus 4.7+ / Fable streams thinking text
|
|
# (their default is "omitted").
|
|
options_kwargs["thinking"] = {"type": "adaptive", "display": "summarized"}
|
|
options = sdk.ClaudeAgentOptions(**options_kwargs)
|
|
if self._cli_path:
|
|
options.cli_path = self._cli_path
|
|
if model:
|
|
options.model = model
|
|
if self._cwd:
|
|
options.cwd = self._cwd
|
|
# Install the unified can_use_tool gate. It runs the TOOL_CALL
|
|
# policy evaluation in EVERY permission mode — including
|
|
# ``bypassPermissions`` — so connector-native MCP tools
|
|
# (``mcp__github__*``, ``mcp__atlassian__*``) that execute inside
|
|
# the CLI subprocess are evaluated against Omnigent TOOL_CALL
|
|
# policy before they run, instead of bypassing policy entirely.
|
|
#
|
|
# The gate is no-friction when nothing matches: a policy ALLOW /
|
|
# no-match returns allow with no human prompt, so
|
|
# ``bypassPermissions`` ergonomics are preserved for un-gated
|
|
# tools. The human-consent elicitation half of the gate still only
|
|
# fires for non-bypass modes (see ``_can_use_tool_gate``).
|
|
#
|
|
# Install whenever EITHER a policy evaluator OR an elicitation
|
|
# handler is wired. Previously this only installed for non-bypass
|
|
# modes, which is why default ``claude-sdk`` sessions (which
|
|
# default to ``bypassPermissions``) had no per-tool TOOL_CALL gate.
|
|
if (
|
|
getattr(self, "_policy_evaluator", None) is not None
|
|
or self._elicitation_handler is not None
|
|
):
|
|
options.can_use_tool = self._can_use_tool_gate
|
|
|
|
# Log the full configuration for debugging
|
|
logger.info(
|
|
"ClaudeSDKExecutor: model=%s, gateway=%s, base_url=%s, tools=%d, thinking=%r",
|
|
model or "(default)",
|
|
self._gateway,
|
|
env.get("ANTHROPIC_BASE_URL", "(not set)"),
|
|
len(tools),
|
|
options_kwargs.get("thinking"),
|
|
)
|
|
|
|
# Run the query, streaming events as they arrive.
|
|
# The SDK manages its own tool-call loop internally (via MCP).
|
|
# We observe the message stream and yield ExecutorEvents so the
|
|
# CLI can render text chunks and tool-call progress in real time.
|
|
response_text = ""
|
|
turn_usage: dict[str, Any] | None = None # type: ignore[explicit-any]
|
|
# The concrete model the SDK reports on its assistant messages, e.g.
|
|
# ``"claude-opus-4-8"``. Captured from the stream because the resolved
|
|
# config ``model`` is ``None`` when the spec pins none and the gateway
|
|
# picks a default internally — this is then forwarded in ``turn_usage``
|
|
# so the server can price the turn. ``None`` until an assistant message
|
|
# carrying a model arrives.
|
|
observed_model: str | None = None
|
|
system_diagnostics: list[str] = []
|
|
terminal_error: str | None = None
|
|
compaction_occurred: bool = False
|
|
claude_session_id: str | None = None
|
|
|
|
# Track in-flight tool calls so we can emit ToolCallComplete
|
|
# with the tool name and duration when results arrive.
|
|
pending_tools: dict[str, tuple[str, float]] = {} # id → (name, start_mono)
|
|
|
|
# Track whether we've received any StreamEvent messages.
|
|
# When True, we skip text/tool events from AssistantMessage to
|
|
# avoid double-emitting (the SDK sends both StreamEvents AND
|
|
# the complete AssistantMessage for the same content).
|
|
got_stream_events = False
|
|
|
|
# Per-API-call prompt usage from the most recent ``message_start``
|
|
# stream event. A single user turn can drive MANY internal API
|
|
# calls (the SDK's tool loop), and ``ResultMessage.usage`` reports
|
|
# the CUMULATIVE total across all of them — so summing its cache
|
|
# buckets over-counts context, because each iteration re-sends the
|
|
# whole (growing) conversation and those prompt tokens get tallied
|
|
# once per call. For context-window fill we want only the LAST
|
|
# call's prompt size: that single prompt already contains the full
|
|
# conversation that carries into the next turn. We capture each
|
|
# ``message_start``'s ``message.usage`` here and keep the latest,
|
|
# mirroring how the openai-agents executor uses ``raw_responses[-1]``
|
|
# for ``context_tokens``. ``None`` until the first call starts.
|
|
last_call_usage: dict[str, Any] | None = None # type: ignore[explicit-any]
|
|
|
|
client = await self._get_or_create_client(
|
|
sdk,
|
|
session_key=session_key,
|
|
options=options,
|
|
model=model,
|
|
)
|
|
|
|
# ── LLM_REQUEST policy evaluation ────────────────────────
|
|
# If the executor adapter installed a ``_policy_evaluator``
|
|
# callback, call it with the request data so the Omnigent server
|
|
# can evaluate LLM_REQUEST policies before the LLM call.
|
|
_policy_eval = getattr(self, "_policy_evaluator", None)
|
|
if _policy_eval is not None:
|
|
# Extract the user prompt text for PII scanning.
|
|
_last_user_msg = ""
|
|
if isinstance(prompt, str):
|
|
_last_user_msg = prompt[:500]
|
|
elif isinstance(prompt, list):
|
|
_parts = [
|
|
b.get("text", "")
|
|
for b in prompt
|
|
if isinstance(b, dict) and b.get("type") in ("text", "input_text")
|
|
]
|
|
_last_user_msg = " ".join(_parts)[:500]
|
|
_req_data: dict[str, Any] = {
|
|
"model": model,
|
|
"messages_count": len(prompt) if isinstance(prompt, list) else 1,
|
|
"tools_count": len(tools),
|
|
"system_prompt_preview": (system_prompt[:200] if system_prompt else ""),
|
|
"last_user_message": _last_user_msg,
|
|
}
|
|
_req_verdict = await _policy_eval("PHASE_LLM_REQUEST", _req_data)
|
|
if _req_verdict.action == "POLICY_ACTION_DENY":
|
|
_deny_reason = _req_verdict.reason or "no reason given"
|
|
yield ExecutorError(message=f"LLM call denied by policy: {_deny_reason}")
|
|
return
|
|
|
|
try:
|
|
try:
|
|
sdk_prompt: str | AsyncIterator[dict[str, Any]]
|
|
if isinstance(prompt, list):
|
|
# Multimodal content blocks — send as a
|
|
# structured message via the SDK's dict path.
|
|
sdk_prompt = _multimodal_message_iter(prompt, session_id=session_key)
|
|
else:
|
|
sdk_prompt = prompt
|
|
await asyncio.wait_for(
|
|
client.query(sdk_prompt, session_id=session_key),
|
|
timeout=_QUERY_START_TIMEOUT_SECONDS,
|
|
)
|
|
except asyncio.TimeoutError as exc:
|
|
raise TimeoutError(
|
|
f"Claude SDK query start timed out after {int(_QUERY_START_TIMEOUT_SECONDS)}s"
|
|
) from exc
|
|
message_stream = client.receive_response()
|
|
try:
|
|
while True:
|
|
next_task = asyncio.ensure_future(anext(message_stream))
|
|
idle_seconds = 0.0
|
|
try:
|
|
while True:
|
|
done, _ = await asyncio.wait(
|
|
{next_task}, timeout=_STREAM_IDLE_WARN_SECONDS
|
|
)
|
|
if next_task in done:
|
|
break
|
|
idle_seconds += _STREAM_IDLE_WARN_SECONDS
|
|
logger.warning(
|
|
"Claude SDK response stream has been idle for "
|
|
"%ds (session %s); still waiting.",
|
|
int(idle_seconds),
|
|
session_key,
|
|
)
|
|
except BaseException:
|
|
next_task.cancel()
|
|
with suppress(BaseException):
|
|
await next_task
|
|
raise
|
|
try:
|
|
message = next_task.result()
|
|
except StopAsyncIteration:
|
|
break
|
|
if isinstance(message, _StreamEvent):
|
|
got_stream_events = True
|
|
stream_evt = cast(_StreamEventObj, message)
|
|
evt = stream_evt.event
|
|
evt_type = evt.get("type")
|
|
|
|
if evt_type == "message_start":
|
|
# Each ``message_start`` opens one API call and
|
|
# carries that call's prompt-side usage
|
|
# (input + cache buckets). Keep the latest so
|
|
# ``context_tokens`` reflects the final call's
|
|
# prompt — the true context size — instead of
|
|
# ``ResultMessage.usage``'s cumulative sum across
|
|
# every tool-loop iteration.
|
|
msg_obj = evt.get("message")
|
|
if isinstance(msg_obj, dict):
|
|
call_usage = msg_obj.get("usage")
|
|
if isinstance(call_usage, dict) and call_usage:
|
|
last_call_usage = call_usage
|
|
continue
|
|
|
|
if evt_type == "content_block_start":
|
|
block_evt = evt.get("content_block", {})
|
|
block_type = block_evt.get("type")
|
|
if block_type == "thinking":
|
|
# Anchor the ``thinking…`` indicator at the
|
|
# start of an extended-thinking block.
|
|
yield ReasoningChunk(
|
|
delta="",
|
|
event_type="reasoning_started",
|
|
)
|
|
continue
|
|
if block_type == "tool_use":
|
|
raw_tool_id = block_evt.get("id")
|
|
# SSE stream events from the SDK should
|
|
# always carry an ``id`` on tool_use
|
|
# blocks; skip malformed events rather
|
|
# than bucketing them under ``""``.
|
|
if not isinstance(raw_tool_id, str):
|
|
continue
|
|
tool_name = block_evt.get("name", "unknown")
|
|
# Track the tool for duration / pairing.
|
|
# The ToolCallRequest itself is emitted
|
|
# later, when the ``AssistantMessage``
|
|
# arrives with ``tool_block.input``
|
|
# populated — at ``content_block_start``
|
|
# the args have not yet been streamed via
|
|
# ``input_json_delta`` and the request
|
|
# would carry ``args={}``, rendering
|
|
# downstream as ``⏵ Bash()`` with no
|
|
# visible command. Waiting one event
|
|
# loses a few ms of "tool call started"
|
|
# feedback in exchange for correct args.
|
|
pending_tools[raw_tool_id] = (tool_name, time.monotonic())
|
|
|
|
elif evt_type == "content_block_delta":
|
|
delta = evt.get("delta", {})
|
|
delta_type = delta.get("type")
|
|
if delta_type == "text_delta":
|
|
text = delta.get("text")
|
|
if isinstance(text, str) and text:
|
|
response_text += text
|
|
yield TextChunk(text=text)
|
|
elif delta_type == "thinking_delta":
|
|
# Mirror the OpenAI reasoning path so the
|
|
# ``thinking…`` panel populates live.
|
|
thinking_text = delta.get("thinking")
|
|
if isinstance(thinking_text, str) and thinking_text:
|
|
yield ReasoningChunk(
|
|
delta=thinking_text,
|
|
event_type="reasoning_text",
|
|
)
|
|
|
|
elif isinstance(message, sdk.AssistantMessage):
|
|
assistant_msg = cast(_AssistantMessageObj, message)
|
|
# Capture the concrete model the SDK used (the resolved
|
|
# config ``model`` is None when the spec pins none).
|
|
_am_model = getattr(assistant_msg, "model", None)
|
|
if isinstance(_am_model, str) and _am_model:
|
|
observed_model = _am_model
|
|
if got_stream_events:
|
|
# StreamEvents already emitted text. Emit the
|
|
# ToolCallRequest here, once the full
|
|
# ``tool_block.input`` has assembled — see the
|
|
# note in the ``content_block_start`` branch
|
|
# above for why this is deferred.
|
|
for block in assistant_msg.content:
|
|
if isinstance(block, sdk.ToolUseBlock):
|
|
tool_block = cast(_ToolUseBlockObj, block)
|
|
if tool_block.id not in pending_tools:
|
|
pending_tools[tool_block.id] = (
|
|
tool_block.name,
|
|
time.monotonic(),
|
|
)
|
|
yield ToolCallRequest(
|
|
name=tool_block.name,
|
|
args=tool_block.input,
|
|
# Thread the SDK's
|
|
# ``tool_use_id`` through
|
|
# so downstream consumers
|
|
# (notably the new harness
|
|
# contract's
|
|
# :class:`ExecutorAdapter`)
|
|
# can pair this request
|
|
# with the matching
|
|
# :class:`ToolCallComplete`
|
|
# by call_id.
|
|
metadata={"call_id": tool_block.id},
|
|
)
|
|
else:
|
|
# No streaming — emit events from the full message.
|
|
for block in assistant_msg.content:
|
|
if isinstance(block, sdk.TextBlock):
|
|
text_block = cast(_TextBlockObj, block)
|
|
response_text += text_block.text
|
|
yield TextChunk(text=text_block.text)
|
|
elif isinstance(block, sdk.ThinkingBlock):
|
|
# Non-streaming counterpart of the
|
|
# ``thinking_delta`` path above.
|
|
if block.thinking:
|
|
yield ReasoningChunk(
|
|
delta="",
|
|
event_type="reasoning_started",
|
|
)
|
|
yield ReasoningChunk(
|
|
delta=block.thinking,
|
|
event_type="reasoning_text",
|
|
)
|
|
elif isinstance(block, sdk.ToolUseBlock):
|
|
tool_block = cast(_ToolUseBlockObj, block)
|
|
pending_tools[tool_block.id] = (
|
|
tool_block.name,
|
|
time.monotonic(),
|
|
)
|
|
yield ToolCallRequest(
|
|
name=tool_block.name,
|
|
args=tool_block.input,
|
|
metadata={"call_id": tool_block.id},
|
|
)
|
|
|
|
elif isinstance(message, sdk.UserMessage):
|
|
# Tool results come back as UserMessages containing
|
|
# ToolResultBlocks. Match each back to its request.
|
|
user_msg = cast(_UserMessageObj, message)
|
|
content = user_msg.content
|
|
if isinstance(content, list):
|
|
for block in content:
|
|
if isinstance(block, sdk.ToolResultBlock):
|
|
result_block = cast(_ToolResultBlockObj, block)
|
|
tool_name, start = pending_tools.pop(
|
|
result_block.tool_use_id,
|
|
("unknown", time.monotonic()),
|
|
)
|
|
duration_ms = (time.monotonic() - start) * 1000
|
|
classification = classify_tool_result(
|
|
result_block.content,
|
|
fallback_to_string=bool(result_block.is_error),
|
|
)
|
|
status = classification.status
|
|
error = classification.error
|
|
if result_block.is_error and not error:
|
|
status = ToolCallStatus.ERROR
|
|
error = "tool error"
|
|
yield ToolCallComplete(
|
|
name=tool_name,
|
|
status=status,
|
|
result=result_block.content,
|
|
error=error,
|
|
duration_ms=duration_ms,
|
|
# Same rationale as the
|
|
# ToolCallRequest above —
|
|
# the call_id is the only
|
|
# thing the adapter can
|
|
# use to pair this output
|
|
# back to its function_call.
|
|
metadata={"call_id": result_block.tool_use_id},
|
|
)
|
|
|
|
elif isinstance(message, sdk.ResultMessage):
|
|
result_msg = cast(_ResultMessageObj, message)
|
|
claude_session_id = getattr(result_msg, "session_id", None)
|
|
if not response_text and result_msg.result:
|
|
response_text = result_msg.result
|
|
raw_usage = getattr(result_msg, "usage", None)
|
|
if isinstance(raw_usage, dict) and raw_usage:
|
|
# ``ResultMessage.usage`` is CUMULATIVE across every
|
|
# API call in the turn. Keep it for billing
|
|
# (``input``/``output``/``total`` and the cache
|
|
# buckets) — that's the correct sum to price.
|
|
in_tok = raw_usage.get("input_tokens") or 0
|
|
out_tok = raw_usage.get("output_tokens") or 0
|
|
# ``context_tokens`` is window FILL, not a billing
|
|
# sum: it must be a single prompt's size, not the
|
|
# cumulative input across iterations (which re-sends
|
|
# the conversation each tool-loop step and would
|
|
# over-count K-fold on a K-call turn). Use the LAST
|
|
# ``message_start`` call's prompt — input +
|
|
# cache_creation + cache_read — which already holds
|
|
# the full conversation carried into the next turn.
|
|
# Mirrors openai-agents' ``raw_responses[-1]`` choice.
|
|
# Fall back to the cumulative sum only when no
|
|
# ``message_start`` was observed (e.g. non-streaming
|
|
# paths), preserving a non-null value over None.
|
|
ctx_src = last_call_usage if last_call_usage is not None else raw_usage
|
|
ctx_in = ctx_src.get("input_tokens") or 0
|
|
ctx_cc = ctx_src.get("cache_creation_input_tokens") or 0
|
|
ctx_cr = ctx_src.get("cache_read_input_tokens") or 0
|
|
turn_usage = {
|
|
"input_tokens": in_tok,
|
|
"output_tokens": out_tok,
|
|
"total_tokens": in_tok + out_tok,
|
|
"context_tokens": ctx_in + ctx_cc + ctx_cr,
|
|
**{
|
|
k: v
|
|
for k, v in raw_usage.items()
|
|
if k
|
|
not in (
|
|
"input_tokens",
|
|
"output_tokens",
|
|
"context_tokens",
|
|
)
|
|
},
|
|
# Harness-reported model for cost pricing: prefer the SDK's
|
|
# observed_model (config model is None when no model is pinned).
|
|
"model": observed_model or model,
|
|
}
|
|
|
|
elif isinstance(message, sdk.SystemMessage):
|
|
system_msg = cast(_SystemMessageObj, message)
|
|
subtype = system_msg.subtype
|
|
data = system_msg.data
|
|
|
|
if subtype == "api_retry":
|
|
error_status = data.get("error_status")
|
|
retry_error = data.get("error", "unknown_error")
|
|
attempt = data.get("attempt")
|
|
max_retries = data.get("max_retries")
|
|
retry_delay_ms = data.get("retry_delay_ms")
|
|
diagnostic = (
|
|
"Claude CLI API retry"
|
|
f" {attempt}/{max_retries}: {retry_error}"
|
|
f" (status={error_status}, retry_delay_ms={retry_delay_ms})"
|
|
)
|
|
system_diagnostics.append(diagnostic)
|
|
logger.warning(diagnostic)
|
|
|
|
if (
|
|
error_status in {401, 403}
|
|
or retry_error == "authentication_failed"
|
|
):
|
|
if self._gateway_uses_databricks_profile:
|
|
auth_hint = "Check your selected ~/.databrickscfg profile."
|
|
elif self._gateway:
|
|
auth_hint = (
|
|
"Check your provider's base URL and auth command "
|
|
"(ANTHROPIC_BASE_URL / gateway auth)."
|
|
)
|
|
else:
|
|
auth_hint = (
|
|
"Check your Claude CLI login status "
|
|
"(`claude /status`) or API key configuration."
|
|
)
|
|
terminal_error = (
|
|
"Claude SDK provider authentication failed"
|
|
f" ({retry_error}, status={error_status}). "
|
|
f"{auth_hint}"
|
|
)
|
|
break
|
|
|
|
if error_status == 404:
|
|
if self._gateway:
|
|
endpoint_hint = (
|
|
"Check ANTHROPIC_BASE_URL / gateway endpoint "
|
|
"configuration."
|
|
)
|
|
else:
|
|
endpoint_hint = "Check ANTHROPIC_BASE_URL configuration."
|
|
terminal_error = (
|
|
"Claude SDK provider endpoint was not found "
|
|
f"({retry_error}, status={error_status}). "
|
|
f"{endpoint_hint}"
|
|
)
|
|
break
|
|
elif getattr(system_msg, "hook_event_name", None) == "PreCompact":
|
|
compaction_occurred = True
|
|
logger.info("Claude SDK compaction detected (PreCompact hook)")
|
|
else:
|
|
logger.info("Claude CLI system message: %s", data)
|
|
finally:
|
|
# ``receive_response`` returns an async generator, which
|
|
# always has ``aclose``; guard anyway for duck-typed test
|
|
# doubles that implement the iterator protocol without it.
|
|
aclose = getattr(message_stream, _ACLOSE_ATTR, None)
|
|
if aclose is not None:
|
|
await aclose()
|
|
|
|
except Exception as exc: # noqa: BLE001 — top-level executor error boundary; records crash and surfaces to caller
|
|
self._crashed_sessions[session_key] = str(exc)
|
|
await self._close_live_client(session_key)
|
|
stderr_text = "\n".join(stderr_lines) if stderr_lines else "(no stderr captured)"
|
|
diagnostics_text = (
|
|
"\n".join(system_diagnostics)
|
|
if system_diagnostics
|
|
else "(no system diagnostics captured)"
|
|
)
|
|
logger.error(
|
|
"ClaudeSDKExecutor error: %s\nCLI stderr:\n%s\nCLI system diagnostics:\n%s",
|
|
exc,
|
|
stderr_text,
|
|
diagnostics_text,
|
|
)
|
|
yield ExecutorError(
|
|
message=(
|
|
f"Claude SDK error: {exc}\n"
|
|
f"CLI stderr:\n{stderr_text}\n"
|
|
f"CLI system diagnostics:\n{diagnostics_text}"
|
|
)
|
|
)
|
|
return
|
|
if terminal_error:
|
|
yield ExecutorError(message=terminal_error)
|
|
return
|
|
|
|
# A turn can finish the stream without ever yielding a
|
|
# ``ResultMessage`` — the CLI can close the stream early, or the
|
|
# turn can be cut short before its final usage is reported. In
|
|
# that case ``turn_usage`` is None and the context-occupancy
|
|
# meter freezes at the previous successful turn's value, hiding
|
|
# real window fill exactly when a session is in trouble (#1533).
|
|
# We already observed the latest prompt size from ``message_start``
|
|
# (``last_call_usage``), so synthesize a usage dict from it and let
|
|
# ``TurnComplete`` carry it. ``context_tokens`` (window fill) is the
|
|
# meaningful field here; ``output_tokens`` is unknown on an
|
|
# incomplete turn, so report 0 rather than guess. The full
|
|
# ``ResultMessage`` path above still wins whenever it runs.
|
|
if turn_usage is None and last_call_usage is not None:
|
|
ctx_in = last_call_usage.get("input_tokens") or 0
|
|
ctx_cc = last_call_usage.get("cache_creation_input_tokens") or 0
|
|
ctx_cr = last_call_usage.get("cache_read_input_tokens") or 0
|
|
turn_usage = {
|
|
"input_tokens": ctx_in,
|
|
"output_tokens": 0,
|
|
"total_tokens": ctx_in,
|
|
"context_tokens": ctx_in + ctx_cc + ctx_cr,
|
|
"model": observed_model or model,
|
|
}
|
|
|
|
# ── LLM_RESPONSE policy evaluation ───────────────────────
|
|
# Evaluate after the stream completes but before TurnComplete
|
|
# so a DENY prevents the response from being persisted.
|
|
if _policy_eval is not None:
|
|
_resp_data: dict[str, Any] = {
|
|
"model": model,
|
|
"text_preview": (response_text[:500] if response_text else ""),
|
|
"tool_calls_count": len(pending_tools),
|
|
}
|
|
if turn_usage is not None:
|
|
_resp_data["usage"] = turn_usage
|
|
_resp_verdict = await _policy_eval("PHASE_LLM_RESPONSE", _resp_data)
|
|
if _resp_verdict.action == "POLICY_ACTION_DENY":
|
|
_deny_reason = _resp_verdict.reason or "no reason given"
|
|
yield ExecutorError(message=(f"LLM response denied by policy: {_deny_reason}"))
|
|
return
|
|
|
|
_notify_usage_from_dict(model=model, usage=turn_usage)
|
|
|
|
if compaction_occurred and claude_session_id:
|
|
from omnigent.inner.executor import CompactionComplete
|
|
|
|
_compaction_tokens = 0
|
|
if turn_usage is not None:
|
|
_compaction_tokens = turn_usage.get("context_tokens", 0) or 0
|
|
# Read the post-compaction session messages so the runner
|
|
# can persist them for session resume in ephemeral
|
|
# environments where the CLI's own transcript is lost.
|
|
_compacted: list[dict[str, Any]] | None = None
|
|
try:
|
|
from claude_agent_sdk import get_session_messages
|
|
|
|
_msgs = get_session_messages(claude_session_id, directory=self._cwd)
|
|
_compacted = [
|
|
{"type": "message", "role": m.type, "content": m.message.get("content", [])}
|
|
for m in _msgs
|
|
if isinstance(m.message, dict)
|
|
]
|
|
if not _compacted:
|
|
logger.warning(
|
|
"Claude post-compaction read returned no messages "
|
|
"(session=%s); resume will fall back to the synthetic "
|
|
"summary instead of the harness's real compacted state.",
|
|
claude_session_id,
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
# WARNING, not DEBUG: a swallowed read here silently degrades
|
|
# EVERY later resume of this conversation. The runner persists a
|
|
# compaction item with no ``compacted_messages``, so resume
|
|
# replays the lossy synthetic-summary pair instead of the
|
|
# harness's real post-compaction context. Surface it.
|
|
logger.warning(
|
|
"Failed to read Claude post-compaction session messages "
|
|
"(session=%s); resume fidelity for this conversation will "
|
|
"degrade to the synthetic summary.",
|
|
claude_session_id,
|
|
exc_info=True,
|
|
)
|
|
yield CompactionComplete(
|
|
summary="[Claude Code compaction — context was automatically compacted]",
|
|
token_count=_compaction_tokens,
|
|
model=observed_model or model,
|
|
compacted_messages=_compacted,
|
|
)
|
|
|
|
yield TurnComplete(response=response_text, usage=turn_usage)
|
|
|
|
@staticmethod
|
|
def _build_prompt(
|
|
messages: list[Message],
|
|
*,
|
|
resume_session: bool,
|
|
) -> str | list[dict[str, Any]]:
|
|
"""
|
|
Build the prompt for the SDK.
|
|
|
|
For continued Claude SDK sessions, send only the latest user
|
|
message. For a fresh session that already has replayable
|
|
history (for example a sub-agent with
|
|
``pass_history=True``), serialize that history into the
|
|
first prompt so Claude sees the prior conversation context.
|
|
|
|
When the latest user message contains multimodal content
|
|
(images, files), returns a list of Anthropic API content
|
|
blocks instead of a plain string so the caller can send
|
|
them through the SDK's structured message path.
|
|
|
|
:param messages: Conversation history as inner
|
|
:class:`Message` dicts.
|
|
:param resume_session: ``True`` when the SDK session
|
|
already has prior turns cached (no need to replay
|
|
history).
|
|
:returns: A plain string prompt, or a list of Anthropic
|
|
API content block dicts when multimodal blocks are
|
|
present in the latest user message.
|
|
"""
|
|
if resume_session:
|
|
return ClaudeSDKExecutor._extract_latest_user_content(messages)
|
|
|
|
user_messages = [msg for msg in messages if msg.get("role") == "user"]
|
|
if len(messages) <= 1 or len(user_messages) <= 1:
|
|
return ClaudeSDKExecutor._extract_latest_user_content(messages)
|
|
|
|
# Check if the latest user message is multimodal — if so,
|
|
# serialize prior history as a text prefix but preserve the
|
|
# latest message's content blocks for native multimodal
|
|
# delivery to the Anthropic API.
|
|
latest_content = ClaudeSDKExecutor._extract_latest_user_content(messages)
|
|
prior = messages[:-1] if messages else []
|
|
|
|
lines = ["Conversation so far:"]
|
|
for msg in prior:
|
|
role = str(msg.get("role", "user")).replace("_", " ")
|
|
raw_content = msg.get("content")
|
|
if raw_content is None:
|
|
content = ""
|
|
elif isinstance(raw_content, str):
|
|
content = raw_content
|
|
else:
|
|
content = json.dumps(raw_content, ensure_ascii=True)
|
|
lines.append(f"{role}: {content}")
|
|
lines.append("")
|
|
lines.append(
|
|
"Respond to the latest user message, using the conversation above as context."
|
|
)
|
|
history_prefix = "\n".join(lines)
|
|
|
|
if isinstance(latest_content, list):
|
|
return [
|
|
{"type": "text", "text": history_prefix},
|
|
*latest_content,
|
|
]
|
|
return f"{history_prefix}\n\nuser: {latest_content}"
|
|
|
|
@staticmethod
|
|
def _extract_latest_user_content(
|
|
messages: list[Message],
|
|
) -> str | list[dict[str, Any]]:
|
|
"""
|
|
Extract the latest user message content for the SDK.
|
|
|
|
Returns a plain string for text-only messages. When the
|
|
message carries multimodal content blocks (``input_image``,
|
|
``input_file``), converts them to Anthropic API content
|
|
block format and returns the list so the caller can send
|
|
a structured message through the SDK transport.
|
|
|
|
:param messages: Conversation history.
|
|
:returns: A string prompt, or a list of Anthropic content
|
|
block dicts.
|
|
"""
|
|
for msg in reversed(messages):
|
|
if msg.get("role") == "user":
|
|
content = msg.get("content")
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
return _to_anthropic_content_blocks(content)
|
|
return str(content)
|
|
return ""
|