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_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)