jharness-toolkit 0.1.0__tar.gz
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.
- jharness_toolkit-0.1.0/.gitignore +45 -0
- jharness_toolkit-0.1.0/LICENSE +21 -0
- jharness_toolkit-0.1.0/PKG-INFO +46 -0
- jharness_toolkit-0.1.0/README.md +18 -0
- jharness_toolkit-0.1.0/pyproject.toml +43 -0
- jharness_toolkit-0.1.0/src/jharness/toolkit/__init__.py +15 -0
- jharness_toolkit-0.1.0/src/jharness/toolkit/decorators.py +75 -0
- jharness_toolkit-0.1.0/src/jharness/toolkit/py.typed +1 -0
- jharness_toolkit-0.1.0/src/jharness/toolkit/registry.py +181 -0
- jharness_toolkit-0.1.0/src/jharness/toolkit/tool.py +75 -0
- jharness_toolkit-0.1.0/tests/test_toolkit.py +372 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# OS/editor noise
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
*.swp
|
|
5
|
+
*.swo
|
|
6
|
+
|
|
7
|
+
# Local AI assistant/workspace state
|
|
8
|
+
.codex/
|
|
9
|
+
.claude/
|
|
10
|
+
.gemini/
|
|
11
|
+
.aider*
|
|
12
|
+
|
|
13
|
+
# Pinned specification snapshot generated by scripts/sync_spec.py
|
|
14
|
+
.jharness-spec/
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
.python-version
|
|
20
|
+
.venv/
|
|
21
|
+
venv/
|
|
22
|
+
ENV/
|
|
23
|
+
|
|
24
|
+
# Python tooling caches
|
|
25
|
+
.pytest_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
.pyright/
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
|
|
32
|
+
# Packaging/build artifacts
|
|
33
|
+
dist/
|
|
34
|
+
build/
|
|
35
|
+
*.egg-info/
|
|
36
|
+
|
|
37
|
+
# Node/TypeScript SDK artifacts
|
|
38
|
+
node_modules/
|
|
39
|
+
coverage/
|
|
40
|
+
.turbo/
|
|
41
|
+
.next/
|
|
42
|
+
*.tsbuildinfo
|
|
43
|
+
|
|
44
|
+
# Logs
|
|
45
|
+
*.log
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
+MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JHarness contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jharness-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JHarness tool registry, validation helpers, and execution decorators
|
|
5
|
+
Project-URL: Documentation, https://github.com/Ezio2000/jharness-python/tree/main/docs
|
|
6
|
+
Project-URL: Homepage, https://github.com/Ezio2000/jharness-python
|
|
7
|
+
Project-URL: Issues, https://github.com/Ezio2000/jharness-python/issues
|
|
8
|
+
Project-URL: Repository, https://github.com/Ezio2000/jharness-python.git
|
|
9
|
+
Author: JHarness contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,ai,json-schema,tools
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: jharness-kernel<0.2.0,>=0.1.0
|
|
25
|
+
Requires-Dist: jsonschema>=4.23.0
|
|
26
|
+
Requires-Dist: referencing>=0.35.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# jharness-toolkit
|
|
30
|
+
|
|
31
|
+
`jharness-toolkit` is the concrete Python tool-support distribution for
|
|
32
|
+
`jharness.kernel`. It provides an immutable tool registry, JSON Schema
|
|
33
|
+
validation, async function adapters, and retry and circuit-breaking decorators.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add jharness-toolkit
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from jharness.toolkit import ToolRegistry, function_tool
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Runtime semantics and portable tool values remain in `jharness.kernel`;
|
|
44
|
+
`jharness.toolkit` only implements concrete adaptation and execution policies. See the
|
|
45
|
+
[tool protocol](https://github.com/Ezio2000/jharness/blob/v0.1.0/docs/tool-protocol.md) and
|
|
46
|
+
[Python package boundaries](https://github.com/Ezio2000/jharness-python/blob/main/docs/python-package-boundaries.md).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# jharness-toolkit
|
|
2
|
+
|
|
3
|
+
`jharness-toolkit` is the concrete Python tool-support distribution for
|
|
4
|
+
`jharness.kernel`. It provides an immutable tool registry, JSON Schema
|
|
5
|
+
validation, async function adapters, and retry and circuit-breaking decorators.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add jharness-toolkit
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from jharness.toolkit import ToolRegistry, function_tool
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Runtime semantics and portable tool values remain in `jharness.kernel`;
|
|
16
|
+
`jharness.toolkit` only implements concrete adaptation and execution policies. See the
|
|
17
|
+
[tool protocol](https://github.com/Ezio2000/jharness/blob/v0.1.0/docs/tool-protocol.md) and
|
|
18
|
+
[Python package boundaries](https://github.com/Ezio2000/jharness-python/blob/main/docs/python-package-boundaries.md).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jharness-toolkit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "JHarness tool registry, validation helpers, and execution decorators"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "JHarness contributors" }]
|
|
10
|
+
keywords = ["ai", "agent", "json-schema", "tools"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"jsonschema>=4.23.0",
|
|
25
|
+
"jharness-kernel>=0.1.0,<0.2.0",
|
|
26
|
+
"referencing>=0.35.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Documentation = "https://github.com/Ezio2000/jharness-python/tree/main/docs"
|
|
31
|
+
Homepage = "https://github.com/Ezio2000/jharness-python"
|
|
32
|
+
Issues = "https://github.com/Ezio2000/jharness-python/issues"
|
|
33
|
+
Repository = "https://github.com/Ezio2000/jharness-python.git"
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources]
|
|
36
|
+
jharness-kernel = { workspace = true }
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/jharness"]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Concrete tool catalog, adapters, and decorators for kernel."""
|
|
2
|
+
|
|
3
|
+
from jharness.toolkit.decorators import CircuitBreakingTool, RetryingTool
|
|
4
|
+
from jharness.toolkit.registry import ToolRegistry
|
|
5
|
+
from jharness.toolkit.tool import FunctionTool, Tool, ToolFunction, function_tool
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CircuitBreakingTool",
|
|
9
|
+
"FunctionTool",
|
|
10
|
+
"RetryingTool",
|
|
11
|
+
"Tool",
|
|
12
|
+
"ToolFunction",
|
|
13
|
+
"ToolRegistry",
|
|
14
|
+
"function_tool",
|
|
15
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Composable tool execution policies outside kernel state-machine semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
from jharness.kernel import SettledResult, ToolCall, ToolContext, ToolFailure, ToolResult, ToolSpec
|
|
10
|
+
from jharness.toolkit.tool import Tool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RetryingTool:
|
|
15
|
+
"""Retry implementation exceptions for an explicitly idempotent tool."""
|
|
16
|
+
|
|
17
|
+
tool: Tool
|
|
18
|
+
max_attempts: int = 2
|
|
19
|
+
attempt_timeout_seconds: float | None = None
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.max_attempts < 1:
|
|
23
|
+
raise ValueError("max_attempts must be >= 1")
|
|
24
|
+
if self.max_attempts > 1 and not self.spec.execution.idempotent:
|
|
25
|
+
raise ValueError("retrying tool must be idempotent")
|
|
26
|
+
if self.attempt_timeout_seconds is not None and self.attempt_timeout_seconds <= 0:
|
|
27
|
+
raise ValueError("attempt_timeout_seconds must be > 0")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def spec(self) -> ToolSpec:
|
|
31
|
+
return self.tool.spec
|
|
32
|
+
|
|
33
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
34
|
+
attempt = 1
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
async with asyncio.timeout(self.attempt_timeout_seconds):
|
|
38
|
+
return await self.tool.invoke(call, context)
|
|
39
|
+
except Exception:
|
|
40
|
+
if attempt == self.max_attempts:
|
|
41
|
+
raise
|
|
42
|
+
attempt += 1
|
|
43
|
+
await asyncio.sleep(0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class CircuitBreakingTool:
|
|
48
|
+
"""Reject calls after consecutive implementation exceptions."""
|
|
49
|
+
|
|
50
|
+
tool: Tool
|
|
51
|
+
failure_threshold: int = 3
|
|
52
|
+
_lock: Lock = field(default_factory=Lock, init=False, repr=False, compare=False)
|
|
53
|
+
_failures: int = field(default=0, init=False, repr=False, compare=False)
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.failure_threshold < 1:
|
|
57
|
+
raise ValueError("failure_threshold must be >= 1")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def spec(self) -> ToolSpec:
|
|
61
|
+
return self.tool.spec
|
|
62
|
+
|
|
63
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
64
|
+
with self._lock:
|
|
65
|
+
if self._failures >= self.failure_threshold:
|
|
66
|
+
return SettledResult(ToolFailure.from_error("circuit_open", "tool circuit is open"))
|
|
67
|
+
try:
|
|
68
|
+
result = await self.tool.invoke(call, context)
|
|
69
|
+
except Exception:
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._failures += 1
|
|
72
|
+
raise
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._failures = 0
|
|
75
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Immutable invocation catalogs with compiled JSON Schema validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from inspect import iscoroutinefunction
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from jsonschema import Draft202012Validator
|
|
14
|
+
from jsonschema.exceptions import SchemaError, ValidationError
|
|
15
|
+
from jsonschema.protocols import Validator
|
|
16
|
+
from referencing import Registry
|
|
17
|
+
from referencing.jsonschema import DRAFT202012, Schema, SchemaRegistry, SchemaResource
|
|
18
|
+
|
|
19
|
+
from jharness.kernel import (
|
|
20
|
+
SettledResult,
|
|
21
|
+
ToolBinding,
|
|
22
|
+
ToolCall,
|
|
23
|
+
ToolCatalog,
|
|
24
|
+
ToolContext,
|
|
25
|
+
ToolError,
|
|
26
|
+
ToolResult,
|
|
27
|
+
ToolSpec,
|
|
28
|
+
WaitingResult,
|
|
29
|
+
thaw_json_value,
|
|
30
|
+
)
|
|
31
|
+
from jharness.toolkit.tool import Tool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class _Registered:
|
|
36
|
+
tool: Tool
|
|
37
|
+
spec: ToolSpec
|
|
38
|
+
input_validator: Validator
|
|
39
|
+
output_validator: Validator | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ToolRegistry:
|
|
43
|
+
"""Thread-safe registry that opens immutable invocation catalogs."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ("_entries", "_lock")
|
|
46
|
+
|
|
47
|
+
def __init__(self, tools: Sequence[Tool] = ()) -> None:
|
|
48
|
+
self._lock = Lock()
|
|
49
|
+
self._entries: dict[str, _Registered] = {}
|
|
50
|
+
for tool in tools:
|
|
51
|
+
self.register(tool)
|
|
52
|
+
|
|
53
|
+
def register(self, tool: Tool) -> None:
|
|
54
|
+
_validate_tool(tool)
|
|
55
|
+
spec = tool.spec
|
|
56
|
+
registered = _Registered(
|
|
57
|
+
tool,
|
|
58
|
+
spec,
|
|
59
|
+
_compile_schema(spec.input_schema, f"tool {spec.name} input_schema"),
|
|
60
|
+
(
|
|
61
|
+
None
|
|
62
|
+
if spec.output_schema is None
|
|
63
|
+
else _compile_schema(
|
|
64
|
+
spec.output_schema,
|
|
65
|
+
f"tool {spec.name} output_schema",
|
|
66
|
+
)
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
with self._lock:
|
|
70
|
+
if spec.name in self._entries:
|
|
71
|
+
raise ValueError(f"duplicate tool name: {spec.name}")
|
|
72
|
+
self._entries[spec.name] = registered
|
|
73
|
+
|
|
74
|
+
async def open_catalog(self) -> ToolCatalog:
|
|
75
|
+
with self._lock:
|
|
76
|
+
return _Catalog(self._entries)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Catalog:
|
|
80
|
+
__slots__ = ("_entries", "_specs")
|
|
81
|
+
|
|
82
|
+
def __init__(self, entries: Mapping[str, _Registered]) -> None:
|
|
83
|
+
self._entries = MappingProxyType(dict(entries))
|
|
84
|
+
self._specs = tuple(entry.spec for entry in self._entries.values())
|
|
85
|
+
|
|
86
|
+
def specs(self) -> tuple[ToolSpec, ...]:
|
|
87
|
+
return self._specs
|
|
88
|
+
|
|
89
|
+
def spec(self, name: str) -> ToolSpec | None:
|
|
90
|
+
entry = self._entries.get(name)
|
|
91
|
+
return None if entry is None else entry.spec
|
|
92
|
+
|
|
93
|
+
def bind(self, call: ToolCall) -> ToolBinding:
|
|
94
|
+
entry = self._entries.get(call.name)
|
|
95
|
+
if entry is None:
|
|
96
|
+
raise ToolError(f"unknown tool: {call.name}")
|
|
97
|
+
try:
|
|
98
|
+
entry.input_validator.validate(thaw_json_value(call.arguments))
|
|
99
|
+
except ValidationError as exc:
|
|
100
|
+
raise ToolError(
|
|
101
|
+
f"tool {call.name} arguments do not match input_schema: {exc.message}"
|
|
102
|
+
) from exc
|
|
103
|
+
return _Binding(call, entry)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class _Binding:
|
|
108
|
+
call: ToolCall
|
|
109
|
+
_entry: _Registered
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def spec(self) -> ToolSpec:
|
|
113
|
+
return self._entry.spec
|
|
114
|
+
|
|
115
|
+
async def invoke(self, context: ToolContext) -> ToolResult:
|
|
116
|
+
result = _ensure_result(await self._entry.tool.invoke(self.call, context))
|
|
117
|
+
validator = self._entry.output_validator
|
|
118
|
+
if validator is not None:
|
|
119
|
+
try:
|
|
120
|
+
validator.validate(thaw_json_value(result.outcome.structured_content))
|
|
121
|
+
except ValidationError as exc:
|
|
122
|
+
raise ToolError(
|
|
123
|
+
f"tool {self.call.name} structured_content does not match "
|
|
124
|
+
f"output_schema: {exc.message}"
|
|
125
|
+
) from exc
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _compile_schema(
|
|
130
|
+
schema: Mapping[str, Any] | bool,
|
|
131
|
+
label: str,
|
|
132
|
+
) -> Validator:
|
|
133
|
+
plain: Schema = (
|
|
134
|
+
schema if isinstance(schema, bool) else cast(dict[str, Any], thaw_json_value(schema))
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
Draft202012Validator.check_schema(plain)
|
|
138
|
+
except SchemaError as exc:
|
|
139
|
+
raise ValueError(f"{label} must be a valid JSON Schema: {exc.message}") from exc
|
|
140
|
+
base = (
|
|
141
|
+
cast(str, plain.get("$id"))
|
|
142
|
+
if isinstance(plain, dict) and isinstance(plain.get("$id"), str)
|
|
143
|
+
else f"urn:jharness:toolkit:schema:{uuid4()}"
|
|
144
|
+
)
|
|
145
|
+
resource: SchemaResource = DRAFT202012.create_resource(plain)
|
|
146
|
+
registry: SchemaRegistry = Registry[Schema]().with_resource(base, resource).crawl()
|
|
147
|
+
resolver = registry.resolver(base)
|
|
148
|
+
try:
|
|
149
|
+
for reference in _references(plain):
|
|
150
|
+
resolver.lookup(reference)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
raise ValueError(f"{label} contains an unresolvable reference") from exc
|
|
153
|
+
return Draft202012Validator(plain, registry=registry)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ensure_result(value: object) -> ToolResult:
|
|
157
|
+
if not isinstance(value, SettledResult | WaitingResult):
|
|
158
|
+
raise ToolError("tool invoke must return SettledResult or WaitingResult")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _validate_tool(value: object) -> None:
|
|
163
|
+
if not isinstance(value, Tool):
|
|
164
|
+
raise TypeError("registered tool must implement Tool")
|
|
165
|
+
if not isinstance(cast(object, value.spec), ToolSpec):
|
|
166
|
+
raise TypeError("registered tool spec must be ToolSpec")
|
|
167
|
+
if not iscoroutinefunction(value.invoke):
|
|
168
|
+
raise TypeError("registered tool invoke must be async")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _references(value: object) -> Iterator[str]:
|
|
172
|
+
if isinstance(value, Mapping):
|
|
173
|
+
mapping = cast(Mapping[object, object], value)
|
|
174
|
+
reference = mapping.get("$ref")
|
|
175
|
+
if isinstance(reference, str):
|
|
176
|
+
yield reference
|
|
177
|
+
for item in mapping.values():
|
|
178
|
+
yield from _references(item)
|
|
179
|
+
elif isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
180
|
+
for item in cast(Sequence[object], value):
|
|
181
|
+
yield from _references(item)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Concrete Python tool protocol and function adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from inspect import iscoroutinefunction
|
|
8
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from jharness.kernel import (
|
|
11
|
+
ToolCall,
|
|
12
|
+
ToolContext,
|
|
13
|
+
ToolExecution,
|
|
14
|
+
ToolResult,
|
|
15
|
+
ToolRisk,
|
|
16
|
+
ToolSpec,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class Tool(Protocol):
|
|
22
|
+
"""One async invocation operation and one immutable specification."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def spec(self) -> ToolSpec: ...
|
|
26
|
+
|
|
27
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ToolFunction = Callable[[ToolCall, ToolContext], Coroutine[Any, Any, ToolResult]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FunctionTool:
|
|
35
|
+
"""Adapt one async function to the concrete tool protocol."""
|
|
36
|
+
|
|
37
|
+
spec: ToolSpec
|
|
38
|
+
function: ToolFunction
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
if not isinstance(cast(object, self.spec), ToolSpec):
|
|
42
|
+
raise TypeError("function tool spec must be ToolSpec")
|
|
43
|
+
if not iscoroutinefunction(self.function):
|
|
44
|
+
raise TypeError("function tool must be async")
|
|
45
|
+
|
|
46
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
47
|
+
return await self.function(call, context)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def function_tool(
|
|
51
|
+
*,
|
|
52
|
+
name: str,
|
|
53
|
+
description: str,
|
|
54
|
+
input_schema: Mapping[str, Any] | bool,
|
|
55
|
+
output_schema: Mapping[str, Any] | bool | None = None,
|
|
56
|
+
execution: ToolExecution | None = None,
|
|
57
|
+
risk: ToolRisk | None = None,
|
|
58
|
+
) -> Callable[[ToolFunction], FunctionTool]:
|
|
59
|
+
"""Create a `FunctionTool` while keeping schemas and policies explicit."""
|
|
60
|
+
|
|
61
|
+
tool_execution = ToolExecution() if execution is None else execution
|
|
62
|
+
tool_risk = ToolRisk() if risk is None else risk
|
|
63
|
+
spec = ToolSpec(
|
|
64
|
+
name,
|
|
65
|
+
description,
|
|
66
|
+
input_schema,
|
|
67
|
+
output_schema,
|
|
68
|
+
tool_execution,
|
|
69
|
+
tool_risk,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def decorate(function: ToolFunction) -> FunctionTool:
|
|
73
|
+
return FunctionTool(spec, function)
|
|
74
|
+
|
|
75
|
+
return decorate
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from jharness.kernel import (
|
|
11
|
+
ContentPart,
|
|
12
|
+
RunContext,
|
|
13
|
+
SettledResult,
|
|
14
|
+
Suspension,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolContext,
|
|
17
|
+
ToolError,
|
|
18
|
+
ToolExecution,
|
|
19
|
+
ToolFailure,
|
|
20
|
+
ToolResult,
|
|
21
|
+
ToolSpec,
|
|
22
|
+
ToolSuccess,
|
|
23
|
+
ToolWaiting,
|
|
24
|
+
WaitingResult,
|
|
25
|
+
)
|
|
26
|
+
from jharness.toolkit import (
|
|
27
|
+
CircuitBreakingTool,
|
|
28
|
+
FunctionTool,
|
|
29
|
+
RetryingTool,
|
|
30
|
+
ToolRegistry,
|
|
31
|
+
function_tool,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def no_progress(_value: Mapping[str, Any]) -> None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def context() -> ToolContext:
|
|
39
|
+
return ToolContext(RunContext("run-1", 1.0), no_progress, lambda: False)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class ValueTool:
|
|
44
|
+
spec: ToolSpec
|
|
45
|
+
value: object
|
|
46
|
+
calls: int = 0
|
|
47
|
+
|
|
48
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
49
|
+
self.calls += 1
|
|
50
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("ok"),), self.value))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_registry_opens_immutable_invocation_catalogs() -> None:
|
|
54
|
+
first = ValueTool(ToolSpec("first", "first", {"type": "object"}), {"value": 1})
|
|
55
|
+
second = ValueTool(ToolSpec("second", "second", {"type": "object"}), {"value": 2})
|
|
56
|
+
registry = ToolRegistry((first,))
|
|
57
|
+
before = await registry.open_catalog()
|
|
58
|
+
binding = before.bind(ToolCall("call", "first"))
|
|
59
|
+
registry.register(second)
|
|
60
|
+
after = await registry.open_catalog()
|
|
61
|
+
|
|
62
|
+
assert [spec.name for spec in before.specs()] == ["first"]
|
|
63
|
+
assert before.spec("second") is None
|
|
64
|
+
assert [spec.name for spec in after.specs()] == ["first", "second"]
|
|
65
|
+
assert binding.spec is first.spec
|
|
66
|
+
assert isinstance(await binding.invoke(context()), SettledResult)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def test_binding_validates_input_and_output_schemas() -> None:
|
|
70
|
+
tool = ValueTool(
|
|
71
|
+
ToolSpec(
|
|
72
|
+
"strict",
|
|
73
|
+
"strict",
|
|
74
|
+
{
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": ["count"],
|
|
77
|
+
"properties": {"count": {"type": "integer"}},
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"type": "object",
|
|
82
|
+
"required": ["value"],
|
|
83
|
+
"properties": {"value": {"type": "integer"}},
|
|
84
|
+
"additionalProperties": False,
|
|
85
|
+
},
|
|
86
|
+
),
|
|
87
|
+
{"value": 1},
|
|
88
|
+
)
|
|
89
|
+
catalog = await ToolRegistry((tool,)).open_catalog()
|
|
90
|
+
|
|
91
|
+
with pytest.raises(ToolError, match="input_schema"):
|
|
92
|
+
catalog.bind(ToolCall("bad", "strict", {"count": "one"}))
|
|
93
|
+
call = ToolCall("ok", "strict", {"count": 1})
|
|
94
|
+
binding = catalog.bind(call)
|
|
95
|
+
result = await binding.invoke(context())
|
|
96
|
+
assert binding.call is call
|
|
97
|
+
assert isinstance(result, SettledResult)
|
|
98
|
+
assert isinstance(result.outcome, ToolSuccess)
|
|
99
|
+
assert result.outcome.structured_content == {"value": 1}
|
|
100
|
+
|
|
101
|
+
invalid_output = ValueTool(tool.spec, {"value": "one"})
|
|
102
|
+
invalid_catalog = await ToolRegistry((invalid_output,)).open_catalog()
|
|
103
|
+
with pytest.raises(ToolError, match="output_schema"):
|
|
104
|
+
await invalid_catalog.bind(ToolCall("bad-output", "strict", {"count": 1})).invoke(context())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_binding_preserves_waiting_result_and_validates_its_output() -> None:
|
|
108
|
+
spec = ToolSpec(
|
|
109
|
+
"wait",
|
|
110
|
+
"wait",
|
|
111
|
+
{"type": "object"},
|
|
112
|
+
{
|
|
113
|
+
"type": "object",
|
|
114
|
+
"required": ["ticket"],
|
|
115
|
+
"properties": {"ticket": {"type": "string"}},
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class WaitingTool:
|
|
121
|
+
spec: ToolSpec
|
|
122
|
+
|
|
123
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
124
|
+
return WaitingResult(
|
|
125
|
+
ToolWaiting(
|
|
126
|
+
(ContentPart.text_part("pending"),),
|
|
127
|
+
structured_content={"ticket": "ticket-1"},
|
|
128
|
+
),
|
|
129
|
+
Suspension("external", "tool", "ticket-1"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
result = (
|
|
133
|
+
await (await ToolRegistry((WaitingTool(spec),)).open_catalog())
|
|
134
|
+
.bind(ToolCall("call", "wait"))
|
|
135
|
+
.invoke(context())
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
assert isinstance(result, WaitingResult)
|
|
139
|
+
assert result.outcome.kind == "waiting"
|
|
140
|
+
assert result.suspension.wait_id == "ticket-1"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_binding_rejects_an_unwrapped_outcome() -> None:
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class InvalidTool:
|
|
146
|
+
spec: ToolSpec
|
|
147
|
+
|
|
148
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
149
|
+
return cast(Any, ToolSuccess((ContentPart.text_part("invalid"),)))
|
|
150
|
+
|
|
151
|
+
catalog = await ToolRegistry(
|
|
152
|
+
(InvalidTool(ToolSpec("invalid", "invalid", {"type": "object"})),)
|
|
153
|
+
).open_catalog()
|
|
154
|
+
with pytest.raises(ToolError, match="SettledResult or WaitingResult"):
|
|
155
|
+
await catalog.bind(ToolCall("call", "invalid")).invoke(context())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_function_tool_decorator_preserves_explicit_spec() -> None:
|
|
159
|
+
@function_tool(
|
|
160
|
+
name="sum",
|
|
161
|
+
description="sum values",
|
|
162
|
+
input_schema={"type": "object"},
|
|
163
|
+
execution=ToolExecution(read_only=True, idempotent=True),
|
|
164
|
+
)
|
|
165
|
+
async def sum_tool(call: ToolCall, tool_context: ToolContext) -> ToolResult:
|
|
166
|
+
return SettledResult(
|
|
167
|
+
ToolSuccess((ContentPart.text_part(str(sum(call.arguments.values()))),))
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
result = await sum_tool.invoke(ToolCall("call", "sum", {"a": 1, "b": 2}), context())
|
|
171
|
+
assert sum_tool.spec.name == "sum"
|
|
172
|
+
assert isinstance(result, SettledResult)
|
|
173
|
+
assert result.outcome.parts[0].text == "3"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_function_tool_and_registry_reject_synchronous_implementations() -> None:
|
|
177
|
+
spec = ToolSpec("sync", "sync", {"type": "object"})
|
|
178
|
+
|
|
179
|
+
def sync_function(call: ToolCall, tool_context: ToolContext) -> ToolResult:
|
|
180
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("no"),)))
|
|
181
|
+
|
|
182
|
+
with pytest.raises(TypeError, match="must be async"):
|
|
183
|
+
FunctionTool(spec, cast(Any, sync_function))
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class SyncTool:
|
|
187
|
+
spec: ToolSpec
|
|
188
|
+
|
|
189
|
+
def invoke(self, call: ToolCall, tool_context: ToolContext) -> ToolResult:
|
|
190
|
+
return sync_function(call, tool_context)
|
|
191
|
+
|
|
192
|
+
with pytest.raises(TypeError, match="must be async"):
|
|
193
|
+
ToolRegistry((cast(Any, SyncTool(spec)),))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_tool_adapters_reject_invalid_values_and_policy_limits() -> None:
|
|
197
|
+
spec = ToolSpec(
|
|
198
|
+
"valid",
|
|
199
|
+
"valid",
|
|
200
|
+
{"type": "object"},
|
|
201
|
+
execution=ToolExecution(read_only=True, idempotent=True),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def valid_function(call: ToolCall, tool_context: ToolContext) -> ToolResult:
|
|
205
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("ok"),)))
|
|
206
|
+
|
|
207
|
+
with pytest.raises(TypeError, match="spec must be ToolSpec"):
|
|
208
|
+
FunctionTool(cast(Any, object()), valid_function)
|
|
209
|
+
with pytest.raises(TypeError, match="implement Tool"):
|
|
210
|
+
ToolRegistry((cast(Any, object()),))
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class InvalidSpecTool:
|
|
214
|
+
spec: object
|
|
215
|
+
|
|
216
|
+
async def invoke(self, call: ToolCall, tool_context: ToolContext) -> ToolResult:
|
|
217
|
+
return await valid_function(call, tool_context)
|
|
218
|
+
|
|
219
|
+
with pytest.raises(TypeError, match="spec must be ToolSpec"):
|
|
220
|
+
ToolRegistry((cast(Any, InvalidSpecTool(object())),))
|
|
221
|
+
base = FunctionTool(spec, valid_function)
|
|
222
|
+
with pytest.raises(ValueError, match="max_attempts"):
|
|
223
|
+
RetryingTool(base, max_attempts=0)
|
|
224
|
+
with pytest.raises(ValueError, match="attempt_timeout_seconds"):
|
|
225
|
+
RetryingTool(base, attempt_timeout_seconds=0)
|
|
226
|
+
with pytest.raises(ValueError, match="failure_threshold"):
|
|
227
|
+
CircuitBreakingTool(base, failure_threshold=0)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class FlakyTool:
|
|
231
|
+
spec = ToolSpec(
|
|
232
|
+
"flaky",
|
|
233
|
+
"flaky",
|
|
234
|
+
{"type": "object"},
|
|
235
|
+
execution=ToolExecution(read_only=True, idempotent=True),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def __init__(self, failures: int) -> None:
|
|
239
|
+
self.failures = failures
|
|
240
|
+
self.calls = 0
|
|
241
|
+
|
|
242
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
243
|
+
self.calls += 1
|
|
244
|
+
if self.calls <= self.failures:
|
|
245
|
+
raise RuntimeError("temporary")
|
|
246
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("ok"),)))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def test_retry_decorator_retries_one_logical_idempotent_call() -> None:
|
|
250
|
+
base = FlakyTool(2)
|
|
251
|
+
result = await RetryingTool(base, max_attempts=3).invoke(ToolCall("call", "flaky"), context())
|
|
252
|
+
|
|
253
|
+
assert isinstance(result, SettledResult)
|
|
254
|
+
assert isinstance(result.outcome, ToolSuccess)
|
|
255
|
+
assert base.calls == 3
|
|
256
|
+
with pytest.raises(ValueError, match="idempotent"):
|
|
257
|
+
RetryingTool(
|
|
258
|
+
ValueTool(ToolSpec("unsafe", "unsafe", {"type": "object"}), None),
|
|
259
|
+
max_attempts=2,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def test_retry_decorator_owns_attempt_timeout() -> None:
|
|
264
|
+
@dataclass(slots=True)
|
|
265
|
+
class SlowTool:
|
|
266
|
+
spec: ToolSpec
|
|
267
|
+
calls: int = 0
|
|
268
|
+
|
|
269
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
270
|
+
self.calls += 1
|
|
271
|
+
await asyncio.sleep(1)
|
|
272
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("late"),)))
|
|
273
|
+
|
|
274
|
+
slow = SlowTool(
|
|
275
|
+
ToolSpec(
|
|
276
|
+
"slow",
|
|
277
|
+
"slow",
|
|
278
|
+
{"type": "object"},
|
|
279
|
+
execution=ToolExecution(read_only=True, idempotent=True),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
with pytest.raises(TimeoutError):
|
|
283
|
+
await RetryingTool(slow, max_attempts=2, attempt_timeout_seconds=0.001).invoke(
|
|
284
|
+
ToolCall("call", "slow"), context()
|
|
285
|
+
)
|
|
286
|
+
assert slow.calls == 2
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def test_decorators_propagate_cancellation_without_recording_failure() -> None:
|
|
290
|
+
@dataclass(slots=True)
|
|
291
|
+
class CancellingTool:
|
|
292
|
+
spec: ToolSpec
|
|
293
|
+
calls: int = 0
|
|
294
|
+
|
|
295
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
296
|
+
self.calls += 1
|
|
297
|
+
raise asyncio.CancelledError
|
|
298
|
+
|
|
299
|
+
base = CancellingTool(
|
|
300
|
+
ToolSpec(
|
|
301
|
+
"cancel",
|
|
302
|
+
"cancel",
|
|
303
|
+
{"type": "object"},
|
|
304
|
+
execution=ToolExecution(read_only=True, idempotent=True),
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
call = ToolCall("call", "cancel")
|
|
308
|
+
with pytest.raises(asyncio.CancelledError):
|
|
309
|
+
await RetryingTool(base, max_attempts=2).invoke(call, context())
|
|
310
|
+
breaker = CircuitBreakingTool(base, failure_threshold=1)
|
|
311
|
+
assert breaker.spec is base.spec
|
|
312
|
+
with pytest.raises(asyncio.CancelledError):
|
|
313
|
+
await breaker.invoke(call, context())
|
|
314
|
+
with pytest.raises(asyncio.CancelledError):
|
|
315
|
+
await breaker.invoke(call, context())
|
|
316
|
+
assert base.calls == 3
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def test_circuit_breaker_success_resets_consecutive_failure_count() -> None:
|
|
320
|
+
@dataclass(slots=True)
|
|
321
|
+
class AlternatingTool:
|
|
322
|
+
spec: ToolSpec
|
|
323
|
+
calls: int = 0
|
|
324
|
+
|
|
325
|
+
async def invoke(self, call: ToolCall, context: ToolContext) -> ToolResult:
|
|
326
|
+
self.calls += 1
|
|
327
|
+
if self.calls % 2:
|
|
328
|
+
raise RuntimeError("temporary")
|
|
329
|
+
return SettledResult(ToolSuccess((ContentPart.text_part("ok"),)))
|
|
330
|
+
|
|
331
|
+
base = AlternatingTool(ToolSpec("alternating", "alternating", {"type": "object"}))
|
|
332
|
+
breaker = CircuitBreakingTool(base, failure_threshold=2)
|
|
333
|
+
call = ToolCall("call", "alternating")
|
|
334
|
+
for _ in range(2):
|
|
335
|
+
with pytest.raises(RuntimeError, match="temporary"):
|
|
336
|
+
await breaker.invoke(call, context())
|
|
337
|
+
result = await breaker.invoke(call, context())
|
|
338
|
+
assert isinstance(result, SettledResult)
|
|
339
|
+
assert isinstance(result.outcome, ToolSuccess)
|
|
340
|
+
assert base.calls == 4
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def test_circuit_breaker_opens_after_consecutive_failures() -> None:
|
|
344
|
+
base = FlakyTool(10)
|
|
345
|
+
breaker = CircuitBreakingTool(base, failure_threshold=2)
|
|
346
|
+
call = ToolCall("call", "flaky")
|
|
347
|
+
for _ in range(2):
|
|
348
|
+
with pytest.raises(RuntimeError, match="temporary"):
|
|
349
|
+
await breaker.invoke(call, context())
|
|
350
|
+
|
|
351
|
+
result = await breaker.invoke(call, context())
|
|
352
|
+
assert isinstance(result, SettledResult)
|
|
353
|
+
assert isinstance(result.outcome, ToolFailure)
|
|
354
|
+
assert result.outcome.error.code == "circuit_open"
|
|
355
|
+
assert base.calls == 2
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_registry_rejects_duplicate_names_and_invalid_schemas() -> None:
|
|
359
|
+
tool = ValueTool(ToolSpec("same", "same", {"type": "object"}), None)
|
|
360
|
+
with pytest.raises(ValueError, match="duplicate"):
|
|
361
|
+
ToolRegistry((tool, tool))
|
|
362
|
+
with pytest.raises(ValueError, match="valid JSON Schema"):
|
|
363
|
+
ToolRegistry(
|
|
364
|
+
(
|
|
365
|
+
ValueTool(
|
|
366
|
+
ToolSpec("bad", "bad", {"type": "not-a-json-schema-type"}),
|
|
367
|
+
None,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
with pytest.raises(ValueError, match="unresolvable"):
|
|
372
|
+
ToolRegistry((ValueTool(ToolSpec("ref", "ref", {"$ref": "missing"}), None),))
|