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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pico-cli-core
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Agent loop and session tree.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
6
|
+
Project-URL: Repository, https://github.com/Arya-Ojha/PicoCLI_Learn
|
|
7
|
+
Project-URL: Issues, https://github.com/Arya-Ojha/PicoCLI_Learn/issues
|
|
8
|
+
Author-email: Arya-Ojha <aryaojha195@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agent-loop,ai-agent,llm-tools
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pico-cli-ai
|
|
20
|
+
Requires-Dist: pydantic>=2
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# pico-cli-core
|
|
24
|
+
|
|
25
|
+
The agent loop (explicit finite-state machine) and the append-only, tree-based session model for `pico-cli`: nine hardcoded core tools (`read`, `write`, `edit`, `grep`, `fetch`, `websearch`, `bash`, `todo`, `task`), todo tracking, sub-agents, and compaction.
|
|
26
|
+
|
|
27
|
+
See the [pico-cli README](https://github.com/Arya-Ojha/PicoCLI_Learn#readme) for the full picture.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pico_core/__init__.py,sha256=kcq1Edp5ys3REwleuTzQsGOFi2Ah0PAHSN5n-P6zKns,1425
|
|
2
|
+
pico_core/fsm.py,sha256=TkIK8Uh7VupCoYg5ddnh-Peclib-dM6DpU-WqQ2QSzM,20036
|
|
3
|
+
pico_core/py.typed,sha256=s9UQ7wQnXKjmmOWzy7Ds45Se-SUvDNyDnp7jR0CaIgk,2
|
|
4
|
+
pico_core/session.py,sha256=CZfOP9Nfy-gT2emu7QRpXzeuo1pGO7kHw6e1ma-DEik,6397
|
|
5
|
+
pico_core/subagents.py,sha256=504gBz8Z5TZseFvrdPPGG744th_tszPiaGxYKYag6Ew,7030
|
|
6
|
+
pico_core/todos.py,sha256=OlZsdev15AI8rkWRRnJxfrm_QblsCpaOwQUq80Xn36c,5264
|
|
7
|
+
pico_core/tools.py,sha256=HCw2J86nbsmB4OLRjqCn_XXPQe56RcJ-nLA6PpTfcBg,18427
|
|
8
|
+
pico_core/trace.py,sha256=fD1RXKo0YRjGzltF1VYtzr5cBXtjDzJlTbsKjlvjVKc,4469
|
|
9
|
+
pico_cli_core-0.1.1.dist-info/METADATA,sha256=IkhDlC-5OedBoFWA1ojojPjMzW34KQ8GqRZ5jc1ipZY,1218
|
|
10
|
+
pico_cli_core-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
pico_cli_core-0.1.1.dist-info/RECORD,,
|
pico_core/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""pico_core: the agent loop (state machine) and the session tree."""
|
|
2
|
+
|
|
3
|
+
from .fsm import AgentLoop, AgentState, LoopEvent, RunResult
|
|
4
|
+
from .session import (
|
|
5
|
+
AssistantBlock,
|
|
6
|
+
AssistantPayload,
|
|
7
|
+
CompactionSummaryPayload,
|
|
8
|
+
Node,
|
|
9
|
+
Session,
|
|
10
|
+
ToolRequestPayload,
|
|
11
|
+
ToolResultPayload,
|
|
12
|
+
UserPayload,
|
|
13
|
+
)
|
|
14
|
+
from .subagents import (
|
|
15
|
+
DEFAULT_CHILD_TOOLS,
|
|
16
|
+
MAX_DEPTH,
|
|
17
|
+
ChildFactory,
|
|
18
|
+
ChildSpec,
|
|
19
|
+
SpawnTool,
|
|
20
|
+
)
|
|
21
|
+
from .todos import TodoItem, TodoList, TodoTool, format_todos
|
|
22
|
+
from .trace import TraceRow, assemble_trace_rows
|
|
23
|
+
from .tools import (
|
|
24
|
+
BashTool,
|
|
25
|
+
EditTool,
|
|
26
|
+
FetchTool,
|
|
27
|
+
GrepTool,
|
|
28
|
+
ReadTool,
|
|
29
|
+
Tool,
|
|
30
|
+
ToolOutcome,
|
|
31
|
+
ToolRegistry,
|
|
32
|
+
WebSearchTool,
|
|
33
|
+
WriteTool,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AgentLoop",
|
|
38
|
+
"AgentState",
|
|
39
|
+
"LoopEvent",
|
|
40
|
+
"RunResult",
|
|
41
|
+
"AssistantBlock",
|
|
42
|
+
"AssistantPayload",
|
|
43
|
+
"CompactionSummaryPayload",
|
|
44
|
+
"Node",
|
|
45
|
+
"Session",
|
|
46
|
+
"ToolRequestPayload",
|
|
47
|
+
"ToolResultPayload",
|
|
48
|
+
"UserPayload",
|
|
49
|
+
"BashTool",
|
|
50
|
+
"EditTool",
|
|
51
|
+
"FetchTool",
|
|
52
|
+
"GrepTool",
|
|
53
|
+
"ReadTool",
|
|
54
|
+
"Tool",
|
|
55
|
+
"ToolOutcome",
|
|
56
|
+
"ToolRegistry",
|
|
57
|
+
"WebSearchTool",
|
|
58
|
+
"WriteTool",
|
|
59
|
+
"DEFAULT_CHILD_TOOLS",
|
|
60
|
+
"MAX_DEPTH",
|
|
61
|
+
"ChildFactory",
|
|
62
|
+
"ChildSpec",
|
|
63
|
+
"SpawnTool",
|
|
64
|
+
"TodoItem",
|
|
65
|
+
"TodoList",
|
|
66
|
+
"TodoTool",
|
|
67
|
+
"format_todos",
|
|
68
|
+
"TraceRow",
|
|
69
|
+
"assemble_trace_rows",
|
|
70
|
+
]
|
|
71
|
+
|
pico_core/fsm.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""The explicit finite-state-machine agent loop.
|
|
2
|
+
|
|
3
|
+
States::
|
|
4
|
+
|
|
5
|
+
idle → streaming ⇄ tool_executing → done
|
|
6
|
+
↓
|
|
7
|
+
compacting (triggered by token threshold)
|
|
8
|
+
error (reachable from any state)
|
|
9
|
+
|
|
10
|
+
Yolo mode means there is no approval/confirmation state.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import AsyncIterator, Callable
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Literal, Protocol
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
from pico_ai.provider import Provider
|
|
24
|
+
from pico_ai.types import AICallRequest, Message, StreamEvent, ToolCall, Usage
|
|
25
|
+
|
|
26
|
+
from .session import (
|
|
27
|
+
AssistantBlock,
|
|
28
|
+
AssistantPayload,
|
|
29
|
+
CompactionSummaryPayload,
|
|
30
|
+
Node,
|
|
31
|
+
Session,
|
|
32
|
+
ToolRequestPayload,
|
|
33
|
+
ToolResultPayload,
|
|
34
|
+
UserPayload,
|
|
35
|
+
)
|
|
36
|
+
from .todos import TodoItem, TodoTool
|
|
37
|
+
from .tools import ToolRegistry
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentState(str, Enum):
|
|
41
|
+
IDLE = "idle"
|
|
42
|
+
STREAMING = "streaming"
|
|
43
|
+
TOOL_EXECUTING = "tool_executing"
|
|
44
|
+
COMPACTING = "compacting"
|
|
45
|
+
DONE = "done"
|
|
46
|
+
ERROR = "error"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LoopEvent(BaseModel):
|
|
50
|
+
"""A single observable event emitted by the agent loop."""
|
|
51
|
+
|
|
52
|
+
kind: Literal[
|
|
53
|
+
"text", "thinking", "tool_call", "tool_request", "tool_result", "state", "usage"
|
|
54
|
+
]
|
|
55
|
+
text: str = ""
|
|
56
|
+
thinking: str = ""
|
|
57
|
+
tool_call: ToolCall | None = None
|
|
58
|
+
tool_request: ToolRequestPayload | None = None
|
|
59
|
+
tool_result: ToolResultPayload | None = None
|
|
60
|
+
state: AgentState | None = None
|
|
61
|
+
usage: Usage | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RunResult(BaseModel):
|
|
65
|
+
"""The outcome of a completed run."""
|
|
66
|
+
|
|
67
|
+
text: str
|
|
68
|
+
state: AgentState
|
|
69
|
+
session: Session
|
|
70
|
+
error: str | None = None
|
|
71
|
+
truncated: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HookSink(Protocol):
|
|
75
|
+
"""Lifecycle hooks the loop can fire (implemented by the SDK)."""
|
|
76
|
+
|
|
77
|
+
async def on_session_start(self, session: Session) -> None: ...
|
|
78
|
+
async def tool_before(self, name: str, arguments: dict) -> None: ...
|
|
79
|
+
async def tool_after(
|
|
80
|
+
self, name: str, arguments: dict, result: ToolResultPayload
|
|
81
|
+
) -> None: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Summarizer = Callable[[list[Node], str], AsyncIterator[StreamEvent]]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# A run only ends once every todo is completed. When the model stops with
|
|
88
|
+
# unfinished todos, the loop nudges it back in instead of finishing — capped
|
|
89
|
+
# so a stuck model still terminates instead of burning calls forever.
|
|
90
|
+
MAX_TODO_NUDGES = 5
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _collect_text(stream: AsyncIterator[StreamEvent]) -> str:
|
|
94
|
+
parts: list[str] = []
|
|
95
|
+
async for event in stream:
|
|
96
|
+
if event.kind == "text":
|
|
97
|
+
parts.append(event.text)
|
|
98
|
+
return "".join(parts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AgentLoop:
|
|
102
|
+
"""Drives a session through the FSM using a provider and a tool registry."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
provider: Provider,
|
|
107
|
+
session: Session,
|
|
108
|
+
tools: ToolRegistry,
|
|
109
|
+
*,
|
|
110
|
+
system_prompt: str = "",
|
|
111
|
+
model: str = "",
|
|
112
|
+
context_window: int = 128_000,
|
|
113
|
+
reserve_tokens: int = 16_384,
|
|
114
|
+
summarizer: Summarizer | None = None,
|
|
115
|
+
hooks: HookSink | None = None,
|
|
116
|
+
allowed_tools: list[str] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self.provider = provider
|
|
119
|
+
self.session = session
|
|
120
|
+
self.tools = tools
|
|
121
|
+
self.system_prompt = system_prompt
|
|
122
|
+
self.model = model
|
|
123
|
+
self.context_window = context_window
|
|
124
|
+
self.reserve_tokens = reserve_tokens
|
|
125
|
+
self._summarizer = summarizer or self._default_summarizer
|
|
126
|
+
self._hooks = hooks
|
|
127
|
+
# Permission gating (ADR-0003): None allows every registered tool.
|
|
128
|
+
self.allowed_tools = list(allowed_tools) if allowed_tools is not None else None
|
|
129
|
+
self.state = AgentState.IDLE
|
|
130
|
+
self._started = False
|
|
131
|
+
self._todo_nudges = 0
|
|
132
|
+
#: True when the last ``stream()`` stopped at ``max_turns``.
|
|
133
|
+
self.last_truncated = False
|
|
134
|
+
|
|
135
|
+
# -- public -------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
async def run(self, prompt: str, *, max_turns: int | None = None) -> RunResult:
|
|
138
|
+
"""Run the full loop and return the final result."""
|
|
139
|
+
text_parts: list[str] = []
|
|
140
|
+
try:
|
|
141
|
+
async for event in self.stream(prompt, max_turns=max_turns):
|
|
142
|
+
if event.kind == "text":
|
|
143
|
+
text_parts.append(event.text)
|
|
144
|
+
except Exception as exc: # noqa: BLE001 - surface as error state
|
|
145
|
+
self.state = AgentState.ERROR
|
|
146
|
+
return RunResult(
|
|
147
|
+
text="".join(text_parts),
|
|
148
|
+
state=AgentState.ERROR,
|
|
149
|
+
session=self.session,
|
|
150
|
+
error=str(exc),
|
|
151
|
+
)
|
|
152
|
+
return RunResult(
|
|
153
|
+
text="".join(text_parts),
|
|
154
|
+
state=self.state,
|
|
155
|
+
session=self.session,
|
|
156
|
+
truncated=self.last_truncated,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def stream(
|
|
160
|
+
self, prompt: str, *, max_turns: int | None = None
|
|
161
|
+
) -> AsyncIterator[LoopEvent]:
|
|
162
|
+
"""Run the loop, yielding observable events as they happen."""
|
|
163
|
+
self._set_state(AgentState.IDLE)
|
|
164
|
+
self.last_truncated = False
|
|
165
|
+
yield self._state_event()
|
|
166
|
+
if self._hooks is not None and not self._started:
|
|
167
|
+
await self._hooks.on_session_start(self.session)
|
|
168
|
+
self._started = True
|
|
169
|
+
|
|
170
|
+
self.session.append(
|
|
171
|
+
self.session.active_leaf_id, UserPayload(content=prompt)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
turns = 0
|
|
175
|
+
while True:
|
|
176
|
+
if self._needs_compaction():
|
|
177
|
+
async for event in self._compact():
|
|
178
|
+
yield event
|
|
179
|
+
|
|
180
|
+
self._set_state(AgentState.STREAMING)
|
|
181
|
+
yield self._state_event()
|
|
182
|
+
turns += 1
|
|
183
|
+
if max_turns is not None and turns > max_turns:
|
|
184
|
+
# Turn cap hit (sub-agent bounding): stop cleanly with
|
|
185
|
+
# whatever text was produced so far.
|
|
186
|
+
self.last_truncated = True
|
|
187
|
+
self._set_state(AgentState.DONE)
|
|
188
|
+
yield self._state_event()
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
request = self._build_request()
|
|
192
|
+
blocks: list[AssistantBlock] = []
|
|
193
|
+
tool_calls: list[ToolCall] = []
|
|
194
|
+
usage: Usage | None = None
|
|
195
|
+
|
|
196
|
+
# Trace timing (ADR-0006): the assistant node carries the
|
|
197
|
+
# provider-stream wall-time. The finally-append also covers
|
|
198
|
+
# interrupted streams (partial blocks + measured duration);
|
|
199
|
+
# the exception still propagates to the caller.
|
|
200
|
+
stream_start = time.perf_counter()
|
|
201
|
+
try:
|
|
202
|
+
async for stream_event in self.provider.stream(request):
|
|
203
|
+
if stream_event.kind == "text":
|
|
204
|
+
blocks.append(AssistantBlock(kind="text", text=stream_event.text))
|
|
205
|
+
yield LoopEvent(kind="text", text=stream_event.text)
|
|
206
|
+
elif stream_event.kind == "thinking":
|
|
207
|
+
blocks.append(AssistantBlock(kind="thinking", thinking=stream_event.thinking))
|
|
208
|
+
yield LoopEvent(kind="thinking", thinking=stream_event.thinking)
|
|
209
|
+
elif stream_event.kind == "tool_call" and stream_event.tool_call is not None:
|
|
210
|
+
tool_calls.append(stream_event.tool_call)
|
|
211
|
+
yield LoopEvent(kind="tool_call", tool_call=stream_event.tool_call)
|
|
212
|
+
elif stream_event.kind == "usage" and stream_event.usage is not None:
|
|
213
|
+
usage = stream_event.usage
|
|
214
|
+
finally:
|
|
215
|
+
stream_ms = (time.perf_counter() - stream_start) * 1000.0
|
|
216
|
+
self.session.append(
|
|
217
|
+
self.session.active_leaf_id,
|
|
218
|
+
AssistantPayload(blocks=blocks, usage=usage, duration_ms=stream_ms),
|
|
219
|
+
)
|
|
220
|
+
if usage is not None:
|
|
221
|
+
yield LoopEvent(kind="usage", usage=usage)
|
|
222
|
+
|
|
223
|
+
if not tool_calls:
|
|
224
|
+
unfinished = self._unfinished_todos()
|
|
225
|
+
if unfinished and self._todo_nudges < MAX_TODO_NUDGES:
|
|
226
|
+
self._todo_nudges += 1
|
|
227
|
+
self.session.append(
|
|
228
|
+
self.session.active_leaf_id,
|
|
229
|
+
UserPayload(content=_todo_nudge(unfinished)),
|
|
230
|
+
)
|
|
231
|
+
continue
|
|
232
|
+
self._todo_nudges = 0
|
|
233
|
+
if not unfinished:
|
|
234
|
+
self._clear_finished_todos()
|
|
235
|
+
self._set_state(AgentState.DONE)
|
|
236
|
+
yield self._state_event()
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
self._set_state(AgentState.TOOL_EXECUTING)
|
|
240
|
+
yield self._state_event()
|
|
241
|
+
|
|
242
|
+
if any(c.name == "todo" for c in tool_calls):
|
|
243
|
+
# Progress on the list — reset the stuck-model counter.
|
|
244
|
+
self._todo_nudges = 0
|
|
245
|
+
|
|
246
|
+
if tool_calls and all(c.name == "task" for c in tool_calls):
|
|
247
|
+
# A turn of pure delegation fans out concurrently; requests
|
|
248
|
+
# are recorded first, then executions gather, then results
|
|
249
|
+
# are appended in call order so the tree stays deterministic.
|
|
250
|
+
for tool_call in tool_calls:
|
|
251
|
+
request_payload = ToolRequestPayload(tool_call=tool_call)
|
|
252
|
+
self.session.append(self.session.active_leaf_id, request_payload)
|
|
253
|
+
yield LoopEvent(kind="tool_request", tool_request=request_payload)
|
|
254
|
+
results = await asyncio.gather(
|
|
255
|
+
*(self._execute_tool(tool_call) for tool_call in tool_calls)
|
|
256
|
+
)
|
|
257
|
+
for result in results:
|
|
258
|
+
self.session.append(self.session.active_leaf_id, result)
|
|
259
|
+
yield LoopEvent(kind="tool_result", tool_result=result)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
for tool_call in tool_calls:
|
|
263
|
+
request_payload = ToolRequestPayload(tool_call=tool_call)
|
|
264
|
+
self.session.append(self.session.active_leaf_id, request_payload)
|
|
265
|
+
yield LoopEvent(kind="tool_request", tool_request=request_payload)
|
|
266
|
+
result = await self._execute_tool(tool_call)
|
|
267
|
+
self.session.append(self.session.active_leaf_id, result)
|
|
268
|
+
yield LoopEvent(kind="tool_result", tool_result=result)
|
|
269
|
+
|
|
270
|
+
# -- context assembly ---------------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _build_request(self) -> AICallRequest:
|
|
273
|
+
messages: list[Message] = []
|
|
274
|
+
last_assistant = -1
|
|
275
|
+
for node in self.session.active_branch():
|
|
276
|
+
payload = node.payload
|
|
277
|
+
if isinstance(payload, CompactionSummaryPayload):
|
|
278
|
+
# A compaction summary replaces everything before it.
|
|
279
|
+
messages = [
|
|
280
|
+
Message(role="user", content=f"[compacted context]\n{payload.summary}")
|
|
281
|
+
]
|
|
282
|
+
last_assistant = -1
|
|
283
|
+
elif isinstance(payload, UserPayload):
|
|
284
|
+
messages.append(Message(role="user", content=payload.content))
|
|
285
|
+
last_assistant = -1
|
|
286
|
+
elif isinstance(payload, AssistantPayload):
|
|
287
|
+
messages.append(Message(role="assistant", content=payload.text))
|
|
288
|
+
last_assistant = len(messages) - 1
|
|
289
|
+
elif isinstance(payload, ToolRequestPayload):
|
|
290
|
+
if last_assistant >= 0:
|
|
291
|
+
messages[last_assistant].tool_calls.append(payload.tool_call)
|
|
292
|
+
elif isinstance(payload, ToolResultPayload):
|
|
293
|
+
messages.append(
|
|
294
|
+
Message(
|
|
295
|
+
role="tool",
|
|
296
|
+
tool_call_id=payload.tool_call_id,
|
|
297
|
+
name=payload.name,
|
|
298
|
+
content=payload.content,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
# Note: do NOT reset last_assistant here — a single assistant
|
|
302
|
+
# turn can make several tool calls, and every tool_request in
|
|
303
|
+
# that turn must attach to the same assistant message.
|
|
304
|
+
return AICallRequest(
|
|
305
|
+
system=self.system_prompt,
|
|
306
|
+
messages=messages,
|
|
307
|
+
tools=self.tools.definitions(),
|
|
308
|
+
model=self.model,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _context_text(self) -> str:
|
|
312
|
+
parts = [self.system_prompt]
|
|
313
|
+
for node in self.session.active_branch():
|
|
314
|
+
payload = node.payload
|
|
315
|
+
if isinstance(payload, CompactionSummaryPayload):
|
|
316
|
+
# A summary replaces everything before it.
|
|
317
|
+
parts = [self.system_prompt, payload.summary]
|
|
318
|
+
elif isinstance(payload, UserPayload):
|
|
319
|
+
parts.append(payload.content)
|
|
320
|
+
elif isinstance(payload, AssistantPayload):
|
|
321
|
+
parts.append(payload.text)
|
|
322
|
+
elif isinstance(payload, ToolResultPayload):
|
|
323
|
+
parts.append(payload.content)
|
|
324
|
+
return "\n".join(parts)
|
|
325
|
+
|
|
326
|
+
def estimate_tokens(self) -> int:
|
|
327
|
+
"""Return the current estimated token count for the context."""
|
|
328
|
+
return self._estimate_tokens()
|
|
329
|
+
|
|
330
|
+
def _estimate_tokens(self) -> int:
|
|
331
|
+
# Rough heuristic: ~4 characters per token.
|
|
332
|
+
return len(self._context_text()) // 4
|
|
333
|
+
|
|
334
|
+
# -- compaction ---------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
def _needs_compaction(self) -> bool:
|
|
337
|
+
return self._estimate_tokens() > self.context_window - self.reserve_tokens
|
|
338
|
+
|
|
339
|
+
def _split_for_compaction(
|
|
340
|
+
self, branch: list[Node], keep_turns: int
|
|
341
|
+
) -> tuple[list[Node], list[Node]]:
|
|
342
|
+
"""Split the branch into (to_summarize, to_keep).
|
|
343
|
+
|
|
344
|
+
The ``keep_turns`` most recent user turns — and everything after them —
|
|
345
|
+
form the recent window; everything before them is summarised.
|
|
346
|
+
"""
|
|
347
|
+
user_indices = [
|
|
348
|
+
i for i, n in enumerate(branch) if isinstance(n.payload, UserPayload)
|
|
349
|
+
]
|
|
350
|
+
if len(user_indices) <= keep_turns:
|
|
351
|
+
return [], branch
|
|
352
|
+
cutoff = user_indices[-keep_turns]
|
|
353
|
+
return branch[:cutoff], branch[cutoff:]
|
|
354
|
+
|
|
355
|
+
async def _compact(
|
|
356
|
+
self, instructions: str = "", keep_turns: int = 1
|
|
357
|
+
) -> AsyncIterator[LoopEvent]:
|
|
358
|
+
self._set_state(AgentState.COMPACTING)
|
|
359
|
+
yield self._state_event()
|
|
360
|
+
branch = self.session.active_branch()
|
|
361
|
+
to_summarize, to_keep = self._split_for_compaction(branch, keep_turns)
|
|
362
|
+
if not to_summarize:
|
|
363
|
+
return
|
|
364
|
+
summary = await _collect_text(self._summarizer(to_summarize, instructions))
|
|
365
|
+
self.session.append(
|
|
366
|
+
self.session.active_leaf_id, CompactionSummaryPayload(summary=summary)
|
|
367
|
+
)
|
|
368
|
+
# Re-append the kept recent window after the summary (append-only).
|
|
369
|
+
for node in to_keep:
|
|
370
|
+
self.session.append(self.session.active_leaf_id, node.payload)
|
|
371
|
+
|
|
372
|
+
async def compact(self, instructions: str = "") -> None:
|
|
373
|
+
"""Manually trigger compaction with optional steering instructions."""
|
|
374
|
+
async for _ in self._compact(instructions):
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
async def _default_summarizer(
|
|
378
|
+
self, nodes: list[Node], instructions: str
|
|
379
|
+
) -> AsyncIterator[StreamEvent]:
|
|
380
|
+
content = "\n".join(_render_node(n) for n in nodes)
|
|
381
|
+
request = AICallRequest(
|
|
382
|
+
system="Summarize the following conversation, preserving all key facts, "
|
|
383
|
+
"decisions, and outstanding tasks.",
|
|
384
|
+
messages=[Message(role="user", content=content + "\n" + instructions)],
|
|
385
|
+
model=self.model,
|
|
386
|
+
)
|
|
387
|
+
async for event in self.provider.stream(request):
|
|
388
|
+
yield event
|
|
389
|
+
|
|
390
|
+
# -- tool execution -----------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def _unfinished_todos(self) -> list[TodoItem]:
|
|
393
|
+
"""Return every todo that is not completed (empty when no tracker)."""
|
|
394
|
+
tool = self.tools.get("todo")
|
|
395
|
+
if not isinstance(tool, TodoTool):
|
|
396
|
+
return []
|
|
397
|
+
return [i for i in tool.todos.all() if i.status != "completed"]
|
|
398
|
+
|
|
399
|
+
def _clear_finished_todos(self) -> None:
|
|
400
|
+
"""Drop the todo list once a run ends with everything completed.
|
|
401
|
+
|
|
402
|
+
Only fires when nothing is unfinished, so a run stopped by the
|
|
403
|
+
nudge cap keeps its open todos visible instead of losing them.
|
|
404
|
+
"""
|
|
405
|
+
tool = self.tools.get("todo")
|
|
406
|
+
if isinstance(tool, TodoTool) and len(tool.todos) > 0:
|
|
407
|
+
tool.todos.clear_completed()
|
|
408
|
+
|
|
409
|
+
async def _execute_tool(self, tool_call: ToolCall) -> ToolResultPayload:
|
|
410
|
+
# Every attempted call fires pre_tool_use first, so hook observers see
|
|
411
|
+
# a complete attempt log; every terminal path below fires tool_after
|
|
412
|
+
# (which fans out to post_tool_use and, on error, post_tool_failure).
|
|
413
|
+
if self._hooks is not None:
|
|
414
|
+
await self._hooks.tool_before(tool_call.name, tool_call.arguments)
|
|
415
|
+
if self.allowed_tools is not None and tool_call.name not in self.allowed_tools:
|
|
416
|
+
result = ToolResultPayload(
|
|
417
|
+
tool_call_id=tool_call.id,
|
|
418
|
+
name=tool_call.name,
|
|
419
|
+
content=f"error: tool not allowed: {tool_call.name}",
|
|
420
|
+
is_error=True,
|
|
421
|
+
)
|
|
422
|
+
if self._hooks is not None:
|
|
423
|
+
await self._hooks.tool_after(
|
|
424
|
+
tool_call.name, tool_call.arguments, result
|
|
425
|
+
)
|
|
426
|
+
return result
|
|
427
|
+
tool = self.tools.get(tool_call.name)
|
|
428
|
+
if tool is None:
|
|
429
|
+
result = ToolResultPayload(
|
|
430
|
+
tool_call_id=tool_call.id,
|
|
431
|
+
name=tool_call.name,
|
|
432
|
+
content=f"error: unknown tool: {tool_call.name}",
|
|
433
|
+
is_error=True,
|
|
434
|
+
)
|
|
435
|
+
if self._hooks is not None:
|
|
436
|
+
await self._hooks.tool_after(
|
|
437
|
+
tool_call.name, tool_call.arguments, result
|
|
438
|
+
)
|
|
439
|
+
return result
|
|
440
|
+
try:
|
|
441
|
+
outcome = await tool.run(tool_call.arguments)
|
|
442
|
+
except Exception as exc: # noqa: BLE001 - surface as a result
|
|
443
|
+
result = ToolResultPayload(
|
|
444
|
+
tool_call_id=tool_call.id,
|
|
445
|
+
name=tool_call.name,
|
|
446
|
+
content=f"error: {exc}",
|
|
447
|
+
is_error=True,
|
|
448
|
+
)
|
|
449
|
+
else:
|
|
450
|
+
result = ToolResultPayload(
|
|
451
|
+
tool_call_id=tool_call.id,
|
|
452
|
+
name=tool_call.name,
|
|
453
|
+
content=outcome.content,
|
|
454
|
+
is_error=outcome.is_error,
|
|
455
|
+
)
|
|
456
|
+
if self._hooks is not None:
|
|
457
|
+
await self._hooks.tool_after(tool_call.name, tool_call.arguments, result)
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
# -- helpers ------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
def _set_state(self, state: AgentState) -> None:
|
|
463
|
+
self.state = state
|
|
464
|
+
|
|
465
|
+
def _state_event(self) -> LoopEvent:
|
|
466
|
+
return LoopEvent(kind="state", state=self.state)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _todo_nudge(unfinished: list[TodoItem]) -> str:
|
|
470
|
+
"""Build the keep-going message appended when the model stops early."""
|
|
471
|
+
items = "\n".join(f"[{t.id}] {t.status} — {t.text}" for t in unfinished)
|
|
472
|
+
return (
|
|
473
|
+
f"[todos] {len(unfinished)} unfinished todo(s) — do not stop yet. "
|
|
474
|
+
"Keep working, updating todos as you go, until every todo is completed:\n"
|
|
475
|
+
f"{items}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _render_node(node: Node) -> str:
|
|
480
|
+
payload = node.payload
|
|
481
|
+
if isinstance(payload, UserPayload):
|
|
482
|
+
return f"user: {payload.content}"
|
|
483
|
+
if isinstance(payload, AssistantPayload):
|
|
484
|
+
return f"assistant: {payload.text}"
|
|
485
|
+
if isinstance(payload, ToolRequestPayload):
|
|
486
|
+
return f"tool request: {payload.tool_call.name} {payload.tool_call.arguments}"
|
|
487
|
+
if isinstance(payload, ToolResultPayload):
|
|
488
|
+
return f"tool result: {payload.content}"
|
|
489
|
+
if isinstance(payload, CompactionSummaryPayload):
|
|
490
|
+
return f"summary: {payload.summary}"
|
|
491
|
+
return ""
|
pico_core/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
��
|