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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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]
|