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,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from power_loop.contracts.errors import ToolNotFound, ToolValidationError
|
|
9
|
+
from power_loop.contracts.tools import ToolDefinition, validate_tool_args
|
|
10
|
+
|
|
11
|
+
ToolCallable = Callable[..., Any] | Callable[..., Awaitable[Any]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncToolInSyncContext(TypeError):
|
|
15
|
+
"""Raised when ``ToolRegistry.invoke`` (sync) is called for a handler
|
|
16
|
+
that is a coroutine function. The caller should use ``invoke_async``
|
|
17
|
+
instead — silently returning the unawaited coroutine corrupts loop
|
|
18
|
+
state and is the single most common cause of "tool seemed to succeed
|
|
19
|
+
but did nothing" bugs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RegisteredTool:
|
|
25
|
+
definition: ToolDefinition
|
|
26
|
+
handler: ToolCallable
|
|
27
|
+
is_async: bool = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolRegistry:
|
|
31
|
+
"""Open tool registry for dynamic bind/add/remove operations.
|
|
32
|
+
|
|
33
|
+
Design goals:
|
|
34
|
+
- Runtime dynamic registration for library users
|
|
35
|
+
- Tool schema and handler decoupled but bound by the same name
|
|
36
|
+
- One execution entry (`invoke`/`invoke_async`) with built-in required-param validation
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._tools: dict[str, RegisteredTool] = {}
|
|
41
|
+
|
|
42
|
+
def register(self, definition: ToolDefinition, handler: ToolCallable, *, overwrite: bool = False) -> None:
|
|
43
|
+
if not overwrite and definition.name in self._tools:
|
|
44
|
+
raise ValueError(f"Tool already registered: {definition.name}")
|
|
45
|
+
# Detect async at register time so ``invoke()`` can raise a clear
|
|
46
|
+
# error without doing the call first. ``iscoroutinefunction``
|
|
47
|
+
# covers ``async def``; for callable objects whose ``__call__`` is
|
|
48
|
+
# async we additionally check that. Plain sync callables that
|
|
49
|
+
# *happen* to return awaitables are still handled at call time by
|
|
50
|
+
# ``invoke_async``.
|
|
51
|
+
is_async = inspect.iscoroutinefunction(handler)
|
|
52
|
+
if not is_async and not inspect.isfunction(handler) and callable(handler):
|
|
53
|
+
# Callable object whose ``__call__`` is async (e.g. dataclass
|
|
54
|
+
# with ``async def __call__``). Check that explicitly.
|
|
55
|
+
is_async = inspect.iscoroutinefunction(handler.__call__)
|
|
56
|
+
self._tools[definition.name] = RegisteredTool(
|
|
57
|
+
definition=definition, handler=handler, is_async=is_async,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def unregister(self, name: str) -> None:
|
|
61
|
+
self._tools.pop(name, None)
|
|
62
|
+
|
|
63
|
+
def has(self, name: str) -> bool:
|
|
64
|
+
return name in self._tools
|
|
65
|
+
|
|
66
|
+
def get(self, name: str) -> RegisteredTool | None:
|
|
67
|
+
return self._tools.get(name)
|
|
68
|
+
|
|
69
|
+
def definitions(self) -> list[ToolDefinition]:
|
|
70
|
+
return [item.definition for item in self._tools.values()]
|
|
71
|
+
|
|
72
|
+
def to_openai_tools(self) -> list[dict[str, Any]]:
|
|
73
|
+
return [d.to_openai_tool() for d in self.definitions()]
|
|
74
|
+
|
|
75
|
+
def validate(self, name: str, args: Mapping[str, Any]) -> str | None:
|
|
76
|
+
"""Validate tool name and arguments. Returns an error string or ``None``.
|
|
77
|
+
|
|
78
|
+
This is a **legacy internal** method kept for the pipeline's
|
|
79
|
+
``execute_tool``; new code should call ``_raise_if_invalid`` or
|
|
80
|
+
invoke directly and catch ``ToolNotFound`` / ``ToolValidationError``.
|
|
81
|
+
"""
|
|
82
|
+
tool = self._tools.get(name)
|
|
83
|
+
if tool is None:
|
|
84
|
+
return f"Unknown tool: {name}"
|
|
85
|
+
|
|
86
|
+
err = validate_tool_args(name, args)
|
|
87
|
+
if err:
|
|
88
|
+
return err
|
|
89
|
+
|
|
90
|
+
missing = [p for p in tool.definition.required_params if p not in args]
|
|
91
|
+
if missing:
|
|
92
|
+
return f"Error: missing required parameter(s): {', '.join(missing)}"
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _raise_if_invalid(self, name: str, args: Mapping[str, Any]) -> None:
|
|
96
|
+
"""Raise :class:`ToolNotFound` / :class:`ToolValidationError` if the
|
|
97
|
+
tool or its args are invalid."""
|
|
98
|
+
tool = self._tools.get(name)
|
|
99
|
+
if tool is None:
|
|
100
|
+
raise ToolNotFound(name)
|
|
101
|
+
|
|
102
|
+
err = validate_tool_args(name, args)
|
|
103
|
+
if err:
|
|
104
|
+
raise ToolValidationError(name, err)
|
|
105
|
+
|
|
106
|
+
missing = [p for p in tool.definition.required_params if p not in args]
|
|
107
|
+
if missing:
|
|
108
|
+
raise ToolValidationError(
|
|
109
|
+
name, f"missing required parameter(s): {', '.join(missing)}",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def invoke(self, name: str, args: Mapping[str, Any]) -> Any:
|
|
113
|
+
"""Sync invocation. Raises :class:`ToolNotFound` if the tool is
|
|
114
|
+
not registered, :class:`ToolValidationError` if args fail validation,
|
|
115
|
+
and :class:`AsyncToolInSyncContext` if the handler is ``async def``.
|
|
116
|
+
"""
|
|
117
|
+
tool = self._tools.get(name)
|
|
118
|
+
if tool is None:
|
|
119
|
+
raise ToolNotFound(name)
|
|
120
|
+
|
|
121
|
+
if tool.is_async:
|
|
122
|
+
raise AsyncToolInSyncContext(
|
|
123
|
+
f"Tool {name!r} has an async handler; call invoke_async() instead."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self._raise_if_invalid(name, args)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return tool.handler(**dict(args))
|
|
130
|
+
except TypeError:
|
|
131
|
+
return tool.handler(dict(args))
|
|
132
|
+
|
|
133
|
+
async def invoke_async(self, name: str, args: Mapping[str, Any]) -> Any:
|
|
134
|
+
"""Universal invocation entry. Raises :class:`ToolNotFound` if the
|
|
135
|
+
tool is not registered, :class:`ToolValidationError` if args fail
|
|
136
|
+
validation."""
|
|
137
|
+
tool = self._tools.get(name)
|
|
138
|
+
if tool is None:
|
|
139
|
+
raise ToolNotFound(name)
|
|
140
|
+
|
|
141
|
+
self._raise_if_invalid(name, args)
|
|
142
|
+
|
|
143
|
+
if tool.is_async:
|
|
144
|
+
return await tool.handler(**dict(args))
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = tool.handler(**dict(args))
|
|
148
|
+
except TypeError:
|
|
149
|
+
result = tool.handler(dict(args))
|
|
150
|
+
if inspect.isawaitable(result):
|
|
151
|
+
return await result
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_registry(definitions: list[ToolDefinition], handlers: Mapping[str, ToolCallable]) -> ToolRegistry:
|
|
156
|
+
registry = ToolRegistry()
|
|
157
|
+
for definition in definitions:
|
|
158
|
+
handler = handlers.get(definition.name)
|
|
159
|
+
if handler is None:
|
|
160
|
+
raise ValueError(f"Missing handler for tool definition: {definition.name}")
|
|
161
|
+
registry.register(definition, handler)
|
|
162
|
+
return registry
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""spawn_agent + run_agent — meta-tools the LLM uses to delegate work.
|
|
2
|
+
|
|
3
|
+
Two flavours of subagent invocation sit on the same plumbing
|
|
4
|
+
(:func:`power_loop.runtime.spec.run_agent_spec`):
|
|
5
|
+
|
|
6
|
+
* ``spawn_agent(task, preset=…)`` — *imperative*. Simple kwargs, the library
|
|
7
|
+
builds an :class:`AgentSpec` with sensible defaults for ``system_prompt`` and
|
|
8
|
+
the default tool preset. Designed for the common "go do this" case.
|
|
9
|
+
* ``run_agent(spec_json, input)`` — *declarative*. The LLM provides a full
|
|
10
|
+
:class:`AgentSpec` JSON (custom system prompt, explicit tool whitelist,
|
|
11
|
+
max_rounds, etc). Designed for dynamic-workflow patterns where the parent
|
|
12
|
+
agent reasons about what a child should look like.
|
|
13
|
+
|
|
14
|
+
Both tools require an active :class:`StatefulAgentLoop` context (set by
|
|
15
|
+
:meth:`StatefulAgentLoop._run_loop`). Calling them outside one returns a
|
|
16
|
+
clear error string.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from power_loop.contracts.tools import ToolDefinition
|
|
24
|
+
from power_loop.core.agent_context import get_current_loop
|
|
25
|
+
from power_loop.runtime.spec import AgentSpec, AgentSpecError, run_agent_spec
|
|
26
|
+
|
|
27
|
+
DEFAULT_MAX_ROUNDS = 20
|
|
28
|
+
|
|
29
|
+
SPAWN_AGENT_DEFINITION = ToolDefinition(
|
|
30
|
+
name="spawn_agent",
|
|
31
|
+
description=(
|
|
32
|
+
"Spawn a sub-agent to handle a delegated task in an isolated session. "
|
|
33
|
+
"The sub-agent inherits the parent's tool registry (filterable via "
|
|
34
|
+
"the 'tools' arg). Returns the sub-agent's final text."
|
|
35
|
+
),
|
|
36
|
+
input_schema={
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"task": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "The task description / instructions for the sub-agent.",
|
|
42
|
+
},
|
|
43
|
+
"system_prompt": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": (
|
|
46
|
+
"Optional system prompt override. Defaults to a generic "
|
|
47
|
+
"task-completion prompt."
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
"tools": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": {"type": "string"},
|
|
53
|
+
"description": (
|
|
54
|
+
"Optional whitelist of tool names from the parent registry. "
|
|
55
|
+
"Omit to grant the sub-agent the full parent toolset."
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
"max_rounds": {
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"description": f"Maximum rounds (default {DEFAULT_MAX_ROUNDS}, clamp 1-50).",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"required": ["task"],
|
|
64
|
+
},
|
|
65
|
+
required_params=("task",),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
RUN_AGENT_DEFINITION = ToolDefinition(
|
|
69
|
+
name="run_agent",
|
|
70
|
+
description=(
|
|
71
|
+
"Materialize a full AgentSpec JSON as a one-shot sub-agent. Use when "
|
|
72
|
+
"you want explicit control over name / system_prompt / tools / "
|
|
73
|
+
"max_rounds / max_tokens / temperature / lifecycle. Strict schema: "
|
|
74
|
+
"unknown fields are rejected."
|
|
75
|
+
),
|
|
76
|
+
input_schema={
|
|
77
|
+
"type": "object",
|
|
78
|
+
"properties": {
|
|
79
|
+
"spec": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"description": "AgentSpec object (or JSON string).",
|
|
82
|
+
},
|
|
83
|
+
"input": {
|
|
84
|
+
"type": "string",
|
|
85
|
+
"description": "The initial user message sent to the sub-agent.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"required": ["spec", "input"],
|
|
89
|
+
},
|
|
90
|
+
required_params=("spec", "input"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── handlers ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_DEFAULT_SUB_SYSTEM_PROMPT = (
|
|
98
|
+
"You are a delegated sub-agent. Complete the task the parent gave you, "
|
|
99
|
+
"be concise, and return your final answer in the last assistant message."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _handle_spawn_agent(**kwargs: Any) -> str:
|
|
104
|
+
loop = get_current_loop()
|
|
105
|
+
if loop is None:
|
|
106
|
+
return (
|
|
107
|
+
"Error: spawn_agent must be invoked from inside an active "
|
|
108
|
+
"StatefulAgentLoop run."
|
|
109
|
+
)
|
|
110
|
+
task = str(kwargs.get("task") or "").strip()
|
|
111
|
+
if not task:
|
|
112
|
+
return "Error: spawn_agent requires 'task'."
|
|
113
|
+
|
|
114
|
+
spec = AgentSpec(
|
|
115
|
+
name=str(kwargs.get("name") or "delegate"),
|
|
116
|
+
system_prompt=str(kwargs.get("system_prompt") or _DEFAULT_SUB_SYSTEM_PROMPT),
|
|
117
|
+
tools=kwargs.get("tools"),
|
|
118
|
+
max_rounds=int(kwargs.get("max_rounds") or DEFAULT_MAX_ROUNDS),
|
|
119
|
+
)
|
|
120
|
+
result = await run_agent_spec(spec, task, parent_loop=loop)
|
|
121
|
+
return _format_subagent_result(result)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _handle_run_agent(**kwargs: Any) -> str:
|
|
125
|
+
loop = get_current_loop()
|
|
126
|
+
if loop is None:
|
|
127
|
+
return (
|
|
128
|
+
"Error: run_agent must be invoked from inside an active "
|
|
129
|
+
"StatefulAgentLoop run."
|
|
130
|
+
)
|
|
131
|
+
spec_payload = kwargs.get("spec")
|
|
132
|
+
user_input = str(kwargs.get("input") or "")
|
|
133
|
+
if not user_input:
|
|
134
|
+
return "Error: run_agent requires 'input'."
|
|
135
|
+
try:
|
|
136
|
+
spec = AgentSpec.from_json(spec_payload) if not isinstance(spec_payload, AgentSpec) else spec_payload
|
|
137
|
+
except AgentSpecError as exc:
|
|
138
|
+
return f"Error: invalid AgentSpec — {exc}"
|
|
139
|
+
|
|
140
|
+
result = await run_agent_spec(spec, user_input, parent_loop=loop)
|
|
141
|
+
return _format_subagent_result(result)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _format_subagent_result(result: dict[str, Any]) -> str:
|
|
145
|
+
text = result.get("final_text") or "(no output)"
|
|
146
|
+
status = result.get("status")
|
|
147
|
+
if status and status != "completed":
|
|
148
|
+
return f"[sub-agent status={status}]\n{text}"
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── registration helpers ──────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def register_spawn_agent(registry, *, include_run_agent: bool = True, overwrite: bool = False) -> None:
|
|
156
|
+
"""Register the spawn_agent (and optionally run_agent) tools on ``registry``.
|
|
157
|
+
|
|
158
|
+
Usage::
|
|
159
|
+
|
|
160
|
+
from power_loop import create_default_tool_registry, register_spawn_agent
|
|
161
|
+
registry = create_default_tool_registry()
|
|
162
|
+
register_spawn_agent(registry)
|
|
163
|
+
"""
|
|
164
|
+
registry.register(SPAWN_AGENT_DEFINITION, _handle_spawn_agent, overwrite=overwrite)
|
|
165
|
+
if include_run_agent:
|
|
166
|
+
registry.register(RUN_AGENT_DEFINITION, _handle_run_agent, overwrite=overwrite)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"SPAWN_AGENT_DEFINITION",
|
|
171
|
+
"RUN_AGENT_DEFINITION",
|
|
172
|
+
"register_spawn_agent",
|
|
173
|
+
]
|