diff --git a/CHANGELOG.md b/CHANGELOG.md index 82897244..335235c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ Changes are grouped by software version. Entries under Unreleased describe work ### Added +- Pi as a CLI Base Agent (`pi`): the acting agent `agent.base: pi` and the `pi` Evolver run Pi 1.5's `--mode json` + in its own home, offline, with a model reference as Pi's `models.json` provider (the key as `$VARIABLE`); the + call's tools are an MCP server offered directly; a failed request fails the session although Pi exits 0. Pi takes + no roles and has no web search; `image_input: true` declares a bound model that takes images. `hermit deploy + --host pi` declares the adapter's tools `exposure: direct`, with a 120 s timeout. - Models in sandboxes as Harbor's agents get theirs: each sandbox gets its models' key variables and provider hosts (an isolated network becomes an allowlist) and forwards a model meter on the loopback; what a run keeps (the learning material, the step records, the adapter) is scrubbed of key values (`hermit/redact.py`). The allowlist @@ -152,6 +157,9 @@ Changes are grouped by software version. Entries under Unreleased describe work ### Fixed +- A deployed workspace's tool server (`.codex/config.toml`, `.mcp.json`, `.pi/mcp.json`) in a task-sandboxed + acting run crashed at startup, so the adapter's tools never loaded: its sandboxed Session loads no adapter in + the server's process, and the server now reads the manifest from the Session. - A workspace agent's run failed after its session had answered when tidying the kept workspace could not read or remove an empty mount point yet (`PermissionError` on `docs/root_1`: Docker Desktop can hold a bind mount's target a moment after its container exits); the answer was scored as a pipeline failure. Tidying now leaves such a diff --git a/docs/framework/agent-layers.md b/docs/framework/agent-layers.md index 84ff2223..644c7832 100644 --- a/docs/framework/agent-layers.md +++ b/docs/framework/agent-layers.md @@ -48,6 +48,7 @@ How a model takes steps. Layer 2 holds only the framework's agents: | `react` | The framework's tool loop: call the model, run the tools it asks for, call it again | | `codex` | The Codex CLI, working in a workspace | | `claude` | The Claude Code CLI, working in a workspace | +| `pi` | The Pi CLI, working in a workspace (no roles, no web search) | API, the only one Layer 3 sees: @@ -80,7 +81,7 @@ none), Codex's own sandbox mode, the CLI's home, the time limit (the full list i leaves the agent as it was. Running a task again when the provider could not serve it is Layer 3's: the workspace is placed anew first. -Codex and Claude Code share one runner, `CliAgent`: the isolation, the home, the sign-in, the tool server and the +Codex, Claude Code and Pi share one runner, `CliAgent`: the isolation, the home, the sign-in, the tool server and the process are the same for every CLI. Each CLI is a subclass whose hooks give what is its own: its command, its home, and how its event stream is read (the reply, the trajectory, the usage, whether the provider served it, and what a session that was not served met: `outcome`). A new CLI is @@ -98,8 +99,8 @@ What the Base Agent is used for. Two kinds: record). - The **Evolver Agent** learns. Its API to the Trainer is `step(ctx)` and `finalize(ctx)`. It reads the acting runs' trajectories and writes a new adapter, calling a Base Agent: `direct` for methods that decide every step in - their own code (GEPA, SkillOpt, ReHarness, Continual Harness), `codex` or `claude` for methods whose learner is an - agent (Meta-Harness Adapter, the `codex` and `claude` Evolvers). The run's model reaches an Evolver as + their own code (GEPA, SkillOpt, ReHarness, Continual Harness), `codex`, `claude` or `pi` for methods whose learner + is an agent (Meta-Harness Adapter, the `codex`, `claude` and `pi` Evolvers). The run's model reaches an Evolver as `ctx.agent`, a `direct` Base Agent metered against the budget; `ask(ctx.agent, messages, max_tokens=...)` returns the reply as the `llm` tool does. @@ -140,7 +141,7 @@ Each Base Agent offers the tools to the model in its own way: |---|---|---| | `direct` | Their schemas in the API request | Layer 3, which then calls `direct` again | | `react` | Their schemas in the API request | `react`, in its loop | -| `codex`, `claude` | The framework's tool server, over MCP (streamable HTTP) | The tool server | +| `codex`, `claude`, `pi` | The framework's tool server, over MCP (streamable HTTP) | The tool server | The tool server (`hermit/agent/base/tool_server.py`) is started for the one call and stopped after it. It carries a token in its URL, so that only the session it was started for can call it, and tells Codex to approve its tools @@ -223,6 +224,7 @@ accepted; a key given in two layers is refused. On the command line a layer's se | OfficeQA in direct chat | Acting Agent running SkillOpt's chat loop | `direct`, per turn | | GEPA, SkillOpt, ReHarness, Continual Harness, OPRO | Evolver Agent | `direct` (`ctx.agent`); OPRO in OfficeQA: `codex` through `llm: {cli: codex}` | | Meta-Harness Adapter, `codex` and `claude` Evolvers | Evolver Agent | `codex` or `claude` | +| `pi` Evolver | Evolver Agent | `pi` | | RRSI Adapter | Evolver Agent: upstream's rules and roles, the proposer's prompt | `codex` or `claude` for the proposer, and the same, one call per upstream `generate`, for the analyst, digesters and critic | | AHE Adapter | Evolver Agent: the Evolve Agent and the Agent Debugger | `codex` or `claude`, one session per step and per task | | CL-Bench, ServeLearnBench | Acting Agent running the benchmark's loop | `direct`, per call, through the local endpoint | diff --git a/docs/framework/custom-evolver.md b/docs/framework/custom-evolver.md index f813c6d6..97937eb8 100644 --- a/docs/framework/custom-evolver.md +++ b/docs/framework/custom-evolver.md @@ -75,7 +75,7 @@ You can reuse a launcher instead of implementing a new Python learning loop: | `hermit.agent.evolver.agent:AgentEvolver` | Backend, prompt files, model selection, and host arguments. | The learner agent edits the working directory. | | `hermit.agent.evolver.agent:CommandEvolver` | An external command and optional environment values. | The program edits the working directory and may write a result file. | -`AgentEvolver` supports `codex` and `claude`. Its parameters include `backend`, `prompt`, `resume_prompt`, `model`, +`AgentEvolver` supports `codex`, `claude` and `pi`. Its parameters include `backend`, `prompt`, `resume_prompt`, `model`, `args`, and `home`. Prompt placeholders include the workspace paths, `message`, `epoch`, and `step`. The bundled definitions are under `hermit/agent/evolver/methods/`. diff --git a/docs/framework/evolver.md b/docs/framework/evolver.md index 96c93bef..fa78f209 100644 --- a/docs/framework/evolver.md +++ b/docs/framework/evolver.md @@ -130,7 +130,7 @@ For selection guidance, setup, and the complete parameter defaults, use [Choose | `evolver.name` | Method | Needs | |---|---|---| -| `codex`, `claude` | A Codex or Claude Code agent edits the adapter from the learning material; `codex` on the `prompt-only@1.0` template is the [prompt-only](../methods/prompt-only.md) method. | The agent CLI | +| `codex`, `claude`, `pi` | A Codex, Claude Code or Pi agent edits the adapter from the learning material; `codex` on the `prompt-only@1.0` template is the [prompt-only](../methods/prompt-only.md) method. | The agent CLI | | `gepa` | [GEPA](https://github.com/gepa-ai/gepa): one iteration per step. It reflects on the step's trajectories, keeps a child that does better on the same tasks, scores it on validation, and hands back the next parent from its Pareto front. | `llm`, `data.val`, `python -m pip install -e . --group gepa` | | `skillopt` | [SkillOpt](https://github.com/microsoft/SkillOpt): one SkillOpt step per training step. It turns the step's trajectories into a few bounded edits under its learning rate (decaying when `total_steps` is set) and keeps them only if the validation score improves; the slow update and meta skill run at each epoch's end. | `llm`, `data.val`, `python -m pip install -e . --group skillopt` | | `ace` | [ACE](https://github.com/ace-agent/ace): per rollout, a Reflector tags the playbook bullets the agent named and diagnoses the attempt (a wrong answer is run again with the reflection, up to `max_num_rounds`), and a Curator adds bullets; the playbook grows by deltas. See `docs/methods/ace.md`. | `llm`; `data.val` to keep the best validation playbook | diff --git a/docs/framework/task.md b/docs/framework/task.md index 0ae768e4..0c4a1852 100644 --- a/docs/framework/task.md +++ b/docs/framework/task.md @@ -86,7 +86,7 @@ agent: | `agent.name` | Agent | Settings | |---|---|---| | `react` | a chat-completions tool-calling loop over the task's tools and the adapter's tools | `model`, `prompt`, `tools`, `user`, `max_turns`, `max_output_tokens`, `max_tokens`, `temperature`, `answer_tag`, `tool_result_chars`, `logs` | -| `codex`, `claude-code` | Codex or Claude Code run in a fresh workspace the adapter is deployed into | `model`, `login`, `effort`, `prompt`, `files`, `mounts`, `prepare`, `answer`, `collect`, `context_query`, `project_doc_max_bytes`, `args`, `env`, `price`, `timeout_s`, `retries`, `retry_wait_s`, `workspaces` | +| `codex`, `claude-code`, `pi` | Codex, Claude Code or Pi run in a fresh workspace the adapter is deployed into (Pi: no `login`, no roles; its project's `.pi/` is trusted for the session) | `model`, `login`, `effort`, `prompt`, `files`, `mounts`, `prepare`, `answer`, `collect`, `context_query`, `project_doc_max_bytes`, `args`, `env`, `price`, `timeout_s`, `retries`, `retry_wait_s`, `workspaces` | ### A coding-agent CLI as the acting agent diff --git a/docs/get-started/installation.md b/docs/get-started/installation.md index 6e5b8a7d..a8c88366 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -103,6 +103,7 @@ never shown). | Codex / Claude learner | Selected authenticated CLI and supported program-execution environment | | MWM | Linux learner environment, Codex or Claude Code, evidence store, task deployment contract, model access | | Acting and learning on a Claude subscription | Claude Code 2.1.248 or later, `claude setup-token`, Linux with bubblewrap for its sandboxes; see [Claude subscription](../guides/claude-subscription.md) | +| Pi acting agent or learner | The `pi` release binary on `PATH` (GitHub release `pi-linux-x64.tar.gz`, checked against its `SHA256SUMS`) and a model reference | | Benchmark recipe | Its dataset, scorer, model settings, and environment; see [catalog](../recipes/index.md) | Core installation and the deterministic tutorial work on macOS. The sandboxes (the acting agent's, the judge's, the diff --git a/docs/guides/model-backends.md b/docs/guides/model-backends.md index f16b5441..44bc10f5 100644 --- a/docs/guides/model-backends.md +++ b/docs/guides/model-backends.md @@ -26,6 +26,7 @@ hermit train --config run.yaml model=vllm/Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 | `agent.params.model` (or `agent.model`) with `agent.name: react` | the built-in tool-calling agent | chat completions | | `agent.params.model` (or `agent.model`) with `agent.name: codex` / `claude-code` | the CLI acting agent | Responses API / Messages API | | `evolver.params.model` of the `codex`, `claude` and `mwm` Evolvers | the learner's CLI session | Responses API / Messages API | +| `agent.params.model` with `agent.name: pi`, `evolver.params.model` of the `pi` Evolver | Pi | chat completions, else the Messages API, else the Responses API: the first the backend serves | | `model` (top level) | the default for all of the above when they name none | | | the examples' task agents (`agent.params.model`, or the launchers' `MW_MODEL`) | CL-Bench's litellm host, ServeLearnBench's loop, the SkillOpt rollout | chat completions | @@ -36,6 +37,11 @@ extra_body}` still works for the chat-completions callers and names the endpoint A reference's first segment is the backend; the rest is the model's id on it, with whatever slashes the backend uses (`fireworks/accounts/fireworks/models/glm-5p3-flash`, `vllm/Qwen/Qwen3-30B-A3B-Instruct-2507-FP8`). A backend may define aliases (`models: {qwen: Qwen/Qwen3-30B-A3B-Instruct-2507-FP8}`), so `vllm/qwen` works too. +For Pi a reference becomes its `models.json` provider `hermit`: the backend's URL, the key as `$VARIABLE` (Pi reads it +at each request, so no file holds the key) and the backend's price. The cost recorded follows the price (the agent's +own `price`, else the backend's), not Pi's own figure. Pi sends such a model text only; for a model that takes images +(a tool's result, such as a screenshot), set `image_input: true` in `agent.pi` or `evolver.pi`. + A string whose first segment is no backend's name is not a reference: for a Codex or Claude Code agent it is a model of the CLI's own provider (`gpt-5.4`), as before. So a provider id that happens to start with a backend's name (`openai/...`) is read as a reference to that backend. @@ -93,12 +99,12 @@ lab-sglang: # a new one, like the built-in sglang at ## Dialects: what Codex and Claude Code need -The framework's own client, the react agent and the examples' task agents speak **chat completions**. The CLI -agents do not: **Codex speaks the OpenAI Responses API only** (`wire_api = "chat"` is rejected since Codex 0.95), -and **Claude Code speaks the Anthropic Messages API only**. A backend's `apis` says what it serves, and the -framework checks a model's dialect against its backend before a run starts: `hermit check` reports it, -`train` refuses to start on a mismatch, and `hermit backends check ` sends one short request in each -dialect (and lists `GET /models`) so a wrong claim shows up before anything is spent. +The framework's own client, the react agent and the examples' task agents speak **chat completions**, and so does Pi +where the backend serves them. Codex and Claude Code do not: **Codex speaks the OpenAI Responses API only** +(`wire_api = "chat"` is rejected since Codex 0.95), and **Claude Code speaks the Anthropic Messages API only**. A +backend's `apis` says what it serves, and the framework checks a model's dialect against its backend before a run +starts: `hermit check` reports it, `train` refuses to start on a mismatch, and `hermit backends check ` sends +one short request in each dialect (and lists `GET /models`) so a wrong claim shows up before anything is spent. | Backend | chat | responses (Codex) | messages (Claude Code) | |---|---|---|---| diff --git a/docs/methods/agent.md b/docs/methods/agent.md index cb9080e9..9ec8b88e 100644 --- a/docs/methods/agent.md +++ b/docs/methods/agent.md @@ -1,9 +1,10 @@ -# Codex and Claude agent learners +# Codex, Claude and Pi agent learners -Use `evolver.name: codex` or `claude` when a learner should inspect trajectories, use tools, and edit an adapter -directory over several actions. This is useful for skills, multiple files, and tool code that a single text rewrite -does not capture well. On the single-file prompt-only template, the Codex learner is the [prompt-only](prompt-only.md) -method. +Use `evolver.name: codex`, `claude` or `pi` when a learner should inspect trajectories, use tools, and edit an +adapter directory over several actions. This is useful for skills, multiple files, and tool code that a single text +rewrite does not capture well. The `pi` learner signs in through a model reference (`evolver.base: pi` with +`evolver.model: /`, or `evolver.params.model`); it copies no login from `~/.pi/agent` (`home: []`). +On the single-file prompt-only template, the Codex learner is the [prompt-only](prompt-only.md) method. ## Requirements and setup diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index dbe5415e..9277d6d4 100644 --- a/docs/reference/compatibility.md +++ b/docs/reference/compatibility.md @@ -11,7 +11,7 @@ numbers alone may not distinguish unreleased changes. | Local deterministic Quick Start | No model account, GPU, external dataset, or host CLI | | Context-only workspace generation | macOS and Linux | | Sandboxed learner programs | Linux with bubblewrap and permitted namespaces | -| Built-in methods | Prompt-only (the Codex learner on the prompt-only template), Codex, Claude, GEPA, SkillOpt, MWM, Harness², Continual Harness, ReHarness, Meta-Harness Adapter, ACE, AHE Adapter, RRSI Adapter, EvoSafeHarness, OPRO | +| Built-in methods | Prompt-only (the Codex learner on the prompt-only template), Codex, Claude, Pi, GEPA, SkillOpt, MWM, Harness², Continual Harness, ReHarness, Meta-Harness Adapter, ACE, AHE Adapter, RRSI Adapter, EvoSafeHarness, OPRO | | Workspace deployment | Codex, Claude Code, and Pi | | A subscription instead of an API key | Codex (`login: chatgpt`) and Claude Code (`login: claude`, CLI 2.1.248 or later, tested on 2.1.318), acting and learning; see [Claude subscription](../guides/claude-subscription.md) | | Native plugin conversion | Claude Code, Codex, Pi, Hermes, with export restrictions | diff --git a/docs/reference/config-fields.md b/docs/reference/config-fields.md index f9bdb253..1b26aa1b 100644 --- a/docs/reference/config-fields.md +++ b/docs/reference/config-fields.md @@ -32,13 +32,13 @@ guarantee that their consumer supports arbitrary fields. | `data.shuffle` | boolean / true | Shuffle training order; set false for ordered streams | | `data.shuffle_seed` | integer / 0 | Training shuffle seed; does not seed every model or method | | `task_info` | directory / null | Learner-facing task description; provide `README.md` | -| `agent.name` | string / null; required | Built-in `react`, `codex`, `claude-code`, `harbor` (a benchmark's task set in Harbor's format, see [Benchmarks](../framework/task.md#benchmarks)), an Agent class, or a `file.py:function` / `module:function` | +| `agent.name` | string / null; required | Built-in `react`, `codex`, `claude-code`, `pi`, `harbor` (a benchmark's task set in Harbor's format, see [Benchmarks](../framework/task.md#benchmarks)), an Agent class, or a `file.py:function` / `module:function` | | `agent.concurrency` | integer / 4 | Maximum concurrent rollout/evaluation task executions; use a positive value | | `agent.timeout_min` | number / null | A sandboxed task call (its worker) running longer is stopped and recorded as an error; null: no limit | | `agent.params` | mapping / {} | Passed to Agent constructors; plain functions receive it when they declare a `params` argument | -| `agent.base` | string / null | Layer 2 in the per-layer form ([Agent layers](../framework/agent-layers.md#settings-by-layer)): `codex`, `claude`, `react`, or `direct` (with `agent.name`, a task's own loop); sets `agent.name` when unset | +| `agent.base` | string / null | Layer 2 in the per-layer form ([Agent layers](../framework/agent-layers.md#settings-by-layer)): `codex`, `claude`, `pi`, `react`, or `direct` (with `agent.name`, a task's own loop); sets `agent.name` when unset | | `agent.model` | string / mapping / null | Layer 1: a `/` reference, or `{ref` or `name, login, effort, price, ...}`; read into `agent.params` | -| `agent.codex`, `agent.claude`, `agent.react`, `agent.direct` | mapping / null | Layer 2's settings for that Base Agent (the one `agent.base` names); read into `agent.params`. A key set in two layers is refused | +| `agent.codex`, `agent.claude`, `agent.pi`, `agent.react`, `agent.direct` | mapping / null | Layer 2's settings for that Base Agent (the one `agent.base` names); read into `agent.params`. A key set in two layers is refused | | `pipeline.run` | string / null | Legacy alias for `agent.name`, applied only when `agent.name` is unset | | `pipeline.concurrency` | integer / null | Legacy concurrency override when the legacy pipeline alias is used | | `pipeline.params` | mapping / {} | Legacy parameters merged into `agent.params` when the pipeline alias is used | @@ -57,9 +57,9 @@ Task row fields become training material, so private labels require judge-owned | `evolver.name` | string / null; required | Built-in method or directory containing its Evolver definition | | `evolver.resume` | boolean / false | Request learner-session continuation; method must support it | | `evolver.params` | mapping / {} | Overrides merged with the chosen method's defaults | -| `evolver.base` | string / null | Layer 2 the method runs on: `codex` or `claude` (an agent Evolver: sets `evolver.params.backend`) or `direct` | +| `evolver.base` | string / null | Layer 2 the method runs on: `codex`, `claude` or `pi` (an agent Evolver: sets `evolver.params.backend`) or `direct` | | `evolver.model` | string / mapping / null | Layer 1, as `agent.model`; with `evolver.base: direct` it is the run's `llm` | -| `evolver.codex`, `evolver.claude`, `evolver.direct` | mapping / null | Layer 2's settings, read into `evolver.params` | +| `evolver.codex`, `evolver.claude`, `evolver.pi`, `evolver.direct` | mapping / null | Layer 2's settings, read into `evolver.params` | | `evolver_access.environment` | list / [] | Granted environment resources; required for MWM's evidence integration | | `evolver_access.tools` | list / [] | Task tool declarations offered to the learner | | `evolver_access.send_material_out` | boolean / false | Consent declaration checked for methods requesting that need; not a network filter | @@ -79,7 +79,7 @@ dialects it serves, prices). See [Model backends](../guides/model-backends.md). | `llm` | reference, mapping / null | The run's chat-completions `llm` tool: a reference, `{model: , price, extra_body}`, the endpoint itself (below), or a CLI's sessions, `{cli: ..., ...}` (below) | `agent.params.model` takes the same forms. For a Codex or Claude Code acting agent or Evolver a reference becomes -the CLI's provider settings (`args` for Codex, `env` for Claude Code) and the model's id; a plain id without a +the CLI's provider settings (`args` for Codex, `env` for Claude Code, `provider` for Pi) and the model's id; a plain id without a backend goes to the CLI's own provider as before. ### Learner model tool diff --git a/hermit/agent/acting/agent.py b/hermit/agent/acting/agent.py index e8916e04..8cafd23b 100644 --- a/hermit/agent/acting/agent.py +++ b/hermit/agent/acting/agent.py @@ -7,7 +7,7 @@ def run(self, task: dict, adapter: Path | None) -> RunOutput A config names the agent: agent: - name: react # a built-in: react | codex | claude-code + name: react # a built-in: react | codex | claude-code | pi name: agent/agent.py:CLBenchAgent # or the task's own class (file.py:Class, module:Class) name: agent/pipeline.py:run # or a plain function run(task, adapter), wrapped as a FunctionAgent concurrency: 4 @@ -75,9 +75,9 @@ def builtin(name: str): if name == "react": from hermit.agent.acting.react import ReactAgent return ReactAgent - if name in ("cli", "codex", "claude-code", "claude"): + if name in ("cli", "codex", "claude-code", "claude", "pi"): from hermit.agent.acting.workspace import CliAgent - backend = {"codex": "codex", "claude-code": "claude", "claude": "claude"}.get(name) + backend = {"codex": "codex", "claude-code": "claude", "claude": "claude", "pi": "pi"}.get(name) if backend is None: return CliAgent return lambda params, base: CliAgent({"backend": backend, **(params or {})}, base) @@ -87,7 +87,7 @@ def builtin(name: str): return None -BUILTIN_NAMES = ("react", "codex", "claude-code", "cli", "harbor") +BUILTIN_NAMES = ("react", "codex", "claude-code", "pi", "cli", "harbor") def load_agent(name: str, params: dict | None, base: Path) -> Agent: diff --git a/hermit/agent/acting/workspace.py b/hermit/agent/acting/workspace.py index 077ce6aa..f280c344 100644 --- a/hermit/agent/acting/workspace.py +++ b/hermit/agent/acting/workspace.py @@ -1,5 +1,5 @@ -"""The `codex` and `claude-code` agents: one task run by the agent CLI in a workspace the adapter is deployed into. -They are the framework's one way to act with a coding-agent CLI; a task adds only what is its own. +"""The `codex`, `claude-code` and `pi` agents: one task run by the agent CLI in a workspace the adapter is deployed +into. They are the framework's one way to act with a coding-agent CLI; a task adds only what is its own. agent: name: codex # or claude-code; `cli` with params.backend is the same @@ -69,7 +69,7 @@ from hermit.agent.base.login import LOGINS from hermit.task import RunOutput, load_callable -BACKENDS = ("codex", "claude") +BACKENDS = ("codex", "claude", "pi") DEPLOYS = ("host",) ISOLATIONS = ("none", "container") INPUT_FIELDS = ("question", "prompt", "instruction", "input", "query") @@ -141,6 +141,9 @@ def deploy(adapter: Path, workspace: Path, backend: str, *, context: str | None from hermit.hosts.codex import DOC_BYTES, prepare return prepare(adapter, workspace, sys.executable, context=context, context_limit=context_limit or DOC_BYTES, approve_tools=approve_tools, serve_tools=serve_tools) + if backend == "pi": + from hermit.hosts.pi import prepare as prepare_pi + return prepare_pi(adapter, workspace, sys.executable, context=context) from hermit.hosts.claude_code import prepare return prepare(adapter, workspace, sys.executable, context=context, serve_tools=serve_tools) @@ -166,7 +169,10 @@ def _deploy(task: dict, adapter: Path | None, work: Path, backend: str, params: def _session_args(backend: str, deployed: dict, work: Path) -> list[str]: """What the session needs to use what the deployment connected: the workspace's own Codex configuration (its - tools, roles and hooks) trusted, as a person trusts a deployed project, and its sub-agents enabled.""" + tools, roles and hooks) trusted, as a person trusts a deployed project, and its sub-agents enabled; Pi's project + configuration (.pi/mcp.json, .pi/skills) trusted for the session (--approve), without which Pi skips it.""" + if backend == "pi": + return ["--approve"] if backend == "codex": return ["-c", f'projects={{"{work}" = {{ trust_level = "trusted" }} }}', *(["--enable", "multi_agent"] if deployed.get("roles") else [])] @@ -271,6 +277,12 @@ def _check(backend: str, params: dict) -> str: if not isinstance(params.get("web_search"), bool): raise ValueError("agent.params.web_search must be set, true or false: Codex's hosted search runs at the " "provider, past any sandbox or proxy, so it is never left to a default") + if backend == "pi": # Pi signs in through a model reference, and runs on this machine + if params.get("login") is not None: + raise ValueError("agent pi takes no login: its model is a / reference") + if params.get("isolation") == "container": + raise ValueError("isolation: container runs Codex or Claude Code; agent pi takes isolation: none") + return backend login, label = params.get("login"), {"codex": "Codex", "claude": "Claude Code"}[backend] if login is not None and login not in LOGINS: raise ValueError(f"agent.params.login is {' or '.join(sorted(LOGINS))} or absent, not {login!r}") @@ -294,7 +306,8 @@ def base_agent(backend: str, params: dict) -> BaseAgent: """The Base Agent (Layer 2) these params describe: the CLI and how it is run, nothing of the task.""" settings = {k: params[k] for k in ("model", "binding", "login", "plan_dir", "plan_max_wait_s", "usage_credits", "effort", "isolation", "container", "codex_sandbox", "shell_env", - "project_doc_max_bytes", "args", "env", "price") + "project_doc_max_bytes", "args", "env", "price", "provider", + "image_input") if k in params and (params[k] is not None or k == "plan_max_wait_s")} if params.get("claude_permission_mode"): settings["permission_mode"] = params["claude_permission_mode"] diff --git a/hermit/agent/base/cli.py b/hermit/agent/base/cli.py index 0cff0898..ef6e2152 100644 --- a/hermit/agent/base/cli.py +++ b/hermit/agent/base/cli.py @@ -1,4 +1,4 @@ -"""`codex` and `claude`: a coding-agent CLI as a Base Agent (docs/framework/agent-layers.md#layer-2-base-agent). +"""`codex`, `claude` and `pi`: a coding-agent CLI as a Base Agent (docs/framework/agent-layers.md#layer-2-base-agent). One `run` is one session of the CLI in print mode: the prompt is given, the CLI works in `workspace` with its own tools and the call's `tools` (served over MCP by the framework's tool server), and its last message is the reply. @@ -65,6 +65,10 @@ persist_session (false: --no-session-persistence), system_prompt (in place of its own; written to a file in the home), append_system_prompt (a long one goes the same way), effort, permission_mode, usage_credits (true: sessions on `login: claude` may draw the account's usage credits; default: a session that did holds the next ones back) + Pi only: provider (its models.json entry for a bound model, from the binding: Binding.pi_provider), + image_input (true: the bound model takes images, such as a tool's result; default: text only, Pi's default for a + model it does not know), append_system_prompt, effort (its --thinking level). Pi takes no roles, has no web search + and runs under isolation none or sandbox. Limits are enforced by the isolation, or the call is refused (Unsupported): @@ -92,9 +96,9 @@ from hermit.agent.base import container, login as codex_login from hermit.agent.base.interface import BaseAgent, Limits, Mount, Result, Unsupported -from hermit.agent.base.records import parse_claude, parse_codex, price_usage +from hermit.agent.base.records import parse_claude, parse_codex, parse_pi, price_usage from hermit.agent.base.tool import Tool -from hermit.agent.base.trajectory import claude_steps, codex_steps, events +from hermit.agent.base.trajectory import claude_steps, codex_steps, events, pi_steps from hermit.agent.model.backends import CLAUDE_ENV ISOLATIONS = ("none", "sandbox", "container") @@ -103,8 +107,9 @@ UNAVAILABLE = re.compile(r"unexpected status (?:429|5\d\d)|exceeded retry limit|stream disconnected|" r"error sending request|too many requests|service unavailable|bad gateway|overloaded|" r"model is at capacity|" + r"^(?:429|5\d\d): |" # Pi's report of a provider's status r"error response from daemon|cannot connect to the docker daemon|" - + re.escape(container.MISSING), re.I) + + re.escape(container.MISSING), re.I | re.M) CODEX_HOME_SETTINGS = ["features.shell_snapshot = false", # a snapshot would bring back the whole environment "sandbox_workspace_write.network_access = false", 'approval_policy = "never"'] # What would send a session on `login: claude` to another bill or host. The CLI takes any of these over the token, and @@ -1299,8 +1304,166 @@ def _final_turn(objects: list[dict]) -> str: return "\n\n".join(t for t in texts if t.strip()) +PI_PROVIDER = "hermit" # the models.json provider a bound model is given under +PI_SETTINGS = {"cacheWarming": "off", # no provider requests besides the session's own + "enableInstallTelemetry": False} + + +class Pi(CliAgent): + """Pi (`pi --mode json`). Its home is its agent directory (PI_CODING_AGENT_DIR) and its HOME, offline (no catalog, + update or download requests); a bound model is the models.json provider `hermit`, its key read by Pi from the + backend's variable; the call's tools are an MCP server whose tools the model is offered directly. Pi reports a + failed request in its stream and exits 0: whether the session failed is read from the stream (`outcome`).""" + name = backend = program = "pi" + label = "Pi" + home_var, user_folder = "PI_CODING_AGENT_DIR", ".pi/agent" + home_files = ("auth.json",) + turn_done = "agent_settled" + + def home_env(self, home): + """Every variable Pi reads goes here, so that it passes the sandbox's filter: its agent directory, its HOME + (Pi reads ~/.agents/skills), offline, no telemetry; a session directory of the caller's emptied, which Pi + reads as unset (its sessions stay in the home, where a resume finds them).""" + return {"PI_CODING_AGENT_DIR": str(home), "HOME": str(home), "PI_OFFLINE": "1", "PI_TELEMETRY": "0", + "PI_CODING_AGENT_SESSION_DIR": ""} + + @classmethod + def user_home(cls) -> Path: + return Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / cls.user_folder) + + def prepare_home(self, home, roles, tools_url, tools_name): + """models.json (the bound model), mcp.json (the call's tools, offered directly rather than through Pi's code + mode) and settings.json. No host path is written in them: a sandbox translates only the command, the + environment and the prompt.""" + if roles: + raise Unsupported("pi has no sub-agents: it takes no roles") + s = self.settings + _clear(home / "models.json") + (home / "models.json").unlink(missing_ok=True) + if s.get("provider"): + provider = json.loads(json.dumps(s["provider"])) + if s.get("effort") and s["effort"] != "off": # --thinking applies to a reasoning model only + for model in provider.get("models") or []: + model["reasoning"] = True + if s.get("image_input"): # else Pi sends the model no image (a text-only model refuses them) + for model in provider.get("models") or []: + model["input"] = ["text", "image"] + (home / "models.json").write_text(json.dumps({"providers": {PI_PROVIDER: provider}}, indent=1)) + # A tool call may take minutes (a Trainer tool runs rollouts): Codex's tool_timeout_sec, not Pi's 60 s + servers = {tools_name: {"url": tools_url, "exposure": "direct", "timeout": 1800}} if tools_url else {} + for name, data in (("mcp.json", {"mcpServers": servers}), ("settings.json", PI_SETTINGS)): + _clear(home / name) + (home / name).write_text(json.dumps(data, indent=1)) + + @classmethod + def web_search_args(cls, enabled: bool) -> list[str]: + """Pi has no web search: off is its only setting.""" + if enabled: + raise Unsupported("pi has no web search: set web_search to false") + return [] + + def system_settings(self, system, prompt): + return {"append_system_prompt": system}, prompt + + @staticmethod + def _last_assistant(raw: str) -> dict: + found = {} + for event in _objects(raw)[0]: + message = event.get("message") if isinstance(event.get("message"), dict) else {} + if event.get("type") == "message_end" and message.get("role") == "assistant": + found = message + return found + + @classmethod + def served(cls, raw): + """The session settled, not aborted, and its last request did not fail.""" + settled = [e for e in _objects(raw)[0] if e.get("type") == cls.turn_done] + return bool(settled) and not settled[-1].get("aborted") and \ + cls._last_assistant(raw).get("stopReason") not in ("error", "aborted") + + @classmethod + def errors(cls, event): + """A request that ended in an error or an abort, a retry that gave up, a failed compaction.""" + kind = event.get("type") + message = event.get("message") if isinstance(event.get("message"), dict) else {} + if kind == "message_end" and message.get("role") == "assistant" and \ + message.get("stopReason") in ("error", "aborted"): + return [str(message.get("errorMessage") or f"request {message['stopReason']}")] + if kind == "auto_retry_end" and not event.get("success"): + return [str(event.get("finalError") or "retry failed")] + if kind in ("compaction_end", "extension_error") and (event.get("errorMessage") or event.get("error")): + return [str(event.get("errorMessage") or event.get("error"))] + return [] + + def outcome(self, raw, stream, last_file): + """Pi's JSON mode exits 0 however the session ended: one that did not settle, aborted, or whose last request + failed, was aborted or was cut at the output limit (Pi's text mode exits 1 for each) has failed.""" + notes, done = self.notices(raw), self.served(raw) + cut = self._last_assistant(raw).get("stopReason") == "length" + return {"reply": self.last_message(stream, last_file), "served": done, + "unavailable": bool(UNAVAILABLE.search(notes)), "refused": False, "failed": not done or cut, + "failure": (notes or ("the reply was cut at its output limit" if cut else "the session did not settle") + )[-600:]} + + def adjust_usage(self, home, handle, usage, resumed): + """A bound model's cost is left to its price (price_usage): Pi's own figure counts a model whose + models.json entry has no cost as free, and follows no price the caller gave.""" + if self.settings.get("binding"): + for u in usage: + u.cost_usd = None + return usage + + @classmethod + def last_message(cls, log, last=None): + """The text of the last assistant message.""" + text = "" + for event in events(log): + message = event.get("message") if isinstance(event.get("message"), dict) else {} + if event.get("type") == "message_end" and message.get("role") == "assistant": + content = message.get("content") + text = content if isinstance(content, str) else "".join( + str(b.get("text") or "") for b in content or [] if isinstance(b, dict) and b.get("type") == "text") + return text + + @classmethod + def parse(cls, log, model): + return parse_pi(log, model) + + @classmethod + def steps(cls, stream): + return pi_steps(stream) + + def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools_url=None): + s, home = self.settings, last_file.parent # the session's home + system = s.get("append_system_prompt") + if system and len(str(system).encode()) > ARG_MAX: # too long for one argument + _clear(home / "append_system.md") + (home / "append_system.md").write_text(str(system)) + system = str(home / "append_system.md") # Pi reads a file named here + resume = _pi_session(home, session) if session else None + return [str(s.get("bin") or self.program), "--mode", "json", + *(["--provider", PI_PROVIDER] if s.get("provider") else []), + *(["--model", s["model"]] if s.get("model") else []), + *(["--thinking", s["effort"]] if s.get("effort") else []), + *(["--append-system-prompt", system] if system else []), + *self.web_search_args(bool(s.get("web_search"))), + *(["--session", str(resume)] if resume else []), *(s.get("args") or []), + *([] if s.get("stdin") else ["--", prompt])] + + +def _pi_session(home: Path, handle) -> Path | None: + """The file of Pi session `handle` in `home` (sessions//