agentabi 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {agentabi-0.2.2/src/agentabi.egg-info → agentabi-0.3.0}/PKG-INFO +3 -2
  2. {agentabi-0.2.2 → agentabi-0.3.0}/README.md +2 -1
  3. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/__init__.py +1 -1
  4. agentabi-0.3.0/src/agentabi/providers/pi_native.py +347 -0
  5. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/registry.py +2 -0
  6. {agentabi-0.2.2 → agentabi-0.3.0/src/agentabi.egg-info}/PKG-INFO +3 -2
  7. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi.egg-info/SOURCES.txt +1 -0
  8. {agentabi-0.2.2 → agentabi-0.3.0}/LICENSE +0 -0
  9. {agentabi-0.2.2 → agentabi-0.3.0}/pyproject.toml +0 -0
  10. {agentabi-0.2.2 → agentabi-0.3.0}/setup.cfg +0 -0
  11. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/auto_detect.py +0 -0
  12. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/middleware/__init__.py +0 -0
  13. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/middleware/logging.py +0 -0
  14. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/middleware/timeout.py +0 -0
  15. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/middleware/usage.py +0 -0
  16. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/__init__.py +0 -0
  17. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/base.py +0 -0
  18. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/claude_native.py +0 -0
  19. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/claude_sdk.py +0 -0
  20. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/codex_native.py +0 -0
  21. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/codex_sdk.py +0 -0
  22. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/gemini_native.py +0 -0
  23. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/gemini_sdk.py +0 -0
  24. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/providers/opencode_native.py +0 -0
  25. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/py.typed +0 -0
  26. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/session.py +0 -0
  27. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/__init__.py +0 -0
  28. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/__init__.py +0 -0
  29. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/capabilities.py +0 -0
  30. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/events.py +0 -0
  31. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/helpers.py +0 -0
  32. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/permissions.py +0 -0
  33. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/session.py +0 -0
  34. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/task.py +0 -0
  35. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi/types/ir/type_guards.py +0 -0
  36. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi.egg-info/dependency_links.txt +0 -0
  37. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi.egg-info/requires.txt +0 -0
  38. {agentabi-0.2.2 → agentabi-0.3.0}/src/agentabi.egg-info/top_level.txt +0 -0
  39. {agentabi-0.2.2 → agentabi-0.3.0}/tests/test_ir_types.py +0 -0
  40. {agentabi-0.2.2 → agentabi-0.3.0}/tests/test_middleware.py +0 -0
  41. {agentabi-0.2.2 → agentabi-0.3.0}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentabi
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Unified async Python API for driving coding agent CLIs — Claude Code, Codex, Gemini CLI, and more
5
5
  Author-email: Peng Ding <pding.dp@foxmail.com>
6
6
  License-Expression: MIT
