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,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,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()
|