pop-framework 0.1.0__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.
pop/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """pop -- Fast, lean AI agents. 5 lines to production."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = [
8
+ # Core
9
+ "Agent",
10
+ "tool",
11
+ "Runner",
12
+ "run",
13
+ # Multi-agent
14
+ "handoff",
15
+ "pipeline",
16
+ "orchestrate",
17
+ "debate",
18
+ "fan_out",
19
+ # Workflows
20
+ "chain",
21
+ "route",
22
+ "parallel",
23
+ # Models
24
+ "chat",
25
+ "model",
26
+ "register_provider",
27
+ # Types
28
+ "AgentResult",
29
+ "Step",
30
+ "TokenUsage",
31
+ "Message",
32
+ # Stream events
33
+ "ThinkEvent",
34
+ "ToolCallEvent",
35
+ "ToolResultEvent",
36
+ "TextDeltaEvent",
37
+ "DoneEvent",
38
+ ]
39
+
40
+ # Lazy import mapping: attribute name -> (module, name)
41
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
42
+ # Core
43
+ "Agent": ("pop.agent", "Agent"),
44
+ "tool": ("pop.tool", "tool"),
45
+ "Runner": ("pop.runner", "Runner"),
46
+ "run": ("pop.runner", "run"),
47
+ # Multi-agent
48
+ "handoff": ("pop.multi", "handoff"),
49
+ "pipeline": ("pop.multi", "pipeline"),
50
+ "orchestrate": ("pop.multi", "orchestrate"),
51
+ "debate": ("pop.multi", "debate"),
52
+ "fan_out": ("pop.multi", "fan_out"),
53
+ # Workflows
54
+ "chain": ("pop.workflows", "chain"),
55
+ "route": ("pop.workflows", "route"),
56
+ "parallel": ("pop.workflows", "parallel"),
57
+ # Models
58
+ "chat": ("pop.models", "chat"),
59
+ "model": ("pop.models", "model"),
60
+ "register_provider": ("pop.models", "register_provider"),
61
+ # Types
62
+ "AgentResult": ("pop.types", "AgentResult"),
63
+ "Step": ("pop.types", "Step"),
64
+ "TokenUsage": ("pop.types", "TokenUsage"),
65
+ "Message": ("pop.types", "Message"),
66
+ # Stream events
67
+ "ThinkEvent": ("pop.types", "ThinkEvent"),
68
+ "ToolCallEvent": ("pop.types", "ToolCallEvent"),
69
+ "ToolResultEvent": ("pop.types", "ToolResultEvent"),
70
+ "TextDeltaEvent": ("pop.types", "TextDeltaEvent"),
71
+ "DoneEvent": ("pop.types", "DoneEvent"),
72
+ }
73
+
74
+
75
+ def __getattr__(name: str) -> object:
76
+ if name in _LAZY_IMPORTS:
77
+ module_path, attr_name = _LAZY_IMPORTS[name]
78
+ import importlib
79
+
80
+ module = importlib.import_module(module_path)
81
+ value = getattr(module, attr_name)
82
+ # Cache on the module to avoid repeated lookups
83
+ globals()[name] = value
84
+ return value
85
+ raise AttributeError(f"module 'pop' has no attribute {name!r}")
pop/_sync.py ADDED
@@ -0,0 +1,32 @@
1
+ """Shared sync-over-async helper.
2
+
3
+ Both Agent.run() and Runner.run() need to call async methods from sync code.
4
+ This module provides a single implementation to avoid duplication.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import TYPE_CHECKING, Any, TypeVar
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Coroutine
14
+
15
+ _T = TypeVar("_T")
16
+
17
+
18
+ def run_sync(coro: Coroutine[Any, Any, _T]) -> _T:
19
+ """Run a coroutine synchronously, handling running event loops (e.g. Jupyter)."""
20
+ try:
21
+ loop = asyncio.get_running_loop()
22
+ except RuntimeError:
23
+ loop = None
24
+
25
+ if loop and loop.is_running():
26
+ import concurrent.futures
27
+
28
+ with concurrent.futures.ThreadPoolExecutor() as pool:
29
+ future = pool.submit(asyncio.run, coro)
30
+ return future.result()
31
+
32
+ return asyncio.run(coro)
pop/agent.py ADDED
@@ -0,0 +1,328 @@
1
+ """Agent class — the core ReAct loop with optional Reflexion.
2
+
3
+ Implements the Reasoning + Acting pattern: the LLM reasons about the task,
4
+ decides on an action (tool call, final answer, or ask human), and the loop
5
+ continues until completion or a budget is exceeded.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from pop.hooks.base import Hook, HookManager
15
+ from pop.models.router import ModelRouter
16
+ from pop.types import (
17
+ Action,
18
+ ActionType,
19
+ AgentResult,
20
+ AgentState,
21
+ Message,
22
+ ModelResponse,
23
+ Status,
24
+ Step,
25
+ TokenUsage,
26
+ ToolCall,
27
+ ToolDefinition,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Callable
32
+
33
+ from pop.memory.base import MemoryBackend
34
+ from pop.models.base import ModelAdapter
35
+
36
+ # Rough cost estimation per token (fallback when provider doesn't report cost)
37
+ _DEFAULT_COST_PER_INPUT_TOKEN = 0.000003
38
+ _DEFAULT_COST_PER_OUTPUT_TOKEN = 0.000015
39
+
40
+
41
+ def _now() -> datetime:
42
+ return datetime.now(timezone.utc)
43
+
44
+
45
+ def _estimate_cost(usage: TokenUsage) -> float:
46
+ return (
47
+ usage.input_tokens * _DEFAULT_COST_PER_INPUT_TOKEN
48
+ + usage.output_tokens * _DEFAULT_COST_PER_OUTPUT_TOKEN
49
+ )
50
+
51
+
52
+ class Agent:
53
+ """The core ReAct loop agent with optional Reflexion."""
54
+
55
+ def __init__(
56
+ self,
57
+ model: str | list[str] | ModelAdapter,
58
+ *,
59
+ name: str = "agent",
60
+ tools: list[ToolDefinition] | None = None,
61
+ instructions: str = "",
62
+ memory: MemoryBackend | None = None,
63
+ hooks: list[Hook] | None = None,
64
+ max_steps: int = 10,
65
+ max_cost: float | None = None,
66
+ max_retries: int = 3,
67
+ reflect_on_failure: bool = False,
68
+ output_guardrails: list[Callable[[str], bool]] | None = None,
69
+ core_memory: dict[str, str] | None = None,
70
+ ) -> None:
71
+ self.name = name
72
+ self.instructions = instructions
73
+ self._tools: tuple[ToolDefinition, ...] = tuple(tools) if tools else ()
74
+ self._tool_map: dict[str, ToolDefinition] = {t.name: t for t in self._tools}
75
+ self._memory = memory
76
+ self._hook_manager = HookManager(hooks)
77
+ self._max_steps = max_steps
78
+ self._max_cost = max_cost
79
+ self._max_retries = max_retries
80
+ self._reflect_on_failure = reflect_on_failure
81
+ self._output_guardrails: tuple[Callable[[str], bool], ...] = (
82
+ tuple(output_guardrails) if output_guardrails else ()
83
+ )
84
+ self._core_memory: dict[str, str] = dict(core_memory) if core_memory else {}
85
+
86
+ # Resolve model adapter — single router instance shared across paths
87
+ self._router: ModelRouter | None = None
88
+ if isinstance(model, str):
89
+ router = ModelRouter()
90
+ self._adapter: ModelAdapter = router.from_model_string(model)
91
+ self._fallback_models: list[str] = []
92
+ elif isinstance(model, list):
93
+ self._router = ModelRouter()
94
+ self._adapter = self._router.from_model_string(model[0])
95
+ self._fallback_models = list(model)
96
+ else:
97
+ self._adapter = model
98
+ self._fallback_models = []
99
+
100
+ def run(self, task: str, **kwargs: Any) -> AgentResult:
101
+ """Synchronous wrapper around arun()."""
102
+ from pop._sync import run_sync
103
+
104
+ return run_sync(self.arun(task, **kwargs))
105
+
106
+ async def arun(self, task: str, **kwargs: Any) -> AgentResult:
107
+ """Async execution of the agent loop."""
108
+ run_id = kwargs.get("run_id", str(uuid.uuid4()))
109
+ state = AgentState(status=Status.RUNNING)
110
+ state = state.with_message(Message.user(task))
111
+
112
+ self._hook_manager.fire_run_start(task, run_id)
113
+
114
+ result = await self._loop(state, run_id)
115
+
116
+ self._hook_manager.fire_run_end(result)
117
+ return result
118
+
119
+ async def _loop(self, state: AgentState, run_id: str) -> AgentResult:
120
+ """The core ReAct loop."""
121
+ steps: list[Step] = []
122
+
123
+ while state.step_count < self._max_steps:
124
+ if self._max_cost is not None and state.cost_usd >= self._max_cost:
125
+ return self._build_partial_result(state, steps, run_id)
126
+
127
+ messages = self._build_messages(state)
128
+ tools_for_model = list(self._tools) if self._tools else None
129
+
130
+ if self._fallback_models and self._router is not None:
131
+ response = await self._router.chat_with_fallback(
132
+ model_strings=self._fallback_models,
133
+ messages=messages,
134
+ tools=tools_for_model,
135
+ )
136
+ else:
137
+ response = await self._adapter.chat(messages, tools_for_model)
138
+
139
+ step_cost = _estimate_cost(response.token_usage)
140
+ state = state.with_step(
141
+ step_cost=step_cost,
142
+ step_tokens=response.token_usage,
143
+ )
144
+
145
+ if self._max_cost is not None and state.cost_usd > self._max_cost:
146
+ step = self._make_step(
147
+ index=len(steps),
148
+ response=response,
149
+ action=Action(type=ActionType.FINAL_ANSWER, answer=response.content),
150
+ )
151
+ steps = [*steps, step]
152
+ self._hook_manager.fire_step(step)
153
+ return self._build_partial_result(state, steps, run_id)
154
+
155
+ # Determine action from response
156
+ if response.tool_calls:
157
+ state = state.with_message(
158
+ Message.assistant(response.content, tool_calls=response.tool_calls)
159
+ )
160
+ for tool_call in response.tool_calls:
161
+ step, state = await self._handle_tool_call(
162
+ tool_call, response, state, len(steps)
163
+ )
164
+ steps = [*steps, step]
165
+ self._hook_manager.fire_step(step)
166
+ else:
167
+ # Final answer candidate
168
+ action = Action(type=ActionType.FINAL_ANSWER, answer=response.content)
169
+ step = self._make_step(index=len(steps), response=response, action=action)
170
+
171
+ if not self._check_guardrails(response.content):
172
+ # Guardrail failed — add feedback and continue
173
+ state = state.with_message(Message.assistant(response.content))
174
+ state = state.with_message(
175
+ Message.user(
176
+ "Your previous output was rejected by a guardrail. "
177
+ "Please try again with a different response."
178
+ )
179
+ )
180
+ steps = [*steps, step]
181
+ self._hook_manager.fire_step(step)
182
+ continue
183
+
184
+ state = state.with_message(Message.assistant(response.content))
185
+ state = state.with_status(Status.DONE)
186
+ steps = [*steps, step]
187
+ self._hook_manager.fire_step(step)
188
+
189
+ return AgentResult(
190
+ output=response.content,
191
+ steps=tuple(steps),
192
+ state=state,
193
+ cost=state.cost_usd,
194
+ token_usage=state.token_usage,
195
+ run_id=run_id,
196
+ )
197
+
198
+ return self._build_partial_result(state, steps, run_id)
199
+
200
+ async def _handle_tool_call(
201
+ self,
202
+ tool_call: ToolCall,
203
+ response: ModelResponse,
204
+ state: AgentState,
205
+ step_index: int,
206
+ ) -> tuple[Step, AgentState]:
207
+ """Execute a tool call and return the step and updated state."""
208
+ tool_result_str: str | None = None
209
+ error_str: str | None = None
210
+
211
+ try:
212
+ tool_result_str = await self._execute_tool(tool_call)
213
+ except Exception as exc:
214
+ error_str = f"Tool '{tool_call.name}' error: {exc}"
215
+ tool_result_str = error_str
216
+
217
+ call_id = tool_call.call_id or f"call_{step_index}"
218
+ state = state.with_message(
219
+ Message.tool_result(
220
+ content=tool_result_str,
221
+ tool_call_id=call_id,
222
+ name=tool_call.name,
223
+ )
224
+ )
225
+
226
+ if error_str and self._reflect_on_failure:
227
+ state = state.with_message(
228
+ Message.user(
229
+ "The tool call failed. Please reflect on the error above "
230
+ "and decide how to proceed."
231
+ )
232
+ )
233
+
234
+ action = Action(
235
+ type=ActionType.TOOL_CALL,
236
+ tool_call=tool_call,
237
+ )
238
+ step = Step(
239
+ index=step_index,
240
+ timestamp=_now(),
241
+ thought=response.content or None,
242
+ action=action,
243
+ tool_name=tool_call.name,
244
+ tool_args=dict(tool_call.args),
245
+ tool_result=tool_result_str,
246
+ error=error_str,
247
+ token_usage=response.token_usage,
248
+ cost_usd=_estimate_cost(response.token_usage),
249
+ model_used=response.model,
250
+ )
251
+
252
+ return step, state
253
+
254
+ async def _execute_tool(self, tool_call: ToolCall) -> str:
255
+ """Find tool, execute, and return result as string."""
256
+ tool_def = self._tool_map.get(tool_call.name)
257
+ if tool_def is None:
258
+ raise ValueError(f"Unknown tool: '{tool_call.name}'")
259
+
260
+ if tool_def.is_async:
261
+ result = await tool_def.function(**tool_call.args)
262
+ else:
263
+ result = tool_def.function(**tool_call.args)
264
+
265
+ return str(result)
266
+
267
+ def _build_messages(self, state: AgentState) -> list[Message]:
268
+ """Assemble the message list for the LLM."""
269
+ messages: list[Message] = []
270
+
271
+ system_parts: list[str] = []
272
+ if self.instructions:
273
+ system_parts.append(self.instructions)
274
+ if self._core_memory:
275
+ core_lines = [f"- {k}: {v}" for k, v in self._core_memory.items()]
276
+ system_parts.append("Core memory:\n" + "\n".join(core_lines))
277
+
278
+ if system_parts:
279
+ messages.append(Message.system("\n\n".join(system_parts)))
280
+
281
+ messages.extend(state.messages)
282
+ return messages
283
+
284
+ def _check_guardrails(self, output: str) -> bool:
285
+ """Run all guardrail functions. Returns True if all pass."""
286
+ return all(guardrail(output) for guardrail in self._output_guardrails)
287
+
288
+ def _make_step(
289
+ self,
290
+ index: int,
291
+ response: ModelResponse,
292
+ action: Action,
293
+ ) -> Step:
294
+ """Create a Step record from a model response."""
295
+ return Step(
296
+ index=index,
297
+ timestamp=_now(),
298
+ thought=response.content or None,
299
+ action=action,
300
+ token_usage=response.token_usage,
301
+ cost_usd=_estimate_cost(response.token_usage),
302
+ model_used=response.model,
303
+ )
304
+
305
+ def _build_partial_result(
306
+ self,
307
+ state: AgentState,
308
+ steps: list[Step],
309
+ run_id: str,
310
+ ) -> AgentResult:
311
+ """Build a partial result when budget is exceeded."""
312
+ last_output = ""
313
+ if steps:
314
+ last_step = steps[-1]
315
+ if last_step.action.answer:
316
+ last_output = last_step.action.answer
317
+ elif last_step.tool_result:
318
+ last_output = last_step.tool_result
319
+
320
+ return AgentResult(
321
+ output=last_output,
322
+ steps=tuple(steps),
323
+ state=state.with_status(Status.PAUSED),
324
+ cost=state.cost_usd,
325
+ token_usage=state.token_usage,
326
+ partial=True,
327
+ run_id=run_id,
328
+ )
pop/hooks/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Hook system for opt-in middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pop.hooks.base import Hook, HookManager
6
+
7
+ __all__ = [
8
+ "ConsoleHook",
9
+ "CostTrackingHook",
10
+ "FileLogHook",
11
+ "Hook",
12
+ "HookManager",
13
+ ]
14
+
15
+ _LAZY_IMPORTS: dict[str, tuple[str, str]] = {
16
+ "ConsoleHook": ("pop.hooks.console", "ConsoleHook"),
17
+ "CostTrackingHook": ("pop.hooks.cost", "CostTrackingHook"),
18
+ "FileLogHook": ("pop.hooks.file_log", "FileLogHook"),
19
+ }
20
+
21
+
22
+ def __getattr__(name: str) -> object:
23
+ if name in _LAZY_IMPORTS:
24
+ module_path, attr_name = _LAZY_IMPORTS[name]
25
+ import importlib
26
+
27
+ module = importlib.import_module(module_path)
28
+ value = getattr(module, attr_name)
29
+ globals()[name] = value
30
+ return value
31
+ raise AttributeError(f"module 'pop.hooks' has no attribute {name!r}")
pop/hooks/base.py ADDED
@@ -0,0 +1,66 @@
1
+ """Hook base class and manager for opt-in agent middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from pop.types import AgentResult, Step
9
+
10
+
11
+ class Hook:
12
+ """Base class for agent lifecycle hooks.
13
+
14
+ Hooks are opt-in: override only the methods you need.
15
+ Default implementations are no-ops, so subclasses don't need
16
+ to implement methods they don't care about.
17
+
18
+ Previous design used a Protocol with hasattr checks, but since
19
+ Protocol declares all methods, hasattr was always True for valid
20
+ implementors — making the checks redundant. A base class with
21
+ no-op defaults is simpler and actually achieves opt-in behavior.
22
+ """
23
+
24
+ def on_run_start(self, task: str, run_id: str = "") -> None:
25
+ """Called when an agent run starts."""
26
+
27
+ def on_step(self, step: Step) -> None:
28
+ """Called after each agent step completes."""
29
+
30
+ def on_run_end(self, result: AgentResult) -> None:
31
+ """Called when an agent run finishes."""
32
+
33
+
34
+ class HookManager:
35
+ """Dispatches lifecycle events to registered hooks.
36
+
37
+ Zero hooks registered means zero overhead — all fire_* methods
38
+ short-circuit immediately when the hooks tuple is empty.
39
+ """
40
+
41
+ def __init__(self, hooks: list[Hook] | None = None) -> None:
42
+ self._hooks: tuple[Hook, ...] = tuple(hooks) if hooks else ()
43
+
44
+ def fire_run_start(self, task: str, run_id: str = "") -> None:
45
+ if not self._hooks:
46
+ return
47
+ for hook in self._hooks:
48
+ method = getattr(hook, "on_run_start", None)
49
+ if method is not None:
50
+ method(task, run_id)
51
+
52
+ def fire_step(self, step: Step) -> None:
53
+ if not self._hooks:
54
+ return
55
+ for hook in self._hooks:
56
+ method = getattr(hook, "on_step", None)
57
+ if method is not None:
58
+ method(step)
59
+
60
+ def fire_run_end(self, result: AgentResult) -> None:
61
+ if not self._hooks:
62
+ return
63
+ for hook in self._hooks:
64
+ method = getattr(hook, "on_run_end", None)
65
+ if method is not None:
66
+ method(result)
pop/hooks/console.py ADDED
@@ -0,0 +1,50 @@
1
+ """ConsoleHook — pretty-prints agent activity to stderr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pop.types import ActionType, AgentResult, Step
8
+
9
+
10
+ class ConsoleHook:
11
+ """Prints human-readable agent activity to stderr.
12
+
13
+ Uses stderr so agent stdout output remains clean for piping.
14
+ """
15
+
16
+ def on_run_start(self, task: str, run_id: str = "") -> None:
17
+ print(f"Starting agent run: {task}", file=sys.stderr)
18
+
19
+ def on_step(self, step: Step) -> None:
20
+ if step.error:
21
+ print(
22
+ f"Step {step.index}: ERROR — {step.error}",
23
+ file=sys.stderr,
24
+ )
25
+ return
26
+
27
+ if step.action.type == ActionType.TOOL_CALL and step.tool_name:
28
+ args_str = str(step.tool_args or {})
29
+ print(
30
+ f"Step {step.index}: calling {step.tool_name}({args_str})",
31
+ file=sys.stderr,
32
+ )
33
+ if step.tool_result is not None:
34
+ truncated = step.tool_result[:100]
35
+ print(f" → {truncated}", file=sys.stderr)
36
+ return
37
+
38
+ if step.action.type == ActionType.FINAL_ANSWER:
39
+ print(f"Step {step.index}: final answer", file=sys.stderr)
40
+ return
41
+
42
+ print(f"Step {step.index}: {step.action.type.value}", file=sys.stderr)
43
+
44
+ def on_run_end(self, result: AgentResult) -> None:
45
+ step_count = len(result.steps)
46
+ total_tokens = result.token_usage.total
47
+ print(
48
+ f"Done! {step_count} steps, ${result.cost:.4f}, {total_tokens} tokens",
49
+ file=sys.stderr,
50
+ )
pop/hooks/cost.py ADDED
@@ -0,0 +1,53 @@
1
+ """CostTrackingHook — accumulates cost and warns on budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pop.types import AgentResult, Step, TokenUsage
8
+
9
+
10
+ class CostTrackingHook:
11
+ """Tracks cumulative cost and token usage across an agent run.
12
+
13
+ Optionally warns when spending exceeds 80% of a given budget.
14
+ """
15
+
16
+ def __init__(self, budget: float | None = None) -> None:
17
+ self._budget = budget
18
+ self._total_cost: float = 0.0
19
+ self._total_tokens: TokenUsage = TokenUsage()
20
+ self._step_count: int = 0
21
+
22
+ @property
23
+ def total_cost(self) -> float:
24
+ return self._total_cost
25
+
26
+ @property
27
+ def total_tokens(self) -> int:
28
+ return self._total_tokens.total
29
+
30
+ @property
31
+ def step_count(self) -> int:
32
+ return self._step_count
33
+
34
+ def on_step(self, step: Step) -> None:
35
+ self._total_cost = self._total_cost + step.cost_usd
36
+ self._total_tokens = self._total_tokens + step.token_usage
37
+ self._step_count = self._step_count + 1
38
+
39
+ if self._budget is not None and self._total_cost >= self._budget * 0.8:
40
+ print(
41
+ f"Warning: cost ${self._total_cost:.4f} has reached "
42
+ f"{self._total_cost / self._budget * 100:.0f}% of "
43
+ f"${self._budget:.4f} budget",
44
+ file=sys.stderr,
45
+ )
46
+
47
+ def on_run_end(self, result: AgentResult) -> None:
48
+ print(
49
+ f"Cost summary: ${self._total_cost:.4f}, "
50
+ f"{self._total_tokens.total} tokens, "
51
+ f"{self._step_count} steps",
52
+ file=sys.stderr,
53
+ )
pop/hooks/file_log.py ADDED
@@ -0,0 +1,46 @@
1
+ """FileLogHook — writes JSON Lines to a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from pop.types import AgentResult, Step
11
+
12
+
13
+ class FileLogHook:
14
+ """Appends JSON Lines to a file for structured logging.
15
+
16
+ Creates the file (and parent directories) if they don't exist.
17
+ """
18
+
19
+ def __init__(self, path: str | Path) -> None:
20
+ self._path = Path(path)
21
+
22
+ def on_step(self, step: Step) -> None:
23
+ record = {
24
+ "event": "step",
25
+ "index": step.index,
26
+ "tool_name": step.tool_name,
27
+ "latency_ms": step.latency_ms,
28
+ "cost_usd": step.cost_usd,
29
+ "error": step.error,
30
+ }
31
+ self._append(record)
32
+
33
+ def on_run_end(self, result: AgentResult) -> None:
34
+ record = {
35
+ "event": "run_end",
36
+ "output": result.output,
37
+ "cost_usd": result.cost,
38
+ "total_tokens": result.token_usage.total,
39
+ "step_count": len(result.steps),
40
+ }
41
+ self._append(record)
42
+
43
+ def _append(self, record: dict[str, object]) -> None:
44
+ self._path.parent.mkdir(parents=True, exist_ok=True)
45
+ with open(self._path, "a", encoding="utf-8") as f:
46
+ f.write(json.dumps(record) + "\n")