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.
@@ -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,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),))