power-loop 0.2.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.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. power_loop-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import defaultdict
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from power_loop.contracts.events import AgentEvent, AgentEventType
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ EventHandler = Callable[[AgentEvent], Any] | Callable[[AgentEvent], Awaitable[Any]]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class _SubscribedHandler:
19
+ handler: EventHandler
20
+ priority: int
21
+ order: int
22
+
23
+
24
+ class AgentEventBus:
25
+ """In-process pub/sub event bus.
26
+
27
+ - Subscribers are ordered by (priority, registration order).
28
+ - Supports both sync and async handlers.
29
+ - If `suppress_subscriber_errors=True`, subscriber exceptions are logged
30
+ and do not break the publisher.
31
+ """
32
+
33
+ def __init__(self, *, suppress_subscriber_errors: bool = False) -> None:
34
+ self._handlers: defaultdict[AgentEventType, list[_SubscribedHandler]] = defaultdict(list)
35
+ self._global_handlers: list[_SubscribedHandler] = []
36
+ self._counter = 0
37
+ self._suppress_subscriber_errors = suppress_subscriber_errors
38
+
39
+ def subscribe(
40
+ self,
41
+ event_type: AgentEventType | None,
42
+ handler: EventHandler,
43
+ *,
44
+ priority: int = 0,
45
+ ) -> None:
46
+ self._counter += 1
47
+ wrapped = _SubscribedHandler(handler=handler, priority=priority, order=self._counter)
48
+ if event_type is None:
49
+ self._global_handlers.append(wrapped)
50
+ self._global_handlers.sort(key=lambda h: (h.priority, h.order))
51
+ return
52
+ self._handlers[event_type].append(wrapped)
53
+ self._handlers[event_type].sort(key=lambda h: (h.priority, h.order))
54
+
55
+ def unsubscribe(self, handler: EventHandler) -> None:
56
+ self._global_handlers = [h for h in self._global_handlers if h.handler is not handler]
57
+ for etype, handlers in list(self._handlers.items()):
58
+ self._handlers[etype] = [h for h in handlers if h.handler is not handler]
59
+ if not self._handlers[etype]:
60
+ self._handlers.pop(etype, None)
61
+
62
+ def _invoke_handler(self, sub: _SubscribedHandler, event: AgentEvent) -> Any:
63
+ try:
64
+ return sub.handler(event)
65
+ except Exception:
66
+ if self._suppress_subscriber_errors:
67
+ _logger.exception(
68
+ "AgentEventBus: subscriber raised (event_type=%s, handler=%s)",
69
+ event.type,
70
+ getattr(sub.handler, "__qualname__", sub.handler),
71
+ )
72
+ return None
73
+ raise
74
+
75
+ async def _await_handler_result(self, result: Any) -> None:
76
+ if not asyncio.iscoroutine(result):
77
+ return
78
+ try:
79
+ await result
80
+ except Exception:
81
+ if self._suppress_subscriber_errors:
82
+ _logger.exception("AgentEventBus: async subscriber raised")
83
+ return
84
+ raise
85
+
86
+ def publish(self, event: AgentEvent) -> None:
87
+ """Publish synchronously.
88
+
89
+ Async subscriber handlers are scheduled on the running loop when available.
90
+ """
91
+
92
+ handlers: list[_SubscribedHandler] = list(self._global_handlers)
93
+ handlers.extend(self._handlers.get(event.type, []))
94
+
95
+ try:
96
+ loop = asyncio.get_running_loop()
97
+ except RuntimeError:
98
+ loop = None
99
+
100
+ for sub in handlers:
101
+ result = self._invoke_handler(sub, event)
102
+ if asyncio.iscoroutine(result):
103
+ if loop is not None:
104
+ async def _run_coro(coro: Any) -> None:
105
+ await self._await_handler_result(coro)
106
+
107
+ loop.create_task(_run_coro(result))
108
+ else:
109
+ asyncio.run(self._await_handler_result(result))
110
+
111
+ async def publish_async(self, event: AgentEvent) -> None:
112
+ """Publish asynchronously (awaits async subscribers)."""
113
+
114
+ handlers: list[_SubscribedHandler] = list(self._global_handlers)
115
+ handlers.extend(self._handlers.get(event.type, []))
116
+
117
+ for sub in handlers:
118
+ result = self._invoke_handler(sub, event)
119
+ await self._await_handler_result(result)
120
+
121
+
122
+ # Process-wide default bus instance (used only when user doesn't pass one).
123
+ DEFAULT_EVENT_BUS = AgentEventBus()
124
+
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from power_loop.contracts.hook_contexts import BaseHookCtx
8
+ from power_loop.contracts.hooks import HookContext, HookDirective, HookPoint, HookResult
9
+
10
+ HookHandlerFn = Callable[..., Any]
11
+ """A hook handler callable.
12
+
13
+ For **typed** hooks (the recommended style) the signature is::
14
+
15
+ def handler(ctx: SomeHookCtx) -> None | HookDirective
16
+
17
+ Handlers mutate *ctx* in-place and optionally return a ``HookDirective``.
18
+
19
+ Legacy handlers that receive ``HookContext`` and return
20
+ ``HookContext | HookResult | dict | None`` are still supported via
21
+ :meth:`AgentHooks.run` / :meth:`AgentHooks.run_async`.
22
+ """
23
+
24
+
25
+ @dataclass
26
+ class _HookEntry:
27
+ handler: HookHandlerFn
28
+ order: int
29
+
30
+
31
+ class AgentHooks:
32
+ """Hook manager with ordered sync/async handlers.
33
+
34
+ **Typed API** (recommended — all pipeline hook points use this):
35
+
36
+ Handlers receive a strongly-typed ``*Ctx`` dataclass, mutate it in place,
37
+ and optionally return ``HookDirective`` or set ``ctx.directive``.
38
+
39
+ **Legacy API** (still supported for unit-test ergonomics):
40
+
41
+ Handlers receive ``HookContext`` and may return ``HookContext``,
42
+ ``HookResult``, ``dict``, or ``None``.
43
+ """
44
+
45
+ def __init__(self) -> None:
46
+ self._handlers: dict[str, list[_HookEntry]] = {}
47
+
48
+ def register(self, hook_point: HookPoint | str, handler: HookHandlerFn, *, order: int = 0) -> None:
49
+ key = str(hook_point)
50
+ self._handlers.setdefault(key, []).append(_HookEntry(handler=handler, order=order))
51
+ self._handlers[key].sort(key=lambda e: e.order)
52
+
53
+ def clear(self, hook_point: HookPoint | str | None = None) -> None:
54
+ if hook_point is None:
55
+ self._handlers.clear()
56
+ return
57
+ self._handlers.pop(str(hook_point), None)
58
+
59
+ # ── Legacy dict-based API ──
60
+
61
+ @staticmethod
62
+ def _apply(context: HookContext, directive: HookDirective, result: Any) -> tuple[HookContext, HookDirective]:
63
+ if result is None:
64
+ return context, directive
65
+ if isinstance(result, HookResult):
66
+ return result.context, result.directive
67
+ if isinstance(result, HookContext):
68
+ return result, directive
69
+ if isinstance(result, dict):
70
+ context.values = result
71
+ return context, directive
72
+ return context, directive
73
+
74
+ def run(self, hook_point: HookPoint | str, context: HookContext) -> HookResult:
75
+ key = str(hook_point)
76
+ directive = HookDirective.CONTINUE
77
+ for entry in self._handlers.get(key, []):
78
+ result = entry.handler(context)
79
+ context, directive = self._apply(context, directive, result)
80
+ return HookResult(context=context, directive=directive)
81
+
82
+ async def run_async(self, hook_point: HookPoint | str, context: HookContext) -> HookResult:
83
+ key = str(hook_point)
84
+ directive = HookDirective.CONTINUE
85
+ for entry in self._handlers.get(key, []):
86
+ result = entry.handler(context)
87
+ if hasattr(result, "__await__"):
88
+ result = await result
89
+ context, directive = self._apply(context, directive, result)
90
+ return HookResult(context=context, directive=directive)
91
+
92
+ # ── Typed context API (all pipeline hooks use this path) ──
93
+
94
+ @staticmethod
95
+ def _apply_typed(ctx: BaseHookCtx, result: Any) -> None:
96
+ """Apply handler return value to the typed context."""
97
+ if result is None:
98
+ return
99
+ if isinstance(result, HookDirective):
100
+ ctx.directive = result
101
+ return
102
+ if isinstance(result, HookResult):
103
+ ctx.directive = result.directive
104
+ return
105
+
106
+ def run_typed(self, hook_point: HookPoint | str, ctx: BaseHookCtx) -> None:
107
+ """Run handlers with a typed context. Handlers mutate *ctx* in place."""
108
+ for entry in self._handlers.get(str(hook_point), []):
109
+ result = entry.handler(ctx)
110
+ self._apply_typed(ctx, result)
111
+
112
+ async def run_typed_async(self, hook_point: HookPoint | str, ctx: BaseHookCtx) -> None:
113
+ """Async version of :meth:`run_typed`."""
114
+ for entry in self._handlers.get(str(hook_point), []):
115
+ result = entry.handler(ctx)
116
+ if hasattr(result, "__await__"):
117
+ result = await result
118
+ self._apply_typed(ctx, result)
119
+
120
+
121
+ DEFAULT_HOOKS = AgentHooks()
122
+
@@ -0,0 +1,217 @@
1
+ """Declarative ``@phase`` decorator for agent pipeline methods.
2
+
3
+ The decorator automatically wraps a pipeline method with:
4
+ - **before** hook (``hooks.run_async``) — may modify context values or return a directive
5
+ - **after** hook (``hooks.run_async``) — may modify output or return a directive
6
+ - **error** hook (optional) — fires on exception, may suppress/retry
7
+ - **event publishing** — emits start/complete events on the bus
8
+
9
+ This keeps each pipeline method focused on pure business logic while
10
+ cross-cutting concerns (hooks, events, directives) are handled declaratively.
11
+
12
+ Usage::
13
+
14
+ class MyPipeline(AgentPipeline):
15
+ @phase(
16
+ before=HookPoint.LLM_BEFORE,
17
+ after=HookPoint.LLM_AFTER,
18
+ start_event=AgentEventType.STREAM_STARTED,
19
+ end_event=AgentEventType.STREAM_COMPLETED,
20
+ )
21
+ async def call_llm(self, ctx: PhaseContext) -> Any:
22
+ return await self.llm.complete(...)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import functools
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import dataclass, field
29
+ from typing import Any
30
+
31
+ from power_loop.contracts.events import AgentEvent, AgentEventType
32
+ from power_loop.contracts.hooks import HookContext, HookDirective, HookPoint
33
+
34
+
35
+ @dataclass
36
+ class PhaseContext:
37
+ """Mutable bag of values flowing through a phase.
38
+
39
+ Pipeline methods receive this; the ``@phase`` decorator populates it
40
+ from the before-hook result and passes it to the method.
41
+ """
42
+ values: dict[str, Any] = field(default_factory=dict)
43
+ round_index: int = 0
44
+ session_id: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class PhaseResult:
49
+ """Return value of a ``@phase``-decorated method.
50
+
51
+ The decorator merges hook directives and the method's raw output into this.
52
+ The caller only needs to check ``directive`` and read ``output`` / ``values``.
53
+ """
54
+ output: Any = None
55
+ directive: HookDirective = HookDirective.CONTINUE
56
+ values: dict[str, Any] = field(default_factory=dict)
57
+
58
+ @property
59
+ def should_break(self) -> bool:
60
+ return self.directive == HookDirective.BREAK
61
+
62
+ @property
63
+ def should_skip(self) -> bool:
64
+ return self.directive == HookDirective.SKIP
65
+
66
+ @property
67
+ def is_short_circuit(self) -> bool:
68
+ return self.directive == HookDirective.SHORT_CIRCUIT
69
+
70
+
71
+ def phase(
72
+ *,
73
+ before: HookPoint | None = None,
74
+ after: HookPoint | None = None,
75
+ error: HookPoint | None = None,
76
+ start_event: AgentEventType | None = None,
77
+ end_event: AgentEventType | None = None,
78
+ ) -> Callable:
79
+ """Decorator that wraps a pipeline method with hooks, events, and directive handling.
80
+
81
+ The decorated method signature must be::
82
+
83
+ async def method(self, ctx: PhaseContext) -> Any
84
+
85
+ The ``self`` must be an ``AgentPipeline`` instance (has ``.hooks``, ``.bus``,
86
+ ``.session_id`` attributes).
87
+
88
+ Execution flow:
89
+
90
+ 1. Run **before** hook → get ``HookResult``
91
+ - If directive is ``SKIP``: return ``PhaseResult(directive=SKIP, values=...)`` immediately
92
+ - If directive is ``SHORT_CIRCUIT``: return ``PhaseResult(output=values["output"], ...)``
93
+ - Otherwise: merge modified values back into ``ctx``
94
+ 2. Publish **start_event** (if set)
95
+ 3. Call the actual method
96
+ 4. Publish **end_event** (if set)
97
+ 5. Run **after** hook → get ``HookResult``
98
+ - If directive is ``BREAK``: set ``PhaseResult.directive = BREAK``
99
+ - May replace output via ``values["output"]``
100
+ 6. Return ``PhaseResult``
101
+
102
+ On exception:
103
+ - If **error** hook is set, run it.
104
+ - ``SKIP`` → swallow error, use ``values["output"]`` as fallback
105
+ - ``SHORT_CIRCUIT`` → retry the method once
106
+ - Otherwise → re-package as ``PhaseResult`` with error info
107
+ """
108
+
109
+ def decorator(method: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[PhaseResult]]:
110
+
111
+ @functools.wraps(method)
112
+ async def wrapper(self: Any, ctx: PhaseContext) -> PhaseResult:
113
+ hooks = self.hooks
114
+ bus = self.bus
115
+
116
+ event_meta: dict[str, Any] = {
117
+ "session_id": ctx.session_id,
118
+ "round_index": ctx.round_index,
119
+ }
120
+
121
+ # ── before hook ──
122
+ if before is not None:
123
+ hr = await hooks.run_async(before, context=HookContext(values=dict(ctx.values)))
124
+ # Merge hook-modified values back
125
+ ctx.values.update(hr.context.values)
126
+
127
+ if hr.directive == HookDirective.SKIP:
128
+ return PhaseResult(
129
+ output=hr.context.values.get("output"),
130
+ directive=HookDirective.SKIP,
131
+ values=hr.context.values,
132
+ )
133
+ if hr.directive == HookDirective.SHORT_CIRCUIT:
134
+ return PhaseResult(
135
+ output=hr.context.values.get("output"),
136
+ directive=HookDirective.SHORT_CIRCUIT,
137
+ values=hr.context.values,
138
+ )
139
+ if hr.directive == HookDirective.BREAK:
140
+ return PhaseResult(
141
+ output=hr.context.values.get("output"),
142
+ directive=HookDirective.BREAK,
143
+ values=hr.context.values,
144
+ )
145
+
146
+ # ── start event ──
147
+ if start_event is not None:
148
+ bus.publish(AgentEvent(
149
+ type=start_event,
150
+ payload=ctx.values.get("event_payload", {}),
151
+ **event_meta,
152
+ ))
153
+
154
+ # ── execute method ──
155
+ raw_output: Any = None
156
+ try:
157
+ raw_output = await method(self, ctx)
158
+ except Exception as exc:
159
+ if error is not None:
160
+ err_hr = await hooks.run_async(
161
+ error,
162
+ context=HookContext(values={
163
+ **ctx.values,
164
+ "error": exc,
165
+ "error_message": str(exc),
166
+ }),
167
+ )
168
+ if err_hr.directive == HookDirective.SKIP:
169
+ raw_output = err_hr.context.values.get("output", f"Error: {exc}")
170
+ elif err_hr.directive == HookDirective.SHORT_CIRCUIT:
171
+ # Retry once
172
+ try:
173
+ raw_output = await method(self, ctx)
174
+ except Exception as retry_exc:
175
+ raw_output = f"Error (retry failed): {retry_exc}"
176
+ return PhaseResult(output=raw_output, values={**ctx.values, "failed": True})
177
+ else:
178
+ return PhaseResult(
179
+ output=f"Error: {exc}",
180
+ values={**ctx.values, "error": exc, "failed": True},
181
+ )
182
+ else:
183
+ raise
184
+
185
+ # ── end event ──
186
+ if end_event is not None:
187
+ bus.publish(AgentEvent(
188
+ type=end_event,
189
+ payload=ctx.values.get("event_payload", {}),
190
+ **event_meta,
191
+ ))
192
+
193
+ # ── after hook ──
194
+ result_directive = HookDirective.CONTINUE
195
+ result_values = dict(ctx.values)
196
+ if after is not None:
197
+ after_ctx_vals = {**ctx.values, "output": raw_output}
198
+ ahr = await hooks.run_async(after, context=HookContext(values=after_ctx_vals))
199
+ result_values = ahr.context.values
200
+ result_directive = ahr.directive
201
+ # after hook may replace output
202
+ if "output" in ahr.context.values:
203
+ raw_output = ahr.context.values["output"]
204
+
205
+ return PhaseResult(
206
+ output=raw_output,
207
+ directive=result_directive,
208
+ values=result_values,
209
+ )
210
+
211
+ # Preserve the original hook metadata for introspection
212
+ wrapper._phase_before = before # type: ignore[attr-defined]
213
+ wrapper._phase_after = after # type: ignore[attr-defined]
214
+ wrapper._phase_error = error # type: ignore[attr-defined]
215
+ return wrapper
216
+
217
+ return decorator