pi-agent-cli-lc 0.2.0__tar.gz → 0.4.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.4.0/.gitignore +34 -0
- pi_agent_cli_lc-0.4.0/AGENTS.md +57 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/PKG-INFO +4 -3
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/README.md +1 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/agent.example.toml +12 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/__main__.py +159 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/agent.py +184 -9
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/__init__.py +56 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/collector.py +130 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/docker_runner.py +474 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/egress_policy.py +129 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/evaluator.py +582 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/golden_seed.py +204 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/models.py +188 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/task_loader.py +123 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/config.py +59 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/context_files.py +111 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/create_harness.py +65 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/events.py +398 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/factory.py +166 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/git_context.py +85 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/headless.py +47 -3
- pi_agent_cli_lc-0.4.0/pi_agent_cli/prompt_options.py +71 -0
- pi_agent_cli_lc-0.4.0/pi_agent_cli/system_prompt.py +212 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pyproject.toml +3 -3
- pi_agent_cli_lc-0.4.0/tests/snapshots/default_coding_tools_prompt.txt +31 -0
- pi_agent_cli_lc-0.4.0/tests/test_acp_agent.py +645 -0
- pi_agent_cli_lc-0.4.0/tests/test_benchmark_loader.py +25 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_config.py +42 -0
- pi_agent_cli_lc-0.4.0/tests/test_context_files.py +75 -0
- pi_agent_cli_lc-0.4.0/tests/test_evaluator_cache.py +64 -0
- pi_agent_cli_lc-0.4.0/tests/test_factory_skills.py +77 -0
- pi_agent_cli_lc-0.4.0/tests/test_git_context.py +127 -0
- pi_agent_cli_lc-0.4.0/tests/test_golden_checkpoint.py +191 -0
- pi_agent_cli_lc-0.4.0/tests/test_headless.py +149 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_pelican_real_llm.py +1 -1
- pi_agent_cli_lc-0.4.0/tests/test_system_prompt.py +156 -0
- pi_agent_cli_lc-0.2.0/.gitignore +0 -16
- pi_agent_cli_lc-0.2.0/pi_agent_cli/__main__.py +0 -81
- pi_agent_cli_lc-0.2.0/pi_agent_cli/benchmarks/__init__.py +0 -15
- pi_agent_cli_lc-0.2.0/pi_agent_cli/events.py +0 -141
- pi_agent_cli_lc-0.2.0/pi_agent_cli/factory.py +0 -70
- pi_agent_cli_lc-0.2.0/pi_agent_cli/prompt.py +0 -24
- pi_agent_cli_lc-0.2.0/tests/test_acp_agent.py +0 -253
- pi_agent_cli_lc-0.2.0/tests/test_factory_skills.py +0 -43
- pi_agent_cli_lc-0.2.0/tests/test_headless.py +0 -74
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/config.toml.example +0 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/local.env.example +0 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/__init__.py +0 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/benchmarks/pelican.py +0 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/permissions.py +0 -0
- {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_pelican_benchmark.py +0 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.eggs/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
!tui/crates/build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.pytest-audit/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.venv/
|
|
13
|
+
.venv-*/
|
|
14
|
+
venv/
|
|
15
|
+
.env
|
|
16
|
+
# Rust TUI workspace (Apache-2.0 fork under tui/)
|
|
17
|
+
tui/target/
|
|
18
|
+
**/*.rs.bk
|
|
19
|
+
|
|
20
|
+
# Benchmark evaluation cache and trial artifacts
|
|
21
|
+
.cache/
|
|
22
|
+
.pi-eval/
|
|
23
|
+
|
|
24
|
+
# Temporary files, coverage and logs
|
|
25
|
+
*.log
|
|
26
|
+
*.tmp
|
|
27
|
+
*.bak
|
|
28
|
+
*.swp
|
|
29
|
+
*.orig
|
|
30
|
+
.coverage
|
|
31
|
+
.coverage.*
|
|
32
|
+
coverage.xml
|
|
33
|
+
htmlcov/
|
|
34
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
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`.
|
|
11
|
+
|
|
12
|
+
## Config
|
|
13
|
+
|
|
14
|
+
Home: `~/.pi-python/` (override with `PI_HOME`). 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 | grok-shell settings; Python-only keys break TUI parsing |
|
|
20
|
+
|
|
21
|
+
`local.env` in home loads `KEY=VALUE` pairs into env without overwriting (secrets stay out of TOML). See `agent.example.toml` for all keys.
|
|
22
|
+
|
|
23
|
+
## TUI spawn
|
|
24
|
+
|
|
25
|
+
The Rust TUI (`zypi`) spawns the Python agent via (priority order):
|
|
26
|
+
|
|
27
|
+
1. `PI_AGENT_COMMAND` env var
|
|
28
|
+
2. `[agent].command` in `agent.toml`
|
|
29
|
+
3. `PI_PYTHON` env var (appends `-m pi_agent_cli`)
|
|
30
|
+
|
|
31
|
+
## System prompt pipeline
|
|
32
|
+
|
|
33
|
+
`build_system_prompt` (`system_prompt.py`) assembles:
|
|
34
|
+
|
|
35
|
+
1. Base prompt with tool snippets and `prompt_guidelines`
|
|
36
|
+
2. `append_system_prompt` (config or CLI flag)
|
|
37
|
+
3. Context files — `context_files.py` walks cwd → repo root collecting `AGENTS.md`, `CLAUDE.md`, `.pi/SYSTEM.md`
|
|
38
|
+
4. Git status — `<git_status>` from `git_context.py` when cwd is a repo (`[git]` / `--no-git-context`)
|
|
39
|
+
5. Skills from `[skills].paths`, formatted as `<available_skills>`
|
|
40
|
+
|
|
41
|
+
`build_coding_agent_harness_system_prompt` (`create_harness.py`) wraps this for harness use.
|
|
42
|
+
|
|
43
|
+
## Modules
|
|
44
|
+
|
|
45
|
+
| Module | Role |
|
|
46
|
+
|--------|------|
|
|
47
|
+
| `agent.py` | `PiAcpAgent` — ACP methods, permission handling, event projection |
|
|
48
|
+
| `config.py` | TOML loading, `pi_home()`, `CliConfig` dataclass |
|
|
49
|
+
| `factory.py` | `create_session_harness` — wires tools, model, stream_fn, skills into harness |
|
|
50
|
+
| `system_prompt.py` | `build_system_prompt`, tool snippet/guideline consumption |
|
|
51
|
+
| `context_files.py` | AGENTS.md / CLAUDE.md / .pi/SYSTEM.md discovery |
|
|
52
|
+
| `git_context.py` | Read-only branch and `git status` snapshot for the system prompt |
|
|
53
|
+
| `headless.py` | `-p` one-shot mode, prompt override application |
|
|
54
|
+
| `events.py` | Internal events → ACP `session_update` projection |
|
|
55
|
+
| `permissions.py` | Tool permission gating (ask / auto / always-approve) |
|
|
56
|
+
| `create_harness.py` | Harness-level system prompt, coding tool wiring |
|
|
57
|
+
| `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.4.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.4.0
|
|
24
|
+
Requires-Dist: pi-agent-harness-lc==0.4.0
|
|
25
25
|
Requires-Dist: pydantic>=2.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
@@ -31,6 +31,7 @@ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent o
|
|
|
31
31
|
|
|
32
32
|
- stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
|
|
33
33
|
- headless one-shot: `python -m pi_agent_cli -p "..."`
|
|
34
|
+
- Headless prompt overrides (override `agent.toml` `[prompt]`): `--system-prompt`, `--system-prompt-file`, `--append-system-prompt` / `--rules`, `--append-system-prompt-file`, `--no-context-files`
|
|
34
35
|
- Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
|
|
35
36
|
- No `x.ai/*` vendor RPCs — core + harness + ACP only
|
|
36
37
|
|
|
@@ -4,6 +4,7 @@ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent o
|
|
|
4
4
|
|
|
5
5
|
- stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
|
|
6
6
|
- headless one-shot: `python -m pi_agent_cli -p "..."`
|
|
7
|
+
- Headless prompt overrides (override `agent.toml` `[prompt]`): `--system-prompt`, `--system-prompt-file`, `--append-system-prompt` / `--rules`, `--append-system-prompt-file`, `--no-context-files`
|
|
7
8
|
- Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
|
|
8
9
|
- No `x.ai/*` vendor RPCs — core + harness + ACP only
|
|
9
10
|
|
|
@@ -20,3 +20,15 @@ paths = ["~/.pi-python/skills", ".pi/skills"]
|
|
|
20
20
|
[agent]
|
|
21
21
|
# Used by the Rust TUI when PI_AGENT_COMMAND is unset (Windows: prefer a venv python.exe).
|
|
22
22
|
command = "python -m pi_agent_cli"
|
|
23
|
+
|
|
24
|
+
[prompt]
|
|
25
|
+
# no_context_files = false
|
|
26
|
+
# custom_system_prompt = "You are a helpful assistant."
|
|
27
|
+
# custom_system_prompt_file = "~/.pi-python/agent/SYSTEM.md"
|
|
28
|
+
# append_system_prompt = "Always run tests after edits."
|
|
29
|
+
# append_system_prompt_file = "~/.pi-python/agent/APPEND_SYSTEM.md"
|
|
30
|
+
|
|
31
|
+
[git]
|
|
32
|
+
# enabled = true
|
|
33
|
+
# timeout_seconds = 2
|
|
34
|
+
# max_status_lines = 40
|
|
@@ -0,0 +1,159 @@
|
|
|
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 json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from acp import run_agent
|
|
16
|
+
|
|
17
|
+
from pi_agent_cli.agent import PiAcpAgent
|
|
18
|
+
from pi_agent_cli.config import load_local_env
|
|
19
|
+
from pi_agent_cli.headless import HeadlessPromptOverrides, resolve_print_prompt, run_print
|
|
20
|
+
|
|
21
|
+
_PROMPT_CLI_FLAG_NAMES = (
|
|
22
|
+
"system_prompt",
|
|
23
|
+
"system_prompt_file",
|
|
24
|
+
"append_system_prompt",
|
|
25
|
+
"append_system_prompt_file",
|
|
26
|
+
"no_context_files",
|
|
27
|
+
"no_git_context",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _amain() -> None:
|
|
32
|
+
await run_agent(PiAcpAgent())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="pi-agent-cli",
|
|
38
|
+
description="Standard ACP agent over AgentHarness (stdio), or one-shot -p print.",
|
|
39
|
+
)
|
|
40
|
+
src = parser.add_mutually_exclusive_group()
|
|
41
|
+
src.add_argument(
|
|
42
|
+
"-p",
|
|
43
|
+
"--print",
|
|
44
|
+
dest="print_prompt",
|
|
45
|
+
metavar="PROMPT",
|
|
46
|
+
help="Run one prompt, print the assistant text, and exit (no TUI, no ACP stdio).",
|
|
47
|
+
)
|
|
48
|
+
src.add_argument(
|
|
49
|
+
"--prompt-json",
|
|
50
|
+
metavar="JSON",
|
|
51
|
+
help="Single-turn prompt as a JSON string or list of content blocks.",
|
|
52
|
+
)
|
|
53
|
+
src.add_argument(
|
|
54
|
+
"--prompt-file",
|
|
55
|
+
metavar="PATH",
|
|
56
|
+
type=Path,
|
|
57
|
+
help="Read the single-turn prompt from a file.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--cwd",
|
|
61
|
+
metavar="PATH",
|
|
62
|
+
type=Path,
|
|
63
|
+
help="Working directory for the headless session (default: process cwd).",
|
|
64
|
+
)
|
|
65
|
+
system = parser.add_mutually_exclusive_group()
|
|
66
|
+
system.add_argument(
|
|
67
|
+
"--system-prompt",
|
|
68
|
+
"--system-prompt-override",
|
|
69
|
+
dest="system_prompt",
|
|
70
|
+
metavar="TEXT",
|
|
71
|
+
help="Replace the default system prompt (headless only).",
|
|
72
|
+
)
|
|
73
|
+
system.add_argument(
|
|
74
|
+
"--system-prompt-file",
|
|
75
|
+
metavar="PATH",
|
|
76
|
+
type=Path,
|
|
77
|
+
help="Read the system prompt override from a file (headless only).",
|
|
78
|
+
)
|
|
79
|
+
append = parser.add_mutually_exclusive_group()
|
|
80
|
+
append.add_argument(
|
|
81
|
+
"--append-system-prompt",
|
|
82
|
+
"--rules",
|
|
83
|
+
dest="append_system_prompt",
|
|
84
|
+
metavar="TEXT",
|
|
85
|
+
help="Append text to the system prompt (headless only).",
|
|
86
|
+
)
|
|
87
|
+
append.add_argument(
|
|
88
|
+
"--append-system-prompt-file",
|
|
89
|
+
metavar="PATH",
|
|
90
|
+
type=Path,
|
|
91
|
+
help="Read append text from a file (headless only).",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--no-context-files",
|
|
95
|
+
action="store_true",
|
|
96
|
+
help="Skip AGENTS.md / CLAUDE.md discovery (headless only).",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--no-git-context",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help="Omit the <git_status> section from the system prompt (headless only).",
|
|
102
|
+
)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _prompt_overrides_from_args(args: argparse.Namespace) -> HeadlessPromptOverrides:
|
|
107
|
+
return HeadlessPromptOverrides(
|
|
108
|
+
system_prompt=args.system_prompt,
|
|
109
|
+
system_prompt_file=args.system_prompt_file,
|
|
110
|
+
append_system_prompt=args.append_system_prompt,
|
|
111
|
+
append_system_prompt_file=args.append_system_prompt_file,
|
|
112
|
+
no_context_files=True if args.no_context_files else None,
|
|
113
|
+
no_git_context=True if args.no_git_context else None,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _has_prompt_cli_flags(args: argparse.Namespace) -> bool:
|
|
118
|
+
return any(getattr(args, name) for name in _PROMPT_CLI_FLAG_NAMES)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> None:
|
|
122
|
+
load_local_env()
|
|
123
|
+
parser = _build_parser()
|
|
124
|
+
args = parser.parse_args()
|
|
125
|
+
headless = any(
|
|
126
|
+
value is not None for value in (args.print_prompt, args.prompt_json, args.prompt_file)
|
|
127
|
+
)
|
|
128
|
+
if not headless and _has_prompt_cli_flags(args):
|
|
129
|
+
print(
|
|
130
|
+
"error: --system-prompt, --append-system-prompt, --no-context-files, "
|
|
131
|
+
"and --no-git-context require headless mode "
|
|
132
|
+
"(-p, --prompt-json, or --prompt-file)",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
raise SystemExit(2)
|
|
136
|
+
if headless:
|
|
137
|
+
try:
|
|
138
|
+
prompt = resolve_print_prompt(
|
|
139
|
+
print_prompt=args.print_prompt,
|
|
140
|
+
prompt_json=args.prompt_json,
|
|
141
|
+
prompt_file=args.prompt_file,
|
|
142
|
+
)
|
|
143
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
144
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
145
|
+
raise SystemExit(2) from exc
|
|
146
|
+
raise SystemExit(
|
|
147
|
+
asyncio.run(
|
|
148
|
+
run_print(
|
|
149
|
+
prompt,
|
|
150
|
+
cwd=args.cwd,
|
|
151
|
+
prompt_overrides=_prompt_overrides_from_args(args),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
asyncio.run(_amain())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import replace
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
9
|
+
from typing import Any, cast
|
|
8
10
|
|
|
9
11
|
from acp import PROTOCOL_VERSION, RequestError
|
|
10
12
|
from acp.interfaces import Agent, Client
|
|
11
13
|
from acp.schema import (
|
|
12
14
|
AgentCapabilities,
|
|
13
15
|
AudioContentBlock,
|
|
16
|
+
AvailableCommand,
|
|
17
|
+
AvailableCommandInput,
|
|
18
|
+
AvailableCommandsUpdate,
|
|
14
19
|
ClientCapabilities,
|
|
15
20
|
CloseSessionResponse,
|
|
16
21
|
EmbeddedResourceContentBlock,
|
|
@@ -25,6 +30,7 @@ from acp.schema import (
|
|
|
25
30
|
PromptCapabilities,
|
|
26
31
|
PromptResponse,
|
|
27
32
|
ResourceContentBlock,
|
|
33
|
+
ResumeSessionResponse,
|
|
28
34
|
SessionCapabilities,
|
|
29
35
|
SessionCloseCapabilities,
|
|
30
36
|
SessionInfo,
|
|
@@ -32,10 +38,11 @@ from acp.schema import (
|
|
|
32
38
|
SessionResumeCapabilities,
|
|
33
39
|
SseMcpServer,
|
|
34
40
|
TextContentBlock,
|
|
41
|
+
UnstructuredCommandInput,
|
|
35
42
|
)
|
|
36
43
|
|
|
37
|
-
from pi_agent_cli.config import CliConfig, load_config, pi_home
|
|
38
|
-
from pi_agent_cli.events import project_event
|
|
44
|
+
from pi_agent_cli.config import CliConfig, PermissionMode, load_config, pi_home
|
|
45
|
+
from pi_agent_cli.events import project_event, project_message_replay
|
|
39
46
|
from pi_agent_cli.factory import create_session_harness, default_stream_fn, load_session_resources
|
|
40
47
|
from pi_agent_cli.permissions import (
|
|
41
48
|
PERMISSION_OPTIONS,
|
|
@@ -48,6 +55,8 @@ from pi_agent_core.messages import ImageContent
|
|
|
48
55
|
from pi_agent_core.types import StreamFn
|
|
49
56
|
from pi_agent_harness import AgentHarness, JsonlSessionRepo, Session
|
|
50
57
|
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
51
60
|
_AGENT_INFO = Implementation(name="pi-agent-cli", title="pi-python ACP agent", version="0.1.0")
|
|
52
61
|
|
|
53
62
|
|
|
@@ -55,6 +64,7 @@ class PiAcpAgent(Agent):
|
|
|
55
64
|
"""Standard-ACP-only agent. Does not register any vendor extension methods."""
|
|
56
65
|
|
|
57
66
|
_conn: Client | None
|
|
67
|
+
_commands_advertised: set[str]
|
|
58
68
|
|
|
59
69
|
def __init__(
|
|
60
70
|
self,
|
|
@@ -63,6 +73,7 @@ class PiAcpAgent(Agent):
|
|
|
63
73
|
home: Path | str | None = None,
|
|
64
74
|
config: CliConfig | None = None,
|
|
65
75
|
repo: JsonlSessionRepo | None = None,
|
|
76
|
+
extensions: list[Any] | None = None,
|
|
66
77
|
) -> None:
|
|
67
78
|
self._conn = None
|
|
68
79
|
self._home = pi_home(home)
|
|
@@ -73,6 +84,12 @@ class PiAcpAgent(Agent):
|
|
|
73
84
|
self._repo = repo if repo is not None else JsonlSessionRepo(sessions_dir)
|
|
74
85
|
self._harnesses: dict[str, AgentHarness] = {}
|
|
75
86
|
self._abort_tasks: set[asyncio.Task[Any]] = set()
|
|
87
|
+
self._session_cwds: dict[str, str] = {}
|
|
88
|
+
self._client_capabilities: ClientCapabilities | None = None
|
|
89
|
+
self._client_info: Implementation | None = None
|
|
90
|
+
self._extensions: list[Any] = extensions or []
|
|
91
|
+
self._commands_advertised: set[str] = set()
|
|
92
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
76
93
|
|
|
77
94
|
def on_connect(self, conn: Client) -> None:
|
|
78
95
|
self._conn = conn
|
|
@@ -84,6 +101,8 @@ class PiAcpAgent(Agent):
|
|
|
84
101
|
client_info: Implementation | None = None,
|
|
85
102
|
**kwargs: Any,
|
|
86
103
|
) -> InitializeResponse:
|
|
104
|
+
self._client_capabilities = client_capabilities
|
|
105
|
+
self._client_info = client_info
|
|
87
106
|
return InitializeResponse(
|
|
88
107
|
protocol_version=min(protocol_version, PROTOCOL_VERSION),
|
|
89
108
|
agent_capabilities=AgentCapabilities(
|
|
@@ -111,7 +130,15 @@ class PiAcpAgent(Agent):
|
|
|
111
130
|
session = await self._repo.create({"cwd": cwd})
|
|
112
131
|
session_id = (await session.get_metadata()).id
|
|
113
132
|
await self._bind_session(session_id, session, cwd)
|
|
114
|
-
|
|
133
|
+
# Defer available_commands_update: Zed only registers the session
|
|
134
|
+
# AFTER it receives NewSessionResponse, so notifications sent before
|
|
135
|
+
# that are silently dropped. Schedule via create_task + sleep(0) so
|
|
136
|
+
# the response is flushed first. (See zed#60199, zed#53161.)
|
|
137
|
+
self._schedule_deferred_advertise(session_id)
|
|
138
|
+
return NewSessionResponse(
|
|
139
|
+
session_id=session_id,
|
|
140
|
+
field_meta=self._session_response_meta(),
|
|
141
|
+
)
|
|
115
142
|
|
|
116
143
|
async def load_session(
|
|
117
144
|
self,
|
|
@@ -126,7 +153,13 @@ class PiAcpAgent(Agent):
|
|
|
126
153
|
raise RequestError.resource_not_found(session_id)
|
|
127
154
|
session = await self._repo.open(metadata)
|
|
128
155
|
await self._bind_session(session_id, session, metadata.cwd or cwd)
|
|
129
|
-
|
|
156
|
+
if self._conn is not None:
|
|
157
|
+
context = await session.build_context()
|
|
158
|
+
for msg in context.messages:
|
|
159
|
+
for update in project_message_replay(msg):
|
|
160
|
+
await self._conn.session_update(session_id=session_id, update=update)
|
|
161
|
+
self._schedule_deferred_advertise(session_id)
|
|
162
|
+
return LoadSessionResponse(field_meta=self._session_response_meta())
|
|
130
163
|
|
|
131
164
|
async def list_sessions(
|
|
132
165
|
self, cwd: str | None = None, cursor: str | None = None, **kwargs: Any
|
|
@@ -143,8 +176,27 @@ class PiAcpAgent(Agent):
|
|
|
143
176
|
]
|
|
144
177
|
return ListSessionsResponse(sessions=sessions)
|
|
145
178
|
|
|
179
|
+
async def resume_session(
|
|
180
|
+
self,
|
|
181
|
+
session_id: str,
|
|
182
|
+
cwd: str,
|
|
183
|
+
additional_directories: list[str] | None = None,
|
|
184
|
+
mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
|
|
185
|
+
**kwargs: Any,
|
|
186
|
+
) -> ResumeSessionResponse:
|
|
187
|
+
metadata = await self._find_metadata(session_id)
|
|
188
|
+
if metadata is None:
|
|
189
|
+
raise RequestError.resource_not_found(session_id)
|
|
190
|
+
session = await self._repo.open(metadata)
|
|
191
|
+
await self._bind_session(session_id, session, metadata.cwd or cwd)
|
|
192
|
+
# ACP session/resume intentionally does not replay history.
|
|
193
|
+
self._schedule_deferred_advertise(session_id)
|
|
194
|
+
return ResumeSessionResponse(field_meta=self._session_response_meta())
|
|
195
|
+
|
|
146
196
|
async def close_session(self, session_id: str, **kwargs: Any) -> CloseSessionResponse | None:
|
|
147
|
-
self._harnesses.pop(session_id, None)
|
|
197
|
+
harness = self._harnesses.pop(session_id, None)
|
|
198
|
+
if harness is not None:
|
|
199
|
+
await harness.close()
|
|
148
200
|
return CloseSessionResponse()
|
|
149
201
|
|
|
150
202
|
async def prompt(
|
|
@@ -160,6 +212,9 @@ class PiAcpAgent(Agent):
|
|
|
160
212
|
**kwargs: Any,
|
|
161
213
|
) -> PromptResponse:
|
|
162
214
|
harness = self._require_harness(session_id)
|
|
215
|
+
# Fallback: re-advertise commands if the deferred task was missed.
|
|
216
|
+
if session_id not in self._commands_advertised:
|
|
217
|
+
await self._advertise_commands(session_id)
|
|
163
218
|
text, images = _prompt_to_text_images(prompt)
|
|
164
219
|
try:
|
|
165
220
|
message = await harness.prompt(text, images or None)
|
|
@@ -178,11 +233,42 @@ class PiAcpAgent(Agent):
|
|
|
178
233
|
task.add_done_callback(self._abort_tasks.discard)
|
|
179
234
|
|
|
180
235
|
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
236
|
+
if method == "pi/session/delete":
|
|
237
|
+
session_id_raw = params.get("sessionId", params.get("session_id"))
|
|
238
|
+
if not isinstance(session_id_raw, str) or not session_id_raw.strip():
|
|
239
|
+
raise RequestError.invalid_params(
|
|
240
|
+
{"reason": "sessionId is required", "method": method}
|
|
241
|
+
)
|
|
242
|
+
session_id = session_id_raw.strip()
|
|
243
|
+
metadata = await self._find_metadata(session_id)
|
|
244
|
+
harness = self._harnesses.pop(session_id, None)
|
|
245
|
+
if harness is not None:
|
|
246
|
+
await harness.abort()
|
|
247
|
+
# Idempotent delete: missing session still returns success.
|
|
248
|
+
if metadata is not None:
|
|
249
|
+
await self._repo.delete(metadata)
|
|
250
|
+
return {"sessionId": session_id, "deleted": True}
|
|
181
251
|
raise RequestError.method_not_found(method)
|
|
182
252
|
|
|
183
253
|
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
254
|
+
mode = _permission_mode_from_notification(method, params)
|
|
255
|
+
if mode is not None:
|
|
256
|
+
self._config = replace(self._config, permission=mode)
|
|
184
257
|
return None
|
|
185
258
|
|
|
259
|
+
def _session_response_meta(self) -> dict[str, Any] | None:
|
|
260
|
+
model_id = self._config.model_id.strip()
|
|
261
|
+
if not model_id:
|
|
262
|
+
return None
|
|
263
|
+
meta: dict[str, Any] = {
|
|
264
|
+
"pi/currentModelId": model_id,
|
|
265
|
+
"pi/currentModelDisplayName": model_id,
|
|
266
|
+
}
|
|
267
|
+
provider = self._config.provider.strip()
|
|
268
|
+
if provider:
|
|
269
|
+
meta["pi/provider"] = provider
|
|
270
|
+
return meta
|
|
271
|
+
|
|
186
272
|
def _require_harness(self, session_id: str) -> AgentHarness:
|
|
187
273
|
harness = self._harnesses.get(session_id)
|
|
188
274
|
if harness is None:
|
|
@@ -204,13 +290,14 @@ class PiAcpAgent(Agent):
|
|
|
204
290
|
return await self._handle_tool_call(session_id, event)
|
|
205
291
|
|
|
206
292
|
resources = await load_session_resources(cwd=cwd, config=self._config)
|
|
207
|
-
harness = create_session_harness(
|
|
293
|
+
harness = await create_session_harness(
|
|
208
294
|
session=session,
|
|
209
295
|
cwd=cwd,
|
|
210
296
|
config=self._config,
|
|
211
297
|
stream_fn=self._stream_fn,
|
|
212
298
|
resources=resources,
|
|
213
299
|
on_tool_call=on_tool_call,
|
|
300
|
+
extensions=self._extensions,
|
|
214
301
|
)
|
|
215
302
|
|
|
216
303
|
async def on_event(event: Any, signal: Any | None = None) -> None:
|
|
@@ -218,11 +305,67 @@ class PiAcpAgent(Agent):
|
|
|
218
305
|
|
|
219
306
|
harness.subscribe(on_event)
|
|
220
307
|
self._harnesses[session_id] = harness
|
|
308
|
+
self._session_cwds[session_id] = cwd
|
|
309
|
+
|
|
310
|
+
# Eagerly load extensions so slash commands are available.
|
|
311
|
+
await harness.load_extensions()
|
|
312
|
+
|
|
313
|
+
def _schedule_deferred_advertise(self, session_id: str) -> None:
|
|
314
|
+
"""Fire-and-forget: advertise commands after the current response flushes."""
|
|
315
|
+
task = asyncio.create_task(self._deferred_advertise_commands(session_id))
|
|
316
|
+
self._background_tasks.add(task)
|
|
317
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
318
|
+
|
|
319
|
+
async def _deferred_advertise_commands(self, session_id: str) -> None:
|
|
320
|
+
"""Advertise commands after yielding the event loop.
|
|
321
|
+
|
|
322
|
+
Zed registers ACP sessions only after processing the response to
|
|
323
|
+
``session/new``. Notifications sent *before* that response are
|
|
324
|
+
silently dropped ("unknown session"). Yielding with ``sleep(0)``
|
|
325
|
+
lets the response flush first. (See zed-industries/zed#60199.)
|
|
326
|
+
"""
|
|
327
|
+
await asyncio.sleep(0)
|
|
328
|
+
await self._advertise_commands(session_id)
|
|
329
|
+
|
|
330
|
+
async def _advertise_commands(self, session_id: str) -> None:
|
|
331
|
+
"""Send ``AvailableCommandsUpdate`` so ACP clients show slash commands."""
|
|
332
|
+
if self._conn is None:
|
|
333
|
+
return
|
|
334
|
+
harness = self._harnesses.get(session_id)
|
|
335
|
+
if harness is None:
|
|
336
|
+
return
|
|
337
|
+
commands = harness.extension_registry.get_commands()
|
|
338
|
+
if not commands:
|
|
339
|
+
return
|
|
340
|
+
available: list[AvailableCommand] = []
|
|
341
|
+
for name, cmd in commands.items():
|
|
342
|
+
ac = AvailableCommand(
|
|
343
|
+
name=name,
|
|
344
|
+
description=cmd.description,
|
|
345
|
+
input=AvailableCommandInput(
|
|
346
|
+
root=UnstructuredCommandInput(hint="<args>"),
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
available.append(ac)
|
|
350
|
+
logger.debug(
|
|
351
|
+
"_advertise_commands: sending %d commands: %s",
|
|
352
|
+
len(available),
|
|
353
|
+
[c.name for c in available],
|
|
354
|
+
)
|
|
355
|
+
await self._conn.session_update(
|
|
356
|
+
session_id=session_id,
|
|
357
|
+
update=AvailableCommandsUpdate(
|
|
358
|
+
session_update="available_commands_update",
|
|
359
|
+
available_commands=available,
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
self._commands_advertised.add(session_id)
|
|
221
363
|
|
|
222
364
|
async def _emit_updates(self, session_id: str, event: Any) -> None:
|
|
223
365
|
if self._conn is None:
|
|
224
366
|
return
|
|
225
|
-
|
|
367
|
+
cwd = self._session_cwds.get(session_id)
|
|
368
|
+
for update in project_event(event, cwd=cwd):
|
|
226
369
|
await self._conn.session_update(session_id=session_id, update=update)
|
|
227
370
|
|
|
228
371
|
async def _handle_tool_call(self, session_id: str, event: Any) -> dict[str, Any] | None:
|
|
@@ -287,11 +430,43 @@ def _prompt_to_text_images(
|
|
|
287
430
|
|
|
288
431
|
|
|
289
432
|
def _stop_reason(message: Any) -> str:
|
|
433
|
+
"""Map internal AssistantMessage stopReason to ACP PromptResponse stop_reason.
|
|
434
|
+
|
|
435
|
+
ACP stopReason enum: 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'.
|
|
436
|
+
Internal stopReason values: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted'.
|
|
437
|
+
"""
|
|
290
438
|
reason = getattr(message, "stopReason", None) or "stop"
|
|
291
439
|
if reason == "aborted":
|
|
292
440
|
return "cancelled"
|
|
293
441
|
if reason == "length":
|
|
294
442
|
return "max_tokens"
|
|
295
443
|
if reason == "error":
|
|
296
|
-
|
|
444
|
+
err = str(getattr(message, "errorMessage", "") or "")
|
|
445
|
+
if any(w in err.lower() for w in ("refus", "policy", "filter", "safety")):
|
|
446
|
+
return "refusal"
|
|
447
|
+
return "end_turn"
|
|
297
448
|
return "end_turn"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _permission_mode_from_notification(
|
|
452
|
+
method: str, params: dict[str, Any] | None
|
|
453
|
+
) -> PermissionMode | None:
|
|
454
|
+
"""Best-effort permission mode sync for live sessions.
|
|
455
|
+
|
|
456
|
+
TUI settings changes are sent as extension notifications; we accept known
|
|
457
|
+
suffixes and update in-memory mode so the next tool call in this process
|
|
458
|
+
uses the new policy immediately.
|
|
459
|
+
"""
|
|
460
|
+
suffix = method.rsplit("/", 1)[-1].strip().lower()
|
|
461
|
+
if suffix not in {"yolo_mode_changed", "permission_mode_changed"}:
|
|
462
|
+
return None
|
|
463
|
+
payload = params or {}
|
|
464
|
+
raw = payload.get("permission_mode")
|
|
465
|
+
if raw is None:
|
|
466
|
+
raw = payload.get("permissionMode")
|
|
467
|
+
if not isinstance(raw, str):
|
|
468
|
+
return None
|
|
469
|
+
normalized = raw.strip().lower()
|
|
470
|
+
if normalized not in {"ask", "auto", "always-approve"}:
|
|
471
|
+
return None
|
|
472
|
+
return cast(PermissionMode, normalized)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Benchmark evaluation subsystem for pi-agent-cli and pi-python harness."""
|
|
2
|
+
|
|
3
|
+
from pi_agent_cli.benchmarks.collector import (
|
|
4
|
+
format_markdown_report,
|
|
5
|
+
print_summary_table,
|
|
6
|
+
save_summary_artifacts,
|
|
7
|
+
)
|
|
8
|
+
from pi_agent_cli.benchmarks.egress_policy import (
|
|
9
|
+
OFFICIAL_ALLOWED_HOSTS,
|
|
10
|
+
EgressPolicy,
|
|
11
|
+
resolve_task_egress_policy,
|
|
12
|
+
)
|
|
13
|
+
from pi_agent_cli.benchmarks.evaluator import run_trial
|
|
14
|
+
from pi_agent_cli.benchmarks.golden_seed import (
|
|
15
|
+
get_seed_dir,
|
|
16
|
+
is_seed_ready,
|
|
17
|
+
load_golden_manifest,
|
|
18
|
+
provision_golden_seeds,
|
|
19
|
+
)
|
|
20
|
+
from pi_agent_cli.benchmarks.models import (
|
|
21
|
+
BenchmarkSummary,
|
|
22
|
+
BenchmarkTask,
|
|
23
|
+
TrialResult,
|
|
24
|
+
)
|
|
25
|
+
from pi_agent_cli.benchmarks.pelican import (
|
|
26
|
+
PELICAN_PROMPT,
|
|
27
|
+
PelicanSvgReport,
|
|
28
|
+
extract_svg,
|
|
29
|
+
save_pelican_artifact,
|
|
30
|
+
validate_pelican_svg,
|
|
31
|
+
)
|
|
32
|
+
from pi_agent_cli.benchmarks.task_loader import discover_tasks, load_task_from_dir
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"OFFICIAL_ALLOWED_HOSTS",
|
|
36
|
+
"PELICAN_PROMPT",
|
|
37
|
+
"BenchmarkSummary",
|
|
38
|
+
"BenchmarkTask",
|
|
39
|
+
"EgressPolicy",
|
|
40
|
+
"PelicanSvgReport",
|
|
41
|
+
"TrialResult",
|
|
42
|
+
"discover_tasks",
|
|
43
|
+
"extract_svg",
|
|
44
|
+
"format_markdown_report",
|
|
45
|
+
"get_seed_dir",
|
|
46
|
+
"is_seed_ready",
|
|
47
|
+
"load_golden_manifest",
|
|
48
|
+
"load_task_from_dir",
|
|
49
|
+
"print_summary_table",
|
|
50
|
+
"provision_golden_seeds",
|
|
51
|
+
"resolve_task_egress_policy",
|
|
52
|
+
"run_trial",
|
|
53
|
+
"save_pelican_artifact",
|
|
54
|
+
"save_summary_artifacts",
|
|
55
|
+
"validate_pelican_svg",
|
|
56
|
+
]
|