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/session.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Append-only, tree-based session model (ADR-0002).
|
|
2
|
+
|
|
3
|
+
A session is a tree of immutable nodes. Each node carries an id, a parent
|
|
4
|
+
pointer, a timestamp, and a payload. Nodes are never edited or deleted — only
|
|
5
|
+
built upon. Branches are root-to-leaf timelines; forking rewinds to an earlier
|
|
6
|
+
node and starts a new branch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from pico_ai.types import ToolCall, Usage
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def new_id() -> str:
|
|
23
|
+
"""Return a fresh, unique node/session id."""
|
|
24
|
+
return uuid.uuid4().hex
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def utc_now() -> str:
|
|
28
|
+
"""Return the current UTC timestamp as an ISO-8601 string."""
|
|
29
|
+
return datetime.now(timezone.utc).isoformat()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UserPayload(BaseModel):
|
|
33
|
+
"""A user message."""
|
|
34
|
+
|
|
35
|
+
kind: Literal["user"] = "user"
|
|
36
|
+
content: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AssistantBlock(BaseModel):
|
|
40
|
+
"""A block of an assistant response: prose or reasoning."""
|
|
41
|
+
|
|
42
|
+
kind: Literal["text", "thinking"]
|
|
43
|
+
text: str = ""
|
|
44
|
+
thinking: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AssistantPayload(BaseModel):
|
|
48
|
+
"""An assistant response, block-granular (text / thinking)."""
|
|
49
|
+
|
|
50
|
+
kind: Literal["assistant"] = "assistant"
|
|
51
|
+
blocks: list[AssistantBlock] = Field(default_factory=list)
|
|
52
|
+
usage: Usage | None = None
|
|
53
|
+
#: Provider-stream wall-time in milliseconds (None = unknown, e.g.
|
|
54
|
+
#: sessions persisted before trace timing existed).
|
|
55
|
+
duration_ms: float | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def text(self) -> str:
|
|
59
|
+
return "".join(b.text for b in self.blocks if b.kind == "text")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ToolRequestPayload(BaseModel):
|
|
63
|
+
"""A request to run a tool."""
|
|
64
|
+
|
|
65
|
+
kind: Literal["tool_request"] = "tool_request"
|
|
66
|
+
tool_call: ToolCall
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ToolResultPayload(BaseModel):
|
|
70
|
+
"""The output of running a tool."""
|
|
71
|
+
|
|
72
|
+
kind: Literal["tool_result"] = "tool_result"
|
|
73
|
+
tool_call_id: str
|
|
74
|
+
name: str
|
|
75
|
+
content: str
|
|
76
|
+
is_error: bool = False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CompactionSummaryPayload(BaseModel):
|
|
80
|
+
"""A summary of older turns produced by compaction."""
|
|
81
|
+
|
|
82
|
+
kind: Literal["compaction_summary"] = "compaction_summary"
|
|
83
|
+
summary: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
Payload = Annotated[
|
|
87
|
+
UserPayload
|
|
88
|
+
| AssistantPayload
|
|
89
|
+
| ToolRequestPayload
|
|
90
|
+
| ToolResultPayload
|
|
91
|
+
| CompactionSummaryPayload,
|
|
92
|
+
Field(discriminator="kind"),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Node(BaseModel):
|
|
97
|
+
"""An immutable, append-only unit of event data in a session."""
|
|
98
|
+
|
|
99
|
+
id: str = Field(default_factory=new_id)
|
|
100
|
+
parent_id: str | None = None
|
|
101
|
+
timestamp: str = Field(default_factory=utc_now)
|
|
102
|
+
payload: Payload
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Session(BaseModel):
|
|
106
|
+
"""A tree of nodes with a single active branch."""
|
|
107
|
+
|
|
108
|
+
id: str = Field(default_factory=new_id)
|
|
109
|
+
nodes: dict[str, Node] = Field(default_factory=dict)
|
|
110
|
+
active_leaf_id: str | None = None
|
|
111
|
+
|
|
112
|
+
# -- construction -------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def append(self, parent_id: str | None, payload: Payload) -> Node:
|
|
115
|
+
"""Append a node as a child of ``parent_id`` and make it the active leaf."""
|
|
116
|
+
if parent_id is not None and parent_id not in self.nodes:
|
|
117
|
+
raise KeyError(f"unknown parent node id: {parent_id}")
|
|
118
|
+
node = Node(parent_id=parent_id, payload=payload)
|
|
119
|
+
self.nodes[node.id] = node
|
|
120
|
+
self.active_leaf_id = node.id
|
|
121
|
+
return node
|
|
122
|
+
|
|
123
|
+
def fork(self, node_id: str) -> None:
|
|
124
|
+
"""Rewind to ``node_id``, starting a new branch from it."""
|
|
125
|
+
if node_id not in self.nodes:
|
|
126
|
+
raise KeyError(f"unknown node id: {node_id}")
|
|
127
|
+
self.active_leaf_id = node_id
|
|
128
|
+
|
|
129
|
+
# -- traversal ----------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def active_branch(self) -> list[Node]:
|
|
132
|
+
"""Return the root-to-leaf timeline of the active branch."""
|
|
133
|
+
if self.active_leaf_id is None:
|
|
134
|
+
return []
|
|
135
|
+
branch: list[Node] = []
|
|
136
|
+
current: Node | None = self.nodes[self.active_leaf_id]
|
|
137
|
+
while current is not None:
|
|
138
|
+
branch.append(current)
|
|
139
|
+
current = self.nodes.get(current.parent_id) if current.parent_id else None
|
|
140
|
+
branch.reverse()
|
|
141
|
+
return branch
|
|
142
|
+
|
|
143
|
+
def leaves(self) -> list[Node]:
|
|
144
|
+
"""Return every leaf node (nodes with no children)."""
|
|
145
|
+
child_ids = {n.parent_id for n in self.nodes.values() if n.parent_id}
|
|
146
|
+
return [n for n in self.nodes.values() if n.id not in child_ids]
|
|
147
|
+
|
|
148
|
+
# -- persistence --------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def to_jsonl(self) -> str:
|
|
151
|
+
"""Serialize the session as newline-delimited JSON."""
|
|
152
|
+
meta = json.dumps(
|
|
153
|
+
{"session_id": self.id, "active_leaf_id": self.active_leaf_id}
|
|
154
|
+
)
|
|
155
|
+
lines = [meta]
|
|
156
|
+
lines.extend(node.model_dump_json() for node in self.nodes.values())
|
|
157
|
+
return "\n".join(lines) + "\n"
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def from_jsonl(cls, text: str) -> "Session":
|
|
161
|
+
"""Rebuild a session from newline-delimited JSON."""
|
|
162
|
+
session: Session | None = None
|
|
163
|
+
for line in text.splitlines():
|
|
164
|
+
if not line.strip():
|
|
165
|
+
continue
|
|
166
|
+
data = json.loads(line)
|
|
167
|
+
if "session_id" in data and "parent_id" not in data:
|
|
168
|
+
session = cls(
|
|
169
|
+
id=data["session_id"], active_leaf_id=data["active_leaf_id"]
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
if session is None:
|
|
173
|
+
session = cls()
|
|
174
|
+
node = Node.model_validate(data)
|
|
175
|
+
session.nodes[node.id] = node
|
|
176
|
+
if session is None:
|
|
177
|
+
session = cls()
|
|
178
|
+
if session.active_leaf_id is None and session.nodes:
|
|
179
|
+
# No meta header present: the last-appended node is the active leaf.
|
|
180
|
+
session.active_leaf_id = next(reversed(session.nodes))
|
|
181
|
+
return session
|
|
182
|
+
|
|
183
|
+
def save(self, path: Path) -> None:
|
|
184
|
+
"""Persist the session to ``path`` (parent dirs are created)."""
|
|
185
|
+
path = Path(path)
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
path.write_text(self.to_jsonl(), encoding="utf-8")
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def load(cls, path: Path) -> "Session":
|
|
191
|
+
"""Load a session persisted by :meth:`save`."""
|
|
192
|
+
return cls.from_jsonl(Path(path).read_text(encoding="utf-8"))
|
pico_core/subagents.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Model-invoked sub-agents: the hardcoded ``task`` tool (see ADR-0005).
|
|
2
|
+
|
|
3
|
+
A parent loop delegates a self-contained chunk of work by calling ``task``
|
|
4
|
+
with a short description and a full prompt. A fresh child ``AgentLoop`` runs
|
|
5
|
+
on its own ``Session`` to completion; only the child's final summary returns
|
|
6
|
+
to the parent as the tool result, so the parent's context stays lean while
|
|
7
|
+
the child's full transcript persists as its own session file.
|
|
8
|
+
|
|
9
|
+
Children get a restricted toolset by default (read-only research tools),
|
|
10
|
+
fresh todos, and the parent's provider/model unless overridden per-spawn.
|
|
11
|
+
Recursion is bounded: ``task`` is only registered in a child when the
|
|
12
|
+
parent explicitly grants it, and ``max_depth`` caps the nesting — a spawn
|
|
13
|
+
requested beyond it returns an error result instead of running.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from .fsm import AgentLoop, AgentState
|
|
25
|
+
from .tools import ToolOutcome
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: Default child toolset: read-only research tools. No writes, no bash, no
|
|
29
|
+
#: todos (child progress stays out of the parent's todo contract), and no
|
|
30
|
+
#: nested spawning unless the parent explicitly grants ``task``.
|
|
31
|
+
DEFAULT_CHILD_TOOLS = ["read", "grep", "fetch", "websearch"]
|
|
32
|
+
|
|
33
|
+
#: Maximum spawn nesting (the top-level loop runs at depth 0).
|
|
34
|
+
MAX_DEPTH = 2
|
|
35
|
+
|
|
36
|
+
#: Default per-child turn cap (streaming iterations of the child loop).
|
|
37
|
+
DEFAULT_MAX_TURNS = 25
|
|
38
|
+
|
|
39
|
+
#: Default per-child wall-clock budget in seconds.
|
|
40
|
+
DEFAULT_TIMEOUT_S = 300.0
|
|
41
|
+
|
|
42
|
+
#: Child result text beyond this is cut (with a note) before returning.
|
|
43
|
+
MAX_RESULT_CHARS = 8000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChildSpec(BaseModel):
|
|
47
|
+
"""What to run in the child loop."""
|
|
48
|
+
|
|
49
|
+
description: str = ""
|
|
50
|
+
prompt: str
|
|
51
|
+
allowed_tools: list[str] | None = None
|
|
52
|
+
model: str | None = None
|
|
53
|
+
max_turns: int = Field(default=DEFAULT_MAX_TURNS, ge=1)
|
|
54
|
+
timeout_s: float = Field(default=DEFAULT_TIMEOUT_S, gt=0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ChildFactory(Protocol):
|
|
58
|
+
"""Builds a child loop for ``spec`` at nesting ``depth``."""
|
|
59
|
+
|
|
60
|
+
def __call__(self, spec: ChildSpec, depth: int) -> AgentLoop: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SpawnTool:
|
|
64
|
+
"""Run a sub-agent to completion and return its summary."""
|
|
65
|
+
|
|
66
|
+
name = "task"
|
|
67
|
+
description = (
|
|
68
|
+
"Delegate a self-contained task to a sub-agent. Give a short "
|
|
69
|
+
"description and a full prompt; the sub-agent works autonomously "
|
|
70
|
+
"with its own tools and context, and its final summary is returned. "
|
|
71
|
+
"Use for parallelizable research or isolated chunks of work whose "
|
|
72
|
+
"details the parent does not need."
|
|
73
|
+
)
|
|
74
|
+
input_schema = {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"description": {"type": "string"},
|
|
78
|
+
"prompt": {"type": "string"},
|
|
79
|
+
"allowed_tools": {
|
|
80
|
+
"type": "array",
|
|
81
|
+
"items": {"type": "string"},
|
|
82
|
+
},
|
|
83
|
+
"model": {"type": "string"},
|
|
84
|
+
"max_turns": {"type": "integer"},
|
|
85
|
+
"timeout_s": {"type": "number"},
|
|
86
|
+
},
|
|
87
|
+
"required": ["prompt"],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
factory: ChildFactory,
|
|
93
|
+
*,
|
|
94
|
+
depth: int = 0,
|
|
95
|
+
session_dir: Path | str = "~/.pico/sessions",
|
|
96
|
+
default_tools: list[str] | None = None,
|
|
97
|
+
max_depth: int = MAX_DEPTH,
|
|
98
|
+
max_result_chars: int = MAX_RESULT_CHARS,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._factory = factory
|
|
101
|
+
self._depth = depth
|
|
102
|
+
self._session_dir = Path(session_dir).expanduser()
|
|
103
|
+
self._default_tools = (
|
|
104
|
+
list(default_tools) if default_tools is not None else list(DEFAULT_CHILD_TOOLS)
|
|
105
|
+
)
|
|
106
|
+
self._max_depth = max_depth
|
|
107
|
+
self._max_result_chars = max_result_chars
|
|
108
|
+
|
|
109
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
110
|
+
prompt = (arguments.get("prompt") or "")
|
|
111
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
112
|
+
return ToolOutcome(
|
|
113
|
+
content="error: task requires a non-empty prompt", is_error=True
|
|
114
|
+
)
|
|
115
|
+
if self._depth >= self._max_depth:
|
|
116
|
+
return ToolOutcome(
|
|
117
|
+
content=(
|
|
118
|
+
f"error: sub-agent nesting limit reached "
|
|
119
|
+
f"(depth {self._depth} >= max {self._max_depth})"
|
|
120
|
+
),
|
|
121
|
+
is_error=True,
|
|
122
|
+
)
|
|
123
|
+
spec_kwargs: dict[str, Any] = {
|
|
124
|
+
"description": arguments.get("description", ""),
|
|
125
|
+
"prompt": prompt.strip(),
|
|
126
|
+
}
|
|
127
|
+
for key in ("allowed_tools", "model", "max_turns", "timeout_s"):
|
|
128
|
+
if arguments.get(key) is not None:
|
|
129
|
+
spec_kwargs[key] = arguments[key]
|
|
130
|
+
try:
|
|
131
|
+
spec = ChildSpec.model_validate(spec_kwargs)
|
|
132
|
+
except Exception as exc: # noqa: BLE001 - invalid args are results
|
|
133
|
+
return ToolOutcome(content=f"error: invalid task: {exc}", is_error=True)
|
|
134
|
+
if spec.allowed_tools is None:
|
|
135
|
+
spec = spec.model_copy(update={"allowed_tools": self._default_tools})
|
|
136
|
+
try:
|
|
137
|
+
loop = self._factory(spec, self._depth + 1)
|
|
138
|
+
except Exception as exc: # noqa: BLE001 - surface as a result
|
|
139
|
+
return ToolOutcome(
|
|
140
|
+
content=f"error: could not start sub-agent: {exc}", is_error=True
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
result = await asyncio.wait_for(
|
|
144
|
+
loop.run(prompt.strip(), max_turns=spec.max_turns),
|
|
145
|
+
timeout=spec.timeout_s,
|
|
146
|
+
)
|
|
147
|
+
except asyncio.TimeoutError:
|
|
148
|
+
return ToolOutcome(
|
|
149
|
+
content=(
|
|
150
|
+
f"[subagent {loop.session.id} timed out after "
|
|
151
|
+
f"{spec.timeout_s:g}s]"
|
|
152
|
+
),
|
|
153
|
+
is_error=True,
|
|
154
|
+
)
|
|
155
|
+
except Exception as exc: # noqa: BLE001 - surface as a result
|
|
156
|
+
return ToolOutcome(
|
|
157
|
+
content=f"[subagent {loop.session.id} crashed: {exc}]",
|
|
158
|
+
is_error=True,
|
|
159
|
+
)
|
|
160
|
+
# Best-effort persistence: the child's transcript lives as its own
|
|
161
|
+
# session file; a save failure must not fail the delegation itself.
|
|
162
|
+
try:
|
|
163
|
+
loop.session.save(self._session_dir / f"{loop.session.id}.jsonl")
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
notes = []
|
|
167
|
+
if result.truncated:
|
|
168
|
+
notes.append(f"truncated at max_turns={spec.max_turns}")
|
|
169
|
+
header = f"[subagent {loop.session.id} done"
|
|
170
|
+
if notes:
|
|
171
|
+
header += f" ({'; '.join(notes)})"
|
|
172
|
+
header += "]"
|
|
173
|
+
content = f"{header}\n{result.text}" if result.text else header
|
|
174
|
+
if result.state == AgentState.ERROR:
|
|
175
|
+
content = f"{content}\n[subagent ended in error: {result.error}]"
|
|
176
|
+
return ToolOutcome(content=self._cut(content), is_error=True)
|
|
177
|
+
return ToolOutcome(content=self._cut(content))
|
|
178
|
+
|
|
179
|
+
def _cut(self, content: str) -> str:
|
|
180
|
+
if len(content) <= self._max_result_chars:
|
|
181
|
+
return content
|
|
182
|
+
cut = len(content) - self._max_result_chars
|
|
183
|
+
return content[: self._max_result_chars] + f"\n[... truncated {cut} chars ...]"
|
pico_core/todos.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""In-memory todo tracking: a shared list plus the agent-facing ``todo`` tool.
|
|
2
|
+
|
|
3
|
+
The list lives only for the life of the process (no persistence): the owning
|
|
4
|
+
``AgentSession`` creates one :class:`TodoList`, hands it to the ``TodoTool``,
|
|
5
|
+
and the TUI reads it back to render the read-only side panel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from .tools import ToolOutcome
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TodoItem(BaseModel):
|
|
18
|
+
"""A single tracked task."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
text: str
|
|
22
|
+
status: str = "pending" # "pending" | "in_progress" | "completed"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TodoList:
|
|
26
|
+
"""An ordered, in-memory list of todos shared by the tool and the UI."""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._items: list[TodoItem] = []
|
|
30
|
+
self._ids = itertools.count(1)
|
|
31
|
+
|
|
32
|
+
def add(self, text: str) -> TodoItem:
|
|
33
|
+
"""Append a pending todo and return it."""
|
|
34
|
+
item = TodoItem(id=f"t{next(self._ids)}", text=text)
|
|
35
|
+
self._items.append(item)
|
|
36
|
+
return item
|
|
37
|
+
|
|
38
|
+
def update(
|
|
39
|
+
self, todo_id: str, *, status: str | None = None, text: str | None = None
|
|
40
|
+
) -> TodoItem | None:
|
|
41
|
+
"""Update a todo's status and/or text; ``None`` when the id is unknown."""
|
|
42
|
+
item = self.get(todo_id)
|
|
43
|
+
if item is None:
|
|
44
|
+
return None
|
|
45
|
+
if status is not None:
|
|
46
|
+
item.status = status
|
|
47
|
+
if text is not None:
|
|
48
|
+
item.text = text
|
|
49
|
+
return item
|
|
50
|
+
|
|
51
|
+
def get(self, todo_id: str) -> TodoItem | None:
|
|
52
|
+
"""Return the todo with ``todo_id``, or ``None``."""
|
|
53
|
+
for item in self._items:
|
|
54
|
+
if item.id == todo_id:
|
|
55
|
+
return item
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def all(self) -> list[TodoItem]:
|
|
59
|
+
"""Return every todo in insertion order."""
|
|
60
|
+
return list(self._items)
|
|
61
|
+
|
|
62
|
+
def clear_completed(self) -> int:
|
|
63
|
+
"""Drop every completed todo; return how many were removed."""
|
|
64
|
+
before = len(self._items)
|
|
65
|
+
self._items = [i for i in self._items if i.status != "completed"]
|
|
66
|
+
return before - len(self._items)
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
return len(self._items)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_VALID_STATUSES = ("pending", "in_progress", "completed")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_todos(items: list[TodoItem]) -> str:
|
|
76
|
+
"""Render todos as one ``[id] status — text`` line each."""
|
|
77
|
+
if not items:
|
|
78
|
+
return "(no todos)"
|
|
79
|
+
return "\n".join(f"[{i.id}] {i.status} — {i.text}" for i in items)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TodoTool:
|
|
83
|
+
"""Add, update, list, or clear the session's in-memory todos."""
|
|
84
|
+
|
|
85
|
+
name = "todo"
|
|
86
|
+
description = (
|
|
87
|
+
"Track multi-step work: add a todo, update its status "
|
|
88
|
+
"(pending|in_progress|completed), list all todos, or clear completed ones."
|
|
89
|
+
)
|
|
90
|
+
input_schema = {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"action": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"enum": ["add", "update", "list", "clear"],
|
|
96
|
+
},
|
|
97
|
+
"text": {"type": "string"},
|
|
98
|
+
"id": {"type": "string"},
|
|
99
|
+
"status": {"type": "string", "enum": list(_VALID_STATUSES)},
|
|
100
|
+
},
|
|
101
|
+
"required": ["action"],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def __init__(self, todos: TodoList | None = None) -> None:
|
|
105
|
+
# NB: `todos or TodoList()` would be wrong — an empty TodoList is
|
|
106
|
+
# falsy via __len__, so an explicitly shared list would be discarded.
|
|
107
|
+
self._todos = todos if todos is not None else TodoList()
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def todos(self) -> TodoList:
|
|
111
|
+
"""The shared list this tool reads and writes."""
|
|
112
|
+
return self._todos
|
|
113
|
+
|
|
114
|
+
async def run(self, arguments: dict) -> ToolOutcome:
|
|
115
|
+
action = arguments.get("action", "")
|
|
116
|
+
if action == "add":
|
|
117
|
+
text = (arguments.get("text") or "").strip()
|
|
118
|
+
if not text:
|
|
119
|
+
return ToolOutcome(
|
|
120
|
+
content="error: todo add needs a text", is_error=True
|
|
121
|
+
)
|
|
122
|
+
item = self._todos.add(text)
|
|
123
|
+
return ToolOutcome(content=f"added [{item.id}] pending — {item.text}")
|
|
124
|
+
if action == "update":
|
|
125
|
+
todo_id = arguments.get("id", "")
|
|
126
|
+
status = arguments.get("status")
|
|
127
|
+
text = arguments.get("text")
|
|
128
|
+
if status is not None and status not in _VALID_STATUSES:
|
|
129
|
+
return ToolOutcome(
|
|
130
|
+
content=f"error: unknown status: {status}", is_error=True
|
|
131
|
+
)
|
|
132
|
+
if status is None and text is None:
|
|
133
|
+
return ToolOutcome(
|
|
134
|
+
content="error: todo update needs a status and/or text",
|
|
135
|
+
is_error=True,
|
|
136
|
+
)
|
|
137
|
+
updated = self._todos.update(todo_id, status=status, text=text)
|
|
138
|
+
if updated is None:
|
|
139
|
+
return ToolOutcome(
|
|
140
|
+
content=f"error: unknown todo id: {todo_id}", is_error=True
|
|
141
|
+
)
|
|
142
|
+
return ToolOutcome(
|
|
143
|
+
content=f"updated [{updated.id}] {updated.status} — {updated.text}"
|
|
144
|
+
)
|
|
145
|
+
if action == "list":
|
|
146
|
+
return ToolOutcome(content=format_todos(self._todos.all()))
|
|
147
|
+
if action == "clear":
|
|
148
|
+
removed = self._todos.clear_completed()
|
|
149
|
+
return ToolOutcome(content=f"cleared {removed} completed todo(s)")
|
|
150
|
+
return ToolOutcome(content=f"error: unknown action: {action}", is_error=True)
|