pi-agent-cli-lc 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/.gitignore +1 -0
- pi_agent_cli_lc-0.5.0/AGENTS.md +125 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/PKG-INFO +3 -3
- pi_agent_cli_lc-0.5.0/agent.example.toml +82 -0
- pi_agent_cli_lc-0.5.0/config.toml.example +8 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/__main__.py +311 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/agent.py +1017 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/config.py +354 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/context_files.py +67 -25
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/create_harness.py +1 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/events.py +148 -11
- pi_agent_cli_lc-0.5.0/pi_agent_cli/extension_notices.py +44 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/extension_trust.py +296 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/factory.py +90 -15
- pi_agent_cli_lc-0.5.0/pi_agent_cli/git_context.py +131 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/headless.py +34 -1
- pi_agent_cli_lc-0.5.0/pi_agent_cli/mcp_notice.py +50 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/permissions.py +106 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/prompt_options.py +32 -3
- pi_agent_cli_lc-0.5.0/pi_agent_cli/session_list.py +137 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/system_prompt.py +9 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/trust_fingerprint.py +250 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/trust_prompt.py +148 -0
- pi_agent_cli_lc-0.5.0/pi_agent_cli/trust_store.py +217 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pyproject.toml +3 -3
- pi_agent_cli_lc-0.5.0/tests/_print_agent.py +55 -0
- pi_agent_cli_lc-0.5.0/tests/_tool_agent.py +109 -0
- pi_agent_cli_lc-0.5.0/tests/test_acp_agent.py +1104 -0
- pi_agent_cli_lc-0.5.0/tests/test_acp_shutdown.py +143 -0
- pi_agent_cli_lc-0.5.0/tests/test_acp_stdio_contract.py +246 -0
- pi_agent_cli_lc-0.5.0/tests/test_acp_stdio_tools.py +304 -0
- pi_agent_cli_lc-0.5.0/tests/test_agent_info.py +49 -0
- pi_agent_cli_lc-0.5.0/tests/test_background_turns.py +252 -0
- pi_agent_cli_lc-0.5.0/tests/test_config.py +244 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_context_files.py +25 -0
- pi_agent_cli_lc-0.5.0/tests/test_extension_notices.py +222 -0
- pi_agent_cli_lc-0.5.0/tests/test_extension_trust.py +554 -0
- pi_agent_cli_lc-0.5.0/tests/test_extension_wiring.py +306 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_factory_skills.py +5 -1
- pi_agent_cli_lc-0.5.0/tests/test_git_context.py +366 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_headless.py +8 -0
- pi_agent_cli_lc-0.5.0/tests/test_headless_trust.py +246 -0
- pi_agent_cli_lc-0.5.0/tests/test_mcp_notice.py +215 -0
- pi_agent_cli_lc-0.5.0/tests/test_model_reasoning.py +53 -0
- pi_agent_cli_lc-0.5.0/tests/test_permission_annotations.py +335 -0
- pi_agent_cli_lc-0.5.0/tests/test_print_shutdown.py +244 -0
- pi_agent_cli_lc-0.5.0/tests/test_project_resource_trust.py +459 -0
- pi_agent_cli_lc-0.5.0/tests/test_project_trust_decision.py +579 -0
- pi_agent_cli_lc-0.5.0/tests/test_project_trust_prompt.py +993 -0
- pi_agent_cli_lc-0.5.0/tests/test_project_workflow_trust.py +432 -0
- pi_agent_cli_lc-0.5.0/tests/test_session_chosen_id.py +177 -0
- pi_agent_cli_lc-0.5.0/tests/test_session_lifecycle.py +119 -0
- pi_agent_cli_lc-0.5.0/tests/test_session_list.py +124 -0
- pi_agent_cli_lc-0.5.0/tests/test_session_list_paging.py +178 -0
- pi_agent_cli_lc-0.5.0/tests/test_trust_fingerprint.py +870 -0
- pi_agent_cli_lc-0.5.0/tests/test_trust_prompt.py +408 -0
- pi_agent_cli_lc-0.5.0/tests/test_trust_store.py +486 -0
- pi_agent_cli_lc-0.3.0/agent.example.toml +0 -29
- pi_agent_cli_lc-0.3.0/config.toml.example +0 -7
- pi_agent_cli_lc-0.3.0/pi_agent_cli/__main__.py +0 -151
- pi_agent_cli_lc-0.3.0/pi_agent_cli/agent.py +0 -383
- pi_agent_cli_lc-0.3.0/pi_agent_cli/config.py +0 -170
- pi_agent_cli_lc-0.3.0/pi_agent_cli/permissions.py +0 -52
- pi_agent_cli_lc-0.3.0/tests/test_acp_agent.py +0 -379
- pi_agent_cli_lc-0.3.0/tests/test_config.py +0 -117
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/README.md +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/local.env.example +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/__init__.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/__init__.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/collector.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/docker_runner.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/egress_policy.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/evaluator.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/golden_seed.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/models.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/pelican.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/pi_agent_cli/benchmarks/task_loader.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/snapshots/default_coding_tools_prompt.txt +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_benchmark_loader.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_evaluator_cache.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_golden_checkpoint.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_pelican_benchmark.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_pelican_real_llm.py +0 -0
- {pi_agent_cli_lc-0.3.0 → pi_agent_cli_lc-0.5.0}/tests/test_system_prompt.py +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# pi-agent-cli
|
|
2
|
+
|
|
3
|
+
Standard-ACP agent over `AgentHarness`. Two modes:
|
|
4
|
+
|
|
5
|
+
| Mode | Entry | Purpose |
|
|
6
|
+
|------|-------|---------|
|
|
7
|
+
| ACP stdio | `python -m pi_agent_cli` | TUI-spawned agent; `/new` `/resume` `/quit` map to ACP methods |
|
|
8
|
+
| Headless | `python -m pi_agent_cli -p "prompt"` | One-shot turn, prints assistant text, exits |
|
|
9
|
+
|
|
10
|
+
Headless flags: `--system-prompt`, `--append-system-prompt`, `--no-context-files`, `--no-git-context` — see `--help`. `--trust-project-extensions` works in both modes (see Project extensions).
|
|
11
|
+
|
|
12
|
+
## Config
|
|
13
|
+
|
|
14
|
+
Home: `~/.pi-python/` (override with `PI_HOME`). One resolver, `pi_agent_core.home.pi_home` (re-exported as `config.pi_home`), serves the CLI, the extension loader (`<home>/extensions`, exposed to extensions as `pi.home`) and the workflow extension (saved workflows, journals); `create_session_harness` hands the session's home to the harness so they cannot disagree. Two config files with distinct owners:
|
|
15
|
+
|
|
16
|
+
| File | Owner | Scope |
|
|
17
|
+
|------|-------|-------|
|
|
18
|
+
| `agent.toml` | Python agent | model, permission, skills, prompt overrides |
|
|
19
|
+
| `config.toml` | Rust TUI only | TUI settings (`[ui]`, theme, sandbox); a key the TUI knows, written with another type, stops it |
|
|
20
|
+
|
|
21
|
+
Keep the two files apart. The TUI is strict about the keys it knows and tolerant of the keys it does not know (measured with `zypi`; execution log section 10.10, item 3). A `config.toml` that is missing, has an unknown table or key, or has a Python `[model]` table starts normally. These stop `zypi` with exit code 1: a top-level `permission = "ask"` (the TUI expects a table there), a `[[models]]` array, and invalid TOML. A wrong type for `[agent].command` is ignored without a message. The agent reads `agent.toml`; only when that file does not exist does it fall back to `config.toml` (`load_config`, kept for old setups). The TUI reads `[agent].command` from `agent.toml` and, if that file does not set it, from `config.toml` (`acp/spawn.rs`); it reads no other agent key.
|
|
22
|
+
|
|
23
|
+
`local.env` in home loads `KEY=VALUE` pairs into env without overwriting (secrets stay out of TOML). See `agent.example.toml` for all keys.
|
|
24
|
+
|
|
25
|
+
## Model selection (`/model`)
|
|
26
|
+
|
|
27
|
+
Standard ACP **Session Config Options** — `session/set_model` is gone upstream (SDK ≥ 0.11). `new_session` / `load_session` / `resume_session` return `configOptions` with one select (`id="model"`, `category="model"`, `currentValue`, `options`); the client switches with `session/set_config_option` (`PiAcpAgent.set_config_option` → `harness.set_model`, response carries the full option set). Choices = `[model]` + optional `[[models]]` tables in `agent.toml` (`ModelChoice`, `CliConfig.model_choices()`). The model is persisted in the session (`model_change`) and restored on load/resume if still configured. Legacy `_meta` hints (`pi/currentModelId`, `pi/currentModelDisplayName`, `pi/provider`) are still emitted for older clients. Each choice carries its own `api_key_env`: `get_api_key` answers only for that choice's provider (`factory.api_key_for_choice`), and `Model.reasoning` follows `CliConfig.model_reasoning` whichever choice is active.
|
|
28
|
+
|
|
29
|
+
## Project trust
|
|
30
|
+
|
|
31
|
+
A project's own content is used only if the project is trusted: `<cwd>/.pi-python/extensions` (imported — i.e. executed — when a session opens), `<cwd>/.pi-python/workflows` (each `*.py` becomes a slash command with a description the repository wrote, and a command that has the model run the script), `<cwd>/.pi/SYSTEM.md`, `<cwd>/.pi/APPEND_SYSTEM.md`, and `[skills].paths` entries relative to the project (`.pi/skills`) — `trust_fingerprint.gated_project_resources` lists them. Never read the decision from a file inside the project, or a repository could trust itself. Not gated: entry-point packages, `~/.pi-python/extensions`, `<pi home>/workflows` (the user's own), `AGENTS.md` / `CLAUDE.md` (upstream loads them whatever the trust), prompts set in `agent.toml`, and absolute or `~` skills paths. The workflows are read by the dynamic-workflows extension, not by the CLI: it asks `pi.project_trusted` (the harness's `trust_project_extensions`, final by the time extensions activate), so an extension that reads anything under `<cwd>/.pi-python/` must do the same. Design: `docs/specs/2026-09-22-phase7-extension-api-design.md` §4.4.
|
|
32
|
+
|
|
33
|
+
`extension_trust.decide_project_trust(config, cwd, home=)` returns a `ProjectTrustDecision` (hashes files: call it off the event loop). Order: `PI_TRUST_PROJECT_EXTENSIONS` / `--trust-project-extensions` → `default_project_trust = "always"` (or the older `trust_project_extensions`; an explicit `default_project_trust` wins) → `[extensions] trusted_projects` in the **home** `agent.toml` (absolute paths; subdirectories count) → nothing gated (`nothing`) → files cannot be fingerprinted (`unpinnable`: no question, use `trusted_projects`) → an answer saved in `<home>/agent/trust.json` whose fingerprint still matches (`saved`) → `default_project_trust = "never"` → otherwise `undecided` / `changed` (a saved answer for other files), which an ACP client is asked about. `project_extensions_trusted` is the file-free subset (configuration and command line only).
|
|
34
|
+
|
|
35
|
+
- **Who asks.** Only the ACP agent (`agent.py`), and only when the decision `can_ask` and a client is connected. Headless never asks (upstream pi does not prompt in non-interactive modes either) but honours `saved`. The question (`trust_prompt.py`) is preceded by an ordinary agent message naming the directory, the files and what each does (the TUI shows a permission request's title and options only), then a `session/request_permission` with `Don't trust` (`reject_once`, first) and `Trust and remember` (`allow_always`). **No `allow_once` option, deliberately**: clients may answer tool permissions for the user (the TUI's YOLO mode takes the first `allow_once`), and this is not that decision. Only the exact trust option id, selected, is a yes (never reuse `permissions.outcome_allows`, which treats every id not starting with `reject` as yes); asking happens whatever the `permission` mode says.
|
|
36
|
+
- **When.** After the `session/new` (`load`, `resume`) response has gone out — clients drop requests for sessions they have not registered (`_deferred_session_setup`) — and extensions load only after the answer; `prompt()` waits for it (`session/cancel` or closing the session ends the wait; `_SessionTrust`). One question per project at a time (`_project_locks`); the later session decides afresh and normally finds the saved answer. The answer is checked against the disk again after the dialog (`modified` if the files moved), then `TrustStore.remember` (atomic, fail-closed; an unwritable store leaves the session trusted and says so). Refusals are not remembered. `ProjectTrust` is the session's live answer that `create_session_harness`, `load_system_prompt_options` and `load_session_resources` read, so a yes takes effect without rebuilding the session (`AgentHarness.set_trust_project_extensions`, valid until extensions load).
|
|
37
|
+
- **What was left out** is logged and reported to the user with the reason (`untrusted_project_notice(why=)`: not decided, changed since trusted, changed while asked, declined, could not ask, `never`, unpinnable) — ACP agent message after `session/new`, headless stderr.
|
|
38
|
+
- **Fingerprint** (`trust_fingerprint.fingerprint_resources`): SHA-256 over file contents of everything gated (recursive; `__pycache__` and `.git` skipped; symlinks followed with a loop guard; regular files only; at most 2000 entries and 64 MiB, else `None`) — contents, not mtimes. Stored per exact directory (`TrustStore`: `project_key` = `normcase(realpath)`), not per subtree.
|
|
39
|
+
|
|
40
|
+
## Tool permissions
|
|
41
|
+
|
|
42
|
+
`permission` in `agent.toml` is `ask` (default), `auto` or `always-approve`; only `ask` ever asks (headless forces `auto`). In `ask` mode a tool call goes to the client as `session/request_permission` **unless the tool declares itself harmless** (`permissions.needs_permission`): `annotations.readOnlyHint is True`, or `destructiveHint is False` and `openWorldHint is False`. Names and meanings are MCP's, with MCP's worst-case defaults — no annotations means asked — and the values must be real booleans. Tool names no longer matter (there is no allow-list).
|
|
43
|
+
|
|
44
|
+
- **Where annotations live.** `ToolAnnotations` (`pi_agent_core.types`) on `SimpleTool`, `CodingTool` and `ToolDefinition` (extensions); `from_langchain_tool` reads the four hints from `tool.metadata`. `AgentHarness.check_tool_call` looks the tool up **by name in the session's own tool table** and puts its annotations on `ToolCallEvent.annotations` — the session's loop and workflow sub-agents share that path, so a sub-agent's call is judged by the session's tool of the same name (a name the session lacks is asked about).
|
|
45
|
+
- **Our own tools.** `read` / `grep` / `find` / `ls`: read-only. `web_search` / `fetch_url`: read-only and open-world. `goal_update` / `goal_complete`: not read-only, not destructive, not open-world (they only keep notes in the session). `bash` / `edit` / `write` and `workflow` say nothing on purpose. A new tool that only reads, or only keeps the agent's own notes, should say so, or `ask` mode will interrupt the user for it.
|
|
46
|
+
- **Hints are the author's word**, never verified. There is no per-tool allow-list in `agent.toml`; to let an undeclared third-party tool through, give it annotations or use `auto`.
|
|
47
|
+
- **What `zypi` offers is what the agent honours: two modes.** The TUI tells the agent `ask` or `always-approve` (`pi/yolo_mode_changed`: Shift+Tab, `--always-approve`, `--permission-mode bypassPermissions`). It also used to offer Plan, Auto and a settings "Default", which the agent does not honour — there is no `session/set_mode` handler (Plan restricted nothing while the screen said `plan`), `auto` behaves like `always-approve` (the screen promised a classifier), and `_permission_mode_from_notification` drops `default`. They are hidden in the pager (`tui/crates/codegen/pi-pager/src/app/agent_modes.rs`: three constants, the code behind them kept) and `--permission-mode` takes only `default` and `bypassPermissions`. To give the agent a real Plan or Auto, implement it here and flip the constant there; the pager's old paths and tests come back with it (`docs/PLAN/archive/PLAN-RUST-AGENT-RUNTIME-REMOVAL-EXECUTION-LOG.md` section 10.13).
|
|
48
|
+
|
|
49
|
+
A prompt that arrives while a turn the client did not start (a `workflow` result delivered through `trigger_message`) is still running waits for that turn rather than failing `busy`; `session/cancel` and closing the session end the wait, and two prompts in flight from the client are still `busy` (`PiAcpAgent._run_prompt`). The ACP v2-draft `state_update` is deliberately not sent: the v1 SDK and the Rust crate under the TUI reject it. Design: `docs/specs/2026-09-22-phase7-extension-api-design.md` §12 and §13.
|
|
50
|
+
|
|
51
|
+
## TUI spawn
|
|
52
|
+
|
|
53
|
+
The Rust TUI (`zypi`) spawns the Python agent via (priority order):
|
|
54
|
+
|
|
55
|
+
1. `PI_AGENT_COMMAND` env var
|
|
56
|
+
2. `[agent].command` in `agent.toml`, then in `config.toml`
|
|
57
|
+
3. `PI_PYTHON` env var (appends `-m pi_agent_cli`)
|
|
58
|
+
|
|
59
|
+
**The agent's stderr.** The TUI owns the screen, and its own fd 2 is `/dev/null` (`pi_tty_utils::redirect_native_stderr`), so the agent's stderr goes to `<home>/logs/agent.stderr.log` instead (`acp/spawn.rs`, `agent_stderr`): appended across runs, one `--- agent started <time> (zypi pid N) ---` line per spawn, mode 0600 on Unix (a traceback can quote prompts, paths and keys), and a log longer than 1 MiB is moved to `agent.stderr.log.1` at the next spawn. A log that cannot be opened is skipped, not fatal. This is where a Python traceback or a warning from the agent ends up; the agent should still write diagnostics to stderr, not stdout (stdout is the ACP channel). `-p` runs are not redirected: there the agent's stderr is the user's terminal.
|
|
60
|
+
|
|
61
|
+
**When the agent exits on its own.** The stdio bridge (`acp/spawn.rs`) waits on the child next to the cancel token, so an agent that dies is noticed instead of leaving requests to hang. It drains what the agent had already written, builds one message (`agent_exit_message`: the program, its exit status, and the last 12 lines / 4 KiB of `agent.stderr.log`), fails every pending request, and ends the bridge with that message. `connect()` reports a failed `initialize` as that message (`explain_failed_start`): `failed to spawn ACP agent` plus the program and `PI_AGENT_COMMAND` for a program that is not there, `exited on its own (exit status: 1)` plus the traceback's tail for a Python that lacks `pi_agent_cli`. Once the TUI is up, `AgentProcessGuard` prints `Error: …` on the restored terminal. So an agent must not exit on an error it can handle, and anything it prints to stderr before it dies reaches the user. `zypi`'s own exit status is unchanged. `tui/crates/codegen/pi-pager/tests/python_agent_e2e.rs` runs the pager's ACP client against this agent, including these failures (needs a Python with `pi_agent_cli`: `PI_E2E_PYTHON`; `PI_E2E_REQUIRED=1` makes its absence a failure, as in `tui-ci.yml`).
|
|
62
|
+
|
|
63
|
+
## MCP servers
|
|
64
|
+
|
|
65
|
+
The agent connects to **no** MCP server (Phase 4 non-goal; tools come from the harness and its extensions). `session/new`, `load` and `resume` accept `mcp_servers` and ignore them, which departs from ACP, whose session setup says an agent MUST support stdio servers; `initialize` advertises no `mcpCapabilities` (no `http`, no `sse`). The departure is made visible instead of silent: each time a session is set up with a non-empty list, the agent logs a warning and sends one agent message naming the servers (`mcp_notice`, from `_deferred_session_setup`, after the response has flushed like the other notices). Only the **names** are used: `env`, `headers`, `args` and `url` routinely hold tokens. `zypi` itself sends an empty list (it used to forward whatever it found in `.mcp.json`, `~/.claude.json` and Cursor's `mcp.json`), so there the notice never appears; it is for other ACP clients (an editor that passes its own servers). `tests/test_mcp_notice.py`. To implement MCP, start from the notice: it is the one place that sees the list.
|
|
66
|
+
|
|
67
|
+
## Listing sessions
|
|
68
|
+
|
|
69
|
+
`session/list` returns `SESSION_LIST_PAGE_SIZE` (50) sessions at a time, newest first, with `nextCursor` while more follow; the TUI keeps asking until it is gone. The order is the harness's (`JsonlSessionRepo.list_page`): creation time, which the session file names start with, so a page reads a file's header only when it gets that far and costs the files it looks at, not the directory (a `cwd` filter makes it skip other projects' files). `nextCursor` is opaque to the client and carries the file name the next page continues below; the agent keeps nothing between requests, so a cursor survives a restart, a session created meanwhile is simply newer than the cursor, and one deleted meanwhile moves nothing. A page that carries a cursor is never followed by an empty one (the agent looks one session ahead). A string the agent did not issue is `invalid_params`. `updated_at` is still the file's mtime, so it need not be in the order of the list.
|
|
70
|
+
|
|
71
|
+
Looking a session up by id (`load` / `resume` / `pi/session/delete`) is `JsonlSessionRepo.find`: the file name ends in `-<id>.jsonl`, so only that file's header is read. `scripts/session_list_bench.py` measures all of this against the real agent (`start`, first page, every page, `session/load`) on a synthetic home; numbers and the decisions that rest on them are in `docs/PLAN/archive/PLAN-RUST-AGENT-RUNTIME-REMOVAL-EXECUTION-LOG.md` §10.11.
|
|
72
|
+
|
|
73
|
+
## Two things the TUI and the agent agree on beyond ACP v1
|
|
74
|
+
|
|
75
|
+
- **`_meta.sessionId` on `session/new`** is the TUI's `--session-id`: ACP v1 has no field for the id of a new session. The agent (`_chosen_session_id`, `_create_session_with_id`) takes it as the session's id. It must be a UUID in any form `uuid.UUID` reads, and comes back canonical (lower case, hyphens); anything else is `invalid_params`, because the id ends up in a file name. An id already in use is `invalid_params` too, whatever directory the session that has it belongs to: the repository would not refuse it (file names start with the creation time, so a second file with the same id fits beside the first), and a later `session/load` would find either. Requests for the same id at once make one session (`_chosen_id_lock`); two agent processes on one home are not covered. The question is `JsonlSessionRepo.has_session`, which opens a file only when its name does not already say it belongs to another session (`find` would read every header for an id nobody has); the one thing it takes on trust is a file renamed by hand to carry another id. What a free id still costs is the directory listing, about 0.07 s at 1,000 sessions and 0.3 s at 5,000 on both Windows and WSL2, against 8–20 ms for a session with an id of the agent's own (`scripts/session_list_bench.py`, the last block of its output). Nearly all of that is `LocalExecutionEnv.list_dir` building a `FileInfo` per entry (`os.listdir` of the same 5,000 files: 2–11 ms), which `session/list` pays on every page too. `tests/test_session_chosen_id.py`.
|
|
76
|
+
- **`_meta["pi/cwd"]` in the response that opens a session** (`new`, `load`, `resume`) is the directory the session works in. `load` and `resume` take a `cwd` as well, and the one saved with the session wins; a client that only knows where it was started (`zypi --resume <id>` from another directory) learns the real one here. Nothing in the TUI reads it yet.
|
|
77
|
+
|
|
78
|
+
## ACP contract tests
|
|
79
|
+
|
|
80
|
+
`tests/test_acp_stdio_contract.py` drives an agent *process* over stdio the way the TUI does: `initialize`, `session/new`, streamed `prompt`, the `model` config option, `session/list` (first-prompt title), `session/load` replay and `session/resume` across a restart, `session/close`, a client-chosen id on `session/new` (and a taken one refused), the directory `session/load` reports, unknown methods answered with an error, exit code 0 on stdin EOF. It uses the mock LLM (`PI_USE_MOCK=1`) and a throw-away `PI_HOME`. Set `PI_ACP_CONTRACT_COMMAND` to run the same suite against another stdio ACP agent (it must honour `PI_HOME` and answer prompts with `Hello from mock`, without network), e.g. a future `pi-rust`.
|
|
81
|
+
|
|
82
|
+
The mock LLM never calls a tool, so the tool side of the contract has its own file: `tests/test_acp_stdio_tools.py` drives `tests/_tool_agent.py` (the real `serve()` with a scripted LLM turn that calls one tool; `PI_TEST_TOOL_CALL` picks the call, `PI_TEST_PERMISSION` the mode) and checks, over the wire, what the pager relies on: in `ask` mode a `write` call is announced as a `tool_call` update, then put as `session/request_permission` (title, kind, `raw_input`, the two options `allow-once` / `reject-once`) before the tool runs; `allow-once` runs it, `reject-once` and a `cancelled` outcome do not and the turn still ends; `auto` never asks; `session/cancel` ends a prompt as `cancelled` (while a question is open, and while a `bash` call runs, which is then reaped; POSIX only), and is harmless with nothing running or for an unknown session. The scripted stream ends `aborted` once the signal is set, as the LangChain adapter does, so these tests do not depend on a real provider's abort handling.
|
|
83
|
+
|
|
84
|
+
`__main__.serve()` starts `run_agent(..., use_unstable_protocol=True)`: `initialize` advertises `session/close` and `session/resume`, which the SDK serves only with that flag.
|
|
85
|
+
|
|
86
|
+
## Stopping the agent
|
|
87
|
+
|
|
88
|
+
The agent must reap the tools it is running when its client goes away: the TUI closes the agent's stdin and kills it only after a grace period (`acp/spawn.rs`, `AGENT_EOF_GRACE`). `serve()` ends on EOF on stdin (`run_agent` returns, `asyncio.run` cancels the turns in flight, each `bash` tool kills its process group) and on SIGTERM / SIGHUP (the main task is cancelled, same path); all exit 0. SIGHUP matters because a terminal that goes away, or a TUI killed while it is the session leader, sends it to the foreground process group, which includes the agent; its default action kills the agent before it can reap anything. A repeated signal gets the default action (the handlers remove themselves on the first one), so an agent that hangs while it stops — a thread that cannot be cancelled keeps `asyncio.run` from returning — can still be killed. SIGKILL cannot be handled and orphans the tools. `tests/test_acp_shutdown.py` runs `tests/_tool_agent.py` (the real `serve()` with a scripted `bash` call, or a stuck thread) and checks EOF, SIGTERM, SIGHUP and the repeated signal; POSIX only.
|
|
89
|
+
|
|
90
|
+
**`-p` runs** have no stdin to close: `zypi -p` starts the agent as a child on the user's own terminal. They get the same stop signals (`_print_main`: the turn is cancelled, the tools are reaped, the exit status is 128 + the signal number), and zypi names itself in `PI_AGENT_PARENT_PID`, which the agent turns into a watch: every 0.5 s it checks `os.getppid()` and stops the same way once that pid is no longer its parent. That is how a zypi killed alone (`kill -9`, `kill <pid>`, an IDE's stop button) is noticed; a terminal hang-up or Ctrl+C reaches both anyway. The variable counts only if it names the agent's actual parent, or a process that is already gone (the parent died while the agent was starting); a live process that is not the parent is a wrapper script in between, and the watch stays off. `main()` removes the variable from the environment first thing, so a tool or a nested `-p` cannot inherit it. POSIX only: on Windows `getppid()` keeps naming the dead parent. `tests/test_print_shutdown.py` runs `tests/_print_agent.py` (the real `main()` with a scripted `bash` call) and checks both signals, a killed parent, an unrelated live pid, a parent that is already gone, and which values count. End to end, against a built `zypi` (Linux), `scripts/tui_pty/exit_matrix.py` (TUI: six ways out, two launch styles, with or without `--sandbox`) and `scripts/tui_pty/print_exit.py` (`zypi -p`, only zypi signalled) check that nothing is left running; see the README there.
|
|
91
|
+
|
|
92
|
+
## System prompt pipeline
|
|
93
|
+
|
|
94
|
+
`build_system_prompt` (`system_prompt.py`) assembles:
|
|
95
|
+
|
|
96
|
+
1. Base prompt with tool snippets and `prompt_guidelines`
|
|
97
|
+
2. `append_system_prompt` (config or CLI flag)
|
|
98
|
+
3. Context files — `context_files.py` walks cwd → repo root collecting `AGENTS.md`, `CLAUDE.md`; a custom `.pi/SYSTEM.md` / `.pi/APPEND_SYSTEM.md` replaces or extends the base prompt only for a trusted project (see Project trust)
|
|
99
|
+
4. Git status — `<git_status>` from `git_context.py` when cwd is a repo (`[git]` / `--no-git-context`); the prompt inputs (files, git subprocesses) are built in a worker thread, and `timeout_seconds` is one budget for the whole snapshot
|
|
100
|
+
5. Skills from `[skills].paths`, formatted as `<available_skills>`
|
|
101
|
+
|
|
102
|
+
`build_coding_agent_harness_system_prompt` (`create_harness.py`) wraps this for harness use.
|
|
103
|
+
|
|
104
|
+
## Modules
|
|
105
|
+
|
|
106
|
+
| Module | Role |
|
|
107
|
+
|--------|------|
|
|
108
|
+
| `agent.py` | `PiAcpAgent` — ACP methods, permission handling, event projection |
|
|
109
|
+
| `config.py` | TOML loading, `pi_home()`, `CliConfig` dataclass |
|
|
110
|
+
| `factory.py` | `create_session_harness` — wires tools, model, stream_fn, skills into harness |
|
|
111
|
+
| `extension_trust.py` | `decide_project_trust` / `ProjectTrust`: whether a project may load its own extensions, saved workflows, prompt files and skills; the "skipped" and "could not be saved" notices |
|
|
112
|
+
| `trust_fingerprint.py` | The gated resources of a project and a content fingerprint of them |
|
|
113
|
+
| `trust_store.py` | `<home>/agent/trust.json`: answers saved per directory with the fingerprint they were about |
|
|
114
|
+
| `trust_prompt.py` | The `session/request_permission` question (shape, option ids, how an answer is read) and its explanation message |
|
|
115
|
+
| `extension_notices.py` | The "these extensions failed to load" notice (`AgentHarness.failed_extensions`): ACP agent message after `session/new`, headless stderr |
|
|
116
|
+
| `mcp_notice.py` | The "these MCP servers were ignored" notice: names only, never `env` / `headers` / `args` / `url` |
|
|
117
|
+
| `system_prompt.py` | `build_system_prompt`, tool snippet/guideline consumption |
|
|
118
|
+
| `context_files.py` | AGENTS.md / CLAUDE.md / .pi/SYSTEM.md discovery |
|
|
119
|
+
| `git_context.py` | Read-only branch and `git status` snapshot for the system prompt |
|
|
120
|
+
| `headless.py` | `-p` one-shot mode, prompt override application |
|
|
121
|
+
| `events.py` | Internal events → ACP `session_update` projection |
|
|
122
|
+
| `permissions.py` | Tool permission gating (ask / auto / always-approve); in `ask` mode a tool is asked about unless its annotations say it is harmless (see Tool permissions) |
|
|
123
|
+
| `session_list.py` | `session/list` display data: first-user-message title and file-mtime `updated_at` (bounded read), `SESSION_LIST_PAGE_SIZE`, the opaque `nextCursor` (`encode_cursor` / `decode_cursor`) |
|
|
124
|
+
| `create_harness.py` | Harness-level system prompt, coding tool wiring |
|
|
125
|
+
| `benchmarks/` | Pelican benchmark suite, evaluator, Docker runner |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pi-agent-cli-lc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Standard ACP agent over AgentHarness (no x.ai extensions)
|
|
5
5
|
Project-URL: Homepage, https://github.com/zy1233/pi-python
|
|
6
6
|
Project-URL: Repository, https://github.com/zy1233/pi-python
|
|
@@ -20,8 +20,8 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
|
20
20
|
Classifier: Typing :: Typed
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Requires-Dist: agent-client-protocol>=0.12.0
|
|
23
|
-
Requires-Dist: pi-agent-core-lc==0.
|
|
24
|
-
Requires-Dist: pi-agent-harness-lc==0.
|
|
23
|
+
Requires-Dist: pi-agent-core-lc==0.5.0
|
|
24
|
+
Requires-Dist: pi-agent-harness-lc==0.5.0
|
|
25
25
|
Requires-Dist: pydantic>=2.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Copy to ~/.pi-python/agent.toml (or set PI_HOME).
|
|
2
|
+
# Python ACP agent settings ONLY — do not duplicate these keys in config.toml
|
|
3
|
+
# (the Rust TUI reads config.toml with its own typed schema: a key it knows, such as a top-level
|
|
4
|
+
# `permission = "ask"`, stops it at start).
|
|
5
|
+
# LLM keys are read from environment variables — never put secrets in this file.
|
|
6
|
+
|
|
7
|
+
permission = "ask" # ask | auto | always-approve
|
|
8
|
+
# `ask` asks before each tool call, except for tools that declare themselves harmless: read-only
|
|
9
|
+
# or only keeping the agent's own notes (read, grep, find, ls, web_search, fetch_url, goal_*).
|
|
10
|
+
# A tool that declares nothing is asked about; bash, edit, write and workflow always are.
|
|
11
|
+
|
|
12
|
+
thinking_level = "off" # off | minimal | low | medium | high | xhigh
|
|
13
|
+
max_turns = 50
|
|
14
|
+
|
|
15
|
+
[model]
|
|
16
|
+
provider = "deepseek"
|
|
17
|
+
id = "deepseek-ai/DeepSeek-V4-Flash"
|
|
18
|
+
base_url = "https://api.siliconflow.cn/v1"
|
|
19
|
+
api_key_env = "REAL_LLM_API_KEY" # key for `provider` only; other providers use their own (e.g. ANTHROPIC_API_KEY)
|
|
20
|
+
# Can the model reason? A thinking_level other than "off" is sent to the provider only if so.
|
|
21
|
+
# Unset: any level but "off" says it can. `false` keeps a model that rejects reasoning parameters out of them.
|
|
22
|
+
# reasoning = true
|
|
23
|
+
|
|
24
|
+
# Extra models for the TUI `/model` picker (ACP session config option "model").
|
|
25
|
+
# `[model]` above is always offered first; unset fields inherit from it, but
|
|
26
|
+
# base_url / api_key_env / supports_images only when `provider` is unset or the same.
|
|
27
|
+
# [[models]]
|
|
28
|
+
# id = "deepseek-ai/DeepSeek-V4-Pro"
|
|
29
|
+
# name = "DeepSeek V4 Pro"
|
|
30
|
+
#
|
|
31
|
+
# [[models]]
|
|
32
|
+
# provider = "openai"
|
|
33
|
+
# id = "gpt-4.1"
|
|
34
|
+
# base_url = "https://api.openai.com/v1"
|
|
35
|
+
# api_key_env = "OPENAI_API_KEY"
|
|
36
|
+
|
|
37
|
+
[skills]
|
|
38
|
+
# ".pi/skills" is relative to the project, so it loads only for a trusted project (see [extensions]).
|
|
39
|
+
paths = ["~/.pi-python/skills", ".pi/skills"]
|
|
40
|
+
|
|
41
|
+
[agent]
|
|
42
|
+
# Used by the Rust TUI when PI_AGENT_COMMAND is unset (Windows: prefer a venv python.exe).
|
|
43
|
+
command = "python -m pi_agent_cli"
|
|
44
|
+
|
|
45
|
+
[prompt]
|
|
46
|
+
# no_context_files = false
|
|
47
|
+
# custom_system_prompt = "You are a helpful assistant."
|
|
48
|
+
# custom_system_prompt_file = "~/.pi-python/agent/SYSTEM.md"
|
|
49
|
+
# append_system_prompt = "Always run tests after edits."
|
|
50
|
+
# append_system_prompt_file = "~/.pi-python/agent/APPEND_SYSTEM.md"
|
|
51
|
+
|
|
52
|
+
[git]
|
|
53
|
+
# A `git status` snapshot (branch name and the paths of changed and untracked files) is added to
|
|
54
|
+
# the system prompt at the start of a session, so it is sent to the model provider with every
|
|
55
|
+
# request. Set enabled = false if such names are sensitive.
|
|
56
|
+
# enabled = true
|
|
57
|
+
# timeout_seconds = 2 # one budget for the whole snapshot (up to four git commands), not per command
|
|
58
|
+
# max_status_lines = 40
|
|
59
|
+
|
|
60
|
+
[extensions]
|
|
61
|
+
# Project trust. Extensions in <project>/.pi-python/extensions run arbitrary Python as soon as a
|
|
62
|
+
# session opens, saved workflows in <project>/.pi-python/workflows become slash commands that
|
|
63
|
+
# have the model run their scripts, and the project's .pi/SYSTEM.md, .pi/APPEND_SYSTEM.md and
|
|
64
|
+
# project-relative [skills] paths decide what the model is told, so none of them is used until
|
|
65
|
+
# the project is trusted. Only this file, the command line and your own answers decide that
|
|
66
|
+
# (never a config inside the project). Installed extension packages, ~/.pi-python/extensions,
|
|
67
|
+
# your own saved workflows (~/.pi-python/workflows), AGENTS.md and absolute or ~ skills paths
|
|
68
|
+
# are not affected.
|
|
69
|
+
#
|
|
70
|
+
# An ACP client (the TUI) asks about a project that ships such files, once per directory.
|
|
71
|
+
# "Trust and remember" is saved in ~/.pi-python/agent/trust.json with a fingerprint of those
|
|
72
|
+
# files, so you are asked again when one of them changes (after a git pull, say). Delete the
|
|
73
|
+
# project's entry from that file to take the trust back. Headless (-p) runs never ask, but
|
|
74
|
+
# honour an answer given earlier for the same files.
|
|
75
|
+
# default_project_trust = "ask" # "ask" (default): ask in an ACP client
|
|
76
|
+
# # "never": do not ask; only the entries and answers below apply
|
|
77
|
+
# # "always": trust every project without asking
|
|
78
|
+
# trusted_projects = ["~/work/my-repo"] # absolute or ~ paths, never asked about; subdirectories are
|
|
79
|
+
# # trusted too (Windows: use forward slashes, "C:/work/my-repo")
|
|
80
|
+
# trust_project_extensions = false # older switch: true means default_project_trust = "always"
|
|
81
|
+
# # (an explicit default_project_trust wins)
|
|
82
|
+
# One-off alternatives: --trust-project-extensions, or PI_TRUST_PROJECT_EXTENSIONS=1
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Rust TUI config for ~/.pi-python/config.toml
|
|
2
|
+
#
|
|
3
|
+
# Leave this file empty, or write only keys that the TUI knows.
|
|
4
|
+
# Python agent settings belong in agent.toml — NOT here.
|
|
5
|
+
#
|
|
6
|
+
# Top-level ``permission = "ask"`` will break TUI startup (the TUI expects a
|
|
7
|
+
# ``[permission]`` table, not a string). A ``[[models]]`` array and invalid TOML
|
|
8
|
+
# stop it too. Keys that the TUI does not know are ignored.
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""stdio entry: python -m pi_agent_cli
|
|
2
|
+
|
|
3
|
+
Default: ACP agent on stdio.
|
|
4
|
+
`python -m pi_agent_cli -p "..."`: one-shot headless turn (no TUI).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from acp import run_agent
|
|
20
|
+
|
|
21
|
+
from pi_agent_cli.agent import PiAcpAgent
|
|
22
|
+
from pi_agent_cli.config import load_local_env
|
|
23
|
+
from pi_agent_cli.extension_trust import TRUST_ENV
|
|
24
|
+
from pi_agent_cli.headless import HeadlessPromptOverrides, resolve_print_prompt, run_print
|
|
25
|
+
|
|
26
|
+
_PROMPT_CLI_FLAG_NAMES = (
|
|
27
|
+
"system_prompt",
|
|
28
|
+
"system_prompt_file",
|
|
29
|
+
"append_system_prompt",
|
|
30
|
+
"append_system_prompt_file",
|
|
31
|
+
"no_context_files",
|
|
32
|
+
"no_git_context",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# SIGHUP is what a closing terminal sends to its foreground process group, which includes the
|
|
37
|
+
# agent when the TUI is started from a shell. Windows has neither signal handlers nor SIGHUP.
|
|
38
|
+
_STOP_SIGNALS = tuple(
|
|
39
|
+
getattr(signal, name) for name in ("SIGTERM", "SIGHUP") if hasattr(signal, name)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# `zypi -p` gives this process the user's own terminal as stdin, so the way the TUI's agent learns
|
|
43
|
+
# that its client is gone (EOF on stdin) does not exist there. zypi names itself in this variable
|
|
44
|
+
# instead, and the run stops once that process is no longer the parent: the kernel re-parents a
|
|
45
|
+
# child whatever killed its parent, SIGKILL included.
|
|
46
|
+
PARENT_PID_ENV = "PI_AGENT_PARENT_PID"
|
|
47
|
+
PARENT_POLL_SECONDS = 0.5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _StopRequest:
|
|
51
|
+
"""Ends the main task once, and remembers what asked for it."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, task: asyncio.Task[Any]) -> None:
|
|
54
|
+
self._task = task
|
|
55
|
+
self.signum: int | None = None
|
|
56
|
+
|
|
57
|
+
def install_signal_handlers(self) -> None:
|
|
58
|
+
"""Stop on SIGTERM / SIGHUP the way EOF on stdin stops the stdio agent.
|
|
59
|
+
|
|
60
|
+
The main task is cancelled, so `asyncio.run` cancels the in-flight turns and each running
|
|
61
|
+
tool's process group is reaped. Without a handler the signal ends the process at once and
|
|
62
|
+
its tools outlive it. (SIGKILL cannot be handled; there the tools are orphaned.)
|
|
63
|
+
"""
|
|
64
|
+
loop = asyncio.get_running_loop()
|
|
65
|
+
for stop_signal in _STOP_SIGNALS:
|
|
66
|
+
with contextlib.suppress(NotImplementedError, RuntimeError):
|
|
67
|
+
loop.add_signal_handler(stop_signal, self.request, int(stop_signal))
|
|
68
|
+
|
|
69
|
+
def request(self, signum: int) -> None:
|
|
70
|
+
if self.signum is not None:
|
|
71
|
+
return
|
|
72
|
+
self.signum = signum
|
|
73
|
+
# A repeated signal gets the default action, so a process that hangs while it shuts down
|
|
74
|
+
# (a thread that cannot be cancelled) can still be stopped.
|
|
75
|
+
loop = asyncio.get_running_loop()
|
|
76
|
+
for stop_signal in _STOP_SIGNALS:
|
|
77
|
+
with contextlib.suppress(NotImplementedError, RuntimeError):
|
|
78
|
+
loop.remove_signal_handler(stop_signal)
|
|
79
|
+
self._task.cancel()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def serve(agent: PiAcpAgent) -> None:
|
|
83
|
+
"""Serve ``agent`` over stdio until stdin closes or the process is told to stop."""
|
|
84
|
+
main_task = asyncio.current_task()
|
|
85
|
+
if main_task is not None:
|
|
86
|
+
_StopRequest(main_task).install_signal_handlers()
|
|
87
|
+
# `session/close` and `session/resume` are unstable in the SDK and answered with
|
|
88
|
+
# "Method not found" unless this flag is set, although `initialize` advertises both.
|
|
89
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
90
|
+
await run_agent(agent, use_unstable_protocol=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _process_exists(pid: int) -> bool:
|
|
94
|
+
try:
|
|
95
|
+
os.kill(pid, 0)
|
|
96
|
+
except ProcessLookupError:
|
|
97
|
+
return False
|
|
98
|
+
except PermissionError:
|
|
99
|
+
return True # it exists, it is just not ours
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _take_parent_pid() -> int | None:
|
|
104
|
+
"""The pid in ``PI_AGENT_PARENT_PID`` if this run should stop when that process goes away.
|
|
105
|
+
|
|
106
|
+
The variable is removed either way, so nothing this process starts (a tool, a nested
|
|
107
|
+
``pi -p``) inherits a parent that is not its own. It counts when it names this process's
|
|
108
|
+
parent, or a process that no longer exists (the parent died while this one was starting; the
|
|
109
|
+
first check then stops the run). A live process that is not the parent is a wrapper in
|
|
110
|
+
between, and what this process should wait on is not its pid.
|
|
111
|
+
"""
|
|
112
|
+
raw = os.environ.pop(PARENT_PID_ENV, None)
|
|
113
|
+
if os.name != "posix" or raw is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
pid = int(raw)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
if not 0 < pid < 2**31:
|
|
120
|
+
return None
|
|
121
|
+
if pid == os.getppid() or not _process_exists(pid):
|
|
122
|
+
return pid
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _watch_parent(parent_pid: int, stop: _StopRequest) -> None:
|
|
127
|
+
"""Ask for a stop once ``parent_pid`` is no longer this process's parent."""
|
|
128
|
+
while os.getppid() == parent_pid:
|
|
129
|
+
await asyncio.sleep(PARENT_POLL_SECONDS)
|
|
130
|
+
stop.request(signal.SIGTERM)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _print_main(
|
|
134
|
+
prompt: str,
|
|
135
|
+
*,
|
|
136
|
+
cwd: Path | None,
|
|
137
|
+
prompt_overrides: HeadlessPromptOverrides,
|
|
138
|
+
parent_pid: int | None,
|
|
139
|
+
) -> int:
|
|
140
|
+
"""One ``-p`` turn, ended early by a stop signal or by the death of the process that started it.
|
|
141
|
+
|
|
142
|
+
A stop cancels the turn the way it does for the stdio agent, so the process group of every
|
|
143
|
+
running tool is reaped; the exit status is 128 plus the signal number, as a shell reports a
|
|
144
|
+
killed process.
|
|
145
|
+
"""
|
|
146
|
+
task = asyncio.current_task()
|
|
147
|
+
if task is None:
|
|
148
|
+
return await run_print(prompt, cwd=cwd, prompt_overrides=prompt_overrides)
|
|
149
|
+
stop = _StopRequest(task)
|
|
150
|
+
stop.install_signal_handlers()
|
|
151
|
+
watcher = (
|
|
152
|
+
asyncio.create_task(_watch_parent(parent_pid, stop)) if parent_pid is not None else None
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
return await run_print(prompt, cwd=cwd, prompt_overrides=prompt_overrides)
|
|
156
|
+
except asyncio.CancelledError:
|
|
157
|
+
if stop.signum is None:
|
|
158
|
+
raise
|
|
159
|
+
return 128 + stop.signum
|
|
160
|
+
finally:
|
|
161
|
+
if watcher is not None:
|
|
162
|
+
watcher.cancel()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _amain() -> None:
|
|
166
|
+
await serve(PiAcpAgent())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
170
|
+
parser = argparse.ArgumentParser(
|
|
171
|
+
prog="pi-agent-cli",
|
|
172
|
+
description="Standard ACP agent over AgentHarness (stdio), or one-shot -p print.",
|
|
173
|
+
)
|
|
174
|
+
src = parser.add_mutually_exclusive_group()
|
|
175
|
+
src.add_argument(
|
|
176
|
+
"-p",
|
|
177
|
+
"--print",
|
|
178
|
+
dest="print_prompt",
|
|
179
|
+
metavar="PROMPT",
|
|
180
|
+
help="Run one prompt, print the assistant text, and exit (no TUI, no ACP stdio).",
|
|
181
|
+
)
|
|
182
|
+
src.add_argument(
|
|
183
|
+
"--prompt-json",
|
|
184
|
+
metavar="JSON",
|
|
185
|
+
help="Single-turn prompt as a JSON string or list of content blocks.",
|
|
186
|
+
)
|
|
187
|
+
src.add_argument(
|
|
188
|
+
"--prompt-file",
|
|
189
|
+
metavar="PATH",
|
|
190
|
+
type=Path,
|
|
191
|
+
help="Read the single-turn prompt from a file.",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--cwd",
|
|
195
|
+
metavar="PATH",
|
|
196
|
+
type=Path,
|
|
197
|
+
help="Working directory for the headless session (default: process cwd).",
|
|
198
|
+
)
|
|
199
|
+
system = parser.add_mutually_exclusive_group()
|
|
200
|
+
system.add_argument(
|
|
201
|
+
"--system-prompt",
|
|
202
|
+
"--system-prompt-override",
|
|
203
|
+
dest="system_prompt",
|
|
204
|
+
metavar="TEXT",
|
|
205
|
+
help="Replace the default system prompt (headless only).",
|
|
206
|
+
)
|
|
207
|
+
system.add_argument(
|
|
208
|
+
"--system-prompt-file",
|
|
209
|
+
metavar="PATH",
|
|
210
|
+
type=Path,
|
|
211
|
+
help="Read the system prompt override from a file (headless only).",
|
|
212
|
+
)
|
|
213
|
+
append = parser.add_mutually_exclusive_group()
|
|
214
|
+
append.add_argument(
|
|
215
|
+
"--append-system-prompt",
|
|
216
|
+
"--rules",
|
|
217
|
+
dest="append_system_prompt",
|
|
218
|
+
metavar="TEXT",
|
|
219
|
+
help="Append text to the system prompt (headless only).",
|
|
220
|
+
)
|
|
221
|
+
append.add_argument(
|
|
222
|
+
"--append-system-prompt-file",
|
|
223
|
+
metavar="PATH",
|
|
224
|
+
type=Path,
|
|
225
|
+
help="Read append text from a file (headless only).",
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--no-context-files",
|
|
229
|
+
action="store_true",
|
|
230
|
+
help="Skip AGENTS.md / CLAUDE.md discovery (headless only).",
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument(
|
|
233
|
+
"--no-git-context",
|
|
234
|
+
action="store_true",
|
|
235
|
+
help="Omit the <git_status> section from the system prompt (headless only).",
|
|
236
|
+
)
|
|
237
|
+
parser.add_argument(
|
|
238
|
+
"--trust-project-extensions",
|
|
239
|
+
action="store_true",
|
|
240
|
+
help=(
|
|
241
|
+
"Load extensions from <project>/.pi-python/extensions, which run arbitrary "
|
|
242
|
+
f"Python (same as {TRUST_ENV}=1). Without it they are skipped unless the "
|
|
243
|
+
"project is listed under [extensions] trusted_projects in agent.toml."
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
return parser
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _apply_trust_flag(args: argparse.Namespace) -> None:
|
|
250
|
+
"""``--trust-project-extensions`` is ``PI_TRUST_PROJECT_EXTENSIONS=1`` for this process."""
|
|
251
|
+
if args.trust_project_extensions:
|
|
252
|
+
os.environ[TRUST_ENV] = "1"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _prompt_overrides_from_args(args: argparse.Namespace) -> HeadlessPromptOverrides:
|
|
256
|
+
return HeadlessPromptOverrides(
|
|
257
|
+
system_prompt=args.system_prompt,
|
|
258
|
+
system_prompt_file=args.system_prompt_file,
|
|
259
|
+
append_system_prompt=args.append_system_prompt,
|
|
260
|
+
append_system_prompt_file=args.append_system_prompt_file,
|
|
261
|
+
no_context_files=True if args.no_context_files else None,
|
|
262
|
+
no_git_context=True if args.no_git_context else None,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _has_prompt_cli_flags(args: argparse.Namespace) -> bool:
|
|
267
|
+
return any(getattr(args, name) for name in _PROMPT_CLI_FLAG_NAMES)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
parent_pid = _take_parent_pid() # first: nothing started from here may inherit the variable
|
|
272
|
+
load_local_env()
|
|
273
|
+
parser = _build_parser()
|
|
274
|
+
args = parser.parse_args()
|
|
275
|
+
_apply_trust_flag(args)
|
|
276
|
+
headless = any(
|
|
277
|
+
value is not None for value in (args.print_prompt, args.prompt_json, args.prompt_file)
|
|
278
|
+
)
|
|
279
|
+
if not headless and _has_prompt_cli_flags(args):
|
|
280
|
+
print(
|
|
281
|
+
"error: --system-prompt, --append-system-prompt, --no-context-files, "
|
|
282
|
+
"and --no-git-context require headless mode "
|
|
283
|
+
"(-p, --prompt-json, or --prompt-file)",
|
|
284
|
+
file=sys.stderr,
|
|
285
|
+
)
|
|
286
|
+
raise SystemExit(2)
|
|
287
|
+
if headless:
|
|
288
|
+
try:
|
|
289
|
+
prompt = resolve_print_prompt(
|
|
290
|
+
print_prompt=args.print_prompt,
|
|
291
|
+
prompt_json=args.prompt_json,
|
|
292
|
+
prompt_file=args.prompt_file,
|
|
293
|
+
)
|
|
294
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
295
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
296
|
+
raise SystemExit(2) from exc
|
|
297
|
+
raise SystemExit(
|
|
298
|
+
asyncio.run(
|
|
299
|
+
_print_main(
|
|
300
|
+
prompt,
|
|
301
|
+
cwd=args.cwd,
|
|
302
|
+
prompt_overrides=_prompt_overrides_from_args(args),
|
|
303
|
+
parent_pid=parent_pid,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
asyncio.run(_amain())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
main()
|