toolbound 0.2.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.
- toolbound/__init__.py +123 -0
- toolbound/adapters/__init__.py +9 -0
- toolbound/adapters/_common.py +84 -0
- toolbound/adapters/anthropic.py +81 -0
- toolbound/adapters/openai.py +115 -0
- toolbound/cli.py +69 -0
- toolbound/core/__init__.py +4 -0
- toolbound/core/approval.py +314 -0
- toolbound/core/budget.py +42 -0
- toolbound/core/context.py +56 -0
- toolbound/core/contracts.py +689 -0
- toolbound/core/credentials.py +41 -0
- toolbound/core/errors.py +117 -0
- toolbound/core/exposure.py +68 -0
- toolbound/core/gateway.py +1461 -0
- toolbound/core/network.py +267 -0
- toolbound/core/observe.py +60 -0
- toolbound/core/paths.py +138 -0
- toolbound/core/ports.py +166 -0
- toolbound/core/pyschema.py +119 -0
- toolbound/core/ratelimit.py +65 -0
- toolbound/core/result.py +100 -0
- toolbound/core/schema.py +201 -0
- toolbound/core/shaping.py +711 -0
- toolbound/core/signing.py +137 -0
- toolbound/core/state.py +102 -0
- toolbound/estimators.py +24 -0
- toolbound/loaders/__init__.py +202 -0
- toolbound/loaders/meta_schema.py +162 -0
- toolbound/py.typed +0 -0
- toolbound/sync.py +76 -0
- toolbound/transport/__init__.py +19 -0
- toolbound/transport/http.py +269 -0
- toolbound/transport/mock.py +89 -0
- toolbound-0.2.0.dist-info/METADATA +547 -0
- toolbound-0.2.0.dist-info/RECORD +39 -0
- toolbound-0.2.0.dist-info/WHEEL +4 -0
- toolbound-0.2.0.dist-info/entry_points.txt +2 -0
- toolbound-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Credential providers. Secrets stay in trusted code and never reach the model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
|
|
8
|
+
from .context import ExecutionContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnvCredentials:
|
|
12
|
+
"""Read credentials from environment variables.
|
|
13
|
+
|
|
14
|
+
``EnvCredentials({"github_token": "GITHUB_TOKEN"})`` maps names explicitly.
|
|
15
|
+
With no mapping, the name is upper-cased (``github_token`` -> ``GITHUB_TOKEN``),
|
|
16
|
+
optionally with a ``prefix``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, mapping: Mapping[str, str] | None = None, *, prefix: str = "") -> None:
|
|
20
|
+
self._mapping = dict(mapping or {})
|
|
21
|
+
self._prefix = prefix
|
|
22
|
+
|
|
23
|
+
def get(self, name: str, ctx: ExecutionContext) -> str | None:
|
|
24
|
+
var = self._mapping.get(name) or (self._prefix + name.upper().replace(".", "_"))
|
|
25
|
+
return os.environ.get(var) or None
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f"EnvCredentials(names={sorted(self._mapping)!r}, prefix={self._prefix!r})"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StaticCredentials:
|
|
32
|
+
"""Credentials from an in-memory mapping (tests, or values from your vault)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, values: Mapping[str, str]) -> None:
|
|
35
|
+
self._values = dict(values)
|
|
36
|
+
|
|
37
|
+
def get(self, name: str, ctx: ExecutionContext) -> str | None:
|
|
38
|
+
return self._values.get(name)
|
|
39
|
+
|
|
40
|
+
def __repr__(self) -> str: # never print secret values
|
|
41
|
+
return f"StaticCredentials(names={sorted(self._values)!r})"
|
toolbound/core/errors.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Error types.
|
|
2
|
+
|
|
3
|
+
Two kinds of failure exist in Toolbound:
|
|
4
|
+
|
|
5
|
+
* **Developer errors** (an invalid contract, a missing policy) raise
|
|
6
|
+
:class:`ConfigurationError` at registration time, so mistakes surface while
|
|
7
|
+
the application starts rather than in front of a model.
|
|
8
|
+
* **Runtime failures** (invalid arguments, denied authorization, a blocked
|
|
9
|
+
network target, an upstream error) never raise from :meth:`Gateway.call`.
|
|
10
|
+
They are returned as a :class:`~toolbound.ToolResult` with ``ok=False`` and an
|
|
11
|
+
:class:`ErrorCode`, so the agent loop can hand a safe, structured error back
|
|
12
|
+
to the model.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolboundError(Exception):
|
|
21
|
+
"""Base class for every exception raised by Toolbound."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolboundWarning(UserWarning):
|
|
25
|
+
"""A risky but legal configuration (for example an ephemeral signing key).
|
|
26
|
+
|
|
27
|
+
Turn these into errors in CI with ``-W error::toolbound.ToolboundWarning``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StateStoreError(ToolboundError):
|
|
32
|
+
"""A :class:`~toolbound.StateStore` could not record a claim.
|
|
33
|
+
|
|
34
|
+
Toolbound fails closed: the call is not executed.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigurationError(ToolboundError):
|
|
39
|
+
"""A contract, tool, or gateway setting is invalid. Raised to the developer."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ErrorCode(StrEnum):
|
|
43
|
+
"""Stable, machine-readable codes for failed tool calls."""
|
|
44
|
+
|
|
45
|
+
UNKNOWN_TOOL = "unknown_tool"
|
|
46
|
+
INVALID_ARGUMENTS = "invalid_arguments"
|
|
47
|
+
INVALID_CURSOR = "invalid_cursor"
|
|
48
|
+
FORBIDDEN = "forbidden"
|
|
49
|
+
APPROVAL_REQUIRED = "approval_required"
|
|
50
|
+
APPROVAL_DENIED = "approval_denied"
|
|
51
|
+
APPROVAL_INVALID = "approval_invalid"
|
|
52
|
+
DUPLICATE_CALL = "duplicate_call"
|
|
53
|
+
RATE_LIMITED = "rate_limited"
|
|
54
|
+
NETWORK_BLOCKED = "network_blocked"
|
|
55
|
+
TIMEOUT = "timeout"
|
|
56
|
+
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
|
|
57
|
+
UPSTREAM_ERROR = "upstream_error"
|
|
58
|
+
UPSTREAM_SCHEMA_MISMATCH = "upstream_schema_mismatch"
|
|
59
|
+
UNSUPPORTED_CONTENT_TYPE = "unsupported_content_type"
|
|
60
|
+
RESPONSE_TOO_LARGE = "response_too_large"
|
|
61
|
+
RESULT_TOO_LARGE = "result_too_large"
|
|
62
|
+
OUTCOME_UNKNOWN = "outcome_unknown"
|
|
63
|
+
INTERNAL_ERROR = "internal_error"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#: Codes where repeating the same call later may succeed.
|
|
67
|
+
RETRYABLE_CODES = frozenset(
|
|
68
|
+
{ErrorCode.RATE_LIMITED, ErrorCode.TIMEOUT, ErrorCode.UPSTREAM_UNAVAILABLE}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ToolFailure(ToolboundError):
|
|
73
|
+
"""Internal signal used to stop the pipeline with a model-safe error.
|
|
74
|
+
|
|
75
|
+
The ``message`` must never contain secrets, credentials, raw upstream bodies,
|
|
76
|
+
or stack traces: it is shown to the model.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
code: ErrorCode,
|
|
82
|
+
message: str,
|
|
83
|
+
*,
|
|
84
|
+
retryable: bool | None = None,
|
|
85
|
+
http_status: int | None = None,
|
|
86
|
+
details: dict[str, object] | None = None,
|
|
87
|
+
retry_after: float | None = None,
|
|
88
|
+
request_sent: bool | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.code = code
|
|
92
|
+
self.message = message
|
|
93
|
+
self.retryable = code in RETRYABLE_CODES if retryable is None else retryable
|
|
94
|
+
self.http_status = http_status
|
|
95
|
+
self.details = details or {}
|
|
96
|
+
self.retry_after = retry_after
|
|
97
|
+
#: ``False`` when the request certainly never reached the upstream (DNS,
|
|
98
|
+
#: connect, policy). ``None`` means it may have been sent.
|
|
99
|
+
self.request_sent = request_sent
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NetworkBlocked(ToolFailure):
|
|
103
|
+
"""A request target violated the network policy."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, message: str) -> None:
|
|
106
|
+
super().__init__(ErrorCode.NETWORK_BLOCKED, message, retryable=False, request_sent=False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ApprovalInvalid(ToolFailure):
|
|
110
|
+
"""An approval request or grant is forged, expired, reused, or for another call.
|
|
111
|
+
|
|
112
|
+
Raised by :meth:`Gateway.grant` (host code); :meth:`Gateway.call` returns it
|
|
113
|
+
as an ``approval_invalid`` result instead.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, message: str) -> None:
|
|
117
|
+
super().__init__(ErrorCode.APPROVAL_INVALID, message, retryable=False)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Model-visible tool definitions, independent of any provider format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
Strategy = Literal["direct", "grouped"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ToolDefinition:
|
|
15
|
+
"""Name, description and JSON Schema: everything a model is allowed to see."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
input_schema: dict[str, Any]
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"name": self.name,
|
|
24
|
+
"description": self.description,
|
|
25
|
+
"input_schema": copy.deepcopy(self.input_schema),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _summarize(schema: dict[str, Any]) -> str:
|
|
30
|
+
"""Compact one-line argument summary, e.g. ``id: string*, limit: integer 1..100``."""
|
|
31
|
+
required = set(schema.get("required", []))
|
|
32
|
+
parts = []
|
|
33
|
+
for name, prop in schema.get("properties", {}).items():
|
|
34
|
+
if not isinstance(prop, dict):
|
|
35
|
+
continue
|
|
36
|
+
if "enum" in prop:
|
|
37
|
+
kind = "|".join(str(v) for v in prop["enum"])
|
|
38
|
+
elif "const" in prop:
|
|
39
|
+
kind = f"={prop['const']}"
|
|
40
|
+
else:
|
|
41
|
+
kind = str(prop.get("type", "any"))
|
|
42
|
+
if "minimum" in prop or "maximum" in prop:
|
|
43
|
+
kind += f" {prop.get('minimum', '')}..{prop.get('maximum', '')}"
|
|
44
|
+
parts.append(f"{name}: {kind}{'*' if name in required else ''}")
|
|
45
|
+
return ", ".join(parts) or "none"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def group_definitions(group: str, members: Sequence[ToolDefinition]) -> ToolDefinition:
|
|
49
|
+
"""Combine several tools into one compact, ``action``-dispatched definition.
|
|
50
|
+
|
|
51
|
+
The model-facing schema for ``arguments`` is a plain object; each action's
|
|
52
|
+
arguments are summarized in the description (``*`` marks required). The full
|
|
53
|
+
per-action schema is still enforced by the gateway after dispatch.
|
|
54
|
+
"""
|
|
55
|
+
lines = [f"Actions on {group}. Set `action`; put its arguments in `arguments` (* = required)."]
|
|
56
|
+
for d in members:
|
|
57
|
+
lines.append(f"- {d.name}: {d.description.strip()} Args: {_summarize(d.input_schema)}")
|
|
58
|
+
description = "\n".join(lines)
|
|
59
|
+
schema = {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"action": {"type": "string", "enum": [d.name for d in members]},
|
|
63
|
+
"arguments": {"type": "object"},
|
|
64
|
+
},
|
|
65
|
+
"required": ["action", "arguments"],
|
|
66
|
+
"additionalProperties": False,
|
|
67
|
+
}
|
|
68
|
+
return ToolDefinition(group, description, schema)
|