pi-agent-cli-lc 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent_cli_lc-0.2.0/.gitignore +16 -0
- pi_agent_cli_lc-0.2.0/PKG-INFO +45 -0
- pi_agent_cli_lc-0.2.0/README.md +18 -0
- pi_agent_cli_lc-0.2.0/agent.example.toml +22 -0
- pi_agent_cli_lc-0.2.0/config.toml.example +7 -0
- pi_agent_cli_lc-0.2.0/local.env.example +3 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/__init__.py +19 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/__main__.py +81 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/agent.py +297 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/benchmarks/__init__.py +15 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/benchmarks/pelican.py +90 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/config.py +146 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/events.py +141 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/factory.py +70 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/headless.py +79 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/permissions.py +52 -0
- pi_agent_cli_lc-0.2.0/pi_agent_cli/prompt.py +24 -0
- pi_agent_cli_lc-0.2.0/pyproject.toml +48 -0
- pi_agent_cli_lc-0.2.0/tests/test_acp_agent.py +253 -0
- pi_agent_cli_lc-0.2.0/tests/test_config.py +91 -0
- pi_agent_cli_lc-0.2.0/tests/test_factory_skills.py +43 -0
- pi_agent_cli_lc-0.2.0/tests/test_headless.py +74 -0
- pi_agent_cli_lc-0.2.0/tests/test_pelican_benchmark.py +40 -0
- pi_agent_cli_lc-0.2.0/tests/test_pelican_real_llm.py +60 -0
|
@@ -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,18 @@
|
|
|
1
|
+
# pi-agent-cli-lc
|
|
2
|
+
|
|
3
|
+
Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent over `pi-agent-harness`.
|
|
4
|
+
|
|
5
|
+
- stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
|
|
6
|
+
- headless one-shot: `python -m pi_agent_cli -p "..."`
|
|
7
|
+
- Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
|
|
8
|
+
- No `x.ai/*` vendor RPCs — core + harness + ACP only
|
|
9
|
+
|
|
10
|
+
Install:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install pi-agent-cli-lc
|
|
14
|
+
# needs a LangChain provider, e.g.:
|
|
15
|
+
pip install pi-agent-core-lc[deepseek]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
See the [repository README](https://github.com/zy1233/pi-python#install) for development setup and Windows/WSL notes.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copy to ~/.pi-python/agent.toml (or set PI_HOME).
|
|
2
|
+
# Python ACP agent settings ONLY — do not duplicate these keys in config.toml
|
|
3
|
+
# (the Rust TUI parses config.toml as grok-shell config).
|
|
4
|
+
# LLM keys are read from environment variables — never put secrets in this file.
|
|
5
|
+
|
|
6
|
+
permission = "ask" # ask | auto | always-approve
|
|
7
|
+
|
|
8
|
+
thinking_level = "off" # off | minimal | low | medium | high | xhigh
|
|
9
|
+
max_turns = 50
|
|
10
|
+
|
|
11
|
+
[model]
|
|
12
|
+
provider = "deepseek"
|
|
13
|
+
id = "deepseek-ai/DeepSeek-V4-Flash"
|
|
14
|
+
base_url = "https://api.siliconflow.cn/v1"
|
|
15
|
+
api_key_env = "REAL_LLM_API_KEY"
|
|
16
|
+
|
|
17
|
+
[skills]
|
|
18
|
+
paths = ["~/.pi-python/skills", ".pi/skills"]
|
|
19
|
+
|
|
20
|
+
[agent]
|
|
21
|
+
# Used by the Rust TUI when PI_AGENT_COMMAND is unset (Windows: prefer a venv python.exe).
|
|
22
|
+
command = "python -m pi_agent_cli"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Rust TUI (grok-shell) config for ~/.pi-python/config.toml
|
|
2
|
+
#
|
|
3
|
+
# Leave this file empty or use grok-compatible keys only.
|
|
4
|
+
# Python agent settings belong in agent.toml — NOT here.
|
|
5
|
+
#
|
|
6
|
+
# Top-level ``permission = "ask"`` will break TUI startup (grok expects a
|
|
7
|
+
# ``[permission]`` table, not a string).
|
|
@@ -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()
|
|
@@ -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
|