1877 lines
83 KiB
Python
1877 lines
83 KiB
Python
"""OpenAIAgentsSDKExecutor: run agents using the OpenAI Agents SDK.
|
|
|
|
This executor uses the OpenAI Agents SDK as the agent runtime while keeping
|
|
Omnigent as the system of record for tools, policies, and session history.
|
|
Omnigent tools are exposed as SDK FunctionTools whose callbacks route back
|
|
through the Session's tool registry.
|
|
|
|
Conversation state is delegated to the SDK's session layer. Each Omnigent
|
|
Session gets its own SDK SQLiteSession keyed by the Omnigent session id.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Generator
|
|
from dataclasses import dataclass
|
|
from types import ModuleType
|
|
from typing import Any, Literal, Protocol, TypeAlias, cast
|
|
|
|
import httpx
|
|
|
|
from omnigent.llms._usage_observer import notify_from_dict as _notify_usage_from_dict
|
|
from omnigent.llms.errors import is_context_length_exceeded as _is_context_length_exceeded
|
|
from omnigent.reasoning_effort import OPENAI_AGENTS_EFFORTS, validate_effort
|
|
from omnigent.spec.types import RetryPolicy
|
|
|
|
from .executor import (
|
|
Executor,
|
|
ExecutorConfig,
|
|
ExecutorError,
|
|
ExecutorEvent,
|
|
Message,
|
|
ReasoningChunk,
|
|
TextChunk,
|
|
ToolCallComplete,
|
|
ToolCallRequest,
|
|
ToolSpec,
|
|
TurnComplete,
|
|
classify_tool_result,
|
|
split_transient_tail,
|
|
)
|
|
from .open_responses_sdk import (
|
|
_OPENAI_KEY_PLACEHOLDER,
|
|
_convert_messages_to_responses,
|
|
_databricks_openai_base_url,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_OPENAI_AGENTS_DEFAULT_MODEL = "gpt-5.3-codex"
|
|
_DATABRICKS_OPENAI_AGENTS_DEFAULT_MODEL = "databricks-gpt-5-5"
|
|
|
|
# Total run attempts per turn (1 initial + retries). The Databricks
|
|
# gateway occasionally returns a completed-but-empty turn (status
|
|
# completed, no text / no tool calls / no output items); a single
|
|
# retry recovers it. Kept small and hardcoded: more retries would
|
|
# risk wedging long e2e turns under the shard timeout, and the
|
|
# transient is rare.
|
|
_EMPTY_TURN_MAX_ATTEMPTS = 2
|
|
|
|
# SDK ``RunItem.type`` values that are bookkeeping, not user-visible
|
|
# output. A turn whose only new items are these (and which produced no
|
|
# text and no tool activity) is treated as empty and retried. Excluding
|
|
# by name (rather than allow-listing output types) is the conservative
|
|
# direction: an unknown future item type counts as output and is NOT
|
|
# retried.
|
|
_NON_OUTPUT_ITEM_TYPES: frozenset[str] = frozenset({"reasoning_item", "compaction_item"})
|
|
|
|
# Replay items persisted to the SDK Session — heterogeneous Responses-API
|
|
# input items (function_call / function_call_output / message / etc.).
|
|
# The SDK declares this as ``TResponseInputItem``, which is a TypedDict
|
|
# union; we only care about two fields (``id``, ``call_id``) and pass the
|
|
# rest through opaquely.
|
|
ReplayItem: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
# Raw pydantic tool-call / tool-output items from the Responses API. Their
|
|
# concrete union is huge (``ToolCallItemTypes`` / ``ToolCallOutputTypes``)
|
|
# and the fields we touch depend on the runtime member, so we keep it
|
|
# opaque at the boundary and narrow with ``isinstance(..., dict)`` + a
|
|
# Protocol cast at each read site.
|
|
RawToolItem: TypeAlias = Any # type: ignore[explicit-any]
|
|
|
|
# The openai ``AsyncOpenAI`` client. Kept as ``Any`` so ``openai`` stays
|
|
# an optional import at type-check time — the executor only constructs
|
|
# one when instantiated.
|
|
AsyncOpenAIClient: TypeAlias = Any # type: ignore[explicit-any]
|
|
|
|
# Tool executor callable wired in by ``omnigent.Session``. The result
|
|
# is JSON-ish (dict[str, Any]) but the static type leaks ``Any`` through
|
|
# the kwargs dict. Constrained here to match omnigent.executor.
|
|
ToolExecutor: TypeAlias = Callable[ # type: ignore[explicit-any]
|
|
[str, dict[str, Any]], Awaitable[dict[str, Any]]
|
|
]
|
|
|
|
# SDK objects we treat as opaque: agent/tool instances and tool results
|
|
# are structurally duck-typed by callers and the Protocols above.
|
|
SDKAgent: TypeAlias = Any # type: ignore[explicit-any]
|
|
SDKTool: TypeAlias = Any # type: ignore[explicit-any]
|
|
ToolResult: TypeAlias = Any # type: ignore[explicit-any]
|
|
|
|
# Parsed tool arguments / tool-call-output dict. JSON-shaped bag handed
|
|
# to the Omnigent tool executor.
|
|
ToolArgs: TypeAlias = dict[str, Any] # type: ignore[explicit-any]
|
|
|
|
|
|
def _normalize_responses_items_for_chat(
|
|
items: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Apply :func:`_normalize_content_blocks_for_chat` to every message item.
|
|
|
|
Walks the full Responses-API item list produced by
|
|
:func:`~omnigent.inner.open_responses_sdk._convert_messages_to_responses`
|
|
and normalises ``input_file`` blocks in any ``message`` item's ``content``
|
|
list. Non-message items (function_call, function_call_output, …) pass
|
|
through unchanged.
|
|
|
|
:param items: Responses-API input item dicts.
|
|
:returns: New list with normalised ``input_file`` blocks in message
|
|
content. Items without ``input_file`` blocks are returned as-is.
|
|
"""
|
|
result: list[dict[str, Any]] = []
|
|
for item in items:
|
|
if item.get("type") == "message":
|
|
raw_content = item.get("content")
|
|
if isinstance(raw_content, list):
|
|
normalized_content = _normalize_content_blocks_for_chat(raw_content)
|
|
if normalized_content is not raw_content:
|
|
item = {**item, "content": normalized_content}
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _normalize_content_blocks_for_chat(
|
|
content: list[dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
"""Normalize content blocks before handing them to the openai-agents Runner.
|
|
|
|
Converts ``input_file`` blocks whose ``file_data`` is a ``data:`` URI into
|
|
``input_text`` blocks by decoding the base64 payload. Also strips
|
|
Omnigent-only metadata (for example ``filename``) from known Responses
|
|
content block types before the OpenAI Agents SDK serializes them to the
|
|
provider API.
|
|
|
|
Background: :mod:`omnigent.runtime.content_resolver` resolves uploaded
|
|
file IDs to inline ``data:<mime>;base64,<data>`` URIs stored in
|
|
``file_data`` / ``image_url`` while preserving UI/history metadata such as
|
|
``filename``. That metadata is useful in Omnigent, but the OpenAI API
|
|
rejects unknown content-part fields (for example
|
|
``input[0].content[0].filename``). The openai-agents SDK's
|
|
``chatcmpl_converter`` also passes raw ``file_data`` strings to the Chat
|
|
Completions ``file`` content type, which expects **plain base64** — not a
|
|
data URI. Additionally, the Databricks GPT endpoint may not support the
|
|
``file`` content block type at all, so textual files are converted to
|
|
``input_text``. Images are preserved as ``input_image`` with a
|
|
conventional ``data:image/<type>;base64,...`` URL so vision-capable
|
|
Chat Completions providers can see the pixels.
|
|
|
|
:param content: List of Responses-API content-block dicts, possibly
|
|
containing ``input_file`` blocks with ``file_data`` data URIs or
|
|
metadata-bearing ``input_image`` blocks.
|
|
:returns: New list with each ``input_file`` block replaced by an
|
|
``input_text`` block carrying the decoded text content, and known
|
|
content blocks stripped to provider-supported keys. Binary
|
|
(non-``text/*``) file blocks, undecodable, or empty file blocks are
|
|
silently dropped. Unknown block types pass through unchanged.
|
|
"""
|
|
result: list[dict[str, Any]] = []
|
|
changed = False
|
|
for block in content:
|
|
block_type = block.get("type")
|
|
if block_type == "input_file":
|
|
changed = True
|
|
file_data: str = block.get("file_data", "")
|
|
if file_data.startswith("data:"):
|
|
try:
|
|
meta, b64 = file_data.split(",", 1)
|
|
mime = meta.split(";")[0].replace("data:", "")
|
|
if not mime.startswith("text/"):
|
|
continue # binary files (PDF, etc.) can't be inlined as text
|
|
text = base64.b64decode(b64).decode("utf-8", errors="replace")
|
|
except Exception: # noqa: BLE001
|
|
text = ""
|
|
else:
|
|
text = file_data
|
|
if text:
|
|
result.append({"type": "input_text", "text": text})
|
|
# else: drop empty / undecodable blocks
|
|
elif block_type in {"input_text", "output_text", "text"}:
|
|
sanitized = _copy_known_keys(block, ("type", "text"))
|
|
changed = changed or sanitized != block
|
|
result.append(sanitized)
|
|
elif block_type == "input_image":
|
|
# Preserve inline images; strip Omnigent-only metadata.
|
|
sanitized = _copy_known_keys(block, ("type", "image_url", "file_id", "detail"))
|
|
changed = changed or sanitized != block
|
|
result.append(sanitized)
|
|
else:
|
|
result.append(block)
|
|
# Return original list when nothing changed so callers can use
|
|
# identity check (``is``) to avoid unnecessary copies.
|
|
return result if changed else content
|
|
|
|
|
|
def _copy_known_keys(block: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
|
"""Return *block* with only keys accepted by provider content schemas.
|
|
|
|
Omnigent content blocks can carry UI/history metadata such as
|
|
``filename``. Known OpenAI/Responses block types are strict at the API
|
|
boundary, so those metadata fields must be removed from the payload handed
|
|
to the OpenAI Agents SDK while leaving persisted conversation state intact.
|
|
"""
|
|
return {key: block[key] for key in keys if key in block}
|
|
|
|
|
|
class _SDKSession(Protocol):
|
|
"""Structural view of ``agents.memory.Session`` — the four async
|
|
methods + two attributes the SDK's Session protocol requires.
|
|
|
|
Using a local Protocol lets ``_SanitizingSession`` satisfy the SDK
|
|
contract without inheriting from it, and lets tests plug in fake
|
|
sessions without importing the SDK.
|
|
"""
|
|
|
|
session_id: str
|
|
session_settings: Any # type: ignore[explicit-any] # SDK's SessionSettings | None; opaque here
|
|
|
|
async def get_items(self, limit: int | None = None) -> list[ReplayItem]: ...
|
|
async def add_items(self, items: list[ReplayItem]) -> None: ...
|
|
async def pop_item(self) -> ReplayItem | None: ...
|
|
async def clear_session(self) -> None: ...
|
|
|
|
|
|
class _AgentsSDK(Protocol):
|
|
"""Structural view of the ``agents`` module.
|
|
|
|
Tests swap in a ``SimpleNamespace`` with the same attribute names, so
|
|
we can't rely on ``ModuleType`` with real-type attributes. Attributes
|
|
are typed as ``type`` factories that return the actual SDK objects
|
|
the caller touches — kept deliberately loose (``Any`` factories in a
|
|
few places) so the duck-typed test doubles also satisfy the
|
|
Protocol structurally.
|
|
"""
|
|
|
|
# Factories invoked with **kwargs; the results are used via Protocols
|
|
# (_RunResult, _RunState, _StreamEvent) or via attribute access that
|
|
# works on both the real dataclasses and the test fakes.
|
|
Agent: Any # type: ignore[explicit-any] # agents.Agent[Any] factory
|
|
Runner: Any # type: ignore[explicit-any] # agents.Runner class with run_streamed classmethod
|
|
RunConfig: Any # type: ignore[explicit-any] # agents.RunConfig dataclass factory
|
|
ModelSettings: Any # type: ignore[explicit-any] # agents.ModelSettings dataclass factory
|
|
OpenAIProvider: Any # type: ignore[explicit-any] # agents.OpenAIProvider class
|
|
FunctionTool: Any # type: ignore[explicit-any] # agents.FunctionTool dataclass factory
|
|
SQLiteSession: Any # type: ignore[explicit-any] # agents.SQLiteSession class
|
|
ItemHelpers: Any # type: ignore[explicit-any] # agents.ItemHelpers utility class
|
|
MaxTurnsExceeded: type[BaseException]
|
|
|
|
|
|
class _RunResult(Protocol):
|
|
"""Structural view of ``agents.result.RunResultStreaming``.
|
|
|
|
Mirrors the subset this executor actually consumes.
|
|
"""
|
|
|
|
final_output: Any # type: ignore[explicit-any] # SDK declares this as Any; may be str or arbitrary pydantic output
|
|
new_items: list[Any] # type: ignore[explicit-any] # list[RunItem] union the SDK narrows internally
|
|
raw_responses: list[Any] # type: ignore[explicit-any] # list[ModelResponse]; each has .usage.input_tokens
|
|
|
|
def cancel(self, mode: str = ...) -> None: ...
|
|
|
|
def stream_events(self) -> AsyncIterator[_StreamEvent]: ...
|
|
|
|
def to_state(self) -> _RunState: ...
|
|
|
|
|
|
class _RunState(Protocol):
|
|
"""Structural view of ``agents.run_state.RunState`` — only the three
|
|
private counters this executor tweaks when resuming a stepwise turn.
|
|
"""
|
|
|
|
_current_turn: int
|
|
_max_turns: int
|
|
_current_turn_persisted_item_count: int
|
|
|
|
|
|
class _StreamEvent(Protocol):
|
|
"""Minimal shape shared by the SDK's stream events: each one has a
|
|
``type`` discriminator. We narrow on it before reading ``data`` /
|
|
``item`` via ``cast``.
|
|
"""
|
|
|
|
type: str
|
|
|
|
|
|
class _RawResponseEvent(Protocol):
|
|
type: str
|
|
data: _RawResponseData
|
|
|
|
|
|
class _RawResponseData(Protocol):
|
|
type: str
|
|
delta: str
|
|
|
|
|
|
class _RunItemEvent(Protocol):
|
|
type: str
|
|
item: _RunItem
|
|
|
|
|
|
class _RunItem(Protocol):
|
|
type: str
|
|
raw_item: Any # type: ignore[explicit-any] # ToolCallItemTypes | ToolCallOutputTypes union, narrowed on .type
|
|
output: Any # type: ignore[explicit-any] # ToolCallOutputItem.output is declared Any by the SDK
|
|
|
|
|
|
class _ToolCallRawItem(Protocol):
|
|
"""Duck-typed view of non-dict tool-call raw items (pydantic models
|
|
like ``ResponseFunctionToolCall``). Attribute access returns the
|
|
corresponding field — ``None`` on items where it isn't set.
|
|
"""
|
|
|
|
name: str | None
|
|
arguments: str | None
|
|
call_id: str | None
|
|
id: str | None
|
|
|
|
|
|
class _ToolCallOutputRawItem(Protocol):
|
|
call_id: str | None
|
|
id: str | None
|
|
|
|
|
|
class _CallModelData(Protocol):
|
|
model_data: _ModelInputData
|
|
|
|
|
|
class _ModelInputData(Protocol):
|
|
input: list[ReplayItem]
|
|
|
|
|
|
def _patch_openai_sse_keepalive_tolerance() -> None:
|
|
"""Tolerate empty SSE keepalive frames from OpenAI-compatible proxies.
|
|
|
|
Some OpenAI-compatible proxies and relays (for example cli-proxy-api) keep
|
|
long streaming responses alive by periodically emitting an *empty* ``data:``
|
|
frame rather than an SSE comment. The stock openai client then calls
|
|
``json.loads("")`` on that frame and raises ``Expecting value: line 1
|
|
column 1 (char 0)``, which aborts the whole turn with
|
|
``Error streaming response: ...``.
|
|
|
|
SSE *comment* keepalives (``: ping``) are already ignored by the decoder;
|
|
this shim extends the same tolerance to empty-``data`` keepalives by
|
|
dropping any decoded event whose payload is blank, so a single keepalive no
|
|
longer kills a long-running streamed response. Best-effort and idempotent:
|
|
if the openai internals change shape it silently no-ops.
|
|
"""
|
|
try:
|
|
from openai import _streaming as _oai_streaming
|
|
except Exception: # noqa: BLE001 - openai internals are best-effort.
|
|
return
|
|
|
|
decoder = getattr(_oai_streaming, "SSEDecoder", None)
|
|
if decoder is None or getattr(decoder, "_omnigent_keepalive_patch", False):
|
|
return
|
|
|
|
_orig_decode = decoder.decode
|
|
|
|
def decode(self: Any, line: str) -> Any: # type: ignore[explicit-any]
|
|
sse = _orig_decode(self, line)
|
|
if sse is not None and not (getattr(sse, "data", None) or "").strip():
|
|
# Empty-data keepalive frame: drop it so the client never calls
|
|
# json.loads("") on a blank payload mid-stream.
|
|
return None
|
|
return sse
|
|
|
|
decoder.decode = decode # type: ignore[method-assign]
|
|
decoder._omnigent_keepalive_patch = True # type: ignore[attr-defined]
|
|
|
|
|
|
def _ensure_agents_sdk() -> ModuleType:
|
|
try:
|
|
import agents
|
|
|
|
_patch_openai_sse_keepalive_tolerance()
|
|
return agents
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"OpenAIAgentsSDKExecutor requires the 'openai-agents' package. "
|
|
"Install it with: pip install openai-agents"
|
|
) from exc
|
|
|
|
|
|
def _get_openai_async_client(
|
|
profile: str | None = None,
|
|
api_key: str | None = None,
|
|
retry_policy: RetryPolicy | None = None,
|
|
base_url_override: str | None = None,
|
|
host_override: str | None = None,
|
|
databricks_auth_command: str | None = None,
|
|
model: str | None = None,
|
|
) -> AsyncOpenAIClient:
|
|
"""Construct an AsyncOpenAI client for direct or Databricks-hosted use.
|
|
|
|
For Databricks-backed profiles, the returned client uses an httpx
|
|
``Auth`` callback that calls ``Config.authenticate()`` on every
|
|
HTTP request, so OAuth tokens are refreshed transparently and
|
|
long-running sessions survive the 1-hour access-token lifetime.
|
|
|
|
:param profile: Optional ``~/.databrickscfg`` profile name for the
|
|
Databricks path, e.g. ``"dev"``.
|
|
:param api_key: Direct OpenAI-compatible API key, e.g.
|
|
``"sk-proj-..."``. When set, constructs
|
|
``AsyncOpenAI(api_key=…)`` directly, bypassing all profile and
|
|
env-var lookups. Intended for specs that declare
|
|
``executor.auth: {type: api_key, api_key: …}``.
|
|
:param retry_policy: Optional retry policy. When provided, its
|
|
``policy.openai.kwargs()`` (max_retries, timeout) are spread
|
|
into the ``AsyncOpenAI(...)`` constructor so L0 retry uses
|
|
the spec's budget. ``None`` falls back to a default policy
|
|
with the project-wide budget.
|
|
:param base_url_override: When set, use this as the client base URL.
|
|
In gateway host mode this is required and is populated from
|
|
``HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL``.
|
|
:param host_override: Databricks workspace host, e.g.
|
|
``"https://example.databricks.com"``. When set, skips profile host
|
|
lookup and requires ucode-provided gateway URL and auth command
|
|
values.
|
|
:param databricks_auth_command: Shell command from ucode state that
|
|
prints a bearer token, e.g.
|
|
``"databricks auth token --host https://example.databricks.com ..."``.
|
|
:param model: The model name that will be used. When set to a
|
|
non-Databricks model (i.e. does not start with ``"databricks-"``),
|
|
both the explicit ``profile`` block and the ambient Databricks
|
|
credential fallback are skipped — the caller must supply
|
|
``OPENAI_API_KEY`` (and optionally ``OPENAI_BASE_URL``) instead.
|
|
:raises DatabricksAuthError: When an explicit ``profile`` is given,
|
|
authentication fails, and no ``OPENAI_BASE_URL`` / ``OPENAI_API_KEY``
|
|
env-var fallbacks are available.
|
|
:raises OSError: If ucode host state is present but missing the
|
|
corresponding base URL or auth command.
|
|
"""
|
|
try:
|
|
from openai import AsyncOpenAI
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The 'openai' package is required for OpenAIAgentsSDKExecutor. "
|
|
"Install it with: pip install openai"
|
|
) from exc
|
|
|
|
policy = retry_policy if retry_policy is not None else RetryPolicy()
|
|
retry_kwargs = policy.openai.kwargs()
|
|
|
|
if host_override:
|
|
if base_url_override is None:
|
|
raise OSError(
|
|
"OpenAIAgentsSDKExecutor with a gateway workspace host requires "
|
|
"HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL."
|
|
)
|
|
if databricks_auth_command is None:
|
|
raise OSError(
|
|
"OpenAIAgentsSDKExecutor with a gateway workspace host requires "
|
|
"HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND."
|
|
)
|
|
host = host_override.rstrip("/")
|
|
return AsyncOpenAI(
|
|
base_url=base_url_override,
|
|
api_key=_OPENAI_KEY_PLACEHOLDER,
|
|
http_client=httpx.AsyncClient(auth=_ShellCommandBearerAuth(databricks_auth_command)),
|
|
**retry_kwargs,
|
|
)
|
|
|
|
# Explicit spec-level api_key (executor.auth: {type: api_key, …}).
|
|
# Checked before profile and env-var lookups so the spec is self-contained.
|
|
# base_url_override is populated from HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL
|
|
# when the spec also declares executor.auth.base_url.
|
|
#
|
|
# Fall back to the ambient OPENAI_BASE_URL when no override reached us.
|
|
# The api_key is frequently a gateway credential (e.g. a Databricks AI
|
|
# Gateway PAT detected from OPENAI_API_KEY), and the companion base_url
|
|
# can be dropped anywhere on the daemon → runner → harness propagation
|
|
# chain (the spec auth bake omits it when OPENAI_BASE_URL is absent at
|
|
# materialization time; a reused local daemon may predate the env var).
|
|
# Without this fallback, a missing base_url silently routes the gateway
|
|
# token to api.openai.com and every request 401s; honoring the ambient
|
|
# OPENAI_BASE_URL the runner inherits keeps the gateway target present on
|
|
# every turn even when the override is lost. base_url=None still defaults
|
|
# to api.openai.com for a genuine OpenAI key with no gateway configured.
|
|
if api_key and api_key.strip():
|
|
return AsyncOpenAI(
|
|
api_key=api_key,
|
|
base_url=base_url_override or os.environ.get("OPENAI_BASE_URL") or None,
|
|
**retry_kwargs,
|
|
)
|
|
|
|
is_databricks_model = model is None or model.startswith("databricks-")
|
|
|
|
# Databricks profile auth only applies when the model is Databricks-hosted.
|
|
# An explicit spec/provider profile wins over ambient env vars.
|
|
if is_databricks_model and profile:
|
|
from .databricks_executor import DatabricksAuthError, _resolve_databricks_auth
|
|
|
|
try:
|
|
auth, host = _resolve_databricks_auth(profile)
|
|
return AsyncOpenAI(
|
|
base_url=base_url_override or _databricks_openai_base_url(host),
|
|
api_key=_OPENAI_KEY_PLACEHOLDER,
|
|
http_client=httpx.AsyncClient(auth=auth),
|
|
**retry_kwargs,
|
|
)
|
|
except DatabricksAuthError as exc:
|
|
# Fall through to env-var credentials when available so that
|
|
# CI environments (OIDC tokens injected via OPENAI_BASE_URL /
|
|
# OPENAI_API_KEY) still work even if the named profile is not
|
|
# present in ~/.databrickscfg. Log a warning so the fallback
|
|
# is visible rather than silent. Re-raise only when there are
|
|
# no env-var fallbacks to fall through to.
|
|
if os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_KEY"):
|
|
logger.warning(
|
|
"Databricks profile %r authentication failed (%s); "
|
|
"falling back to OPENAI_BASE_URL/OPENAI_API_KEY.",
|
|
profile,
|
|
exc,
|
|
)
|
|
else:
|
|
raise
|
|
except ImportError:
|
|
logger.warning(
|
|
"databricks-sdk is not installed; cannot resolve Databricks "
|
|
"profile %r. Falling back to OPENAI_BASE_URL/OPENAI_API_KEY.",
|
|
profile,
|
|
)
|
|
|
|
if os.environ.get("OPENAI_BASE_URL"):
|
|
return AsyncOpenAI(
|
|
base_url=os.environ["OPENAI_BASE_URL"],
|
|
api_key=os.environ.get("OPENAI_API_KEY", _OPENAI_KEY_PLACEHOLDER),
|
|
**retry_kwargs,
|
|
)
|
|
|
|
api_key = os.environ.get("OPENAI_API_KEY")
|
|
if api_key:
|
|
return AsyncOpenAI(api_key=api_key, **retry_kwargs)
|
|
|
|
# Non-Databricks model with no OpenAI credentials — fail loudly rather
|
|
# than silently routing to the Databricks AI Gateway, which will 404.
|
|
if not is_databricks_model:
|
|
raise ValueError(
|
|
f"Model {model!r} is not a Databricks-hosted model but no OpenAI "
|
|
"credentials were found. Set OPENAI_API_KEY (and optionally "
|
|
"OPENAI_BASE_URL) to use this model, or use a 'databricks-' "
|
|
"prefixed model name to route through Databricks."
|
|
)
|
|
|
|
# No profile, no env — final fallback via ambient Databricks credentials.
|
|
try:
|
|
from .databricks_executor import _resolve_databricks_auth
|
|
|
|
auth, host = _resolve_databricks_auth(profile)
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"The 'databricks-sdk' package is required for Databricks "
|
|
"authentication but is not installed, and no OPENAI_API_KEY or "
|
|
"OPENAI_BASE_URL environment variables are set. Either install "
|
|
"the package (`pip install 'omnigent[databricks]'`) or set "
|
|
"OPENAI_API_KEY/OPENAI_BASE_URL for non-Databricks OpenAI access."
|
|
) from exc
|
|
return AsyncOpenAI(
|
|
base_url=base_url_override or _databricks_openai_base_url(host),
|
|
api_key=_OPENAI_KEY_PLACEHOLDER,
|
|
http_client=httpx.AsyncClient(auth=auth),
|
|
**retry_kwargs,
|
|
)
|
|
|
|
|
|
class _ShellCommandBearerAuth(httpx.Auth):
|
|
"""httpx Auth that refreshes a bearer token through a shell command.
|
|
|
|
:param command: Shell command that prints the access token to stdout,
|
|
e.g. ``"databricks auth token --host https://example.databricks.com ..."``.
|
|
"""
|
|
|
|
def __init__(self, command: str) -> None:
|
|
"""Store the command for per-request token refresh.
|
|
|
|
:param command: Shell command that prints the access token.
|
|
"""
|
|
self._command = command
|
|
|
|
def auth_flow(
|
|
self,
|
|
request: httpx.Request,
|
|
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
"""Inject a fresh ``Authorization: Bearer`` header.
|
|
|
|
:param request: The outgoing httpx request.
|
|
:yields: The request with the auth header set.
|
|
:raises RuntimeError: When the command fails or prints no token.
|
|
"""
|
|
result = subprocess.run(
|
|
["sh", "-c", self._command],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
token = result.stdout.strip()
|
|
if result.returncode != 0 or not token:
|
|
raise RuntimeError("Databricks auth command failed to return a bearer token.")
|
|
request.headers["Authorization"] = f"Bearer {token}"
|
|
yield request
|
|
|
|
|
|
# Sentinel pushed onto the per-turn event queue when
|
|
# :meth:`OpenAIAgentsSDKExecutor._consume_stream_into_queue`
|
|
# exits (normally OR via cancellation). The consumer loop in
|
|
# :meth:`OpenAIAgentsSDKExecutor.run_turn` breaks on it and
|
|
# proceeds to await the producer task, which surfaces any
|
|
# non-cancel exception. A unique module-level object avoids
|
|
# false positives from real ``StreamEvent`` instances.
|
|
_STREAM_DONE = object()
|
|
|
|
|
|
@dataclass
|
|
class _AgentsSessionState:
|
|
"""
|
|
Per-session state for the OpenAI Agents SDK executor.
|
|
|
|
:param sdk_session: The SDK's persistent session (SQLite-
|
|
backed). Holds the conversational history items.
|
|
:param started: ``True`` once the SDK has accepted the
|
|
first turn for this session.
|
|
:param agent: Cached ``agents.Agent[Any]`` instance for
|
|
reuse across turns; rebuilt when *agent_signature*
|
|
changes.
|
|
:param agent_signature: ``(model, system_prompt, tools,
|
|
parallel_tool_calls)``-derived key. A signature change
|
|
forces an agent rebuild.
|
|
:param resume_state: Optional SDK ``_RunState`` carried
|
|
across stepwise-internal turns.
|
|
:param active_result: The in-flight ``RunResultStreaming``
|
|
for the current turn, or ``None`` between turns.
|
|
:param interrupt_requested: Set by :meth:`interrupt_session`
|
|
to signal the in-flight turn to stop emitting events.
|
|
Cleared at the top of the next ``run_turn``.
|
|
:param stream_consumer_task: The asyncio.Task running
|
|
``_consume_stream_into_queue`` for the in-flight turn.
|
|
:meth:`interrupt_session` cancels this task, which lands
|
|
``CancelledError`` inside the SDK's
|
|
``await self._event_queue.get()`` (agents/result.py:721)
|
|
— the SDK's own ``except CancelledError`` then calls
|
|
``self.cancel()`` to close the underlying httpx stream.
|
|
Without this task indirection, the only cancel signal
|
|
is ``result.cancel(mode="immediate")``, which sets a
|
|
flag but does NOT close the network connection — events
|
|
keep streaming for 15+ seconds. ``None`` between turns.
|
|
:param history_cursor: Index into the persisted-message
|
|
suffix marking how much we've already replayed.
|
|
:param run_item_count_before: ``len(sdk_session.items)``
|
|
snapshot taken before the in-flight turn so a cancelled
|
|
turn can be rolled back to this point.
|
|
:param rollback_to_item_count: When non-``None``, the next
|
|
``run_turn`` rewinds the SDK session to this length
|
|
before starting. Set by :meth:`interrupt_session`.
|
|
"""
|
|
|
|
sdk_session: Any # type: ignore[explicit-any] # _SanitizingSession or OpenAIResponsesCompactionSession
|
|
started: bool = False
|
|
# agents.Agent[Any] instance; cached for reuse across turns.
|
|
agent: SDKAgent = None
|
|
agent_signature: tuple[str, str, str, str, str] | None = None
|
|
resume_state: _RunState | None = None
|
|
active_result: _RunResult | None = None
|
|
interrupt_requested: bool = False
|
|
stream_consumer_task: asyncio.Task[None] | None = None
|
|
history_cursor: int = 0
|
|
run_item_count_before: int | None = None
|
|
rollback_to_item_count: int | None = None
|
|
|
|
|
|
# ``_sanitize_replay_item`` walks replay values recursively. At the
|
|
# boundary these are either SDK TypedDicts, pydantic models that have
|
|
# already been ``model_dump``-ed to dicts, or primitive JSON values — so
|
|
# ``Any`` is the right annotation. The recursion preserves shape, so we
|
|
# return the same static type we received.
|
|
_ReplayValue: TypeAlias = Any # type: ignore[explicit-any]
|
|
|
|
|
|
def _sanitize_replay_item(value: _ReplayValue) -> _ReplayValue:
|
|
"""Strip provider-only fields Databricks rejects during replay."""
|
|
if isinstance(value, list):
|
|
return [_sanitize_replay_item(item) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
|
|
return {
|
|
key: _sanitize_replay_item(item)
|
|
for key, item in value.items()
|
|
if not (key == "id" and isinstance(item, str) and len(item) > 64)
|
|
}
|
|
|
|
|
|
class _SanitizingSession:
|
|
"""Session wrapper that normalizes replay items before persistence."""
|
|
|
|
def __init__(self, underlying: _SDKSession) -> None:
|
|
self._underlying = underlying
|
|
self.session_id = underlying.session_id
|
|
# ``session_settings`` is declared on the SDK Session protocol but
|
|
# some fake sessions (tests) / older SDK revs omit it. Fall back
|
|
# to ``None`` rather than propagating the AttributeError.
|
|
self.session_settings = (
|
|
underlying.session_settings if hasattr(underlying, "session_settings") else None
|
|
)
|
|
|
|
async def get_items(self, limit: int | None = None) -> list[ReplayItem]:
|
|
return await self._underlying.get_items(limit)
|
|
|
|
async def add_items(self, items: list[ReplayItem]) -> None:
|
|
await self._underlying.add_items([_sanitize_replay_item(item) for item in items])
|
|
|
|
async def pop_item(self) -> ReplayItem | None:
|
|
item = await self._underlying.pop_item()
|
|
if item is None:
|
|
return None
|
|
return cast(ReplayItem, _sanitize_replay_item(item))
|
|
|
|
async def clear_session(self) -> None:
|
|
await self._underlying.clear_session()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawToolItemParts:
|
|
"""Normalized view of a tool-call raw item from the Agents SDK.
|
|
|
|
The SDK hands tool calls back in one of two shapes — a dict (e.g. the
|
|
Responses-API input-item TypedDict) or a pydantic model such as
|
|
``ResponseFunctionToolCall`` — and this dataclass is the single
|
|
extracted form we pass downstream as a ``ToolCallRequest``.
|
|
|
|
:param name: The tool name. Empty string when the raw item didn't
|
|
carry a name.
|
|
:param args: Parsed JSON arguments. Falls back to
|
|
``{"raw": <args_text>}`` when the argument string isn't valid JSON.
|
|
:param call_id: The SDK-assigned call id (``call_id`` or ``id``), or
|
|
``None`` when the raw item didn't carry one.
|
|
"""
|
|
|
|
name: str
|
|
args: ToolArgs
|
|
call_id: str | None
|
|
|
|
|
|
def _tool_args_from_raw_item(raw_item: RawToolItem) -> RawToolItemParts:
|
|
"""Extract ``name`` / ``args`` / ``call_id`` from an SDK tool raw item.
|
|
|
|
:param raw_item: Either a dict (Responses-API TypedDict input item) or
|
|
a pydantic ``_ToolCallRawItem`` produced by the Agents SDK.
|
|
"""
|
|
name: str | None = None
|
|
args_text: str | None = None
|
|
call_id: str | None = None
|
|
if isinstance(raw_item, dict):
|
|
raw_dict: ToolArgs = raw_item
|
|
name_val = raw_dict.get("name")
|
|
name = name_val if isinstance(name_val, str) else None
|
|
args_val = raw_dict.get("arguments")
|
|
args_text = args_val if isinstance(args_val, str) else None
|
|
cid = raw_dict.get("call_id") or raw_dict.get("id")
|
|
call_id = cid if isinstance(cid, str) else None
|
|
else:
|
|
# Pydantic BaseModel-style raw item (e.g. ResponseFunctionToolCall).
|
|
# Only ``ResponseFunctionToolCall`` in the ToolCallItemTypes union
|
|
# carries all three fields; other members don't emit ToolCallRequest
|
|
# events in practice (this executor only wires function tools).
|
|
proto = cast(_ToolCallRawItem, raw_item)
|
|
name = proto.name
|
|
args_text = proto.arguments
|
|
call_id = proto.call_id or proto.id
|
|
|
|
args: ToolArgs
|
|
try:
|
|
args = cast(ToolArgs, json.loads(args_text)) if args_text else {}
|
|
except (TypeError, json.JSONDecodeError):
|
|
args = {"raw": args_text}
|
|
# ``RawToolItemParts.name`` is typed ``str`` because every
|
|
# downstream consumer emits it as the tool-call name; widening to
|
|
# ``str | None`` here would cascade into ``ToolCallRequest`` and
|
|
# force ``None``-handling on every callsite for a case that
|
|
# indicates a malformed SDK payload. Keep the coercion localized
|
|
# with an explicit ternary at this dataclass boundary.
|
|
resolved_name: str = name if name is not None else ""
|
|
return RawToolItemParts(name=resolved_name, args=args, call_id=call_id)
|
|
|
|
|
|
def _build_reasoning_model_settings(effort: str | None) -> dict[str, object]:
|
|
"""Build ModelSettings kwargs for reasoning effort."""
|
|
if effort is None:
|
|
return {}
|
|
try:
|
|
from openai.types.shared import Reasoning
|
|
except ImportError:
|
|
return {"reasoning": {"effort": effort}}
|
|
openai_effort = cast(
|
|
Literal["none", "minimal", "low", "medium", "high", "xhigh"],
|
|
effort,
|
|
)
|
|
return {"reasoning": Reasoning(effort=openai_effort)}
|
|
|
|
|
|
def _is_databricks_openai_client(client: AsyncOpenAIClient) -> bool:
|
|
"""Return whether *client* targets a Databricks AI Gateway base URL."""
|
|
return "/ai-gateway/" in str(getattr(client, "base_url", ""))
|
|
|
|
|
|
class _ReasoningBlockFilterStream:
|
|
"""Async stream wrapper that converts list-type ``delta.content`` to ``None``.
|
|
|
|
Kimi K2 (and similar reasoning models served via Databricks) sends
|
|
thinking/reasoning blocks as list-type content in streaming deltas:
|
|
``choices[0].delta.content = [{'type': 'reasoning', ...}]``.
|
|
The openai-agents SDK's ``ChatCmplStreamHandler`` creates a
|
|
``ResponseTextDeltaEvent(delta=content)`` which Pydantic rejects for
|
|
list input — ``delta`` must be ``str``.
|
|
|
|
This wrapper replaces list-type content with ``None`` (the SDK treats
|
|
``None`` as "no text delta") so the reasoning tokens are silently
|
|
discarded at the stream boundary and only the final answer text
|
|
reaches the SDK.
|
|
|
|
:param stream: The underlying ``AsyncStream[ChatCompletionChunk]``
|
|
from ``AsyncOpenAI.chat.completions.create(stream=True)``.
|
|
"""
|
|
|
|
def __init__(self, stream: Any) -> None: # type: ignore[explicit-any]
|
|
"""
|
|
:param stream: Underlying async stream of chat completion chunks.
|
|
"""
|
|
self._stream = stream
|
|
|
|
def __aiter__(self) -> _ReasoningBlockFilterStream:
|
|
return self
|
|
|
|
async def __anext__(self) -> Any: # type: ignore[explicit-any]
|
|
"""
|
|
:returns: The next chunk, with list-type ``delta.content`` replaced
|
|
by ``None``.
|
|
:raises StopAsyncIteration: When the underlying stream is exhausted.
|
|
"""
|
|
chunk = await self._stream.__anext__()
|
|
for choice in chunk.choices:
|
|
if isinstance(getattr(choice.delta, "content", None), list):
|
|
# Bypass Pydantic's __setattr__ validation — the field type
|
|
# in ChoiceDelta is ``str | None`` and the list value from
|
|
# reasoning models fails validation if assigned normally.
|
|
object.__setattr__(choice.delta, "content", None)
|
|
return chunk
|
|
|
|
async def __aenter__(self) -> _ReasoningBlockFilterStream:
|
|
if hasattr(self._stream, "__aenter__"):
|
|
await self._stream.__aenter__()
|
|
return self
|
|
|
|
async def __aexit__(self, *args: object) -> None:
|
|
if hasattr(self._stream, "__aexit__"):
|
|
await self._stream.__aexit__(*args)
|
|
|
|
def __getattr__(self, name: str) -> Any: # type: ignore[explicit-any]
|
|
return getattr(self._stream, name)
|
|
|
|
|
|
class _ReasoningBlockFilterCompletions:
|
|
"""Wraps ``AsyncCompletions`` to inject :class:`_ReasoningBlockFilterStream`.
|
|
|
|
Sits between the openai-agents SDK's ``OpenAIProvider`` and the real
|
|
``AsyncOpenAI.chat.completions`` so reasoning-model list content never
|
|
reaches ``ChatCmplStreamHandler``.
|
|
|
|
:param completions: The real ``AsyncCompletions`` object.
|
|
"""
|
|
|
|
def __init__(self, completions: Any) -> None: # type: ignore[explicit-any]
|
|
self._completions = completions
|
|
|
|
async def create(self, **kwargs: Any) -> Any: # type: ignore[explicit-any]
|
|
"""
|
|
Proxy ``create()``; wrap the result in
|
|
:class:`_ReasoningBlockFilterStream` when streaming is enabled.
|
|
|
|
:param kwargs: Forwarded verbatim to the underlying ``create()``.
|
|
:returns: A :class:`_ReasoningBlockFilterStream` when ``stream=True``,
|
|
otherwise the raw ``ChatCompletion`` response.
|
|
"""
|
|
result = await self._completions.create(**kwargs)
|
|
if kwargs.get("stream") and hasattr(result, "__anext__"):
|
|
return _ReasoningBlockFilterStream(result)
|
|
return result
|
|
|
|
def __getattr__(self, name: str) -> Any: # type: ignore[explicit-any]
|
|
return getattr(self._completions, name)
|
|
|
|
|
|
class _ReasoningBlockFilterChat:
|
|
"""Wraps ``AsyncChat`` to expose a :class:`_ReasoningBlockFilterCompletions`.
|
|
|
|
:param chat: The real ``AsyncOpenAI.chat`` object.
|
|
"""
|
|
|
|
def __init__(self, chat: Any) -> None: # type: ignore[explicit-any]
|
|
self._chat = chat
|
|
self.completions: _ReasoningBlockFilterCompletions = _ReasoningBlockFilterCompletions(
|
|
chat.completions
|
|
)
|
|
|
|
def __getattr__(self, name: str) -> Any: # type: ignore[explicit-any]
|
|
return getattr(self._chat, name)
|
|
|
|
|
|
def _wrap_client_for_reasoning_models(client: AsyncOpenAIClient) -> AsyncOpenAIClient:
|
|
"""Wrap *client* so reasoning-model list content is filtered from streams.
|
|
|
|
Replaces ``client.chat`` with a :class:`_ReasoningBlockFilterChat`
|
|
proxy that intercepts ``chat.completions.create(stream=True)`` and
|
|
wraps the result in :class:`_ReasoningBlockFilterStream`. All other
|
|
attribute accesses fall through to the real client.
|
|
|
|
:param client: The ``AsyncOpenAI`` (or compatible) client to wrap.
|
|
:returns: The same *client* object with ``chat`` replaced by the filter
|
|
proxy. The object is modified in-place and returned for chaining.
|
|
"""
|
|
# Patch ``chat`` directly on the client instance so the proxy intercepts
|
|
# every ``client.chat.completions.create()`` call the SDK makes.
|
|
# ``object.__setattr__`` bypasses both the OpenAI SDK's own ``__setattr__``
|
|
# and any Pydantic frozen-instance guard on the client class.
|
|
object.__setattr__(client, "chat", _ReasoningBlockFilterChat(client.chat))
|
|
return client
|
|
|
|
|
|
def _count_output_items(new_items: list[Any]) -> int: # type: ignore[explicit-any]
|
|
"""Count run items that represent user-visible output.
|
|
|
|
Excludes bookkeeping items (reasoning, compaction) per
|
|
:data:`_NON_OUTPUT_ITEM_TYPES` so a reasoning-only ghost turn
|
|
counts as zero output items.
|
|
|
|
:param new_items: ``RunResult.new_items`` for the completed run.
|
|
:returns: Number of items whose ``.type`` is not in
|
|
:data:`_NON_OUTPUT_ITEM_TYPES`.
|
|
"""
|
|
return sum(
|
|
1 for item in new_items if getattr(item, "type", None) not in _NON_OUTPUT_ITEM_TYPES
|
|
)
|
|
|
|
|
|
def _sum_output_tokens(raw_responses: list[Any] | None) -> int: # type: ignore[explicit-any]
|
|
"""Sum ``output_tokens`` across a run's raw model responses.
|
|
|
|
:param raw_responses: ``RunResult.raw_responses``; each element has a
|
|
``.usage.output_tokens``. ``None`` or empty yields ``0``.
|
|
:returns: Total output tokens reported across all sub-turn responses.
|
|
"""
|
|
if not raw_responses:
|
|
return 0
|
|
return sum(getattr(r.usage, "output_tokens", 0) or 0 for r in raw_responses)
|
|
|
|
|
|
def _is_empty_turn(
|
|
final_text: str,
|
|
saw_tool_activity: bool,
|
|
new_items: list[Any], # type: ignore[explicit-any]
|
|
) -> bool:
|
|
"""Whether a completed run produced literally nothing worth surfacing.
|
|
|
|
A turn is empty when it has no final text, no tool activity, and no
|
|
output-bearing items. A turn that called tools but produced no text
|
|
is NOT empty (tool activity is legitimate output).
|
|
|
|
Token usage is deliberately NOT part of this predicate: it must not
|
|
suppress a retry (gateway token accounting can be unreliable, and a
|
|
token quirk should not stop us from re-running an empty turn). The
|
|
fail-loud decision after the retry loop applies a *separate*,
|
|
narrower gate (``output_tokens == 0``) on top of this one — see the
|
|
call site for why an empty turn that still billed tokens is left to
|
|
complete silently rather than raised as an error.
|
|
|
|
:param final_text: The assembled assistant text for the turn.
|
|
:param saw_tool_activity: ``True`` if any tool call was streamed.
|
|
:param new_items: ``RunResult.new_items`` for the completed run.
|
|
:returns: ``True`` if the turn is empty and should be retried.
|
|
"""
|
|
return not final_text and not saw_tool_activity and _count_output_items(new_items) == 0
|
|
|
|
|
|
class OpenAIAgentsSDKExecutor(Executor):
|
|
"""Execute turns using the OpenAI Agents SDK."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
client: AsyncOpenAIClient = None,
|
|
profile: str | None = None,
|
|
api_key: str | None = None,
|
|
use_responses: bool = True,
|
|
model: str | None = None,
|
|
retry_policy: RetryPolicy | None = None,
|
|
base_url_override: str | None = None,
|
|
gateway_host: str | None = None,
|
|
gateway_auth_command: str | None = None,
|
|
) -> None:
|
|
"""Create an OpenAIAgentsSDKExecutor.
|
|
|
|
:param client: A preconfigured ``openai.AsyncOpenAI`` client. When
|
|
``None`` the executor calls :func:`_get_openai_async_client`.
|
|
:param profile: Optional ``~/.databrickscfg`` profile name for the
|
|
Databricks fallback path, e.g. ``"<your-profile>"``.
|
|
:param api_key: Direct OpenAI-compatible API key, e.g.
|
|
``"sk-proj-..."``. When set, constructs the client with this key
|
|
directly, bypassing profile resolution and env-var lookups. Set
|
|
from ``HARNESS_OPENAI_AGENTS_API_KEY`` when the agent spec
|
|
declares ``executor.auth: {type: api_key, api_key: …}``.
|
|
:param use_responses: When ``True`` the executor talks to the
|
|
OpenAI ``/responses`` endpoint; when ``False`` it falls back to
|
|
``/chat/completions``.
|
|
:param model: Optional constructor-level model default. Acts
|
|
as the spec-level default — applied when a per-turn
|
|
``ExecutorConfig.model`` is not set. A non-None
|
|
``cfg.model`` (set by AP's
|
|
the harness HTTP client when the request carries
|
|
a ``model_override``) wins over this value, so the REPL's
|
|
``/model`` slash command can shadow the spec model
|
|
per-request. The harness wrap reads
|
|
``HARNESS_OPENAI_AGENTS_MODEL`` from the spawn env and
|
|
threads the value here so the harness can pin a model
|
|
independently of the per-call ``request.model`` (which AP
|
|
uses to identify the AGENT, not the underlying LLM).
|
|
``None`` falls back to ``cfg.model`` then the executor's
|
|
built-in default.
|
|
:param base_url_override: Override the OpenAI-compatible base URL
|
|
instead of deriving it from the Databricks profile host. Set
|
|
from ``HARNESS_OPENAI_AGENTS_GATEWAY_BASE_URL`` (written by
|
|
the Omnigent workflow layer). Required whenever ``gateway_host`` is set.
|
|
:param gateway_host: Gateway workspace host origin, e.g.
|
|
``"https://example.databricks.com"``. Set from
|
|
``HARNESS_OPENAI_AGENTS_GATEWAY_HOST`` (written by the AP
|
|
workflow layer). When set, skips profile host lookup and requires
|
|
the gateway base URL and auth command values.
|
|
:param gateway_auth_command: Shell command that prints a bearer token,
|
|
e.g.
|
|
``"databricks auth token --host https://example.databricks.com ..."``
|
|
or ``"printf %s sk-..."``. Set from
|
|
``HARNESS_OPENAI_AGENTS_GATEWAY_AUTH_COMMAND``.
|
|
"""
|
|
self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy()
|
|
raw_client = (
|
|
client
|
|
if client is not None
|
|
else _get_openai_async_client(
|
|
profile=profile,
|
|
api_key=api_key,
|
|
retry_policy=self._retry_policy,
|
|
base_url_override=base_url_override,
|
|
host_override=gateway_host,
|
|
databricks_auth_command=gateway_auth_command,
|
|
model=model,
|
|
)
|
|
)
|
|
# Wrap the chat.completions path to strip list-type delta.content
|
|
# (reasoning blocks emitted by models like Kimi K2). The SDK's
|
|
# ChatCmplStreamHandler validates delta as str; list input raises
|
|
# ValidationError and the turn silently produces no output.
|
|
# The wrapper is a no-op for models that always return str content.
|
|
# Only needed for the chat-completions path; the Responses API path
|
|
# has its own event handling that doesn't go through ChatCmplStreamHandler.
|
|
self._client = (
|
|
_wrap_client_for_reasoning_models(raw_client) if not use_responses else raw_client
|
|
)
|
|
self._profile = profile
|
|
self._use_responses = use_responses
|
|
self._model_override = model
|
|
self._databricks = _is_databricks_openai_client(self._client)
|
|
self._tool_executor: ToolExecutor | None = None
|
|
self._session_states: dict[str, _AgentsSessionState] = {}
|
|
|
|
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_stepwise_internal_turns(self) -> bool:
|
|
return True
|
|
|
|
def max_context_tokens(self) -> int | None:
|
|
return None
|
|
|
|
def _session_key(self, messages: list[Message]) -> str:
|
|
if messages:
|
|
if messages[-1].get("session_id"):
|
|
return str(messages[-1]["session_id"])
|
|
metadata = messages[-1].get("metadata", {})
|
|
if isinstance(metadata, dict) and metadata.get("session_id"):
|
|
return str(metadata["session_id"])
|
|
return "default"
|
|
|
|
def _get_or_create_session_state(
|
|
self, agents_sdk: _AgentsSDK, session_key: str
|
|
) -> _AgentsSessionState:
|
|
state = self._session_states.get(session_key)
|
|
if state is not None:
|
|
return state
|
|
|
|
underlying = _SanitizingSession(agents_sdk.SQLiteSession(session_key))
|
|
sdk_session: Any = underlying # type: ignore[explicit-any]
|
|
# Wrap with compaction session when the client targets a real
|
|
# HTTP endpoint. Skip for bare object() clients in unit tests
|
|
# that lack base_url.
|
|
_base = str(getattr(self._client, "base_url", ""))
|
|
if _base.startswith("http"):
|
|
try:
|
|
from agents.memory import OpenAIResponsesCompactionSession
|
|
|
|
class _SafeCompactionSession(OpenAIResponsesCompactionSession):
|
|
"""Compaction session that treats compaction failures as non-fatal.
|
|
|
|
The responses.compact API may not be available on all
|
|
endpoints (mock servers, some proxies). A 404 or other
|
|
failure should not kill the turn — the session continues
|
|
without compaction.
|
|
"""
|
|
|
|
async def run_compaction(self, args=None): # type: ignore[override]
|
|
try:
|
|
await super().run_compaction(args)
|
|
except Exception: # noqa: BLE001
|
|
logger.debug(
|
|
"Compaction call failed (endpoint may not support "
|
|
"responses.compact), continuing without compaction",
|
|
exc_info=True,
|
|
)
|
|
|
|
sdk_session = _SafeCompactionSession(
|
|
session_id=session_key,
|
|
underlying_session=underlying, # type: ignore[arg-type]
|
|
client=self._client,
|
|
)
|
|
except (ImportError, AttributeError, ValueError) as exc:
|
|
logger.debug(
|
|
"Compaction session setup failed, falling back to plain session: %s",
|
|
exc,
|
|
)
|
|
state = _AgentsSessionState(sdk_session=sdk_session)
|
|
self._session_states[session_key] = state
|
|
return state
|
|
|
|
async def close_session(self, session_key: str) -> None:
|
|
self._session_states.pop(session_key, None)
|
|
|
|
async def interrupt_session(self, session_key: str) -> bool:
|
|
"""
|
|
Halt the in-flight turn for *session_key* and roll the
|
|
SDK session back to its pre-turn state.
|
|
|
|
Cancellation strategy: cancel the per-turn
|
|
``stream_consumer_task`` task (set up in :meth:`run_turn`).
|
|
That task is parked on
|
|
``await self._event_queue.get()`` deep inside the SDK's
|
|
``stream_events`` (agents/result.py:721). Cancelling our
|
|
task lands ``CancelledError`` exactly there — and the
|
|
SDK's own ``except CancelledError`` block (result.py:722-
|
|
725) catches it, calls ``self.cancel()`` on the
|
|
``RunResultStreaming``, and re-raises. ``self.cancel()``
|
|
in turn cancels the SDK's run-loop task, which closes the
|
|
underlying httpx response stream when its async-with
|
|
unwinds — finally stopping the network bytes that were
|
|
producing 1,500+ post-cancel deltas in the original bug.
|
|
|
|
Calling ``active.cancel(mode="immediate")`` on the result
|
|
directly does *not* close the network stream — it only
|
|
sets a flag (``_cancel_mode``) and pushes a sentinel; if
|
|
the consumer is currently awaiting ``queue.get()``,
|
|
``_drain_event_queue`` is skipped and pre-buffered events
|
|
keep flowing. The task-cancel path above is the SDK's
|
|
documented contract for "really" stopping a stream.
|
|
|
|
:param session_key: Key for the per-session state, e.g.
|
|
``"conv_abc123"``. ``"default"`` for un-keyed turns.
|
|
:returns: ``True`` if a turn was in flight and the
|
|
cancel was issued; ``False`` if no active result
|
|
exists for this session.
|
|
"""
|
|
state = self._session_states.get(session_key)
|
|
if state is None or state.active_result is None:
|
|
return False
|
|
state.interrupt_requested = True
|
|
# Cancel the consumer task so CancelledError lands inside
|
|
# the SDK's stream_events()'s queue.get() — see the class
|
|
# docstring above for why this is the *only* path that
|
|
# actually halts the underlying httpx stream.
|
|
consumer = state.stream_consumer_task
|
|
if consumer is not None and not consumer.done():
|
|
consumer.cancel()
|
|
state.resume_state = None
|
|
state.rollback_to_item_count = state.run_item_count_before
|
|
return True
|
|
|
|
async def _rewind_sdk_session(
|
|
self,
|
|
state: _AgentsSessionState,
|
|
target_item_count: int,
|
|
) -> None:
|
|
items = await state.sdk_session.get_items()
|
|
while len(items) > target_item_count:
|
|
await state.sdk_session.pop_item()
|
|
items = await state.sdk_session.get_items()
|
|
|
|
async def _prepare_sdk_session_for_turn(
|
|
self,
|
|
state: _AgentsSessionState,
|
|
messages: list[Message],
|
|
) -> None:
|
|
if state.rollback_to_item_count is not None:
|
|
await self._rewind_sdk_session(state, state.rollback_to_item_count)
|
|
state.rollback_to_item_count = None
|
|
|
|
if state.started and len(split_transient_tail(messages).persisted) < state.history_cursor:
|
|
await self._rewind_sdk_session(state, 0)
|
|
state.started = False
|
|
state.resume_state = None
|
|
state.history_cursor = 0
|
|
|
|
def _build_input_for_turn(
|
|
self,
|
|
state: _AgentsSessionState,
|
|
messages: list[Message],
|
|
*,
|
|
allow_resume_without_new_input: bool,
|
|
) -> str | list[Message] | _RunState:
|
|
if state.resume_state is not None and allow_resume_without_new_input:
|
|
resume_state = state.resume_state
|
|
state.resume_state = None
|
|
resume_state._current_turn = 0
|
|
resume_state._max_turns = 1
|
|
resume_state._current_turn_persisted_item_count = 0
|
|
return resume_state
|
|
|
|
if state.resume_state is not None:
|
|
state.resume_state = None
|
|
|
|
if not state.started:
|
|
return _normalize_responses_items_for_chat(_convert_messages_to_responses(messages))
|
|
|
|
split = split_transient_tail(messages)
|
|
delta_persisted = split.persisted[state.history_cursor :]
|
|
delta_messages = list(delta_persisted) + list(split.transient)
|
|
if not delta_messages:
|
|
return ""
|
|
if len(delta_messages) == 1 and delta_messages[0].get("role") == "user":
|
|
content = delta_messages[0].get("content")
|
|
if content is None:
|
|
return ""
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
# Normalize input_file blocks before handing to the SDK.
|
|
# The openai-agents SDK's chatcmpl_converter passes
|
|
# ``file_data`` directly to the Chat Completions ``file``
|
|
# content type, which expects plain base64 — not the data:
|
|
# URI that content_resolver produces. The Databricks GPT
|
|
# endpoint may not support ``file`` content blocks at all.
|
|
# Converting to ``input_text`` is the universally compatible
|
|
# path: the model sees the file content as plain text.
|
|
normalized = _normalize_content_blocks_for_chat(content)
|
|
return [{"type": "message", "role": "user", "content": normalized}]
|
|
return json.dumps(content)
|
|
return _normalize_responses_items_for_chat(_convert_messages_to_responses(delta_messages))
|
|
|
|
def _build_tools(
|
|
self,
|
|
agents_sdk: _AgentsSDK,
|
|
tools: list[ToolSpec],
|
|
) -> list[SDKTool]:
|
|
sdk_tools: list[SDKTool] = []
|
|
for tool in tools:
|
|
raw_name = tool.get("name")
|
|
# The OpenAI Agents SDK's ``FunctionTool`` requires a
|
|
# non-empty ``name``; drop malformed specs rather than
|
|
# registering an unnamed tool the SDK will reject.
|
|
if not isinstance(raw_name, str) or not raw_name:
|
|
continue
|
|
tool_name: str = raw_name
|
|
raw_desc = tool.get("description")
|
|
tool_desc: str = raw_desc if isinstance(raw_desc, str) else ""
|
|
params = tool.get("parameters", {"type": "object", "properties": {}})
|
|
|
|
async def on_invoke_tool(
|
|
_ctx: ToolResult,
|
|
input_json: str,
|
|
*,
|
|
_tool_name: str = tool_name,
|
|
) -> ToolResult:
|
|
if self._tool_executor is None:
|
|
return {"error": f"No tool executor for '{_tool_name}'"}
|
|
|
|
try:
|
|
args = json.loads(input_json) if input_json else {}
|
|
except (TypeError, json.JSONDecodeError):
|
|
args = {}
|
|
|
|
result = self._tool_executor(_tool_name, args)
|
|
if hasattr(result, "__await__"):
|
|
result = await result # type: ignore[assignment]
|
|
return result
|
|
|
|
sdk_tools.append(
|
|
agents_sdk.FunctionTool(
|
|
name=tool_name,
|
|
description=tool_desc,
|
|
params_json_schema=params,
|
|
on_invoke_tool=on_invoke_tool,
|
|
strict_json_schema=False,
|
|
)
|
|
)
|
|
|
|
return sdk_tools
|
|
|
|
@staticmethod
|
|
def _tool_signature(tools: list[ToolSpec]) -> str:
|
|
normalized = [
|
|
{
|
|
# Signature is used only as a cache key; missing fields
|
|
# are normalised to ``None`` so tool specs that differ
|
|
# only by absent keys still produce distinct signatures
|
|
# from ones that explicitly set empty strings.
|
|
"name": tool.get("name"),
|
|
"description": tool.get("description"),
|
|
"parameters": tool.get("parameters", {"type": "object", "properties": {}}),
|
|
}
|
|
for tool in tools
|
|
]
|
|
return json.dumps(normalized, sort_keys=True, separators=(",", ":"))
|
|
|
|
def _get_or_create_agent(
|
|
self,
|
|
agents_sdk: _AgentsSDK,
|
|
state: _AgentsSessionState,
|
|
*,
|
|
model: str,
|
|
system_prompt: str,
|
|
tools: list[ToolSpec],
|
|
parallel_tool_calls: bool | None,
|
|
reasoning_effort: str | None,
|
|
max_tokens: int | None,
|
|
) -> SDKAgent:
|
|
signature = (
|
|
model,
|
|
system_prompt,
|
|
self._tool_signature(tools),
|
|
json.dumps(parallel_tool_calls),
|
|
reasoning_effort or "",
|
|
json.dumps(max_tokens),
|
|
)
|
|
if state.agent is not None and state.agent_signature == signature:
|
|
return state.agent
|
|
|
|
state.agent = agents_sdk.Agent(
|
|
name="Omnigent",
|
|
instructions=system_prompt or None,
|
|
model=model,
|
|
model_settings=agents_sdk.ModelSettings(
|
|
parallel_tool_calls=parallel_tool_calls,
|
|
max_tokens=max_tokens,
|
|
**_build_reasoning_model_settings(reasoning_effort),
|
|
),
|
|
tools=self._build_tools(agents_sdk, tools),
|
|
tool_use_behavior="run_llm_again",
|
|
)
|
|
state.agent_signature = signature
|
|
return state.agent
|
|
|
|
async def _consume_stream_into_queue(
|
|
self,
|
|
result: _RunResult,
|
|
queue: asyncio.Queue[object],
|
|
) -> None:
|
|
"""
|
|
Pump the SDK's ``stream_events`` into a local queue.
|
|
|
|
Runs as a separate :class:`asyncio.Task` so
|
|
:meth:`interrupt_session` can cancel us while
|
|
:meth:`run_turn` is blocked on the queue. Cancellation
|
|
lands inside ``await self._event_queue.get()`` deep in
|
|
the SDK's ``stream_events`` (agents/result.py:721) — and
|
|
the SDK's own ``except CancelledError`` (result.py:722-
|
|
725) catches it, calls ``self.cancel()`` on the
|
|
``RunResultStreaming`` (which closes the underlying
|
|
httpx response stream by cancelling the run-loop task),
|
|
and re-raises.
|
|
|
|
Without this task indirection, the only way to signal
|
|
the SDK to stop is the public
|
|
``result.cancel(mode="immediate")`` API, which sets a
|
|
flag and pushes a sentinel but does NOT close the
|
|
network connection — events keep streaming for 15+
|
|
seconds.
|
|
|
|
:param result: The SDK's ``RunResultStreaming``
|
|
instance for the in-flight turn.
|
|
:param queue: Per-turn unbounded :class:`asyncio.Queue`
|
|
owned by :meth:`run_turn`. Receives every
|
|
``StreamEvent`` the SDK emits, then a single
|
|
:data:`_STREAM_DONE` sentinel in this method's
|
|
``finally`` (so the consumer sees a terminal even
|
|
on cancellation / exception).
|
|
:raises asyncio.CancelledError: When :meth:`interrupt_session`
|
|
cancels this task. Caller (run_turn) awaits the
|
|
task after the sentinel and expects this on the
|
|
interrupt path.
|
|
"""
|
|
try:
|
|
async for event in result.stream_events():
|
|
queue.put_nowait(event)
|
|
finally:
|
|
# Always push the sentinel — even on CancelledError /
|
|
# exception — so the consumer's ``await queue.get()``
|
|
# unblocks and the run_turn finally can clean up.
|
|
queue.put_nowait(_STREAM_DONE)
|
|
|
|
async def run_turn(
|
|
self,
|
|
messages: list[Message],
|
|
tools: list[ToolSpec],
|
|
system_prompt: str,
|
|
config: ExecutorConfig | None = None,
|
|
) -> AsyncIterator[ExecutorEvent]:
|
|
cfg = config or ExecutorConfig()
|
|
# cfg.model (per-request /model override; agent name no longer
|
|
# leaks here) wins over the spec default
|
|
# (HARNESS_OPENAI_AGENTS_MODEL → self._model_override).
|
|
model = (
|
|
cfg.model
|
|
or self._model_override
|
|
or (
|
|
_DATABRICKS_OPENAI_AGENTS_DEFAULT_MODEL
|
|
if self._databricks
|
|
else _OPENAI_AGENTS_DEFAULT_MODEL
|
|
)
|
|
)
|
|
agents_sdk = cast(_AgentsSDK, _ensure_agents_sdk())
|
|
session_key = self._session_key(messages)
|
|
try:
|
|
reasoning_effort = validate_effort(
|
|
cfg.extra.get("reasoning_effort"), "OpenAI Agents SDK", OPENAI_AGENTS_EFFORTS
|
|
)
|
|
except ValueError as exc:
|
|
yield ExecutorError(message=str(exc), retryable=False)
|
|
return
|
|
|
|
state = self._get_or_create_session_state(agents_sdk, session_key)
|
|
state.interrupt_requested = False
|
|
await self._prepare_sdk_session_for_turn(state, messages)
|
|
stepwise_internal_turns = bool(cfg.extra.get("stepwise_internal_turns"))
|
|
input_value = self._build_input_for_turn(
|
|
state,
|
|
messages,
|
|
allow_resume_without_new_input=(
|
|
stepwise_internal_turns and not bool(cfg.extra.get("new_user_messages_flushed"))
|
|
),
|
|
)
|
|
parallel_tool_calls = cfg.extra.get("parallel_tool_calls", None)
|
|
max_tokens_raw = cfg.extra.get("max_tokens")
|
|
max_tokens = int(max_tokens_raw) if max_tokens_raw is not None else None
|
|
provider = agents_sdk.OpenAIProvider(
|
|
openai_client=self._client,
|
|
use_responses=self._use_responses,
|
|
)
|
|
agent = self._get_or_create_agent(
|
|
agents_sdk,
|
|
state,
|
|
model=model,
|
|
system_prompt=system_prompt,
|
|
tools=tools,
|
|
parallel_tool_calls=parallel_tool_calls,
|
|
reasoning_effort=reasoning_effort,
|
|
max_tokens=max_tokens,
|
|
)
|
|
current_item_count = len(await state.sdk_session.get_items())
|
|
run_config = agents_sdk.RunConfig(
|
|
model=model,
|
|
model_provider=provider,
|
|
tracing_disabled=True,
|
|
reasoning_item_id_policy="omit",
|
|
call_model_input_filter=self._filter_model_input,
|
|
)
|
|
max_turns = 1 if stepwise_internal_turns else int(cfg.extra.get("max_turns", 1000))
|
|
|
|
# ── 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.
|
|
# This mirrors the identical block in ``OpenResponsesExecutor``
|
|
# and ``ClaudeSDKExecutor``.
|
|
_policy_eval = getattr(self, "_policy_evaluator", None)
|
|
if _policy_eval is not None:
|
|
_last_user_msg = ""
|
|
for _msg in reversed(messages):
|
|
if _msg.get("role") == "user":
|
|
_content = _msg.get("content")
|
|
if isinstance(_content, str):
|
|
_last_user_msg = _content[:500]
|
|
elif isinstance(_content, list):
|
|
_parts = [
|
|
b.get("text", "")
|
|
for b in _content
|
|
if isinstance(b, dict) and b.get("type") in ("input_text", "text")
|
|
]
|
|
_last_user_msg = " ".join(_parts)[:500]
|
|
break
|
|
_req_data: dict[str, Any] = {
|
|
"model": model,
|
|
"messages_count": len(messages),
|
|
"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
|
|
|
|
# Run the turn, retrying once if the gateway returns a
|
|
# completed-but-empty turn (see ``_EMPTY_TURN_MAX_ATTEMPTS``).
|
|
# ``_is_empty_turn`` guarantees an empty attempt streamed nothing
|
|
# (no text, no tool calls), so retrying never double-emits — we
|
|
# stream each attempt's events live and only defer the terminal
|
|
# TurnComplete until the emptiness decision after the loop.
|
|
result: _RunResult | None = None
|
|
response_text = ""
|
|
saw_tool_activity = False
|
|
final_text = ""
|
|
for attempt in range(_EMPTY_TURN_MAX_ATTEMPTS):
|
|
response_text = ""
|
|
pending_tools: dict[str, tuple[str, float]] = {}
|
|
saw_tool_activity = False
|
|
result = None
|
|
# Per-turn unbounded queue bridging the stream-pump task
|
|
# (producer) with this generator (consumer). Unbounded so
|
|
# the producer's ``put_nowait`` never blocks — keeping the
|
|
# producer parked on ``stream_events()``'s ``queue.get()``
|
|
# is what makes :meth:`interrupt_session`'s ``task.cancel()``
|
|
# land at the SDK boundary instead of in our queue plumbing.
|
|
event_queue: asyncio.Queue[object] = asyncio.Queue()
|
|
try:
|
|
# Reuse the count already fetched above; avoids a second
|
|
# ``get_items()`` round-trip.
|
|
state.run_item_count_before = current_item_count
|
|
result = cast(
|
|
_RunResult,
|
|
agents_sdk.Runner.run_streamed(
|
|
agent,
|
|
input=input_value,
|
|
session=state.sdk_session,
|
|
max_turns=max_turns,
|
|
run_config=run_config,
|
|
),
|
|
)
|
|
state.active_result = result
|
|
state.stream_consumer_task = asyncio.create_task(
|
|
self._consume_stream_into_queue(result, event_queue),
|
|
name=f"openai-agents-stream-pump:{session_key}",
|
|
)
|
|
while True:
|
|
item = await event_queue.get()
|
|
if item is _STREAM_DONE:
|
|
break
|
|
# Drop everything queued after interrupt, but keep
|
|
# draining so the sentinel can land and the finally
|
|
# block can run cleanly.
|
|
if state.interrupt_requested:
|
|
continue
|
|
|
|
event = cast(_StreamEvent, item)
|
|
if event.type == "raw_response_event":
|
|
raw_event = cast(_RawResponseEvent, event)
|
|
data = raw_event.data
|
|
if data.type == "response.output_text.delta":
|
|
text = data.delta
|
|
if text:
|
|
response_text += text
|
|
yield TextChunk(text=text)
|
|
elif data.type in (
|
|
"response.reasoning_summary_text.delta",
|
|
"response.reasoning_text.delta",
|
|
):
|
|
reasoning_delta = data.delta
|
|
if reasoning_delta:
|
|
yield ReasoningChunk(
|
|
delta=reasoning_delta, event_type="reasoning_text"
|
|
)
|
|
|
|
elif event.type == "run_item_stream_event":
|
|
item_event = cast(_RunItemEvent, event)
|
|
sdk_item = item_event.item
|
|
if sdk_item.type == "tool_call_item":
|
|
parts = _tool_args_from_raw_item(sdk_item.raw_item)
|
|
pending_tools[parts.call_id or parts.name] = (
|
|
parts.name,
|
|
time.monotonic(),
|
|
)
|
|
saw_tool_activity = True
|
|
yield ToolCallRequest(
|
|
name=parts.name,
|
|
args=parts.args,
|
|
metadata={"call_id": parts.call_id} if parts.call_id else {},
|
|
)
|
|
elif sdk_item.type == "tool_call_output_item":
|
|
raw_item = sdk_item.raw_item
|
|
call_id = None
|
|
if isinstance(raw_item, dict):
|
|
raw_dict: ToolArgs = raw_item
|
|
maybe_cid = raw_dict.get("call_id") or raw_dict.get("id")
|
|
call_id = maybe_cid if isinstance(maybe_cid, str) else None
|
|
else:
|
|
out_proto = cast(_ToolCallOutputRawItem, raw_item)
|
|
call_id = out_proto.call_id or out_proto.id
|
|
name = ""
|
|
started: float | None = None
|
|
# ``call_id`` is ``None`` when the SDK didn't
|
|
# surface one; in that case we can't look up
|
|
# a pending tool and emit with defaults.
|
|
if call_id is not None and call_id in pending_tools:
|
|
name, started = pending_tools.pop(call_id)
|
|
duration_ms = ((time.monotonic() - started) * 1000) if started else 0.0
|
|
classification = classify_tool_result(sdk_item.output)
|
|
yield ToolCallComplete(
|
|
name=name,
|
|
status=classification.status,
|
|
result=sdk_item.output,
|
|
error=classification.error,
|
|
duration_ms=duration_ms,
|
|
metadata={"call_id": call_id} if call_id else {},
|
|
)
|
|
|
|
# Producer signalled completion via _STREAM_DONE.
|
|
# Surface any non-cancel exception it raised so the
|
|
# outer try/except below can translate it to the
|
|
# MaxTurnsExceeded / generic-error paths exactly as
|
|
# before — these used to fire from inside the inline
|
|
# ``async for`` loop; now they fire from the producer.
|
|
pump = state.stream_consumer_task
|
|
assert pump is not None and pump.done()
|
|
# On the interrupt path, ``pump.result()`` raises
|
|
# CancelledError — state.interrupt_requested is True,
|
|
# the consumer already drained without emitting, and
|
|
# the rollback was set up by interrupt_session.
|
|
# Nothing more to do here. Non-cancel exceptions from
|
|
# the producer (MaxTurnsExceeded, generic SDK errors)
|
|
# propagate to the outer try/except blocks below.
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
pump.result()
|
|
|
|
state.started = True
|
|
state.resume_state = None
|
|
except agents_sdk.MaxTurnsExceeded:
|
|
if state.interrupt_requested:
|
|
return
|
|
if stepwise_internal_turns and saw_tool_activity and result is not None:
|
|
state.started = True
|
|
state.resume_state = result.to_state()
|
|
state.history_cursor = len(split_transient_tail(messages).persisted)
|
|
yield TurnComplete(continue_turn=True)
|
|
return
|
|
logger.error("OpenAIAgentsSDKExecutor: max turns exceeded")
|
|
yield ExecutorError(message=f"OpenAI Agents SDK exceeded max turns ({max_turns})")
|
|
return
|
|
except Exception as exc: # re-raises context overflow, classifies the rest
|
|
if state.interrupt_requested:
|
|
return
|
|
if _is_context_length_exceeded(exc):
|
|
# Re-raise so the ExecutorAdapter's error classifier
|
|
# maps it to ``context_length_exceeded`` and the
|
|
# runner surfaces the overflow to the user.
|
|
raise
|
|
from .databricks_executor import DatabricksAuthError
|
|
|
|
if isinstance(exc, DatabricksAuthError) or (
|
|
exc.__cause__ is not None and isinstance(exc.__cause__, DatabricksAuthError)
|
|
):
|
|
# When exc IS the DatabricksAuthError, use str(exc) — it
|
|
# carries the actionable "Run: databricks auth login -p X"
|
|
# message. Its __cause__ is the raw SDK exception (e.g.
|
|
# ValueError("token expired")), which is NOT actionable.
|
|
# When exc.__cause__ IS the DatabricksAuthError (exc is a
|
|
# wrapper), str(exc.__cause__) is correct.
|
|
auth_msg = (
|
|
str(exc) if isinstance(exc, DatabricksAuthError) else str(exc.__cause__)
|
|
)
|
|
logger.error("OpenAIAgentsSDKExecutor: auth failed: %s", auth_msg)
|
|
yield ExecutorError(message=auth_msg)
|
|
else:
|
|
logger.error("OpenAIAgentsSDKExecutor: run failed: %s", exc)
|
|
yield ExecutorError(message=f"OpenAI Agents SDK error: {exc}")
|
|
return
|
|
finally:
|
|
# If the outer generator was aclose'd before the
|
|
# drain completed (e.g. ExecutorAdapter returned
|
|
# without going through interrupt_session), the
|
|
# pump task is still alive — cancel it here so it
|
|
# doesn't outlive run_turn.
|
|
pump_task = state.stream_consumer_task
|
|
if pump_task is not None and not pump_task.done():
|
|
pump_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await pump_task
|
|
state.stream_consumer_task = None
|
|
state.active_result = None
|
|
state.run_item_count_before = None
|
|
|
|
if state.interrupt_requested:
|
|
return
|
|
assert result is not None # mypy: try block either assigns or raises/returns
|
|
final_output = result.final_output
|
|
if isinstance(final_output, str):
|
|
final_text = final_output
|
|
else:
|
|
final_text = agents_sdk.ItemHelpers.text_message_outputs(result.new_items)
|
|
if not final_text:
|
|
final_text = response_text
|
|
|
|
if not _is_empty_turn(final_text, saw_tool_activity, result.new_items):
|
|
break # got real output: text, tool activity, or output items
|
|
|
|
# Empty turn. Retry from a clean state if attempts remain;
|
|
# otherwise fall through to the fail-loud gate below.
|
|
if attempt + 1 < _EMPTY_TURN_MAX_ATTEMPTS:
|
|
logger.warning(
|
|
"OpenAIAgentsSDKExecutor: empty completion (attempt %d/%d), retrying",
|
|
attempt + 1,
|
|
_EMPTY_TURN_MAX_ATTEMPTS,
|
|
)
|
|
# Roll the SDK session back to its pre-turn item count so
|
|
# the retry re-runs from an identical state — the empty
|
|
# attempt may have appended a stray empty assistant item.
|
|
await self._rewind_sdk_session(state, current_item_count)
|
|
|
|
# ``final_text`` / ``result`` are from the surfaced (last) attempt.
|
|
# If still empty AND the gateway reported zero output tokens, the
|
|
# turn is a gateway hiccup, not a deliberate empty answer (which
|
|
# still bills output tokens): surface a loud retryable error so
|
|
# the workflow's retry policy can reissue, rather than a silent
|
|
# empty turn.
|
|
#
|
|
# The zero-token condition deliberately narrows fail-loud to the
|
|
# signature we actually observed (status=completed,
|
|
# output=[], 0 output tokens). An empty turn that DID bill tokens
|
|
# falls through to the silent ``TurnComplete("")`` below — exactly
|
|
# today's behavior, so this change never makes a billed-but-empty
|
|
# turn worse. If a billed-but-empty gateway failure shows up later,
|
|
# widen the gate then, with that evidence in hand.
|
|
assert result is not None
|
|
if _is_empty_turn(final_text, saw_tool_activity, result.new_items):
|
|
if _sum_output_tokens(getattr(result, "raw_responses", None)) == 0:
|
|
logger.error(
|
|
"OpenAIAgentsSDKExecutor: empty completion after %d attempts",
|
|
_EMPTY_TURN_MAX_ATTEMPTS,
|
|
)
|
|
yield ExecutorError(
|
|
message=(
|
|
"openai-agents returned an empty completion after "
|
|
f"{_EMPTY_TURN_MAX_ATTEMPTS} attempts"
|
|
),
|
|
retryable=True,
|
|
)
|
|
return
|
|
|
|
if not response_text and final_text:
|
|
yield TextChunk(text=final_text)
|
|
state.history_cursor = len(split_transient_tail(messages).persisted) + 1
|
|
# Aggregate usage across all raw responses for this turn. The SDK
|
|
# accumulates per-request usage on RunResult.raw_responses.
|
|
#
|
|
# ``input_tokens``, ``output_tokens``, and ``total_tokens`` are
|
|
# summed across all sub-turns for billing accuracy.
|
|
#
|
|
# ``context_tokens`` is set to the *last* sub-turn's total only,
|
|
# ``context_tokens`` is the last sub-turn's total (input +
|
|
# output) — the best proxy for how full the context window
|
|
# will be on the NEXT request (prior conversation + the
|
|
# response just generated). For multi-tool turns, summing
|
|
# input_tokens across sub-turns double-counts (each sub-turn
|
|
# re-sends the full history); the last sub-turn's total is
|
|
# stable. Always set so the REPL and compaction don't fall
|
|
# back to total_tokens (which sums across ALL sub-turns).
|
|
turn_usage: dict[str, Any] | None = None
|
|
raw_responses = getattr(result, "raw_responses", None)
|
|
if raw_responses:
|
|
in_tok = sum(getattr(r.usage, "input_tokens", 0) or 0 for r in raw_responses)
|
|
out_tok = sum(getattr(r.usage, "output_tokens", 0) or 0 for r in raw_responses)
|
|
total_tok = sum(getattr(r.usage, "total_tokens", 0) or 0 for r in raw_responses)
|
|
# OpenAI's ``input_tokens`` (aka ``prompt_tokens``) is the
|
|
# TOTAL input count *including* cached tokens, whereas
|
|
# ``compute_llm_cost`` expects Anthropic semantics where
|
|
# ``input_tokens`` is the non-cached portion and
|
|
# ``cache_read_input_tokens`` is additive. Extract cached
|
|
# tokens from ``prompt_tokens_details.cached_tokens`` and
|
|
# subtract so downstream billing uses the cheaper cache rate.
|
|
cached_tok = 0
|
|
for r in raw_responses:
|
|
details = getattr(r.usage, "prompt_tokens_details", None)
|
|
if details is not None:
|
|
cached = getattr(details, "cached_tokens", None)
|
|
if cached is None and isinstance(details, dict):
|
|
cached = details.get("cached_tokens")
|
|
cached_tok += cached or 0
|
|
last_r = raw_responses[-1]
|
|
last_in = getattr(last_r.usage, "input_tokens", 0) or 0
|
|
last_out = getattr(last_r.usage, "output_tokens", 0) or 0
|
|
last_total = getattr(last_r.usage, "total_tokens", 0) or 0
|
|
context_tok = last_total if last_total else last_in + last_out
|
|
if in_tok or out_tok:
|
|
turn_usage = {
|
|
"input_tokens": in_tok - cached_tok, # non-cached portion
|
|
"output_tokens": out_tok,
|
|
"total_tokens": total_tok if total_tok else in_tok + out_tok,
|
|
"context_tokens": context_tok,
|
|
# Harness-reported model for cost pricing when the spec pins no model.
|
|
"model": model,
|
|
}
|
|
if cached_tok:
|
|
turn_usage["cache_read_input_tokens"] = cached_tok
|
|
_notify_usage_from_dict(model=model, usage=turn_usage)
|
|
|
|
# Emit CompactionComplete if the SDK compacted this turn.
|
|
if result is not None:
|
|
for item in result.new_items:
|
|
if getattr(item, "type", None) == "compaction_item":
|
|
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
|
|
_compacted: list[dict[str, Any]] | None = None
|
|
try:
|
|
_compacted = await state.sdk_session.get_items()
|
|
except Exception: # noqa: BLE001
|
|
logger.warning(
|
|
"Failed to read compacted session items",
|
|
exc_info=True,
|
|
)
|
|
yield CompactionComplete(
|
|
summary=(
|
|
"[OpenAI Responses API compaction"
|
|
" — context was automatically compacted]"
|
|
),
|
|
token_count=_compaction_tokens,
|
|
model=model,
|
|
compacted_messages=_compacted,
|
|
)
|
|
break
|
|
|
|
yield TurnComplete(response=final_text, usage=turn_usage)
|
|
|
|
@staticmethod
|
|
def _filter_model_input(data: _CallModelData) -> _ModelInputData:
|
|
# Sanitize so identity fields (long ``id`` strings) are stripped
|
|
# and content blocks are normalized for the chat-completions path.
|
|
sanitized = [_sanitize_replay_item(item) for item in data.model_data.input]
|
|
data.model_data.input = _normalize_responses_items_for_chat(sanitized)
|
|
return data.model_data
|