Skip to content

Repository files navigation

pi-repl-agents

A Pi coding-agent extension that combines a persistent Python kernel with child AgentSessions exposed as Python calls.

The project began as an MIT-licensed fork of k3-2o/pi-repl-py v0.6.6. That project supplied the persistent ipykernel workbench, Jupyter/ZMTP host, snapshots, recovery, and helper loading. This fork keeps and extends that foundation, then adds child-session orchestration and Python-value transfer between parent and child kernels.

A coding agent has one context window, and everything it reads competes with the work it is trying to do. Files, long strings, parsed documents, computed state, and other agents' responses can sit in kernel variables without being displayed to the parent model. Kernel variables enter the parent context only when code displays them. Keep the parent context for decisions; let Python hold the working set.

Child sessions are ordinary calls from that kernel. Each child works in its own model context and Python namespace, then returns prose or publishes a Python value. The parent's code can store, validate, filter, and forward those values, including to further children, without reading them.

The ordinary case is one line:

import pi_agents as agents

print(agents.run("Reply with exactly: pong"))
pong

The same call can delegate ordinary coding work:

print(agents.run(
    "Review src/auth.ts and report concrete defects with file:line evidence."
))

If you only need the answer, stop there. Assign the response when you want Python to keep and compose it.

Composing children in Python

The upstream kernel foundation and the agent layer meet at one boundary: Python values can move between kernels while the parent conversation receives only what code chooses to show.

flowchart LR
    P["parent model context"] -->|code| K["persistent Python kernel<br/>working set"]
    K -->|"printed / returned output only"| P
    K -->|"run() / spawn()"| A["child AgentSession<br/>own model context"]
    K -->|"objects=  (values copied)"| C["child Python kernel<br/>inputs namespace"]
    A <-->|execute| C
    A -->|"prose · published value"| K
Loading

Several children can work independently while their responses stay in the kernel, unread by the parent. The reviewers below can read the repository themselves. The parent never ingests their reports merely to pass them to a verifier:

lenses = [
    "injection and input handling",
    "authentication and session management",
    "concurrency and shared state",
]

reviews = agents.run([
    {
        "message": f"Review the repository for {lens}. "
                   f"Call agents.publish_result(...) with a list of findings. "
                   f"Each finding must contain file and claim.",
        "thinking": "high",
    }
    for lens in lenses
], errors="collect")

def valid_findings(value):
    return (
        isinstance(value, list)
        and all(
            isinstance(finding, dict)
            and {"file", "claim"} <= finding.keys()
            for finding in value
        )
    )

# The reviews are in kernel variables. The parent has not read them.
packets = []
for response in reviews:
    if not response.ok:
        continue
    try:
        packets.append(response.require(validator=valid_findings))
    except agents.AgentOutputError:
        continue

verified = agents.run(
    "Check every claim in inputs.reviews against the repository. "
    "Deduplicate overlaps and call agents.publish_result(...) "
    "with only supported defects.",
    objects={"reviews": packets},
).value

print(f"{len(verified)} verified findings from {len(packets)} accepted reviews")
3 verified findings from 3 accepted reviews

Parent-context savings are not total-token savings. A child still spends tokens on whatever it reads and reveals in its own context. objects= copies values into the child's kernel, where its code reads them as inputs.reviews; the values are not inserted into the child prompt.

This is also not a durable workflow engine. Use one when restart recovery, scheduling, formal approvals, or exact replay define the problem. PiReplAgents provides Python control flow inside a live coding session.

API in brief

Call Behavior
agents.run(message, **opts) Blocks and returns a Response. Printing the response shows the child's prose.
agents.run([msg, ...], errors="collect") Runs children in parallel and returns responses in input order. Per-item failures remain inspectable.
response.value Returns the exact Python value supplied through the child's publish_result() call.
agents.spawn(...) Starts a persistent child conversation for follow-up turns.
objects={...} Copies values into the child kernel, readable there as inputs.<name>.

