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,140 @@
1
+ """Public error hierarchy for power-loop.
2
+
3
+ All errors raised by the library inherit from :class:`PowerLoopError` so
4
+ callers can ``except PowerLoopError`` as a single catch-all.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class PowerLoopError(Exception):
13
+ """Base for all power-loop raised exceptions."""
14
+
15
+
16
+ class SessionNotFoundError(PowerLoopError):
17
+ """Raised when a ``session_id`` does not exist in the store."""
18
+
19
+ def __init__(self, session_id: str) -> None:
20
+ super().__init__(f"session not found: {session_id}")
21
+ self.session_id = session_id
22
+
23
+
24
+ class SessionPendingError(PowerLoopError):
25
+ """Raised when a session has unresolved tool_calls from a previous run.
26
+
27
+ The previous loop crashed (or was killed) after the assistant emitted
28
+ ``tool_calls`` but before all matching ``tool`` messages were appended.
29
+ The OpenAI/Anthropic message protocol forbids us from sending the next
30
+ LLM request in this state. The caller must explicitly choose:
31
+
32
+ * ``StatefulAgentLoop.resume(sid)`` — finish executing the pending
33
+ tool_calls and continue the loop, or
34
+ * ``StatefulAgentLoop.abort_pending(sid)`` — append synthetic
35
+ ``<aborted>`` tool messages, restoring protocol validity, then
36
+ proceed with the new user input.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ session_id: str,
42
+ *,
43
+ assistant_seq: int,
44
+ pending_tool_calls: list[dict[str, Any]],
45
+ ) -> None:
46
+ self.session_id = session_id
47
+ self.assistant_seq = assistant_seq
48
+ self.pending_tool_calls = pending_tool_calls
49
+ names = ",".join(
50
+ str((tc.get("function") or {}).get("name") or tc.get("name") or "?")
51
+ for tc in pending_tool_calls
52
+ )
53
+ super().__init__(
54
+ f"session {session_id} has {len(pending_tool_calls)} unresolved tool_calls"
55
+ f" from round (assistant_seq={assistant_seq}): {names}"
56
+ )
57
+
58
+
59
+ class LLMTimeout(PowerLoopError):
60
+ """Raised when an LLM call (or a sequence of retries) exceeds ``total_timeout``.
61
+
62
+ ``elapsed`` is the wall-clock seconds spent since the policy started
63
+ counting (before the timeout fired). ``attempts`` is how many physical
64
+ LLM calls were made before giving up.
65
+ """
66
+
67
+ def __init__(self, *, elapsed: float, attempts: int, total_timeout: float) -> None:
68
+ self.elapsed = elapsed
69
+ self.attempts = attempts
70
+ self.total_timeout = total_timeout
71
+ super().__init__(
72
+ f"LLM call timed out after {elapsed:.2f}s "
73
+ f"(total_timeout={total_timeout:.2f}s, attempts={attempts})"
74
+ )
75
+
76
+
77
+ class LLMRetryExhausted(PowerLoopError):
78
+ """Raised when ``LLMRetryPolicy.max_attempts`` is reached without success.
79
+
80
+ ``last_error`` is the exception raised by the most recent attempt — the
81
+ direct cause of the give-up. Always set as ``__cause__`` via ``raise from``.
82
+ """
83
+
84
+ def __init__(self, *, attempts: int, last_error: BaseException) -> None:
85
+ self.attempts = attempts
86
+ self.last_error = last_error
87
+ super().__init__(
88
+ f"LLM retry exhausted after {attempts} attempt(s); "
89
+ f"last error: {type(last_error).__name__}: {last_error}"
90
+ )
91
+
92
+
93
+ class CancellationRequested(PowerLoopError):
94
+ """Raised when a ``CancellationToken`` fires while the loop is awaiting work.
95
+
96
+ This is a controlled, expected exit path — callers cancelling via
97
+ ``stop_event.set()`` / ``token.cancel()`` / ``HookDirective.CANCEL`` should
98
+ catch it (or let ``StatefulAgentLoop`` translate it to a ``cancelled``
99
+ result for them).
100
+ """
101
+
102
+ def __init__(self, reason: str = "cancelled") -> None:
103
+ self.reason = reason
104
+ super().__init__(reason)
105
+
106
+
107
+ class CompactionFailed(PowerLoopError):
108
+ """Reserved for the compactor's hard-fail path (M2.5). Soft fail today
109
+ still emits ``compact.failed`` event and continues — this exception is
110
+ only raised when the loop must be aborted because the un-compacted
111
+ history blew the context window."""
112
+
113
+
114
+ class ToolNotFound(PowerLoopError):
115
+ """Raised when a tool name is not registered in the :class:`ToolRegistry`."""
116
+
117
+ def __init__(self, tool_name: str) -> None:
118
+ self.tool_name = tool_name
119
+ super().__init__(f"tool not found: {tool_name}")
120
+
121
+
122
+ class ToolValidationError(PowerLoopError):
123
+ """Raised when tool arguments fail schema / required-param validation."""
124
+
125
+ def __init__(self, tool_name: str, message: str) -> None:
126
+ self.tool_name = tool_name
127
+ self.message = message
128
+ super().__init__(f"tool {tool_name!r}: {message}")
129
+
130
+
131
+ class SpecValidationError(PowerLoopError):
132
+ """Raised when an :class:`~power_loop.runtime.spec.AgentSpec` fails validation.
133
+
134
+ Replaces the previous ``AgentSpecError(ValueError)``. ``field`` is the
135
+ spec key that caused the failure (or ``None`` for a general error).
136
+ """
137
+
138
+ def __init__(self, message: str, *, field: str | None = None) -> None:
139
+ self.field = field
140
+ super().__init__(message)
@@ -0,0 +1,278 @@
1
+ """Typed event payload dataclasses — one per AgentEventType.
2
+
3
+ Each dataclass declares the exact fields an event subscriber receives,
4
+ replacing the untyped ``AgentEvent.payload`` dict. Subscribers can
5
+ access fields directly with IDE auto-completion and type checking.
6
+
7
+ Legacy dict-based access is preserved via :meth:`BaseEventPayload.to_dict`
8
+ and the ``AgentEvent.payload`` property for backward compatibility.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ # ── Base ──
16
+
17
+
18
+ @dataclass
19
+ class BaseEventPayload:
20
+ """Common base for all event payloads.
21
+
22
+ Provides :meth:`to_dict` for backward-compatible dict serialization.
23
+ """
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ result: dict[str, Any] = {}
27
+ for k, v in self.__dict__.items():
28
+ if not k.startswith("_"):
29
+ result[k] = v
30
+ return result
31
+
32
+
33
+ # ── Session ──
34
+
35
+
36
+ @dataclass
37
+ class LlmRetryAttemptedPayload(BaseEventPayload):
38
+ """Emitted after each *failed* LLM attempt that will be retried.
39
+
40
+ ``attempt`` is the 0-based index of the attempt that **just failed**;
41
+ ``next_sleep_seconds`` is what the policy will sleep before the next
42
+ attempt (capped by remaining deadline).
43
+ """
44
+ attempt: int = 0
45
+ max_attempts: int = 0
46
+ error_type: str = ""
47
+ error_message: str = ""
48
+ next_sleep_seconds: float = 0.0
49
+
50
+
51
+ @dataclass
52
+ class LlmDegradedPayload(BaseEventPayload):
53
+ """Emitted when the loop gives up on the LLM (retry exhausted or
54
+ total_timeout) and proceeds with status=``degraded``."""
55
+ reason: str = "" # "retry_exhausted" | "timeout"
56
+ attempts: int = 0
57
+ error_type: str = ""
58
+ error_message: str = ""
59
+
60
+
61
+ @dataclass
62
+ class LoopCancelledPayload(BaseEventPayload):
63
+ """Emitted when the loop terminates because a CancellationToken fired."""
64
+ reason: str = "cancelled"
65
+ round_index: int | None = None
66
+
67
+
68
+ @dataclass
69
+ class MemoryRecalledPayload(BaseEventPayload):
70
+ """Emitted after a MemoryProvider.recall() call.
71
+
72
+ ``injected`` is the number actually placed into ``messages`` (after
73
+ the MEMORY_RECALLED hook had a chance to filter/skip). ``returned``
74
+ is what the provider gave us before hook filtering.
75
+ """
76
+ returned: int = 0
77
+ injected: int = 0
78
+ budget_tokens: int = 0
79
+
80
+
81
+ @dataclass
82
+ class MemoryFailedPayload(BaseEventPayload):
83
+ """Emitted when recall() or remember() raises.
84
+
85
+ The loop continues regardless — memory is best-effort. ``phase`` is
86
+ ``"recall"`` or ``"remember"`` so subscribers know which side broke.
87
+ """
88
+ phase: str = ""
89
+ error_type: str = ""
90
+ error_message: str = ""
91
+
92
+
93
+ @dataclass
94
+ class SessionStartedPayload(BaseEventPayload):
95
+ scope: str = "main"
96
+
97
+
98
+ @dataclass
99
+ class SessionEndedPayload(BaseEventPayload):
100
+ reason: str = ""
101
+
102
+
103
+ # ── Round ──
104
+
105
+
106
+ @dataclass
107
+ class RoundStartedPayload(BaseEventPayload):
108
+ round_index: int = 0
109
+
110
+
111
+ @dataclass
112
+ class RoundCompletedPayload(BaseEventPayload):
113
+ round_index: int = 0
114
+ has_tools: bool = False
115
+ used_todo: bool = False
116
+
117
+
118
+ @dataclass
119
+ class RoundToolsPresentPayload(BaseEventPayload):
120
+ has_tools: bool = False
121
+
122
+
123
+ # ── Stream ──
124
+
125
+
126
+ @dataclass
127
+ class StreamStartedPayload(BaseEventPayload):
128
+ stream_id: str = "main"
129
+
130
+
131
+ @dataclass
132
+ class StreamDeltaPayload(BaseEventPayload):
133
+ stream_id: str = "main"
134
+ text: str = ""
135
+ is_think: bool = False
136
+
137
+
138
+ @dataclass
139
+ class StreamCompletedPayload(BaseEventPayload):
140
+ stream_id: str = "main"
141
+
142
+
143
+ # ── Tool ──
144
+
145
+
146
+ @dataclass
147
+ class ToolCallStartedPayload(BaseEventPayload):
148
+ name: str = ""
149
+ tool_input: dict[str, Any] = field(default_factory=dict)
150
+ tool_call_id: str = ""
151
+
152
+
153
+ @dataclass
154
+ class ToolCallCompletedPayload(BaseEventPayload):
155
+ name: str = ""
156
+ output: str = ""
157
+ tool_input: dict[str, Any] = field(default_factory=dict)
158
+ tool_call_id: str = ""
159
+
160
+
161
+ @dataclass
162
+ class ToolCallFailedPayload(BaseEventPayload):
163
+ name: str = ""
164
+ output: str = ""
165
+ tool_input: dict[str, Any] = field(default_factory=dict)
166
+ tool_call_id: str = ""
167
+
168
+
169
+ # ── Status ──
170
+
171
+
172
+ @dataclass
173
+ class StatusChangedPayload(BaseEventPayload):
174
+ """Polymorphic status payload, discriminated by ``kind``."""
175
+ kind: str = ""
176
+
177
+
178
+ @dataclass
179
+ class AutoCompactStatusPayload(StatusChangedPayload):
180
+ kind: str = "auto_compact"
181
+ phase: str = ""
182
+ round_index: int = 0
183
+ trigger: str = ""
184
+ before_tokens: int = 0
185
+ after_tokens: int = 0
186
+
187
+
188
+ @dataclass
189
+ class RoundUsageStatusPayload(StatusChangedPayload):
190
+ kind: str = "round_usage"
191
+ time_iso: str = ""
192
+ round_index: int = 0
193
+ round_number: int = 0
194
+ max_rounds: int = 0
195
+ prompt_tokens: int | None = None
196
+ completion_tokens: int | None = None
197
+ cache_read_tokens: int | None = None
198
+ reasoning_tokens: int | None = None
199
+
200
+
201
+ @dataclass
202
+ class HitRoundLimitStatusPayload(StatusChangedPayload):
203
+ kind: str = "hit_round_limit"
204
+ max_rounds: int = 0
205
+
206
+
207
+ # ── Usage ──
208
+
209
+
210
+ @dataclass
211
+ class UsageUpdatedPayload(BaseEventPayload):
212
+ usage: dict[str, Any] = field(default_factory=dict)
213
+
214
+
215
+ # ── Todo ──
216
+
217
+
218
+ @dataclass
219
+ class TodoUpdatedPayload(BaseEventPayload):
220
+ kind: str = "todo_snapshot"
221
+ items: list[dict[str, Any]] = field(default_factory=list)
222
+ counts: dict[str, int] = field(default_factory=dict)
223
+ rendered: str = ""
224
+ text: str = ""
225
+
226
+
227
+ # ── Notification / Log ──
228
+
229
+
230
+ @dataclass
231
+ class UserNotificationPayload(BaseEventPayload):
232
+ message: str = ""
233
+
234
+
235
+ @dataclass
236
+ class AgentErrorPayload(BaseEventPayload):
237
+ error: str = ""
238
+ error_type: str = ""
239
+ details: str = ""
240
+
241
+
242
+ @dataclass
243
+ class SystemLogPayload(BaseEventPayload):
244
+ message: str = ""
245
+ level: str = "info"
246
+
247
+
248
+ # ── Subagent ──
249
+
250
+
251
+ @dataclass
252
+ class SubagentTaskStartPayload(BaseEventPayload):
253
+ task: str = ""
254
+ preset: str = "core"
255
+ sub_session_id: str = ""
256
+ depth: int = 0
257
+
258
+
259
+ @dataclass
260
+ class SubagentTextPayload(BaseEventPayload):
261
+ sub_session_id: str = ""
262
+ status: str = ""
263
+ rounds: int = 0
264
+ final_text: str = ""
265
+
266
+
267
+ @dataclass
268
+ class SubagentLimitPayload(BaseEventPayload):
269
+ sub_session_id: str = ""
270
+ max_rounds: int = 0
271
+
272
+
273
+ @dataclass
274
+ class SubagentCompletedPayload(BaseEventPayload):
275
+ sub_session_id: str = ""
276
+ status: str = ""
277
+ rounds: int = 0
278
+ final_text: str = ""
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from power_loop.contracts.event_payloads import BaseEventPayload
9
+
10
+
11
+ class AgentEventType(str, Enum):
12
+ # Session lifecycle
13
+ SESSION_STARTED = "session_started"
14
+ SESSION_ENDED = "session_ended"
15
+
16
+ # Round lifecycle
17
+ ROUND_STARTED = "round_started"
18
+ ROUND_COMPLETED = "round_completed"
19
+ ROUND_TOOLS_PRESENT = "round_tools_present"
20
+
21
+ # LLM retry / cancellation lifecycle
22
+ LLM_RETRY_ATTEMPTED = "llm_retry_attempted"
23
+ LLM_DEGRADED = "llm_degraded"
24
+ LOOP_CANCELLED = "loop_cancelled"
25
+
26
+ # Memory lifecycle (M1.9)
27
+ MEMORY_RECALLED = "memory_recalled"
28
+ MEMORY_FAILED = "memory_failed"
29
+
30
+ # Streaming lifecycle
31
+ STREAM_STARTED = "stream_started"
32
+ STREAM_DELTA = "stream_delta"
33
+ STREAM_THINK_DELTA = "stream_think_delta"
34
+ STREAM_COMPLETED = "stream_completed"
35
+
36
+ # Tool lifecycle
37
+ TOOL_CALL_STARTED = "tool_call_started"
38
+ TOOL_CALL_COMPLETED = "tool_call_completed"
39
+ TOOL_CALL_FAILED = "tool_call_failed"
40
+
41
+ # Status and usage
42
+ STATUS_CHANGED = "status_changed"
43
+ USAGE_UPDATED = "usage_updated"
44
+
45
+ # Task list / planner (optional feature, used when todo tool is enabled)
46
+ TODO_UPDATED = "todo_updated"
47
+
48
+ # System and user-facing notifications
49
+ USER_NOTIFICATION = "user_notification"
50
+ AGENT_ERROR = "agent_error"
51
+ SYSTEM_LOG = "system_log"
52
+
53
+ # Subagent events
54
+ SUBAGENT_TASK_START = "subagent_task_start"
55
+ SUBAGENT_TEXT = "subagent_text"
56
+ SUBAGENT_LIMIT = "subagent_limit"
57
+ SUBAGENT_COMPLETED = "subagent_completed"
58
+
59
+
60
+ @dataclass
61
+ class AgentEvent:
62
+ """Agent lifecycle event with typed payload.
63
+
64
+ The ``data`` field holds a strongly-typed payload dataclass (e.g.
65
+ ``StreamDeltaPayload``, ``ToolCallStartedPayload``). Subscribers can
66
+ access fields directly with IDE auto-completion::
67
+
68
+ def on_delta(event: AgentEvent) -> None:
69
+ delta: StreamDeltaPayload = event.data
70
+ print(delta.text)
71
+
72
+ The legacy ``payload`` dict is auto-generated from ``data.to_dict()``
73
+ for backward compatibility. If ``data`` is not set, ``payload`` dict
74
+ is used directly (legacy path).
75
+ """
76
+ type: AgentEventType
77
+ payload: dict[str, Any] = field(default_factory=dict)
78
+ data: BaseEventPayload | None = field(default=None, repr=False)
79
+ session_id: str | None = None
80
+ round_index: int | None = None
81
+ stream_id: str | None = None
82
+ source: str | None = None
83
+
84
+ def __post_init__(self) -> None:
85
+ if self.data is not None and not self.payload:
86
+ self.payload = self.data.to_dict()
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Protocol
6
+
7
+ from power_loop.contracts.events import AgentEvent
8
+ from power_loop.contracts.hook_contexts import BaseHookCtx
9
+ from power_loop.contracts.hooks import HookDirective
10
+
11
+
12
+ class EventHandler(Protocol):
13
+ def __call__(self, event: AgentEvent) -> Any:
14
+ ...
15
+
16
+
17
+ class HookHandler(Protocol):
18
+ """Hook handler callable.
19
+
20
+ The recommended (typed) signature receives a ``BaseHookCtx`` subclass,
21
+ mutates it in-place, and optionally returns ``HookDirective`` or ``None``.
22
+
23
+ Legacy handlers that accept ``HookContext`` are still supported.
24
+ """
25
+
26
+ def __call__(self, ctx: BaseHookCtx) -> HookDirective | None | Awaitable[HookDirective | None]:
27
+ ...
28
+
29
+
30
+ @dataclass
31
+ class ToolHandlerResult:
32
+ """Normalized tool execution result envelope."""
33
+
34
+ ok: bool
35
+ content: str
36
+ data: dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ class ToolHandler(Protocol):
40
+ def __call__(self, args: dict[str, Any]) -> ToolHandlerResult | dict[str, Any] | str | Awaitable[ToolHandlerResult | dict[str, Any] | str]:
41
+ ...
42
+
43
+
44
+ ToolHandlerMap = dict[str, ToolHandler]
45
+ EventSubscriber = Callable[[EventHandler], None]