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,201 @@
|
|
|
1
|
+
"""Skill loading from ``SKILL.md`` files.
|
|
2
|
+
|
|
3
|
+
Skills are Markdown files with YAML frontmatter. Each skill lives in its own
|
|
4
|
+
directory under ``skills_dir``::
|
|
5
|
+
|
|
6
|
+
skills_dir/
|
|
7
|
+
├── python-expert/
|
|
8
|
+
│ └── SKILL.md # name: python-expert, description: ...
|
|
9
|
+
└── security-reviewer/
|
|
10
|
+
└── SKILL.md
|
|
11
|
+
|
|
12
|
+
The library ships with a :class:`SkillLoader` that parses these files and
|
|
13
|
+
provides ``get_descriptions()`` (for system prompts) and ``get_content()``
|
|
14
|
+
(for detailed instructions). A ``load_skill`` tool can be registered in
|
|
15
|
+
:class:`ToolRegistry` so the LLM can dynamically fetch skill content.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from importlib import import_module
|
|
22
|
+
from importlib.util import find_spec
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from power_loop.contracts.tools import ToolDefinition
|
|
27
|
+
from power_loop.runtime.env import AGENT_DIR, WORKSPACE_DIR
|
|
28
|
+
from power_loop.runtime.env import SKILLS_DIR as ENV_SKILLS_DIR
|
|
29
|
+
|
|
30
|
+
_yaml = import_module("yaml") if find_spec("yaml") else None
|
|
31
|
+
|
|
32
|
+
LOAD_SKILL_DEFINITION = ToolDefinition(
|
|
33
|
+
name="load_skill",
|
|
34
|
+
description=(
|
|
35
|
+
"Load the full content of a skill by name. "
|
|
36
|
+
"Use this when you need detailed instructions for a specific domain. "
|
|
37
|
+
"Param: name (string) — the skill name (e.g. 'python-expert')."
|
|
38
|
+
),
|
|
39
|
+
input_schema={
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {"name": {"type": "string", "description": "Skill name to load"}},
|
|
42
|
+
"required": ["name"],
|
|
43
|
+
},
|
|
44
|
+
required_params=("name",),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_skills_dir(explicit: str | Path | None) -> Path:
|
|
49
|
+
"""Resolve the skills directory from config, env, or default."""
|
|
50
|
+
if explicit is not None:
|
|
51
|
+
path = Path(explicit).expanduser()
|
|
52
|
+
if not path.is_absolute():
|
|
53
|
+
path = (AGENT_DIR / path).resolve()
|
|
54
|
+
else:
|
|
55
|
+
path = path.resolve()
|
|
56
|
+
if path.exists() and path.is_dir():
|
|
57
|
+
return path
|
|
58
|
+
return ENV_SKILLS_DIR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SkillLoader:
|
|
62
|
+
"""Loads and caches ``SKILL.md`` files from a directory tree.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
skills_dir: Path to the directory containing skill subdirectories.
|
|
66
|
+
If ``None``, uses the env-var / default resolution chain.
|
|
67
|
+
|
|
68
|
+
The loader scans for ``*/SKILL.md`` files and parses their YAML
|
|
69
|
+
frontmatter. Each skill's ``name`` comes from its parent directory name.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, skills_dir: str | Path | None = None):
|
|
73
|
+
self.skills_dir: Path = _resolve_skills_dir(skills_dir)
|
|
74
|
+
self.skills: dict[str, dict[str, Any]] = {}
|
|
75
|
+
self._load_all()
|
|
76
|
+
|
|
77
|
+
def reload(self) -> None:
|
|
78
|
+
"""Rescan the skills directory. Call after adding/removing skills."""
|
|
79
|
+
self.skills.clear()
|
|
80
|
+
self._load_all()
|
|
81
|
+
|
|
82
|
+
# ── Internal ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def _load_all(self) -> None:
|
|
85
|
+
if not self.skills_dir.exists():
|
|
86
|
+
return
|
|
87
|
+
for f in sorted(self.skills_dir.glob("*/SKILL.md")):
|
|
88
|
+
name = f.parent.name
|
|
89
|
+
text = f.read_text(encoding="utf-8")
|
|
90
|
+
meta, body = self._parse_frontmatter(text)
|
|
91
|
+
self.skills[name] = {"meta": meta, "body": body, "path": str(f.resolve())}
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
|
95
|
+
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", text, re.DOTALL)
|
|
96
|
+
if not match:
|
|
97
|
+
return {}, text.strip()
|
|
98
|
+
body = match.group(2).strip()
|
|
99
|
+
if _yaml is None:
|
|
100
|
+
return {}, body
|
|
101
|
+
try:
|
|
102
|
+
meta = _yaml.safe_load(match.group(1))
|
|
103
|
+
if not isinstance(meta, dict):
|
|
104
|
+
meta = {}
|
|
105
|
+
except Exception:
|
|
106
|
+
meta = {}
|
|
107
|
+
return meta, body
|
|
108
|
+
|
|
109
|
+
# ── Public API ──────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def names(self) -> list[str]:
|
|
113
|
+
"""Sorted list of available skill names."""
|
|
114
|
+
return sorted(self.skills.keys())
|
|
115
|
+
|
|
116
|
+
def get_descriptions(self) -> str:
|
|
117
|
+
"""One-line-per-skill summary suitable for system prompts."""
|
|
118
|
+
if not self.skills:
|
|
119
|
+
return "(no skills available)"
|
|
120
|
+
lines = ["Available skills:"]
|
|
121
|
+
for name in sorted(self.skills):
|
|
122
|
+
s = self.skills[name]
|
|
123
|
+
desc = s["meta"].get("description", "No description")
|
|
124
|
+
lines.append(f" - {name}: {desc}")
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
def get_content(self, name: str) -> str:
|
|
128
|
+
"""Full instructions for a skill, including execution context."""
|
|
129
|
+
skill = self.skills.get(name)
|
|
130
|
+
if not skill:
|
|
131
|
+
available = ", ".join(sorted(self.skills))
|
|
132
|
+
return f"Error: Unknown skill '{name}'. Available: {available}"
|
|
133
|
+
skill_path = Path(skill["path"]).resolve()
|
|
134
|
+
skill_root = skill_path.parent
|
|
135
|
+
return (
|
|
136
|
+
f'<skill name="{name}" path="{skill["path"]}">\n'
|
|
137
|
+
f"Source: {skill['path']}\n\n"
|
|
138
|
+
"[Execution Context]\n"
|
|
139
|
+
f"- Workspace (user project): {WORKSPACE_DIR}\n"
|
|
140
|
+
f"- Agent home: {AGENT_DIR}\n"
|
|
141
|
+
f"- Skill root: {skill_root}\n"
|
|
142
|
+
"- Rules:\n"
|
|
143
|
+
f" 1) Run skill-relative commands from skill root: {skill_root}\n"
|
|
144
|
+
f" 2) Prefer absolute paths when applicable.\n"
|
|
145
|
+
" 3) Output files go to workspace.\n"
|
|
146
|
+
" 4) Do not search outside workspace/skill root unless requested.\n\n"
|
|
147
|
+
f"{skill['body']}\n"
|
|
148
|
+
"</skill>"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def build_system_prompt_section(self) -> str:
|
|
152
|
+
"""A section to append to ``AgentLoopConfig.system_prompt``.
|
|
153
|
+
|
|
154
|
+
Includes skill descriptions in a compact format the LLM can parse.
|
|
155
|
+
"""
|
|
156
|
+
if not self.skills:
|
|
157
|
+
return ""
|
|
158
|
+
parts = ["## Available Skills"]
|
|
159
|
+
parts.append(
|
|
160
|
+
"You have a `load_skill` tool. Call `load_skill(name=\"...\")` "
|
|
161
|
+
"to get detailed instructions for any skill below.\n"
|
|
162
|
+
)
|
|
163
|
+
parts.append(self.get_descriptions())
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
|
|
166
|
+
# ── Tool handler ────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def handle_load_skill(self, name: str) -> str:
|
|
169
|
+
"""Handler for the ``load_skill`` tool. Returns the skill's full content."""
|
|
170
|
+
return self.get_content(name)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Module-level singleton for backward compatibility.
|
|
174
|
+
_default_loader: SkillLoader | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_default_loader() -> SkillLoader:
|
|
178
|
+
"""Return the module-level :class:`SkillLoader` singleton."""
|
|
179
|
+
global _default_loader
|
|
180
|
+
if _default_loader is None:
|
|
181
|
+
_default_loader = SkillLoader()
|
|
182
|
+
return _default_loader
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def register_skill_tools(registry, *, skills_dir: str | Path | None = None) -> SkillLoader:
|
|
186
|
+
"""Register the ``load_skill`` tool in the given registry.
|
|
187
|
+
|
|
188
|
+
Returns the :class:`SkillLoader` instance so callers can inspect or
|
|
189
|
+
inject the skill descriptions into the system prompt.
|
|
190
|
+
"""
|
|
191
|
+
loader = SkillLoader(skills_dir)
|
|
192
|
+
registry.register(LOAD_SKILL_DEFINITION, loader.handle_load_skill)
|
|
193
|
+
return loader
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = [
|
|
197
|
+
"LOAD_SKILL_DEFINITION",
|
|
198
|
+
"SkillLoader",
|
|
199
|
+
"get_default_loader",
|
|
200
|
+
"register_skill_tools",
|
|
201
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Declarative subagent spec (M1.8).
|
|
2
|
+
|
|
3
|
+
The parent agent emits an :class:`AgentSpec` (as JSON or dict) describing
|
|
4
|
+
*what kind of* subagent to run, and :func:`run_agent_spec` materializes it as
|
|
5
|
+
a child :class:`StatefulAgentLoop` session.
|
|
6
|
+
|
|
7
|
+
This is the in-process complement of the imperative :mod:`spawn_agent` tool:
|
|
8
|
+
|
|
9
|
+
* ``spawn_agent`` — a tool the LLM calls in turn-form ("delegate this task").
|
|
10
|
+
* ``run_agent`` (meta-tool) — accepts a full :class:`AgentSpec` JSON for
|
|
11
|
+
cases where the parent wants explicit control over the child's tool
|
|
12
|
+
whitelist / model / max_rounds / system prompt.
|
|
13
|
+
|
|
14
|
+
Both paths reuse this module's :func:`run_agent_spec` so depth-guard,
|
|
15
|
+
parent-linking, lifecycle cleanup and tool-whitelist filtering live in one
|
|
16
|
+
place.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field, fields
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from power_loop.agent.types import AgentLoopConfig
|
|
26
|
+
from power_loop.contracts.errors import SpecValidationError
|
|
27
|
+
from power_loop.runtime.session_store import MAX_SPAWN_DEPTH, SubagentLifecycle
|
|
28
|
+
from power_loop.tools.registry import ToolRegistry
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AgentSpec",
|
|
32
|
+
"AgentSpecError",
|
|
33
|
+
"filtered_registry",
|
|
34
|
+
"run_agent_spec",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentSpecError(SpecValidationError):
|
|
39
|
+
"""Raised when an :class:`AgentSpec` payload fails strict validation.
|
|
40
|
+
|
|
41
|
+
Inherits from :class:`~power_loop.contracts.errors.SpecValidationError`
|
|
42
|
+
so ``except PowerLoopError`` catches it alongside all other library errors.
|
|
43
|
+
"""
|
|
44
|
+
# Keep ValueError-compatible interface for callers that already do
|
|
45
|
+
# ``except ValueError`` or ``except AgentSpecError``.
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class AgentSpec:
|
|
51
|
+
"""Strict-schema description of a one-shot subagent run.
|
|
52
|
+
|
|
53
|
+
All fields are explicit; unknown JSON keys are rejected so a hallucinated
|
|
54
|
+
payload from the parent LLM surfaces early.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
system_prompt: str
|
|
59
|
+
tools: list[str] | None = None # whitelist; None = inherit all from parent
|
|
60
|
+
max_rounds: int = 8
|
|
61
|
+
max_tokens: int = 4000
|
|
62
|
+
temperature: float = 0.0
|
|
63
|
+
model: str | None = None
|
|
64
|
+
lifecycle: str = SubagentLifecycle.EPHEMERAL.value
|
|
65
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
# ── validation ────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if not self.name or not isinstance(self.name, str):
|
|
71
|
+
raise AgentSpecError("AgentSpec.name must be a non-empty string")
|
|
72
|
+
if not self.system_prompt or not isinstance(self.system_prompt, str):
|
|
73
|
+
raise AgentSpecError("AgentSpec.system_prompt must be a non-empty string")
|
|
74
|
+
if self.tools is not None:
|
|
75
|
+
if not isinstance(self.tools, list) or not all(isinstance(x, str) for x in self.tools):
|
|
76
|
+
raise AgentSpecError("AgentSpec.tools must be a list[str] or None")
|
|
77
|
+
if not 1 <= int(self.max_rounds) <= 50:
|
|
78
|
+
raise AgentSpecError("AgentSpec.max_rounds must be in [1, 50]")
|
|
79
|
+
try:
|
|
80
|
+
SubagentLifecycle(self.lifecycle)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
raise AgentSpecError(f"unknown lifecycle: {self.lifecycle}") from exc
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentSpec:
|
|
86
|
+
allowed = {f.name for f in fields(cls)}
|
|
87
|
+
unknown = set(data) - allowed
|
|
88
|
+
if unknown:
|
|
89
|
+
raise AgentSpecError(f"unknown AgentSpec field(s): {sorted(unknown)}")
|
|
90
|
+
return cls(**{k: v for k, v in data.items() if k in allowed})
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_json(cls, payload: str | dict[str, Any]) -> AgentSpec:
|
|
94
|
+
if isinstance(payload, str):
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(payload)
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
raise AgentSpecError(f"AgentSpec JSON parse error: {exc}") from exc
|
|
99
|
+
else:
|
|
100
|
+
data = dict(payload)
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise AgentSpecError("AgentSpec payload must decode to an object")
|
|
103
|
+
return cls.from_dict(data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def filtered_registry(
|
|
110
|
+
parent_registry: ToolRegistry | None, whitelist: list[str] | None
|
|
111
|
+
) -> ToolRegistry | None:
|
|
112
|
+
"""Return a registry containing only the whitelisted tools from ``parent``.
|
|
113
|
+
|
|
114
|
+
* ``parent_registry=None`` → ``None``
|
|
115
|
+
* ``whitelist=None`` → return the parent registry as-is (full inherit)
|
|
116
|
+
* Names missing from the parent registry are silently skipped (subagent
|
|
117
|
+
simply doesn't see them).
|
|
118
|
+
"""
|
|
119
|
+
if parent_registry is None:
|
|
120
|
+
return None
|
|
121
|
+
if whitelist is None:
|
|
122
|
+
return parent_registry
|
|
123
|
+
sub = ToolRegistry()
|
|
124
|
+
for name in whitelist:
|
|
125
|
+
existing = parent_registry.get(name)
|
|
126
|
+
if existing is None:
|
|
127
|
+
continue
|
|
128
|
+
sub.register(existing.definition, existing.handler, overwrite=True)
|
|
129
|
+
return sub
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def run_agent_spec(
|
|
133
|
+
spec: AgentSpec | dict[str, Any] | str,
|
|
134
|
+
user_input: str,
|
|
135
|
+
*,
|
|
136
|
+
parent_loop: Any,
|
|
137
|
+
spawn_tool_call_id: str | None = None,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""Materialize ``spec`` as a child session under ``parent_loop`` and run it.
|
|
140
|
+
|
|
141
|
+
Returns a dict with ``final_text``, ``status``, ``rounds``, ``session_id``,
|
|
142
|
+
``depth`` — easy for the parent LLM to consume as a tool result.
|
|
143
|
+
|
|
144
|
+
Lifecycle:
|
|
145
|
+
* ``EPHEMERAL`` (default) → child session is deleted on success;
|
|
146
|
+
preserved on non-completed status (``hit_round_limit`` / ``cancelled``)
|
|
147
|
+
so the parent can debug.
|
|
148
|
+
* ``LINKED`` → child kept; cascade-deleted when parent is closed.
|
|
149
|
+
* ``DETACHED`` → child kept, lives independently.
|
|
150
|
+
"""
|
|
151
|
+
if not isinstance(spec, AgentSpec):
|
|
152
|
+
spec = AgentSpec.from_json(spec)
|
|
153
|
+
|
|
154
|
+
parent_sid = None
|
|
155
|
+
# parent_loop must be a StatefulAgentLoop; we accept Any to avoid a cycle.
|
|
156
|
+
from power_loop.core.agent_context import get_session_id
|
|
157
|
+
parent_sid = get_session_id()
|
|
158
|
+
|
|
159
|
+
# Pre-check depth so we don't even create the session row if it'll bounce.
|
|
160
|
+
if parent_sid is not None:
|
|
161
|
+
parent_row = parent_loop.store.get_session(parent_sid)
|
|
162
|
+
if parent_row is not None and parent_row.spawn_depth + 1 > MAX_SPAWN_DEPTH:
|
|
163
|
+
return {
|
|
164
|
+
"session_id": None,
|
|
165
|
+
"status": "rejected",
|
|
166
|
+
"final_text": (
|
|
167
|
+
f"spawn rejected — depth {parent_row.spawn_depth + 1} "
|
|
168
|
+
f"exceeds max {MAX_SPAWN_DEPTH}"
|
|
169
|
+
),
|
|
170
|
+
"rounds": 0,
|
|
171
|
+
"depth": parent_row.spawn_depth + 1,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
child_config = AgentLoopConfig(
|
|
175
|
+
system_prompt=spec.system_prompt,
|
|
176
|
+
max_rounds=int(spec.max_rounds),
|
|
177
|
+
max_tokens=int(spec.max_tokens),
|
|
178
|
+
temperature=float(spec.temperature),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
child_sid = parent_loop.store.create_session(
|
|
182
|
+
system_prompt=spec.system_prompt,
|
|
183
|
+
config={
|
|
184
|
+
"spec_name": spec.name,
|
|
185
|
+
"max_rounds": child_config.max_rounds,
|
|
186
|
+
"max_tokens": child_config.max_tokens,
|
|
187
|
+
"temperature": child_config.temperature,
|
|
188
|
+
"model": spec.model,
|
|
189
|
+
},
|
|
190
|
+
parent_session_id=parent_sid,
|
|
191
|
+
spawn_tool_call_id=spawn_tool_call_id,
|
|
192
|
+
lifecycle=SubagentLifecycle(spec.lifecycle),
|
|
193
|
+
metadata=dict(spec.metadata),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
sub_registry = filtered_registry(parent_loop.tool_registry, spec.tools)
|
|
197
|
+
|
|
198
|
+
# Build a sibling loop sharing the same store + registry-subset.
|
|
199
|
+
from power_loop.agent.stateful_loop import StatefulAgentLoop
|
|
200
|
+
|
|
201
|
+
child_loop = StatefulAgentLoop(
|
|
202
|
+
llm=parent_loop.llm,
|
|
203
|
+
store=parent_loop.store,
|
|
204
|
+
config=child_config,
|
|
205
|
+
tool_registry=sub_registry,
|
|
206
|
+
hooks=parent_loop._runner.hooks,
|
|
207
|
+
event_bus=parent_loop._runner.event_bus,
|
|
208
|
+
)
|
|
209
|
+
result = await child_loop.send(user_input, session_id=child_sid)
|
|
210
|
+
|
|
211
|
+
# Lifecycle cleanup.
|
|
212
|
+
if spec.lifecycle == SubagentLifecycle.EPHEMERAL.value:
|
|
213
|
+
if result.status == "completed":
|
|
214
|
+
parent_loop.store.close_session(child_sid, cascade=True)
|
|
215
|
+
returned_sid: str | None = None
|
|
216
|
+
else:
|
|
217
|
+
# Preserve for debugging.
|
|
218
|
+
returned_sid = child_sid
|
|
219
|
+
else:
|
|
220
|
+
returned_sid = child_sid
|
|
221
|
+
|
|
222
|
+
child_row = parent_loop.store.get_session(child_sid)
|
|
223
|
+
depth = child_row.spawn_depth if child_row is not None else (
|
|
224
|
+
(parent_loop.store.get_session(parent_sid).spawn_depth + 1) if parent_sid else 1
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"session_id": returned_sid,
|
|
229
|
+
"status": result.status,
|
|
230
|
+
"final_text": result.final_text,
|
|
231
|
+
"rounds": result.rounds,
|
|
232
|
+
"depth": depth,
|
|
233
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Structured output helpers (M1.3).
|
|
2
|
+
|
|
3
|
+
Two things, kept deliberately small:
|
|
4
|
+
|
|
5
|
+
1. :class:`StructuredOutputSpec` —— declarative bundle of (name, JSON
|
|
6
|
+
schema, strict-mode) that knows how to render itself into the
|
|
7
|
+
OpenAI-compatible ``response_format`` dict the provider expects.
|
|
8
|
+
2. :func:`parse_structured` —— extracts a JSON object from any LLM
|
|
9
|
+
response text, repairs common defects (markdown fences, trailing
|
|
10
|
+
commas, single quotes), and optionally validates against a minimal
|
|
11
|
+
subset of JSON Schema (``type=="object"`` + ``required`` keys).
|
|
12
|
+
|
|
13
|
+
We do **not** depend on jsonschema or pydantic for parsing. The schema
|
|
14
|
+
field is forwarded to the provider as-is for **server-side** validation
|
|
15
|
+
(strict mode); local parsing only enforces "shape is an object and
|
|
16
|
+
required keys are present", which is enough to catch the wedge cases
|
|
17
|
+
that bite real Agents: the model wrote prose instead of JSON, or omitted
|
|
18
|
+
a critical field.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from llm_client.interface import LLMResponse
|
|
30
|
+
from power_loop.contracts.errors import PowerLoopError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StructuredOutputError(PowerLoopError):
|
|
34
|
+
"""Raised when ``parse_structured`` cannot produce a valid object.
|
|
35
|
+
|
|
36
|
+
``raw_text`` is the original LLM output (truncated); ``reason``
|
|
37
|
+
explains which check failed: ``no_json`` / ``invalid_json`` /
|
|
38
|
+
``not_object`` / ``missing_required:<field>`` / ``wrong_type:<field>``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, reason: str, raw_text: str, detail: str = "") -> None:
|
|
42
|
+
self.reason = reason
|
|
43
|
+
self.raw_text = raw_text[:1000]
|
|
44
|
+
self.detail = detail
|
|
45
|
+
msg = f"structured output rejected ({reason})"
|
|
46
|
+
if detail:
|
|
47
|
+
msg += f": {detail}"
|
|
48
|
+
super().__init__(msg)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class StructuredOutputSpec:
|
|
53
|
+
"""Declarative spec for ``LLMRequest.response_format``.
|
|
54
|
+
|
|
55
|
+
The OpenAI ``json_schema`` mode requires a ``name`` and a ``schema``.
|
|
56
|
+
``strict=True`` is the safer default (most providers will refuse
|
|
57
|
+
extra/missing keys server-side); set to ``False`` for providers that
|
|
58
|
+
don't yet support strict mode.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
schema: dict[str, Any]
|
|
63
|
+
strict: bool = True
|
|
64
|
+
description: str | None = None
|
|
65
|
+
examples: list[dict[str, Any]] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def to_openai_response_format(self) -> dict[str, Any]:
|
|
68
|
+
"""Render the ``response_format`` payload OpenAI-compatible APIs accept."""
|
|
69
|
+
js: dict[str, Any] = {"name": self.name, "schema": self.schema}
|
|
70
|
+
if self.strict:
|
|
71
|
+
js["strict"] = True
|
|
72
|
+
if self.description:
|
|
73
|
+
js["description"] = self.description
|
|
74
|
+
return {"type": "json_schema", "json_schema": js}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── JSON repair ─────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_FENCE_RE = re.compile(r"^\s*```(?:json|JSON)?\s*\n?|\n?```\s*$", re.MULTILINE)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _strip_markdown_fences(text: str) -> str:
|
|
84
|
+
"""Drop leading/trailing ```json / ``` fences if present."""
|
|
85
|
+
return _FENCE_RE.sub("", text).strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_first_json_object(text: str) -> str | None:
|
|
89
|
+
"""Find the first balanced ``{...}`` block. Skips string contents.
|
|
90
|
+
|
|
91
|
+
Returns the substring or ``None`` if no balanced object exists.
|
|
92
|
+
"""
|
|
93
|
+
depth = 0
|
|
94
|
+
start = -1
|
|
95
|
+
in_str = False
|
|
96
|
+
esc = False
|
|
97
|
+
for i, ch in enumerate(text):
|
|
98
|
+
if in_str:
|
|
99
|
+
if esc:
|
|
100
|
+
esc = False
|
|
101
|
+
elif ch == "\\":
|
|
102
|
+
esc = True
|
|
103
|
+
elif ch == '"':
|
|
104
|
+
in_str = False
|
|
105
|
+
continue
|
|
106
|
+
if ch == '"':
|
|
107
|
+
in_str = True
|
|
108
|
+
continue
|
|
109
|
+
if ch == "{":
|
|
110
|
+
if depth == 0:
|
|
111
|
+
start = i
|
|
112
|
+
depth += 1
|
|
113
|
+
elif ch == "}":
|
|
114
|
+
depth -= 1
|
|
115
|
+
if depth == 0 and start >= 0:
|
|
116
|
+
return text[start : i + 1]
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _try_repair_json(blob: str) -> str:
|
|
121
|
+
"""Apply cheap, reversible fixes that recover from typical LLM JSON noise.
|
|
122
|
+
|
|
123
|
+
Order matters: fence strip → trailing-comma → single→double quotes
|
|
124
|
+
(only when an attempt to ``json.loads`` already failed)."""
|
|
125
|
+
# Trailing commas before } or ]
|
|
126
|
+
return re.sub(r",(\s*[}\]])", r"\1", blob)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_structured(
|
|
130
|
+
output: LLMResponse | str,
|
|
131
|
+
*,
|
|
132
|
+
schema: dict[str, Any] | None = None,
|
|
133
|
+
) -> dict[str, Any]:
|
|
134
|
+
"""Extract a dict from an LLM response, repairing common JSON defects.
|
|
135
|
+
|
|
136
|
+
Steps:
|
|
137
|
+
|
|
138
|
+
1. Pull text from ``LLMResponse`` (``raw_text`` / ``content_text``)
|
|
139
|
+
or use the string directly.
|
|
140
|
+
2. Strip markdown fences.
|
|
141
|
+
3. Try ``json.loads`` directly; on failure, find the first balanced
|
|
142
|
+
``{...}`` and retry; on failure, apply trailing-comma repair and
|
|
143
|
+
retry once more.
|
|
144
|
+
4. If a ``schema`` is supplied, check ``type=="object"`` shape and
|
|
145
|
+
all ``required`` keys are present. Mismatches raise
|
|
146
|
+
:class:`StructuredOutputError` with a precise ``reason``.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
output: An ``LLMResponse`` or the raw text string.
|
|
150
|
+
schema: Optional JSON-Schema-flavoured dict. Only ``type`` and
|
|
151
|
+
``required`` (top-level) are enforced locally; the full
|
|
152
|
+
schema is what the provider validates server-side when used
|
|
153
|
+
via :class:`StructuredOutputSpec`.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The parsed dict.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
StructuredOutputError: with ``reason`` in ``{"no_json",
|
|
160
|
+
"invalid_json", "not_object", "missing_required:<field>"}``.
|
|
161
|
+
"""
|
|
162
|
+
text = output if isinstance(output, str) else (
|
|
163
|
+
getattr(output, "raw_text", "") or getattr(output, "content_text", "") or ""
|
|
164
|
+
)
|
|
165
|
+
text = text or ""
|
|
166
|
+
cleaned = _strip_markdown_fences(text)
|
|
167
|
+
|
|
168
|
+
parsed: Any = None
|
|
169
|
+
for attempt in ("direct", "extract", "repair"):
|
|
170
|
+
candidate = cleaned
|
|
171
|
+
if attempt == "extract":
|
|
172
|
+
extracted = _extract_first_json_object(cleaned)
|
|
173
|
+
if extracted is None:
|
|
174
|
+
continue
|
|
175
|
+
candidate = extracted
|
|
176
|
+
elif attempt == "repair":
|
|
177
|
+
extracted = _extract_first_json_object(cleaned) or cleaned
|
|
178
|
+
candidate = _try_repair_json(extracted)
|
|
179
|
+
try:
|
|
180
|
+
parsed = json.loads(candidate)
|
|
181
|
+
break
|
|
182
|
+
except Exception:
|
|
183
|
+
continue
|
|
184
|
+
else:
|
|
185
|
+
# Distinguish "no object at all" from "object but malformed".
|
|
186
|
+
reason = "no_json" if _extract_first_json_object(cleaned) is None else "invalid_json"
|
|
187
|
+
raise StructuredOutputError(reason=reason, raw_text=text)
|
|
188
|
+
|
|
189
|
+
if not isinstance(parsed, Mapping):
|
|
190
|
+
raise StructuredOutputError(reason="not_object", raw_text=text,
|
|
191
|
+
detail=f"got {type(parsed).__name__}")
|
|
192
|
+
parsed = dict(parsed)
|
|
193
|
+
|
|
194
|
+
if schema is not None:
|
|
195
|
+
_check_top_level_schema(parsed, schema, raw_text=text)
|
|
196
|
+
return parsed
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_top_level_schema(obj: dict[str, Any], schema: dict[str, Any], *, raw_text: str) -> None:
|
|
200
|
+
"""Minimal local validation: top-level type=object + required keys present.
|
|
201
|
+
|
|
202
|
+
Anything deeper (per-field type checking, enum, regex, nested
|
|
203
|
+
schemas) is intentionally delegated to the provider's strict-mode
|
|
204
|
+
server-side validation. Doing it here would duplicate a real
|
|
205
|
+
json-schema implementation and silently disagree with the provider
|
|
206
|
+
in edge cases.
|
|
207
|
+
"""
|
|
208
|
+
expected_type = schema.get("type")
|
|
209
|
+
if expected_type not in (None, "object"):
|
|
210
|
+
raise StructuredOutputError(
|
|
211
|
+
reason="not_object", raw_text=raw_text,
|
|
212
|
+
detail=f"schema requires type={expected_type!r}",
|
|
213
|
+
)
|
|
214
|
+
for field_name in schema.get("required") or []:
|
|
215
|
+
if field_name not in obj:
|
|
216
|
+
raise StructuredOutputError(
|
|
217
|
+
reason=f"missing_required:{field_name}", raw_text=raw_text,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = [
|
|
222
|
+
"StructuredOutputError",
|
|
223
|
+
"StructuredOutputSpec",
|
|
224
|
+
"parse_structured",
|
|
225
|
+
]
|