docs: pi 0.84.3 runtime mechanics, isolation baseline, personality layering, gateway patterns

Establishes this repository as the authoritative source for Pi agent
configuration across scenarios, starting with the documentation layer.

Key verified findings (probe harness included, zero model tokens):

- The skills section of the system prompt is emitted only when an active tool
  named 'read' exists (system-prompt.js:59,113). Therefore --no-tools silently
  makes every SKILL.md unreachable and --skill a no-op.
- registerTool accepts a plain JSON Schema object, so tool definitions can be
  served from a backend instead of duplicated in TypeScript.
- An extension can shadow a built-in tool by name, which is how a dedicated
  agent gets a path-restricted 'read' while still satisfying the rule above.
- .pi/SYSTEM.md replaces pi's coding-assistant prompt, but the replacement
  branch contributes neither the tool list nor the guidelines.
- Without --no-skills/--no-extensions, user-global resources leak into every
  scenario; probed leak was find-skills, modsearch, summarize.

Measured effect of the full baseline: system prompt 2619 -> 960 characters,
coding-assistant framing and pi-docs paths removed, skill finally reachable.

Secrets are guarded by scripts/verify-no-secrets.sh, installed as a pre-commit
hook. Backups deliberately live outside the repository.
This commit is contained in:
Kai
2026-08-26 22:47:53 -07:00
commit cbba8faabc
13 changed files with 1202 additions and 0 deletions
@@ -0,0 +1,60 @@
### C · Curator 现状 (--no-tools)
```
flags: --no-tools
PROBE_ALL_TOOLS=[]
PROBE_ACTIVE_TOOLS=[]
PROBE_SP_HAS_SKILLS=false
PROBE_SP_HAS_PROBE_SKILL=false
PROBE_SP_HAS_AGENTS=true
PROBE_SP_HAS_CODING_ASSISTANT=true
PROBE_SP_HAS_SYSTEM_MD=false
PROBE_SP_HAS_PI_DOCS=true
PROBE_SP_LEN=1859
PROBE_SKILLNAMES=[]
```
### A · --no-builtin-tools,无隔离旗标
```
flags: --no-builtin-tools
PROBE_ALL_TOOLS=["read[cli]","bash[builtin]","powershell[builtin]","edit[builtin]","write[builtin]","grep[builtin]","find[builtin]","ls[builtin]","probe_plain_schema[cli]"]
PROBE_ACTIVE_TOOLS=["probe_plain_schema","read"]
PROBE_SP_HAS_SKILLS=true
PROBE_SP_HAS_PROBE_SKILL=false
PROBE_SP_HAS_AGENTS=true
PROBE_SP_HAS_CODING_ASSISTANT=true
PROBE_SP_HAS_SYSTEM_MD=false
PROBE_SP_HAS_PI_DOCS=true
PROBE_SP_LEN=3413
PROBE_SKILLNAMES=["find-skills","modsearch","summarize"]
```
### F · +--no-skills --skill
```
flags: --no-builtin-tools --no-skills --skill /tmp/pi-probe/.pi/skills/probe-skill
PROBE_ALL_TOOLS=["read[cli]","bash[builtin]","powershell[builtin]","edit[builtin]","write[builtin]","grep[builtin]","find[builtin]","ls[builtin]","probe_plain_schema[cli]"]
PROBE_ACTIVE_TOOLS=["probe_plain_schema","read"]
PROBE_SP_HAS_SKILLS=true
PROBE_SP_HAS_PROBE_SKILL=true
PROBE_SP_HAS_AGENTS=true
PROBE_SP_HAS_CODING_ASSISTANT=true
PROBE_SP_HAS_SYSTEM_MD=false
PROBE_SP_HAS_PI_DOCS=true
PROBE_SP_LEN=2619
PROBE_SKILLNAMES=["probe-skill"]
```
### I · 目标最终组合 (+.pi/SYSTEM.md +--approve)
```
flags: --no-builtin-tools --no-skills --skill /tmp/pi-probe/.pi/skills/probe-skill --approve
PROBE_ALL_TOOLS=["read[cli]","bash[builtin]","powershell[builtin]","edit[builtin]","write[builtin]","grep[builtin]","find[builtin]","ls[builtin]","probe_plain_schema[cli]"]
PROBE_ACTIVE_TOOLS=["probe_plain_schema","read"]
PROBE_SP_HAS_SKILLS=true
PROBE_SP_HAS_PROBE_SKILL=true
PROBE_SP_HAS_AGENTS=true
PROBE_SP_HAS_CODING_ASSISTANT=false
PROBE_SP_HAS_SYSTEM_MD=true
PROBE_SP_HAS_PI_DOCS=false
PROBE_SP_LEN=960
PROBE_SKILLNAMES=["probe-skill"]
```
@@ -0,0 +1,2 @@
CURATOR_SYSTEM_MARKER
你是一个测试用的专用 agent,不是编码助手。
@@ -0,0 +1,7 @@
---
name: probe-skill
description: PROBE_SKILL_MARKER - a probe skill used only to verify that pi surfaces the skills section in the system prompt under a given flag combination.
---
# Probe Skill Body
PROBE_SKILL_BODY_MARKER
@@ -0,0 +1,2 @@
# Probe Workspace
AGENTS_MARKER_PRESENT
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
run() { L="$1"; shift; echo "### $L"; echo '```'; echo "flags: $*"; printf '{"id":"1","type":"get_state"}\n' | timeout 60 /home/claw/.npm-global/bin/pi --mode rpc --no-session --no-extensions -e /tmp/pi-probe/probe-ext.ts --no-prompt-templates --no-themes --provider zenmux --model openai/gpt-5.6-luna "$@" 2>&1 >/dev/null | grep -E "^PROBE_"; echo '```'; echo; }
run "C · Curator 现状 (--no-tools)" --no-tools
run "A · --no-builtin-tools,无隔离旗标" --no-builtin-tools
run "F · +--no-skills --skill" --no-builtin-tools --no-skills --skill /tmp/pi-probe/.pi/skills/probe-skill
run "I · 目标最终组合 (+.pi/SYSTEM.md +--approve)" --no-builtin-tools --no-skills --skill /tmp/pi-probe/.pi/skills/probe-skill --approve
+50
View File
@@ -0,0 +1,50 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
// Probe 1: does registerTool accept a PLAIN JSON Schema object (not TypeBox)?
const plainJsonSchema = {
type: "object",
properties: { q: { type: "string", description: "probe query" } },
required: ["q"],
} as any;
export default function probe(pi: ExtensionAPI) {
pi.registerTool({
name: "probe_plain_schema",
label: "Probe Plain Schema",
description: "Probe tool declared with a raw JSON Schema object.",
promptSnippet: "probe_plain_schema: raw JSON Schema probe",
parameters: plainJsonSchema,
async execute() {
return { content: [{ type: "text" as const, text: "PROBE_PLAIN_OK" }], details: {} };
},
});
// Probe 2: override the builtin `read`
pi.registerTool({
name: "read",
label: "Restricted Read",
description: "RESTRICTED_READ_OVERRIDE probe.",
promptSnippet: "read: restricted read override",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } as any,
async execute(_id, params: any) {
return { content: [{ type: "text" as const, text: `RESTRICTED_READ_CALLED ${params.path}` }], details: {} };
},
});
pi.on("session_start", async (_e, ctx) => {
const all = pi.getAllTools().map((t) => `${t.name}[${(t as any).sourceInfo?.source ?? "?"}]`);
const active = pi.getActiveTools();
const sp = ctx.getSystemPrompt?.() ?? "";
console.error("PROBE_ALL_TOOLS=" + JSON.stringify(all));
console.error("PROBE_ACTIVE_TOOLS=" + JSON.stringify(active));
console.error("PROBE_SP_HAS_SKILLS=" + String(sp.includes("available_skills")));
console.error("PROBE_SP_HAS_PROBE_SKILL=" + String(sp.includes("PROBE_SKILL_MARKER")));
console.error("PROBE_SP_HAS_AGENTS=" + String(sp.includes("AGENTS_MARKER_PRESENT")));
console.error("PROBE_SP_HAS_CODING_ASSISTANT=" + String(sp.includes("expert coding assistant")));
console.error("PROBE_SP_HAS_SYSTEM_MD=" + String(sp.includes("CURATOR_SYSTEM_MARKER")));
console.error("PROBE_SP_HAS_PI_DOCS=" + String(sp.includes("Pi documentation")));
console.error("PROBE_SP_LEN=" + String(sp.length));
const names = [...sp.matchAll(/<name>([^<]+)<\/name>/g)].map(m=>m[1]);
console.error("PROBE_SKILLNAMES=" + JSON.stringify(names));
});
}
+236
View File
@@ -0,0 +1,236 @@
# Gateway Patterns
> Patterns for hosting a Pi agent behind a long-running service (Telegram, HTTP,
> queue). Distilled from the **memo-inbox** scenario, which has run these in
> production since 2026-07, and from the defects found in the **curator**
> scenario, which reinvented the same problems worse.
>
> Mechanism references: [`pi-runtime-notes.md`](pi-runtime-notes.md) §§1318.
---
## Pattern 1 — One long-lived RPC process, not one process per message
**Do:** start `pi --mode rpc` once, drive it turn by turn over stdin.
**Don't:** spawn `pi --print` per message.
Per-message spawning pays node startup, resource discovery, skill loading and
session deserialization on every single turn. The curator scenario spawned 24
processes per Telegram message; with a 120 s per-invocation timeout its worst
case was ~480 s of model time for one message, with no overall budget.
An RPC process additionally gives you `abort`, `set_model`,
`set_thinking_level`, `compact` and `get_session_stats` at runtime — none of
which are reachable from `--print`.
### Framing is strict JSONL
Split on `\n` **only**, strip a trailing `\r`. Do not use a generic line reader:
Node's `readline` also splits on `U+2028`/`U+2029`, which are legal inside JSON
strings. The reference implementation:
```python
buffer = b""
while chunk := await proc.stdout.read(65536):
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
line = line.rstrip(b"\r")
if not line:
continue
await events.put(json.loads(line))
```
### Correlate by `id`, and understand what `success` means
Every command may carry an `id`; the matching `response` echoes it. But
`success: true` only means *accepted*. Failures after acceptance never produce a
second response for that id — they arrive as events. So a request is complete
when you have seen both the `response` **and** the terminal event you care about
(`agent_settled`, or `message_end` for a reply).
---
## Pattern 2 — Rotate sessions on a counter
Pi has no session TTL, rotation or size cap, and auto-compaction only fires near
the context window — with a 1.05 M-token model that is effectively never.
memo-inbox rotates after a fixed number of prompts:
```
PI_SESSION_ROTATE_AFTER_PROMPTS=24
```
The curator scenario did not. Its per-chat session reached 174 KB / 74 messages,
growing ~4.7 KB per turn because every turn embedded the full backend fact
payload of that turn. Latency, cost and timeout risk degrade monotonically.
Rotate on whichever bound you can measure: prompt count, session bytes, or
`get_session_stats().contextUsage.percent`. Prefer starting a fresh
`--session-id` suffix over relying on `compact`, so that history is auditable.
---
## Pattern 3 — Derive write receipts from `tool_execution_end`, never from prose
This is the single most valuable pattern in memo-inbox. The gateway subscribes to
tool completion events and extracts the receipt from the **tool result**:
```python
elif (event.get("type") == "tool_execution_end"
and event.get("toolName") in MUTATION_TOOLS):
result = event.get("result") or {}
receipt = "\n".join(b.get("text", "") for b in result.get("content") or [])
```
The model's prose is then a summary, not the record of what happened.
Compare the curator scenario, which asked the model to phrase the outcome and
relied on prompt rules to keep the distinction. Observed production output when
`has_file` was `false`:
> 《My Brilliant Career》4K 版**已成功加入库中**,但目前还没有 4K 文件。
"加入库中" reads as *in the library* while the item had only been added to the
tracker. Prose guardrails did not hold. Deterministic receipts do.
**Rule:** anything a user might act on — created, added, deleted, scheduled —
must be rendered by code from a tool result, not generated by the model.
---
## Pattern 4 — Structured extraction via a terminating tool
For classification and extraction there is no need for session state, skills, or
prose parsing. Define a tool whose `parameters` *is* the output schema:
```typescript
{
name: "emit_extraction",
parameters: <generated JSON Schema>,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_id, params) {
return { content: [{ type: "text", text: "recorded" }],
details: params, terminate: true };
}
}
```
Run these calls with `--no-session` so they cannot pollute the conversational
history, and at a lower thinking level than the conversational turn.
This removes an entire failure class. The curator scenario extracted JSON with
`re.compile(r"\{.*\}", re.DOTALL)` — a greedy match from the first `{` to the
last `}` — and reused one post-processor across three different schemas, which
injected an `items: []` key into every intent plan.
---
## Pattern 5 — Separate the stateful and stateless roles
| Role | Session | Skills | Thinking | Process |
|---|---|---|---|---|
| Conversation / answering | persistent, rotated | yes | high | long-lived RPC |
| Classification / extraction / synthesis | `--no-session` | no | lowmedium | short-lived `--print --mode json` |
Sharing one session across both roles causes mode confusion: the curator session
alternated "只输出 JSON,不回答问题" and "直接回答用户,不要输出 JSON" for 37
consecutive turns, and each turn carried the previous turn's full fact payload.
---
## Pattern 6 — Project a fact pack; do not hand over raw backend responses
Everything you put in a prompt is context you pay for and surface you expose.
Project backend results through an explicit allowlist before they reach the
model.
Observed in a single production curator prompt: filesystem paths
(`/mnt/truenas/multimedia/tv/…`, `/mnt/unRaid/tv4k/…`), `size_on_disk`,
`quality_profile_id`, internal database ids, a static `capabilities` block
resent every turn, and an `action_result.regular_matches` object that duplicated
verbatim an object already present under `library.matches`.
None of it was needed to answer "is there a 4K version".
Budget by estimated tokens, not characters — for CJK text the ratio is close to
1:1, so an 80 000-character cap is an 80 000-token cap.
---
## Pattern 7 — Close the timeout budget, and kill the process group
Per-invocation timeouts do not compose. Define **one deadline per user
message**, allocate it across stages, and enforce it with RPC `abort` rather
than by killing the process.
When you do have to kill: pi is a node CLI that may spawn children. Launch with
`start_new_session=True` and terminate with `killpg`, otherwise orphaned node
processes keep consuming provider quota.
---
## Pattern 8 — Keep provider and backend credentials out of the node process
`subprocess` inherits the parent environment by default. A gateway unit that
carries `*_API_KEY` for Radarr, Sonarr, Plex, Telegram and a search provider
hands all of them to pi and to every extension it loads.
Pass an explicit minimal `env=` (`PATH`, `HOME`, `LANG`, plus the few
`PI_*`/bridge variables the extension genuinely needs). The provider key belongs
in `~/.pi/agent/models.json`, which pi reads itself.
Both scenarios in this repository currently inherit the full environment. This is
the highest-value shared fix.
---
## Pattern 9 — Bridge host capabilities over loopback HTTP
There is no RPC command to inject a tool result from the host, so host-side
capabilities must be exposed as extension tools that call back out. memo-inbox
does this for document parsing (`127.0.0.1:8090`) and OCR.
Recommended shape:
- backend listens on `127.0.0.1` only, with a per-start shared secret passed to
the extension through `env`;
- backend serves both the tool **definitions** (JSON Schema) and the tool
**invocations**, so the schema has one source of truth;
- the extension is a generic proxy — `registerTool` accepts a plain JSON Schema
object (verified), so tool definitions need not be duplicated in TypeScript;
- every tool truncates its own output before returning.
---
## Pattern 10 — Observability is already in the stream
Do not build separate instrumentation. `message_update` carries `usage`
(input / output / cacheRead / cacheWrite / cost) and `get_session_stats` returns
totals plus `contextUsage`. Persist per-turn model, thinking level, latency,
tokens, cost and fallback into the service's own event ledger.
The curator scenario discarded `stderr` on success and kept only the last line on
failure, so "how often does the primary model time out" was answerable only by
grepping journald for a Chinese notice string.
---
## Conformance
| Pattern | memo-inbox | curator (as found) |
|---|---|---|
| 1 long-lived RPC | yes | no — 24 spawns/message |
| 2 session rotation | yes — 24 prompts | no — unbounded |
| 3 deterministic receipts | yes | no — model prose, observed failure |
| 4 terminating-tool schema | n/a (tools are the output) | no — greedy regex |
| 5 role separation | n/a (single role) | no — shared session |
| 6 fact-pack projection | n/a | no — raw responses |
| 7 closed timeout budget | partial (600 s event wait) | no |
| 8 minimal `env` | no | no |
| 9 loopback bridge | yes | n/a (no tools at all) |
| 10 usage observability | not persisted | not persisted |
Shared implementations live in `shared/lib/py/` and `shared/extensions/`.
+172
View File
@@ -0,0 +1,172 @@
# Isolation Baseline for a Dedicated Pi Agent
> Applies to every scenario in `scenarios/`. Mechanisms verified against
> pi 0.84.3 — see [`pi-runtime-notes.md`](pi-runtime-notes.md) for the source
> quotes and [`evidence/`](evidence/) for reproduction.
A "dedicated agent" is not achieved by narrowing what the model is *told*. It is
achieved by narrowing what the process *loads* and what it *can call*. Pi offers
four independent layers. Use all four; each one covers a different failure mode.
---
## The four layers
```
┌──────────────────────────────────────────────────────────────┐
│ L1 Loading what enters the process at all │
│ --no-extensions -e / --no-skills --skill / │
│ --no-prompt-templates / --no-themes / -nc / project trust │
├──────────────────────────────────────────────────────────────┤
│ L2 Personality who the agent is │
│ .pi/SYSTEM.md (replace) · APPEND_SYSTEM.md · AGENTS.md │
│ AGENTS.override.md · --system-prompt │
├──────────────────────────────────────────────────────────────┤
│ L3 Capability which tools exist and are active │
│ --no-builtin-tools · --tools · --exclude-tools │
│ pi.setActiveTools() · same-name registerTool override │
├──────────────────────────────────────────────────────────────┤
│ L4 Invocation per-call authorization │
│ pi.on("tool_call") -> { block: true, reason } │
└──────────────────────────────────────────────────────────────┘
```
Layers 3 and 4 must be enforced **inside the extension**, not only on the
command line, so that a flag mistake in a systemd unit cannot silently widen the
agent's reach.
---
## Baseline flag set
```bash
pi --mode rpc \
--session-id "<scenario>-<subject>" \
--session-dir "<dedicated session dir>" \
--no-builtin-tools \
--no-extensions -e "<abs>/scenario-tools.ts" \
--no-skills --skill "<abs>/.pi/skills/<name>" \
--no-prompt-templates \
--no-themes \
--approve \
--provider "<provider>" --model "<model>" --thinking "<level>" \
--name "<display name>"
```
Launched with:
| Property | Value | Reason |
|---|---|---|
| `cwd` | the scenario workspace | bounds `AGENTS.md` and `.pi` discovery |
| `env` | explicit minimal allowlist | keeps backend API keys out of the node process |
| `start_new_session=True` | yes | lets the parent kill the whole process group |
| timeout handling | `killpg`, not `kill` | pi is a node CLI and may leave children |
### Why each flag
| Flag | Failure mode it prevents |
|---|---|
| `--no-extensions` + `-e <abs>` | `~/.pi/agent/extensions/*.ts` (currently `herdr-agent-state.ts`, `pi-memo-trust.ts`) being injected into an unrelated agent. The absolute `-e` path also loads before project trust resolves, so the extension works without trusting the directory. |
| `--no-skills` + `--skill <abs>` | `~/.agents/skills/*` leaking in. Probed leak: `["find-skills","modsearch","summarize"]``find-skills` actively instructs the agent to install more skills. |
| `--no-prompt-templates` | `~/.pi/agent/prompts/*.md` becoming callable slash commands. |
| `--no-themes` | pointless discovery I/O and a source of run-to-run variation. |
| `--no-builtin-tools` | `bash`/`edit`/`write`/`grep`/`find`/`ls` being active. Chosen over `--tools` because it imposes no registry-level allowlist, so an extension may register tools dynamically. Built-ins stay registered-but-inactive; the extension governs the active set. |
| `--approve` | required to load `.pi/SYSTEM.md` and `.pi/settings.json`. Combined with `--no-skills` it does **not** re-open skill auto-discovery. |
| `--session-id` + `--session-dir` | sessions colliding with the user's interactive history. |
### Do not use `--no-tools`
It disables extension tools as well, which removes `read`, which removes the
skills block from the system prompt (see `pi-runtime-notes.md` §1). Any
`SKILL.md` then becomes dead weight. This was a real, long-lived defect in the
Curator scenario.
---
## Required extension responsibilities
A scenario extension must do all five:
```typescript
const ALLOWED_TOOLS = ["read", /* … scenario tools … */];
export default function scenarioGuard(pi: ExtensionAPI) {
// 1. path-restricted `read` override — shadows the built-in by name
pi.registerTool({ name: "read", /* allowlist-checked implementation */ });
// 2. scenario tools, each with promptSnippet so they appear in the prose list
pi.registerTool({ name: "…", promptSnippet: "…", promptGuidelines: [ "…" ] });
// 3. declarative active set, reasserted after resource discovery
const restrict = () => pi.setActiveTools(ALLOWED_TOOLS);
pi.on("session_start", async () => restrict());
pi.on("resources_discover", async () => restrict());
// 4. per-call gate — defence in depth against layer-3 mistakes
pi.on("tool_call", async (event) => {
if (!ALLOWED_TOOLS.includes(event.toolName)) {
return { block: true, reason: `<scenario> blocks tool: ${event.toolName}` };
}
});
// 5. every tool truncates its own output (50 KB / 2000 line caps)
}
```
The `read` override must still permit the skill directory, otherwise skill
bodies cannot be loaded (progressive disclosure, `pi-runtime-notes.md` §2).
---
## Measured effect
Same workspace, same extension, only flags differ:
| Configuration | system prompt | active tools | skills visible | coding-assistant persona | pi-docs paths |
|---|---:|---|---|---|---|
| `--no-tools` (Curator, as found) | 1859 | *(none)* | **no** | yes | yes |
| `--no-builtin-tools`, no isolation | 3413 | own + `read` | 3 foreign | yes | yes |
| `+ --no-skills --skill` | 2619 | own + `read` | **1, correct** | yes | yes |
| `+ .pi/SYSTEM.md --approve` | **960** | own + `read` | 1, correct | **no** | **no** |
Full baseline is 72 % smaller than the unisolated default *and* strictly more
capable, because the skill is finally reachable.
---
## Residual risk
1. **No kernel boundary.** Pi ships no sandbox; extensions run with the process's
full permissions. The `read` override is application-level. A future step is
bubblewrap (`examples/extensions/sandbox/`) or a container.
2. **`env` discipline is the only secret boundary.** Without an explicit `env=`
allowlist the node process inherits every `*_API_KEY` in the unit file.
3. **Parent-directory context files.** Nothing prevents a future
`~/AGENTS.md` from layering into every scenario. Mitigate with `-nc` plus
`SYSTEM.md`, or an `AGENTS.override.md` in the workspace.
4. **Workspace should be read-only to the service.** `--approve` trusts
everything project-local, so a writable `.pi/` is a code-execution path.
Enforce with systemd `ReadOnlyPaths=`.
5. **Prompt injection is not solved by any of this.** Untrusted text must be
delimited and labelled as data, and any write must be gated by deterministic
code outside the model.
---
## Per-scenario conformance
| Scenario | L1 loading | L2 personality | L3 capability | L4 invocation | `env` minimal |
|---|---|---|---|---|---|
| `curator` | target | target | target | target | target |
| `memo-inbox` | **gap** | **gap** | ok (`setActiveTools`) | ok (`tool_call`) | **gap** |
| `pi-grok` | not assessed | not assessed | not assessed | not assessed | not assessed |
memo-inbox already implements L3 and L4 correctly and is the reference for those
layers; its L1/L2/`env` gaps are tracked as a follow-up and must not be changed
in the same pass that migrates its configuration into this repository.
Check conformance with:
```bash
scripts/pi-diff.sh <scenario>
```
+128
View File
@@ -0,0 +1,128 @@
# Personality Layering
> How to decide what goes in `SYSTEM.md`, `AGENTS.md`, `SKILL.md` and the
> per-request prompt. Mechanisms verified against pi 0.84.3; see
> [`pi-runtime-notes.md`](pi-runtime-notes.md) §§2, 8, 9, 19.
Four slots exist and they are not interchangeable. Putting content in the wrong
slot is why rules get duplicated three times and then drift.
---
## Assembly order
With `.pi/SYSTEM.md` present and trusted, pi builds the system prompt as:
```
SYSTEM.md
→ APPEND_SYSTEM.md
→ <project_context> … AGENTS.md from ~/.pi/agent, each parent dir, cwd
→ <available_skills> … name + description + location only
→ "Current working directory: …"
```
Without `SYSTEM.md`, the first slot is pi's built-in **coding assistant** prompt,
which also contributes a tool list, a guidelines list, and absolute paths to
pi's own documentation.
Critically: **the `SYSTEM.md` branch contributes neither the tool list nor the
guidelines.** If you replace, you own both.
---
## Slot assignment
| Slot | Loaded | Cost | Put here |
|---|---|---|---|
| `.pi/SYSTEM.md` | needs trust (`--approve`) | always in context | Identity. Tool overview. Fact-authority map. Write discipline. Untrusted-data rule. Output format. |
| `.pi/APPEND_SYSTEM.md` | needs trust | always in context | Nothing, normally. Use only when you want to keep pi's default prompt and bolt something on. |
| `AGENTS.md` (workspace) | always | always in context | Durable role, responsibilities, routing, domain defaults, escalation policy. Human-editable narrative. |
| `AGENTS.override.md` | always | always in context | Same as `AGENTS.md`, but also **stops** `AGENTS.md`/`CLAUDE.md` from that directory. Use to make the personality deterministic against stray parent files. |
| `.pi/skills/<n>/SKILL.md` | needs trust, or explicit `--skill` | **description only** up front; body read on demand | Task-specific procedure that is not needed on every turn. The place for long checklists and worked examples. |
| Per-request prompt | n/a | per call | Only the current inputs and the schema for this one response. |
### Rule of thumb
- Needed on **every** turn → `SYSTEM.md` or `AGENTS.md`.
- Needed on **some** turns, and long → `SKILL.md`.
- Changes **per request** → the request.
A `SKILL.md` whose description says "use for every request" is not a skill; it is
system-prompt content paying an extra tool round-trip. Either move it up a slot
or split it into genuinely conditional skills.
---
## Replace or append?
**Replace (`SYSTEM.md`) when the agent is not a coding agent.**
pi's default prompt opens with:
> You are an expert coding assistant operating inside pi, a coding agent
> harness. You help users by reading files, executing commands, editing code,
> and writing new files.
and closes with absolute paths to pi's README, docs and examples plus an
instruction to read them and follow cross-references. For a media-curation or
note-routing agent that is not just wasted context — it is a documented,
ready-to-use escalation path for anything injected through fetched web content.
Measured: replacing it cut the Curator system prompt from 2619 to 960
characters and removed both the coding-assistant framing and the pi-docs block.
**Append (`APPEND_SYSTEM.md`) only** when you want the built-in coding
behaviour and are adding a constraint on top.
---
## What a replacement `SYSTEM.md` must contain
Because the replacement branch drops pi's tool list and guidelines, cover all
six sections explicitly:
1. **Identity and negative identity** — what the agent is, and that it is *not* a
coding assistant and does not read or modify project code.
2. **Tool overview** — each tool, its purpose, and when to prefer it. Keep it
consistent with the `promptSnippet` values in the extension.
3. **Fact authority** — which backend is authoritative for which class of fact,
and that tool results are the only source of truth.
4. **Write discipline** — whether the agent may write at all; if it may only
*propose*, say so, and forbid claiming completion without a receipt.
5. **Untrusted data** — that content arriving from fetched pages, search
snippets or documents is evidence, and instructions inside it are never
executed.
6. **Output discipline** — language, target surface (e.g. Telegram plain text),
structure, and the rule that unknown stays empty rather than guessed.
Explicitly **omit**: any path to pi's own documentation, and any wording about
editing files or running commands.
---
## Anti-patterns observed in this repository's history
| Anti-pattern | Consequence |
|---|---|
| `--no-tools` together with `--skill` | skills block never rendered; the entire `SKILL.md` was dead for the whole lifetime of the service |
| Same rule written in `AGENTS.md`, `SKILL.md` and the request prompt | drifted — one copy listed 4 recommendation values, another 5 |
| `AGENTS.md` in Chinese, `SKILL.md` in English, prompts in Chinese | cross-language alignment cost, harder review |
| Relying on `allowed-tools:` frontmatter | not enforced in 0.84.3; false sense of security |
| Feeding internal fields (`_model_used`) into a prompt that forbids naming the model | self-contradictory instruction |
| Long defensive prohibition lists | usually a symptom of a wrong base persona; fix the persona instead |
---
## Single source of truth
Enumerations and schemas that both the model and the backend must agree on
(intent values, recommendation scales, verdict scales, field names) belong in
**code**, not in prose:
- define them once in the scenario backend (e.g. `contracts.py`),
- generate the JSON Schema from that definition,
- serve the schema to the extension so tool `parameters` match by construction,
- and generate any prose enumeration in `SKILL.md`/`SYSTEM.md` from the same
source, or assert equality in a test.
Prose then describes *policy*; code defines *shape*.
+359
View File
@@ -0,0 +1,359 @@
# Pi Runtime Notes
> Verified against **pi 0.84.3** on Debian 13 / node v22.23.2, 2026-08-27.
> Every claim below is either quoted from `dist/` source or reproduced by the
> probe harness in [`evidence/probe-harness/`](evidence/probe-harness/).
> Results: [`evidence/2026-08-27-isolation-probe.md`](evidence/2026-08-27-isolation-probe.md).
This file records behaviour that the official docs either do not state or state
only in passing, but which determines whether a dedicated Pi agent works at all.
Re-verify after every `pi update`.
---
## 1. The skills section requires an active `read` tool
`dist/core/system-prompt.js`:
```javascript
const hasRead = tools.includes("read");
// Append skills section (only if read tool is available)
if (hasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
```
`tools` is the **active** tool-name list, passed as `selectedTools` from
`agent-session.js:_rebuildSystemPrompt(this.getActiveToolNames())`.
**Consequence:** `--no-tools` silently removes the entire skills block.
Any `SKILL.md` is then unreachable, and `--skill <path>` becomes a no-op.
Probe evidence (`--no-tools` vs `--no-builtin-tools`):
| Flags | `available_skills` in prompt | prompt length |
|---|---|---|
| `--no-tools` | **false** | 1859 |
| `--no-builtin-tools` | true | 3413 |
This was a live defect in the Curator scenario: it ran `--no-tools --skill …`
for its whole lifetime, so its 64-line media policy never reached the model.
## 2. Skill injection is progressive, not full text
`formatSkillsForPrompt` emits only `<name>`, `<description>` and `<location>`
per skill, plus the instruction "Use the read tool to load a skill's file when
the task matches its description."
So the body of a `SKILL.md` costs nothing up front but **is only ever read via
the `read` tool**. A skill body is unreachable without a working `read`.
Corollary: if you override `read` with a path-restricted implementation, the
allowlist must include the skill directory or skill bodies cannot be loaded.
## 3. `--skill` does not double-load an auto-discovered skill
`dist/core/skills.js:loadSkills()` dedupes twice — by canonical realpath, and
by skill `name`. Defaults are added first, so on a name collision the
**auto-discovered** skill wins and a collision diagnostic is emitted.
Same file via symlink is skipped silently.
## 4. Tool gating semantics
| Flag | registry filter | initial active set | extension tools |
|---|---|---|---|
| *(none)* | none | `settings.defaultTools ?? [read,bash,edit,write]` | all activated |
| `--tools a,b` | **strict allowlist across every source** | `[a,b]` | must be named to exist |
| `--no-tools` | empty allowlist | `[]` | **also killed** |
| `--no-builtin-tools` | none | `[]` | **all activated** |
| `--exclude-tools x` | removes `x` | filtered | applies to all sources |
From `dist/core/agent-session.js`:
```javascript
const isAllowedTool = (name) =>
(!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
```
Under `--no-builtin-tools` the built-ins stay **registered but inactive**, so an
extension can re-enable them with `pi.setActiveTools([...])`. Under `--no-tools`
they were never registered and cannot be revived.
Probed: `--no-builtin-tools` + one extension →
`ACTIVE_TOOLS=["probe_plain_schema","read"]`, while `bash/edit/write/grep/find/ls`
remain listed as `[builtin]` and inactive.
## 5. `registerTool` accepts a plain JSON Schema object — verified
Declared type is TypeBox `TSchema`, but a raw JSON Schema object works:
```typescript
pi.registerTool({
name: "probe_plain_schema",
parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } as any,
...
});
```
Probe result: the tool appears as `probe_plain_schema[cli]` and is active.
**Why this matters:** a backend can serve its tool definitions as JSON Schema
over HTTP and a generic bridge extension can register them at runtime, so the
schema has exactly one source of truth. See `shared/extensions/`.
Note `--tools` is a *registry-level* allowlist, so dynamically registered tools
must either be named in `--tools` or you must use `--no-builtin-tools`
(no allowlist) and let the extension govern the active set.
## 6. An extension can override a built-in tool by name — verified
Registering `name: "read"` replaces the built-in in the registry: the probe
showed `read[cli]` instead of `read[builtin]` while other built-ins kept
`[builtin]`. This is the supported way to give an agent a path-restricted
`read` while still satisfying the requirement in §1.
`memo-guard.ts` in the memo-inbox scenario has used this in production.
## 7. Only tools with `promptSnippet` appear in the human-readable tool list
`dist/core/system-prompt.js`: "A tool appears in Available tools only when the
caller provides a one-line snippet."
The tool *schemas* are always sent through the provider's tool-calling API, so
an omitted `promptSnippet` does not break invocation — but the model loses the
prose overview. Always set `promptSnippet`, and `promptGuidelines` where useful.
## 8. `.pi/SYSTEM.md` replaces the default prompt; the replacement branch omits tools and guidelines
Default prompt (`system-prompt.js:82`) begins:
> You are an expert coding assistant operating inside pi, a coding agent harness.
> You help users by reading files, executing commands, editing code, and writing new files.
and ends with a block of **absolute paths to pi's own README / docs / examples**
plus "read the docs and examples, and follow .md cross-references before
implementing".
For a non-coding agent this is not merely noise: it is a ready-made escalation
path for prompt injection.
The `customPrompt` branch (`system-prompt.js:13-33`) concatenates only:
```
SYSTEM.md → APPEND_SYSTEM.md → <project_context>(AGENTS.md) → skills section → cwd
```
It does **not** include `toolsList` or `Guidelines`. A replacement `SYSTEM.md`
must therefore carry its own tool overview and output discipline.
`.pi/SYSTEM.md` lives under `.pi/`, so it requires **project trust** — pass
`--approve` in non-interactive modes.
Probe evidence:
| Combination | coding-assistant text | pi-docs block | SYSTEM.md applied | prompt length |
|---|---|---|---|---|
| isolation flags only | true | true | false | 2619 |
| `+ .pi/SYSTEM.md + --approve` | **false** | **false** | **true** | **960** |
## 9. Context files are loaded from every parent directory
Load order: `~/.pi/agent/AGENTS.md`, then each parent directory walking up from
cwd, then cwd. `AGENTS.override.md` replaces `AGENTS.md`/`CLAUDE.md` **for that
directory only**; other directories still layer normally.
So a stray `~/AGENTS.md` or `~/pi-workspaces/AGENTS.md` silently contaminates
every scenario rooted below it. Neither existed on this host as of 2026-08-27,
but nothing prevents one from appearing. Use `-nc` plus an explicit
`SYSTEM.md`, or an `AGENTS.override.md` in the workspace, to make the
personality deterministic. Probed: `-nc` removes the AGENTS.md marker.
## 10. Project trust gates `.pi/`, and CLI `-e` bypasses it
Trust is required when the directory contains `.pi/settings.json`,
`.pi/{extensions,skills,prompts,themes}`, `.pi/SYSTEM.md`,
`.pi/APPEND_SYSTEM.md`, or a project `.agents/skills`. A bare `.pi` does not
count.
Always loaded regardless of trust: `AGENTS.override.md`, `AGENTS.md`,
`CLAUDE.md`, user/global extensions, and **CLI `-e` extensions** — the last one
is deliberate so they can handle the `project_trust` event.
Non-interactive modes (`-p`, `--mode json`, `--mode rpc`) never prompt; they use
`defaultProjectTrust` (`ask` default / `always` / `never`) unless `--approve` /
`--no-approve` overrides for the run.
**Design consequence:** loading a scenario extension with an absolute
`-e /path/to/ext.ts` avoids the trust question entirely, which is the most
robust option for a systemd-managed gateway.
## 11. User-global resources leak into every scenario
Probed with no isolation flags, from an unrelated workspace, the skills list was:
```
PROBE_SKILLNAMES=["find-skills","modsearch","summarize"]
```
Those come from `~/.agents/skills/`. `find-skills` in particular instructs the
agent to discover and install further skills — an unwanted capability surface
for a narrow-purpose agent. `~/.pi/agent/extensions/*.ts` leak the same way.
With `--no-skills --skill <abs>` the list becomes exactly `["probe-skill"]`.
## 12. No output-schema flag; use a terminating tool with constrained sampling
There is no `--output-schema` / `--response-format`. Structured output is
achieved with a tool whose `parameters` is the desired schema:
```typescript
{
name: "emit_result",
parameters: <schema>,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_id, params) {
return { content: [...], details: params, terminate: true };
}
}
```
`ConstrainedSamplingConfig`:
```typescript
| { type: "json_schema"; strict: "prefer" | "require" }
| { type: "grammar"; variants: Partial<Record<"openai_lark" | "openai_regex", string>> }
```
`strict: "prefer"` falls back gracefully; `"require"` fails the request if the
provider cannot honour it. `terminate: true` ends the turn only when *every*
finalized tool result in the batch sets it.
This replaces regex-scraping JSON out of prose.
## 13. Tools must truncate their own output
Built-in caps are **50 KB** and **2000 lines**. Helpers exported for this:
`truncateHead`, `truncateTail`, `truncateLine`, `formatSize`,
`DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`.
Errors: **throw** from `execute` to set `isError: true`. Returning an
error-shaped object does not mark the call failed.
`AgentToolResult.content` goes to the model; `details` is persisted in the
session but **not** sent to the model.
## 14. Per-call gating hook
```typescript
pi.on("tool_call", async (event) => {
if (!ALLOWED.includes(event.toolName)) {
return { block: true, reason: "…" };
}
});
```
`ToolCallEventResult = { block?, reason?, terminate? }`. To rewrite arguments,
mutate `event.input` in place. Later handlers observe earlier mutations and no
re-validation happens afterwards.
Combined with `pi.setActiveTools()` on `session_start` and
`resources_discover`, this gives two independent layers of capability control
that survive a mistake in the CLI flags.
## 15. RPC mode is strict JSONL and is meant to be long-lived
`--mode rpc`: commands as JSON objects on stdin, one per line; responses and
events as JSON lines on stdout.
> Split records on `\n` only. Accept optional `\r\n` by stripping a trailing
> `\r`. Node `readline` is **not** protocol-compliant because it also splits on
> `U+2028`/`U+2029`, which are valid inside JSON strings.
33 commands, including `prompt`, `steer`, `follow_up`, `abort`, `new_session`,
`get_state`, `set_model`, `set_thinking_level`, `compact`,
`set_auto_compaction`, `get_session_stats`, `switch_session`, `fork`,
`get_entries`.
`message_update` carries **deltas only** — no cumulative `message`, no
`partial`. Treat `message_end.message` as authoritative.
There is **no** command to inject a tool result from the host. Host-side
capabilities must be exposed as an extension tool that calls back out.
Probing `get_state` alone starts the agent, fires `session_start`, and exits
without ever contacting the model — a zero-cost way to inspect the resolved
system prompt and tool set.
## 16. Usage and cost are recorded per assistant message
Every `AssistantMessage` carries:
```typescript
Usage { input, output, cacheRead, cacheWrite, totalTokens,
cost: { input, output, cacheRead, cacheWrite, total } }
```
`ToolResultMessage`, `CompactionEntry` and `BranchSummaryEntry` carry an
optional `usage` for nested LLM work. Live equivalent: RPC
`get_session_stats`, which also returns
`contextUsage { tokens, contextWindow, percent }`.
Observability therefore needs no extra instrumentation — read the session JSONL
or subscribe to `message_update`.
## 17. Sessions never rotate or expire
`--session-id <id>` is an idempotent "use this project session, create if
missing" primitive (mutually exclusive with `--session`, `--continue`,
`--resume`; validated against `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`).
There is no built-in TTL, rotation, size cap or pruning. Files grow
monotonically until deleted by hand.
## 18. Auto-compaction rarely triggers on large-context models
Condition: `contextTokens > contextWindow - reserveTokens`.
```json
{ "compaction": { "enabled": true, "reserveTokens": 16384, "keepRecentTokens": 20000 } }
```
With a 1,050,000-token context window the threshold is ~1.03 M tokens, so
latency and cost degrade for a very long time before compaction ever fires.
A gateway must implement its own rotation policy — see
[`gateway-patterns.md`](gateway-patterns.md).
## 19. `allowed-tools` in SKILL.md frontmatter is not enforced
`dist/core/skills.js:loadSkillFromFile` consumes only `name`, `description` and
`disable-model-invocation`. `allowed-tools` is documented as experimental and is
**not read** in 0.84.3.
The memo-inbox scenario declares `allowed-tools:` in its `SKILL.md`; the real
enforcement is the `ALLOWED_TOOLS` array plus `setActiveTools` and the
`tool_call` hook inside `memo-guard.ts`. Do not rely on the frontmatter field
for security.
## 20. There is no sandbox
`docs/security.md`: "Pi does not include a built-in sandbox. Built-in tools can
read files, write files, edit files, and run shell commands with the permissions
of the pi process. Extensions are TypeScript modules that run with the same
permissions."
A path-restricted `read` override is an application-level boundary, not a kernel
one. Real isolation requires bubblewrap / container / micro-VM; pi ships
`examples/extensions/sandbox/` (bubblewrap, `.pi/sandbox.json`) and
`examples/extensions/gondolin/` (micro-VM) as starting points.
---
## Re-verification
```bash
cd docs/evidence/probe-harness
./collect-evidence.sh > ../$(date -u +%Y-%m-%d)-isolation-probe.md
```
The harness costs zero model tokens: it sends a single `get_state` over RPC and
reads the resolved system prompt from `session_start`.