pico-cli-core 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+ .eggs/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+ env/
15
+
16
+ # Test / coverage
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # IDE / OS
22
+ .idea/
23
+ .vscode/
24
+ .DS_Store
25
+ Thumbs.db
@@ -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,5 @@
1
+ # pico-cli-core
2
+
3
+ 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.
4
+
5
+ See the [pico-cli README](https://github.com/Arya-Ojha/PicoCLI_Learn#readme) for the full picture.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "pico-cli-core"
3
+ version = "0.1.1"
4
+ description = "Agent loop and session tree."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Arya-Ojha", email = "aryaojha195@gmail.com" }]
8
+ keywords = ["ai-agent", "agent-loop", "llm-tools"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+ requires-python = ">=3.12"
18
+ dependencies = ["pico-cli-ai", "pydantic>=2", "httpx>=0.27"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/Arya-Ojha/PicoCLI_Learn"
22
+ Repository = "https://github.com/Arya-Ojha/PicoCLI_Learn"
23
+ Issues = "https://github.com/Arya-Ojha/PicoCLI_Learn/issues"
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/pico_core"]
31
+
32
+ [tool.uv.sources]
33
+ pico-cli-ai = { workspace = true }
@@ -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
+
@@ -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 ""
@@ -0,0 +1 @@
1
+ ��