pi-agent-cli-lc 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,19 @@
1
+ """Standard ACP agent over AgentHarness. Vendor extension RPCs are not implemented."""
2
+
3
+ from pi_agent_cli.agent import PiAcpAgent
4
+ from pi_agent_cli.config import (
5
+ CliConfig,
6
+ expand_config_path,
7
+ load_config,
8
+ make_get_api_key,
9
+ pi_home,
10
+ )
11
+
12
+ __all__ = [
13
+ "CliConfig",
14
+ "PiAcpAgent",
15
+ "expand_config_path",
16
+ "load_config",
17
+ "make_get_api_key",
18
+ "pi_home",
19
+ ]
@@ -0,0 +1,81 @@
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 resolve_print_prompt, run_print
20
+
21
+
22
+ async def _amain() -> None:
23
+ await run_agent(PiAcpAgent())
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="pi-agent-cli",
29
+ description="Standard ACP agent over AgentHarness (stdio), or one-shot -p print.",
30
+ )
31
+ src = parser.add_mutually_exclusive_group()
32
+ src.add_argument(
33
+ "-p",
34
+ "--print",
35
+ dest="print_prompt",
36
+ metavar="PROMPT",
37
+ help="Run one prompt, print the assistant text, and exit (no TUI, no ACP stdio).",
38
+ )
39
+ src.add_argument(
40
+ "--prompt-json",
41
+ metavar="JSON",
42
+ help="Single-turn prompt as a JSON string or list of content blocks.",
43
+ )
44
+ src.add_argument(
45
+ "--prompt-file",
46
+ metavar="PATH",
47
+ type=Path,
48
+ help="Read the single-turn prompt from a file.",
49
+ )
50
+ parser.add_argument(
51
+ "--cwd",
52
+ metavar="PATH",
53
+ type=Path,
54
+ help="Working directory for the headless session (default: process cwd).",
55
+ )
56
+ return parser
57
+
58
+
59
+ def main() -> None:
60
+ load_local_env()
61
+ parser = _build_parser()
62
+ args = parser.parse_args()
63
+ headless = any(
64
+ value is not None for value in (args.print_prompt, args.prompt_json, args.prompt_file)
65
+ )
66
+ if headless:
67
+ try:
68
+ prompt = resolve_print_prompt(
69
+ print_prompt=args.print_prompt,
70
+ prompt_json=args.prompt_json,
71
+ prompt_file=args.prompt_file,
72
+ )
73
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
74
+ print(f"error: {exc}", file=sys.stderr)
75
+ raise SystemExit(2) from exc
76
+ raise SystemExit(asyncio.run(run_print(prompt, cwd=args.cwd)))
77
+ asyncio.run(_amain())
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
pi_agent_cli/agent.py ADDED
@@ -0,0 +1,297 @@
1
+ """ACP Agent: standard methods only; AgentHarness is the engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from acp import PROTOCOL_VERSION, RequestError
10
+ from acp.interfaces import Agent, Client
11
+ from acp.schema import (
12
+ AgentCapabilities,
13
+ AudioContentBlock,
14
+ ClientCapabilities,
15
+ CloseSessionResponse,
16
+ EmbeddedResourceContentBlock,
17
+ HttpMcpServer,
18
+ ImageContentBlock,
19
+ Implementation,
20
+ InitializeResponse,
21
+ ListSessionsResponse,
22
+ LoadSessionResponse,
23
+ McpServerStdio,
24
+ NewSessionResponse,
25
+ PromptCapabilities,
26
+ PromptResponse,
27
+ ResourceContentBlock,
28
+ SessionCapabilities,
29
+ SessionCloseCapabilities,
30
+ SessionInfo,
31
+ SessionListCapabilities,
32
+ SessionResumeCapabilities,
33
+ SseMcpServer,
34
+ TextContentBlock,
35
+ )
36
+
37
+ from pi_agent_cli.config import CliConfig, load_config, pi_home
38
+ from pi_agent_cli.events import project_event
39
+ from pi_agent_cli.factory import create_session_harness, default_stream_fn, load_session_resources
40
+ from pi_agent_cli.permissions import (
41
+ PERMISSION_OPTIONS,
42
+ needs_permission,
43
+ outcome_allows,
44
+ permission_tool_call,
45
+ )
46
+ from pi_agent_core.coding_tools.path_utils import normalize_host_path
47
+ from pi_agent_core.messages import ImageContent
48
+ from pi_agent_core.types import StreamFn
49
+ from pi_agent_harness import AgentHarness, JsonlSessionRepo, Session
50
+
51
+ _AGENT_INFO = Implementation(name="pi-agent-cli", title="pi-python ACP agent", version="0.1.0")
52
+
53
+
54
+ class PiAcpAgent(Agent):
55
+ """Standard-ACP-only agent. Does not register any vendor extension methods."""
56
+
57
+ _conn: Client | None
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ stream_fn: StreamFn | None = None,
63
+ home: Path | str | None = None,
64
+ config: CliConfig | None = None,
65
+ repo: JsonlSessionRepo | None = None,
66
+ ) -> None:
67
+ self._conn = None
68
+ self._home = pi_home(home)
69
+ self._config = config if config is not None else load_config(self._home)
70
+ self._stream_fn = stream_fn if stream_fn is not None else default_stream_fn()
71
+ sessions_dir = self._home / "sessions"
72
+ sessions_dir.mkdir(parents=True, exist_ok=True)
73
+ self._repo = repo if repo is not None else JsonlSessionRepo(sessions_dir)
74
+ self._harnesses: dict[str, AgentHarness] = {}
75
+ self._abort_tasks: set[asyncio.Task[Any]] = set()
76
+
77
+ def on_connect(self, conn: Client) -> None:
78
+ self._conn = conn
79
+
80
+ async def initialize(
81
+ self,
82
+ protocol_version: int,
83
+ client_capabilities: ClientCapabilities | None = None,
84
+ client_info: Implementation | None = None,
85
+ **kwargs: Any,
86
+ ) -> InitializeResponse:
87
+ return InitializeResponse(
88
+ protocol_version=min(protocol_version, PROTOCOL_VERSION),
89
+ agent_capabilities=AgentCapabilities(
90
+ load_session=True,
91
+ prompt_capabilities=PromptCapabilities(
92
+ image=True, audio=False, embedded_context=False
93
+ ),
94
+ session_capabilities=SessionCapabilities(
95
+ list=SessionListCapabilities(),
96
+ resume=SessionResumeCapabilities(),
97
+ close=SessionCloseCapabilities(),
98
+ ),
99
+ ),
100
+ auth_methods=[],
101
+ agent_info=_AGENT_INFO,
102
+ )
103
+
104
+ async def new_session(
105
+ self,
106
+ cwd: str,
107
+ additional_directories: list[str] | None = None,
108
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
109
+ **kwargs: Any,
110
+ ) -> NewSessionResponse:
111
+ session = await self._repo.create({"cwd": cwd})
112
+ session_id = (await session.get_metadata()).id
113
+ await self._bind_session(session_id, session, cwd)
114
+ return NewSessionResponse(session_id=session_id)
115
+
116
+ async def load_session(
117
+ self,
118
+ cwd: str,
119
+ session_id: str,
120
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
121
+ additional_directories: list[str] | None = None,
122
+ **kwargs: Any,
123
+ ) -> LoadSessionResponse | None:
124
+ metadata = await self._find_metadata(session_id)
125
+ if metadata is None:
126
+ raise RequestError.resource_not_found(session_id)
127
+ session = await self._repo.open(metadata)
128
+ await self._bind_session(session_id, session, metadata.cwd or cwd)
129
+ return LoadSessionResponse()
130
+
131
+ async def list_sessions(
132
+ self, cwd: str | None = None, cursor: str | None = None, **kwargs: Any
133
+ ) -> ListSessionsResponse:
134
+ listed = await self._repo.list({"cwd": cwd} if cwd is not None else None)
135
+ sessions = [
136
+ SessionInfo(
137
+ session_id=item.id,
138
+ cwd=item.cwd,
139
+ title=_session_title(item.id, item.createdAt),
140
+ updated_at=item.createdAt,
141
+ )
142
+ for item in listed
143
+ ]
144
+ return ListSessionsResponse(sessions=sessions)
145
+
146
+ async def close_session(self, session_id: str, **kwargs: Any) -> CloseSessionResponse | None:
147
+ self._harnesses.pop(session_id, None)
148
+ return CloseSessionResponse()
149
+
150
+ async def prompt(
151
+ self,
152
+ session_id: str,
153
+ prompt: list[
154
+ TextContentBlock
155
+ | ImageContentBlock
156
+ | AudioContentBlock
157
+ | ResourceContentBlock
158
+ | EmbeddedResourceContentBlock
159
+ ],
160
+ **kwargs: Any,
161
+ ) -> PromptResponse:
162
+ harness = self._require_harness(session_id)
163
+ text, images = _prompt_to_text_images(prompt)
164
+ try:
165
+ message = await harness.prompt(text, images or None)
166
+ except Exception as exc:
167
+ if type(exc).__name__ == "AgentHarnessError" and getattr(exc, "code", None) == "busy":
168
+ raise RequestError.invalid_params({"reason": "busy"}) from exc
169
+ raise
170
+ return PromptResponse(stop_reason=_stop_reason(message))
171
+
172
+ async def cancel(self, session_id: str, **kwargs: Any) -> None:
173
+ harness = self._harnesses.get(session_id)
174
+ if harness is None:
175
+ return
176
+ task = asyncio.create_task(harness.abort())
177
+ self._abort_tasks.add(task)
178
+ task.add_done_callback(self._abort_tasks.discard)
179
+
180
+ async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
181
+ raise RequestError.method_not_found(method)
182
+
183
+ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
184
+ return None
185
+
186
+ def _require_harness(self, session_id: str) -> AgentHarness:
187
+ harness = self._harnesses.get(session_id)
188
+ if harness is None:
189
+ raise RequestError.invalid_params(
190
+ {"sessionId": session_id, "reason": "unknown session"}
191
+ )
192
+ return harness
193
+
194
+ async def _find_metadata(self, session_id: str) -> Any:
195
+ for item in await self._repo.list():
196
+ if item.id == session_id:
197
+ return item
198
+ return None
199
+
200
+ async def _bind_session(self, session_id: str, session: Session, cwd: str) -> None:
201
+ cwd = normalize_host_path(cwd)
202
+
203
+ async def on_tool_call(event: Any) -> dict[str, Any] | None:
204
+ return await self._handle_tool_call(session_id, event)
205
+
206
+ resources = await load_session_resources(cwd=cwd, config=self._config)
207
+ harness = create_session_harness(
208
+ session=session,
209
+ cwd=cwd,
210
+ config=self._config,
211
+ stream_fn=self._stream_fn,
212
+ resources=resources,
213
+ on_tool_call=on_tool_call,
214
+ )
215
+
216
+ async def on_event(event: Any, signal: Any | None = None) -> None:
217
+ await self._emit_updates(session_id, event)
218
+
219
+ harness.subscribe(on_event)
220
+ self._harnesses[session_id] = harness
221
+
222
+ async def _emit_updates(self, session_id: str, event: Any) -> None:
223
+ if self._conn is None:
224
+ return
225
+ for update in project_event(event):
226
+ await self._conn.session_update(session_id=session_id, update=update)
227
+
228
+ async def _handle_tool_call(self, session_id: str, event: Any) -> dict[str, Any] | None:
229
+ name = event.toolName
230
+ if not needs_permission(name, self._config.permission):
231
+ return None
232
+ if self._conn is None:
233
+ return {"block": True, "reason": "No ACP client connected"}
234
+ raw_input = dict(event.input or {})
235
+ response = await self._conn.request_permission(
236
+ session_id=session_id,
237
+ tool_call=permission_tool_call(event.toolCallId, name, raw_input),
238
+ options=list(PERMISSION_OPTIONS),
239
+ )
240
+ if outcome_allows(response.outcome):
241
+ return None
242
+ return {"block": True, "reason": "User denied permission"}
243
+
244
+
245
+ def _session_title(session_id: str, created_at: str) -> str:
246
+ short = session_id[:8] if len(session_id) > 8 else session_id
247
+ return f"{created_at} ({short})"
248
+
249
+
250
+ def _prompt_to_text_images(
251
+ prompt: list[Any],
252
+ ) -> tuple[str, list[ImageContent]]:
253
+ texts: list[str] = []
254
+ images: list[ImageContent] = []
255
+ for block in prompt:
256
+ if isinstance(block, dict):
257
+ btype = block.get("type")
258
+ if btype == "text":
259
+ texts.append(str(block.get("text") or ""))
260
+ elif btype == "image":
261
+ images.append(
262
+ {
263
+ "type": "image",
264
+ "data": str(block.get("data") or ""),
265
+ "mimeType": str(
266
+ block.get("mimeType") or block.get("mime_type") or "image/png"
267
+ ),
268
+ }
269
+ )
270
+ continue
271
+ btype = getattr(block, "type", None)
272
+ if btype == "text":
273
+ texts.append(str(getattr(block, "text", "") or ""))
274
+ elif btype == "image":
275
+ images.append(
276
+ {
277
+ "type": "image",
278
+ "data": str(getattr(block, "data", "") or ""),
279
+ "mimeType": str(
280
+ getattr(block, "mime_type", None)
281
+ or getattr(block, "mimeType", None)
282
+ or "image/png"
283
+ ),
284
+ }
285
+ )
286
+ return "".join(texts), images
287
+
288
+
289
+ def _stop_reason(message: Any) -> str:
290
+ reason = getattr(message, "stopReason", None) or "stop"
291
+ if reason == "aborted":
292
+ return "cancelled"
293
+ if reason == "length":
294
+ return "max_tokens"
295
+ if reason == "error":
296
+ return "refusal"
297
+ return "end_turn"
@@ -0,0 +1,15 @@
1
+ """Product-level smoke benchmarks for pi-agent-cli / pi TUI."""
2
+
3
+ from pi_agent_cli.benchmarks.pelican import (
4
+ PELICAN_PROMPT,
5
+ PelicanSvgReport,
6
+ extract_svg,
7
+ validate_pelican_svg,
8
+ )
9
+
10
+ __all__ = [
11
+ "PELICAN_PROMPT",
12
+ "PelicanSvgReport",
13
+ "extract_svg",
14
+ "validate_pelican_svg",
15
+ ]
@@ -0,0 +1,90 @@
1
+ """Pelican-on-a-bicycle benchmark (Simon Willison / Karpathy-style SVG smoke test).
2
+
3
+ See docs/benchmarks/PELCAN-BICYCLE.md.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+
13
+ PELICAN_PROMPT = (
14
+ "Generate an SVG of a pelican riding a bicycle. "
15
+ "Output only valid SVG markup with xmlns and a viewBox, no markdown fences or explanation."
16
+ )
17
+
18
+ _FENCE_RE = re.compile(r"```(?:svg|xml)?\s*\n?(.*?)```", re.DOTALL | re.IGNORECASE)
19
+ _SVG_RE = re.compile(r"(<svg[\s\S]*?</svg>)", re.IGNORECASE)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PelicanSvgReport:
24
+ ok: bool
25
+ checks: dict[str, bool]
26
+ svg: str | None
27
+ notes: tuple[str, ...] = ()
28
+
29
+
30
+ def extract_svg(text: str) -> str | None:
31
+ """Pull the first SVG document from model text (raw or fenced)."""
32
+ raw = text.strip()
33
+ if not raw:
34
+ return None
35
+ for match in _FENCE_RE.finditer(raw):
36
+ inner = match.group(1).strip()
37
+ svg = _SVG_RE.search(inner)
38
+ if svg:
39
+ return svg.group(1).strip()
40
+ if inner.lower().startswith("<svg"):
41
+ return inner
42
+ svg = _SVG_RE.search(raw)
43
+ if svg:
44
+ return svg.group(1).strip()
45
+ if raw.lower().startswith("<svg"):
46
+ return raw
47
+ return None
48
+
49
+
50
+ def validate_pelican_svg(svg: str | None) -> PelicanSvgReport:
51
+ """Heuristic pass/fail for the pelican bicycle benchmark (not artistic scoring)."""
52
+ notes: list[str] = []
53
+ if svg is None:
54
+ return PelicanSvgReport(
55
+ ok=False,
56
+ checks={"extracted": False},
57
+ svg=None,
58
+ notes=("no SVG found in model output",),
59
+ )
60
+ lower = svg.lower()
61
+ checks = {
62
+ "extracted": True,
63
+ "svg_root": "<svg" in lower and "</svg>" in lower,
64
+ "xmlns": "xmlns" in lower,
65
+ "viewbox": "viewbox" in lower,
66
+ "geometry": any(
67
+ tag in lower for tag in ("<path", "<circle", "<rect", "<ellipse", "<polygon")
68
+ ),
69
+ "min_size": len(svg.strip()) >= 200,
70
+ }
71
+ if not checks["geometry"]:
72
+ notes.append("no path/circle/rect/ellipse/polygon elements")
73
+ if not checks["min_size"]:
74
+ notes.append("SVG shorter than 200 characters")
75
+ return PelicanSvgReport(ok=all(checks.values()), checks=checks, svg=svg, notes=tuple(notes))
76
+
77
+
78
+ def default_artifact_dir(home: Path | str | None = None) -> Path:
79
+ from pi_agent_cli.config import pi_home
80
+
81
+ return pi_home(home) / "benchmarks" / "pelican"
82
+
83
+
84
+ def save_pelican_artifact(svg: str, *, home: Path | str | None = None) -> Path:
85
+ out_dir = default_artifact_dir(home)
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
88
+ path = out_dir / f"pelican-{stamp}.svg"
89
+ path.write_text(svg, encoding="utf-8")
90
+ return path
pi_agent_cli/config.py ADDED
@@ -0,0 +1,146 @@
1
+ """`~/.pi-python/agent.toml` (Python ACP agent). Override with PI_HOME.
2
+
3
+ Do not put Python agent settings in ``config.toml`` — the Rust TUI parses that
4
+ file as grok-shell config and will fail on keys like ``permission = \"ask\"``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from pi_agent_core.types import ThinkingLevel
16
+
17
+ PermissionMode = Literal["ask", "auto", "always-approve"]
18
+
19
+ _VALID_PERMISSION: set[str] = {"ask", "auto", "always-approve"}
20
+ _VALID_THINKING: set[str] = {"off", "minimal", "low", "medium", "high", "xhigh"}
21
+
22
+
23
+ def pi_home(override: Path | str | None = None) -> Path:
24
+ if override is not None:
25
+ return Path(override).expanduser()
26
+ raw = os.environ.get("PI_HOME")
27
+ if raw:
28
+ return Path(raw).expanduser()
29
+ return Path.home() / ".pi-python"
30
+
31
+
32
+ def load_local_env(home: Path | str | None = None) -> None:
33
+ """Load ``~/.pi-python/local.env`` (KEY=value) without overwriting existing env."""
34
+ path = pi_home(home) / "local.env"
35
+ if not path.is_file():
36
+ return
37
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
38
+ line = raw_line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ if line.startswith("export "):
42
+ line = line[7:].strip()
43
+ key, sep, value = line.partition("=")
44
+ if not sep:
45
+ continue
46
+ key = key.strip()
47
+ value = value.strip()
48
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
49
+ value = value[1:-1]
50
+ if key and key not in os.environ:
51
+ os.environ[key] = value
52
+
53
+
54
+ def agent_config_path(home: Path | str | None = None) -> Path:
55
+ return pi_home(home) / "agent.toml"
56
+
57
+
58
+ def legacy_config_path(home: Path | str | None = None) -> Path:
59
+ """Legacy path; breaks the Rust TUI if it contains Python-only keys."""
60
+ return pi_home(home) / "config.toml"
61
+
62
+
63
+ def expand_config_path(raw: str, *, cwd: str | Path) -> str:
64
+ """Expand ~ and resolve relative skill/config paths against cwd."""
65
+ expanded = os.path.expanduser(raw)
66
+ path = Path(expanded)
67
+ if not path.is_absolute():
68
+ path = Path(cwd) / path
69
+ return str(path.resolve())
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class CliConfig:
74
+ permission: PermissionMode = "ask"
75
+ provider: str = "mock"
76
+ model_id: str = "mock"
77
+ base_url: str | None = None
78
+ thinking_level: ThinkingLevel = "off"
79
+ max_turns: int | None = None
80
+ api_key_env: str | None = None
81
+ skills_dirs: tuple[str, ...] = ()
82
+ agent_command: str | None = None
83
+
84
+
85
+ def load_config(home: Path | str | None = None) -> CliConfig:
86
+ import tomllib
87
+
88
+ agent_path = agent_config_path(home)
89
+ if agent_path.is_file():
90
+ data = tomllib.loads(agent_path.read_text(encoding="utf-8"))
91
+ return _from_toml(data)
92
+ legacy = legacy_config_path(home)
93
+ if legacy.is_file():
94
+ data = tomllib.loads(legacy.read_text(encoding="utf-8"))
95
+ return _from_toml(data)
96
+ return CliConfig()
97
+
98
+
99
+ def make_get_api_key(
100
+ config: CliConfig,
101
+ ) -> Callable[[str], str | None] | None:
102
+ env_name = config.api_key_env
103
+ if not env_name:
104
+ return None
105
+
106
+ def get_api_key(_provider: str) -> str | None:
107
+ return os.environ.get(env_name) or None
108
+
109
+ return get_api_key
110
+
111
+
112
+ def _from_toml(data: dict[str, Any]) -> CliConfig:
113
+ model = data.get("model") if isinstance(data.get("model"), dict) else {}
114
+ skills = data.get("skills") if isinstance(data.get("skills"), dict) else {}
115
+ agent = data.get("agent") if isinstance(data.get("agent"), dict) else {}
116
+
117
+ permission = data.get("permission", "ask")
118
+ if permission not in _VALID_PERMISSION:
119
+ permission = "ask"
120
+ thinking = data.get("thinking_level", "off")
121
+ if thinking not in _VALID_THINKING:
122
+ thinking = "off"
123
+ max_turns = data.get("max_turns")
124
+ if max_turns is not None:
125
+ max_turns = int(max_turns)
126
+
127
+ raw_paths = skills.get("paths") or skills.get("dirs") or []
128
+ skills_dirs: tuple[str, ...] = ()
129
+ if isinstance(raw_paths, list):
130
+ skills_dirs = tuple(str(item) for item in raw_paths if str(item).strip())
131
+
132
+ agent_command = agent.get("command")
133
+ if agent_command is not None:
134
+ agent_command = str(agent_command).strip() or None
135
+
136
+ return CliConfig(
137
+ permission=permission, # type: ignore[arg-type]
138
+ provider=str(model.get("provider") or data.get("provider") or "mock"),
139
+ model_id=str(model.get("id") or model.get("model_id") or data.get("model_id") or "mock"),
140
+ base_url=model.get("base_url") or data.get("base_url"),
141
+ thinking_level=thinking, # type: ignore[arg-type]
142
+ max_turns=max_turns,
143
+ api_key_env=model.get("api_key_env") or data.get("api_key_env"),
144
+ skills_dirs=skills_dirs,
145
+ agent_command=agent_command,
146
+ )
pi_agent_cli/events.py ADDED
@@ -0,0 +1,141 @@
1
+ """Project AgentEvent onto standard ACP session/update payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from acp.helpers import (
9
+ start_tool_call,
10
+ text_block,
11
+ tool_content,
12
+ tool_diff_content,
13
+ update_agent_message_text,
14
+ update_agent_thought_text,
15
+ update_tool_call,
16
+ )
17
+ from acp.schema import AgentMessageChunk, AgentThoughtChunk, ToolCallProgress, ToolCallStart
18
+
19
+ from pi_agent_core.types import AgentEvent
20
+
21
+ _KIND: dict[str, str] = {
22
+ "read": "read",
23
+ "edit": "edit",
24
+ "write": "edit",
25
+ "bash": "execute",
26
+ "grep": "search",
27
+ "find": "search",
28
+ "ls": "search",
29
+ }
30
+
31
+ SessionUpdate = AgentMessageChunk | AgentThoughtChunk | ToolCallStart | ToolCallProgress
32
+
33
+
34
+ def tool_kind(name: str) -> str:
35
+ return _KIND.get(name, "other")
36
+
37
+
38
+ def project_event(event: AgentEvent) -> Iterator[SessionUpdate]:
39
+ etype = getattr(event, "type", None)
40
+ if etype == "message_end":
41
+ message = getattr(event, "message", None)
42
+ stop = getattr(message, "stopReason", None)
43
+ err = getattr(message, "errorMessage", None)
44
+ if stop in ("error", "aborted") and err:
45
+ yield update_agent_message_text(f"Error: {err}")
46
+ return
47
+
48
+ if etype == "message_update":
49
+ ame = getattr(event, "assistant_message_event", None)
50
+ ame_type = getattr(ame, "type", None)
51
+ if ame_type == "text_delta":
52
+ delta = getattr(ame, "delta", "") or ""
53
+ if delta:
54
+ yield update_agent_message_text(delta)
55
+ elif ame_type == "thinking_delta":
56
+ delta = getattr(ame, "delta", "") or ""
57
+ if delta:
58
+ yield update_agent_thought_text(delta)
59
+ return
60
+
61
+ if etype == "tool_execution_start":
62
+ name = event.tool_name
63
+ yield start_tool_call(
64
+ event.tool_call_id,
65
+ name,
66
+ kind=tool_kind(name), # type: ignore[arg-type]
67
+ status="pending",
68
+ raw_input=event.args,
69
+ )
70
+ return
71
+
72
+ if etype == "tool_execution_update":
73
+ content = _partial_content(event.partial_result)
74
+ yield update_tool_call(
75
+ event.tool_call_id,
76
+ status="in_progress",
77
+ content=content,
78
+ )
79
+ return
80
+
81
+ if etype == "tool_execution_end":
82
+ status = "failed" if event.is_error else "completed"
83
+ args = getattr(event, "args", None)
84
+ yield update_tool_call(
85
+ event.tool_call_id,
86
+ status=status,
87
+ content=_result_content(event.tool_name, args, event.result),
88
+ raw_output=_raw_output(event.result),
89
+ )
90
+
91
+
92
+ def _text_from_result(result: Any) -> str:
93
+ if result is None:
94
+ return ""
95
+ content = getattr(result, "content", None)
96
+ if isinstance(content, list):
97
+ parts: list[str] = []
98
+ for block in content:
99
+ if isinstance(block, dict) and block.get("type") == "text":
100
+ parts.append(str(block.get("text") or ""))
101
+ elif getattr(block, "type", None) == "text":
102
+ parts.append(str(getattr(block, "text", "") or ""))
103
+ return "".join(parts)
104
+ return str(result)
105
+
106
+
107
+ def _partial_content(partial: Any) -> list[Any] | None:
108
+ text = _text_from_result(partial)
109
+ if not text:
110
+ return None
111
+ return [tool_content(text_block(text))]
112
+
113
+
114
+ def _result_content(tool_name: str, args: Any, result: Any) -> list[Any] | None:
115
+ details = getattr(result, "details", None) if result is not None else None
116
+ path = None
117
+ if isinstance(args, dict):
118
+ path = args.get("path")
119
+ if path is None and isinstance(details, dict):
120
+ path = details.get("path")
121
+ if tool_name in {"edit", "write"} and path:
122
+ patch = None
123
+ if isinstance(details, dict):
124
+ patch = details.get("diff") or details.get("patch")
125
+ if patch:
126
+ return [tool_diff_content(str(path), str(patch))]
127
+ text = _text_from_result(result)
128
+ if text:
129
+ return [tool_diff_content(str(path), text)]
130
+ text = _text_from_result(result)
131
+ if not text:
132
+ return None
133
+ return [tool_content(text_block(text))]
134
+
135
+
136
+ def _raw_output(result: Any) -> Any:
137
+ if result is None:
138
+ return None
139
+ if hasattr(result, "model_dump"):
140
+ return result.model_dump(exclude_none=True)
141
+ return result
@@ -0,0 +1,70 @@
1
+ """Build an AgentHarness bound to coding tools and a JSONL session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pi_agent_cli.config import CliConfig, expand_config_path, make_get_api_key
10
+ from pi_agent_cli.prompt import CODING_SYSTEM_PROMPT
11
+ from pi_agent_core.coding_tools import create_all_tools
12
+ from pi_agent_core.coding_tools.path_utils import normalize_host_path
13
+ from pi_agent_core.types import Model, StreamFn
14
+ from pi_agent_harness import AgentHarness, AgentHarnessResources, LocalExecutionEnv, Session
15
+ from pi_agent_harness.skills import load_skills
16
+
17
+
18
+ def default_stream_fn() -> StreamFn:
19
+ import os
20
+
21
+ if os.environ.get("PI_USE_MOCK") == "1":
22
+ from pi_agent_core.tests.mock_stream import mock_text_stream
23
+
24
+ return mock_text_stream
25
+ from pi_agent_core.adapters.langchain_stream import langchain_stream
26
+
27
+ return langchain_stream
28
+
29
+
30
+ async def load_session_resources(*, cwd: str | Path, config: CliConfig) -> AgentHarnessResources:
31
+ if not config.skills_dirs:
32
+ return AgentHarnessResources()
33
+ cwd_s = str(Path(normalize_host_path(str(cwd))).resolve())
34
+ env = LocalExecutionEnv(cwd_s)
35
+ paths = [expand_config_path(item, cwd=cwd_s) for item in config.skills_dirs]
36
+ result = await load_skills(env, paths)
37
+ return AgentHarnessResources(skills=result.skills)
38
+
39
+
40
+ def create_session_harness(
41
+ *,
42
+ session: Session,
43
+ cwd: str | Path,
44
+ config: CliConfig,
45
+ stream_fn: StreamFn,
46
+ resources: AgentHarnessResources | None = None,
47
+ on_tool_call: Callable[[Any], Any | Awaitable[Any]] | None = None,
48
+ ) -> AgentHarness:
49
+ cwd_s = str(Path(normalize_host_path(str(cwd))).resolve())
50
+ tools = list(create_all_tools(cwd_s).values())
51
+ model = Model(
52
+ provider=config.provider,
53
+ model_id=config.model_id,
54
+ base_url=config.base_url,
55
+ )
56
+ harness = AgentHarness(
57
+ session=session,
58
+ model=model,
59
+ stream_fn=stream_fn,
60
+ env=LocalExecutionEnv(cwd_s),
61
+ tools=tools,
62
+ resources=resources or AgentHarnessResources(),
63
+ get_api_key=make_get_api_key(config),
64
+ system_prompt=CODING_SYSTEM_PROMPT,
65
+ thinking_level=config.thinking_level,
66
+ max_turns=config.max_turns,
67
+ )
68
+ if on_tool_call is not None:
69
+ harness.on("tool_call", on_tool_call)
70
+ return harness
@@ -0,0 +1,79 @@
1
+ """Single-turn headless prompt (`python -m pi_agent_cli -p`). No TUI, no ACP stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+
9
+ from pi_agent_cli.config import load_config, pi_home
10
+ from pi_agent_cli.factory import create_session_harness, default_stream_fn, load_session_resources
11
+ from pi_agent_harness import JsonlSessionRepo
12
+
13
+
14
+ def assistant_text(message: object) -> str:
15
+ parts: list[str] = []
16
+ for block in getattr(message, "content", None) or []:
17
+ if isinstance(block, dict) and block.get("type") == "text":
18
+ parts.append(str(block.get("text") or ""))
19
+ return "".join(parts)
20
+
21
+
22
+ def prompt_from_json(raw: str) -> str:
23
+ data = json.loads(raw)
24
+ if isinstance(data, str):
25
+ return data
26
+ if isinstance(data, list):
27
+ return "".join(
28
+ str(block.get("text") or "")
29
+ for block in data
30
+ if isinstance(block, dict) and block.get("type") == "text"
31
+ )
32
+ raise ValueError("prompt JSON must be a string or a list of content blocks")
33
+
34
+
35
+ def resolve_print_prompt(
36
+ *,
37
+ print_prompt: str | None,
38
+ prompt_json: str | None,
39
+ prompt_file: str | Path | None,
40
+ ) -> str:
41
+ if print_prompt is not None:
42
+ return print_prompt
43
+ if prompt_json is not None:
44
+ return prompt_from_json(prompt_json)
45
+ if prompt_file is not None:
46
+ return Path(prompt_file).read_text(encoding="utf-8")
47
+ raise ValueError("one of -p/--print, --prompt-json, or --prompt-file is required")
48
+
49
+
50
+ async def run_print(
51
+ prompt: str,
52
+ *,
53
+ cwd: str | Path | None = None,
54
+ home: str | Path | None = None,
55
+ ) -> int:
56
+ """Create a JSONL session, run one harness turn, print assistant text."""
57
+ text = prompt.strip()
58
+ if not text:
59
+ print("error: empty prompt", flush=True)
60
+ return 2
61
+ home_path = pi_home(home)
62
+ sessions_dir = home_path / "sessions"
63
+ sessions_dir.mkdir(parents=True, exist_ok=True)
64
+ cwd_s = str(Path(cwd).resolve() if cwd is not None else Path.cwd())
65
+ config = replace(load_config(home_path), permission="auto")
66
+ repo = JsonlSessionRepo(sessions_dir)
67
+ session = await repo.create({"cwd": cwd_s})
68
+ resources = await load_session_resources(cwd=cwd_s, config=config)
69
+ harness = create_session_harness(
70
+ session=session,
71
+ cwd=cwd_s,
72
+ config=config,
73
+ stream_fn=default_stream_fn(),
74
+ resources=resources,
75
+ )
76
+ message = await harness.prompt(text)
77
+ out = assistant_text(message)
78
+ print(out, end="" if out.endswith("\n") else "\n")
79
+ return 0
@@ -0,0 +1,52 @@
1
+ """Map bash/edit/write tool_call hooks to ACP session/request_permission."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from acp.schema import AllowedOutcome, DeniedOutcome, PermissionOption, ToolCallUpdate
8
+
9
+ from pi_agent_cli.config import PermissionMode
10
+ from pi_agent_cli.events import tool_kind
11
+
12
+ PERMISSION_TOOLS = frozenset({"bash", "edit", "write"})
13
+
14
+ PERMISSION_OPTIONS = [
15
+ PermissionOption(option_id="allow-once", name="Allow once", kind="allow_once"),
16
+ PermissionOption(option_id="reject-once", name="Reject", kind="reject_once"),
17
+ ]
18
+
19
+
20
+ def needs_permission(tool_name: str, mode: PermissionMode) -> bool:
21
+ if mode in {"auto", "always-approve"}:
22
+ return False
23
+ return tool_name in PERMISSION_TOOLS
24
+
25
+
26
+ def permission_tool_call(
27
+ tool_call_id: str, tool_name: str, raw_input: dict[str, Any]
28
+ ) -> ToolCallUpdate:
29
+ return ToolCallUpdate(
30
+ tool_call_id=tool_call_id,
31
+ title=tool_name,
32
+ kind=tool_kind(tool_name), # type: ignore[arg-type]
33
+ status="pending",
34
+ raw_input=raw_input,
35
+ )
36
+
37
+
38
+ def outcome_allows(outcome: AllowedOutcome | DeniedOutcome | Any) -> bool:
39
+ if isinstance(outcome, DeniedOutcome):
40
+ return False
41
+ if isinstance(outcome, AllowedOutcome):
42
+ return not str(outcome.option_id).startswith("reject")
43
+ if isinstance(outcome, dict):
44
+ if outcome.get("outcome") == "cancelled":
45
+ return False
46
+ option_id = str(outcome.get("optionId") or outcome.get("option_id") or "")
47
+ return not option_id.startswith("reject")
48
+ option_id = str(getattr(outcome, "option_id", "") or "")
49
+ kind = str(getattr(outcome, "outcome", "") or "")
50
+ if kind == "cancelled":
51
+ return False
52
+ return not option_id.startswith("reject")
pi_agent_cli/prompt.py ADDED
@@ -0,0 +1,24 @@
1
+ """Coding-agent system prompt for Phase 4 (deeper prompt work is Phase 5)."""
2
+
3
+ CODING_SYSTEM_PROMPT = """You are pi, a coding agent working in the user's local workspace.
4
+
5
+ ## Tools
6
+ - Use read, grep, find, and ls to explore before changing code.
7
+ - Prefer the smallest change that solves the request; re-read or run checks to verify.
8
+ - Use bash for builds, tests, and one-off commands — not for bulk file edits.
9
+
10
+ ## Self-correction
11
+ - If a tool fails, read the error, adjust arguments or approach, and retry once with a fix.
12
+ - Do not repeat the same failing call without a concrete change.
13
+
14
+ ## Safety
15
+ - Stay inside the workspace unless the user explicitly asks otherwise.
16
+ - Do not exfiltrate secrets (.env, keys, tokens, credentials).
17
+ - Destructive or irreversible shell commands require clear user intent.
18
+
19
+ ## Skills
20
+ When skill metadata appears below in <skills>...</skills>, follow the matching skill file
21
+ when the task fits.
22
+
23
+ When finished, answer the user directly without calling more tools.
24
+ """
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: pi-agent-cli-lc
3
+ Version: 0.2.0
4
+ Summary: Standard ACP agent over AgentHarness (no x.ai extensions)
5
+ Project-URL: Homepage, https://github.com/zy1233/pi-python
6
+ Project-URL: Repository, https://github.com/zy1233/pi-python
7
+ Project-URL: Issues, https://github.com/zy1233/pi-python/issues
8
+ Project-URL: Changelog, https://github.com/zy1233/pi-python/blob/main/CHANGELOG.md
9
+ Author-email: zy1233 <zy1233@users.noreply.github.com>
10
+ License-Expression: MIT
11
+ Keywords: acp,agent,ai,cli,coding-agent,llm
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: agent-client-protocol>=0.12.0
23
+ Requires-Dist: pi-agent-core-lc==0.2.0
24
+ Requires-Dist: pi-agent-harness-lc==0.2.0
25
+ Requires-Dist: pydantic>=2.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # pi-agent-cli-lc
29
+
30
+ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent over `pi-agent-harness`.
31
+
32
+ - stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
33
+ - headless one-shot: `python -m pi_agent_cli -p "..."`
34
+ - Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
35
+ - No `x.ai/*` vendor RPCs — core + harness + ACP only
36
+
37
+ Install:
38
+
39
+ ```bash
40
+ pip install pi-agent-cli-lc
41
+ # needs a LangChain provider, e.g.:
42
+ pip install pi-agent-core-lc[deepseek]
43
+ ```
44
+
45
+ See the [repository README](https://github.com/zy1233/pi-python#install) for development setup and Windows/WSL notes.
@@ -0,0 +1,15 @@
1
+ pi_agent_cli/__init__.py,sha256=4i3ERBRV_TfY_2e6hbw5FsYiwgPVg59Bi2kngFLWlWE,391
2
+ pi_agent_cli/__main__.py,sha256=_fElQvUrxctX50eQ_agP8853K7jN9zglzLtnIfDTNN0,2222
3
+ pi_agent_cli/agent.py,sha256=dna2zRao57gd3u5JE_MIHCY9CtUMyNnBw1a4Gq2xaWs,10606
4
+ pi_agent_cli/config.py,sha256=sxYc3zNNWsqmhiQIeeT8adsi9cJE67f7VjwCpIKj3WE,4916
5
+ pi_agent_cli/events.py,sha256=-guskf4IVudKQcMKZgoxAFpR4Xv9y-FF7QlmdxXLmus,4381
6
+ pi_agent_cli/factory.py,sha256=oRdlaCVE4LZVlzCcqOEMSDe7gKpBdHOgOXI4SQO3YF4,2408
7
+ pi_agent_cli/headless.py,sha256=7z8BcBVDOom5nLKpXT_DqiechXEgs9tT5t41Kskx2iQ,2593
8
+ pi_agent_cli/permissions.py,sha256=NfsjkFxCUbX24UmKuvsZCgdAH2kbuJyHjAEhZwIZBXk,1762
9
+ pi_agent_cli/prompt.py,sha256=dhUdWkmMx2sx70Z6EB1G7sU-w9d1TLSPnvZNKhobuOo,1000
10
+ pi_agent_cli/benchmarks/__init__.py,sha256=9cufUT_W5UB88Ho18zDL2kvp2pX2f7V76B5Kh200xEE,306
11
+ pi_agent_cli/benchmarks/pelican.py,sha256=KfGbPpVi9pbMFsl7ZUtMkeSaZV2u76MYJjZFjEtyKz4,2834
12
+ pi_agent_cli_lc-0.2.0.dist-info/METADATA,sha256=MCSc4VnP5py0mf2IBCal5e-uKdVoN5ScjBfZOpxxMGQ,1811
13
+ pi_agent_cli_lc-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ pi_agent_cli_lc-0.2.0.dist-info/entry_points.txt,sha256=skflnGQl1PatvwuariTijSYd6xi8PId512LvMT8idp8,60
15
+ pi_agent_cli_lc-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pi-agent-cli = pi_agent_cli.__main__:main