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,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
+ ]