pico-cli-core 0.1.1__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.
- pico_cli_core-0.1.1.dist-info/METADATA +27 -0
- pico_cli_core-0.1.1.dist-info/RECORD +11 -0
- pico_cli_core-0.1.1.dist-info/WHEEL +4 -0
- pico_core/__init__.py +71 -0
- pico_core/fsm.py +491 -0
- pico_core/py.typed +1 -0
- pico_core/session.py +192 -0
- pico_core/subagents.py +183 -0
- pico_core/todos.py +150 -0
- pico_core/tools.py +527 -0
- pico_core/trace.py +128 -0
pico_core/tools.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""The core tools: read, write, edit, bash, grep, fetch, websearch.
|
|
2
|
+
|
|
3
|
+
Each tool operates against a working directory. Bash runs unsandboxed and is
|
|
4
|
+
disabled unless an opt-in flag is set. Tool errors (missing file, non-zero exit)
|
|
5
|
+
surface as results rather than exceptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import fnmatch
|
|
12
|
+
import html as _html
|
|
13
|
+
import re
|
|
14
|
+
from html.parser import HTMLParser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
from pico_ai.types import ToolDefinition
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolOutcome(BaseModel):
|
|
26
|
+
"""The result of running a tool."""
|
|
27
|
+
|
|
28
|
+
content: str
|
|
29
|
+
is_error: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Tool(Protocol):
|
|
33
|
+
"""A capability the agent can invoke."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
description: str
|
|
37
|
+
input_schema: dict
|
|
38
|
+
|
|
39
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
40
|
+
"""Execute the tool and return its outcome."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve(cwd: Path, raw: str) -> Path:
|
|
45
|
+
"""Resolve a possibly-relative path against the working directory."""
|
|
46
|
+
p = Path(raw)
|
|
47
|
+
return p if p.is_absolute() else cwd / p
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReadTool:
|
|
51
|
+
"""Read a file's contents."""
|
|
52
|
+
|
|
53
|
+
name = "read"
|
|
54
|
+
description = "Read the contents of a file."
|
|
55
|
+
input_schema = {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {"path": {"type": "string"}},
|
|
58
|
+
"required": ["path"],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def __init__(self, cwd: Path) -> None:
|
|
62
|
+
self._cwd = cwd
|
|
63
|
+
|
|
64
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
65
|
+
path = _resolve(self._cwd, arguments["path"])
|
|
66
|
+
try:
|
|
67
|
+
return ToolOutcome(content=path.read_text(encoding="utf-8"))
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
return ToolOutcome(
|
|
70
|
+
content=f"error: file not found: {arguments['path']}", is_error=True
|
|
71
|
+
)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
return ToolOutcome(content=f"error: {exc}", is_error=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class WriteTool:
|
|
77
|
+
"""Create or overwrite a file."""
|
|
78
|
+
|
|
79
|
+
name = "write"
|
|
80
|
+
description = "Create or overwrite a file with the given content."
|
|
81
|
+
input_schema = {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"path": {"type": "string"},
|
|
85
|
+
"content": {"type": "string"},
|
|
86
|
+
},
|
|
87
|
+
"required": ["path", "content"],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def __init__(self, cwd: Path) -> None:
|
|
91
|
+
self._cwd = cwd
|
|
92
|
+
|
|
93
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
94
|
+
path = _resolve(self._cwd, arguments["path"])
|
|
95
|
+
try:
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text(arguments.get("content", ""), encoding="utf-8")
|
|
98
|
+
return ToolOutcome(content=f"wrote {arguments['path']}")
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
return ToolOutcome(content=f"error: {exc}", is_error=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class EditTool:
|
|
104
|
+
"""Apply a surgical search/replace patch to a file."""
|
|
105
|
+
|
|
106
|
+
name = "edit"
|
|
107
|
+
description = "Replace a unique occurrence of old_text with new_text in a file."
|
|
108
|
+
input_schema = {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"path": {"type": "string"},
|
|
112
|
+
"old_text": {"type": "string"},
|
|
113
|
+
"new_text": {"type": "string"},
|
|
114
|
+
},
|
|
115
|
+
"required": ["path", "old_text", "new_text"],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def __init__(self, cwd: Path) -> None:
|
|
119
|
+
self._cwd = cwd
|
|
120
|
+
|
|
121
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
122
|
+
path = _resolve(self._cwd, arguments["path"])
|
|
123
|
+
old = arguments["old_text"]
|
|
124
|
+
new = arguments.get("new_text", "")
|
|
125
|
+
try:
|
|
126
|
+
content = path.read_text(encoding="utf-8")
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
return ToolOutcome(
|
|
129
|
+
content=f"error: file not found: {arguments['path']}", is_error=True
|
|
130
|
+
)
|
|
131
|
+
count = content.count(old)
|
|
132
|
+
if count == 0:
|
|
133
|
+
return ToolOutcome(content="error: old_text not found", is_error=True)
|
|
134
|
+
if count > 1:
|
|
135
|
+
return ToolOutcome(
|
|
136
|
+
content=f"error: old_text found {count} times; provide a unique match",
|
|
137
|
+
is_error=True,
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
path.write_text(content.replace(old, new, 1), encoding="utf-8")
|
|
141
|
+
return ToolOutcome(content=f"edited {arguments['path']}")
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
return ToolOutcome(content=f"error: {exc}", is_error=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class BashTool:
|
|
147
|
+
"""Run a shell command (unsandboxed, opt-in)."""
|
|
148
|
+
|
|
149
|
+
name = "bash"
|
|
150
|
+
description = "Run a shell command and return its output and exit code."
|
|
151
|
+
input_schema = {
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {"command": {"type": "string"}},
|
|
154
|
+
"required": ["command"],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def __init__(self, cwd: Path, enabled: bool = False) -> None:
|
|
158
|
+
self._cwd = cwd
|
|
159
|
+
self.enabled = enabled
|
|
160
|
+
|
|
161
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
162
|
+
if not self.enabled:
|
|
163
|
+
return ToolOutcome(
|
|
164
|
+
content="error: bash is disabled; drop --no-bash to enable",
|
|
165
|
+
is_error=True,
|
|
166
|
+
)
|
|
167
|
+
command = arguments["command"]
|
|
168
|
+
try:
|
|
169
|
+
proc = await asyncio.create_subprocess_shell(
|
|
170
|
+
command,
|
|
171
|
+
cwd=str(self._cwd),
|
|
172
|
+
stdout=asyncio.subprocess.PIPE,
|
|
173
|
+
stderr=asyncio.subprocess.PIPE,
|
|
174
|
+
)
|
|
175
|
+
stdout, stderr = await proc.communicate()
|
|
176
|
+
output = stdout.decode(errors="replace") + stderr.decode(errors="replace")
|
|
177
|
+
content = output.rstrip() + f"\n[exit code: {proc.returncode}]"
|
|
178
|
+
return ToolOutcome(content=content, is_error=proc.returncode != 0)
|
|
179
|
+
except Exception as exc: # noqa: BLE001 - surface any failure as a result
|
|
180
|
+
return ToolOutcome(content=f"error: {exc}", is_error=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class GrepTool:
|
|
184
|
+
"""Search file contents for a regex pattern."""
|
|
185
|
+
|
|
186
|
+
name = "grep"
|
|
187
|
+
description = (
|
|
188
|
+
"Search file contents for a regex pattern. Searches a single file or "
|
|
189
|
+
"recursively under a directory; returns matching lines as "
|
|
190
|
+
"`path:line: content`."
|
|
191
|
+
)
|
|
192
|
+
input_schema = {
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {
|
|
195
|
+
"pattern": {"type": "string"},
|
|
196
|
+
"path": {"type": "string"},
|
|
197
|
+
"include": {"type": "string"},
|
|
198
|
+
},
|
|
199
|
+
"required": ["pattern"],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Directories never descended into during a recursive search.
|
|
203
|
+
_SKIP_DIRS = frozenset(
|
|
204
|
+
{
|
|
205
|
+
".git",
|
|
206
|
+
".venv",
|
|
207
|
+
"venv",
|
|
208
|
+
"__pycache__",
|
|
209
|
+
"node_modules",
|
|
210
|
+
".mypy_cache",
|
|
211
|
+
".pytest_cache",
|
|
212
|
+
".ruff_cache",
|
|
213
|
+
"dist",
|
|
214
|
+
"build",
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
_MAX_MATCHES = 100
|
|
218
|
+
_MAX_LINE_LEN = 200
|
|
219
|
+
|
|
220
|
+
def __init__(self, cwd: Path) -> None:
|
|
221
|
+
self._cwd = cwd
|
|
222
|
+
|
|
223
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
224
|
+
raw_pattern = arguments.get("pattern", "")
|
|
225
|
+
if not raw_pattern:
|
|
226
|
+
return ToolOutcome(content="error: pattern is required", is_error=True)
|
|
227
|
+
try:
|
|
228
|
+
regex = re.compile(raw_pattern)
|
|
229
|
+
except re.error as exc:
|
|
230
|
+
return ToolOutcome(content=f"error: invalid regex: {exc}", is_error=True)
|
|
231
|
+
|
|
232
|
+
target = _resolve(self._cwd, arguments.get("path", ".") or ".")
|
|
233
|
+
include = arguments.get("include", "")
|
|
234
|
+
|
|
235
|
+
if not target.exists():
|
|
236
|
+
return ToolOutcome(
|
|
237
|
+
content=f"error: path not found: {arguments.get('path', '.')}",
|
|
238
|
+
is_error=True,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
files: list[Path] = []
|
|
242
|
+
if target.is_file():
|
|
243
|
+
files = [target]
|
|
244
|
+
else:
|
|
245
|
+
for p in sorted(target.rglob("*")):
|
|
246
|
+
if not p.is_file():
|
|
247
|
+
continue
|
|
248
|
+
if any(part in self._SKIP_DIRS for part in p.parts):
|
|
249
|
+
continue
|
|
250
|
+
if p.suffix == ".egg-info" or ".egg-info" in p.parts:
|
|
251
|
+
continue
|
|
252
|
+
if include and not fnmatch.fnmatch(p.name, include):
|
|
253
|
+
continue
|
|
254
|
+
files.append(p)
|
|
255
|
+
|
|
256
|
+
if target.is_file() and include and not fnmatch.fnmatch(target.name, include):
|
|
257
|
+
return ToolOutcome(content="(no matches)")
|
|
258
|
+
|
|
259
|
+
matches: list[str] = []
|
|
260
|
+
for file in files:
|
|
261
|
+
try:
|
|
262
|
+
text = file.read_text(encoding="utf-8", errors="strict")
|
|
263
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
rel = file.relative_to(self._cwd).as_posix()
|
|
267
|
+
except ValueError:
|
|
268
|
+
rel = str(file)
|
|
269
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
270
|
+
if regex.search(line):
|
|
271
|
+
line = line.strip()
|
|
272
|
+
if len(line) > self._MAX_LINE_LEN:
|
|
273
|
+
line = line[: self._MAX_LINE_LEN] + "…"
|
|
274
|
+
matches.append(f"{rel}:{lineno}: {line}")
|
|
275
|
+
if len(matches) >= self._MAX_MATCHES:
|
|
276
|
+
matches.append(
|
|
277
|
+
f"... truncated at {self._MAX_MATCHES} matches"
|
|
278
|
+
)
|
|
279
|
+
return ToolOutcome(content="\n".join(matches))
|
|
280
|
+
if not matches:
|
|
281
|
+
return ToolOutcome(content="(no matches)")
|
|
282
|
+
return ToolOutcome(content="\n".join(matches))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class _TextExtractor(HTMLParser):
|
|
286
|
+
"""Collect visible text from HTML, skipping script/style content."""
|
|
287
|
+
|
|
288
|
+
_SKIP_TAGS = frozenset({"script", "style", "noscript"})
|
|
289
|
+
|
|
290
|
+
def __init__(self) -> None:
|
|
291
|
+
super().__init__()
|
|
292
|
+
self._chunks: list[str] = []
|
|
293
|
+
self._skip_depth = 0
|
|
294
|
+
|
|
295
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
296
|
+
if tag in self._SKIP_TAGS:
|
|
297
|
+
self._skip_depth += 1
|
|
298
|
+
elif tag in ("p", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
|
|
299
|
+
self._chunks.append("\n")
|
|
300
|
+
|
|
301
|
+
def handle_endtag(self, tag: str) -> None:
|
|
302
|
+
if tag in self._SKIP_TAGS and self._skip_depth:
|
|
303
|
+
self._skip_depth -= 1
|
|
304
|
+
elif tag in ("p", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
|
|
305
|
+
self._chunks.append("\n")
|
|
306
|
+
|
|
307
|
+
def handle_data(self, data: str) -> None:
|
|
308
|
+
if not self._skip_depth:
|
|
309
|
+
self._chunks.append(data)
|
|
310
|
+
|
|
311
|
+
def text(self) -> str:
|
|
312
|
+
raw = "".join(self._chunks)
|
|
313
|
+
raw = _html.unescape(raw)
|
|
314
|
+
lines = [re.sub(r"\s+", " ", ln).strip() for ln in raw.splitlines()]
|
|
315
|
+
return "\n".join(ln for ln in lines if ln)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _html_to_text(body: str) -> str:
|
|
319
|
+
"""Strip tags from an HTML document, returning readable plain text."""
|
|
320
|
+
extractor = _TextExtractor()
|
|
321
|
+
extractor.feed(body)
|
|
322
|
+
return extractor.text()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class FetchTool:
|
|
326
|
+
"""Fetch a URL and return its content as text."""
|
|
327
|
+
|
|
328
|
+
name = "fetch"
|
|
329
|
+
description = (
|
|
330
|
+
"Fetch a URL over HTTP(S) and return its content as text. "
|
|
331
|
+
"HTML pages are converted to readable plain text."
|
|
332
|
+
)
|
|
333
|
+
input_schema = {
|
|
334
|
+
"type": "object",
|
|
335
|
+
"properties": {
|
|
336
|
+
"url": {"type": "string"},
|
|
337
|
+
"max_chars": {"type": "integer"},
|
|
338
|
+
},
|
|
339
|
+
"required": ["url"],
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_DEFAULT_MAX_CHARS = 8000
|
|
343
|
+
_MAX_CHARS_LIMIT = 50_000
|
|
344
|
+
|
|
345
|
+
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
|
346
|
+
self._client = client
|
|
347
|
+
|
|
348
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
349
|
+
url = (arguments.get("url") or "").strip()
|
|
350
|
+
if not url:
|
|
351
|
+
return ToolOutcome(content="error: url is required", is_error=True)
|
|
352
|
+
try:
|
|
353
|
+
scheme = urlparse(url).scheme.lower()
|
|
354
|
+
except ValueError as exc:
|
|
355
|
+
return ToolOutcome(content=f"error: invalid url: {exc}", is_error=True)
|
|
356
|
+
if scheme not in ("http", "https"):
|
|
357
|
+
return ToolOutcome(
|
|
358
|
+
content="error: only http(s) urls are supported", is_error=True
|
|
359
|
+
)
|
|
360
|
+
try:
|
|
361
|
+
max_chars = int(arguments.get("max_chars", self._DEFAULT_MAX_CHARS))
|
|
362
|
+
except (TypeError, ValueError):
|
|
363
|
+
return ToolOutcome(content="error: max_chars must be an integer",
|
|
364
|
+
is_error=True)
|
|
365
|
+
max_chars = max(1, min(max_chars, self._MAX_CHARS_LIMIT))
|
|
366
|
+
|
|
367
|
+
client = self._client or httpx.AsyncClient(
|
|
368
|
+
timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=True
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
response = await client.get(
|
|
372
|
+
url, headers={"User-Agent": "pico-agent/0.1"}
|
|
373
|
+
)
|
|
374
|
+
except httpx.HTTPError as exc:
|
|
375
|
+
return ToolOutcome(content=f"error: request failed: {exc}", is_error=True)
|
|
376
|
+
finally:
|
|
377
|
+
if self._client is None:
|
|
378
|
+
await client.aclose()
|
|
379
|
+
|
|
380
|
+
if response.status_code >= 400:
|
|
381
|
+
return ToolOutcome(
|
|
382
|
+
content=f"error: HTTP {response.status_code} for {url}",
|
|
383
|
+
is_error=True,
|
|
384
|
+
)
|
|
385
|
+
content_type = response.headers.get("content-type", "")
|
|
386
|
+
body = response.text
|
|
387
|
+
if "html" in content_type or body.lstrip().startswith("<"):
|
|
388
|
+
body = _html_to_text(body)
|
|
389
|
+
if not body.strip():
|
|
390
|
+
return ToolOutcome(content="(empty response)")
|
|
391
|
+
if len(body) > max_chars:
|
|
392
|
+
body = body[:max_chars] + f"\n... truncated at {max_chars} chars"
|
|
393
|
+
return ToolOutcome(content=body)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class _DDGResultParser(HTMLParser):
|
|
397
|
+
"""Parse DuckDuckGo ``/html/`` results into (title, url, snippet) triples."""
|
|
398
|
+
|
|
399
|
+
def __init__(self) -> None:
|
|
400
|
+
super().__init__()
|
|
401
|
+
self.results: list[dict[str, str]] = []
|
|
402
|
+
self._current: dict[str, str] | None = None
|
|
403
|
+
self._in_title = False
|
|
404
|
+
self._in_snippet = False
|
|
405
|
+
|
|
406
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
407
|
+
classes = dict(attrs).get("class", "") or ""
|
|
408
|
+
if tag == "a" and "result__a" in classes:
|
|
409
|
+
self._current = {"title": "", "url": dict(attrs).get("href", "") or "",
|
|
410
|
+
"snippet": ""}
|
|
411
|
+
self._in_title = True
|
|
412
|
+
elif tag == "a" and "result__snippet" in classes:
|
|
413
|
+
self._in_snippet = True
|
|
414
|
+
|
|
415
|
+
def handle_endtag(self, tag: str) -> None:
|
|
416
|
+
if tag != "a":
|
|
417
|
+
return
|
|
418
|
+
if self._in_title:
|
|
419
|
+
self._in_title = False
|
|
420
|
+
elif self._in_snippet:
|
|
421
|
+
self._in_snippet = False
|
|
422
|
+
if self._current and self._current["url"]:
|
|
423
|
+
self._current["title"] = self._current["title"].strip()
|
|
424
|
+
self._current["snippet"] = self._current["snippet"].strip()
|
|
425
|
+
self.results.append(self._current)
|
|
426
|
+
self._current = None
|
|
427
|
+
|
|
428
|
+
def handle_data(self, data: str) -> None:
|
|
429
|
+
if self._in_title and self._current is not None:
|
|
430
|
+
self._current["title"] += data
|
|
431
|
+
elif self._in_snippet and self._current is not None:
|
|
432
|
+
self._current["snippet"] += data
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class WebSearchTool:
|
|
436
|
+
"""Search the web (no API key required) and return ranked results."""
|
|
437
|
+
|
|
438
|
+
name = "websearch"
|
|
439
|
+
description = (
|
|
440
|
+
"Search the web for a query and return ranked results "
|
|
441
|
+
"(title, url, snippet)."
|
|
442
|
+
)
|
|
443
|
+
input_schema = {
|
|
444
|
+
"type": "object",
|
|
445
|
+
"properties": {
|
|
446
|
+
"query": {"type": "string"},
|
|
447
|
+
"count": {"type": "integer"},
|
|
448
|
+
},
|
|
449
|
+
"required": ["query"],
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
_DEFAULT_COUNT = 5
|
|
453
|
+
_MAX_COUNT = 10
|
|
454
|
+
_SEARCH_URL = "https://html.duckduckgo.com/html/"
|
|
455
|
+
|
|
456
|
+
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
|
457
|
+
self._client = client
|
|
458
|
+
|
|
459
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
460
|
+
query = (arguments.get("query") or "").strip()
|
|
461
|
+
if not query:
|
|
462
|
+
return ToolOutcome(content="error: query is required", is_error=True)
|
|
463
|
+
try:
|
|
464
|
+
count = int(arguments.get("count", self._DEFAULT_COUNT))
|
|
465
|
+
except (TypeError, ValueError):
|
|
466
|
+
return ToolOutcome(content="error: count must be an integer",
|
|
467
|
+
is_error=True)
|
|
468
|
+
count = max(1, min(count, self._MAX_COUNT))
|
|
469
|
+
|
|
470
|
+
client = self._client or httpx.AsyncClient(
|
|
471
|
+
timeout=httpx.Timeout(30.0, connect=10.0)
|
|
472
|
+
)
|
|
473
|
+
try:
|
|
474
|
+
response = await client.post(
|
|
475
|
+
self._SEARCH_URL,
|
|
476
|
+
data={"q": query},
|
|
477
|
+
headers={"User-Agent": "pico-agent/0.1"},
|
|
478
|
+
)
|
|
479
|
+
except httpx.HTTPError as exc:
|
|
480
|
+
return ToolOutcome(content=f"error: search failed: {exc}", is_error=True)
|
|
481
|
+
finally:
|
|
482
|
+
if self._client is None:
|
|
483
|
+
await client.aclose()
|
|
484
|
+
|
|
485
|
+
if response.status_code >= 400:
|
|
486
|
+
return ToolOutcome(
|
|
487
|
+
content=f"error: search returned HTTP {response.status_code}",
|
|
488
|
+
is_error=True,
|
|
489
|
+
)
|
|
490
|
+
parser = _DDGResultParser()
|
|
491
|
+
parser.feed(response.text)
|
|
492
|
+
results = parser.results[:count]
|
|
493
|
+
if not results:
|
|
494
|
+
return ToolOutcome(content="(no results)")
|
|
495
|
+
lines: list[str] = []
|
|
496
|
+
for i, item in enumerate(results, start=1):
|
|
497
|
+
lines.append(f"{i}. {item['title']}\n {item['url']}\n {item['snippet']}")
|
|
498
|
+
return ToolOutcome(content="\n".join(lines))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class ToolRegistry:
|
|
502
|
+
"""A name → tool mapping that also exposes tool definitions."""
|
|
503
|
+
|
|
504
|
+
def __init__(self) -> None:
|
|
505
|
+
self._tools: dict[str, Tool] = {}
|
|
506
|
+
|
|
507
|
+
def register(self, tool: Tool) -> None:
|
|
508
|
+
self._tools[tool.name] = tool
|
|
509
|
+
|
|
510
|
+
def unregister(self, name: str) -> Tool | None:
|
|
511
|
+
"""Remove a tool by name, returning it (or ``None`` if absent)."""
|
|
512
|
+
return self._tools.pop(name, None)
|
|
513
|
+
|
|
514
|
+
def get(self, name: str) -> Tool | None:
|
|
515
|
+
return self._tools.get(name)
|
|
516
|
+
|
|
517
|
+
def names(self) -> list[str]:
|
|
518
|
+
return list(self._tools)
|
|
519
|
+
|
|
520
|
+
def definitions(self) -> list[ToolDefinition]:
|
|
521
|
+
return [
|
|
522
|
+
ToolDefinition(
|
|
523
|
+
name=t.name, description=t.description, input_schema=t.input_schema
|
|
524
|
+
)
|
|
525
|
+
for t in self._tools.values()
|
|
526
|
+
]
|
|
527
|
+
|
pico_core/trace.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Trace rows for the trace view (ADR-0006).
|
|
2
|
+
|
|
3
|
+
Pure function: session nodes -> display rows. No Textual/Rich dependency,
|
|
4
|
+
so the TUI overlay and any future headless export share one semantic.
|
|
5
|
+
|
|
6
|
+
Missing data renders as blanks (``None``), never estimates: only assistant
|
|
7
|
+
nodes carry token usage, only assistant nodes carry a measured duration,
|
|
8
|
+
and only tool results paired with a request carry a computed duration.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from .session import (
|
|
19
|
+
AssistantPayload,
|
|
20
|
+
CompactionSummaryPayload,
|
|
21
|
+
Node,
|
|
22
|
+
ToolRequestPayload,
|
|
23
|
+
ToolResultPayload,
|
|
24
|
+
UserPayload,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
#: Summary width, matching the history picker's 80-char rows.
|
|
28
|
+
SUMMARY_LIMIT = 80
|
|
29
|
+
|
|
30
|
+
#: Same rule as the TUI's bash collapse: trailing ``[exit code: N]``.
|
|
31
|
+
_BASH_EXIT = re.compile(r"\[exit code: (-?\d+)\]\s*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TraceRow(BaseModel):
|
|
35
|
+
"""One display row in the trace view, derived from a single node."""
|
|
36
|
+
|
|
37
|
+
node_id: str
|
|
38
|
+
time: str # HH:MM:SS of the node timestamp (raw fallback when unparsable)
|
|
39
|
+
kind: str
|
|
40
|
+
summary: str # one line, truncated to SUMMARY_LIMIT
|
|
41
|
+
status: str # "ok" | "error", bash rows add "(exit N)"
|
|
42
|
+
tokens: int | None # assistant total; None elsewhere
|
|
43
|
+
duration_ms: float | None # measured (assistant) or computed (paired tool result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _short_time(timestamp: str) -> str:
|
|
47
|
+
"""Format an ISO-8601 timestamp as HH:MM:SS; never raises."""
|
|
48
|
+
try:
|
|
49
|
+
return datetime.fromisoformat(timestamp).strftime("%H:%M:%S")
|
|
50
|
+
except ValueError:
|
|
51
|
+
return timestamp[:8] if timestamp else "--:--:--"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _truncate(text: str, limit: int = SUMMARY_LIMIT) -> str:
|
|
55
|
+
"""Collapse whitespace and cut to one line (mirrors the TUI renderer)."""
|
|
56
|
+
text = " ".join(text.split())
|
|
57
|
+
return text if len(text) <= limit else text[:limit] + "..."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _summary(payload: object) -> str:
|
|
61
|
+
"""One-line human-readable summary per payload kind."""
|
|
62
|
+
if isinstance(payload, UserPayload):
|
|
63
|
+
return payload.content
|
|
64
|
+
if isinstance(payload, AssistantPayload):
|
|
65
|
+
return payload.text
|
|
66
|
+
if isinstance(payload, ToolRequestPayload):
|
|
67
|
+
return f"{payload.tool_call.name} {payload.tool_call.arguments}"
|
|
68
|
+
if isinstance(payload, ToolResultPayload):
|
|
69
|
+
return payload.content
|
|
70
|
+
if isinstance(payload, CompactionSummaryPayload):
|
|
71
|
+
return payload.summary
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _status(payload: object) -> str:
|
|
76
|
+
"""Derived status: ok/error from is_error, plus bash exit code."""
|
|
77
|
+
if isinstance(payload, ToolResultPayload):
|
|
78
|
+
base = "error" if payload.is_error else "ok"
|
|
79
|
+
if payload.name == "bash":
|
|
80
|
+
match = _BASH_EXIT.search(payload.content)
|
|
81
|
+
if match is not None:
|
|
82
|
+
return f"{base} (exit {match.group(1)})"
|
|
83
|
+
return base
|
|
84
|
+
return "ok"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _tool_duration_ms(request_ts: str | None, result_ts: str) -> float | None:
|
|
88
|
+
"""Computed tool span: result.timestamp - request.timestamp in ms."""
|
|
89
|
+
if request_ts is None:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
delta = datetime.fromisoformat(result_ts) - datetime.fromisoformat(request_ts)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
return max(delta.total_seconds() * 1000.0, 0.0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def assemble_trace_rows(branch: list[Node]) -> list[TraceRow]:
|
|
99
|
+
"""Map one branch (root-to-leaf nodes) to one trace row per node."""
|
|
100
|
+
request_times: dict[str, str] = {}
|
|
101
|
+
for node in branch:
|
|
102
|
+
if isinstance(node.payload, ToolRequestPayload):
|
|
103
|
+
request_times.setdefault(node.payload.tool_call.id, node.timestamp)
|
|
104
|
+
rows: list[TraceRow] = []
|
|
105
|
+
for node in branch:
|
|
106
|
+
payload = node.payload
|
|
107
|
+
tokens: int | None = None
|
|
108
|
+
duration_ms: float | None = None
|
|
109
|
+
if isinstance(payload, AssistantPayload):
|
|
110
|
+
if payload.usage is not None:
|
|
111
|
+
tokens = payload.usage.total_tokens
|
|
112
|
+
duration_ms = payload.duration_ms
|
|
113
|
+
elif isinstance(payload, ToolResultPayload):
|
|
114
|
+
duration_ms = _tool_duration_ms(
|
|
115
|
+
request_times.get(payload.tool_call_id), node.timestamp
|
|
116
|
+
)
|
|
117
|
+
rows.append(
|
|
118
|
+
TraceRow(
|
|
119
|
+
node_id=node.id,
|
|
120
|
+
time=_short_time(node.timestamp),
|
|
121
|
+
kind=payload.kind,
|
|
122
|
+
summary=_truncate(_summary(payload)),
|
|
123
|
+
status=_status(payload),
|
|
124
|
+
tokens=tokens,
|
|
125
|
+
duration_ms=duration_ms,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return rows
|