pi-py-sdk 0.1.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_py_agent/__init__.py +8 -0
- pi_py_agent/app.py +337 -0
- pi_py_agent/cli.py +53 -0
- pi_py_agent/py.typed +0 -0
- pi_py_agent/render.py +137 -0
- pi_py_sdk/__init__.py +104 -0
- pi_py_sdk/_discovery.py +41 -0
- pi_py_sdk/client.py +436 -0
- pi_py_sdk/config.py +51 -0
- pi_py_sdk/errors.py +37 -0
- pi_py_sdk/jsonl.py +66 -0
- pi_py_sdk/protocol.py +310 -0
- pi_py_sdk/py.typed +0 -0
- pi_py_sdk/sync.py +123 -0
- pi_py_sdk/transport.py +132 -0
- pi_py_sdk-0.1.0.dist-info/METADATA +155 -0
- pi_py_sdk-0.1.0.dist-info/RECORD +20 -0
- pi_py_sdk-0.1.0.dist-info/WHEEL +4 -0
- pi_py_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- pi_py_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
pi_py_agent/__init__.py
ADDED
pi_py_agent/app.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Interactive REPL and one-shot runner for the Python coding agent.
|
|
2
|
+
|
|
3
|
+
Built entirely on :class:`pi_py_sdk.PiAgent` — the agent loop, tools, and model calls
|
|
4
|
+
all run inside Pi via the RPC bridge.
|
|
5
|
+
|
|
6
|
+
The REPL multiplexes a single stdin source across three consumers — idle prompts,
|
|
7
|
+
mid-turn steering, and approval dialogs — using one persistent reader thread feeding a
|
|
8
|
+
single async dispatch loop. A turn runs as a background task so the loop stays free to
|
|
9
|
+
route steering/abort input while the agent streams. SIGINT aborts the active turn.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pi_py_sdk import ExtensionUIRequest, PiAgent, PiConfig, PiTimeoutError
|
|
21
|
+
|
|
22
|
+
from .render import Renderer
|
|
23
|
+
|
|
24
|
+
_HELP = """\
|
|
25
|
+
Commands (when idle):
|
|
26
|
+
/help show this help
|
|
27
|
+
/model | /models show or list models
|
|
28
|
+
/new start a fresh session
|
|
29
|
+
/state model, message count, queue modes
|
|
30
|
+
/compact compact the conversation now
|
|
31
|
+
/fork [n] list forkable messages, or fork message n
|
|
32
|
+
/clone clone the current branch into a new session
|
|
33
|
+
/exit | /quit leave
|
|
34
|
+
|
|
35
|
+
While the agent is responding, type to steer it:
|
|
36
|
+
<text> steer (delivered after the current tool call)
|
|
37
|
+
+<text> queue a follow-up (delivered after the turn)
|
|
38
|
+
/abort stop the current turn
|
|
39
|
+
Ctrl-C aborts the active turn; Ctrl-D (EOF) exits."""
|
|
40
|
+
|
|
41
|
+
_YES = {"y", "yes"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify_turn_input(line: str) -> tuple[str, str]:
|
|
45
|
+
"""Classify a line typed during an active turn into (action, text)."""
|
|
46
|
+
if line in ("/exit", "/quit"):
|
|
47
|
+
return ("exit", "")
|
|
48
|
+
if line in ("/abort", "/stop"):
|
|
49
|
+
return ("abort", "")
|
|
50
|
+
if line.startswith("+"):
|
|
51
|
+
return ("follow_up", line[1:].strip())
|
|
52
|
+
return ("steer", line)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_approval(request: ExtensionUIRequest, line: str) -> Any:
|
|
56
|
+
"""Map a typed line to the value the SDK expects for a dialog request."""
|
|
57
|
+
method = request.method
|
|
58
|
+
if method == "confirm":
|
|
59
|
+
return line.strip().lower() in _YES
|
|
60
|
+
if method == "select":
|
|
61
|
+
options = request.options or []
|
|
62
|
+
stripped = line.strip()
|
|
63
|
+
if stripped.isdigit() and int(stripped) < len(options):
|
|
64
|
+
return options[int(stripped)]
|
|
65
|
+
return None
|
|
66
|
+
if method in ("input", "editor"):
|
|
67
|
+
return line if line else None
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LineReader:
|
|
72
|
+
"""Reads stdin lines on a daemon thread into an asyncio queue.
|
|
73
|
+
|
|
74
|
+
A daemon thread (not the loop executor) is used so a blocked ``readline`` never
|
|
75
|
+
prevents interpreter exit. EOF enqueues a ``None`` sentinel.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.queue: asyncio.Queue[str | None] = asyncio.Queue()
|
|
80
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
81
|
+
self._thread: threading.Thread | None = None
|
|
82
|
+
|
|
83
|
+
def start(self) -> None:
|
|
84
|
+
self._loop = asyncio.get_event_loop()
|
|
85
|
+
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
86
|
+
self._thread.start()
|
|
87
|
+
|
|
88
|
+
def _read_loop(self) -> None:
|
|
89
|
+
while True:
|
|
90
|
+
line = sys.stdin.readline()
|
|
91
|
+
assert self._loop is not None
|
|
92
|
+
if line == "": # EOF
|
|
93
|
+
self._loop.call_soon_threadsafe(self.queue.put_nowait, None)
|
|
94
|
+
return
|
|
95
|
+
self._loop.call_soon_threadsafe(self.queue.put_nowait, line.rstrip("\n"))
|
|
96
|
+
|
|
97
|
+
async def get(self) -> str | None:
|
|
98
|
+
return await self.queue.get()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Repl:
|
|
102
|
+
def __init__(self, agent: PiAgent, *, color: bool = True) -> None:
|
|
103
|
+
self.agent = agent
|
|
104
|
+
self.renderer = Renderer(color=color)
|
|
105
|
+
self.color = color
|
|
106
|
+
self.reader = LineReader()
|
|
107
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
108
|
+
self._turn_task: asyncio.Task[None] | None = None
|
|
109
|
+
self._pending_approval: tuple[ExtensionUIRequest, asyncio.Future[Any]] | None = None
|
|
110
|
+
self._fork_list: list[dict[str, Any]] = []
|
|
111
|
+
self._exit = False
|
|
112
|
+
|
|
113
|
+
# -- output helpers ---------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _out(self, text: str) -> None:
|
|
116
|
+
sys.stdout.write(text)
|
|
117
|
+
sys.stdout.flush()
|
|
118
|
+
|
|
119
|
+
def _idle_prompt(self) -> None:
|
|
120
|
+
if not self._exit:
|
|
121
|
+
self._out("\nYou: ")
|
|
122
|
+
|
|
123
|
+
def _turn_active(self) -> bool:
|
|
124
|
+
return self._turn_task is not None and not self._turn_task.done()
|
|
125
|
+
|
|
126
|
+
# -- lifecycle --------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def run(self, initial: str | None = None) -> None:
|
|
129
|
+
self._loop = asyncio.get_event_loop()
|
|
130
|
+
self.reader.start()
|
|
131
|
+
self.agent.on_ui_request(self._approve)
|
|
132
|
+
self._install_sigint()
|
|
133
|
+
self._out(f"pi-py · {await self._model_label()} · /help for commands\n")
|
|
134
|
+
|
|
135
|
+
if initial:
|
|
136
|
+
self._out(f"You: {initial}\n")
|
|
137
|
+
self._start_turn(initial)
|
|
138
|
+
else:
|
|
139
|
+
self._idle_prompt()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
while not self._exit:
|
|
143
|
+
line = await self.reader.get()
|
|
144
|
+
if line is None: # EOF
|
|
145
|
+
break
|
|
146
|
+
await self._dispatch(line)
|
|
147
|
+
finally:
|
|
148
|
+
self._teardown()
|
|
149
|
+
|
|
150
|
+
def _install_sigint(self) -> None:
|
|
151
|
+
try:
|
|
152
|
+
assert self._loop is not None
|
|
153
|
+
self._loop.add_signal_handler(signal.SIGINT, self._on_sigint)
|
|
154
|
+
except (NotImplementedError, RuntimeError):
|
|
155
|
+
pass # e.g. Windows / non-main thread — fall back to default behavior
|
|
156
|
+
|
|
157
|
+
def _on_sigint(self) -> None:
|
|
158
|
+
if self._turn_active():
|
|
159
|
+
self._out("\n[aborting]\n")
|
|
160
|
+
asyncio.ensure_future(self.agent.abort())
|
|
161
|
+
else:
|
|
162
|
+
self._exit = True
|
|
163
|
+
self.reader.queue.put_nowait(None) # unblock the dispatch loop
|
|
164
|
+
|
|
165
|
+
def _teardown(self) -> None:
|
|
166
|
+
if self._turn_task and not self._turn_task.done():
|
|
167
|
+
self._turn_task.cancel()
|
|
168
|
+
if self._pending_approval and not self._pending_approval[1].done():
|
|
169
|
+
self._pending_approval[1].set_result(None)
|
|
170
|
+
|
|
171
|
+
# -- input dispatch ---------------------------------------------------
|
|
172
|
+
|
|
173
|
+
async def _dispatch(self, line: str) -> None:
|
|
174
|
+
# 1) An open approval dialog claims the next line.
|
|
175
|
+
if self._pending_approval is not None:
|
|
176
|
+
request, future = self._pending_approval
|
|
177
|
+
if not future.done():
|
|
178
|
+
future.set_result(parse_approval(request, line))
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# 2) During a turn, input steers the agent.
|
|
182
|
+
if self._turn_active():
|
|
183
|
+
await self._route_steering(line)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# 3) Idle: blank, command, or a new prompt.
|
|
187
|
+
stripped = line.strip()
|
|
188
|
+
if not stripped:
|
|
189
|
+
self._idle_prompt()
|
|
190
|
+
return
|
|
191
|
+
if stripped.startswith("/"):
|
|
192
|
+
if await self._command(stripped):
|
|
193
|
+
self._exit = True
|
|
194
|
+
return
|
|
195
|
+
self._idle_prompt()
|
|
196
|
+
return
|
|
197
|
+
self._start_turn(stripped)
|
|
198
|
+
|
|
199
|
+
async def _route_steering(self, line: str) -> None:
|
|
200
|
+
stripped = line.strip()
|
|
201
|
+
if not stripped:
|
|
202
|
+
return
|
|
203
|
+
action, text = classify_turn_input(stripped)
|
|
204
|
+
if action == "exit":
|
|
205
|
+
await self.agent.abort()
|
|
206
|
+
self._exit = True
|
|
207
|
+
elif action == "abort":
|
|
208
|
+
await self.agent.abort()
|
|
209
|
+
self._out("[aborting]\n")
|
|
210
|
+
elif action == "follow_up" and text:
|
|
211
|
+
await self.agent.follow_up(text)
|
|
212
|
+
self._out(f"[queued follow-up: {text}]\n")
|
|
213
|
+
elif action == "steer":
|
|
214
|
+
await self.agent.steer(text)
|
|
215
|
+
self._out(f"[steered: {text}]\n")
|
|
216
|
+
|
|
217
|
+
# -- turns ------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _start_turn(self, message: str) -> None:
|
|
220
|
+
self._turn_task = asyncio.ensure_future(self._run_turn(message))
|
|
221
|
+
self._turn_task.add_done_callback(self._after_turn)
|
|
222
|
+
|
|
223
|
+
async def _run_turn(self, message: str) -> None:
|
|
224
|
+
try:
|
|
225
|
+
async for event in self.agent.prompt_stream(message):
|
|
226
|
+
self.renderer.handle(event)
|
|
227
|
+
except asyncio.CancelledError:
|
|
228
|
+
raise
|
|
229
|
+
except PiTimeoutError as exc:
|
|
230
|
+
self._out(f"\n[timeout] {exc}\n")
|
|
231
|
+
except Exception as exc: # keep the REPL alive on agent errors
|
|
232
|
+
self._out(f"\n[error] {exc}\n")
|
|
233
|
+
|
|
234
|
+
def _after_turn(self, task: asyncio.Task[None]) -> None:
|
|
235
|
+
if not task.cancelled():
|
|
236
|
+
task.exception() # retrieve to avoid "never retrieved" warnings
|
|
237
|
+
self._idle_prompt()
|
|
238
|
+
|
|
239
|
+
# -- approvals --------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
async def _approve(self, request: ExtensionUIRequest) -> Any:
|
|
242
|
+
assert self._loop is not None
|
|
243
|
+
future: asyncio.Future[Any] = self._loop.create_future()
|
|
244
|
+
self._pending_approval = (request, future)
|
|
245
|
+
self._print_approval_prompt(request)
|
|
246
|
+
try:
|
|
247
|
+
return await future
|
|
248
|
+
finally:
|
|
249
|
+
self._pending_approval = None
|
|
250
|
+
|
|
251
|
+
def _print_approval_prompt(self, request: ExtensionUIRequest) -> None:
|
|
252
|
+
self.renderer._break() # ensure we start on a fresh line
|
|
253
|
+
if request.method == "confirm":
|
|
254
|
+
self._out(f"\n[approve] {request.title or 'Confirm'}? [y/N] ")
|
|
255
|
+
elif request.method == "select":
|
|
256
|
+
self._out(f"\n{request.title or 'Choose'}:\n")
|
|
257
|
+
for i, option in enumerate(request.options or []):
|
|
258
|
+
self._out(f" {i}) {option}\n")
|
|
259
|
+
self._out("[select] number: ")
|
|
260
|
+
elif request.method in ("input", "editor"):
|
|
261
|
+
self._out(f"\n[{request.method}] {request.title or 'Value'}: ")
|
|
262
|
+
|
|
263
|
+
# -- commands ---------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
async def _model_label(self) -> str:
|
|
266
|
+
state = await self.agent.get_state()
|
|
267
|
+
model = state.get("model") or {}
|
|
268
|
+
return f"{model.get('provider')}/{model.get('id')}"
|
|
269
|
+
|
|
270
|
+
async def _command(self, line: str) -> bool:
|
|
271
|
+
"""Handle a slash command. Return True to exit the REPL."""
|
|
272
|
+
parts = line.split()
|
|
273
|
+
cmd, args = parts[0].lower(), parts[1:]
|
|
274
|
+
if cmd in ("/exit", "/quit"):
|
|
275
|
+
return True
|
|
276
|
+
if cmd == "/help":
|
|
277
|
+
self._out(_HELP + "\n")
|
|
278
|
+
elif cmd == "/model":
|
|
279
|
+
self._out(await self._model_label() + "\n")
|
|
280
|
+
elif cmd == "/models":
|
|
281
|
+
for m in await self.agent.get_available_models():
|
|
282
|
+
self._out(f" {m.get('provider')}/{m.get('id')}\n")
|
|
283
|
+
elif cmd == "/new":
|
|
284
|
+
await self.agent.new_session()
|
|
285
|
+
self._out("[new session]\n")
|
|
286
|
+
elif cmd == "/state":
|
|
287
|
+
st = await self.agent.get_state()
|
|
288
|
+
self._out(
|
|
289
|
+
f"model={await self._model_label()} thinking={st.get('thinkingLevel')} "
|
|
290
|
+
f"messages={st.get('messageCount')} steering={st.get('steeringMode')} "
|
|
291
|
+
f"follow-up={st.get('followUpMode')}\n"
|
|
292
|
+
)
|
|
293
|
+
elif cmd == "/compact":
|
|
294
|
+
self._out("[compacting…]\n")
|
|
295
|
+
await self.agent.compact()
|
|
296
|
+
self._out("[compacted]\n")
|
|
297
|
+
elif cmd == "/clone":
|
|
298
|
+
result = await self.agent.clone()
|
|
299
|
+
self._out("[cloned]\n" if not result.get("cancelled") else "[clone cancelled]\n")
|
|
300
|
+
elif cmd == "/fork":
|
|
301
|
+
await self._fork(args)
|
|
302
|
+
else:
|
|
303
|
+
self._out(f"unknown command: {cmd} (try /help)\n")
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
async def _fork(self, args: list[str]) -> None:
|
|
307
|
+
if args and args[0].isdigit():
|
|
308
|
+
index = int(args[0])
|
|
309
|
+
if not self._fork_list:
|
|
310
|
+
self._fork_list = await self.agent.get_fork_messages()
|
|
311
|
+
if 0 <= index < len(self._fork_list):
|
|
312
|
+
await self.agent.fork(self._fork_list[index]["entryId"])
|
|
313
|
+
self._out(f"[forked at message {index}]\n")
|
|
314
|
+
else:
|
|
315
|
+
self._out(f"no message {index}; run /fork to list\n")
|
|
316
|
+
return
|
|
317
|
+
self._fork_list = await self.agent.get_fork_messages()
|
|
318
|
+
if not self._fork_list:
|
|
319
|
+
self._out("[no forkable messages]\n")
|
|
320
|
+
return
|
|
321
|
+
self._out("Forkable messages (use /fork <n>):\n")
|
|
322
|
+
for i, msg in enumerate(self._fork_list):
|
|
323
|
+
text = (msg.get("text") or "").replace("\n", " ")
|
|
324
|
+
self._out(f" {i}) {text[:80]}\n")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def run_repl(config: PiConfig, *, color: bool = True, initial: str | None = None) -> None:
|
|
328
|
+
async with PiAgent(config=config) as agent:
|
|
329
|
+
await Repl(agent, color=color).run(initial=initial)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def run_once(config: PiConfig, message: str, *, color: bool = True) -> None:
|
|
333
|
+
renderer = Renderer(color=color)
|
|
334
|
+
async with PiAgent(config=config) as agent:
|
|
335
|
+
async for event in agent.prompt_stream(message):
|
|
336
|
+
renderer.handle(event)
|
|
337
|
+
print()
|
pi_py_agent/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`pi-py` command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from pi_py_sdk import PiConfig
|
|
10
|
+
|
|
11
|
+
from .app import run_once, run_repl
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="pi-py",
|
|
17
|
+
description="A Python coding agent built on the Pi RPC bridge.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("prompt", nargs="*", help="optional prompt; omit for an interactive REPL")
|
|
20
|
+
parser.add_argument("--model", help="model id, e.g. anthropic/claude-sonnet-4-20250514")
|
|
21
|
+
parser.add_argument("--provider", help="provider override")
|
|
22
|
+
parser.add_argument("--cwd", help="working directory for the agent")
|
|
23
|
+
parser.add_argument("--session-dir", help="directory for session persistence")
|
|
24
|
+
parser.add_argument("--no-session", action="store_true", help="disable session persistence")
|
|
25
|
+
parser.add_argument("--print", action="store_true", help="one-shot: print the response and exit")
|
|
26
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
27
|
+
return parser
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None) -> int:
|
|
31
|
+
args = build_parser().parse_args(argv)
|
|
32
|
+
config = PiConfig(
|
|
33
|
+
model=args.model,
|
|
34
|
+
provider=args.provider,
|
|
35
|
+
cwd=args.cwd,
|
|
36
|
+
session_dir=args.session_dir,
|
|
37
|
+
no_session=args.no_session,
|
|
38
|
+
)
|
|
39
|
+
color = sys.stdout.isatty() and not args.no_color
|
|
40
|
+
prompt = " ".join(args.prompt).strip()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if prompt and args.print:
|
|
44
|
+
asyncio.run(run_once(config, prompt, color=color))
|
|
45
|
+
else:
|
|
46
|
+
asyncio.run(run_repl(config, color=color, initial=prompt or None))
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
return 130
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
raise SystemExit(main())
|
pi_py_agent/py.typed
ADDED
|
File without changes
|
pi_py_agent/render.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Render streamed agent events to a terminal.
|
|
2
|
+
|
|
3
|
+
Pure and testable: construct with a ``write`` callable and a ``color`` flag, then feed
|
|
4
|
+
it :class:`~pi_py_sdk.Event` objects. State is tracked only to insert sensible line
|
|
5
|
+
breaks between assistant text, thinking, and tool activity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from pi_py_sdk import Event
|
|
13
|
+
|
|
14
|
+
_RESET = "\033[0m"
|
|
15
|
+
_DIM = "\033[2m"
|
|
16
|
+
_CYAN = "\033[36m"
|
|
17
|
+
_YELLOW = "\033[33m"
|
|
18
|
+
_GREEN = "\033[32m"
|
|
19
|
+
_RED = "\033[31m"
|
|
20
|
+
|
|
21
|
+
# Keys whose value best summarizes a tool call, in priority order.
|
|
22
|
+
_ARG_KEYS = ("command", "path", "file_path", "pattern", "query", "url")
|
|
23
|
+
# Keys that typically carry a tool's textual result, in priority order.
|
|
24
|
+
_RESULT_KEYS = ("output", "stdout", "content", "text", "result", "message")
|
|
25
|
+
|
|
26
|
+
_PREVIEW_LINES = 8
|
|
27
|
+
_PREVIEW_WIDTH = 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _summarize_args(args: Any, limit: int = 80) -> str:
|
|
31
|
+
if isinstance(args, dict):
|
|
32
|
+
for key in _ARG_KEYS:
|
|
33
|
+
if key in args and args[key] is not None:
|
|
34
|
+
value = str(args[key]).replace("\n", " ")
|
|
35
|
+
return value if len(value) <= limit else value[: limit - 1] + "…"
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _result_text(result: Any) -> str:
|
|
40
|
+
"""Best-effort extraction of human-readable text from a tool result."""
|
|
41
|
+
if result is None:
|
|
42
|
+
return ""
|
|
43
|
+
if isinstance(result, str):
|
|
44
|
+
return result
|
|
45
|
+
if isinstance(result, dict):
|
|
46
|
+
for key in _RESULT_KEYS:
|
|
47
|
+
value = result.get(key)
|
|
48
|
+
if isinstance(value, str) and value.strip():
|
|
49
|
+
return value
|
|
50
|
+
if isinstance(value, list): # content-block array
|
|
51
|
+
parts = [
|
|
52
|
+
item["text"]
|
|
53
|
+
for item in value
|
|
54
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str)
|
|
55
|
+
]
|
|
56
|
+
if parts:
|
|
57
|
+
return "\n".join(parts)
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Renderer:
|
|
62
|
+
def __init__(self, write: Callable[[str], Any] | None = None, *, color: bool = True):
|
|
63
|
+
if write is None:
|
|
64
|
+
import sys
|
|
65
|
+
|
|
66
|
+
write = sys.stdout.write
|
|
67
|
+
self._write = write
|
|
68
|
+
self.color = color
|
|
69
|
+
self._mode: str | None = None # "text" | "thinking" | None
|
|
70
|
+
|
|
71
|
+
def _c(self, code: str, text: str) -> str:
|
|
72
|
+
return f"{code}{text}{_RESET}" if self.color else text
|
|
73
|
+
|
|
74
|
+
def handle(self, event: Event) -> None:
|
|
75
|
+
kind = event.type
|
|
76
|
+
if kind == "message_update":
|
|
77
|
+
self._on_message_update(event)
|
|
78
|
+
elif kind == "tool_execution_start":
|
|
79
|
+
self._break()
|
|
80
|
+
name = getattr(event, "toolName", None) or "tool"
|
|
81
|
+
summary = _summarize_args(getattr(event, "args", None))
|
|
82
|
+
line = self._c(_CYAN, f"→ {name}") + (f" {summary}" if summary else "")
|
|
83
|
+
self._write(line + "\n")
|
|
84
|
+
elif kind == "tool_execution_end":
|
|
85
|
+
mark = self._c(_RED, "✗") if getattr(event, "isError", False) else self._c(_GREEN, "✓")
|
|
86
|
+
self._write(f" {mark}\n")
|
|
87
|
+
self._write_result_preview(getattr(event, "result", None))
|
|
88
|
+
elif kind == "queue_update":
|
|
89
|
+
steering = len(getattr(event, "steering", None) or [])
|
|
90
|
+
follow_up = len(getattr(event, "followUp", None) or [])
|
|
91
|
+
if steering or follow_up:
|
|
92
|
+
self._break()
|
|
93
|
+
self._write(self._c(_DIM, f"[queued: steering={steering} follow-up={follow_up}]") + "\n")
|
|
94
|
+
elif kind == "auto_retry_start":
|
|
95
|
+
self._break()
|
|
96
|
+
a, m = getattr(event, "attempt", "?"), getattr(event, "maxAttempts", "?")
|
|
97
|
+
self._write(self._c(_YELLOW, f"[retrying {a}/{m}…]") + "\n")
|
|
98
|
+
elif kind == "compaction_start":
|
|
99
|
+
self._break()
|
|
100
|
+
self._write(self._c(_YELLOW, "[compacting context…]") + "\n")
|
|
101
|
+
elif kind == "agent_end":
|
|
102
|
+
self._break()
|
|
103
|
+
|
|
104
|
+
def _on_message_update(self, event: Event) -> None:
|
|
105
|
+
ame = getattr(event, "assistantMessageEvent", None)
|
|
106
|
+
if ame is None or not getattr(ame, "delta", None):
|
|
107
|
+
return
|
|
108
|
+
if ame.type == "text_delta":
|
|
109
|
+
if self._mode != "text":
|
|
110
|
+
if self._mode == "thinking":
|
|
111
|
+
self._write("\n")
|
|
112
|
+
self._mode = "text"
|
|
113
|
+
self._write(ame.delta)
|
|
114
|
+
elif ame.type == "thinking_delta":
|
|
115
|
+
if self._mode != "thinking":
|
|
116
|
+
if self._mode == "text":
|
|
117
|
+
self._write("\n")
|
|
118
|
+
self._write(self._c(_DIM, "(thinking) "))
|
|
119
|
+
self._mode = "thinking"
|
|
120
|
+
self._write(self._c(_DIM, ame.delta))
|
|
121
|
+
|
|
122
|
+
def _write_result_preview(self, result: Any) -> None:
|
|
123
|
+
text = _result_text(result).strip("\n")
|
|
124
|
+
if not text:
|
|
125
|
+
return
|
|
126
|
+
lines = text.split("\n")
|
|
127
|
+
for line in lines[:_PREVIEW_LINES]:
|
|
128
|
+
clipped = line if len(line) <= _PREVIEW_WIDTH else line[: _PREVIEW_WIDTH - 1] + "…"
|
|
129
|
+
self._write(self._c(_DIM, f" │ {clipped}") + "\n")
|
|
130
|
+
if len(lines) > _PREVIEW_LINES:
|
|
131
|
+
extra = len(lines) - _PREVIEW_LINES
|
|
132
|
+
self._write(self._c(_DIM, f" │ … (+{extra} more lines)") + "\n")
|
|
133
|
+
|
|
134
|
+
def _break(self) -> None:
|
|
135
|
+
if self._mode is not None:
|
|
136
|
+
self._write("\n")
|
|
137
|
+
self._mode = None
|
pi_py_sdk/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""pi-py-sdk: Python SDK for the Pi coding agent over its RPC bridge.
|
|
2
|
+
|
|
3
|
+
Drives ``pi --mode rpc`` (the well-tested ``pi-agent-core`` runtime) as a subprocess,
|
|
4
|
+
exposing an async Python API. No agent logic is reimplemented in Python.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .client import PiAgent, UiHandler, UiResult
|
|
10
|
+
from .config import PiConfig
|
|
11
|
+
from .sync import PiAgentSync
|
|
12
|
+
from .errors import (
|
|
13
|
+
PiCommandError,
|
|
14
|
+
PiError,
|
|
15
|
+
PiNotStartedError,
|
|
16
|
+
PiProcessError,
|
|
17
|
+
PiTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
from .protocol import (
|
|
20
|
+
DIALOG_METHODS,
|
|
21
|
+
AgentEndEvent,
|
|
22
|
+
AgentStartEvent,
|
|
23
|
+
AssistantMessage,
|
|
24
|
+
AssistantMessageEvent,
|
|
25
|
+
AutoRetryEndEvent,
|
|
26
|
+
AutoRetryStartEvent,
|
|
27
|
+
BashExecutionMessage,
|
|
28
|
+
CompactionEndEvent,
|
|
29
|
+
CompactionStartEvent,
|
|
30
|
+
Event,
|
|
31
|
+
ExtensionErrorEvent,
|
|
32
|
+
ExtensionUIRequest,
|
|
33
|
+
ImageContent,
|
|
34
|
+
MessageEndEvent,
|
|
35
|
+
MessageStartEvent,
|
|
36
|
+
MessageUpdateEvent,
|
|
37
|
+
QueueUpdateEvent,
|
|
38
|
+
Response,
|
|
39
|
+
SessionInfoChangedEvent,
|
|
40
|
+
TextContent,
|
|
41
|
+
ThinkingContent,
|
|
42
|
+
ThinkingLevelChangedEvent,
|
|
43
|
+
ToolCall,
|
|
44
|
+
ToolExecutionEndEvent,
|
|
45
|
+
ToolExecutionStartEvent,
|
|
46
|
+
ToolExecutionUpdateEvent,
|
|
47
|
+
ToolResultMessage,
|
|
48
|
+
TurnEndEvent,
|
|
49
|
+
UserMessage,
|
|
50
|
+
message_text,
|
|
51
|
+
parse_event,
|
|
52
|
+
parse_message,
|
|
53
|
+
parse_messages,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"PiAgent",
|
|
60
|
+
"PiAgentSync",
|
|
61
|
+
"PiConfig",
|
|
62
|
+
"UiHandler",
|
|
63
|
+
"UiResult",
|
|
64
|
+
"PiError",
|
|
65
|
+
"PiNotStartedError",
|
|
66
|
+
"PiProcessError",
|
|
67
|
+
"PiTimeoutError",
|
|
68
|
+
"PiCommandError",
|
|
69
|
+
"Event",
|
|
70
|
+
"AgentStartEvent",
|
|
71
|
+
"AgentEndEvent",
|
|
72
|
+
"AssistantMessageEvent",
|
|
73
|
+
"MessageStartEvent",
|
|
74
|
+
"MessageUpdateEvent",
|
|
75
|
+
"MessageEndEvent",
|
|
76
|
+
"TurnEndEvent",
|
|
77
|
+
"ToolExecutionStartEvent",
|
|
78
|
+
"ToolExecutionUpdateEvent",
|
|
79
|
+
"ToolExecutionEndEvent",
|
|
80
|
+
"QueueUpdateEvent",
|
|
81
|
+
"CompactionStartEvent",
|
|
82
|
+
"CompactionEndEvent",
|
|
83
|
+
"AutoRetryStartEvent",
|
|
84
|
+
"AutoRetryEndEvent",
|
|
85
|
+
"SessionInfoChangedEvent",
|
|
86
|
+
"ThinkingLevelChangedEvent",
|
|
87
|
+
"ExtensionErrorEvent",
|
|
88
|
+
"ExtensionUIRequest",
|
|
89
|
+
"DIALOG_METHODS",
|
|
90
|
+
"Response",
|
|
91
|
+
"parse_event",
|
|
92
|
+
"TextContent",
|
|
93
|
+
"ThinkingContent",
|
|
94
|
+
"ImageContent",
|
|
95
|
+
"ToolCall",
|
|
96
|
+
"UserMessage",
|
|
97
|
+
"AssistantMessage",
|
|
98
|
+
"ToolResultMessage",
|
|
99
|
+
"BashExecutionMessage",
|
|
100
|
+
"parse_message",
|
|
101
|
+
"parse_messages",
|
|
102
|
+
"message_text",
|
|
103
|
+
"__version__",
|
|
104
|
+
]
|
pi_py_sdk/_discovery.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Locate the Pi runtime.
|
|
2
|
+
|
|
3
|
+
Resolution order (confirmed project decision):
|
|
4
|
+
1. An explicit ``bin`` (path or command name), if provided.
|
|
5
|
+
2. ``pi`` discovered on ``PATH``.
|
|
6
|
+
3. ``npx --yes @earendil-works/pi-coding-agent@<pinned>`` as a fallback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
|
|
13
|
+
from .errors import PiProcessError
|
|
14
|
+
|
|
15
|
+
#: Pinned Pi package + version this SDK was developed and tested against.
|
|
16
|
+
PINNED_PI_PACKAGE = "@earendil-works/pi-coding-agent"
|
|
17
|
+
PINNED_PI_VERSION = "0.79.9"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_pi_command(bin: str | None = None) -> list[str]:
|
|
21
|
+
"""Return the argv prefix that launches Pi (before ``--mode rpc`` args).
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
PiProcessError: if neither an explicit/`PATH` binary nor ``npx`` is available.
|
|
25
|
+
"""
|
|
26
|
+
if bin:
|
|
27
|
+
resolved = shutil.which(bin) or bin
|
|
28
|
+
return [resolved]
|
|
29
|
+
|
|
30
|
+
on_path = shutil.which("pi")
|
|
31
|
+
if on_path:
|
|
32
|
+
return [on_path]
|
|
33
|
+
|
|
34
|
+
npx = shutil.which("npx")
|
|
35
|
+
if npx:
|
|
36
|
+
return [npx, "--yes", f"{PINNED_PI_PACKAGE}@{PINNED_PI_VERSION}"]
|
|
37
|
+
|
|
38
|
+
raise PiProcessError(
|
|
39
|
+
"Could not find the `pi` binary on PATH and `npx` is unavailable. "
|
|
40
|
+
f"Install it with `npm i -g {PINNED_PI_PACKAGE}` (or ensure Node/npx is installed)."
|
|
41
|
+
)
|