@@ -67,6 +67,7 @@ One interface. Any coding agent.
67
67
  | [Codex](https://github.com/openai/codex) | OpenAI | Implemented |
68
68
  | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google | Implemented |
69
69
  | [OpenCode](https://opencode.ai) | Open Source | Implemented |
70
+ | [Pi](https://pi.dev/) | Open Source | Implemented |
70
71
 
71
72
  ## Installation
72
73
 
@@ -83,7 +84,7 @@ pip install agentabi[gemini] # Gemini CLI SDK support
83
84
  pip install agentabi[all] # All optional SDKs
84
85
  ```
85
86
 
86
- > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`).
87
+ > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`, `pi`).
87
88
 
88
89
  ## Quick Start
89
90
 
@@ -21,6 +21,7 @@ One interface. Any coding agent.
21
21
  | [Codex](https://github.com/openai/codex) | OpenAI | Implemented |
22
22
  | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google | Implemented |
23
23
  | [OpenCode](https://opencode.ai) | Open Source | Implemented |
24
+ | [Pi](https://pi.dev/) | Open Source | Implemented |
24
25
 
25
26
  ## Installation
26
27
 
@@ -37,7 +38,7 @@ pip install agentabi[gemini] # Gemini CLI SDK support
37
38
  pip install agentabi[all] # All optional SDKs
38
39
  ```
39
40
 
40
- > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`).
41
+ > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`, `pi`).
41
42
 
42
43
  ## Quick Start
43
44
 
@@ -69,7 +69,7 @@ from .types.ir import (
69
69
  UsageEvent,
70
70
  )
71
71
 
72
- __version__ = "0.2.2"
72
+ __version__ = "0.3.0"
73
73
 
74
74
  __all__ = [
75
75
  # Consumer API
@@ -0,0 +1,347 @@
1
+ """
2
+ agentabi - Pi Coding Agent Native Provider
3
+
4
+ Native subprocess provider for Pi CLI.
5
+ Runs `pi --print --mode json <prompt>` and parses JSONL output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import shutil
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any
16
+
17
+ from ..types.ir.capabilities import AgentCapabilities
18
+ from ..types.ir.events import (
19
+ IREvent,
20
+ MessageDeltaEvent,
21
+ MessageEndEvent,
22
+ MessageStartEvent,
23
+ SessionEndEvent,
24
+ SessionStartEvent,
25
+ ToolResultEvent,
26
+ ToolUseEvent,
27
+ UsageEvent,
28
+ UsageInfo,
29
+ )
30
+ from ..types.ir.session import SessionResult
31
+ from ..types.ir.task import TaskConfig
32
+ from .base import collect_subprocess_errors
33
+
34
+
35
+ class PiNativeProvider:
36
+ """Native subprocess provider for Pi coding agent CLI.
37
+
38
+ Runs `pi --print --mode json <prompt>` as a subprocess
39
+ and parses JSONL events into IR events.
40
+
41
+ Pi CLI JSONL event types:
42
+ - session — session metadata (id, cwd)
43
+ - agent_start — agent begins processing (skipped, no IR mapping)
44
+ - turn_start — new LLM turn begins
45
+ - message_start — message begins (user messages skipped)
46
+ - message_update — streaming delta (text_delta mapped;
47
+ thinking_start/delta/end skipped, no IR type)
48
+ - message_end — message completed with full content + usage
49
+ - tool_execution_start — tool call begins
50
+ - tool_execution_end — tool call completed with result
51
+ - turn_end — turn ends with usage stats
52
+ - agent_end — agent finishes, contains full message history
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ self._pending_text: list[str] = []
57
+
58
+ @staticmethod
59
+ def is_available() -> bool:
60
+ """Check if `pi` CLI is available."""
61
+ return shutil.which("pi") is not None
62
+
63
+ def capabilities(self) -> AgentCapabilities:
64
+ """Declare Pi capabilities."""
65
+ return {
66
+ "name": "Pi",
67
+ "agent_type": "pi",
68
+ "supports_streaming": True,
69
+ "supports_mcp": False,
70
+ "supports_session_resume": True,
71
+ "supports_system_prompt": True,
72
+ "supports_tool_filtering": True,
73
+ "supports_permissions": False,
74
+ "supports_multi_turn": True,
75
+ "transport": "subprocess",
76
+ }
77
+
78
+ async def stream(self, task: TaskConfig) -> AsyncIterator[IREvent]:
79
+ """Run task via pi CLI and yield IR events."""
80
+ cmd = self._build_command(task)
81
+ merged_env = {**os.environ, **(task.get("env") or {})}
82
+
83
+ proc = await asyncio.create_subprocess_exec(
84
+ *cmd,
85
+ stdout=asyncio.subprocess.PIPE,
86
+ stderr=asyncio.subprocess.PIPE,
87
+ env=merged_env,
88
+ cwd=task.get("working_dir"),
89
+ )
90
+
91
+ timed_out = False
92
+ timeout = task.get("timeout")
93
+ kill_task: asyncio.Task[None] | None = None
94
+
95
+ if timeout is not None and timeout > 0:
96
+
97
+ async def _kill_after(secs: float) -> None:
98
+ await asyncio.sleep(secs)
99
+ nonlocal timed_out
100
+ timed_out = True
101
+ proc.kill()
102
+
103
+ kill_task = asyncio.create_task(_kill_after(timeout))
104
+
105
+ try:
106
+ assert proc.stdout is not None
107
+ async for line_bytes in proc.stdout:
108
+ line = line_bytes.decode().rstrip("\n").rstrip("\r")
109
+ if not line:
110
+ continue
111
+ try:
112
+ raw = json.loads(line)
113
+ except json.JSONDecodeError:
114
+ continue
115
+ for event in self._parse_event(raw):
116
+ yield event
117
+
118
+ await proc.wait()
119
+ for err_event in await collect_subprocess_errors(
120
+ proc, timed_out=timed_out, timeout_seconds=timeout
121
+ ):
122
+ yield err_event
123
+ finally:
124
+ if kill_task is not None:
125
+ kill_task.cancel()
126
+ if proc.returncode is None:
127
+ proc.terminate()
128
+ try:
129
+ await asyncio.wait_for(proc.wait(), timeout=5)
130
+ except asyncio.TimeoutError:
131
+ proc.kill()
132
+ await proc.wait()
133
+
134
+ async def run(self, task: TaskConfig) -> SessionResult:
135
+ """Run task and return aggregated result."""
136
+ from .base import default_run
137
+
138
+ return await default_run(self, task)
139
+
140
+ # ========== Private: command building ==========
141
+
142
+ @staticmethod
143
+ def _build_command(task: TaskConfig) -> list[str]:
144
+ """Convert TaskConfig to pi CLI arguments."""
145
+ cmd = ["pi", "--print", "--mode", "json"]
146
+
147
+ if "model" in task:
148
+ cmd.extend(["--model", task["model"]])
149
+
150
+ if "system_prompt" in task:
151
+ cmd.extend(["--system-prompt", task["system_prompt"]])
152
+ if "append_system_prompt" in task:
153
+ cmd.extend(["--append-system-prompt", task["append_system_prompt"]])
154
+
155
+ if "session_id" in task and task.get("resume"):
156
+ cmd.extend(["--session", task["session_id"]])
157
+
158
+ if "allowed_tools" in task:
159
+ cmd.extend(["--tools", ",".join(task["allowed_tools"])])
160
+ elif task.get("permissions") and "allowed_tools" in task["permissions"]:
161
+ cmd.extend(["--tools", ",".join(task["permissions"]["allowed_tools"])])
162
+
163
+ if "disallowed_tools" in task:
164
+ cmd.extend(["--exclude-tools", ",".join(task["disallowed_tools"])])
165
+ elif task.get("permissions") and "disallowed_tools" in task["permissions"]:
166
+ cmd.extend(
167
+ [
168
+ "--exclude-tools",
169
+ ",".join(task["permissions"]["disallowed_tools"]),
170
+ ]
171
+ )
172
+
173
+ cmd.append(task["prompt"])
174
+ return cmd
175
+
176
+ # ========== Private: event parsing ==========
177
+
178
+ def _parse_event(self, raw: dict[str, Any]) -> list[IREvent]:
179
+ """Convert a Pi CLI JSONL event to IR events."""
180
+ event_type = raw.get("type")
181
+
182
+ if event_type == "session":
183
+ return self._handle_session(raw)
184
+ elif event_type == "turn_start":
185
+ return self._handle_turn_start()
186
+ elif event_type == "message_update":
187
+ return self._handle_message_update(raw)
188
+ elif event_type == "message_end":
189
+ return self._handle_message_end(raw)
190
+ elif event_type == "tool_execution_start":
191
+ return self._handle_tool_start(raw)
192
+ elif event_type == "tool_execution_end":
193
+ return self._handle_tool_end(raw)
194
+ elif event_type == "turn_end":
195
+ return self._handle_turn_end(raw)
196
+ elif event_type == "agent_end":
197
+ return self._handle_agent_end(raw)
198
+ return []
199
+
200
+ @staticmethod
201
+ def _handle_session(raw: dict[str, Any]) -> list[IREvent]:
202
+ """Handle session event — emits SessionStartEvent."""
203
+ start: SessionStartEvent = {
204
+ "type": "session_start",
205
+ "session_id": raw.get("id", ""),
206
+ "agent": "pi",
207
+ }
208
+ cwd = raw.get("cwd")
209
+ if cwd:
210
+ start["working_dir"] = cwd
211
+ return [start]
212
+
213
+ def _handle_turn_start(self) -> list[IREvent]:
214
+ """Handle turn_start — emits MessageStartEvent.
215
+
216
+ Also defensively clears pending text to prevent bleed from
217
+ a previous turn if message_end was missed.
218
+ """
219
+ self._pending_text = []
220
+ msg_start: MessageStartEvent = {
221
+ "type": "message_start",
222
+ "role": "assistant",
223
+ }
224
+ return [msg_start]
225
+
226
+ def _handle_message_update(self, raw: dict[str, Any]) -> list[IREvent]:
227
+ """Handle message_update — emits MessageDeltaEvent for text deltas."""
228
+ assistant_event = raw.get("assistantMessageEvent", {})
229
+ event_subtype = assistant_event.get("type", "")
230
+
231
+ if event_subtype == "text_delta":
232
+ delta_text = assistant_event.get("delta", "")
233
+ if delta_text:
234
+ self._pending_text.append(delta_text)
235
+ delta: MessageDeltaEvent = {
236
+ "type": "message_delta",
237
+ "text": delta_text,
238
+ }
239
+ return [delta]
240
+ # Skip thinking_start, thinking_delta, thinking_end, text_start, text_end
241
+ return []
242
+
243
+ def _handle_message_end(self, raw: dict[str, Any]) -> list[IREvent]:
244
+ """Handle message_end — emits MessageEndEvent with accumulated text."""
245
+ message = raw.get("message", {})
246
+ role = message.get("role", "")
247
+
248
+ # Skip user message_end events
249
+ if role != "assistant":
250
+ return []
251
+
252
+ # Extract full text from content blocks
253
+ content = message.get("content", [])
254
+ full_text = ""
255
+ for block in content:
256
+ if block.get("type") == "text":
257
+ full_text += block.get("text", "")
258
+
259
+ end: MessageEndEvent = {
260
+ "type": "message_end",
261
+ "stop_reason": message.get("stopReason", ""),
262
+ }
263
+
264
+ # Prefer full text from content blocks, fallback to pending
265
+ if full_text:
266
+ end["text"] = full_text
267
+ self._pending_text = []
268
+ elif self._pending_text:
269
+ end["text"] = "".join(self._pending_text)
270
+ self._pending_text = []
271
+
272
+ return [end]
273
+
274
+ @staticmethod
275
+ def _handle_tool_start(raw: dict[str, Any]) -> list[IREvent]:
276
+ """Handle tool_execution_start — emits ToolUseEvent."""
277
+ tool_use: ToolUseEvent = {
278
+ "type": "tool_use",
279
+ "tool_use_id": raw.get("toolCallId", ""),
280
+ "tool_name": raw.get("toolName", ""),
281
+ "tool_input": raw.get("args", {}),
282
+ }
283
+ return [tool_use]
284
+
285
+ @staticmethod
286
+ def _handle_tool_end(raw: dict[str, Any]) -> list[IREvent]:
287
+ """Handle tool_execution_end — emits ToolResultEvent."""
288
+ result = raw.get("result", {})
289
+ content_blocks = result.get("content", [])
290
+ content_text = ""
291
+ for block in content_blocks:
292
+ if isinstance(block, dict) and block.get("type") == "text":
293
+ content_text += block.get("text", "")
294
+
295
+ tool_result: ToolResultEvent = {
296
+ "type": "tool_result",
297
+ "tool_use_id": raw.get("toolCallId", ""),
298
+ "content": content_text,
299
+ }
300
+ if raw.get("isError"):
301
+ tool_result["is_error"] = True
302
+ return [tool_result]
303
+
304
+ def _handle_turn_end(self, raw: dict[str, Any]) -> list[IREvent]:
305
+ """Handle turn_end — emits UsageEvent with token counts and cost."""
306
+ results: list[IREvent] = []
307
+ message = raw.get("message", {})
308
+
309
+ # Extract usage
310
+ raw_usage = message.get("usage", {})
311
+ usage: UsageInfo = {}
312
+ if raw_usage.get("input"):
313
+ usage["input_tokens"] = raw_usage["input"]
314
+ if raw_usage.get("output"):
315
+ usage["output_tokens"] = raw_usage["output"]
316
+ total = raw_usage.get("totalTokens", 0)
317
+ if total:
318
+ usage["total_tokens"] = total
319
+ if raw_usage.get("cacheRead"):
320
+ usage["cache_read_tokens"] = raw_usage["cacheRead"]
321
+ if raw_usage.get("cacheWrite"):
322
+ usage["cache_creation_tokens"] = raw_usage["cacheWrite"]
323
+
324
+ usage_event: UsageEvent = {"type": "usage", "usage": usage}
325
+
326
+ # Extract cost
327
+ cost = raw_usage.get("cost", {})
328
+ total_cost = cost.get("total")
329
+ if total_cost:
330
+ usage_event["cost_usd"] = total_cost
331
+
332
+ # Extract model
333
+ model = message.get("model")
334
+ if model:
335
+ usage_event["model"] = model
336
+
337
+ results.append(usage_event)
338
+ return results
339
+
340
+ @staticmethod
341
+ def _handle_agent_end(raw: dict[str, Any]) -> list[IREvent]:
342
+ """Handle agent_end — emits SessionEndEvent."""
343
+ end: SessionEndEvent = {"type": "session_end"}
344
+ return [end]
345
+
346
+
347
+ __all__ = ["PiNativeProvider"]
@@ -35,12 +35,14 @@ def _build_provider_chain() -> dict[str, list[type[Provider]]]:
35
35
  from .gemini_native import GeminiNativeProvider
36
36
  from .gemini_sdk import GeminiSDKProvider
37
37
  from .opencode_native import OpenCodeNativeProvider
38
+ from .pi_native import PiNativeProvider
38
39
 
39
40
  return {
40
41
  "claude_code": [ClaudeNativeProvider, ClaudeSDKProvider],
41
42
  "codex": [CodexNativeProvider, CodexSDKProvider],
42
43
  "gemini_cli": [GeminiNativeProvider, GeminiSDKProvider],
43
44
  "opencode": [OpenCodeNativeProvider],
45
+ "pi": [PiNativeProvider],
44
46
  }
45
47
 
46
48
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentabi
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Unified async Python API for driving coding agent CLIs — Claude Code, Codex, Gemini CLI, and more
5
5
  Author-email: Peng Ding <pding.dp@foxmail.com>
6
6
  License-Expression: MIT
@@ -67,6 +67,7 @@ One interface. Any coding agent.
67
67
  | [Codex](https://github.com/openai/codex) | OpenAI | Implemented |
68
68
  | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google | Implemented |
69
69
  | [OpenCode](https://opencode.ai) | Open Source | Implemented |
70
+ | [Pi](https://pi.dev/) | Open Source | Implemented |
70
71
 
71
72
  ## Installation
72
73
 
@@ -83,7 +84,7 @@ pip install agentabi[gemini] # Gemini CLI SDK support
83
84
  pip install agentabi[all] # All optional SDKs
84
85
  ```
85
86
 
86
- > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`).
87
+ > **Note:** Each agent's CLI must be installed separately (e.g., `claude`, `codex`, `gemini`, `opencode`, `pi`).
87
88
 
88
89
  ## Quick Start
89
90
 
@@ -23,6 +23,7 @@ src/agentabi/providers/codex_sdk.py
23
23
  src/agentabi/providers/gemini_native.py
24
24
  src/agentabi/providers/gemini_sdk.py
25
25
  src/agentabi/providers/opencode_native.py
26
+ src/agentabi/providers/pi_native.py
26
27
  src/agentabi/providers/registry.py
27
28
  src/agentabi/types/__init__.py
28
29
  src/agentabi/types/ir/__init__.py
File without changes
File without changes
File without changes
File without changes
File without changes