From 0361e420f932a70afad590fa04896cf6ff48a4d0 Mon Sep 17 00:00:00 2001 From: yizhuod Date: Sun, 8 Nov 2026 12:26:13 -0500 Subject: [PATCH 1/4] Add Pi as a CLI Base Agent (acting agent and pi Evolver) --- CHANGELOG.md | 4 + docs/framework/agent-layers.md | 10 +- docs/framework/custom-evolver.md | 2 +- docs/framework/evolver.md | 2 +- docs/framework/task.md | 2 +- docs/get-started/installation.md | 1 + docs/guides/model-backends.md | 4 + docs/methods/agent.md | 7 +- docs/reference/compatibility.md | 2 +- docs/reference/config-fields.md | 6 +- hermit/agent/acting/agent.py | 8 +- hermit/agent/acting/workspace.py | 25 +- hermit/agent/base/cli.py | 178 ++++++++++- hermit/agent/base/records.py | 30 ++ hermit/agent/base/trajectory.py | 74 ++++- hermit/agent/evolver/agent.py | 17 +- hermit/agent/evolver/loader.py | 3 +- hermit/agent/evolver/methods/pi/RESUME.md | 4 + hermit/agent/evolver/methods/pi/START.md | 15 + hermit/agent/evolver/methods/pi/evolver.yaml | 13 + hermit/agent/model/backends.py | 65 +++- hermit/config.py | 28 +- hermit/defaults.yaml | 8 +- hermit/hosts/pi.py | 8 +- hermit/isolate.py | 2 +- tests/README.md | 3 +- tests/core/test_pi_agent.py | 312 +++++++++++++++++++ tests/core/test_sandbox.py | 2 +- tests/core/test_selection.py | 2 +- tests/fixtures/pi/README.md | 13 + tests/fixtures/pi/failure.jsonl | 37 +++ tests/fixtures/pi/resume.jsonl | 13 + tests/fixtures/pi/success.jsonl | 50 +++ tests/selection.py | 2 +- 34 files changed, 861 insertions(+), 91 deletions(-) create mode 100644 hermit/agent/evolver/methods/pi/RESUME.md create mode 100644 hermit/agent/evolver/methods/pi/START.md create mode 100644 hermit/agent/evolver/methods/pi/evolver.yaml create mode 100644 tests/core/test_pi_agent.py create mode 100644 tests/fixtures/pi/README.md create mode 100644 tests/fixtures/pi/failure.jsonl create mode 100644 tests/fixtures/pi/resume.jsonl create mode 100644 tests/fixtures/pi/success.jsonl diff --git a/CHANGELOG.md b/CHANGELOG.md index 021cc8c6..beebd1a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ 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. `hermit deploy --host pi` declares the adapter's tools `exposure: direct`. - 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 diff --git a/docs/framework/agent-layers.md b/docs/framework/agent-layers.md index 77554a7a..80b17d64 100644 --- a/docs/framework/agent-layers.md +++ b/docs/framework/agent-layers.md @@ -45,6 +45,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: @@ -76,7 +77,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). A new CLI is a subclass listed in `CLIS`, a reader of its stream in `hermit/agent/base/trajectory.py`, and its model binding in @@ -93,8 +94,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. @@ -131,7 +132,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 @@ -212,6 +213,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 | Evolver Agent | `direct` (`ctx.agent`) | | 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; `codex`, one call per upstream `generate`, for the analyst, digesters and critic | | CL-Bench, ServeLearnBench | Acting Agent running the benchmark's loop | `direct`, per call, through the local endpoint | | MWM | Evolver Agent | `codex`, in a view of the run's sandbox with MWM's native mounts (`/w`, `/evidence`, ...) | 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 e7a76975..96524a46 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 cd6904a2..2ffa0837 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 21502c04..82278b54 100644 --- a/docs/get-started/installation.md +++ b/docs/get-started/installation.md @@ -101,6 +101,7 @@ never shown). | Harness² | Python 3.12 or 3.13, `python -m pip install -e . --group harness2`, Claude Code, and the execution environment described in [Harness²](../methods/harness2.md) | | RRSI Adapter | `python -m pip install -e . --group rrsi`, the proposer CLI (Codex or Claude Code), the Codex CLI for its analyst, digesters and critic, a training split evaluated k times per task | | Codex / Claude learner | Selected authenticated CLI and supported program-execution environment | +| 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 | | MWM | Linux learner environment, Codex, evidence store, task deployment contract, model access | | Benchmark recipe | Its dataset, scorer, model settings, and environment; see [catalog](../recipes/index.md) | diff --git a/docs/guides/model-backends.md b/docs/guides/model-backends.md index da169acd..91069a56 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,9 @@ 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), the backend's price (Pi's own cost figure follows it). + 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. diff --git a/docs/methods/agent.md b/docs/methods/agent.md index cb9080e9..7f7a3db6 100644 --- a/docs/methods/agent.md +++ b/docs/methods/agent.md @@ -1,7 +1,8 @@ -# 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 +Use `evolver.name: codex`, `claude` or `pi` when a learner should inspect trajectories, use tools, and edit an +adapter directory over several actions. The `pi` learner signs in through a model reference (`evolver.model: +/`); it copies no login from `~/.pi/agent` (`home: []`). 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. diff --git a/docs/reference/compatibility.md b/docs/reference/compatibility.md index 396052de..17ad5b1e 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 | +| 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 | | Workspace deployment | Codex, Claude Code, and Pi | | Native plugin conversion | Claude Code, Codex, Pi, Hermes, with export restrictions | | Stdio tool server | `hermit serve DIR`; normally started by the host | diff --git a/docs/reference/config-fields.md b/docs/reference/config-fields.md index e64bd365..66b2ae88 100644 --- a/docs/reference/config-fields.md +++ b/docs/reference/config-fields.md @@ -36,7 +36,7 @@ guarantee that their consumer supports arbitrary fields. | `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 | | `pipeline.run` | string / null | Legacy alias for `agent.name`, applied only when `agent.name` is unset | @@ -57,7 +57,7 @@ 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_access.environment` | list / [] | Granted environment resources; required for MWM's evidence integration | @@ -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}`, or the endpoint itself (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 5589bfce..0e3dbf24 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 @@ -66,7 +66,7 @@ from hermit.agent.base.interface import BaseAgent, Limits, Mount, make 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") @@ -138,6 +138,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) @@ -163,7 +166,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 [])] @@ -242,6 +248,11 @@ def run(task: dict, adapter: Path | None, *, params: dict, base: Path) -> RunOut return _run(task, adapter, params, base) +def _agent_name(backend: str) -> str: + """The acting agent's name for a backend (claude: claude-code).""" + return {"claude": "claude-code"}.get(backend, backend) + + def _check(backend: str, params: dict) -> str: """The backend; refuses what does not combine.""" backend = params.get("backend", backend) @@ -264,13 +275,13 @@ def _check(backend: str, params: dict) -> str: if params.get("login") not in (None, "chatgpt"): raise ValueError(f"agent.params.login is chatgpt or absent, not {params['login']!r}") if params.get("login") and backend != "codex": - raise ValueError("login: chatgpt is Codex's sign-in; agent claude-code takes its own") + raise ValueError(f"login: chatgpt is Codex's sign-in; agent {_agent_name(backend)} takes its own") if params.get("login") and (not params.get("model") or params.get("binding")): raise ValueError("with login: chatgpt, agent.params.model is a Codex model name, not empty or a " "/ reference") if params.get("isolation") == "container": if backend != "codex": - raise ValueError("isolation: container runs Codex; agent claude-code takes isolation: none") + raise ValueError(f"isolation: container runs Codex; agent {_agent_name(backend)} takes isolation: none") container.settings(params) # refuses unknown keys return backend @@ -279,7 +290,7 @@ 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", "effort", "isolation", "container", "codex_sandbox", "shell_env", "project_doc_max_bytes", - "args", "env", "price") + "args", "env", "price", "provider") 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 e8c821f6..cbb187ce 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. @@ -6,8 +6,8 @@ `CliAgent` runs every CLI the same way (isolation, home, sign-in, tool server, process, the stream's safety); what differs per CLI is its subclass's hooks: its command, its home (`home_env`, `prepare_home`, `user_home`), how its -stream is read (`served`, `notices`, `last_message`, `parse`, `steps`, `adjust_usage`), its web search, its system -instructions and its isolations. `CLIS` names every CLI; a new one is a subclass there, its stream's reader in +stream is read (`served`, `notices`, `last_message`, `parse`, `steps`, `adjust_usage`, `exit_code`), its web +search, its system instructions and its isolations. `CLIS` names every CLI; a new one is a subclass there, its stream's reader in hermit.agent.base.trajectory, and its model binding in hermit.agent.model.backends. Settings (given once; `configured(...)` for a call's own): @@ -55,6 +55,9 @@ caller that would rather start over from its own files Claude Code only: output_format (stream-json, or json: one result object), allowed_tools (its --tools), persist_session (false: --no-session-persistence), append_system_prompt, effort, permission_mode + Pi only: provider (its models.json entry for a bound model, from the binding: Binding.pi_provider), + 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): @@ -82,9 +85,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 ISOLATIONS = ("none", "sandbox", "container") # The CLI's own reports of a provider it could not reach (it retries those itself first), and of a container that @@ -92,8 +95,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"'] _ready: set = set() @@ -364,9 +368,16 @@ class CliAgent(BaseAgent): turn_done = "" # the event type of a turn the provider served # ---------- the CLI's own: its command and its home ---------- - def command(self, prompt: str, workspace: Path, last_file: Path, *, session=None, roles=(), tools_url=None) -> list: + def command(self, prompt: str, workspace: Path, last_file: Path, *, session=None, roles=(), tools_url=None, + home: Path | None = None) -> list: + """The CLI's command line; `home` is the session's home (where a CLI keeps the session to resume).""" raise NotImplementedError + def exit_code(self, code: int, raw: str) -> int: + """The session's exit code as the caller reads it: the CLI's own, for a CLI whose exit code says whether the + session failed.""" + return code + def home_env(self, home: Path) -> dict[str, str]: """The variables that point the CLI at the session's home.""" return {self.home_var: str(home)} @@ -682,7 +693,7 @@ def _session(self, prompt, *, workspace=None, tools: Sequence[Tool] = (), limits self.prepare_home(home, roles, server.mcp_url if server else None, name) env.update(self.home_env(home)) command = self.command(prompt, workspace, last_file, session=session, roles=roles, - tools_url=server.mcp_url if server else None) + tools_url=server.mcp_url if server else None, home=home) started = time.time() if conf is not None: for m in mounts: @@ -765,13 +776,14 @@ def _session(self, prompt, *, workspace=None, tools: Sequence[Tool] = (), limits if fresh: shutil.rmtree(home, ignore_errors=True) done = self.served(raw) - return Result(reply=reply, trajectory=steps, usage=usage, session=handle, exit_code=code, + read = self.exit_code(code, raw) + return Result(reply=reply, trajectory=steps, usage=usage, session=handle, exit_code=read, timed_out=timed_out, log=stream, extra={"served": done, "unavailable": bool(UNAVAILABLE.search(notes)), "refused": codex_login.refused(notes) and not done, "started": any(line.lstrip().startswith("{") for line in raw.splitlines()), "cancelled": cancelled, "isolation": isolation, "wall_s": round(wall_s, 1), - "output_tail": raw[-2000:]}) + "output_tail": raw[-2000:], **({"cli_exit_code": code} if read != code else {})}) class Codex(CliAgent): @@ -832,7 +844,7 @@ def adjust_usage(self, home, handle, usage, resumed): """Codex reports a thread's running total: a resumed thread's earlier calls are taken off (_this_call).""" return [_this_call(home, str(handle), usage[0], resumed=resumed)] if handle and usage else usage - def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools_url=None): + def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools_url=None, home=None): s = self.settings effort = s.get("effort") hooks = (workspace / ".codex" / "hooks.json").is_file() # the deployment's hooks, trusted as deployed @@ -891,7 +903,7 @@ def parse(cls, log, model): def steps(cls, stream): return claude_steps(stream) - def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools_url=None): + def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools_url=None, home=None): s = self.settings mcp = {"mcpServers": {s.get("tools_name") or "hermit_tools": {"type": "http", "url": tools_url}}} agents = {r["name"]: {"description": r["description"], "prompt": r["instructions"]} for r in roles} @@ -910,8 +922,148 @@ def command(self, prompt, workspace, last_file, *, session=None, roles=(), tools *(["--resume", str(session)] if session else []), *(s.get("args") or [])] +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: the exit code is read from the stream (`exit_code`).""" + 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.""" + return {"PI_CODING_AGENT_DIR": str(home), "HOME": str(home), "PI_OFFLINE": "1", "PI_TELEMETRY": "0"} + + @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 + (home / "models.json").write_text(json.dumps({"providers": {PI_PROVIDER: provider}}, indent=1)) + servers = {tools_name: {"url": tools_url, "exposure": "direct"}} 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 exit_code(self, code, raw): + """1 for a session whose last request failed or was aborted although Pi exited 0 (its JSON mode does).""" + if code == 0 and self._last_assistant(raw).get("stopReason") in ("error", "aborted"): + return 1 + return code + + @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, home=None): + s = self.settings + system = s.get("append_system_prompt") + if system and len(str(system).encode()) > ARG_MAX and home is not None: # 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 and home is not None 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//