A bare Response expression displays compact metadata rather than prose. Use print(response) for the answer, or keep the response in a variable.

Requirements and installation

Pi, Node.js 22.19.0 or newer, and Python 3.10 or newer are required. Install Pi first if needed:

npm install -g --ignore-scripts @earendil-works/pi-coding-agent

The package installation looks for python3, then python, and needs an interpreter that can create a virtual environment and install the evaluator runtime. The package does not bundle Python or these dependencies.

For a local checkout, run the user's npm install first because Pi does not run npm for local-path packages:

cd /path/to/pi-repl-agents
npm install
pi install /path/to/pi-repl-agents

For the current GitHub version:

pi install git:github.com/TobyNoSkillSon/pi-repl-agents

Pi uses its configured npm command for Git packages, npm by default. If npm lifecycle scripts are disabled, run node scripts/setup-venv.mjs from the checkout before starting Pi. Model calls use the user's existing Pi authentication; the package contains no credentials.

The install hook creates or repairs the evaluator environment at:

${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-repl/venv

It verifies and installs these exact evaluator targets:

ipykernel==7.3.0
jupyter-client==8.10.0
pyzmq==27.2.0
traitlets==5.16.1
tornado==6.5.8

A stale but valid venv is repaired in place without deleting user-installed helper packages. The bundled Python client is loaded from the package itself; it has no third-party Python dependency.

Activation

The extension is dormant in an ordinary Pi session. Activate it with the --repl flag or the development override:

pi --repl
PI_REPL_FORCE=1 pi

In REPL mode, Pi adds an execute tool backed by a persistent Python kernel and keeps the configured active tools. The child-agent API is preloaded in that kernel as agents.

API reference

The kernel preloads the client as agents. The canonical explicit import is:

import pi_agents as agents

A bare Response expression in Pi displays compact metadata. Use print(response), response.text, or str(response) for the full prose. A bare batch displays a summary rather than concatenating every answer; str(responses) remains the explicit full rendering.

Pass a list to run independent tasks concurrently. An item can be a string or a mapping with required message and optional objects, model, thinking, tools, or add_tools:

items = [
    {
        "message": "Classify inputs.shard and call agents.publish_result(...) with the ordered labels.",
        "objects": {"shard": shard},
        "thinking": "high" if shard_is_difficult(shard) else "medium",
    }
    for shard in shards
]
responses = agents.run(items, model="provider/model-id", errors="collect")

Top-level arguments are defaults. A key supplied by an item replaces its default. If an item supplies tools or add_tools, it owns both tool-selection fields; the omitted sibling is not inherited from the top level. Mapping items are list-only, and unknown keys fail locally. The client validates and pickles the entire batch before sending any child request. Results remain in input order. Pressing Escape, Pi's default app.interrupt binding, while waiting for a batch aborts the execute cell and closes every batch request. The host then cancels and disposes the running children before Python surfaces KeyboardInterrupt; interrupted batches do not return partial responses. Separate scalar run calls are sequential, so keep independent work in one list. The default limit is six simultaneous children, and batches of six or fewer need no concurrency argument.

For list-form batches, the default errors="collect" retains terminal, context-size, busy, and capacity failures by item. Host shutdown, authorization, transport, protocol, configuration, and unexpected client failures still propagate. For a list-form batch, errors="raise" waits for collected outcomes and raises ResponsesError with the complete ordered batch:

reviews = agents.run([
    "Review the repository for security defects.",
    "Review the repository for correctness defects.",
    "Review the repository tests.",
], errors="raise")

agents.run(
    "Compare inputs.reviews and return one supported verdict.",
    objects={"reviews": [response.text for response in reviews]},
)

Counts in examples are illustrative. Let the number of children follow the useful independent questions; there is no preferred number of reviewers.

Every run child disposes its kernel after the response. response.agent() can reopen the retained Pi session while the current host still retains it. Host restart, auto-retirement, or retention eviction can make an old handle unavailable.

spawn begins one child in the background and returns an Agent after its first model response starts. If the initial generation fails before producing model output, spawn still returns the Agent; agent.initial.result() contains the failure. The agent owns a persistent conversation, and each turn has an exact Generation handle. submit() immediately reserves and returns a queued generation while the child finishes earlier turns. send() remains the blocking convenience:

with agents.spawn("Inspect the records.", objects={"records": [1, 2, 3]}) as agent:
    initial = agent.initial.result()
    followup = agent.submit("Now report only the outliers.")
    # Continue independent work before joining this exact turn.
    outliers = followup.result()

Generation.status(), .result(), and .cancel() always target one turn. Agent.result() and .cancel() preserve their legacy latest/active behavior.

Use the bundled orchestrate-pi-agents skill for practical examples of generated batches, focused critics, competing views, stored values, adaptive branching, retained follow-ups, nested delegation, and shared-file control. The examples use ordinary Python rather than package-defined workflows.

Messages are ordinary Python strings

PiReplAgents sends the message string exactly as Python produced it. It does not parse braces, expand placeholders, or wrap values in hidden message markup.

Use an f-string when the task contains a Python value:

candidate = "src/session.ts"
response = agents.run(f"Review {candidate} and cite file:line evidence.")

Use json.dumps() when you want stable JSON text:

import json

policy_text = json.dumps(policy, indent=2, sort_keys=True)
response = agents.run(f"Review src/session.ts against this policy:\n\n{policy_text}")

JSON, dictionaries, format strings, regular expressions, and unmatched braces remain literal after Python constructs the string. There is no context= argument or package-specific placeholder syntax. Pass a value through objects= when the child needs the actual Python object rather than text.

Objects and inputs

objects is a mapping of Python values copied to the child for that generation. The child receives those values through the inputs namespace:

# Code running in the child
from pi_agents import inputs

records = inputs.records

Object values must be pickleable in the parent and must successfully unpickle in the child process. Prefer built-in containers, importable classes, or types from installed modules; classes defined only in the parent REPL are not portable. An empty objects={} sends no payload. A copied input or published payload is limited to 100,000,000 bytes, which is 100 MB decimal. max_payload_mb can lower that limit but cannot raise it above 100. Queued child inputs enforce the same aggregate byte cap.

A child can return named Python values with publish, or one conventional "result" with publish_result. The parent receives the values in Response.objects and can consume the conventional result strictly through Response.value:

# Code running in the child
import pi_agents as agents
from pi_agents import inputs

agents.publish_result({"count": len(inputs.records)}, preview="Counted the records")
summary = response.value
if not isinstance(summary, dict) or not isinstance(summary.get("count"), int):
    raise TypeError("expected a mapping with an integer count")

Response.value is a strict read-only property for the conventional publish_result() value. It raises if the generation failed or no "result" publication exists, and it never falls back to response prose. get_object() retrieves a named publication and optionally accepts a default. require() remains available when its validator semantics fit: None or True keeps the original value, another falsey result rejects it, and another truthy result becomes the returned value. Arbitrary pickle values remain opaque to the TypeScript host, so validation runs in the parent Python client. Published descriptor metadata remains available in Response.published.

The optional preview is for completion notices and is limited to 4,096 UTF-8 bytes. Publishing is owned by the current child generation; the root parent cannot call it directly. Publications commit together only for a completed generation.

Results, failures, and supervision

A Response exposes ok, failure, generation_id, host_id, session_path, published, value, get_object(), raise_for_status(), generation(), and optional info. Responses exposes ok, successes, failures, and raise_for_status(). Broker and lifecycle failures carry stable codes and agent/generation identity when available.

New hosts attach an immutable RunInfo to each exact terminal generation. It records request, batch, slot, host, agent, and generation identity; the task's UTF-8 byte count and SHA-256 digest; resolved model; lineage; timing; per-generation usage when measurable; model-turn count; and tool names with invocation counts. It contains no task text or publication values. Usage is the delta for that generation, including a warm follow-up, rather than cumulative retained-session usage. info.usage can be None when the host cannot measure a delta. context_percent may exceed 100 when provider telemetry reports context beyond the registered model window; the client preserves that optional measurement without invalidating a completed response.

info = response.info
if info is not None:
    tokens = None if info.usage is None else info.usage.total_tokens
    print(
        info.batch_id,
        info.slot,
        info.lineage.parent_tool_call_id,
        tokens,
        info.tools,
        info.timing.total_ms,
    )

Tool counts report invocations, not inferred effects. Verify filesystem, network, or external-system effects through the relevant trace or state when that distinction matters.

snapshot = agents.inspect()                 # root caller only; live children
snapshot = agents.inspect(include_closed=True)
agent_state = agent.describe()

In interactive Pi, a passive widget appears above the editor while child work is active. Its aligned rows show the child number, friendly model name, total generation runtime, completed model turns, current active-context tokens, and a short current or last tool description. The normal generating phase is implicit; exceptional starting, retrying, and compacting states remain explicit. List-form batches include their exact scheduled total and a N queued summary; finished rows disappear immediately. Tool detail is deliberately narrow: read, edit, and write may expose only the target basename, while recognized shell commands may expose only an allowlisted command family and subcommand. The widget is notification-driven, performs no model calls, accepts no input, and excludes prompts, raw arguments, code, full paths, results, credentials, and published values.

/agents opens the detailed bounded read-only supervisor overlay; /agents all includes closed children. It shows topology, lifecycle state, elapsed time, queue depth, token usage, the latest generation ID, and why each child remains alive.

Models, tools, and configuration

agents.models() returns the models advertised by the host. A call can select "inherit", an unqualified model id when it is unique, "provider/id", or {"provider": "...", "id": "..."}:

models = agents.models()
result = agents.run("Do the analysis.", model="provider/model-id", thinking="medium")

Children inherit the parent session's active tools, profile extensions and skills, authentication paths, and Python helpers. MCP access comes through inherited extensions and their profile configuration. execute always remains active. Child sessions enable Pi's standard auto-compaction and bounded transient-error retry in their isolated in-memory settings, even when the root profile disables either feature for a root-only replacement. The root profile file and its configured compaction and retry tuning remain unchanged. With Codex's automatic transport, a WebSocket failure marks that child session for SSE fallback before Pi retries the interrupted turn; exhausted retries fail the generation. tools=[...] or add_tools=[...] can activate additional configured tool names but cannot remove inherited parent tools; a call or configuration cannot provide both.

The optional global configuration file is:

${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-agents/config.json

It accepts the settings directly or under an agents wrapper. Per-call arguments override global settings. Python uses snake_case names such as max_payload_mb; JSON uses the corresponding camelCase names such as maxPayloadMB. agents.configure(...) changes defaults in memory for the current extension host; edit config.json for persistent defaults.

The defaults are:

Setting Default
model "inherit"
thinking "inherit"
completion "queue"
concurrency 6
max_children 32
delegation_depth 0
max_payload_mb 100
keep_warm_seconds 300
tools ["execute"]

Unknown or misspelled Python call and agents.configure() options raise InvalidRequestError instead of being ignored. The concurrency argument overrides the simultaneous-child limit for list-form run; omit it when the configured default already covers the batch. A scalar run accepts it for signature compatibility but does not change host authority.

The default completion mode is queued. Use spawn when the parent should continue before the child finishes. Use run when the Python call should wait. A spawned child completion is delivered to Pi as a non-display completion message, and its response remains retrievable through the Agent.

There are no caller-supplied system prompts. The fixed child prompt lives in src/agents/child-system-prompt.md. Children load the same profile extensions and skills as the parent, with PiReplAgents itself excluded to prevent a recursive child host. They use the same profile configuration, authentication paths, environment, and helper directory. Inherited extensions receive the child session lifecycle, so MCP connections are child-owned and close with the child. Parent conversation, prompt templates, themes, and context files are not inherited.

Runtime paths and controls

Runtime state is profile-specific under ${PI_CODING_AGENT_DIR:-~/.pi/agent}:

Location Purpose
pi-repl/venv/ Python evaluator and ipykernel
pi-repl/helpers/ Optional user Python helpers
pi-repl/state/ Per-session namespace snapshots
pi-agents/config.json Child-agent defaults
sessions/ Pi child-session transcripts

PI_REPL_STATE_ROOT overrides the snapshot root. PI_REPL_TIMEOUT_MS sets the per-cell silence timeout in milliseconds; 0, the default, disables it. See Architecture and Helpers for details.

Persistence and limits

The parent Python namespace persists across execute calls and turns while its kernel is alive. For sessions with a session file, the namespace is snapshotted and restored on restart on a best-effort basis. Agent, Generation, Response, and Responses are pickleable and survive a kernel-only rebuild under the same host. Older response pickles restore with info=None. Values that cannot be pickled are omitted. Handles restored after an extension restart remain bound to the old host and fail explicitly.

Compatibility remains on protocol v1. Per-item mapping preparation happens in the Python client, and an older host can run those resulting scalar requests. Older hosts omit generation observations, so the current client returns response.info is None. Older clients ignore the additive observation field returned by a newer host.

Child sessions have Pi session files. run children dispose their kernels after completion. Spawned children remain warm for 300 seconds by default after their queue drains. A cold refill on the next follow-up reopens the child from its session file. Warm follow-ups retain the current inputs; passing a non-empty replacement mapping replaces them. An empty objects={} sends no payload and leaves warm inputs unchanged. Transient non-empty objects are not in the session history. If an agent previously received such objects, pass replacements after cold expiry or use continue_without_objects=True to continue without them:

agent.send("Continue from the conversation only.", continue_without_objects=True)

An Agent is bound to one host incarnation; stale handles fail explicitly after an extension restart. Closing a child revokes its live capability but leaves a host-local tombstone for idempotent close, inspection, and bounded exact-result retrieval. The newest 32 terminal generations per agent are retained within the configured payload budget; an evicted handle returns generation_expired rather than another generation. Expired IDs and closed tombstones are also bounded (128 each); older identities become generation_not_found or agent_not_found. When max_children is full, the host may auto-retire the oldest cold terminal child before admitting new work. Tombstones disappear when the host shuts down. run always sets keepWarmSeconds to zero. Nested child delegation is disabled by default by delegation_depth=0. Descendants may use blocking agents.run; nested background spawn is rejected because completion routing belongs to the creating session. Closing a parent revokes its descendant tree. Each caller has a distinct host-issued broker capability. Descendants inherit the root parent's active tool surface while delegation depth, payload, concurrency, child-count, and warm-retention limits remain bounded by host authority.

Standard output, standard error, displayed results, and exception text are bounded before entering model context. An oversized Jupyter frame rejects the active cell and forces a kernel rebuild on the next call.

The evaluator and child sessions are trusted code, not sandboxes. They run with the permissions of the Pi process and can access files, subprocesses, and the network. Object transport, published values, and namespace snapshots use Python pickle. The Python client unpickles received publications, and the evaluator unpickles restored namespace state. A crafted pickle can execute code. Use only trusted models, child code, publications, and snapshot files.

Development and tests

From a checkout:

npm install
npm test
npm run knip
npm pack --dry-run

npm test runs formatting and lint checks, TypeScript typechecking, Python tests, TypeScript unit and regression suites, and real-kernel integration tests. The integration suite boots ipykernel processes and takes longer than the unit suites.

Documentation

License and attribution

This fork is MIT licensed. The base is k3-2o/pi-repl-py v0.6.6, tag v0.6.6, commit 11c10e0a56a68a161c4a1d206d68e8e9e91f313a. See LICENSE for the preserved MIT notice and PATCHES.md for the fork changes.

About

Python-native child-agent orchestration for Pi with persistent ipykernel workspaces

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages