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.
- pi_agent_cli/__init__.py +19 -0
- pi_agent_cli/__main__.py +81 -0
- pi_agent_cli/agent.py +297 -0
- pi_agent_cli/benchmarks/__init__.py +15 -0
- pi_agent_cli/benchmarks/pelican.py +90 -0
- pi_agent_cli/config.py +146 -0
- pi_agent_cli/events.py +141 -0
- pi_agent_cli/factory.py +70 -0
- pi_agent_cli/headless.py +79 -0
- pi_agent_cli/permissions.py +52 -0
- pi_agent_cli/prompt.py +24 -0
- pi_agent_cli_lc-0.2.0.dist-info/METADATA +45 -0
- pi_agent_cli_lc-0.2.0.dist-info/RECORD +15 -0
- pi_agent_cli_lc-0.2.0.dist-info/WHEEL +4 -0
- pi_agent_cli_lc-0.2.0.dist-info/entry_points.txt +2 -0
pi_agent_cli/__init__.py
ADDED
|
@@ -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
|
+
]
|
pi_agent_cli/__main__.py
ADDED
|
@@ -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
|
pi_agent_cli/factory.py
ADDED
|
@@ -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
|
pi_agent_cli/headless.py
ADDED
|
@@ -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,,
|