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 +85 -0
- pop/_sync.py +32 -0
- pop/agent.py +328 -0
- pop/hooks/__init__.py +31 -0
- pop/hooks/base.py +66 -0
- pop/hooks/console.py +50 -0
- pop/hooks/cost.py +53 -0
- pop/hooks/file_log.py +46 -0
- pop/memory/__init__.py +24 -0
- pop/memory/base.py +38 -0
- pop/memory/inmemory.py +89 -0
- pop/memory/markdown.py +183 -0
- pop/models/__init__.py +43 -0
- pop/models/anthropic.py +228 -0
- pop/models/base.py +37 -0
- pop/models/deepseek.py +19 -0
- pop/models/gemini.py +222 -0
- pop/models/glm.py +19 -0
- pop/models/kimi.py +19 -0
- pop/models/minimax.py +19 -0
- pop/models/openai.py +187 -0
- pop/models/router.py +169 -0
- pop/multi/__init__.py +23 -0
- pop/multi/handoff.py +55 -0
- pop/multi/patterns.py +223 -0
- pop/py.typed +0 -0
- pop/runner.py +133 -0
- pop/tool.py +200 -0
- pop/types.py +314 -0
- pop/workflows/__init__.py +5 -0
- pop/workflows/patterns.py +103 -0
- pop_framework-0.1.0.dist-info/METADATA +148 -0
- pop_framework-0.1.0.dist-info/RECORD +35 -0
- pop_framework-0.1.0.dist-info/WHEEL +4 -0
- pop_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
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")
|