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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- 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)
|