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,219 @@
1
+ """Tiny eval harness: score fixture workflows (pass and fail cases)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from readyagents.errors import ConfigError
14
+ from readyagents.testing.helpers import run_workflow_file_test, run_workflow_spec
15
+ from readyagents.tools import ToolRegistry
16
+ from readyagents.workflow.schema import WorkflowSpec
17
+ from readyagents.workflow.state import RunState
18
+
19
+
20
+ @dataclass
21
+ class EvalCase:
22
+ name: str
23
+ workflow: Mapping[str, Any] | WorkflowSpec | Path | str
24
+ inputs: dict[str, Any] = field(default_factory=dict)
25
+ decisions: dict[str, str] = field(default_factory=dict)
26
+ expect_status: str = "succeeded"
27
+ expect_outputs: dict[str, Any] | None = None
28
+ expect_contains: dict[str, str] | None = None
29
+
30
+
31
+ @dataclass
32
+ class EvalResult:
33
+ name: str
34
+ passed: bool
35
+ reason: str = ""
36
+ state: RunState | None = None
37
+
38
+
39
+ @dataclass
40
+ class EvalReport:
41
+ results: list[EvalResult]
42
+
43
+ @property
44
+ def passed(self) -> int:
45
+ return sum(1 for row in self.results if row.passed)
46
+
47
+ @property
48
+ def failed(self) -> int:
49
+ return sum(1 for row in self.results if not row.passed)
50
+
51
+ @property
52
+ def ok(self) -> bool:
53
+ return self.failed == 0
54
+
55
+ def assert_passing(self) -> None:
56
+ if self.ok:
57
+ return
58
+ lines = [f"{row.name}: {row.reason}" for row in self.results if not row.passed]
59
+ raise AssertionError("eval failures:\n" + "\n".join(lines))
60
+
61
+
62
+ def load_eval_suite(path: Path | str) -> list[EvalCase]:
63
+ """Load a YAML/JSON mapping with a ``cases:`` list of :class:`EvalCase` fields."""
64
+ file = Path(path)
65
+ if not file.is_file():
66
+ raise ConfigError(f"Eval suite file not found: {file}")
67
+ text = file.read_text(encoding="utf-8")
68
+ try:
69
+ if file.suffix.lower() in {".json"}:
70
+ data = json.loads(text)
71
+ else:
72
+ data = yaml.safe_load(text)
73
+ except (json.JSONDecodeError, yaml.YAMLError) as exc:
74
+ raise ConfigError(f"Could not parse eval suite {file}: {exc}") from exc
75
+ if not isinstance(data, Mapping):
76
+ raise ConfigError(f"Eval suite {file} must be a mapping with a 'cases' list")
77
+ raw_cases = data.get("cases")
78
+ if not isinstance(raw_cases, list):
79
+ raise ConfigError(f"Eval suite {file} must be a mapping with a 'cases' list")
80
+ if not raw_cases:
81
+ raise ConfigError(f"Eval suite {file} has no cases")
82
+ return [
83
+ _case_from_mapping(row, index=i, suite=file) for i, row in enumerate(raw_cases, start=1)
84
+ ]
85
+
86
+
87
+ def _case_from_mapping(raw: object, *, index: int, suite: Path) -> EvalCase:
88
+ if not isinstance(raw, Mapping):
89
+ raise ConfigError(f"Eval suite {suite} case #{index} must be a mapping")
90
+ name = raw.get("name")
91
+ if not isinstance(name, str) or not name.strip():
92
+ raise ConfigError(f"Eval suite {suite} case #{index} needs a name")
93
+ name = name.strip()
94
+ workflow_field = raw.get("workflow")
95
+ if isinstance(workflow_field, str):
96
+ rel = workflow_field.strip()
97
+ if not rel:
98
+ raise ConfigError(f"Eval case {name!r} has an empty workflow path")
99
+ # File workflows are resolved next to the suite, not the process cwd.
100
+ workflow: Mapping[str, Any] | WorkflowSpec | Path | str = Path(suite).parent / rel
101
+ elif isinstance(workflow_field, Mapping):
102
+ workflow = dict(workflow_field)
103
+ else:
104
+ raise ConfigError(f"Eval case {name!r} needs a workflow path or inline mapping")
105
+ expect_status = raw.get("expect_status", "succeeded")
106
+ if not isinstance(expect_status, str) or not expect_status.strip():
107
+ raise ConfigError(f"Eval case {name!r} field 'expect_status' must be a string")
108
+ return EvalCase(
109
+ name=name,
110
+ workflow=workflow,
111
+ inputs=_mapping_field(raw, "inputs", name, default={}),
112
+ decisions=_str_mapping_field(raw, "decisions", name, default={}),
113
+ expect_status=expect_status.strip(),
114
+ expect_outputs=_optional_mapping_field(raw, "expect_outputs", name),
115
+ expect_contains=_optional_str_mapping_field(raw, "expect_contains", name),
116
+ )
117
+
118
+
119
+ def _mapping_field(
120
+ raw: Mapping[str, Any],
121
+ key: str,
122
+ name: str,
123
+ *,
124
+ default: dict[str, Any],
125
+ ) -> dict[str, Any]:
126
+ if key not in raw or raw[key] is None:
127
+ return default
128
+ value = raw[key]
129
+ if not isinstance(value, Mapping):
130
+ raise ConfigError(f"Eval case {name!r} field '{key}' must be a mapping")
131
+ return dict(value)
132
+
133
+
134
+ def _str_mapping_field(
135
+ raw: Mapping[str, Any],
136
+ key: str,
137
+ name: str,
138
+ *,
139
+ default: dict[str, str],
140
+ ) -> dict[str, str]:
141
+ data = _mapping_field(raw, key, name, default=default)
142
+ return {str(k): str(v) for k, v in data.items()}
143
+
144
+
145
+ def _optional_mapping_field(
146
+ raw: Mapping[str, Any],
147
+ key: str,
148
+ name: str,
149
+ ) -> dict[str, Any] | None:
150
+ if key not in raw or raw[key] is None:
151
+ return None
152
+ value = raw[key]
153
+ if not isinstance(value, Mapping):
154
+ raise ConfigError(f"Eval case {name!r} field '{key}' must be a mapping")
155
+ return dict(value)
156
+
157
+
158
+ def _optional_str_mapping_field(
159
+ raw: Mapping[str, Any],
160
+ key: str,
161
+ name: str,
162
+ ) -> dict[str, str] | None:
163
+ data = _optional_mapping_field(raw, key, name)
164
+ if data is None:
165
+ return None
166
+ return {str(k): str(v) for k, v in data.items()}
167
+
168
+
169
+ def _score(state: RunState, case: EvalCase) -> tuple[bool, str]:
170
+ if state.status != case.expect_status:
171
+ return False, f"status {state.status!r} != {case.expect_status!r}"
172
+ outputs = state.output_keys or state.node_outputs
173
+ if case.expect_outputs:
174
+ for key, expected in case.expect_outputs.items():
175
+ actual = outputs.get(key)
176
+ if actual != expected:
177
+ return False, f"output {key!r}={actual!r} != {expected!r}"
178
+ if case.expect_contains:
179
+ for key, needle in case.expect_contains.items():
180
+ hay = outputs.get(key)
181
+ if needle not in str(hay):
182
+ return False, f"output {key!r}={hay!r} does not contain {needle!r}"
183
+ return True, "ok"
184
+
185
+
186
+ def run_eval(
187
+ cases: Sequence[EvalCase],
188
+ *,
189
+ llm: Any = None,
190
+ tools: ToolRegistry | None = None,
191
+ settings: Any | None = None,
192
+ ) -> EvalReport:
193
+ results: list[EvalResult] = []
194
+ for case in cases:
195
+ try:
196
+ if isinstance(case.workflow, (Path, str)):
197
+ state = run_workflow_file_test(
198
+ case.workflow,
199
+ inputs=case.inputs,
200
+ llm=llm,
201
+ settings=settings,
202
+ persist=False,
203
+ decisions=case.decisions,
204
+ extra_tools=tools,
205
+ )
206
+ else:
207
+ state = run_workflow_spec(
208
+ case.workflow,
209
+ inputs=case.inputs,
210
+ llm=llm,
211
+ tools=tools,
212
+ decisions=case.decisions,
213
+ )
214
+ except Exception as exc: # noqa: BLE001
215
+ results.append(EvalResult(name=case.name, passed=False, reason=str(exc)))
216
+ continue
217
+ ok, reason = _score(state, case)
218
+ results.append(EvalResult(name=case.name, passed=ok, reason=reason, state=state))
219
+ return EvalReport(results)
@@ -0,0 +1,128 @@
1
+ """Helpers that wrap the same engine path the CLI uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from readyagents.errors import LLMError
10
+ from readyagents.llm.base import CompletionResult, Message, ToolCall
11
+ from readyagents.tools import ToolRegistry
12
+ from readyagents.workflow.engine import run_workflow
13
+ from readyagents.workflow.nodes import ExecutionContext
14
+ from readyagents.workflow.runner import run_workflow_file
15
+ from readyagents.workflow.schema import WorkflowSpec
16
+ from readyagents.workflow.state import RunState
17
+
18
+
19
+ class ScriptedLLM:
20
+ """In-process LLM stand-in. Queue completions or errors per model id."""
21
+
22
+ name = "scripted"
23
+
24
+ def __init__(self) -> None:
25
+ self.calls: list[dict[str, Any]] = []
26
+ self._by_model: dict[str, list[CompletionResult | BaseException]] = {}
27
+ self._queue: list[CompletionResult | BaseException] = []
28
+
29
+ def enqueue(
30
+ self,
31
+ text: str = "ok",
32
+ *,
33
+ model: str | None = None,
34
+ usage: Mapping[str, Any] | None = None,
35
+ error: BaseException | None = None,
36
+ tool_calls: Sequence[ToolCall] | None = None,
37
+ ) -> ScriptedLLM:
38
+ item: CompletionResult | BaseException
39
+ if error is not None:
40
+ item = error
41
+ else:
42
+ item = CompletionResult(
43
+ text=text,
44
+ model=model or "scripted",
45
+ usage=dict(usage or {}),
46
+ tool_calls=list(tool_calls or []),
47
+ )
48
+ if model:
49
+ self._by_model.setdefault(model, []).append(item)
50
+ else:
51
+ self._queue.append(item)
52
+ return self
53
+
54
+ def complete(
55
+ self,
56
+ messages: list[Message],
57
+ *,
58
+ model: str,
59
+ tools: Any = None,
60
+ **kwargs: Any,
61
+ ) -> CompletionResult:
62
+ self.calls.append({"model": model, "messages": messages, "tools": tools})
63
+ item: CompletionResult | BaseException | None = None
64
+ bucket = self._by_model.get(model)
65
+ if bucket:
66
+ item = bucket.pop(0)
67
+ elif self._queue:
68
+ item = self._queue.pop(0)
69
+ if item is None:
70
+ return CompletionResult(text="ok", model=model, usage={})
71
+ if isinstance(item, BaseException):
72
+ raise item
73
+ if not item.model:
74
+ item.model = model
75
+ return item
76
+
77
+
78
+ def run_workflow_spec(
79
+ spec: Mapping[str, Any] | WorkflowSpec,
80
+ *,
81
+ inputs: Mapping[str, Any] | None = None,
82
+ llm: Any | None = None,
83
+ tools: ToolRegistry | None = None,
84
+ decisions: Mapping[str, str] | None = None,
85
+ **ctx_kwargs: Any,
86
+ ) -> RunState:
87
+ """Validate a workflow mapping and run it through ``run_workflow``."""
88
+ workflow = spec if isinstance(spec, WorkflowSpec) else WorkflowSpec.model_validate(dict(spec))
89
+ merged = dict(workflow.input_defaults())
90
+ if inputs:
91
+ merged.update(inputs)
92
+ ctx = ExecutionContext(
93
+ workflow,
94
+ tools or ToolRegistry(),
95
+ llm=llm,
96
+ default_model=ctx_kwargs.pop("default_model", workflow.default_model or "mock:test"),
97
+ decisions=decisions,
98
+ **ctx_kwargs,
99
+ )
100
+ return run_workflow(workflow, merged, ctx)
101
+
102
+
103
+ def run_workflow_file_test(
104
+ path: Path | str,
105
+ *,
106
+ inputs: Mapping[str, Any] | None = None,
107
+ llm: Any | None = None,
108
+ settings: Any | None = None,
109
+ persist: bool = False,
110
+ decisions: Mapping[str, str] | None = None,
111
+ extra_tools: ToolRegistry | None = None,
112
+ **kwargs: Any,
113
+ ) -> RunState:
114
+ """``run_workflow_file`` with test-friendly defaults (no persist)."""
115
+ return run_workflow_file(
116
+ path,
117
+ inputs=inputs,
118
+ llm=llm,
119
+ settings=settings,
120
+ persist=persist,
121
+ decisions=decisions,
122
+ extra_tools=extra_tools,
123
+ **kwargs,
124
+ )
125
+
126
+
127
+ def fail(message: str) -> LLMError:
128
+ return LLMError(message)
@@ -0,0 +1,68 @@
1
+ """Offline / recorded LLM: replay a cassette file, no network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from readyagents.errors import LLMError
10
+ from readyagents.llm.base import CompletionResult, Message
11
+ from readyagents.llm.tool_calls import tool_calls_from_json, tool_calls_to_json
12
+
13
+
14
+ class RecordedLLM:
15
+ """Replay stored completions. Optional ``inner`` records new calls to disk."""
16
+
17
+ name = "recorded"
18
+
19
+ def __init__(self, cassette: Path | str, *, inner: Any | None = None) -> None:
20
+ self.cassette = Path(cassette)
21
+ self.inner = inner
22
+ self.calls: list[list[Message]] = []
23
+ self.models: list[str] = []
24
+ self._tape: list[dict[str, Any]] = []
25
+ if self.cassette.is_file():
26
+ loaded = json.loads(self.cassette.read_text(encoding="utf-8"))
27
+ if isinstance(loaded, list):
28
+ self._tape = [row for row in loaded if isinstance(row, dict)]
29
+ self._index = 0
30
+
31
+ def complete(
32
+ self,
33
+ messages: list[Message],
34
+ *,
35
+ model: str,
36
+ tools: Any = None,
37
+ **kwargs: Any,
38
+ ) -> CompletionResult:
39
+ self.calls.append(messages)
40
+ self.models.append(model)
41
+ if self._index < len(self._tape):
42
+ row = self._tape[self._index]
43
+ self._index += 1
44
+ usage = row.get("usage") if isinstance(row.get("usage"), dict) else {}
45
+ return CompletionResult(
46
+ text=str(row.get("text") or ""),
47
+ model=str(row.get("model") or model),
48
+ usage=dict(usage),
49
+ tool_calls=tool_calls_from_json(row.get("tool_calls")),
50
+ )
51
+ if self.inner is None:
52
+ raise LLMError(
53
+ f"No recorded completion at index {self._index} in {self.cassette} "
54
+ "(offline replay — no network)"
55
+ )
56
+ result = self.inner.complete(messages, model=model, tools=tools, **kwargs)
57
+ self._tape.append(
58
+ {
59
+ "text": result.text,
60
+ "model": result.model or model,
61
+ "usage": dict(result.usage or {}),
62
+ "tool_calls": tool_calls_to_json(result.tool_calls),
63
+ }
64
+ )
65
+ self._index += 1
66
+ self.cassette.parent.mkdir(parents=True, exist_ok=True)
67
+ self.cassette.write_text(json.dumps(self._tape, indent=2, ensure_ascii=False) + "\n")
68
+ return result
@@ -0,0 +1,67 @@
1
+ """Common Tool protocol wrapping builtin and MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Protocol
8
+
9
+ from readyagents.errors import ToolError
10
+
11
+
12
+ class Tool(Protocol):
13
+ name: str
14
+ description: str
15
+ schema: dict[str, Any]
16
+
17
+ def run(self, **kwargs: Any) -> Any: ...
18
+
19
+
20
+ @dataclass
21
+ class FunctionTool:
22
+ name: str
23
+ description: str
24
+ handler: Callable[..., Any]
25
+ schema: dict[str, Any] = field(default_factory=dict)
26
+
27
+ def run(self, **kwargs: Any) -> Any:
28
+ try:
29
+ return self.handler(**kwargs)
30
+ except TypeError as exc:
31
+ raise ToolError(f"Tool '{self.name}' got invalid arguments: {exc}") from exc
32
+
33
+
34
+ class ToolRegistry:
35
+ def __init__(self) -> None:
36
+ self._tools: dict[str, Tool] = {}
37
+
38
+ def register(self, tool: Tool) -> None:
39
+ self._tools[tool.name] = tool
40
+
41
+ def get(self, name: str) -> Tool:
42
+ if name not in self._tools:
43
+ known = ", ".join(sorted(self._tools)) or "(none)"
44
+ raise ToolError(f"Unknown tool '{name}'. Available: {known}")
45
+ return self._tools[name]
46
+
47
+ def names(self) -> list[str]:
48
+ return sorted(self._tools)
49
+
50
+ def as_dict(self) -> Mapping[str, Tool]:
51
+ return dict(self._tools)
52
+
53
+ def merge(self, other: Mapping[str, Tool] | ToolRegistry) -> None:
54
+ items = other.as_dict() if isinstance(other, ToolRegistry) else other
55
+ for tool in items.values():
56
+ if tool.name in self._tools:
57
+ continue
58
+ self.register(tool)
59
+
60
+
61
+ def default_registry(*, allow_http: bool, workspace: Any) -> ToolRegistry:
62
+ from readyagents.mcp.builtin import builtin_tools
63
+
64
+ registry = ToolRegistry()
65
+ for tool in builtin_tools(allow_http=allow_http, workspace=workspace):
66
+ registry.register(tool)
67
+ return registry
@@ -0,0 +1,3 @@
1
+ from readyagents.workflow.schema import WorkflowSpec
2
+
3
+ __all__ = ["WorkflowSpec"]
@@ -0,0 +1,88 @@
1
+ """Cooperative cancellation for in-flight workflow runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from collections.abc import Callable
8
+
9
+ from readyagents.errors import CancellationRequested
10
+
11
+ _SLEEP_CHUNK = 0.05
12
+
13
+
14
+ class CancellationToken:
15
+ """Thread-safe cooperative cancellation flag (threading.Event)."""
16
+
17
+ def __init__(self) -> None:
18
+ self._event = threading.Event()
19
+ self._lock = threading.Lock()
20
+ self._actor: str | None = None
21
+ self._reason: str | None = None
22
+ self._listeners: list[Callable[[], None]] = []
23
+
24
+ def request(self, *, actor: str | None = None, reason: str | None = None) -> None:
25
+ listeners: list[Callable[[], None]]
26
+ with self._lock:
27
+ first = not self._event.is_set()
28
+ if first:
29
+ self._actor = actor
30
+ self._reason = reason
31
+ listeners = list(self._listeners)
32
+ self._event.set()
33
+ if first:
34
+ for listener in listeners:
35
+ listener()
36
+
37
+ def is_requested(self) -> bool:
38
+ return self._event.is_set()
39
+
40
+ def raise_if_requested(self, *, run_id: str | None = None) -> None:
41
+ if not self.is_requested():
42
+ return
43
+ raise CancellationRequested(run_id=run_id, reason=self.reason)
44
+
45
+ def wait(self, timeout: float) -> bool:
46
+ """Block up to ``timeout`` seconds. True if cancellation was requested."""
47
+ return self._event.wait(max(float(timeout), 0.0))
48
+
49
+ @property
50
+ def actor(self) -> str | None:
51
+ with self._lock:
52
+ return self._actor
53
+
54
+ @property
55
+ def reason(self) -> str | None:
56
+ with self._lock:
57
+ return self._reason
58
+
59
+ def add_listener(self, callback: Callable[[], None]) -> None:
60
+ """Invoke ``callback`` on the first ``request()`` (or immediately if already set)."""
61
+ with self._lock:
62
+ self._listeners.append(callback)
63
+ pending = self._event.is_set()
64
+ if pending:
65
+ callback()
66
+
67
+ def clear_listeners(self) -> None:
68
+ with self._lock:
69
+ self._listeners.clear()
70
+
71
+
72
+ def cancellable_sleep(seconds: float, token: CancellationToken | None) -> None:
73
+ """Sleep ``seconds``, or until ``token`` is requested (then raise CancellationRequested)."""
74
+ delay = max(float(seconds), 0.0)
75
+ if token is None:
76
+ time.sleep(delay)
77
+ return
78
+ if delay <= 0:
79
+ token.raise_if_requested()
80
+ return
81
+ remaining = delay
82
+ while remaining > 0:
83
+ chunk = remaining if remaining < _SLEEP_CHUNK else _SLEEP_CHUNK
84
+ if token.wait(chunk):
85
+ token.raise_if_requested()
86
+ return
87
+ remaining -= chunk
88
+ token.raise_if_requested()