jharness-toolkit 0.1.0__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.
@@ -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,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,9 @@
1
+ jharness/toolkit/__init__.py,sha256=EgIpaFhh-JHW_xvId52bCgvMOoAKCFMLh_4yDpVmaV4,429
2
+ jharness/toolkit/decorators.py,sha256=KB7TzP4DJtZ7x2BSf4AmugV6id1KkuCkgmu6cVhP8dQ,2590
3
+ jharness/toolkit/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ jharness/toolkit/registry.py,sha256=FX0YH4j-fFdlVU5EJtalvmj8SI-4GXT4768-U0tFsVo,6029
5
+ jharness/toolkit/tool.py,sha256=FGfBqzBCSzEj4ktar9yqBnKpyxszbDLhRRctWSyH6jI,2098
6
+ jharness_toolkit-0.1.0.dist-info/METADATA,sha256=Hi6DkclC0AcUtAAAqr7QGxVDov2wibZmiI_Wj8aXQ8A,1929
7
+ jharness_toolkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ jharness_toolkit-0.1.0.dist-info/licenses/LICENSE,sha256=XzIWNbzi6XETaKrN27-ExGMZwjoAMDNssS8g_F-Dcv8,1079
9
+ jharness_toolkit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.