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:
@@ -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
@@ -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
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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) §§13–18.
|
||||
|
||||
---
|
||||
|
||||
## 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 2–4
|
||||
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 | low–medium | 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 — 2–4 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/`.
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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*.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user