readyagentsdev 0.8.2__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 (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,472 @@
1
+ """Immutable-ish run state and persisted run records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from uuid import uuid4
14
+
15
+ from readyagents.errors import ConfigError, WorkflowError
16
+
17
+
18
+ def utc_now() -> str:
19
+ return datetime.now(UTC).isoformat()
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class NodeResult:
24
+ node_id: str
25
+ type: str
26
+ status: str
27
+ output: Any = None
28
+ error: str | None = None
29
+ attempts: int = 1
30
+ started_at: str = ""
31
+ finished_at: str = ""
32
+ usage: dict[str, int] = field(default_factory=dict)
33
+ tool_rounds: list[dict[str, Any]] = field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class RunState:
38
+ """Inputs + per-node outputs. Treat as append-only during a run."""
39
+
40
+ run_id: str
41
+ workflow_name: str
42
+ inputs: dict[str, Any]
43
+ node_outputs: dict[str, Any] = field(default_factory=dict)
44
+ output_keys: dict[str, Any] = field(default_factory=dict)
45
+ results: list[NodeResult] = field(default_factory=list)
46
+ metadata: dict[str, Any] = field(default_factory=dict)
47
+ errors: list[str] = field(default_factory=list)
48
+ status: str = "running"
49
+ pending_node: str | None = None
50
+ pending: dict[str, Any] | None = None
51
+ usage: dict[str, int] = field(default_factory=dict)
52
+ started_at: str = field(default_factory=utc_now)
53
+ finished_at: str | None = None
54
+ _last_node_usage: dict[str, int] = field(default_factory=dict, repr=False, compare=False)
55
+ _usage_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
56
+
57
+ @classmethod
58
+ def start(
59
+ cls,
60
+ workflow_name: str,
61
+ inputs: Mapping[str, Any],
62
+ *,
63
+ metadata: Mapping[str, Any] | None = None,
64
+ run_id: str | None = None,
65
+ ) -> RunState:
66
+ return cls(
67
+ run_id=run_id or uuid4().hex,
68
+ workflow_name=workflow_name,
69
+ inputs=dict(inputs),
70
+ metadata=dict(metadata or {}),
71
+ )
72
+
73
+ def mapping(self) -> dict[str, Any]:
74
+ """Template namespace: inputs, node ids, output keys, metadata."""
75
+ ns: dict[str, Any] = {}
76
+ ns.update(self.metadata)
77
+ ns.update(self.inputs)
78
+ ns.update(self.node_outputs)
79
+ ns.update(self.output_keys)
80
+ ns["inputs"] = self.inputs
81
+ ns["outputs"] = self.node_outputs
82
+ ns["run_id"] = self.run_id
83
+ return ns
84
+
85
+ def record(
86
+ self,
87
+ node_id: str,
88
+ output: Any,
89
+ *,
90
+ node_type: str,
91
+ output_key: str | None = None,
92
+ attempts: int = 1,
93
+ started_at: str = "",
94
+ finished_at: str = "",
95
+ usage: Mapping[str, int] | None = None,
96
+ tool_rounds: list[dict[str, Any]] | None = None,
97
+ ) -> None:
98
+ self.node_outputs[node_id] = output
99
+ if output_key:
100
+ self.output_keys[output_key] = output
101
+ self.results.append(
102
+ NodeResult(
103
+ node_id=node_id,
104
+ type=node_type,
105
+ status="ok",
106
+ output=_jsonable(output),
107
+ attempts=attempts,
108
+ started_at=started_at,
109
+ finished_at=finished_at,
110
+ usage={str(k): int(v) for k, v in dict(usage or {}).items()},
111
+ tool_rounds=[dict(row) for row in (tool_rounds or [])],
112
+ )
113
+ )
114
+
115
+ def record_error(
116
+ self, node_id: str, node_type: str, message: str, *, attempts: int = 1
117
+ ) -> None:
118
+ self.errors.append(message)
119
+ self.results.append(
120
+ NodeResult(
121
+ node_id=node_id,
122
+ type=node_type,
123
+ status="error",
124
+ error=message,
125
+ attempts=attempts,
126
+ finished_at=utc_now(),
127
+ )
128
+ )
129
+
130
+ def finish(self, status: str) -> None:
131
+ self.status = status
132
+ self.finished_at = utc_now()
133
+
134
+ def add_usage(self, **amounts: Any) -> None:
135
+ cleaned = _clean_usage(amounts)
136
+ if not cleaned:
137
+ return
138
+ with self._usage_lock:
139
+ _add_usage_into(self.usage, cleaned)
140
+
141
+ def note_node_usage(self, amounts: Mapping[str, Any], *, rollup: bool = True) -> None:
142
+ """Attach usage to the current node. Accumulates so parallel branches merge."""
143
+ cleaned = _clean_usage(amounts)
144
+ if not cleaned:
145
+ return
146
+ with self._usage_lock:
147
+ _add_usage_into(self._last_node_usage, cleaned)
148
+ if rollup:
149
+ _add_usage_into(self.usage, cleaned)
150
+
151
+ def take_node_usage(self) -> dict[str, int]:
152
+ with self._usage_lock:
153
+ usage = dict(self._last_node_usage)
154
+ self._last_node_usage = {}
155
+ return usage
156
+
157
+ def node_usage_map(self) -> dict[str, dict[str, int]]:
158
+ return {r.node_id: dict(r.usage) for r in self.results if r.usage}
159
+
160
+ def to_record(self) -> dict[str, Any]:
161
+ return {
162
+ "run_id": self.run_id,
163
+ "workflow": self.workflow_name,
164
+ "status": self.status,
165
+ "started_at": self.started_at,
166
+ "finished_at": self.finished_at,
167
+ "pending_node": self.pending_node,
168
+ "pending": _jsonable(self.pending) if self.pending else None,
169
+ "inputs": _jsonable(self.inputs),
170
+ "outputs": _jsonable(self.output_keys or self.node_outputs),
171
+ "output_keys": _jsonable(self.output_keys),
172
+ "node_outputs": _jsonable(self.node_outputs),
173
+ "node_results": [
174
+ {
175
+ "node_id": r.node_id,
176
+ "type": r.type,
177
+ "status": r.status,
178
+ "output": r.output,
179
+ "error": r.error,
180
+ "attempts": r.attempts,
181
+ "started_at": r.started_at,
182
+ "finished_at": r.finished_at,
183
+ "usage": dict(r.usage),
184
+ "tool_rounds": list(r.tool_rounds),
185
+ }
186
+ for r in self.results
187
+ ],
188
+ "metadata": _jsonable(self.metadata),
189
+ "errors": list(self.errors),
190
+ "usage": dict(self.usage),
191
+ }
192
+
193
+ @classmethod
194
+ def from_record(cls, data: Mapping[str, Any]) -> RunState:
195
+ results = [
196
+ NodeResult(
197
+ node_id=str(row.get("node_id", "")),
198
+ type=str(row.get("type", "")),
199
+ status=str(row.get("status", "")),
200
+ output=row.get("output"),
201
+ error=row.get("error"),
202
+ attempts=int(row.get("attempts") or 1),
203
+ started_at=str(row.get("started_at") or ""),
204
+ finished_at=str(row.get("finished_at") or ""),
205
+ usage={
206
+ str(k): int(v)
207
+ for k, v in dict(row.get("usage") or {}).items()
208
+ if _is_intlike(v)
209
+ },
210
+ tool_rounds=[
211
+ dict(item)
212
+ for item in (row.get("tool_rounds") or [])
213
+ if isinstance(item, Mapping)
214
+ ],
215
+ )
216
+ for row in data.get("node_results") or []
217
+ if isinstance(row, Mapping)
218
+ ]
219
+ pending_raw = data.get("pending")
220
+ pending = dict(pending_raw) if isinstance(pending_raw, Mapping) else None
221
+ return cls(
222
+ run_id=str(data.get("run_id") or ""),
223
+ workflow_name=str(data.get("workflow") or data.get("workflow_name") or ""),
224
+ inputs=dict(data.get("inputs") or {}),
225
+ node_outputs=dict(data.get("node_outputs") or {}),
226
+ output_keys=dict(data.get("output_keys") or data.get("outputs") or {}),
227
+ results=results,
228
+ metadata=dict(data.get("metadata") or {}),
229
+ errors=list(data.get("errors") or []),
230
+ status=str(data.get("status") or "running"),
231
+ pending_node=data.get("pending_node"),
232
+ pending=pending,
233
+ usage={str(k): int(v) for k, v in dict(data.get("usage") or {}).items()},
234
+ started_at=str(data.get("started_at") or utc_now()),
235
+ finished_at=data.get("finished_at"),
236
+ )
237
+
238
+
239
+ def persist_run(state: RunState, runs_dir: Path, *, redactor: Any | None = None) -> Path:
240
+ """Atomically write `<run_id>.json` (temp file + os.replace)."""
241
+ runs_dir.mkdir(parents=True, exist_ok=True)
242
+ path = runs_dir / f"{state.run_id}.json"
243
+ tmp = runs_dir / f".{state.run_id}.{uuid4().hex}.json.tmp"
244
+ record_obj: Any = state.to_record()
245
+ if redactor is not None:
246
+ record_obj = redactor.redact(record_obj)
247
+ record = json.dumps(record_obj, indent=2, ensure_ascii=False) + "\n"
248
+ try:
249
+ tmp.write_text(record, encoding="utf-8")
250
+ os.replace(tmp, path)
251
+ finally:
252
+ if tmp.exists():
253
+ tmp.unlink(missing_ok=True)
254
+ return path
255
+
256
+
257
+ def delete_run(runs_dir: Path, run_id: str) -> Path:
258
+ """Remove one run JSON file. ``run_id`` may be a unique prefix."""
259
+ state = load_run(runs_dir, run_id)
260
+ path = Path(runs_dir) / f"{state.run_id}.json"
261
+ if not path.is_file():
262
+ raise ConfigError(f"Run not found: {run_id}")
263
+ path.unlink()
264
+ return path
265
+
266
+
267
+ def gc_runs(
268
+ runs_dir: Path,
269
+ *,
270
+ statuses: list[str] | None = None,
271
+ include_paused: bool = False,
272
+ keep: int = 0,
273
+ ) -> list[str]:
274
+ """Delete local run files. Never deletes ``paused`` unless ``include_paused``."""
275
+ wanted = {s.strip().lower() for s in (statuses or ["succeeded", "failed", "cancelled"])}
276
+ if include_paused:
277
+ wanted.add("paused")
278
+ found = list_runs(runs_dir)
279
+ if keep and keep > 0:
280
+ found = found[keep:]
281
+ deleted: list[str] = []
282
+ for state in found:
283
+ if state.status == "paused" and not include_paused:
284
+ continue
285
+ if state.status not in wanted:
286
+ continue
287
+ path = Path(runs_dir) / f"{state.run_id}.json"
288
+ if path.is_file():
289
+ path.unlink()
290
+ deleted.append(state.run_id)
291
+ return deleted
292
+
293
+
294
+ def mark_cancelled(state: RunState) -> RunState:
295
+ """Mark a run cancelled. Does not persist."""
296
+ state.finish("cancelled")
297
+ return state
298
+
299
+
300
+ def load_run(runs_dir: Path, run_id: str) -> RunState:
301
+ """Load a persisted run. `run_id` may be a unique prefix."""
302
+ runs_dir = Path(runs_dir)
303
+ exact = runs_dir / f"{run_id}.json"
304
+ if exact.is_file():
305
+ return _read_run(exact)
306
+ matches = sorted(runs_dir.glob(f"{run_id}*.json")) if runs_dir.is_dir() else []
307
+ if len(matches) == 1:
308
+ return _read_run(matches[0])
309
+ if len(matches) > 1:
310
+ ids = ", ".join(p.stem for p in matches[:8])
311
+ raise ConfigError(f"Run id '{run_id}' is ambiguous. Matches: {ids}")
312
+ raise ConfigError(f"Run not found: {run_id}")
313
+
314
+
315
+ def list_runs(
316
+ runs_dir: Path,
317
+ *,
318
+ status: str | None = None,
319
+ workflow: str | None = None,
320
+ limit: int = 0,
321
+ ) -> list[RunState]:
322
+ runs_dir = Path(runs_dir)
323
+ if not runs_dir.is_dir():
324
+ return []
325
+ states: list[RunState] = []
326
+ for path in runs_dir.glob("*.json"):
327
+ if path.name.startswith("."):
328
+ continue
329
+ try:
330
+ states.append(_read_run(path))
331
+ except (OSError, json.JSONDecodeError, ConfigError, TypeError, ValueError):
332
+ continue
333
+ states.sort(key=lambda s: s.started_at, reverse=True)
334
+ if status:
335
+ wanted = status.strip().lower()
336
+ states = [s for s in states if s.status == wanted]
337
+ if workflow:
338
+ wanted_wf = workflow.strip()
339
+ states = [s for s in states if s.workflow_name == wanted_wf]
340
+ if limit and limit > 0:
341
+ states = states[:limit]
342
+ return states
343
+
344
+
345
+ def _read_run(path: Path) -> RunState:
346
+ try:
347
+ text = path.read_text(encoding="utf-8")
348
+ data = json.loads(text)
349
+ except json.JSONDecodeError as exc:
350
+ raise ConfigError(f"Corrupt run record {path}: {exc}") from exc
351
+ except OSError as exc:
352
+ raise ConfigError(f"Cannot read run record {path}: {exc}") from exc
353
+ if not isinstance(data, dict) or not data.get("run_id"):
354
+ raise ConfigError(f"Invalid run record: {path}")
355
+ return RunState.from_record(data)
356
+
357
+
358
+ def build_decisions(approve: list[str], reject: list[str]) -> dict[str, str]:
359
+ decisions: dict[str, str] = {}
360
+ for node_id in approve:
361
+ decisions[node_id] = "approve"
362
+ for node_id in reject:
363
+ decisions[node_id] = "reject"
364
+ return decisions
365
+
366
+
367
+ def parse_decision_payload(data: Any) -> dict[str, str]:
368
+ """Accept a few JSON shapes used by external decision injection."""
369
+ if data is None:
370
+ return {}
371
+ if isinstance(data, Mapping):
372
+ if "decisions" in data and isinstance(data["decisions"], Mapping):
373
+ return {str(k): str(v).strip().lower() for k, v in data["decisions"].items()}
374
+ if "decisions" in data and isinstance(data["decisions"], list):
375
+ return parse_decision_payload(data["decisions"])
376
+ node = data.get("node_id") or data.get("node") or data.get("id")
377
+ decision = data.get("decision") or data.get("value")
378
+ if node and decision is not None:
379
+ return {str(node): str(decision).strip().lower()}
380
+ return {str(k): str(v).strip().lower() for k, v in data.items() if v is not None}
381
+ if isinstance(data, list):
382
+ merged: dict[str, str] = {}
383
+ for item in data:
384
+ merged.update(parse_decision_payload(item))
385
+ return merged
386
+ raise WorkflowError("Decision payload must be a JSON object or list")
387
+
388
+
389
+ def load_decision_file(path: Path | str) -> dict[str, str]:
390
+ file = Path(path)
391
+ if not file.is_file():
392
+ from readyagents.errors import ConfigError
393
+
394
+ raise ConfigError(f"Decision file not found: {file}")
395
+ try:
396
+ data = json.loads(file.read_text(encoding="utf-8"))
397
+ except json.JSONDecodeError as exc:
398
+ raise WorkflowError(f"Decision file {file} is not valid JSON: {exc}") from exc
399
+ return parse_decision_payload(data)
400
+
401
+
402
+ def _clean_usage(amounts: Mapping[str, Any] | None) -> dict[str, int]:
403
+ cleaned: dict[str, int] = {}
404
+ for key, raw in dict(amounts or {}).items():
405
+ if raw is None:
406
+ continue
407
+ try:
408
+ cleaned[str(key)] = int(raw)
409
+ except (TypeError, ValueError):
410
+ continue
411
+ return cleaned
412
+
413
+
414
+ def _add_usage_into(target: dict[str, int], amounts: Mapping[str, int]) -> None:
415
+ for key, value in amounts.items():
416
+ target[key] = int(target.get(key, 0)) + int(value)
417
+
418
+
419
+ def _is_intlike(value: Any) -> bool:
420
+ try:
421
+ int(value)
422
+ return True
423
+ except (TypeError, ValueError):
424
+ return False
425
+
426
+
427
+ def _jsonable(value: Any) -> Any:
428
+ if value is None or isinstance(value, (str, int, float, bool)):
429
+ return value
430
+ if isinstance(value, Path):
431
+ return str(value)
432
+ if isinstance(value, dict):
433
+ return {str(k): _jsonable(v) for k, v in value.items()}
434
+ if isinstance(value, (list, tuple)):
435
+ return [_jsonable(v) for v in value]
436
+ try:
437
+ json.dumps(value)
438
+ return value
439
+ except TypeError:
440
+ return str(value)
441
+
442
+
443
+ def parse_input_pairs(pairs: list[str]) -> dict[str, Any]:
444
+ """Parse CLI `--input KEY=VALUE` pairs."""
445
+ result: dict[str, Any] = {}
446
+ for raw in pairs:
447
+ if "=" not in raw:
448
+ raise WorkflowError(f"Invalid --input '{raw}' (expected KEY=VALUE)")
449
+ key, value = raw.split("=", 1)
450
+ key = key.strip()
451
+ if not key:
452
+ raise WorkflowError(f"Invalid --input '{raw}' (empty key)")
453
+ result[key] = _coerce_scalar(value)
454
+ return result
455
+
456
+
457
+ def _coerce_scalar(value: str) -> Any:
458
+ lowered = value.strip().lower()
459
+ if lowered == "true":
460
+ return True
461
+ if lowered == "false":
462
+ return False
463
+ if lowered == "null" or lowered == "none":
464
+ return None
465
+ try:
466
+ if value.strip().isdigit() or (
467
+ value.strip().startswith("-") and value.strip()[1:].isdigit()
468
+ ):
469
+ return int(value.strip())
470
+ return float(value) if "." in value.strip() else value
471
+ except ValueError:
472
+ return value
@@ -0,0 +1,103 @@
1
+ """Strict structured output validation for agent nodes (Pydantic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
9
+
10
+ from readyagents.errors import StructuredOutputError
11
+
12
+ _JSON_TYPES: dict[str, Any] = {
13
+ "string": str,
14
+ "number": float,
15
+ "integer": int,
16
+ "boolean": bool,
17
+ "array": list,
18
+ "object": dict,
19
+ "null": type(None),
20
+ }
21
+
22
+
23
+ def parse_json_payload(text: str) -> Any:
24
+ stripped = (text or "").strip()
25
+ if not stripped:
26
+ raise ValueError("empty LLM output")
27
+ try:
28
+ return json.loads(stripped)
29
+ except json.JSONDecodeError:
30
+ pass
31
+ start = stripped.find("{")
32
+ end = stripped.rfind("}")
33
+ if start != -1 and end != -1 and end > start:
34
+ try:
35
+ return json.loads(stripped[start : end + 1])
36
+ except json.JSONDecodeError:
37
+ pass
38
+ start = stripped.find("[")
39
+ end = stripped.rfind("]")
40
+ if start != -1 and end != -1 and end > start:
41
+ return json.loads(stripped[start : end + 1])
42
+ raise ValueError("LLM output is not JSON")
43
+
44
+
45
+ def _annotation(spec: Any) -> Any:
46
+ if not isinstance(spec, dict):
47
+ return Any
48
+ raw_type = spec.get("type")
49
+ if isinstance(raw_type, list):
50
+ parts = [_simple(t) for t in raw_type]
51
+ out: Any = parts[0] if parts else Any
52
+ for part in parts[1:]:
53
+ out = out | part
54
+ return out
55
+ return _simple(raw_type or "string")
56
+
57
+
58
+ def _simple(raw: Any) -> Any:
59
+ if not isinstance(raw, str):
60
+ return Any
61
+ return _JSON_TYPES.get(raw, Any)
62
+
63
+
64
+ def model_from_schema(schema: dict[str, Any]) -> type[BaseModel]:
65
+ """Build a one-off Pydantic model from a JSON Schema object."""
66
+ extra_flag = schema.get("additionalProperties", True)
67
+ extra = "allow" if extra_flag else "forbid"
68
+
69
+ class _Base(BaseModel):
70
+ model_config = ConfigDict(extra=extra)
71
+
72
+ props = schema.get("properties")
73
+ if not isinstance(props, dict):
74
+ # Whole-payload type, e.g. {"type": "object"} with no properties.
75
+ return create_model("AgentStructured", __base__=_Base)
76
+
77
+ required = {str(item) for item in (schema.get("required") or []) if item}
78
+ fields: dict[str, Any] = {}
79
+ for name, spec in props.items():
80
+ annotation = _annotation(spec if isinstance(spec, dict) else {})
81
+ if name in required:
82
+ fields[name] = (annotation, Field(...))
83
+ else:
84
+ fields[name] = (annotation | None, None)
85
+ return create_model("AgentStructured", __base__=_Base, **fields)
86
+
87
+
88
+ def validate_structured_output(text: str, schema: dict[str, Any], *, node_id: str) -> Any:
89
+ """Parse JSON and validate against ``schema``. Raises StructuredOutputError."""
90
+ try:
91
+ data = parse_json_payload(text)
92
+ except (ValueError, json.JSONDecodeError) as exc:
93
+ raise StructuredOutputError(node_id, f"structured output is not JSON: {exc}") from exc
94
+ if not isinstance(schema, dict) or not schema:
95
+ raise StructuredOutputError(node_id, "output_schema must be a JSON Schema object")
96
+ model = model_from_schema(schema)
97
+ try:
98
+ parsed = model.model_validate(data)
99
+ except ValidationError as exc:
100
+ raise StructuredOutputError(
101
+ node_id, f"structured output failed schema validation: {exc}"
102
+ ) from exc
103
+ return parsed.model_dump()
@@ -0,0 +1,125 @@
1
+ """Safe `{{dotted.path}}` interpolation against run state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from readyagents.errors import TemplateError
11
+
12
+ _VAR = re.compile(
13
+ r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_\.]*)"
14
+ r"(?:\s*\|\s*(default|len|join)(?:\s+([^}]+?))?)?"
15
+ r"\s*\}\}"
16
+ )
17
+ _FILTERS = frozenset({"default", "len", "join"})
18
+
19
+
20
+ def resolve_path(data: Any, path: str) -> Any:
21
+ """Resolve a dotted path (`a.b.0.c`) against dicts/lists."""
22
+ current = data
23
+ for part in path.split("."):
24
+ current = _step(current, part, path)
25
+ return current
26
+
27
+
28
+ def _step(current: Any, part: str, full: str) -> Any:
29
+ if current is None:
30
+ raise TemplateError(f"Missing template variable: {full}")
31
+ if isinstance(current, Mapping):
32
+ if part in current:
33
+ return current[part]
34
+ raise TemplateError(f"Missing template variable: {full}")
35
+ if isinstance(current, (list, tuple)):
36
+ try:
37
+ idx = int(part)
38
+ except ValueError as exc:
39
+ raise TemplateError(f"Missing template variable: {full}") from exc
40
+ try:
41
+ return current[idx]
42
+ except IndexError as exc:
43
+ raise TemplateError(f"Missing template variable: {full}") from exc
44
+ raise TemplateError(f"Missing template variable: {full}")
45
+
46
+
47
+ def lookup(mapping: Mapping[str, Any], path: str) -> Any:
48
+ if path in mapping and "." not in path:
49
+ return mapping[path]
50
+ head, _, rest = path.partition(".")
51
+ if head not in mapping:
52
+ raise TemplateError(f"Missing template variable: {path}")
53
+ if not rest:
54
+ return mapping[head]
55
+ return resolve_path(mapping[head], rest)
56
+
57
+
58
+ def interpolate(template: str, mapping: Mapping[str, Any]) -> str:
59
+ """Replace `{{var}}` tokens. Missing names raise TemplateError unless `| default`."""
60
+
61
+ def repl(match: re.Match[str]) -> str:
62
+ path = match.group(1)
63
+ filt = match.group(2)
64
+ arg = (match.group(3) or "").strip()
65
+ try:
66
+ value = lookup(mapping, path)
67
+ except TemplateError:
68
+ if filt == "default":
69
+ return _unquote(arg)
70
+ raise
71
+ if filt:
72
+ value = _apply_filter(filt, value, arg)
73
+ return _stringify(value)
74
+
75
+ return _VAR.sub(repl, template)
76
+
77
+
78
+ def _unquote(text: str) -> str:
79
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
80
+ return text[1:-1]
81
+ return text
82
+
83
+
84
+ def _apply_filter(name: str, value: Any, arg: str) -> Any:
85
+ if name == "default":
86
+ if value is None or value == "":
87
+ return _unquote(arg)
88
+ return value
89
+ if name == "len":
90
+ try:
91
+ return len(value)
92
+ except TypeError as exc:
93
+ raise TemplateError("filter len requires a sized value") from exc
94
+ if name == "join":
95
+ sep = _unquote(arg) if arg else ""
96
+ if isinstance(value, str):
97
+ return sep.join(value)
98
+ try:
99
+ return sep.join(_stringify(item) for item in value)
100
+ except TypeError as exc:
101
+ raise TemplateError("filter join requires an iterable") from exc
102
+ raise TemplateError(f"unknown filter '{name}'")
103
+
104
+
105
+ def interpolate_value(value: Any, mapping: Mapping[str, Any]) -> Any:
106
+ """Recursively interpolate strings inside dicts/lists."""
107
+ if isinstance(value, str):
108
+ return interpolate(value, mapping)
109
+ if isinstance(value, Mapping):
110
+ return {k: interpolate_value(v, mapping) for k, v in value.items()}
111
+ if isinstance(value, list):
112
+ return [interpolate_value(v, mapping) for v in value]
113
+ if isinstance(value, tuple):
114
+ return tuple(interpolate_value(v, mapping) for v in value)
115
+ return value
116
+
117
+
118
+ def _stringify(value: Any) -> str:
119
+ if value is None:
120
+ return ""
121
+ if isinstance(value, str):
122
+ return value
123
+ if isinstance(value, (dict, list, tuple, bool)):
124
+ return json.dumps(value, ensure_ascii=False)
125
+ return str(value)