Skip to content

Agent core in Rust after Pi: pi-ai, pi-agent-core, pi-coding-agent - #1104

Merged
AnthonyRonning merged 78 commits into
masterfrom
claude/kind-turing-gzv8nl
Oct 13, 2026
Merged

AnthonyRonning merged 78 commits into
masterfrom
claude/kind-turing-gzv8nl

Conversation

@AnthonyRonning

@AnthonyRonning AnthonyRonning commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1124 (phase 0; base agent-pi/phase-0). Do not merge: nothing in the stack merges until the end result is accepted.

What this is

A Rust rebuild of the Pi agent harness as three crates under apps/maple-agent/crates/. They keep Pi's structure and design, written as idiomatic Rust. This is not a transliteration. There is no byte-for-byte conformance with Pi's TypeScript, and Pi's TypeScript tests were not translated. The tests are Rust unit and integration tests of the same kinds Pi has, run against a scripted provider.

The crates have no Maple dependencies. Maple's runtime moves onto them in the next stacked PR, #1109.

Size: about 22,800 lines of code and 14,600 lines of tests (405 tests).

The crates

pi-ai (after Pi's pi-ai)

  • One message model for every provider. System messages carry the prompt (as named sections) and the tool declarations. Replaying the transcript gives the current prompt and tools.
  • Providers stream events that always end in Done or Error, so request failures are messages, not errors.
  • An API registry, and a scripted FauxProvider for tests.
  • An OpenAI-compatible Chat Completions provider over a host-supplied HTTP transport, which receives each request's cancellation as Pi's fetch receives an abort signal. As Pi's provider does, it:
    • repairs the transcript, and sends it with the developer role only for reasoning models and a placeholder where a model cannot take an image;
    • sends reasoning effort, including a model's own value for off;
    • fits max_tokens to the context window;
    • keys OpenAI's prompt cache by the session, and sends session-affinity headers where the host turns them on;
    • reports usage and cost, with each server's cache fields;
    • sends tools that ask for it in strict mode where the provider has one (Pi's constrained sampling);
    • reads the event stream as the OpenAI SDK reads it, fails a reply that ends without a known finish reason, and keeps the provider's whole error in the message.
  • Pi's request options: tool choice, sampling parameters (the model's own, then those for the thinking level, then the request's), a model's own headers, how long a response may take to start, a hook that rewrites the request's headers, and hooks that see the response's status and headers and each streamed event.
  • Pi's retries of a request whose response did not start, after the OpenAI SDK's policy: a connection failure, a timeout, a conflict, a rate limit or a server error is sent again, or as x-should-retry says, after the wait retry-after-ms or retry-after asks for or a growing backoff. A server asking for longer than the limit fails the request at once.
  • Pi's classifier API (classify): choice, score and yes/no questions about a JSON state and images, answered with probabilities, through a registry by API. system_one speaks Maple's System One protocol (POST /systemone), sent as Pi's classifier requests are, with a timeout per attempt and two retries. A scripted FauxClassifier is for tests.
  • Thinking levels a model does not accept clamp up first, then down, as Pi's do.
  • Helpers: streaming JSON repair (truncated JSON, raw control characters, stray backslashes), tool-argument validation with lenient coercion (through unions too), overflow and retry classification with Pi's texts (an exhausted quota or billing limit is never retried), and token estimates at Pi's 3.5 characters per token. apply_event follows a tool call's arguments as they stream.

pi-agent-core (after Pi's pi-agent-core)

  • The agent loop:
    • parallel or sequential tool batches: ends are reported as calls finish, results are appended in call order;
    • truncated calls never run;
    • steering after each turn, follow-ups when the agent would stop, in Pi's queue modes (all, one-at-a-time);
    • turn and request preparation, and terminate.
  • AgentHooks: message conversion, context transforms, per-request keys, tool gates (a failing gate blocks the call), result rewrites, message finalization.
  • The transcript type is the application's own (AgentMessage).
  • Agent covers prompt (with images, as Pi's prompt(text, images)), steer, follow-up, continue, abort and reset, with awaited listeners, and gives the running turn's cancellation, as Pi's signal does. Each queue can be cleared alone. A run whose loop fails still ends as runs end, with an empty reply carrying the error.

pi-coding-agent (after the core of Pi's coding agent)

  • Sessions: an append-only entry tree with branching, labels, context edits and branch summaries.
    • Storage sits behind SessionStore. JSONL here; an encrypted SQLite store can replace it later.
    • Nothing is written until there is a conversation.
    • A host can give a new session its id, as Pi's newSession takes one.
    • Session files are named and kept as Pi keeps them (sessions/--<cwd>--/<time>_<id>.jsonl in the agent's folder), and a fork names its parent's file. The current branch can be exported as a session file of its own, as Pi's exportToJsonl.
  • Compaction: the cut never separates a tool result from its call, a split turn gets its own summary, and summaries are updated across compactions in Pi's update format. Compaction summaries think at the session's level, and summary requests are retried, with events, when they fail for a moment. The context size after a context edit or a compaction is estimated as Pi estimates it. Moving in the tree can summarize the branch being left, written as Pi writes it: its preamble, and the files the branch read and changed.
  • Resources: AGENTS.md context files, skills (also from .agents/skills, in the project up to its repository root and in the home folder) and prompt templates; a project's skills and templates override the user's. A template path can name one file, and template folders are searched as Pi's discovery searches them. The skills and templates the settings list resolve against their own file's folder and win over the default folders, as in Pi. SYSTEM.md replaces the default prompt and APPEND_SYSTEM.md adds to it. Skill folders follow their ignore files, and in a linked worktree inside the main checkout, the worktree's own context file replaces the main checkout's, as in Pi. The project folder name comes from the host (.maple).
  • Settings: a file that cannot be read or parsed is left out with its error, and the other still applies, as in Pi. A byte order mark is skipped. Levels for particular models (Pi's modelThinkingLevels) win over the default level. Pi's per-model compaction settings, images.blockImages, the default model, and the provider's timeout and retries (retry.provider, httpIdleTimeoutMs) are read as Pi reads them. What a session changes is saved to a settings file the host names, as Pi saves the user's.
  • Project trust: a project's own skills, prompts, settings and prompt files load only once the user trusts the folder, decided as Pi decides it and remembered per folder in trust.json. Context files always load.
  • System prompt: built from named sections. The app name comes from the host, so there is no "pi" in model-facing text. Changes are sent as section patches.
  • Extension API: typed events with Pi's semantics: gates fail closed, rewrites chain, observer failures are isolated. Extensions can also add tools, commands and model providers.
    • Handlers get Pi's context to act on the session: compaction, the running turn's stop signal, project trust, the model registry, tool details, commands, settings and the session's name. Commands get Pi's command context on top: waiting for idle, tree moves, forks and the system prompt options.
    • context hides system messages and keeps the prompt, as Pi's does; context_with_system sees the whole transcript.
    • Events include Pi's session_before_fork, session_before_switch, session_info_changed, session_compact_failed, agent_settled, resources_discover, user_bash and the provider's request, header, response and stream events, and compaction and tree events carry Pi's payloads (the reason, whether the reply is asked again, the branch, the tree preparation and the extension's overrides). Session start and shutdown say why, as Pi's do.
    • At turn_end and the new agent_before_settle, handlers can add entries (Pi's boundary drafts: extension state and messages, context edits, compactions) and ask for one more request, with a preview of the context they would make.
    • Commands can start a new session, switch to a stored one and fork through the session runtime, as Pi's command context can, and offer completions for their arguments.
    • Extensions can register classifiers, and providers after the session starts. The interface a host shows covers Pi's notify levels, dialog timeouts and cancellation, the editor, status, widgets and title.
  • Built-in tools (after Pi's core/tools): a session creates them for its folder and gives the model read, bash, edit and write. As in Pi, grep, find and ls are registered but off, and powershell stays registered for Windows. Hosts configure them, choose the active ones, and replace one by registering a tool with its name.
    • read: text a page at a time (2000 lines or 50KB, with continuation notes); images recognized by their bytes, BMP converted to PNG, and images resized to fit inline limits; macOS screenshot names found when the typed name is not.
    • bash and powershell: output streams to the interface at most every 100ms. The model gets the last 2000 lines or 50KB, and the full output goes to a private file it can read. No default timeout. Stop or a timeout ends the command's whole process group (its process tree on Windows). A background job that keeps printing after the shell exits keeps the call reading, as in Pi, until Stop or the timeout ends the call, and on Unix the group with it. A finished command may leave a background job running. A spawn hook adjusts the command, folder and environment.
    • edit: several disjoint replacements per call, matched against the original file, exactly or else loosely (trailing spaces, typographic quotes, dashes and spaces, Unicode compatibility forms). Line endings, the byte order mark and untouched lines are kept. The result carries a display diff and a unified patch.
    • write: creates folders as needed. Edits and writes to one file run one at a time.
    • grep, find and ls: by the rules Pi's ripgrep and fd follow. Hidden files are included; .gitignore, .ignore and each tool's own ignore file are respected; .git is skipped.
      • grep takes Rust regular expressions (ripgrep's syntax) or literal text, a glob filter and context lines. It stops at 100 matches or 50KB and cuts lines at 500 characters.
      • find matches names, or paths when the pattern has a /. A pattern without capitals ignores case, as in fd.
      • ls sorts entries regardless of case and marks folders with /.
    • Every tool takes swappable operations, as Pi's do, so it can run somewhere else.
    • Pi's truncate_middle and write_output_file cut a long result in the middle and save the whole of it to a private file, for a host's own tools, such as MCP ones.
  • AgentSession:
    • prompts go through commands, input handlers, and skill and template expansion; so do steering and follow-ups, which refuse extension commands and carry images, as Pi's do;
    • every message is persisted;
    • the session is busy from a prompt's start until it settles, retries and compactions included;
    • a session starts at Pi's default thinking level (medium) unless its record or the settings name one, a level for its model first, and a model switch applies the model's level or the default again; model and level changes are recorded only when something changes;
    • the model, thinking level, tools and prompt are refreshed before every turn, so changes made during a run reach its next request; prompt options a before_agent_start handler returns hold for that run;
    • transient errors are retried with backoff, and failed attempts stay in the tree but out of the context;
    • compaction is decided as Pi's _checkCompaction decides it: a retryable error is retried first; an overflow, or a reply cut short below the model's output limit, is compacted and asked again once; a reply over the window is compacted and kept; a context near its limit is compacted, mid-run for long tool loops too; and the same check runs before a prompt, so a stopped run or a switch to a smaller model compacts first;
    • manual compaction stops what runs first;
    • a prompt's images, and images any tool returns, are converted and resized to fit inline limits before the model sees them;
    • the user's own shell commands (Pi's !command, and !!command kept out of the context) join the conversation, after the run when one is going; a colour code split between output chunks is held back;
    • forks go before a user message, whose text comes back to be edited, or at an entry; tree moves to a user message stand before it and restore the tools declared there, with labels and replaced summary instructions;
    • automatic compaction, retries and the queue modes can be changed during a session;
    • all_tools and commands describe the tools and slash commands as Pi's getAllTools and getCommands do;
    • events tell a host what Pi's tell: whether a retry follows a run, whether it was stopped, appended entries, the session's name, thinking-level changes, summary retries, and whether a compaction was stopped or its reply is asked again; the context usage is unknown after a compaction until a reply measures it;
    • a host can replace the context files, skills and templates between prompts, as Pi's reload does with what its resource loader finds; the next prompt sends only the prompt sections that changed;
    • tools can be registered after the session starts, by the host or through an extension's context, as Pi's registerTool can be called while Pi runs;
    • a host can start a turn with a custom message the user does not see, as Pi's sendCustomMessage does with triggerTurn;
    • session stats count messages and add up tokens and cost;
    • the session's own requests carry Pi's provider settings: the session's id, the timeout, the provider's retries and the extensions' header, request, response and stream hooks.
  • Setup and runtime: create_agent_session sets a session up in one call, as Pi's createAgentSession: settings and resources from the agent's folder and the trusted project, a new session file unless the host opens one, and the model and thinking level chosen in Pi's order, with Pi's message when a recorded model cannot be restored. AgentSessionRuntime holds the session a host is on and replaces it as Pi's runtime does: a new session, a stored one, a fork, or an imported file, each with Pi's events.

Where it departs from Pi, and why

Decided with the repository owner: each adaptation that Pi's TypeScript does not have is to be reviewed before the stack merges; until then they stay.

  • Message type: Pi extends its message union through TypeScript declaration merging. In Rust the agent is generic over the application's message type.
  • Hooks: Pi assigns optional callbacks on the agent. Here the hooks are one trait whose methods all have neutral defaults.
  • Streaming events: Pi shares a live partial-message object across events. Here events carry deltas, and listeners apply them with apply_event, which also follows a tool call's arguments as they stream.
  • Extensions: they are Rust types registered at build time. Pi loads TypeScript files at runtime; loading out-of-process plugins is a separate decision.
  • Credentials come from the host through an ApiKeySource.
  • Built-in tools:
    • The session's variables and output files are named after the host app (MAPLE_SESSION_ID, maple-bash-….log) instead of PI_* and pi-bash-…, since nothing model-facing says "pi".
    • Images are processed with the image crate instead of Photon (WebAssembly), with Pi's limits; diffs come from similar instead of jsdiff, so hunk headers can differ slightly.
    • File errors carry Rust's text ("No such file or directory (os error 2)") instead of Node's codes.
    • A dropped tool call kills the command's process group, since a Rust future can be dropped.
    • grep and find search natively with ripgrep's own libraries (ignore, globset, regex) instead of running rg and fd, which Pi downloads when they are missing. Results come in path order; ripgrep and fd search in parallel, in no fixed order.
    • grep applies .gitignore outside a repository too, as Pi has fd do (Pi's ripgrep applies it only inside one). It skips .git, and passes over unreadable files instead of failing the whole search.
    • find always leaves out node_modules, as Pi asks of custom operations.
    • Results have no structuredContent for programmatic callers; that belongs with codemode (below).
  • Reloading resources: Pi's reload reads settings, resources and extensions again itself. Here the host finds the resources and hands them to set_resources; settings and extensions are not reloaded.
  • Project trust: the question does not mention installing packages or running extensions, which this port does not do. The trust store is locked with an operating-system file lock instead of a lock folder.
  • Session API:
    • Settings changed during a session are saved only to a settings file the host names (create_agent_session names the user's); Pi always saves, as its settings always come from files. Pi locks the file across processes while it writes; this writes through a temporary file instead.
    • Without the session runtime, a fork goes on in the same AgentSession with the same extensions, so they get SessionStart (fork) with no SessionShutdown before it; commands' new and switch do nothing then, as in Pi. The runtime replaces the session as Pi's does.
    • An export's relative path is relative to the session's folder, where Pi takes its process's.
    • Pi's turn_end is an event of its own here, while the agent's TurnEnd still reaches extensions with the other agent events; a boundary handler's continue is continue_run, as continue is a Rust keyword; the turn's entry ids are found by its messages in the branch, where Pi keeps a map from each message.
    • The extension interface leaves out the parts of Pi's that belong to its terminal: raw input, the working indicator's frames, custom components, footers and headers, editor components, autocomplete providers and themes.
    • ExtensionContext::compact returns a handle to await instead of taking onComplete and onError callbacks.
    • is_project_trusted is what the host states in AgentSessionOptions.
    • ModelSelect has no source, since only the host sets models here; Pi also reports cycling and restoring.
    • set_session_name awaits the extensions' handlers, where Pi fires them off.
    • A tree move that an extension cancels, or that a stop interrupts, fails with Cancelled. Pi returns cancelled, with aborted for a stop.
    • Whether a reply came before the latest compaction is read from its place in the branch. Pi compares timestamps, which a fast reply can share with the compaction; here timestamps decide only for a reply the branch does not hold.
    • Summary requests carry the session's id, so they route and cache as the session's requests do. Pi gives them a fresh id with no cache retention, which needs cacheRetention (not ported).
    • A context edit on an assistant message replaces only its text, since our edit content cannot carry thinking or tool calls; Pi's replacement can.
    • The context estimate is one number; Pi's also gives the usage and trailing tokens it was made of.
  • Setup and runtime:
    • The host names the agent's folder and the project folder's name, where Pi defaults to ~/.pi/agent and .pi. The project is trusted only when the host says so; Pi's SDK trusts it unless told otherwise.
    • Models come from the host's registry, so Pi's preferred model per known provider is not ported and the first one with credentials is taken. "No models available." has no login help, which is the host's to give. Tools are chosen as AgentSessionOptions takes them, since Pi's tool lists and defaultTools are not ported.
    • A cancelled replacement fails with Cancelled, as tree moves do here, where Pi returns cancelled; one started while another is under way fails as busy. A replaced session's commands no longer act on the runtime, where Pi's stale contexts throw.
    • A fork copies the tree in memory instead of reading the file again, which gives the same session. Extensions hear session_start before the runtime takes the session, so a session_start handler cannot replace it. A new session's setup is not async. The factory reports the settings errors of the sessions it builds, where Pi's runtime keeps their diagnostics. Pi's projectTrustContext and setBeforeSessionInvalidate are not taken.
  • Provider:
    • Retry patterns match status codes as whole numbers. Pi matches any 500 in the text, so now that whole error bodies are in messages it would retry a 400 that mentions "15000 tokens". Rust's transport texts ("error sending request", "broken pipe") and a few looser phrases ("high demand", "at capacity") are retried too.
    • Pi's supportsFinishReason: false is not ported: every stream that ends without a finish reason fails, as with Pi's default.
    • Error bodies are read up to 4 KB, where Pi reads the whole body and cuts the error object at 4,000 characters, and a metadata.raw that is not text is not added.
    • When a successful response ends without a finish reason, the lines of it that are not events, such as a JSON error body, follow the error text. Pi's SDK drops them.
    • User messages always go out as content parts. Pi sends a string when a message's content is one, which ours never holds.
    • The host turns session-affinity headers on, for OpenRouter too, as it sets the other compat flags; Pi turns OpenRouter's on by itself.
    • Usage reasoning is absent when a server does not report it; Pi reports 0.
    • Estimates count Unicode characters where Pi counts UTF-16 units; they differ only outside the Basic Multilingual Plane. The faux provider's usage uses the 3.5-character estimate, where Pi's faux has its own at 4.
    • A tool call closes when the next block starts, so a fragment that arrives later shows only in the final message; Pi ends every call when the stream ends.
    • Coercion does not repeat TypeBox's Value.Convert pass, and reads tuples only from list-form items, as Pi's own coercion does.
    • The env, metadata and websocket request options are left out, as they serve providers and transports these crates do not have. Pi also gives its HTTP client httpIdleTimeoutMs as the time a response may stall; here that is the transport's.
  • Classifiers and System One (at the repository owner's request, after Maple's System One API):
    • images go as data URLs and the request's temperature is sent, where Pi's TypeSafe System One sends neither; answers keep the temperature and option mass Maple's server reports, scores keep their probabilities and legend, and the result keeps the reply's id and its upstream request count;
    • the state is any JSON, instructions and criteria are JSON values, and a yes/no question's criteria are optional, as Maple's API takes them; Pi takes an object and text;
    • cached tokens are read out of the input as cache reads, as Chat Completions usage is, and an API key is optional, since the host's transport may authenticate;
    • FauxClassifier has no counterpart in Pi.
  • Settings and resources:
    • A value of the wrong type leaves out its whole settings file, reported as a syntax error is; Pi does not check types when it loads. Errors carry the file's path, not Pi's global or project scope, since the host passes both paths. Pi's settings migrations are not ported.
    • Template folders a host adds are searched with their subfolders, as Pi searches a folder its settings list; Pi reads its command-line and extension folders one level deep.
    • Pi's !, + and - override entries and globs in the settings' skills and prompts are not ported; they belong to its package system.
    • A folder that links back into itself is searched once, for templates and skills alike. Pi recurses until its stack runs out and keeps what it found.
    • Within a folder, templates are taken in name order, where Pi takes the order the system lists them; this matters only for clashing names. Ignore-file edge cases can differ between Node's ignore package and the Rust ignore crate.
    • Branch summaries use the compaction reserve, since Pi's branchSummary settings are not implemented. Prompt-state messages on the branch left do not count against the budget; Pi counts them, though it never writes them out.
    • Session files store a compaction's fromExtension; Pi's store fromHook.

Not included yet

Decided with the repository owner:

  • Not implementing, yet: the project_trust extension event (only the host and the user decide trust); grammar-constrained tools (OpenAI Lark and regex tools, which only Pi's codemode uses); session listing (hosts keep their own index, as Maple does); and Pi's smaller settings (branchSummary, enableSkillCommands, defaultTools and thinkingBudgets).
  • MCP: on the rmcp crate, with only Pi's thin conventions on top (mcp__<server>__<tool> names, an output limit, server instructions in the prompt). Pi's own MCP client, exposure modes, resource tools and tool_search can follow later if wanted. Where it lands, its differences from Pi's MCP get written down for later comparison.
  • Codemode: left out for now. Pi's is recent, and a later proof of concept may build it on Maple's CPython work instead of Pi's QuickJS sandbox. What serves it and prompt-cache providers (executeTool, the cache warmer, the event bus and pi.exec) is left out with it.
  • Not implementing: the llama.cpp provider; HTML export; Pi's terminal UI, RPC and print modes and themes; Pi's provider catalog, OAuth and other provider APIs (Anthropic, Responses, Google, Bedrock); packages and virtual models; the rg/fd downloader; reading Pi CLI session files (format migrations); streamProxy, replay and the deferred-handle helpers.
  • Not porting (decided on 2026-10-11 from the list the parity review left to decide; the rest of that list is ported in commits 63 to 77): Pi's other thinking formats (DeepSeek, z.ai, Qwen, OpenRouter, chat templates) and the default budgets it sends where a model names a budget field; OpenRouter's reasoning_details and cache_control, and cacheRetention; provider detection from the name or URL; cost tiers, input limits, the response's model and durations; image generation; and the TypeSafe, OpenAI Decisions and llama.cpp classifiers.
  • Ported but not for Maple yet: the one-call setup and the session runtime. Maple runs many sessions at once in one process and builds each itself, so it keeps AgentSession::new for now.

Reviews

After the PR opened, four independent reviewers checked the crates for real defects, and an outside review of the first round of fixes found two more. I verified every finding against the code. Each was fixed with a regression test, except the behaviors Pi shares on purpose (listed below). The fixes are in commits 4 to 7. Commit 8 fixes a gap found while planning phase 2: the provider did not send a model its earlier reasoning, which Pi does. Commits 9 to 16 add what the first version left out of Pi's built-ins: its tools, the quota rule, project trust with SYSTEM.md, APPEND_SYSTEM.md and .agents skills, strict tool schemas, user shell commands, image fitting for every tool result, and session stats. Commit 17 fixes drift that a security review of the stack found: once the shell had exited, Stop and the timeout no longer reached a command whose background job kept printing, as Pi's do. Commits 18 to 23 are Pi features phase 1 had not ported: a transport's cancellation and a host-chosen session id, resources replaced between prompts, images in steering and follow-ups, truncate_middle and write_output_file, tools registered after the session starts, and a custom message that starts a turn. They were first written in #1109, where Maple needed them, and moved here on 2026-10-10 so that Pi's features live in this layer.

Commits 24 to 60 come from a parity review on 2026-10-10. Two read-only reviewers compared Pi 1.1.0 with these crates, for behavior that differs without being recorded here and for features of Pi's ported packages that the crates still lacked. The repository owner asked for the differences to be matched or recorded and for the missing features marked for porting to be ported; what it left to decide was settled on 2026-10-11 (below and under "Not porting" above). Every claim was checked against Pi's code before anything changed, and each commit message says whether it fixes drift (Pi already behaved so) or adds a Pi feature. Commits 24 to 35 are pi-ai; 36 to 46 are compaction, settings and resources, with 42 for pi-agent-core's queue modes; 47 to 60 are the session and the extension API. Commits 61 and 62 follow the owner's answers on two questions the review left: the skill and template paths the settings list now resolve and rank as Pi's, and Pi's levels for particular models are ported, as the place for Maple's own recommendations per model.

Commits 63 to 77 port what the review left to decide, as the repository owner asked on 2026-10-11, except what they chose not to port (above): 63 for pi-agent-core (a failed run still ends; prompts with images; clearing one queue); 64 and 66 for pi-ai (request options and the retries before a response; classifiers and System One, after Maple's API); 65 and 67 to 73 for the session (Pi's provider settings on its requests, ignore files in skill folders, worktree context files, per-model compaction and blocked images, the resources_discover and user_bash events, Pi's small helpers, the rest of the extension interface, and saving settings); 75 and 76 for Pi's one-call setup and session runtime; and 77 for the boundary drafts. Commit 74 fixes drift found while porting the runtime: a fork named its parent by id and re-stamped its labels.

Compared with Pi

Every finding was also checked against Pi 1.0.4, and the shared ones again against Pi 1.1.0 (earendil-works/pi at 6fb2e78, 2026-10-08). Paths and line numbers below are in that repository at 1.1.0. Issue states are from Pi's public tracker on the same day.

Nothing here is being reported upstream for now. Once the Rust version works end to end, we decide which of the shared behaviors and quirks to change.

Bugs Pi has too (fixed here)

These look like real bugs in Pi.

  • A 429 that says "Too many tokens" counts as a context overflow, so the session compacts instead of backing off.
    • The overflow patterns include /too many tokens/i (packages/ai/src/utils/overflow.ts:61), and the throttling exclusions only match Bedrock's message prefix (:76-80).
    • The overflow check runs before the retry check (packages/coding-agent/src/core/agent-session.ts:3711).
    • Upstream: 429 status triggers auto compaction earendil-works/pi#1038 ("429 status triggers auto compaction") is closed with no linked fix. This case still misfires in 1.1.0.
  • An entry with no message right before a turn makes the compaction cut treat that turn as split.
    • A model change, thinking-level change, label or extension entry is enough.
    • The cut walks back onto that entry before it checks for a turn start (packages/coding-agent/src/core/compaction/compaction.ts:858-864).
    • The result is an extra summary request and a misleading "split turn" section.
    • Upstream: none found.
  • Branch summaries ignore context edits, so redacted or omitted content is sent to the summarizer and can come back in the summary.
    • getMessageFromEntry has no case for context edits (packages/coding-agent/src/core/compaction/branch-summarization.ts:156).
    • Upstream: none found.
  • A custom message that starts a turn on a fresh session goes out without the system prompt.
  • A prompt during a tree move can run on the wrong branch.
  • A failed append leaves a gap in the session file.
    • The entry is added in memory before the write, and the write is never retried (packages/coding-agent/src/core/session-manager.ts:1191-1195).
    • After a reload, everything before the gap drops out of the context.
    • Upstream: none found.
  • Plain .md skills without a name are named after their folder, so two of them in one skills folder collide and one is dropped.
    • See packages/coding-agent/src/core/skills.ts:311,321. Pi does report the clash.
    • Upstream: none found.
  • A model an extension provides is not restored when a host calls createAgentSession() directly.
    • The session's model is looked up (packages/coding-agent/src/core/sdk.ts:219-230) before the runner registers extension providers.
    • The CLI registers them first and is fine.
    • Ours chooses the model once extensions have registered, in AgentSession::new and in create_agent_session.
    • Upstream: none found.

Shared on purpose (kept as Pi does)

Small Pi quirks noticed (not filed)

  • An aborted retry is reported as auto_retry_end with success: true (packages/coding-agent/src/core/agent-session.ts:1176-1178).
  • A branch with nothing to summarize stores "No content to summarize" as a branch summary, which then enters the context (packages/coding-agent/src/core/compaction/branch-summarization.ts:318).
  • A tool-call fragment with neither an index nor an id opens a new call (packages/ai/src/api/openai-completions.ts:500-504).
  • If the first session write fails partway, the file is left behind, so every later write fails with EEXIST (packages/coding-agent/src/core/session-manager.ts:1177). Compare the closed SessionManager._persist() throws EEXIST when file is pre-written by _rewriteFile(), masking real agent errors earendil-works/pi#5844.
  • The session hands the thinking level to generateBranchSummary, which never reads it, so branch summaries are written without thinking. Ours sends none either.

Our own drift (fixed here; Pi already gets these right)

  • Agent: continue drained the queues before it refused a busy agent.
  • Provider:
    • a stream that ended without a finish reason counted as finished;
    • tool-call fragments without an index were merged;
    • reasoning sent in two fields was doubled;
    • nulls a schema allows were dropped;
    • unsupported thinking levels were sent as is;
    • a model's earlier reasoning was not sent back to it, in the field it came in, and another model's was not sent as text (found while planning phase 2).
  • Session:
    • the session looked idle during a retry backoff;
    • user messages from extensions skipped the prompt path;
    • the starting thinking level was not recorded;
    • queued extension messages were listed as prompts;
    • the retry counter carried over to the next prompt;
    • a silent overflow was compacted but not retried;
    • a failed fork write split the tree from the agent;
    • invalid prompt options were kept for later prompts.
  • Storage and resources:
  • From the outside review:
    • changes made during a run did not reach its next request;
    • stopping did not cancel a branch summary.
  • Built-in tools (from a security review of the stack): once the shell had exited, Stop and the timeout no longer ended a call that a background job's output kept open.
  • From the parity review (commits 24 to 60):
    • provider: levels were clamped down only, and off's value was never sent; [DONE] without a finish reason, and unknown finish reasons, finished the reply, so its tool calls could run; errors kept only error.message, so an overflow that OpenRouter wraps was retried instead of compacted; every model got the developer role, a model without image input was told "(see attached image)", and reply blocks came back joined with newlines; looser overflow texts and exclusions retried a LiteLLM overflow inside a 503; estimates used 4 characters per token; DeepSeek, Kimi, OpenRouter and Moonshot cache usage was not read; raw control characters and stray backslashes turned arguments into {}; coercion did not go through unions; malformed and multi-line events were skipped, and error events ignored;
    • session: a session started at thinking off and kept its level across model switches, recording changes that changed nothing; before_agent_start options outlived their run; the context event saw system messages; manual compaction during a run was refused; steering skipped input handlers and expansion; prompt images went out unprocessed; a tree move left a user message as the leaf and kept the current tools; a reply from another model, or from before the latest compaction, could trigger compaction, and the threshold was not checked after a failed reply; the context usage after a compaction was an estimate; summary requests retried when retries were off, with delays capped at a fixed minute;
    • compaction and resources: summaries did not think at the session's level; branch summaries sent tool results, lacked Pi's preamble and file lists, and allowed 2048 output tokens; an update asked in a first summary's format; an extension's compaction passed on its file lists; usage from before a later edit was trusted for the context size; one broken or byte-order-marked settings file failed the whole load; a template path could not name a file, and template folders were read one level deep with every Markdown file in them; an empty argument hint was kept; a colour code split across output chunks left fragments; the queue modes were spelled one_at_a_time; the skill and template paths the settings list resolved against the working folder, lost name clashes to the default folders, and a project's list replaced the user's.

Rust only

Dropped futures (a timed-out prompt or compaction, a dropped tool call) and races between threads. JavaScript cannot have either.

Testing

  • cargo fmt --all -- --check: passes.
  • cargo clippy -p pi-ai -p pi-agent-core -p pi-coding-agent --all-targets -- -D warnings: clean. pi-ai is also clippy-clean with --no-default-features.
  • cargo test -p pi-ai -p pi-agent-core -p pi-coding-agent: 405 tests pass. The built-in tools' tests follow Pi's tool tests (paging, images, exact and loose edits, line endings, truncation and full-output files, timeouts, exit codes and signals, flag-like search patterns, glob errors, hidden and ignored files). They add what stopping or dropping a command reaches (also once the shell has exited while a background job keeps printing), the ripgrep and fd rules above (nested repositories, globs, case, limits), and sessions that run bash and write end to end. The newer built-ins have their own: strict schema conversion and refusal, quota errors, the trust store and its resolution order, what an untrusted project keeps, user commands (cleaned output, !!, abort, a command ending mid-run), oversized tool images, and stats. The features moved from Maple Agent on the Pi crates: the runtime, then every feature, then no Goose #1109 have theirs: the middle cut and output files, replaced resources, images in steering, tools registered later, and a custom message's turn.
  • The parity commits test each change against Pi's behavior: for the provider, clamping, finish reasons, error text, the transcript, overflow and retry texts, the output cap, cache keys and usage, JSON repair, coercion through unions, streamed arguments and the event stream; for compaction and resources, settings errors, the paths settings list, template files and folders, split colour codes, summary prompts, branch summaries and estimates; for the session, thinking defaults and levels for particular models, run-scoped prompt options, both context events, compaction during a run and its triggers and events, settings, tool details and commands, steering, session events, forks, the handler and command contexts, prompt images, tree moves, context usage and summary retries.
  • Commits 63 to 77 test what they port against Pi's behavior: a failed run's events, prompt images and single queues; tool choice and sampling order, header rewrites and removal, retries by status, x-should-retry, retry-after-ms, retry-after seconds and dates, a refused long wait, a stop during the wait, timeouts and the response hooks; the provider settings and their old names; classifier questions on the wire, answers, usage, retries, timeouts and errors, and refused images; ignore files in skill folders; worktree context files; per-model compaction and blocked images; both new events; skill blocks, exports and completions; dialogs and the editor; saved settings; fork parents and label times; session files, model and thinking choice, credentials, trust and settings errors in the one-call setup; new, stored, forked and imported sessions, cancellation, commands, missing folders and stale sessions in the runtime; and drafts, previews, refused continuations and invalid entries at both boundaries.
  • Each commit from 24 on passes the format check, both clippy runs and the tests on its own.
  • Locally I checked without the GUI app member, because its git dependencies are not available offline in my environment. Maple Agent CI covers the full workspace on Linux, macOS and Windows.
  • pi-ai turns on serde_json's preserve_order, so error objects keep the server's key order as Pi's JSON.stringify does; the full workspace already had it on.
  • Cargo.lock gains the three path packages and similar. Every other external crate they use (ignore, globset and image among them) was already locked, at the same versions; a locked resolution of the full workspace, GUI app included, accepts the lockfile unchanged. The commits from 24 on change no dependencies.

🤖 Generated with Claude Code

https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d

Copy link
Copy Markdown
Contributor Author

The description now has a Compared with Pi section. Every review finding was checked against Pi 1.0.4, and the shared ones again against Pi 1.1.0 (earendil-works/pi at 6fb2e78):

c92e36cc fixes the outside review's two findings. Both were drift on our side that Pi already handles:

  • changes made during a run now reach its next request;
  • stopping now cancels a branch summary.

The same commit makes a project's skills and templates override the user's, as in Pi.

Decision for now: nothing is reported upstream, and the shared behaviors and quirks stay as they are. Which of them to change will be decided once the Rust version works end to end.


Generated by Claude Code

@AnthonyRonning
AnthonyRonning added this pull request to stack #1107 October 8, 2026 17:06
@AnthonyRonning
AnthonyRonning force-pushed the agent-trim/checklist-perf branch from 884dafe to 51b777b Compare October 8, 2026 17:20
@AnthonyRonning
AnthonyRonning force-pushed the claude/kind-turing-gzv8nl branch 2 times, most recently from 2052e83 to d03a8e2 Compare October 8, 2026 18:50
@AnthonyRonning
AnthonyRonning force-pushed the agent-trim/checklist-perf branch from 51b777b to 31ab2e4 Compare October 8, 2026 18:50
AnthonyRonning pushed a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
AnthonyRonning pushed a commit that referenced this pull request Oct 9, 2026
@AnthonyRonning
AnthonyRonning force-pushed the agent-trim/checklist-perf branch from 31ab2e4 to 9663764 Compare October 10, 2026 07:38
@AnthonyRonning
AnthonyRonning force-pushed the claude/kind-turing-gzv8nl branch 3 times, most recently from d95c54c to ec74687 Compare October 10, 2026 09:01
@AnthonyRonning
AnthonyRonning removed this pull request from stack #1107 October 10, 2026 09:01
@AnthonyRonning
AnthonyRonning changed the base branch from agent-trim/checklist-perf to agent-pi/phase-0 October 10, 2026 09:01
@AnthonyRonning
AnthonyRonning added this pull request to stack #1125 October 10, 2026 09:01
@AnthonyRonning
AnthonyRonning force-pushed the claude/kind-turing-gzv8nl branch from fe8c5ff to df7bfbd Compare October 10, 2026 21:32
AnthonyRonning and others added 12 commits October 11, 2026 08:00
Simplify the existing runtime before replacing Goose with the Pi crates:
remove approval and mode surfaces, Goose subagents, local usage tables,
and standalone CuaDriver; update the scenario and performance guidance.

Consolidates PRs #1090, #1091, #1093, #1094, #1095, #1096, #1097,
#1098 and #1099. The resulting tree is identical to 9663764.

Co-authored-by: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
…ider

The first of three crates that rebuild Pi's agent harness in Rust, after
Pi's pi-ai package. It has no Maple dependencies.

- One message model for every provider. System messages carry the
  prompt as named sections and the tool declarations, so replaying the
  transcript yields the current prompt and tools.
- A provider streams an assistant response as events that always end in
  Done or Error: request failures are messages, not errors. A builder
  emits the events in protocol order; consumers apply deltas.
- An API registry dispatches on the model's API, and a scripted faux
  provider stands in for models in tests.
- The OpenAI-compatible Chat Completions provider over a host-supplied
  HTTP transport, with transcript repair, reasoning effort, usage and
  cost.
- Shared helpers: streaming JSON repair for cut-off tool arguments,
  tool-argument validation with lenient coercion, context-overflow and
  retry classification, and token estimates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
The second crate, after Pi's pi-agent-core package. No Maple
dependencies.

- The loop streams a response, runs its tool calls and asks again until
  the model stops. Tool calls run in parallel by default (ends reported
  as they finish, results appended in call order) or one at a time when
  any tool asks. Calls from a length-truncated response never run.
- Steering messages arrive after the current turn's tool calls;
  follow-ups once the agent would stop.
- Applications plug in through hooks: message conversion, per-request
  context transforms and credentials, tool-call gates that fail closed,
  result rewrites, message finalization, and turn and request
  preparation (where compaction can run mid-run).
- The transcript type is the application's own: anything implementing
  AgentMessage, with a conversion to what the model sees.
- Agent owns the transcript, tools and queues and offers prompt, steer,
  follow-up, continue, abort, reset and awaited listeners.

Tests cover the event order, tool batches, gates and rewrites, queues,
turn hooks, continuation and cancellation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
The third crate, after the core of Pi's coding agent. No Maple
dependencies, and no tools of its own: hosts register theirs.

- A session is an append-only tree of entries. Moving the leaf starts a
  branch; context edits, labels and branch summaries never rewrite
  history. Storage sits behind SessionStore (JSONL here) and is only
  written once there is a conversation.
- Compaction summarizes the older part of a branch near the context
  limit, never separating a tool result from its call and summarizing a
  split turn's start separately. Moving in the tree can summarize the
  branch being left.
- Resources from folders the host names: AGENTS.md context files,
  skills and prompt templates.
- The system prompt is built from named sections with the host's app
  name, and later changes are sent as section patches.
- The extension API: typed events with Pi's semantics (gates fail
  closed, rewrites chain, observer failures are isolated), tools,
  commands and model providers, and a context to act on the session.
- AgentSession ties it together: prompts through commands, input
  handlers and expansion, persistence of every message, retry with
  backoff that keeps failed attempts out of the context, and automatic
  compaction on overflow and near the limit.

Maple's runtime does not use these crates yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Fixes from a review of the provider code:

- A body that ends with neither [DONE] nor a finish reason is a
  retryable error instead of a finished reply, so tool calls cut off
  mid-arguments never run. A JSON error body sent with a success
  status is reported the same way, with the provider's message.
- Tool-call fragments without an index are told apart by id, and an
  id or name that arrives after the first fragment is filled in.
- Reasoning that a server sends in two fields is kept once.
- A 429 that mentions tokens is a rate limit, not a context overflow.
- Integer text keeps every digit when coerced, and a null the schema
  allows (anyOf with null, nullable, enum) is kept.
- A thinking level the model does not accept is clamped before it is
  sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
- continue_run takes the run before draining the queues, so a refused
  continue no longer loses queued messages.
- A run whose future is dropped (a timeout, a panic) cancels its token,
  so the provider and tool work it started stops.
- The running flag changes under the run lock, so a run that is ending
  cannot mark the next one idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Fixes from a review of the session code:

- The session is busy from a prompt's start until it settles, retries
  and compactions included. Another prompt is queued when it asks to
  be, otherwise refused, and compaction, tree moves and forks wait.
  A dropped prompt or compaction future no longer leaves it busy.
- A user message an extension sends goes through the prompt path, and
  a custom message that starts a run brings the system prompt up to
  date first. Failures are reported as extension errors.
- Queued extension messages are not listed as queued prompts.
- The starting thinking level is recorded, and a model an extension
  provides is restored on resume.
- A retry the run leaves unfinished ends there instead of carrying
  into the next prompt. A reply cut off by a full context is
  compacted and asked again. An aborted run is not compacted.
- Prompt options an extension makes invalid are refused and reported
  instead of breaking every later prompt.
- Compaction no longer treats a turn after a state change as split,
  context size ignores usage from before the latest compaction, and
  branch summaries see the branch as the context did (edits applied,
  no request when there is nothing to summarize).
- Forks keep what a compaction kept from a label. A failed write is
  written again with the next entry, entries are written in one call,
  a line cut off by a crash no longer swallows the next one, and
  loading skips lines that are not valid UTF-8.
- Resources: an empty frontmatter block parses, plain skill files are
  named after themselves, template name collisions are reported, and
  an oversized ${@:N} start selects nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
…maries

Three differences from Pi's session, found while comparing review
findings with Pi 1.0.4:

- The session's next-turn hook passes the current model, thinking
  level, active tools and prompt patch to the loop, as Pi's session
  does. Changes an extension or the host makes during a run now reach
  the run's next request; the loop declares a changed tool set.
- Branch summarization registers its cancellation, so stopping the
  session stops it. The move then ends as cancelled and the leaf
  stays where it was.
- Project skills and prompt templates override the user's, matching
  Pi's resource precedence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
The Chat Completions provider dropped earlier thinking when it built a
request, so a model lost its own reasoning between turns and across tool
calls. Pi sends it back, and so does today's Maple build.

- The field a response streamed its reasoning in (reasoning_content,
  reasoning or reasoning_text) becomes the thinking block's signature.
- A request sends a model's own earlier reasoning back in that field.
  Reasoning without a known field is not sent.
- Another model's reasoning goes back as plain text, before its answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
…ershell

A session now creates Pi's built-in tools for its folder and gives the
model read, bash, edit and write, as Pi's does; powershell stays
registered for Windows. Hosts configure them through ToolsOptions, pick
the active ones, and replace one by registering a tool with its name.
They follow Pi's tools in names, schemas, limits, messages and prompt
text, written the Rust way:

- read: text a page at a time (2000 lines or 50KB, with continuation
  notes), images recognized by their bytes, BMP converted to PNG, and
  images resized to fit inline limits. macOS screenshot names are found
  when the typed name is not.
- bash and powershell: output streams to the interface at most every
  100ms; the model gets the last 2000 lines or 50KB, and the full output
  goes to a private file it can read. No default timeout. Stop, a
  timeout or a dropped call ends the command's whole process group (its
  process tree on Windows); a finished command may leave a background
  job running. A spawn hook adjusts the command, folder and environment;
  the session's id, file, model and thinking level are exposed as
  <APP>_* variables.
- edit: several disjoint replacements per call, matched against the
  original file, exactly or else loosely (trailing spaces, typographic
  quotes, dashes and spaces, Unicode compatibility forms), keeping line
  endings, the byte order mark and untouched lines. The result carries a
  display diff and a unified patch. Older argument shapes are accepted.
- write: creates folders as needed. Edits and writes to one file run one
  at a time.

Each tool takes swappable operations, as Pi's do. Settings gain Pi's
shellPath, shellCommandPrefix and images.autoResize. The system prompt
again says to use the shell for file operations when no file tools are
active, and loads skills through read, else bash, as Pi's does.

New dependencies: similar (diffs); image, base64, unicode-normalization
and libc were already in the lockfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Sessions now register Pi's grep, find and ls, off until a host or the
user turns them on, as in Pi. They follow Pi's tools in names, schemas,
limits, notices and prompt text, and search natively where Pi runs
ripgrep and fd, by the same rules:

- grep: Rust regular expressions (ripgrep's syntax), literal and
  case-insensitive search, a glob filter like ripgrep's --glob, context
  lines, 100 matches or 50KB, and lines cut at 500 characters. Hidden
  files are searched; a file's search stops at a NUL byte, as ripgrep's
  does for binary files.
- find: a pattern without / matches names, and one with / matches paths
  from the search folder at any depth; a pattern without capitals
  ignores case, as fd's does. Folders end with /. 1000 results or 50KB.
  node_modules is left out, as Pi asks of custom operations.
- ls: entries sorted regardless of case, dotfiles included and folders
  marked with /, 500 entries or 50KB.

grep and find skip .git and respect .gitignore, .ignore and their own
ignore file (.rgignore, .fdignore). As Pi has fd do, .gitignore applies
outside a repository too, and inside one a nested repository is not
matched against its parent's rules. Results come in path order. Each
tool takes swappable operations, as Pi's do.

New dependencies: ignore and globset, both already in the lockfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A failed response that names an account limit is no longer retried,
even when it comes as a 429: an exhausted quota, budget or balance,
billing, or a usage limit that resets after hours. Pi checks these
before its transient patterns, and so does is_retryable_error now.

The transient patterns gain the ones Pi 1.1 added: WebSocket closes,
HTTP/2 streams that got no response or were cancelled, retry-delay
caps, gRPC ResourceExhausted and gateway buffer limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
claude added 24 commits October 11, 2026 08:01
AgentSession::fork takes Pi's position. Before a user message, the new
session holds the path up to that message and the message's text comes
back to be edited; that is Pi's default. At an entry, it holds the
path up to and including it, as ours always did. A fork before the
first message starts an empty session.

As in Pi, extensions hear of a fork first as session_before_fork and
can cancel it, and a run in progress is stopped first. The forked
session's SessionStart names the file it came from, and a prompt's
before_agent_start event carries its images, as Pi's do.

Ours forks in place: the session and its extensions go on, so they get
a SessionStart without a shutdown before it, where Pi starts a new
runtime and shuts the old one down.

Pi features phase 1 had not ported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Every handler's context gains what Pi's ExtensionContext has and ours
lacked: compact, which runs without the handler waiting, stopping what
runs first, with a handle to await for the result; signal, the running
turn's cancellation; isProjectTrusted, which the host states in the
session's options; and the model registry.

Commands get their own context, as Pi's ExtensionCommandContext: the
same context plus what only a command the user started can wait for,
with no run waiting on it: waitForIdle, navigateTree, fork and the
system prompt options. pi-agent-core's Agent gives the running turn's
cancellation, as Pi's agent.signal does.

Pi features phase 1 had not ported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A prompt's images are converted and resized as the read tool's are,
after the before_agent_start handlers, as Pi's prompt does. The notes
on what was done follow the prompt's text, and an image that cannot be
used is left out, with the note saying so. They were sent as they
came, so an image a provider rejects failed every later request.

Steering and follow-ups keep their images as they are, as in Pi.

Drift: Pi already did this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
AgentSession::navigate_tree takes Pi's options and answers as Pi's
navigateTree does. Moving to a user or custom message stands before
it, and its text comes back to be edited, so sending again starts a
branch there; before, the message stayed the leaf and sending put two
user messages in a row. Moving to the first message stands before
everything. After a move, the tools the transcript declares there are
the active ones; ours kept the current set.

A label goes on the summary, or on the target when there is none.
Custom instructions can replace the default summary instructions.
Extensions get Pi's tree preparation and the stop signal in
session_before_tree, and can change the instructions and the label as
well as supply the summary, which is used only when the user wants
one. session_tree carries the summary entry, as Pi's does.

Drift (where the leaf goes, the tools) and Pi features phase 1 had not
ported (labels, replaced instructions, the preparation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
After a run, the session decides as Pi's _checkCompaction does. A
retryable error is retried first, never an overflow. An overflow, or a
reply cut short below the model's output limit, is compacted and asked
again once; a second time, the failure is reported, in Pi's words. A
complete reply over the window is compacted and kept, and a context
near its limit is compacted, after a failed reply too, from the
estimate. A reply from another model, or from before the latest
compaction, is left alone, and a silent overflow is read from usage
only while no edit since has changed the context. Before a prompt,
the same check runs on the last reply, a stopped one included, as in
Pi, so a stopped run or a switch to a smaller model can compact first.

Compaction reports as Pi's does: CompactionEnd says whether it was
stopped and whether the reply is asked again, failures read
"Compaction failed: ...", "Auto-compaction failed: ..." or "Context
overflow recovery failed: ...", and an automatic compaction with
nothing to compact reports nothing. Extensions hear of a failure as
session_compact_failed, and session_before_compact and
session_compact carry the reason, whether the reply is asked again,
and, before, the branch and the stop signal.

A reply's place in the branch says whether it came before the latest
compaction; Pi compares timestamps, which a fast reply can share with
the compaction. The timestamps decide only for a reply the branch does
not hold.

Drift (the checks, the order) and Pi features phase 1 had not ported
(the cut-short trigger, the check before a prompt, the events).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
As Pi's getContextUsage reports it, the context size is unknown after
a compaction until a reply measures the context again: the usage kept
from before the compaction measured the old context. ContextUsage's
tokens and percent are options now, and a model without a context
window has no usage. Ours went on reporting an estimate.

Drift: Pi already did this, since earendil-works/pi#1382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A summary request that fails for a moment is tried again, and the
host hears of it as Pi's summarization retry events report it: the
retry scheduled, with its attempt, delay and error; the request going
out again, for a compaction with its reason or for a branch summary;
and the end of the retries, however they end. As in Pi's
retryAssistantCall, summaries do not retry when retries are off, and
the delays grow to the settings' longest one, which was fixed at a
minute.

Pi features phase 1 had not ported, and drift (the policy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
The skill and prompt template paths a settings file lists resolve as
Pi resolves them: `~` is the home folder, and a relative path is
relative to the folder of the settings file that lists it. They were
resolved against the working folder. Both files' lists apply, the
project's first, as Pi's resource loader takes them; a project's list
replaced the user's.

What the settings list comes before the default folders, so a listed
template or skill wins a name clash over the folders', as in Pi. Paths
a host adds still come after the folders, as Pi's additional paths do.
Resources::load takes the settings for their lists, and load_skills
and load_prompt_templates take the listed paths ahead of the host's.

Decided with the repository owner: follow Pi in both.

Drift: Pi already did this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Settings::model_thinking_levels is Pi's modelThinkingLevels: a level
per model, by provider/model-id. As in Pi, a new session starts at its
model's own level before the settings' default, and a switch to a
model with its own level applies it. A session restored with a
conversation keeps the level it recorded. This is where a host's
recommendations per model go.

A Pi feature phase 1 had not ported; it was listed as not implemented
yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
As in Pi's Agent, a run whose loop fails still ends as runs end: with
an empty reply carrying the error (aborted when the run was stopped),
then message_start, message_end, turn_end and agent_end, and the
prompt returns normally. In Rust only a panic in a hook, a listener
or the stream function fails the loop, and it ended the run without
those events. What the run left behind is then cancelled.

prompt_with_images is Pi's prompt(text, images): the images follow
the text in one user message. clear_steering_queue and
clear_follow_up_queue each clear one queue, as Pi's do; like
clear_queues, they return what they removed.

Pi features phase 1 had not ported; they were listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
StreamOptions gains Pi's request options for the Chat Completions
provider:
- tool_choice, auto or none;
- sampling_params, merged into the body after the named fields, over
  the model's own and those for the request's thinking level;
- timeout_ms, how long a response may take to start: ten minutes when
  unset, as in the OpenAI SDK;
- transform_headers, Pi's transformHeaders, which rewrites the
  request's own headers before they are sent;
- on_response and on_provider_stream_event, which see the response's
  status and headers, and each streamed event before it is read.
A request header may now be None, which removes it, and a model's own
headers go under the request's.

max_retries and max_retry_delay_ms are Pi's retryProviderRequest. A
request without a response, a timeout, a conflict, a rate limit or a
server error is sent again, or as x-should-retry says, after the wait
retry-after-ms or retry-after asks for or else a growing backoff. A
server asking for longer than the limit (a minute unless set) fails
the request at once, and the session's retries take over. Unset,
nothing is retried, as in Pi. HttpResponse carries the response
headers for this.

Pi features phase 1 had not ported; they were listed to decide. Left
out: the env, metadata and websocket options, which serve providers
or transports these crates do not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A session's own requests now go out as Pi's session sends them
(buildRequestOptions), with options read as each request goes:
- the session's id;
- how long the response may take to start: retry.provider.timeoutMs,
  or else httpIdleTimeoutMs, five minutes unless set, and zero or
  "disabled" for no limit;
- retry.provider.maxRetries and maxRetryDelayMs, for a provider's
  retries before a response starts;
- hooks for the extension events before_provider_headers (rewrite the
  request's headers; None removes one), after_provider_response and
  provider_stream_event, next to before_provider_request.
Compaction and summaries still go to the models directly, as in Pi.

The retry settings take Pi's current names: the session's longest
delay is maxAgentDelayMs, and a file's older maxDelayMs moves to
retry.provider.maxRetryDelayMs unless the file sets that too, as Pi
migrates it. maxDelayMs had been read as the session's delay.

Pi features phase 1 had not ported, and drift (the names). Pi also
gives its HTTP client httpIdleTimeoutMs as the time a response may
stall; here that stays the transport's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Pi's classifier API: a ClassifierModel; a ClassifierContext with a
JSON state, images and named questions (choice, score and yes/no);
and answers with their probabilities, in a ClassifierResult that ends
in error or aborted as a failed reply does. ClassifyFn is Pi's
ProviderClassifier, and ClassifierRegistry dispatches by API as Pi's
classify() does, refusing images for a model without image input.
The session's ModelRegistry holds classifier models and APIs and
classifies with the provider's key; extensions register classifiers,
as with Pi's registerProvider, and reach them through their context's
models().

system_one speaks System One's protocol (POST {base_url}/systemone),
sent as Pi's postClassifierRequest sends it: the payload and response
hooks, a timeout per attempt, two retries unless set, and Pi's error
messages. A yes/no question is a noul question on the wire.

The protocol is Maple's version, so these differ from Pi:
- images go as data URLs and the request's temperature is sent; Pi's
  typesafe-system-one sends neither;
- answers keep the temperature and option mass Maple's server
  reports, and scores keep their probabilities and legend;
- the result keeps the reply's id and its upstream request count;
- the state is any JSON, instructions and criteria are JSON values,
  and a yes/no question's criteria are optional, as Maple's API takes
  them; Pi takes an object and text;
- cached tokens are read out of the input as cache reads, as Chat
  Completions usage is;
- an API key is optional, since the host's transport may
  authenticate, as for Chat Completions.
FauxClassifier is a scripted classifier for tests; Pi has none.

Pi's TypeSafe, Cloudflare, llama.cpp and OpenAI Decisions APIs are
left out, as the repository owner decided.

A Pi feature phase 1 had not ported. The differences above follow
Maple's System One API, at the repository owner's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A skills folder's search now leaves out what the .gitignore, .ignore
and .fdignore files of each folder searched name, as Pi's skill scan
does and as the template folders already did. A SKILL.md they leave
out does not make its folder a skill, and the folder's subfolders are
still searched, as in Pi.

A folder linked from inside itself is searched once, as for template
folders. Pi recurses until its stack runs out and keeps what it found;
in Rust that would end the process.

A Pi feature phase 1 had not ported; it was listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
In a linked worktree inside the main checkout (git worktree add
.worktrees/feat, say), the main checkout's AGENTS.md is an ancestor of
the worktree, so both it and the worktree's own copy loaded: the same
repository's context twice. As Pi's findShadowedContextFile does, the
main checkout's file with the name of the worktree's own is now left
out. A worktree beside the main checkout, a bare layout and a
submodule are left alone, as in Pi, and so is a worktree without a
context file of its own, which still inherits the main checkout's.

A Pi feature phase 1 had not ported; it was listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
compaction.modelOverrides gives a model, by provider/model-id, its
own reserveTokens and keepRecentTokens, over the others, as Pi's
getCompactionSettings resolves them. The session applies them where
Pi does: automatic compaction after a reply and before the next turn,
and every compaction's cut. Branch summaries keep the plain reserve,
as they stand in for Pi's branchSummary settings.

images.blockImages sends the model no images: in user messages and
tool results each becomes "Image reading is disabled.", one note for
images in a row, as Pi's session converts them. The setting is read
for each request, and the session keeps the images.

Pi features phase 1 had not ported; they were listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
resources_discover is asked after session_start, as in Pi: an
extension answers with skill and prompt template files or folders of
its own, and they join the session's resources after the others, as
Pi's extendResourcesFromExtensions adds them. A name taken already
keeps its skill or template and the newcomer is reported as a
collision. Resources the host replaces keep what was discovered. Pi's
theme paths have no counterpart here.

user_bash is asked about a shell command the user runs, before it
runs: AgentSession::user_bash returns the first answer, operations to
run it with or the extension's own result, as Pi's emitUserBash does
for its hosts. A failing handler is reported and its error returned,
so the host does not run the command.

Pi features phase 1 had not ported; they were listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
- parse_skill_block reads back the block an expanded /skill: command
  puts in a user message, as Pi's parseSkillBlock: the skill's name,
  location and text, and what the user wrote after it.
- AgentSession::export_to_jsonl writes the current branch to a session
  file of its own, as Pi's exportToJsonl: a header with the session's
  id and folder, then the branch's entries, each the parent of the
  next. Without a path it is session-<time>.jsonl. A relative path is
  relative to the session's folder, where Pi takes its process's;
  a library's host decides its process's folder for other reasons.
- An extension command can offer completions for its arguments, as
  Pi's getArgumentCompletions, through
  register_command_completions; a host asks with
  command_argument_completions.
- ExtensionContext::register_provider and unregister_provider take
  effect at once, as Pi's registerProvider and unregisterProvider do
  once extensions are loaded.

Pi features phase 1 had not ported; they were listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
ExtensionUi gains the parts of Pi's ExtensionUIContext that are not
its terminal's:
- notify takes a level: info, warning or error;
- confirm, select and input take Pi's dialog options: a cancellation
  and a timeout, after which the host closes the dialog;
- editor, a multi-line editor with prefilled text;
- set_status, set_working_message, set_widget (lines above or below
  the editor) and set_title;
- paste_to_editor, set_editor_text and editor_text, for the prompt
  being written.
Each does nothing by default, so a host implements what it shows.
Left out with Pi's terminal interface: raw terminal input, the
working indicator's frames, custom components, footers and headers,
editor components, autocomplete providers and themes.

Pi features phase 1 had not ported; they were listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
When the host names a settings file (AgentSessionOptions::
settings_file), what the session's setters change is saved to it as
Pi's settings manager saves the user's settings: turning automatic
compaction or retries on or off, the steering and follow-up modes,
and, through set_thinking_level_as_default (Pi's setThinkingLevel
with persist), the default thinking level. Only the changed settings
are written; the rest of the file stays, and it is written indented by
two spaces, as Pi writes it. A file that does not parse is left alone,
as Pi leaves it, and a failed save is reported as a persistence error.

The settings gain Pi's defaultProvider and defaultModel, and
set_model_as_default (Pi's setModel with persist) switches the model
and saves both in one write.

Pi always saves, since its settings always come from files; without a
file here, changes stay in the session, as before. Pi locks the file
across processes while it writes; this writes it through a temporary
file instead.

A Pi feature phase 1 had not ported; it was listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
A fork's header named the session it came from by id. Pi's
createBranchedSession names the parent's session file, and nothing
when the parent was kept in memory; forks now do the same, so a
session file reads as Pi's does. The labels a fork carries over also
keep the time they were set, as Pi keeps it (labelTimestampsById);
they were stamped with the time of the fork.

Drift from Pi, found while porting Pi's session runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
create_agent_session sets a session up as Pi's createAgentSession
does. Without a session, it starts a new one kept in Pi's folder for
the project (default_session_dir: sessions/--<cwd>-- in the agent's
folder, the file named <time>_<id>.jsonl). Without settings, it loads
the user's settings.json from the agent's folder, overlaid with the
project's when the project is trusted, and saves what the session
changes back to the user's file. Without resources, it loads them for
the working directory.

The model is chosen in Pi's order: the one asked for; else, for a
session with a conversation, the one it recorded while its provider
has credentials ("Could not restore model p/id. Using q/id"
otherwise); else find_initial_model, the settings' defaultProvider and
defaultModel while that provider has credentials, or else the first
model whose provider has. The thinking level follows Pi too: the one
asked for, else the recorded one, else the model's own level from the
settings, else the default, clamped to the model. The result carries
Pi's modelFallbackMessage and the settings files that did not load.

The model is chosen after extensions register their providers, so a
session restores a model an extension provides; Pi looks it up first
and misses it, which is one of the Pi bugs this PR lists as fixed.
AgentSession::new keeps its own rule (the recorded model, or else the
host's). Sessions also take the start event extensions hear
(session_start, Pi's sessionStartEvent), with Pi's reasons: startup,
reload, new, resume and fork. A reload discovers resources again, as
in Pi.

The supporting pieces: SessionManager::create_in and open_file (Pi's
create and open, including cwdOverride), ApiKeySource and
ModelRegistry::has_configured_auth, and available_models.

Departures from Pi: the host names the agent's folder and the
project folder's name (Pi defaults to ~/.pi/agent and .pi); the
project is trusted only when the host says so, where Pi's SDK trusts
it unless told otherwise; models come from the host's registry, so
Pi's preferred model per known provider is not ported and the first
available one is taken; "No models available." has no login help,
which is the host's to give; and tools are chosen as
AgentSessionOptions takes them, since Pi's tool lists and defaultTools
are not ported.

A Pi feature phase 1 had not ported; it was listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
AgentSessionRuntime holds the session a host is on and replaces it as
Pi's runtime does: new_session (kept next to the current session's
file, or in memory with it, with Pi's parentSession, setup and
withSession), switch_session (a stored file, with cwdOverride), fork
(before a user message, giving its text back, or at an entry, into a
new file next to the current one) and import_from_jsonl (copied into
the session's folder unless it is there, a name taken getting -1, -2
and so on). dispose shuts the session down.
create_agent_session_runtime builds the first session with the host's
RuntimeFactory, Pi's CreateAgentSessionRuntimeFactory, which builds
each later one.

Each replacement goes as Pi's: extensions can cancel it with the new
session_before_switch event (new or resume) or session_before_fork;
the session left is stopped, so its last turn is recorded, and hears
session_shutdown, which now carries Pi's reason (quit, reload, new,
resume or fork) and the file that replaces it; the next session hears
session_start with its reason and the file before. A stored session
whose folder is gone is refused with MissingSessionCwd, as Pi's
MissingSessionCwdError, so the host can continue elsewhere; a missing
import is ImportFileNotFound. set_rebind_session hands the host each
new session.

Commands get Pi's newSession and switchSession, and fork as Pi's
takes its options: a session the runtime owns is replaced through it.
Without a runtime, newSession and switchSession do nothing, as in Pi,
and fork goes on in the same session as before, now kept in a new
file next to the old one when there is one.

Departures from Pi: a cancelled replacement fails with Cancelled, as
tree moves do here, where Pi returns cancelled; one started while
another is under way fails as busy; a replaced session's commands no
longer act on the runtime, where Pi's stale contexts throw, and Pi's
setBeforeSessionInvalidate is not ported; a fork copies the tree in
memory instead of reading the file again, which gives the same
session; extensions hear session_start before the runtime takes the
session, so a session_start handler cannot replace it; setup is not
async; the factory reports the settings errors of the sessions it
builds, where Pi's runtime keeps their diagnostics; and project trust
is the host's, so Pi's projectTrustContext is not taken.

A Pi feature phase 1 had not ported; it was listed to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
Extensions can add session entries where a run pauses, as Pi's
boundary drafts let them: at turn_end, once a turn's reply and tool
results are in the session, and at the new agent_before_settle, once
a run's retries and compactions are done and before it settles. A
handler answers with drafts (extension state, an extension message, a
context edit, or a compaction of its own, which without a first kept
entry keeps nothing before it) and whether one more request should
follow even when nothing else would make one. Each handler sees what
the ones before it asked for and a preview of the context it would
make (Pi's BoundaryContextPreview). Drafts that would not apply, such
as an edit of an entry the branch does not hold, are reported as
"Invalid boundary entries" and none are added; a request with nothing
to answer is refused and reported, in Pi's words.

turn_end carries the turn's index in the run, its reply and tool
results and their entry ids, and how the turn ended; drafts added
there are in the next request. Entries added before settling are in
the session, and a run asked to go on continues from it, as in Pi.

Departures from Pi: turn_end is an event of its own here, while the
agent's TurnEnd still reaches extensions with the other agent events;
the entry ids are found by the message in the branch, where Pi keeps
a map from each message; and `continue` is continue_run, as continue
is a Rust keyword.

A Pi feature phase 1 had not ported; it was listed under not
implementing for turn_end and to decide for agent_before_settle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STEaSNTNUiiNNLXcKidE2d
@AnthonyRonning
AnthonyRonning force-pushed the claude/kind-turing-gzv8nl branch from 2a33432 to 76309fc Compare October 11, 2026 08:48
@AnthonyRonning
AnthonyRonning marked this pull request as ready for review October 13, 2026 00:02
@AnthonyRonning
AnthonyRonning removed this pull request from stack #1125 October 13, 2026 00:04
@AnthonyRonning
AnthonyRonning changed the base branch from agent-pi/phase-0 to master October 13, 2026 00:04
@AnthonyRonning
AnthonyRonning merged commit 075eeb7 into master Oct 13, 2026
37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SessionManager._persist() throws EEXIST when file is pre-written by _rewriteFile(), masking real agent errors

2 participants