2273 lines
99 KiB
Python
2273 lines
99 KiB
Python
"""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:<port>``.
|
|
|
|
: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 <value>`` (what the current
|
|
spawn code emits), and
|
|
* the equals-joined form ``--append-system-prompt=<value>`` (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/<model>``
|
|
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 <token>`` 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:<mime>;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 <path>`` (explicitly load
|
|
a skill directory; repeatable). This helper composes them based
|
|
on the spec's ``skills_filter``:
|
|
|
|
- ``"all"`` → emit ``--skill <path>`` 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 <path>`` 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 ``<bundle>/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. ``"<your-profile>"``. 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, ``<bundle_dir>/skills/<name>/SKILL.md`` files
|
|
are exposed to Pi via ``--skill <path>`` 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 <comma-list>`` 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/<model>`` 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 ``<cwd>/.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 <path>``. 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)
|