"""PiExecutor: run agents through the Pi coding agent's RPC mode. Spawns Pi (``pi --mode rpc``) as a subprocess and communicates via a JSONL protocol over stdin/stdout. Pi manages its own agent loop, tool execution, context window, and compaction internally. This executor translates the Pi event stream into Omnigent ExecutorEvents. Omnigent tools are bridged into Pi via a generated JavaScript extension that registers each tool with ``pi.registerTool()``. Tool execution is proxied over a local TCP socket to the Omnigent Python process, so policies, history recording, sub-agents, runtime, and all other Omnigent features work exactly as they do with the Claude SDK and Codex harnesses. When ``os_env`` is set, the Pi subprocess is wrapped in the same sandbox used for other harnesses. Databricks support: a temporary ``models.json`` is generated with three providers (OpenAI Responses for GPT, Anthropic Messages for Claude, and OpenAI Completions for others) and ``PI_CODING_AGENT_DIR`` is set so Pi picks it up. Requirements: The ``pi`` CLI must be installed and on PATH. Environment (Databricks): DATABRICKS_CONFIG_PROFILE — optional Databricks profile selector ~/.databrickscfg — host + token profile for workspace access (or ~/.databrickscfg with a profile containing host + token) """ from __future__ import annotations import asyncio import base64 import contextlib import hmac import json import logging import os import pathlib import secrets import shutil import subprocess import tempfile from asyncio import Queue, Task from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from dataclasses import dataclass, field from typing import Any, TypeAlias from urllib.parse import urlparse as _urlparse from omnigent.inner.native_attachments import parse_data_uri 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.runner.identity import OMNIGENT_SESSION_ENV_VAR from omnigent.spec.types import RetryPolicy from ._subprocess_lifecycle import close_subprocess_transport from .databricks_executor import _read_databrickscfg from .datamodel import OSEnvSandboxSpec, OSEnvSpec from .executor import ( Executor, ExecutorConfig, ExecutorError, ExecutorEvent, Message, ReasoningChunk, TextChunk, ToolCallComplete, ToolCallRequest, ToolCallStatus, ToolSpec, TurnComplete, ) logger = logging.getLogger(__name__) # Each line of Pi's JSONL output; the event schema is owned by the Pi CLI # not us, and varies across subcommands (response ack, message_update, # tool_execution_start/end, agent_end, message_end, etc.). CodexEvent: TypeAlias = dict[str, Any] # type: ignore[explicit-any] def _fetch_shell_command_token(command: str) -> str | None: """Run an auth helper command and return its stdout token. :param command: Shell command that prints a bearer token. :returns: The stripped token, or ``None`` when the command fails or prints no token. """ result = subprocess.run( ["sh", "-c", command], check=False, capture_output=True, text=True, ) token = result.stdout.strip() if result.returncode != 0 or not token: logger.debug("Pi Databricks auth command failed: %s", result.stderr.strip()) return None return token # Tool-server callback provided by ``Session._wire_sdk_executor``. Invoked # with a tool name and argument dict; may return the result dict directly # or a coroutine/future yielding one. ToolExecutor: TypeAlias = Callable[ # type: ignore[explicit-any] [str, dict[str, Any]], Awaitable[dict[str, Any]] | dict[str, Any], ] # Native-tool policy gate wired by :class:`PiExecutor`. Invoked with a native # (non-bridged) tool name + argument dict; returns ``{"block": bool, "reason": # str}``. Pi's native tools (e.g. ``read``, enabled for skill loading) execute # inside the Pi process and never traverse the bridged ``/mcp`` path, so the # ``tool_call`` extension hook routes them here for a TOOL_CALL policy verdict. NativePolicyGate: TypeAlias = Callable[ # type: ignore[explicit-any] [str, dict[str, Any]], Awaitable[dict[str, Any]] | dict[str, Any], ] # Plain JSON value — recursive union used by ``_check_blocked`` to walk # parsed Pi tool-result payloads. JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] # --------------------------------------------------------------------------- # TCP tool server — serves Omnigent tools to the Pi extension # --------------------------------------------------------------------------- def _safe_dumps(response: dict[str, Any], req_id: str | None) -> str: # type: ignore[explicit-any] """Serialize a tool-server response, never raising on bad payloads. Tool callbacks may return values ``json.dumps`` can't encode (a ``datetime``, ``set``, ``bytes``, custom object, ...). Encoding happens on the response path *outside* :meth:`_ToolServer._execute`'s try, so an unguarded ``json.dumps`` would propagate and the connection would close with zero bytes written — leaving the JS ``callTool`` promise pending and hanging the entire Pi turn. Mirrors codex's ``_result_text`` guard: on a serialization failure, fall back to an ``{"error": ...}`` envelope so the client always receives a valid frame. :param response: The response dict to serialize. :param req_id: The originating request id, echoed back on the error envelope so the client correlates the failure with its call. :returns: A compact JSON string (no trailing newline). """ try: return json.dumps(response, separators=(",", ":")) except (TypeError, ValueError) as exc: # Stringify ``req_id`` so the fallback envelope itself can never raise # on a non-serializable id (the caller passes a ``str`` today, but the # guard must hold for any future caller). ``None`` stays ``None`` so the # client still sees a null id rather than the literal "None". safe_id: str | None = req_id if req_id is None or isinstance(req_id, str) else str(req_id) return json.dumps( {"id": safe_id, "error": f"unserializable tool result: {exc}"}, separators=(",", ":"), ) class _ToolServer: """Async TCP server that handles tool-call requests from the Pi extension. Protocol (JSONL over TCP): Request: {"id":"...","token":"...","tool":"tool_name","args":{...}} Response: {"id":"...","result":{...}} or {"id":"...","error":"..."} The loopback socket is reachable by any co-located process, so every request must carry :attr:`token` (a per-server secret embedded only in the generated Pi extension); frames with a missing or wrong token are rejected before dispatch. """ def __init__(self) -> None: self._server: asyncio.Server | None = None self.port: int = 0 self._tool_executor: ToolExecutor | None = None # Policy gate for native (non-bridged) Pi tool calls, set by # :meth:`PiExecutor._ensure_tool_server`. ``None`` (single-process # / test paths) means native tool calls are not gated. self._policy_gate: NativePolicyGate | None = None # Per-server bearer token required on every request. Minted at # construction (never None) so auth is always enforced. self.token: str = secrets.token_urlsafe(32) async def start(self) -> int: """Start listening on a random port. Returns the port number.""" self._server = await asyncio.start_server( self._handle_client, "127.0.0.1", 0, ) addr = self._server.sockets[0].getsockname() self.port = addr[1] logger.debug("PiExecutor tool server listening on port %d", self.port) return self.port async def stop(self) -> None: if self._server is not None: self._server.close() await self._server.wait_closed() self._server = None async def _handle_client( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, ) -> None: try: while True: raw = await reader.readline() if not raw: break line = raw.decode("utf-8", errors="replace").strip() if not line: continue try: request = json.loads(line) except json.JSONDecodeError: continue # Authenticate before any dispatch; close the connection on # failure (unlike the malformed-frame ``continue`` below). if not self._token_ok(request.get("token")): writer.write( json.dumps( {"id": request.get("id"), "error": "unauthorized"}, separators=(",", ":"), ).encode("utf-8") + b"\n" ) await writer.drain() break raw_req_id = request.get("id") raw_tool_name = request.get("tool") # The Pi extension always supplies ``id`` and ``tool`` # on a tool request. Drop malformed frames rather than # executing under an empty-string tool name. if not isinstance(raw_req_id, str) or not isinstance(raw_tool_name, str): continue tool_args = request.get("args", {}) if request.get("kind") == "policy_eval": # The ``tool_call`` extension hook asks for a TOOL_CALL # verdict on a native (non-bridged) tool — evaluate only, # never execute (Pi runs the tool itself on ALLOW). verdict = await self._evaluate_policy(raw_tool_name, tool_args) response = {"id": raw_req_id, "verdict": verdict} else: response = await self._execute(raw_tool_name, tool_args) response["id"] = raw_req_id # Serialize defensively: a tool result may carry a value # ``json.dumps`` can't encode (e.g. ``datetime``/``set``). # If it raises here — outside ``_execute``'s try — the frame # is never written and the JS ``callTool`` promise hangs the # whole turn (no ``data``/``error`` event). Always emit a JSON # frame: a serializable error envelope when the response can't # be encoded, mirroring codex's ``_result_text`` guard. out = _safe_dumps(response, raw_req_id) + "\n" writer.write(out.encode("utf-8")) await writer.drain() except (asyncio.CancelledError, ConnectionError): pass finally: writer.close() def _token_ok(self, presented: JsonValue) -> bool: """ Validate a request's ``token`` against this server's secret. :param presented: The ``token`` field from the request JSON — a ``str`` when authenticated, any JSON type (or ``None``) on a malformed/forged frame, hence the ``isinstance`` guard. :returns: ``True`` only when *presented* is a string equal to :attr:`token`, compared in constant time. """ return isinstance(presented, str) and hmac.compare_digest(presented, self.token) async def _execute( # type: ignore[explicit-any] self, name: str, args: dict[str, Any], ) -> dict[str, Any]: if self._tool_executor is None: return {"error": f"No tool executor for '{name}'"} try: raw = self._tool_executor(name, args) resolved = await raw if asyncio.iscoroutine(raw) or asyncio.isfuture(raw) else raw if not isinstance(resolved, dict): resolved = {"result": resolved} return {"result": resolved} except Exception as exc: # noqa: BLE001 — tool errors are surfaced via the JSON response envelope return {"error": str(exc)} async def _evaluate_policy( # type: ignore[explicit-any] self, name: str, args: dict[str, Any], ) -> dict[str, Any]: """Evaluate a native (non-bridged) tool call against TOOL_CALL policy. Routes through the :attr:`_policy_gate` wired by :meth:`PiExecutor._ensure_tool_server`. Returns ``{"block": bool, "reason": str}``. Fail-open (allow) when no gate is wired or the gate raises: this mirrors the runner/scaffold policy-evaluation contract, which also defaults to ALLOW on a stalled or failed verdict so a transient Omnigent outage can't wedge the agent mid-turn. """ if self._policy_gate is None: return {"block": False, "reason": ""} try: raw = self._policy_gate(name, args) resolved = await raw if asyncio.iscoroutine(raw) or asyncio.isfuture(raw) else raw if isinstance(resolved, dict): return { "block": bool(resolved.get("block")), "reason": str(resolved.get("reason") or ""), } return {"block": False, "reason": ""} except Exception as exc: # noqa: BLE001 — fail-open; the verdict path must never wedge Pi logger.warning("Pi native-tool policy eval failed for %r: %s", name, exc) return {"block": False, "reason": ""} # --------------------------------------------------------------------------- # Pi extension generator — bridges Omnigent tools into Pi # --------------------------------------------------------------------------- def _sanitize_schema(schema: ToolSpec) -> ToolSpec: """Strip JSON Schema features unsupported by the OpenAI Responses/Completions APIs. Removes ``anyOf``, ``oneOf``, ``allOf``, ``examples``, ``default``, ``additionalProperties``, and ``$ref``. Union keywords are collapsed to the first ``object`` branch when one exists (it carries the structured properties the model needs), else the first typed branch. This ensures the schema is accepted by Databricks serving endpoints. """ if not isinstance(schema, dict): return schema result: ToolSpec = {} for key, value in schema.items(): if key in ("examples", "default", "$ref", "additionalProperties"): continue if key in ("anyOf", "oneOf", "allOf"): # Prefer the object branch: collapsing to a primitive drops the properties. if isinstance(value, list) and value: typed = [c for c in value if isinstance(c, dict) and "type" in c] for candidate in typed: if candidate["type"] == "object": return _sanitize_schema(candidate) if typed: return _sanitize_schema(typed[0]) continue if key == "properties" and isinstance(value, dict): result[key] = {k: _sanitize_schema(v) for k, v in value.items()} elif key == "items" and isinstance(value, dict): result[key] = _sanitize_schema(value) else: result[key] = value return result def _generate_extension_js(port: int, tool_schemas: list[ToolSpec], token: str) -> str: """Generate a JavaScript Pi extension that registers Omnigent tools. Each tool is forwarded to the TCP tool server at ``127.0.0.1:``. :param port: TCP port the Omnigent tool server is listening on, e.g. ``54321``. :param tool_schemas: Omnigent tool schemas to register with Pi. :param token: The tool server's bearer token (:attr:`_ToolServer.token`), embedded in the extension and sent on every request so the server can authenticate this Pi process. """ # Build tool descriptors for the JS code. ``ToolSpec`` is the # same JSON-shaped ``dict[str, Any]`` we consume. descriptors: list[ToolSpec] = [] for s in tool_schemas: raw_name = s.get("name") # Pi requires a concrete name on each registered tool; skip # malformed entries rather than registering an unnamed tool. if not isinstance(raw_name, str) or not raw_name: continue raw_desc = s.get("description") # Description is optional; Pi's JS bridge expects ``str`` on # both ``description`` and ``promptSnippet``. desc: str = raw_desc if isinstance(raw_desc, str) else "" descriptors.append( { "name": raw_name, "description": desc, "promptSnippet": desc[:120], "parameters": _sanitize_schema( s.get("parameters", {"type": "object", "properties": {}}) ), } ) tools_json = json.dumps(descriptors, indent=2) # json.dumps so the secret is correctly JS-string-escaped. token_json = json.dumps(token) return f"""\ // Auto-generated Omnigent tool bridge extension for Pi. // Connects to the Omnigent TCP tool server on port {port}. const net = require("net"); const TOOLS = {tools_json}; const BRIDGED = new Set(TOOLS.map((t) => t.name)); const PORT = {port}; const TOKEN = {token_json}; /** Send a tool call request over TCP and return the result. */ function callTool(toolName, args) {{ return new Promise((resolve) => {{ // Idempotent settle: a tool call must resolve exactly once. Route every // resolve through finish() so a late "close" after a real "data" response // can't clobber the result, and a bare close with no data still resolves // (rather than hanging Pi's agent loop forever). let settled = false; const finish = (v) => {{ if (!settled) {{ settled = true; resolve(v); }} }}; const errorResult = (text) => ({{ content: [{{ type: "text", text: JSON.stringify({{ error: text }}) }}], isError: true }}); const client = net.createConnection({{ port: PORT, host: "127.0.0.1" }}, () => {{ const id = Math.random().toString(36).slice(2); const req = JSON.stringify({{ id, token: TOKEN, tool: toolName, args }}) + "\\n"; let buf = ""; client.on("data", (chunk) => {{ buf += chunk.toString(); const nl = buf.indexOf("\\n"); if (nl !== -1) {{ try {{ const resp = JSON.parse(buf.slice(0, nl)); client.end(); if (resp.error) {{ finish(errorResult(resp.error)); }} else {{ const text = typeof resp.result === "string" ? resp.result : JSON.stringify(resp.result); const isError = resp.result && (resp.result.error || resp.result.blocked); finish({{ content: [{{ type: "text", text }}], isError: !!isError }}); }} }} catch (e) {{ client.end(); finish(errorResult(e.message)); }} }} }}); client.on("error", (err) => {{ finish(errorResult(err.message)); }}); // A clean FIN with no bytes (e.g. the server failed to serialize the // result and closed) emits neither "data" nor "error"; without this // the promise would hang forever. finish() is a no-op if already settled. client.on("close", () => {{ finish(errorResult("tool server closed connection without a response")); }}); client.write(req); }}); client.on("error", (err) => {{ finish(errorResult(err.message)); }}); }}); }} /** * Ask the Omnigent tool server for a TOOL_CALL policy verdict on a native * (non-bridged) Pi tool. Resolves to the verdict object {{ block, reason }} * or null. Fail-open (null) on any transport error: a wedged native tool * would break Pi worse than a missed gate, and bridged tools stay gated * server-side regardless. */ function evalNativePolicy(toolName, args) {{ return new Promise((resolve) => {{ const client = net.createConnection({{ port: PORT, host: "127.0.0.1" }}, () => {{ const id = Math.random().toString(36).slice(2); const frame = {{ id, token: TOKEN, kind: "policy_eval", tool: toolName, args }}; const req = JSON.stringify(frame) + "\\n"; let buf = ""; client.on("data", (chunk) => {{ buf += chunk.toString(); const nl = buf.indexOf("\\n"); if (nl !== -1) {{ client.end(); try {{ const resp = JSON.parse(buf.slice(0, nl)); resolve(resp && resp.verdict ? resp.verdict : null); }} catch (e) {{ resolve(null); }} }} }}); client.on("error", () => resolve(null)); client.write(req); }}); client.on("error", () => resolve(null)); }}); }} module.exports = function(pi) {{ // Gate native (non-bridged) tool calls through Omnigent policy. Pi's // native tools (e.g. ``read``, enabled for skill loading) run in-process // and never traverse the bridged /mcp path, so without this hook they // escape all guardrails. Bridged tools ARE evaluated server-side at /mcp, // so skip them here to avoid double-evaluation (and double ASK prompts). pi.on("tool_call", async (event) => {{ if (!event || typeof event.toolName !== "string") return; if (BRIDGED.has(event.toolName)) return; const verdict = await evalNativePolicy(event.toolName, event.input || {{}}); if (verdict && verdict.block) {{ return {{ block: true, reason: verdict.reason || "blocked by Omnigent policy" }}; }} }}); for (const tool of TOOLS) {{ // Pi passes tool.parameters directly to the LLM as JSON Schema, so we // can use the Omnigent schema as-is without TypeBox conversion. pi.registerTool({{ name: tool.name, label: tool.name, description: tool.description, promptSnippet: tool.promptSnippet || tool.description, parameters: tool.parameters || {{ type: "object", properties: {{}} }}, async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {{ return callTool(tool.name, _params); }}, }}); }} }}; """ # --------------------------------------------------------------------------- # Credential helpers (shared pattern with codex_executor / claude_sdk_executor) # --------------------------------------------------------------------------- def _find_pi_cli() -> str | None: """Find the ``pi`` CLI on PATH.""" return shutil.which("pi") # --------------------------------------------------------------------------- # Databricks models.json generation # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Databricks model definitions for Pi's models.json # --------------------------------------------------------------------------- # Databricks exposes API styles at different URL paths. ucode state can # override these provider URLs; the host-derived defaults remain for legacy # profile-only usage. # Each static entry declares ``input: ["text", "image"]`` for the same reason # the dynamic-registration path does (see _build_models_json): Pi's # transformMessages strips image blocks unless the model entry advertises # image input. These are all vision-capable models, and the run model is often # a static id — in which case _build_models_json's append is skipped, so the # capability has to be declared here too or attached images are silently # dropped (#515/#516). _DATABRICKS_RESPONSES_MODELS = [ { "id": "databricks-gpt-5-4-mini", "name": "GPT-5.4 Mini", "contextWindow": 1047576, "maxTokens": 32768, "input": ["text", "image"], }, { "id": "databricks-gpt-5-4", "name": "GPT-5.4", "contextWindow": 1047576, "maxTokens": 32768, "input": ["text", "image"], }, { "id": "databricks-gpt-5-5", "name": "GPT-5.5", # OSS profile endpoint metadata: 400K total context, 128K max output. "contextWindow": 400000, "maxTokens": 128000, "input": ["text", "image"], }, { "id": "databricks-gpt-5-5-pro", "name": "GPT-5.5 Pro", # OSS profile endpoint metadata: 400K total context, 128K max output. "contextWindow": 400000, "maxTokens": 128000, "input": ["text", "image"], }, ] _DATABRICKS_ANTHROPIC_MODELS = [ { "id": "databricks-claude-opus-4-8", "name": "Claude Opus 4.8", # Gateway-verified caps: >1000000 input rejects, 128001+ output rejects. "contextWindow": 1000000, "maxTokens": 128000, "input": ["text", "image"], }, { "id": "databricks-claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "contextWindow": 1000000, "maxTokens": 128000, "input": ["text", "image"], }, { "id": "databricks-claude-sonnet-4-5", "name": "Claude Sonnet 4.5", # Gateway rejects this model past ~200k input. "contextWindow": 200000, "maxTokens": 16384, "input": ["text", "image"], }, ] # Empty: the only listed endpoint (meta-llama-3.3-70b) no longer exists on # the gateway. The provider stays so non-Claude/GPT ids keep a routing home. _DATABRICKS_COMPLETIONS_MODELS: list[dict[str, str | int]] = [] # Prefix-matched env var names allowed into the Pi subprocess. Only # known-safe categories pass: Pi's own config knobs, proxy settings, # TLS trust overrides, Node.js runtime knobs (Pi is a Node CLI), and # locale. Credential families (``DATABRICKS_*``, ``AWS_*``, LLM # provider API keys, ...) deliberately do NOT match. _PI_ENV_ALLOW_PREFIXES: tuple[str, ...] = ( "PI_", "HTTP_", "HTTPS_", "ALL_PROXY", "NO_PROXY", "SSL_", "NODE_", "XDG_", "LANG", "LC_", ) # Exact-matched env var names allowed into the Pi subprocess: the # minimal set a POSIX CLI reasonably expects. _PI_ENV_ALLOW_EXACT: frozenset[str] = frozenset( { "HOME", "PATH", "TERM", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME", "SHELL", "TZ", OMNIGENT_SESSION_ENV_VAR, # "inside Omnigent" marker (CLAUDE_CODE/CODEX analog) } ) _STREAM_READ_CHUNK_SIZE = 65536 # CLI flags whose values are sensitive (e.g. the full system prompt) and must # not be written to logs verbatim. The value following these flags is replaced # with a length-only placeholder. _REDACTED_ARGV_FLAGS = frozenset({"--append-system-prompt", "--system-prompt"}) def _redact_argv_for_log(args: Sequence[str]) -> list[str]: """ Return a copy of ``args`` with sensitive flag values redacted for logging. The system prompt value (e.g. passed via ``--append-system-prompt``) is replaced with a ``[system prompt N chars]`` placeholder so it never leaks into debug logs. Two argv forms are handled: * the two-token form ``--append-system-prompt `` (what the current spawn code emits), and * the equals-joined form ``--append-system-prompt=`` (not emitted today, but redacted defensively in case a future refactor switches to it). All other tokens are preserved so the command remains useful for debugging. """ redacted: list[str] = [] redact_next = False for arg in args: if redact_next: redacted.append(f"[system prompt {len(arg)} chars]") redact_next = False continue if arg in _REDACTED_ARGV_FLAGS: # Two-token form: redact the following value token. redacted.append(arg) redact_next = True continue flag, sep, value = arg.partition("=") if sep and flag in _REDACTED_ARGV_FLAGS: # Equals-joined form: redact the inline value, keep the flag name. redacted.append(f"{flag}=[system prompt {len(value)} chars]") continue redacted.append(arg) return redacted def _build_models_json( host: str, token: str, base_urls: dict[str, str] | None = None, model: str | None = None, ) -> dict[str, Any]: # type: ignore[explicit-any] # Pi's models.json mixes str/int/bool/list/dict across provider configs; # see _DATABRICKS_*_MODELS and the compat/authHeader shapes below. The # schema is owned by the Pi CLI and not worth a TypedDict tree here. """Build a Pi ``models.json`` with three gateway providers. Each provider targets a different API gateway path and wire format so the correct protocol is used for each model family. The static model lists cover the known Databricks-gateway ids; *model* additionally registers the resolved run model so a gateway model outside those lists (an OpenRouter/LiteLLM id like ``moonshotai/kimi-k2.6``, or a Databricks id newer than the static list) resolves instead of Pi failing with "Model not found" — Pi only accepts ``provider/`` selectors whose id is registered under that provider. :param host: Databricks workspace URL used for legacy profile-only defaults. :param token: Bearer token to write into Pi's provider entries. :param base_urls: Optional provider base URLs keyed by model family, e.g. ``{"claude": "...", "openai": "..."}`` — from ucode state or a generic (OpenRouter/LiteLLM) provider entry. :param model: The resolved model id this run will select, e.g. ``"moonshotai/kimi-k2.6"``; registered (bare ``{"id": ...}``, the same shape ucode writes) under the provider :func:`_pi_provider_for_model` routes it to when absent from the static list. ``None`` skips registration (Pi picks its default). :returns: Pi ``models.json`` contents. """ h = host.rstrip("/") serving_endpoints_url = f"{h}/serving-endpoints" raw_openai_base_url = (base_urls or {}).get("openai") # ucode's ``openai`` gateway is the Codex Responses gateway # (``.../ai-gateway/codex/v1``), which 404s pi's openai-completions # ``/chat/completions`` POST. Re-route only that case to serving-endpoints; # generic providers (no ``/ai-gateway/codex``) pass through. Gemini rides # this path via databricks-completions since pi can't speak generateContent. if raw_openai_base_url and "/ai-gateway/codex" in raw_openai_base_url: openai_base_url = serving_endpoints_url else: openai_base_url = raw_openai_base_url or serving_endpoints_url claude_base_url = (base_urls or {}).get("claude") or f"{h}/serving-endpoints/anthropic" config: dict[str, Any] = { # type: ignore[explicit-any] # Pi-owned schema, see note above "providers": { # GPT models → OpenAI Chat Completions API. # We use completions (not responses) because the Databricks # Responses API rejects tool-result chaining on subsequent turns. # The ``compat`` settings ensure Pi uses ``system`` role (not # ``developer``) and avoids other OpenAI-specific features that # Databricks doesn't support. "databricks": { "baseUrl": openai_base_url, "apiKey": token, "api": "openai-completions", "compat": { "supportsDeveloperRole": False, "supportsStore": False, "supportsStrictMode": False, "supportsReasoningEffort": False, }, "models": _DATABRICKS_RESPONSES_MODELS, }, # Claude models → Anthropic Messages API. # ``authHeader`` sends ``Authorization: Bearer `` instead # of the default Anthropic ``x-api-key`` header. "databricks-anthropic": { "baseUrl": claude_base_url, "apiKey": token, "api": "anthropic-messages", "authHeader": True, "models": _DATABRICKS_ANTHROPIC_MODELS, }, # Everything else (Llama, etc.) → same endpoint, same API "databricks-completions": { "baseUrl": openai_base_url, "apiKey": token, "api": "openai-completions", "compat": { "supportsDeveloperRole": False, "supportsStore": False, "supportsStrictMode": False, "supportsReasoningEffort": False, }, "models": _DATABRICKS_COMPLETIONS_MODELS, }, }, } if model is not None: provider = config["providers"][_pi_provider_for_model(model)] if not any(entry.get("id") == model for entry in provider["models"]): # Rebind (don't append): the static lists are module-level # constants shared across builds, so in-place mutation would # leak this run's model id into every later models.json. # Declare image input: Pi's transformMessages drops every image # block ("model does not support images") unless the model entry # advertises it, so a dynamically-registered vision model would # silently lose its images (#515). We assert capability for ALL # dynamically-routed ids (no per-model vision metadata here): for a # genuinely text-only model this turns a silent image drop into a # provider-side 400 on image turns — a deliberate trade (loud error # over silent loss), since most current gateway models are # multimodal and text-only turns are unaffected. provider["models"] = [ *provider["models"], {"id": model, "input": ["text", "image"]}, ] return config def _pi_provider_for_model(model: str) -> str: """Return the Pi provider name to use for a given Databricks model.""" lower = model.lower() if "claude" in lower: return "databricks-anthropic" if "gpt" in lower: return "databricks" return "databricks-completions" async def _create_subprocess_exec(*args: Any, **kwargs: Any) -> asyncio.subprocess.Process: # type: ignore[explicit-any] """ Indirection point for ``asyncio.create_subprocess_exec``. Exists so tests can stub subprocess creation without patching ``asyncio.create_subprocess_exec`` globally (patching ``omnigent.inner.pi_executor.asyncio.create_subprocess_exec`` walks the dotted path into the real ``asyncio`` module singleton and leaks the mock into every other test in the process). :param args: Positional argv components forwarded to ``asyncio.create_subprocess_exec``. :param kwargs: Keyword args (``stdin``, ``stdout``, ``stderr``, ``env``, ``cwd``, ...) forwarded as-is. :returns: The spawned subprocess handle. """ return await asyncio.create_subprocess_exec(*args, **kwargs) def _clean_pi_env(extra_allowed: Sequence[str] | None = None) -> dict[str, str]: """ Build a filtered copy of ``os.environ`` for the Pi subprocess. Deny-by-default allowlist mirroring the codex path's :func:`omnigent.inner.codex_executor._clean_codex_env`. The previous additive ``{**os.environ, **env}`` merge leaked every host secret — cloud tokens, API keys — into the Pi process, sandboxed or not. Credential families are deliberately not allowlisted: gateway runs authenticate through the generated ``models.json``, and a spec that legitimately needs a variable inside the Pi process opts in via ``os_env.sandbox.env_passthrough``. :param extra_allowed: Extra exact names to pass through, e.g. the spec's ``os_env.sandbox.env_passthrough`` entries (``["ANTHROPIC_API_KEY"]`` for an env-key-authenticated non-gateway run). ``None`` means no extras. :returns: Filtered environment dict, ready to extend with the executor's own variables (``PI_CODING_AGENT_DIR``, ...). """ allow_exact = set(_PI_ENV_ALLOW_EXACT) if extra_allowed is not None: allow_exact.update(extra_allowed) return { key: value for key, value in os.environ.items() if key in allow_exact or key.startswith(_PI_ENV_ALLOW_PREFIXES) } # --------------------------------------------------------------------------- # Pi RPC subprocess wrapper # --------------------------------------------------------------------------- @dataclass class _PiRpcSession: """Manages a single Pi subprocess in RPC mode.""" process: asyncio.subprocess.Process | None = None _read_task: Task[None] | None = None _stderr_task: Task[None] | None = None # Non-optional: initialized eagerly so the reader/writer paths don't # need to None-narrow on every access. ``None`` sentinel values are # pushed onto the queue to signal EOF to ``read_line``. _line_queue: Queue[str | None] = field(default_factory=Queue) _stderr_lines: list[str] = field(default_factory=list) _tmp_dir: str | None = None async def start( self, pi_path: str, *, env: dict[str, str], cwd: str | None = None, model: str | None = None, system_prompt: str | None = None, extra_args: list[str] | None = None, ) -> None: """ Spawn the Pi subprocess in RPC mode and start the I/O readers. :param pi_path: Path to spawn — the ``pi`` binary or the sandbox launcher script wrapping it. :param env: The COMPLETE subprocess environment. Deliberately not merged with ``os.environ`` — the caller builds it from the :func:`_clean_pi_env` allowlist so host/server secrets (e.g. ``DATABRICKS_TOKEN``) never leak into the (possibly sandboxed) Pi process. :param cwd: Working directory for the subprocess, or ``None`` to inherit the parent's. :param model: Pi model selector, e.g. ``"databricks-anthropic/databricks-claude-sonnet-4-6"``. ``None`` lets Pi pick its default. :param system_prompt: Text appended to Pi's default system prompt via ``--append-system-prompt``. ``None`` skips it. :param extra_args: Extra CLI tokens (``--extension``, ``--tools``, ...). ``None`` appends nothing. """ args = [pi_path, "--mode", "rpc", "--no-session"] if model: pi_coding_agent_dir = env.get("PI_CODING_AGENT_DIR") args.extend( [ "--model", f"databricks/{model}" if pi_coding_agent_dir is not None and "databricks" in pi_coding_agent_dir else model, ] ) if system_prompt: # Use --append-system-prompt instead of --system-prompt so Pi # keeps its default prompt (which includes tool descriptions from # promptSnippet and guidelines). Using --system-prompt would # replace the default prompt entirely, stripping tool awareness. args.extend(["--append-system-prompt", system_prompt]) if extra_args: args.extend(extra_args) logger.debug("PiExecutor: spawning %s", " ".join(_redact_argv_for_log(args))) self.process = await _create_subprocess_exec( *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd, env=env, ) self._read_task = asyncio.create_task(self._reader()) self._stderr_task = asyncio.create_task(self._stderr_reader()) async def _reader(self) -> None: """Background task: read lines from Pi stdout and enqueue them.""" assert self.process is not None and self.process.stdout is not None try: async for raw_line in self._iter_stream_lines(self.process.stdout): line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r") if line: self._line_queue.put_nowait(line) except asyncio.CancelledError: return except Exception as exc: # noqa: BLE001 — reader loop logs and exits on any unexpected error logger.debug("PiExecutor reader error: %s", exc) finally: self._line_queue.put_nowait(None) async def _stderr_reader(self) -> None: """Drain stderr in the background.""" if self.process is None or self.process.stderr is None: return try: async for raw_line in self._iter_stream_lines(self.process.stderr): text = raw_line.decode("utf-8", errors="replace").rstrip("\n\r") if text: logger.debug("pi stderr: %s", text) if len(self._stderr_lines) < 50: self._stderr_lines.append(text) except (asyncio.CancelledError, Exception): # noqa: BLE001 — stderr drainer is best-effort; never crashes pass @staticmethod async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]: while True: chunk = await stream.read(_STREAM_READ_CHUNK_SIZE) if not chunk: break yield chunk @staticmethod async def _iter_stream_lines(stream: asyncio.StreamReader) -> AsyncIterator[bytes]: buffer = bytearray() async for chunk in _PiRpcSession._iter_stream_chunks(stream): buffer.extend(chunk) while True: newline_index = buffer.find(b"\n") if newline_index < 0: break line = bytes(buffer[: newline_index + 1]) del buffer[: newline_index + 1] yield line if buffer: yield bytes(buffer) async def send_command(self, command: CodexEvent) -> None: """Send a JSONL command to Pi's stdin.""" if self.process is None or self.process.stdin is None: raise RuntimeError("Pi process not running") line = json.dumps(command, separators=(",", ":")) + "\n" self.process.stdin.write(line.encode("utf-8")) await self.process.stdin.drain() async def read_line(self, timeout: float = 120.0) -> str | None: """Read the next JSONL line from Pi's stdout. Returns None on EOF.""" try: return await asyncio.wait_for(self._line_queue.get(), timeout=timeout) except asyncio.TimeoutError: return None async def close(self) -> None: for task in (self._read_task, self._stderr_task): if task is not None: task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await task self._read_task = None self._stderr_task = None if self.process is not None: with contextlib.suppress(ProcessLookupError): self.process.terminate() try: await asyncio.wait_for(self.process.wait(), timeout=2.0) except (asyncio.TimeoutError, ProcessLookupError, RuntimeError): # RuntimeError can happen when the subprocess was created on a # different event loop (e.g. test fixtures that call close() in # a fresh loop). Fall back to synchronous kill. with contextlib.suppress(ProcessLookupError): self.process.kill() close_subprocess_transport(self.process) self.process = None if self._tmp_dir is not None: shutil.rmtree(self._tmp_dir, ignore_errors=True) self._tmp_dir = None # --------------------------------------------------------------------------- # PiExecutor # --------------------------------------------------------------------------- @dataclass class _PiSessionState: rpc: _PiRpcSession | None = None system_prompt: str | None = None model: str | None = None _has_sent_prompt: bool = False @dataclass(frozen=True) class BlockedCheck: """Result of inspecting a Pi tool result for a policy-blocked payload. :param blocked: ``True`` when the result is (or wraps) a ``{"blocked": true, "reason": "..."}`` dict. :param reason: The human-readable block reason when ``blocked`` is ``True``; an empty string otherwise. """ blocked: bool reason: str @dataclass(frozen=True) class PiSubprocessConfig: """Materialized environment + CLI args for a Pi subprocess. :param env: The complete environment for the Pi process — the :func:`_clean_pi_env` allowlist base, plus ``PI_CODING_AGENT_DIR`` when Databricks model routing is in use. :param tmp_dir: Temp directory that owns any ``models.json`` / ``omnigent_tools.js`` files generated for this subprocess. The caller is responsible for cleaning it up on shutdown. :param extra_args: Extra CLI arguments to append to the Pi invocation, e.g. ``["--extension", ".../omnigent_tools.js"]``. """ env: dict[str, str] tmp_dir: str extra_args: list[str] def _extract_text(msg: Message) -> str: """ Extract text content from a message dict. :param msg: A conversation message dict. :returns: Plain text content of the message. """ content = msg.get("content") if content is None: return "" if isinstance(content, str): return content if isinstance(content, list): parts: list[str] = [] for part in content: if not isinstance(part, dict): continue text = part.get("text") if isinstance(text, str): parts.append(text) return " ".join(parts) return str(content) def _extract_latest_user_content( messages: list[Message], ) -> str | list[dict[str, Any]]: """ Extract the latest user message content. Returns a plain string for text-only messages. When the message carries multimodal content blocks, returns the block list so the caller can pass structured input to Pi. :param messages: Conversation history. :returns: A string prompt or a list of 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 content return str(content) return "" def _split_pi_prompt(blocks: list[dict[str, Any]]) -> tuple[str, list[dict[str, str]]]: """Split content blocks into Pi's prompt ``message`` text and ``images``. Pi's RPC ``prompt`` command carries text in ``message`` and images in a native ``images`` field (``ImageContent = {type, data, mimeType}``), which the ``openai-completions`` provider converts to ``image_url`` blocks. JSON-encoding the blocks into ``message`` instead (the previous behavior) makes Pi forward the image data URI as literal text, so the model never sees the image (#515). :param blocks: Responses-API content block dicts. ``input_text`` → message text; ``input_image`` → Pi ``images``; ``input_file`` (a resolved attachment data URI) → its text payload is decoded into the message for text-like MIME types and skipped (with a warning) for binary ones, mirroring the Codex harness — Pi has no native file channel, but a text file's content is still useful inline, and the old ``json.dumps(prompt)`` path surfaced it as text, so neither a silent drop nor a hard failure is the right default here. A genuinely unknown block type raises ``ValueError`` (→ ``ExecutorError`` in ``run_turn``) so a new shape fails loudly rather than vanishing. :returns: ``(message, images)`` — joined text and a list of Pi ``ImageContent`` dicts. :raises ValueError: On a malformed ``input_image`` or an unhandled block type. """ text_parts: list[str] = [] images: list[dict[str, str]] = [] for block in blocks: block_type = block.get("type") if block_type in ("input_text", "output_text", "text"): text_parts.append(str(block.get("text") or "")) elif block_type == "input_image": image_url = block.get("image_url") if not isinstance(image_url, str) or not image_url.startswith("data:"): raise ValueError( "input_image 'image_url' must be an inline data URI " "('data:;base64,...'); Pi forwards images inline and " "cannot fetch external URLs or resolve file references " f"(got {str(image_url)[:48]!r})." ) parsed = parse_data_uri(image_url) images.append( {"type": "image", "data": parsed.base64_payload, "mimeType": parsed.mime_type} ) elif block_type == "input_file": # Pi has no file channel, so inline text-like files into the # message (the model can still reason about them) and skip binary # ones. Matches codex_executor's input_file fallback. file_data = block.get("file_data") or "" if isinstance(file_data, str) and file_data.startswith("data:"): parsed = parse_data_uri(file_data) if not parsed.mime_type.startswith("text/"): logger.warning( "Pi harness: skipping non-text input_file (%s) — Pi " "cannot consume binary attachments inline.", parsed.mime_type, ) continue try: decoded = base64.b64decode(parsed.base64_payload).decode("utf-8", "replace") except ValueError as exc: # binascii.Error subclasses ValueError raise ValueError( f"input_file has an undecodable base64 payload: {exc}" ) from exc text_parts.append(decoded) elif isinstance(file_data, str) and file_data: # Already-inline text (no data: wrapper). text_parts.append(file_data) else: raise ValueError( f"Unsupported content block type {block_type!r} for the Pi " "harness: only 'input_text', 'input_image', and 'input_file' " "blocks can be forwarded. Dropping it silently would lose the " "attachment, so the turn fails loudly instead." ) return "\n".join(text_parts), images def _build_pi_prompt( messages: list[Message], *, is_first_turn: bool ) -> str | list[dict[str, Any]]: """ Build the prompt to send to Pi. On the first turn with prior history (e.g. sub-agent with ``pass_history=True``), serializes the full conversation so the Pi process has context. Otherwise returns just the latest user message content (may be multimodal). :param messages: Omnigent conversation history for the turn. :param is_first_turn: ``True`` when this is the first turn against a freshly-started Pi subprocess, so the full history needs to be serialized into the prompt. :returns: A string prompt or a list of content block dicts. """ user_messages = [m for m in messages if m.get("role") == "user"] if is_first_turn and len(messages) > 1 and len(user_messages) > 1: lines = ["Conversation so far:"] for msg in messages: role = str(msg.get("role") or "user").replace("_", " ") text = _extract_text(msg) lines.append(f"{role}: {text}") lines.append("") lines.append( "Respond to the latest user message, using the conversation above as context." ) return "\n".join(lines) return _extract_latest_user_content(messages) @dataclass(frozen=True) class SandboxedPiCli: """Result of wrapping the Pi CLI in a sandbox. :param launch_path: The path the executor should actually spawn. Either the original ``pi_path`` (sandbox skipped) or a generated wrapper script that applies the sandbox before exec-ing Pi. :param sandboxed: ``True`` when the wrapper script is active. """ launch_path: str sandboxed: bool def _try_sandbox_pi( pi_path: str, os_env: OSEnvSpec | None, cwd: str | None, spawn_env_names: Sequence[str] | None = None, ) -> SandboxedPiCli: """Wrap the Pi CLI in a sandbox if ``os_env`` requests it. :param pi_path: Path to the resolved ``pi`` binary. :param os_env: The agent's ``os_env`` spec. ``None`` or a ``"none"`` sandbox skips wrapping. :param cwd: Working directory the sandbox should consider the root. :param spawn_env_names: Env-var names the executor deliberately passes in the Pi subprocess env (the :func:`_clean_pi_env` keys plus per-spawn extras like ``PI_CODING_AGENT_DIR``). Baked into the launcher policy so :func:`run_launcher` prunes anything else its environment picks up — defense in depth against host-env leakage into the sandbox. ``None`` skips the prune. """ if os_env is None: return SandboxedPiCli(launch_path=pi_path, sandboxed=False) sandbox_spec = os_env.sandbox or OSEnvSandboxSpec() if sandbox_spec.type == "none": return SandboxedPiCli(launch_path=pi_path, sandboxed=False) try: import pathlib from .sandbox import ( create_exec_launcher, resolve_sandbox, with_additional_read_roots, with_additional_write_roots, with_spawn_env_allowlist, ) resolved_cwd = pathlib.Path(cwd or os.getcwd()).resolve(strict=False) sandbox = resolve_sandbox(os_env, resolved_cwd) if not sandbox.active: return SandboxedPiCli(launch_path=pi_path, sandboxed=False) # Pi needs to read its own installation + node_modules. pi_dir = pathlib.Path(pi_path).resolve().parent.parent sandbox = with_additional_read_roots(sandbox, [pi_dir]) # Pi writes to ~/.pi and to /tmp. home_pi = pathlib.Path(os.path.expanduser("~/.pi")) sandbox = with_additional_write_roots(sandbox, [home_pi, pathlib.Path("/tmp")]) sandbox = with_spawn_env_allowlist(sandbox, spawn_env_names) launcher = create_exec_launcher(pi_path, sandbox) return SandboxedPiCli(launch_path=launcher, sandboxed=True) except (OSError, ImportError, NotImplementedError) as exc: logger.warning("Could not apply sandbox for Pi: %s", exc) return SandboxedPiCli(launch_path=pi_path, sandboxed=False) def _resolve_pi_skill_args( skills_filter: str | list[str], bundle_dir: pathlib.Path | None, ) -> list[str]: """ Translate ``skills_filter`` into Pi CLI args. Pi exposes two skill knobs at the CLI: ``--no-skills`` (suppress auto-discovery + loading) and ``--skill `` (explicitly load a skill directory; repeatable). This helper composes them based on the spec's ``skills_filter``: - ``"all"`` → emit ``--skill `` for every bundled skill so the agent definitely sees them, AND let Pi's auto-discovery run (no ``--no-skills``) so any host-installed skills also surface. - ``"none"`` → ``["--no-skills"]``. Pi's auto-discovery is suppressed and no explicit skills are loaded. - ``list[str]`` → ``["--no-skills"]`` (suppress auto-discovery) plus one ``--skill `` per named bundle skill that exists. Names not present in the bundle are silently skipped — matches the SDK convention where a missing skill is no-op, not an error. Pi's ``--skill`` flag accepts a directory path, not a name, so the resolver looks up named skills under ``/skills/``. Host-installed Pi skills aren't accessible by name without knowing Pi's internal extension layout, so the named-list mode only resolves bundle skills. :param skills_filter: ``"all"`` / ``"none"`` / list of skill names from the spec. :param bundle_dir: The agent bundle's extracted on-disk path (e.g. ``loaded.workdir``). ``None`` when no bundle is available — the resolver returns no ``--skill`` flags regardless of filter, and only the ``--no-skills`` flag for ``"none"``/list cases. :returns: A list of CLI tokens to extend ``self._extra_args`` with. Empty for ``"all"`` when there are no bundle skills. """ bundle_skills: dict[str, pathlib.Path] = {} if bundle_dir is not None: skills_root = bundle_dir / "skills" if skills_root.is_dir(): for child in sorted(skills_root.iterdir()): if child.is_dir() and (child / "SKILL.md").is_file(): bundle_skills[child.name] = child if skills_filter == "all": # Pi auto-discovers host skills; we explicitly add bundled # ones so the agent definitely sees them regardless of cwd. args: list[str] = [] for path in bundle_skills.values(): args.extend(["--skill", str(path)]) return args if skills_filter == "none": # ``--no-skills`` suppresses Pi's discovery walk AND loading. # No ``--skill`` flags either — explicit paths would override # ``--no-skills`` (Pi loads what's explicitly named). return ["--no-skills"] if isinstance(skills_filter, list): args = ["--no-skills"] for name in skills_filter: if name in bundle_skills: args.extend(["--skill", str(bundle_skills[name])]) return args # Unknown shape — fall back to Pi's defaults (auto-discovery on, # no explicit skills). The harness wrap's resolver should # have validated upstream, so this branch is belt-and-suspenders. return [] def _extract_pi_turn_usage( message: object, fallback_model: str | None, ) -> dict[str, Any] | None: # type: ignore[explicit-any] """Map a Pi assistant message's ``usage`` object onto the wire shape that :class:`TurnComplete` consumes, so pi sub-agent cost is priced the same way as ``claude-sdk`` and ``codex`` turns. Pi (``@earendil-works/pi-coding-agent``) forwards assistant messages whose ``usage`` dict carries ``input`` / ``output`` / ``cacheRead`` / ``cacheWrite`` / ``totalTokens`` token counts, and the message itself carries the resolved ``model``. This translates those into omnigent's usage schema (see :class:`omnigent.inner.executor.TurnComplete`). :param message: A pi message dict (e.g. ``event["message"]`` from a ``message_end`` event, or an entry from ``event["messages"]`` on ``agent_end``). Defensive against non-dict shapes. :param fallback_model: The executor's configured model, used for cost pricing when the assistant message omits ``model``. :returns: The mapped usage dict, or ``None`` when ``message`` is not an assistant message carrying a ``usage`` dict — callers leave ``TurnComplete.usage`` as ``None`` in that case. """ if not isinstance(message, dict): return None if message.get("role") != "assistant": return None usage = message.get("usage") if not isinstance(usage, dict): return None raw_model = message.get("model") model = raw_model if isinstance(raw_model, str) and raw_model else fallback_model return { "input_tokens": int(usage.get("input") or 0), "output_tokens": int(usage.get("output") or 0), "total_tokens": int(usage.get("totalTokens") or 0), "cache_read_input_tokens": int(usage.get("cacheRead") or 0), "cache_creation_input_tokens": int(usage.get("cacheWrite") or 0), "model": model, } def _aggregate_pi_turn_usage( message_usages: list[dict[str, Any]], # type: ignore[explicit-any] fallback_model: str | None, ) -> dict[str, Any] | None: # type: ignore[explicit-any] """Aggregate per-message Pi usage into one turn-level usage dict. A single Omnigent turn drives Pi's full agent loop, which may make several LLM calls (one assistant message per iteration of a tool-use loop), each forwarded as its own ``message_end``. Mirrors the openai-agents executor's billing/context split (see ``openai_agents_sdk_executor``): token counts are SUMMED across those calls because each call is billed for the full input it sends, while ``context_tokens`` reflects only the LAST call's total — the proxy for how full the context window is going into the next request (summing inputs would double-count the re-sent history). :param message_usages: Per-message usage dicts from :func:`_extract_pi_turn_usage`, in arrival order. Empty when pi reported no usage for the turn. :param fallback_model: The executor's configured model id, used only when no captured message carried a ``model``, e.g. ``"databricks-claude-sonnet-4-6"``. :returns: A turn-level usage dict for ``TurnComplete.usage`` carrying summed ``input_tokens`` / ``output_tokens`` / ``total_tokens`` / ``cache_read_input_tokens`` / ``cache_creation_input_tokens``, a last-call ``context_tokens``, and the pricing ``model``; or ``None`` when no usable usage was captured. """ if not message_usages: return None input_tokens = sum(u["input_tokens"] for u in message_usages) output_tokens = sum(u["output_tokens"] for u in message_usages) total_tokens = sum(u["total_tokens"] for u in message_usages) cache_read = sum(u["cache_read_input_tokens"] for u in message_usages) cache_write = sum(u["cache_creation_input_tokens"] for u in message_usages) # No countable tokens means pi reported empty usage objects — treat as # "no usage" so the server leaves the session unpriced rather than # recording a $0.00 turn. if not (input_tokens or output_tokens): return None last = message_usages[-1] # context_tokens = the LAST call's total context (the proxy for # next-request context fill); recompute from components when the # provider omitted ``totalTokens``. context_tokens = last["total_tokens"] or ( last["input_tokens"] + last["output_tokens"] + last["cache_read_input_tokens"] + last["cache_creation_input_tokens"] ) # _extract_pi_turn_usage already applied the per-message # message-model-else-fallback precedence, so the last entry's model is # the authoritative one to price the turn with. model = last["model"] if last["model"] else fallback_model return { "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens or (input_tokens + output_tokens), "cache_read_input_tokens": cache_read, "cache_creation_input_tokens": cache_write, "context_tokens": context_tokens, "model": model, } class PiExecutor(Executor): """Execute agent turns via the Pi coding agent (``pi --mode rpc``).""" def __init__( self, *, cwd: str | None = None, os_env: OSEnvSpec | None = None, model: str | None = None, pi_path: str | None = None, gateway: bool = False, databricks_profile: str | None = None, gateway_host: str | None = None, base_url_override: str | None = None, base_urls_override: dict[str, str] | None = None, gateway_auth_command: 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", ) -> None: """Create a PiExecutor. :param cwd: Working directory for the Pi subprocess. :param os_env: Optional OS environment / sandbox spec. When set, the Pi subprocess is wrapped in the same sandbox other harnesses use. :param model: Override the model name, e.g. ``"databricks-claude-sonnet-4-6"``. :param pi_path: Absolute path to a ``pi`` CLI binary. When ``None`` the executor searches ``PATH``. :param gateway: When ``True``, write a ``models.json`` pointing Pi at a vendor-neutral gateway. The Databricks AI gateway is one producer of this transport; generic providers are another. :param databricks_profile: Databricks-specific config profile from ``~/.databrickscfg``, e.g. ``""``. Only used on the Databricks producer path. ``None`` falls back to ``DATABRICKS_CONFIG_PROFILE`` then the first valid profile. :param gateway_host: Gateway workspace host origin, e.g. ``"https://example.databricks.com"``. Set from ``HARNESS_PI_GATEWAY_HOST`` (written by the Omnigent workflow layer). When set, skips profile host lookup. :param base_url_override: Override the workspace host used when building Pi's ``models.json``. Expected to be the Anthropic gateway URL from ucode state.json (``base_urls.claude``), e.g. ``"https://example.databricks.com/ai-gateway/anthropic"``. The host portion is extracted and used as the base for all Pi provider URLs. ``None`` falls back to deriving the host from the Databricks profile credentials. :param base_urls_override: ucode-provided Pi gateway URLs keyed by model family, e.g. ``{"claude": "...", "openai": "..."}``. :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_PI_GATEWAY_AUTH_COMMAND``. :param retry_policy: The spec's ``llm.retry`` budget. Threads ``policy.pi.settings()`` into Pi's ``.pi/settings.json`` before subprocess spawn — Pi natively does exponential backoff and Retry-After honoring; this just sets the budget. ``None`` resolves to ``RetryPolicy()`` defaults. See Phase 1f of ``designs/RETRY_ACROSS_HARNESSES.md``. :param bundle_dir: The agent bundle's extracted on-disk path. When set, ``/skills//SKILL.md`` files are exposed to Pi via ``--skill `` based on *skills_filter*. ``None`` skips bundle-skill wiring. :param agent_name: Optional agent display name. Reserved for future use; currently unused by Pi. :param skills_filter: Host-skill filter (``"all"`` / ``"none"`` / ``list[str]``). ``"all"`` (default) lets Pi's auto-discovery run AND adds explicit ``--skill`` flags for each bundle skill; ``"none"`` adds ``--no-skills`` so Pi sees zero skills; a list adds ``--no-skills`` plus ``--skill`` for each named bundle skill — names not present in the bundle are silently skipped. """ resolved_pi = pi_path or _find_pi_cli() if not resolved_pi: raise ImportError( "PiExecutor requires the 'pi' CLI on PATH. " "Install it with: npm install -g @earendil-works/pi-coding-agent" ) self._pi_path = resolved_pi self._cwd = cwd self._os_env_spec = os_env self._model_override = model self._gateway = gateway self._databricks_profile = databricks_profile self._gateway_host_override = gateway_host.rstrip("/") if gateway_host else None self._base_url_override = base_url_override self._base_urls_override = base_urls_override self._gateway_auth_command = gateway_auth_command # Retry policy → Pi's .pi/settings.json before subprocess spawn. # See ``RetryPolicy.pi.settings()`` for the schema. Pi natively # does exponential backoff + Retry-After honoring; we just set # the budget. self._retry_policy = retry_policy if retry_policy is not None else RetryPolicy() # Allowlisted base env for the Pi subprocess — never the full # host environ. The spec's os_env.sandbox.env_passthrough is the # opt-in for anything beyond the base set (e.g. a provider API # key on a direct, non-gateway run). self._env: dict[str, str] = _clean_pi_env( os_env.sandbox.env_passthrough if os_env is not None and os_env.sandbox else None ) # ``--no-tools`` in pi 0.68.x disables BOTH built-ins AND extension # tools by default; we re-enable just the bridged tool names per # turn via ``--tools `` in :meth:`_build_env_and_dir`. # The combined effect is: pi's native read/bash/edit/write stay # off (they don't route through Omnigent policies / history and # can 400 against the Databricks Responses API), and the bridge # extension's tools are explicitly allowlisted. self._extra_args: list[str] = ["--no-tools"] self._bundle_dir = bundle_dir self._agent_name = agent_name self._skills_filter = skills_filter # Resolve once at construction (the bundle layout doesn't # change across turns within a session). Each turn's # ``_build_env_and_dir`` copies ``self._extra_args`` so this # extension is read-only after init. self._extra_args.extend(_resolve_pi_skill_args(skills_filter, bundle_dir)) # Set by Session._wire_sdk_executor(). self._tool_executor: ToolExecutor | None = None if gateway: creds = None if self._gateway_host_override is None: creds = _read_databrickscfg(databricks_profile) if creds is None: raise OSError( "PiExecutor(gateway=True) requires gateway credentials via " "the gateway base URL / auth command or a valid " "~/.databrickscfg profile." ) if self._gateway_host_override is not None: self._databricks_host = self._gateway_host_override if gateway_auth_command is None: raise OSError( "PiExecutor(gateway=True) with a gateway workspace host " "requires a gateway auth command." ) token = _fetch_shell_command_token(gateway_auth_command) if token is None: raise OSError( "PiExecutor(gateway=True) could not fetch a gateway token " "for the workspace host." ) self._databricks_token = token elif base_url_override: # Derive the workspace host from the Anthropic gateway URL # ucode wrote to state.json. _parsed = _urlparse(base_url_override) self._databricks_host = f"{_parsed.scheme}://{_parsed.netloc}" assert creds is not None self._databricks_token = creds.token else: assert creds is not None self._databricks_host = creds.host self._databricks_token = creds.token # True when the gateway transport was derived from a ~/.databrickscfg # profile (no gateway host or base URL supplied directly). Gates the # Databricks default model in :meth:`_resolve_model`; on the ucode / # generic-provider paths the producer resolves a concrete model. self._gateway_uses_databricks_profile = bool( gateway and self._gateway_host_override is None and base_url_override is None ) # Apply sandbox. # ``PI_CODING_AGENT_DIR`` is added per-spawn by # ``_build_env_and_dir`` on the gateway path, so it must be in # the launcher's prune allowlist even though it's not in # ``self._env`` yet. sandboxed = _try_sandbox_pi( self._pi_path, os_env, cwd, spawn_env_names=[*self._env, "PI_CODING_AGENT_DIR"], ) self._pi_launch_path = sandboxed.launch_path self._sandboxed = sandboxed.sandboxed self._session_states: dict[str, _PiSessionState] = {} self._tool_server: _ToolServer | None = 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 async def enqueue_session_message( # type: ignore[explicit-any] self, session_key: str, content: str | dict[str, Any], ) -> bool: """Send a steering message to Pi mid-turn. Pi's RPC ``steer`` command injects a user message between tool calls within the current agent turn, so the model sees it before its next response. """ state = self._session_states.get(session_key) if state is None or state.rpc is None: return False text = content if isinstance(content, str) else str(content) try: await state.rpc.send_command( { "type": "steer", "message": text, } ) return True except Exception as exc: # noqa: BLE001 — steer is best-effort; any failure surfaces as False logger.debug("PiExecutor: failed to enqueue steer message: %s", exc) return False async def close_session(self, session_key: str) -> None: state = self._session_states.pop(session_key, None) if state is not None and state.rpc is not None: await state.rpc.close() async def interrupt_session(self, session_key: str) -> bool: state = self._session_states.get(session_key) if state is None or state.rpc is None: return False # Best-effort abort to halt the in-flight turn. try: await state.rpc.send_command({"type": "abort", "id": "abort"}) except Exception as exc: # noqa: BLE001 — abort is best-effort logger.debug("PiExecutor: interrupt abort failed: %s", exc) # Always drop the session so the next turn starts fresh and replays # full history. A resumed subprocess sends only the latest user # message, which would bypass the runner's "[System: interrupted]" # marker and silently continue the abandoned request. See # claude_sdk_executor.interrupt_session for the rationale. try: await self.close_session(session_key) return True except Exception as exc: # noqa: BLE001 — close failures surface as False logger.debug("PiExecutor: session close after interrupt failed: %s", exc) return False async def close(self) -> None: keys = list(self._session_states.keys()) for key in keys: await self.close_session(key) if self._tool_server is not None: await self._tool_server.stop() self._tool_server = None def _session_key(self, messages: list[Message]) -> str: if messages: last = messages[-1] if last.get("session_id"): return str(last["session_id"]) meta = last.get("metadata", {}) if meta.get("session_id"): return str(meta["session_id"]) return "__default__" def _resolve_model(self, config: ExecutorConfig | None) -> str | None: """ Determine the model name to pass to Pi. ``cfg.model`` (per-request /model override) wins over the spec default (``HARNESS_PI_MODEL`` → ``self._model_override``). On the Databricks-profile gateway path a missing model falls back to :data:`DATABRICKS_CLAUDE_DEFAULT_MODEL` — Pi's own default is an Anthropic-direct id the gateway rejects. Elsewhere ``None`` falls through to let Pi pick its own default. :param config: Optional :class:`ExecutorConfig` whose ``model`` takes precedence when set. :returns: The resolved model string, or ``None`` when both sources are unset off the Databricks-profile gateway path. """ cfg = config or ExecutorConfig() model = cfg.model or self._model_override if model is None and self._gateway_uses_databricks_profile: return DATABRICKS_CLAUDE_DEFAULT_MODEL return model async def _ensure_tool_server(self, tools: list[ToolSpec]) -> int | None: """Start the TCP tool server if there are Omnigent tools to bridge.""" if not tools: return None if self._tool_server is None: self._tool_server = _ToolServer() await self._tool_server.start() self._tool_server._tool_executor = self._tool_executor self._tool_server._policy_gate = self._gate_native_tool return self._tool_server.port async def _gate_native_tool( # type: ignore[explicit-any] self, name: str, args: dict[str, Any], ) -> dict[str, Any]: """Evaluate a native Pi tool call against Omnigent TOOL_CALL policy. Bridges the tool server's :attr:`_ToolServer._policy_gate` to the ``_policy_evaluator`` the harness scaffold installs on this executor (the same round-trip the Claude SDK executor uses for LLM_REQUEST / LLM_RESPONSE policies). Mirrors the claude-native / codex-native PreToolUse hooks: the verdict is computed by the Omnigent server against the session's full policy set (inherited parent session policies + the agent spec's guardrails). :param name: Native tool name, e.g. ``"read"`` or ``"bash"``. :param args: The tool's argument dict. :returns: ``{"block": bool, "reason": str}`` — block on DENY. Allow when no evaluator is wired (single-process / test paths). TOOL_CALL ASK is collapsed to ALLOW/DENY server-side before the verdict returns, so only DENY blocks here. """ # ``_policy_evaluator`` is installed best-effort by the harness # scaffold's executor adapter; absent on single-process / pre-turn # paths. Same fetch pattern as the Claude SDK executor. evaluator = getattr(self, "_policy_evaluator", None) if evaluator is None: return {"block": False, "reason": ""} verdict = await evaluator("PHASE_TOOL_CALL", {"name": name, "arguments": args}) if verdict.action == "POLICY_ACTION_DENY": return {"block": True, "reason": verdict.reason or "blocked by policy"} return {"block": False, "reason": ""} def _build_env_and_dir( self, tools: list[ToolSpec], tool_server_port: int | None, tool_server_token: str | None, model: str | None, ) -> PiSubprocessConfig: """Build env dict, temp dir, and extra CLI args for a Pi subprocess. :param tools: Omnigent tool schemas to bridge into Pi. :param tool_server_port: TCP port the Omnigent tool server is listening on, or ``None`` if no tools need bridging. :param tool_server_token: The tool server's bearer token (:attr:`_ToolServer.token`), embedded in the generated extension so requests authenticate. Non-``None`` whenever *tool_server_port* is. :param model: The resolved model id this subprocess will run, e.g. ``"moonshotai/kimi-k2.6"``; registered in the generated ``models.json`` on the gateway path so the ``provider/`` selector resolves. ``None`` when no model is pinned (Pi picks its own default). """ env = dict(self._env) tmp_dir = tempfile.mkdtemp(prefix="omnigent_pi_") extra_args: list[str] = list(self._extra_args) if self._gateway: models_json = _build_models_json( self._databricks_host, self._databricks_token, self._base_urls_override, model=model, ) models_path = os.path.join(tmp_dir, "models.json") with open(models_path, "w") as f: json.dump(models_json, f) env["PI_CODING_AGENT_DIR"] = tmp_dir # Gateway mode relocates Pi's agent root — copy the user's global # settings (extensions, packages, …) and symlink install trees so # ``pi install`` packages still resolve. See #1423. from omnigent.inner.pi_settings import prepare_managed_pi_agent_dir prepare_managed_pi_agent_dir( pathlib.Path(tmp_dir), overlay=self._retry_policy.pi.settings(), ) # Pi natively supports retry config via ``.pi/settings.json`` # (see ``RetryPolicy.pi.settings()`` for schema). On the non-gateway # path, merge the retry block into ``/.pi/settings.json``. # Gateway runs apply retry via :func:`prepare_managed_pi_agent_dir` # into the managed agent dir instead. if not self._gateway: retry_settings = self._retry_policy.pi.settings() settings_dir_root = self._cwd or tmp_dir settings_path = os.path.join(settings_dir_root, ".pi", "settings.json") try: if os.path.exists(settings_path): with open(settings_path) as f: existing_settings = json.load(f) if not isinstance(existing_settings, dict): existing_settings = {} else: existing_settings = {} existing_settings.update(retry_settings) os.makedirs(os.path.dirname(settings_path), exist_ok=True) with open(settings_path, "w") as f: json.dump(existing_settings, f, indent=2) except OSError: fallback_path = os.path.join(tmp_dir, ".pi", "settings.json") os.makedirs(os.path.dirname(fallback_path), exist_ok=True) with open(fallback_path, "w") as f: json.dump(retry_settings, f, indent=2) # Generate the Omnigent tool bridge extension if tools are available. if tools and tool_server_port is not None: if tool_server_token is None: # A port without a token would spawn the bridge # unauthenticated; fail loud instead. raise ValueError("tool_server_token is required when tool_server_port is set") ext_path = os.path.join(tmp_dir, "omnigent_tools.js") with open(ext_path, "w") as f: f.write(_generate_extension_js(tool_server_port, tools, tool_server_token)) extra_args.extend(["--extension", ext_path]) # Allowlist the bridged tool names. ``--no-tools`` (set in # __init__) disables every tool by default in pi 0.68+; # ``--tools`` adds specific names back. Without this pass # the bridge extension's tools register but pi never # exposes them to the LLM — symptom: model replies "I # don't have a calculate tool available." tool_names = [ name for name in (s.get("name") for s in tools) if isinstance(name, str) and name ] # Pi's ``formatSkillsForPrompt`` (system-prompt.js:33,112) # gates skill-index injection on ``selectedTools`` including # ``"read"``. Pi's native ``read`` is a local filesystem read # that runs in-process and never traverses the bridged /mcp # path — so enabling it lets the model see (and load) the skills # we wired via ``--skill ``. As a native tool it would # otherwise escape all guardrails, so the generated extension's # ``tool_call`` hook routes it (and any other native tool) through # an Omnigent TOOL_CALL policy verdict; see # :func:`_generate_extension_js` and # :meth:`PiExecutor._gate_native_tool`. if self._skills_filter != "none": tool_names.append("read") if tool_names: extra_args.extend(["--tools", ",".join(tool_names)]) return PiSubprocessConfig(env=env, tmp_dir=tmp_dir, extra_args=extra_args) async def _ensure_rpc( self, session_key: str, system_prompt: str, model: str | None, tools: list[ToolSpec], ) -> _PiRpcSession: """Get or create a Pi RPC subprocess for the given session.""" state = self._session_states.setdefault(session_key, _PiSessionState()) if ( state.rpc is not None and state.rpc.process is not None and state.rpc.process.returncode is None and state.system_prompt == system_prompt and state.model == model ): return state.rpc if state.rpc is not None: await state.rpc.close() tool_server_port = await self._ensure_tool_server(tools) tool_server_token = self._tool_server.token if self._tool_server is not None else None subprocess_config = self._build_env_and_dir( tools, tool_server_port, tool_server_token, model ) env = subprocess_config.env tmp_dir = subprocess_config.tmp_dir extra_args = subprocess_config.extra_args rpc = _PiRpcSession() rpc._tmp_dir = tmp_dir # For Databricks models, prefix with the provider name so Pi resolves # the model from our custom provider in models.json. pi_model: str | None if self._gateway and model: provider = _pi_provider_for_model(model) pi_model = f"{provider}/{model}" else: pi_model = model await rpc.start( self._pi_launch_path, env=env, cwd=self._cwd, model=pi_model or None, system_prompt=system_prompt or None, extra_args=extra_args or None, ) state.rpc = rpc state.system_prompt = system_prompt state.model = model state._has_sent_prompt = False return rpc async def run_turn( self, messages: list[Message], tools: list[ToolSpec], system_prompt: str, config: ExecutorConfig | None = None, ) -> AsyncIterator[ExecutorEvent]: if self._gateway: if self._gateway_host_override is None: creds = _read_databrickscfg(self._databricks_profile) if creds is not None: self._databricks_token = creds.token else: assert self._gateway_auth_command is not None token = _fetch_shell_command_token(self._gateway_auth_command) if token: self._databricks_token = token session_key = self._session_key(messages) model = self._resolve_model(config) try: rpc = await self._ensure_rpc(session_key, system_prompt, model, tools) except Exception as exc: # noqa: BLE001 — executor boundary surfaces startup errors as ExecutorError yield ExecutorError(message=f"Failed to start Pi: {exc}") return # Build the prompt to send to Pi. On the first turn of a new Pi # process, if there are prior messages (e.g. parent history passed # to a sub-agent), we serialize the full conversation so Pi has # context. On subsequent turns the Pi process already has context, # so we only send the latest user message. state = self._session_states.get(session_key) is_first_turn = state is not None and not state._has_sent_prompt prompt = _build_pi_prompt(messages, is_first_turn=is_first_turn) if not prompt: # No prompt built (e.g. empty message list on a resumed session) — # end the turn with no assistant text rather than fabricating one. yield TurnComplete(response=None) return if state is not None: state._has_sent_prompt = True # Send prompt command. Pi's JSONL protocol carries text in ``message`` # and images in a native ``images`` field. Split multimodal blocks into # both (rather than JSON-encoding them into the text) so the model # actually receives attached images instead of a base64 text blob (#515). message: str images: list[dict[str, str]] = [] if isinstance(prompt, list): try: message, images = _split_pi_prompt(prompt) except Exception as exc: # noqa: BLE001 — prompt-prep boundary: any failure (malformed/unsupported block, bad data URI) surfaces as ExecutorError, never an uncaught crash, matching the send_command path below yield ExecutorError(message=f"Failed to prepare prompt for Pi: {exc}") return else: message = prompt cmd_id = f"turn_{id(messages)}" command: dict[str, Any] = {"type": "prompt", "message": message, "id": cmd_id} if images: command["images"] = images try: await rpc.send_command(command) except Exception as exc: # noqa: BLE001 — executor boundary surfaces prompt-send errors as ExecutorError yield ExecutorError(message=f"Failed to send prompt to Pi: {exc}") return # Read events until agent_end. response_text = "" streamed_any = False # Per-LLM-call token usage captured from each assistant message pi # forwards (``message_end`` is the capture site; ``agent_end`` is a # fallback). Summed into a turn-level usage dict at completion so a # multi-step (tool-loop) turn bills for every call, not just the # last. Empty when pi reports no usage — cost tracking is skipped. message_usages: list[dict[str, Any]] = [] # type: ignore[explicit-any] while True: line = await rpc.read_line(timeout=120.0) if line is None: if not streamed_any and not response_text: stderr = "\n".join(rpc._stderr_lines) if rpc._stderr_lines else "" stderr_suffix = f" Stderr: {stderr}" if stderr else "" yield ExecutorError( message=f"Pi process ended without response.{stderr_suffix}" ) else: turn_usage = _aggregate_pi_turn_usage(message_usages, model) _notify_usage_from_dict(model=model, usage=turn_usage) yield TurnComplete(response=response_text, usage=turn_usage) return try: event = json.loads(line) except json.JSONDecodeError: logger.debug("PiExecutor: non-JSON line: %s", line[:200]) continue raw_event_type = event.get("type") event_type: str | None = raw_event_type if isinstance(raw_event_type, str) else None # Skip the command-ack response. if event_type == "response": if not event.get("success", True): yield ExecutorError(message=event.get("error", "Pi command failed")) return continue # Streaming text and thinking deltas. if event_type == "message_update": ame = event.get("assistantMessageEvent", {}) ame_type = ame.get("type") if ame_type == "text_delta": raw_delta = ame.get("delta") if isinstance(raw_delta, str) and raw_delta: yield TextChunk(text=raw_delta) response_text += raw_delta streamed_any = True elif ame_type == "thinking_start": # Anchors the "Thinking…" indicator before the first delta. yield ReasoningChunk(delta="", event_type="reasoning_started") elif ame_type == "thinking_delta": raw_delta = ame.get("delta") if isinstance(raw_delta, str) and raw_delta: # Reasoning stays out of response_text — it is not assistant text. yield ReasoningChunk(delta=raw_delta, event_type="reasoning_text") continue # Tool execution events. if event_type == "tool_execution_start": tool_name = event.get("toolName", "unknown") args = event.get("args", {}) yield ToolCallRequest( name=tool_name, args=args if isinstance(args, dict) else {}, ) continue if event_type == "tool_execution_end": tool_name = event.get("toolName", "unknown") is_error = event.get("isError", False) result = event.get("result") # Pi may report isError at the top level OR only inside # the result dict (result.isError). Check both. if isinstance(result, dict) and result.get("isError"): is_error = True # Detect policy-blocked results coming back from the tool # server. The _execute_tool callback returns # {"blocked": True, "reason": "..."} when a policy blocks # the call. By the time it reaches us, the result may be: # - a dict with "blocked" key (direct) # - a dict with "content" list containing JSON text (from Pi extension) # - a string containing JSON with "blocked" key result_str = str(result) if result is not None else "" is_blocked = False def _check_blocked(obj: JsonValue) -> BlockedCheck: """Check if obj represents a blocked tool result.""" if isinstance(obj, dict): if obj.get("blocked"): return BlockedCheck(blocked=True, reason=str(obj.get("reason", obj))) # Pi extension wraps in {content: [{type:"text", text:"..."}]} content = obj.get("content") if isinstance(content, list): for item in content: if isinstance(item, dict) and item.get("type") == "text": text = item.get("text") if not isinstance(text, str): continue try: parsed = json.loads(text) if isinstance(parsed, dict) and parsed.get("blocked"): return BlockedCheck( blocked=True, reason=str(parsed.get("reason", text)), ) except (json.JSONDecodeError, TypeError): pass elif isinstance(obj, str): try: parsed = json.loads(obj) if isinstance(parsed, dict) and parsed.get("blocked"): return BlockedCheck( blocked=True, reason=str(parsed.get("reason", obj)), ) except (json.JSONDecodeError, TypeError): pass return BlockedCheck(blocked=False, reason="") if is_error: check = _check_blocked(result) is_blocked = check.blocked if is_blocked: result_str = check.reason if is_blocked: status = ToolCallStatus.BLOCKED elif is_error: status = ToolCallStatus.ERROR else: status = ToolCallStatus.SUCCESS yield ToolCallComplete( name=tool_name, status=status, result=result, error=result_str if (is_error or is_blocked) else "", ) continue # Agent ended — the turn is complete. if event_type == "agent_end": end_messages = event.get("messages", []) if not response_text: for m in reversed(end_messages): if m.get("role") == "assistant": content = m.get("content", []) if isinstance(content, str): response_text = content elif isinstance(content, list): text_parts: list[str] = [] for part in content: if not (isinstance(part, dict) and part.get("type") == "text"): continue part_text = part.get("text") if isinstance(part_text, str): text_parts.append(part_text) response_text = "".join(text_parts) break # Fallback usage capture: if no ``message_end`` carried # usage, pull it from the last assistant message in # ``messages`` (only the last — ``messages`` may hold the # whole conversation, so summing it would overcount). if not message_usages and isinstance(end_messages, list): for m in reversed(end_messages): captured = _extract_pi_turn_usage(m, model) if captured is not None: message_usages.append(captured) break turn_usage = _aggregate_pi_turn_usage(message_usages, model) _notify_usage_from_dict(model=model, usage=turn_usage) yield TurnComplete(response=response_text, usage=turn_usage) return # message_end carries one completed assistant message, whose # ``usage`` object holds that LLM call's token counts — collect # each for the turn-level sum before handling error stop reasons. if event_type == "message_end": msg = event.get("message", {}) if isinstance(msg, dict): captured = _extract_pi_turn_usage(msg, model) if captured is not None: message_usages.append(captured) raw_stop = msg.get("stopReason") stop: str | None = raw_stop if isinstance(raw_stop, str) else None if stop in ("error", "aborted"): err = msg.get("errorMessage", stop) yield ExecutorError(message=str(err)) return continue logger.debug("PiExecutor: ignoring event type=%s", event_type)