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,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
|
+
|
power_loop/core/hooks.py
ADDED
|
@@ -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
|
+
|
power_loop/core/phase.py
ADDED
|
@@ -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
|