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