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,265 @@
|
|
|
1
|
+
"""Typed hook context dataclasses — one per hook point.
|
|
2
|
+
|
|
3
|
+
Each dataclass declares the exact fields a hook handler receives and can
|
|
4
|
+
modify, replacing the untyped ``HookContext.values`` dict. Handlers
|
|
5
|
+
mutate the context in place and set ``directive`` when needed.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
def my_handler(ctx: ToolBeforeCtx) -> None:
|
|
10
|
+
if "rm -rf" in str(ctx.tool_args):
|
|
11
|
+
ctx.output = "[blocked by policy]"
|
|
12
|
+
ctx.directive = HookDirective.SKIP
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from power_loop.contracts.hooks import HookDirective
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from llm_client.interface import LLMResponse
|
|
24
|
+
from power_loop.agent.types import LoopMessage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Base ──
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BaseHookCtx:
|
|
32
|
+
"""Common fields shared by all hook contexts."""
|
|
33
|
+
|
|
34
|
+
round_index: int = 0
|
|
35
|
+
directive: HookDirective = HookDirective.CONTINUE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Session ──
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SessionStartCtx(BaseHookCtx):
|
|
43
|
+
"""Context for :pyattr:`HookPoint.SESSION_START`.
|
|
44
|
+
|
|
45
|
+
Handler may modify ``messages``.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
scope: str = "main"
|
|
49
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
50
|
+
stop_event: threading.Event | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SessionEndCtx(BaseHookCtx):
|
|
55
|
+
"""Context for :pyattr:`HookPoint.SESSION_END` (read-only)."""
|
|
56
|
+
|
|
57
|
+
scope: str = "main"
|
|
58
|
+
reason: str = ""
|
|
59
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
60
|
+
final_text: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── Round ──
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RoundStartCtx(BaseHookCtx):
|
|
68
|
+
"""Context for :pyattr:`HookPoint.ROUND_START`.
|
|
69
|
+
|
|
70
|
+
Directives: BREAK (set ``reason``), SKIP.
|
|
71
|
+
Handler may modify ``messages``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
75
|
+
stop_event: threading.Event | None = None
|
|
76
|
+
# Handler output
|
|
77
|
+
reason: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RoundEndCtx(BaseHookCtx):
|
|
82
|
+
"""Context for :pyattr:`HookPoint.ROUND_END` (read-only).
|
|
83
|
+
|
|
84
|
+
Both ``response_text`` and ``used_todo`` are always present.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
88
|
+
has_tools: bool = False
|
|
89
|
+
response_text: str = ""
|
|
90
|
+
used_todo: bool = False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── LLM ──
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class LlmBeforeCtx(BaseHookCtx):
|
|
98
|
+
"""Context for :pyattr:`HookPoint.LLM_BEFORE`.
|
|
99
|
+
|
|
100
|
+
Directives: SHORT_CIRCUIT (set ``output`` to an ``LLMResponse``), BREAK.
|
|
101
|
+
Handler may modify any input field.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
105
|
+
system_prompt: str = ""
|
|
106
|
+
tools: list[dict[str, Any]] | None = None
|
|
107
|
+
max_tokens: int = 8000
|
|
108
|
+
temperature: float = 0.0
|
|
109
|
+
# Handler output (for SHORT_CIRCUIT)
|
|
110
|
+
output: LLMResponse | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class LlmAfterCtx(BaseHookCtx):
|
|
115
|
+
"""Context for :pyattr:`HookPoint.LLM_AFTER`.
|
|
116
|
+
|
|
117
|
+
Directives: BREAK.
|
|
118
|
+
Handler may replace ``output``.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
122
|
+
output: LLMResponse | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── Round decide ──
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class RoundDecideCtx(BaseHookCtx):
|
|
130
|
+
"""Context for :pyattr:`HookPoint.ROUND_DECIDE`.
|
|
131
|
+
|
|
132
|
+
Directives: SKIP (set ``output`` as skip message), BREAK.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
136
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
137
|
+
assistant_text: str = ""
|
|
138
|
+
# Handler output (for SKIP)
|
|
139
|
+
output: str = "[skipped by round_decide hook]"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Tools batch ──
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class ToolsBatchBeforeCtx(BaseHookCtx):
|
|
147
|
+
"""Context for :pyattr:`HookPoint.TOOLS_BATCH_BEFORE`.
|
|
148
|
+
|
|
149
|
+
Directives: SKIP (set ``output`` as placeholder result for all tools).
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
153
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
154
|
+
# Handler output (for SKIP)
|
|
155
|
+
output: str = "[skipped by batch hook]"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class ToolsBatchAfterCtx(BaseHookCtx):
|
|
160
|
+
"""Context for :pyattr:`HookPoint.TOOLS_BATCH_AFTER` (read-only)."""
|
|
161
|
+
|
|
162
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
163
|
+
used_todo: bool = False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── Individual tool ──
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class ToolBeforeCtx(BaseHookCtx):
|
|
171
|
+
"""Context for :pyattr:`HookPoint.TOOL_BEFORE`.
|
|
172
|
+
|
|
173
|
+
Directives: SKIP (set ``output``).
|
|
174
|
+
Handler may modify ``tool_name`` and ``tool_args``.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
tool_call: dict[str, Any] = field(default_factory=dict)
|
|
178
|
+
tool_name: str = ""
|
|
179
|
+
tool_args: dict[str, Any] = field(default_factory=dict)
|
|
180
|
+
# Handler output (for SKIP)
|
|
181
|
+
output: str = "[skipped by hook]"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class ToolAfterCtx(BaseHookCtx):
|
|
186
|
+
"""Context for :pyattr:`HookPoint.TOOL_AFTER`.
|
|
187
|
+
|
|
188
|
+
Directives: BREAK.
|
|
189
|
+
Handler may replace ``output`` and ``failed``.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
tool_call: dict[str, Any] = field(default_factory=dict)
|
|
193
|
+
tool_name: str = ""
|
|
194
|
+
tool_args: dict[str, Any] = field(default_factory=dict)
|
|
195
|
+
output: str = ""
|
|
196
|
+
failed: bool = False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class ToolErrorCtx(BaseHookCtx):
|
|
201
|
+
"""Context for :pyattr:`HookPoint.TOOL_ERROR`.
|
|
202
|
+
|
|
203
|
+
Directives: SKIP (use ``output`` as fallback), SHORT_CIRCUIT (retry).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
tool_call: dict[str, Any] = field(default_factory=dict)
|
|
207
|
+
tool_name: str = ""
|
|
208
|
+
tool_args: dict[str, Any] = field(default_factory=dict)
|
|
209
|
+
error: Exception | None = None
|
|
210
|
+
error_message: str = ""
|
|
211
|
+
# Handler output (fallback for SKIP)
|
|
212
|
+
output: str = ""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Compact ──
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class CompactBeforeCtx(BaseHookCtx):
|
|
220
|
+
"""Context for :pyattr:`HookPoint.COMPACT_BEFORE`.
|
|
221
|
+
|
|
222
|
+
Directives: SKIP (skip compaction this round).
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class CompactAfterCtx(BaseHookCtx):
|
|
230
|
+
"""Context for :pyattr:`HookPoint.COMPACT_AFTER` (read-only)."""
|
|
231
|
+
|
|
232
|
+
messages: list[LoopMessage] = field(default_factory=list)
|
|
233
|
+
messages_before_count: int = 0
|
|
234
|
+
messages_after_count: int = 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── Message ──
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class MessageAppendCtx(BaseHookCtx):
|
|
242
|
+
"""Context for :pyattr:`HookPoint.MESSAGE_APPEND`.
|
|
243
|
+
|
|
244
|
+
Handler may modify ``message``.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
message: dict[str, Any] = field(default_factory=dict)
|
|
248
|
+
session_id: str | None = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Memory (M1.9) ──
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class MemoryRecalledCtx(BaseHookCtx):
|
|
256
|
+
"""Context for :pyattr:`HookPoint.MEMORY_RECALLED`.
|
|
257
|
+
|
|
258
|
+
Fired after :meth:`MemoryProvider.recall` returns, before injection.
|
|
259
|
+
Handler may mutate ``recalled`` (filter, redact, reorder) or set
|
|
260
|
+
``directive=SKIP`` to drop everything and inject nothing.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
recalled: list[dict[str, Any]] = field(default_factory=list)
|
|
264
|
+
session_id: str | None = None
|
|
265
|
+
budget_tokens: int = 1500
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HookPoint(str, Enum):
|
|
9
|
+
SESSION_START = "session.start"
|
|
10
|
+
SESSION_END = "session.end"
|
|
11
|
+
ROUND_START = "round.start"
|
|
12
|
+
ROUND_END = "round.end"
|
|
13
|
+
LLM_BEFORE = "llm.before"
|
|
14
|
+
LLM_AFTER = "llm.after"
|
|
15
|
+
TOOLS_BATCH_BEFORE = "tools.batch.before"
|
|
16
|
+
TOOLS_BATCH_AFTER = "tools.batch.after"
|
|
17
|
+
TOOL_BEFORE = "tool.before"
|
|
18
|
+
TOOL_AFTER = "tool.after"
|
|
19
|
+
TOOL_ERROR = "tool.error"
|
|
20
|
+
ROUND_DECIDE = "round.decide"
|
|
21
|
+
COMPACT_BEFORE = "compact.before"
|
|
22
|
+
COMPACT_AFTER = "compact.after"
|
|
23
|
+
MESSAGE_APPEND = "message.append"
|
|
24
|
+
MEMORY_RECALLED = "memory.recalled"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HookDirective(str, Enum):
|
|
28
|
+
"""Control-flow directives that hooks can return to influence the agent loop.
|
|
29
|
+
|
|
30
|
+
Not every directive is valid at every hook point. The agent loop checks
|
|
31
|
+
the directive returned by ``hooks.run_async`` and acts accordingly.
|
|
32
|
+
|
|
33
|
+
Supported combinations:
|
|
34
|
+
ROUND_START -> BREAK (end loop), SKIP (skip this round)
|
|
35
|
+
LLM_BEFORE -> SHORT_CIRCUIT (use values["response"] instead of calling LLM)
|
|
36
|
+
LLM_AFTER -> BREAK (end loop, ignore tool calls)
|
|
37
|
+
ROUND_DECIDE -> SKIP (skip tool execution), BREAK (end loop)
|
|
38
|
+
TOOLS_BATCH_BEFORE -> SKIP (skip all tools this round)
|
|
39
|
+
TOOL_BEFORE -> SKIP (skip this tool, use values["tool_output"] as result)
|
|
40
|
+
TOOL_ERROR -> SKIP (swallow error, use values["tool_output"]),
|
|
41
|
+
SHORT_CIRCUIT (retry — re-invoke the tool)
|
|
42
|
+
TOOL_AFTER -> BREAK (stop executing remaining tools, proceed to next round)
|
|
43
|
+
COMPACT_BEFORE -> SKIP (skip compaction this round)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
CONTINUE = "continue"
|
|
47
|
+
SKIP = "skip"
|
|
48
|
+
BREAK = "break"
|
|
49
|
+
SHORT_CIRCUIT = "short_circuit"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class HookContext:
|
|
54
|
+
"""Mutable context passed through each hook chain."""
|
|
55
|
+
|
|
56
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class HookResult:
|
|
61
|
+
"""Return value from hook execution, carrying both context and control directive."""
|
|
62
|
+
|
|
63
|
+
context: HookContext = field(default_factory=HookContext)
|
|
64
|
+
directive: HookDirective = HookDirective.CONTINUE
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
MessageRole = Literal["system", "user", "assistant", "tool"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ToolCall:
|
|
11
|
+
"""Normalized tool call contract emitted by assistant messages."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
def to_openai_tool_call(self) -> dict[str, Any]:
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
"id": self.id,
|
|
22
|
+
"type": "function",
|
|
23
|
+
"function": {
|
|
24
|
+
"name": self.name,
|
|
25
|
+
"arguments": json.dumps(self.arguments, ensure_ascii=False),
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AgentMessage:
|
|
32
|
+
"""Unified runtime message contract used inside AgentLoop."""
|
|
33
|
+
|
|
34
|
+
role: MessageRole
|
|
35
|
+
content: str
|
|
36
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
37
|
+
tool_call_id: str | None = None
|
|
38
|
+
name: str | None = None
|
|
39
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def to_openai_message(self) -> dict[str, Any]:
|
|
42
|
+
payload: dict[str, Any] = {
|
|
43
|
+
"role": self.role,
|
|
44
|
+
"content": self.content,
|
|
45
|
+
}
|
|
46
|
+
if self.role == "assistant" and self.tool_calls:
|
|
47
|
+
payload["tool_calls"] = [call.to_openai_tool_call() for call in self.tool_calls]
|
|
48
|
+
if self.role == "tool" and self.tool_call_id:
|
|
49
|
+
payload["tool_call_id"] = self.tool_call_id
|
|
50
|
+
if self.name:
|
|
51
|
+
payload["name"] = self.name
|
|
52
|
+
return payload
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def SystemMessage(content: str, **metadata: Any) -> AgentMessage:
|
|
56
|
+
return AgentMessage(role="system", content=content, metadata=dict(metadata))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def UserMessage(content: str, **metadata: Any) -> AgentMessage:
|
|
60
|
+
return AgentMessage(role="user", content=content, metadata=dict(metadata))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def AssistantMessage(
|
|
64
|
+
content: str,
|
|
65
|
+
*,
|
|
66
|
+
tool_calls: list[ToolCall] | None = None,
|
|
67
|
+
**metadata: Any,
|
|
68
|
+
) -> AgentMessage:
|
|
69
|
+
return AgentMessage(
|
|
70
|
+
role="assistant",
|
|
71
|
+
content=content,
|
|
72
|
+
tool_calls=list(tool_calls or []),
|
|
73
|
+
metadata=dict(metadata),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ToolResultMessage(
|
|
78
|
+
content: str,
|
|
79
|
+
*,
|
|
80
|
+
tool_call_id: str,
|
|
81
|
+
name: str | None = None,
|
|
82
|
+
**metadata: Any,
|
|
83
|
+
) -> AgentMessage:
|
|
84
|
+
return AgentMessage(
|
|
85
|
+
role="tool",
|
|
86
|
+
content=content,
|
|
87
|
+
tool_call_id=tool_call_id,
|
|
88
|
+
name=name,
|
|
89
|
+
metadata=dict(metadata),
|
|
90
|
+
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from power_loop.contracts.events import AgentEvent, AgentEventType
|
|
7
|
+
from power_loop.contracts.handlers import EventHandler, HookHandler
|
|
8
|
+
from power_loop.contracts.hook_contexts import BaseHookCtx
|
|
9
|
+
from power_loop.contracts.hooks import HookContext, HookPoint, HookResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventBusProtocol(Protocol):
|
|
13
|
+
def subscribe(self, event_type: AgentEventType | None, handler: EventHandler, *, priority: int = 0) -> None:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def unsubscribe(self, handler: EventHandler) -> None:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def publish(self, event: AgentEvent) -> None:
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
async def publish_async(self, event: AgentEvent) -> None:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HookManagerProtocol(Protocol):
|
|
27
|
+
def register(self, hook_point: HookPoint | str, handler: HookHandler, *, order: int = 0) -> None:
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def clear(self, hook_point: HookPoint | str | None = None) -> None:
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
def run(self, hook_point: HookPoint | str, context: HookContext) -> HookResult:
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
async def run_async(self, hook_point: HookPoint | str, context: HookContext) -> HookResult:
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def run_typed(self, hook_point: HookPoint | str, ctx: BaseHookCtx) -> None:
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
async def run_typed_async(self, hook_point: HookPoint | str, ctx: BaseHookCtx) -> None:
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolArgsValidator(Protocol):
|
|
47
|
+
def __call__(self, tool_name: str, args: dict[str, Any]) -> str | None | Awaitable[str | None]:
|
|
48
|
+
...
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ToolDefinition:
|
|
10
|
+
name: str
|
|
11
|
+
description: str
|
|
12
|
+
input_schema: dict[str, Any] = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
13
|
+
required_params: tuple[str, ...] = ()
|
|
14
|
+
|
|
15
|
+
def to_openai_tool(self) -> dict[str, Any]:
|
|
16
|
+
return {
|
|
17
|
+
"type": "function",
|
|
18
|
+
"function": {
|
|
19
|
+
"name": self.name,
|
|
20
|
+
"description": self.description,
|
|
21
|
+
"parameters": dict(self.input_schema),
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_REQUIRED_PARAMS: dict[str, tuple[str, ...]] = {
|
|
27
|
+
"write_file": ("path", "content"),
|
|
28
|
+
"read_file": ("path",),
|
|
29
|
+
"edit_file": ("path", "old_text", "new_text"),
|
|
30
|
+
"apply_patch": ("path", "patch"),
|
|
31
|
+
"bash": ("command",),
|
|
32
|
+
"glob": ("pattern",),
|
|
33
|
+
"grep": ("pattern",),
|
|
34
|
+
"load_skill": ("name",),
|
|
35
|
+
"todo": ("items",),
|
|
36
|
+
"background_run": ("command",),
|
|
37
|
+
"web_search": ("query",),
|
|
38
|
+
"generate_image": ("prompt",),
|
|
39
|
+
"edit_image": ("image_paths", "prompt"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_tool_args(tool_name: str, args: Mapping[str, Any]) -> str | None:
|
|
44
|
+
required = DEFAULT_REQUIRED_PARAMS.get(tool_name)
|
|
45
|
+
if not required:
|
|
46
|
+
return None
|
|
47
|
+
missing = [param for param in required if param not in args]
|
|
48
|
+
if not missing:
|
|
49
|
+
return None
|
|
50
|
+
req = ", ".join(required)
|
|
51
|
+
miss = ", ".join(missing)
|
|
52
|
+
return (
|
|
53
|
+
f"Error: missing required parameter(s): {miss}. "
|
|
54
|
+
f"{tool_name} requires: {req}. "
|
|
55
|
+
"Please provide all required parameters as a valid JSON object."
|
|
56
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar, Token
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from power_loop.agent.stateful_loop import StatefulAgentLoop
|
|
8
|
+
from power_loop.core.events import AgentEventBus
|
|
9
|
+
from power_loop.core.hooks import AgentHooks
|
|
10
|
+
from power_loop.core.state import ContextManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_current_event_bus: ContextVar[AgentEventBus | None] = ContextVar("power_loop_event_bus", default=None)
|
|
14
|
+
_current_hooks: ContextVar[AgentHooks | None] = ContextVar("power_loop_hooks", default=None)
|
|
15
|
+
_current_ctx: ContextVar[ContextManager | None] = ContextVar("power_loop_ctx", default=None)
|
|
16
|
+
_current_session_id: ContextVar[str | None] = ContextVar("power_loop_session_id", default=None)
|
|
17
|
+
_current_loop: ContextVar[StatefulAgentLoop | None] = ContextVar(
|
|
18
|
+
"power_loop_current_loop", default=None
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_event_bus() -> AgentEventBus:
|
|
23
|
+
bus = _current_event_bus.get()
|
|
24
|
+
# Local import to avoid import cycles
|
|
25
|
+
from power_loop.core.events import DEFAULT_EVENT_BUS
|
|
26
|
+
|
|
27
|
+
return DEFAULT_EVENT_BUS if bus is None else bus
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_hooks() -> AgentHooks:
|
|
31
|
+
hooks = _current_hooks.get()
|
|
32
|
+
from power_loop.core.hooks import DEFAULT_HOOKS
|
|
33
|
+
|
|
34
|
+
return DEFAULT_HOOKS if hooks is None else hooks
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_ctx() -> ContextManager:
|
|
38
|
+
ctx = _current_ctx.get()
|
|
39
|
+
if ctx is None:
|
|
40
|
+
from power_loop.core.state import ContextManager
|
|
41
|
+
|
|
42
|
+
ctx = ContextManager(role="main")
|
|
43
|
+
_current_ctx.set(ctx)
|
|
44
|
+
return ctx
|
|
45
|
+
return ctx
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_session_id() -> str | None:
|
|
49
|
+
return _current_session_id.get()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_current_loop() -> StatefulAgentLoop | None:
|
|
53
|
+
return _current_loop.get()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def set_current_loop(loop: StatefulAgentLoop | None) -> Token:
|
|
57
|
+
return _current_loop.set(loop)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def reset_current_loop(token: Token) -> None:
|
|
61
|
+
_current_loop.reset(token)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_event_bus(bus: AgentEventBus | None) -> Token:
|
|
65
|
+
return _current_event_bus.set(bus)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_hooks(hooks: AgentHooks | None) -> Token:
|
|
69
|
+
return _current_hooks.set(hooks)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_ctx(ctx: ContextManager | None) -> Token:
|
|
73
|
+
return _current_ctx.set(ctx)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_session_id(session_id: str | None) -> Token:
|
|
77
|
+
return _current_session_id.set(session_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def reset_event_bus(token: Token) -> None:
|
|
81
|
+
_current_event_bus.reset(token)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def reset_hooks(token: Token) -> None:
|
|
85
|
+
_current_hooks.reset(token)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def reset_ctx(token: Token) -> None:
|
|
89
|
+
_current_ctx.reset(token)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def reset_session_id(token: Token) -> None:
|
|
93
|
+
_current_session_id.reset(token)
|
|
94
|
+
|