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 ADDED
@@ -0,0 +1,123 @@
1
+ """Toolbound: contract-first, secure-by-default tools for AI agents."""
2
+
3
+ from toolbound.core.approval import PendingApproval
4
+ from toolbound.core.budget import ByteEstimator
5
+ from toolbound.core.context import ExecutionContext
6
+ from toolbound.core.contracts import (
7
+ ApiKeyAuth,
8
+ BasicAuth,
9
+ BearerAuth,
10
+ Const,
11
+ Effect,
12
+ Field,
13
+ FromContext,
14
+ Limits,
15
+ NetworkPolicy,
16
+ NoAuth,
17
+ Operation,
18
+ Output,
19
+ Pagination,
20
+ PythonTool,
21
+ RateLimit,
22
+ RetryPolicy,
23
+ Service,
24
+ Tool,
25
+ ToolSpec,
26
+ from_ctx,
27
+ )
28
+ from toolbound.core.credentials import EnvCredentials, StaticCredentials
29
+ from toolbound.core.errors import (
30
+ ApprovalInvalid,
31
+ ConfigurationError,
32
+ ErrorCode,
33
+ NetworkBlocked,
34
+ StateStoreError,
35
+ ToolboundError,
36
+ ToolboundWarning,
37
+ ToolFailure,
38
+ )
39
+ from toolbound.core.exposure import ToolDefinition
40
+ from toolbound.core.gateway import Gateway, allow_all, deny_all
41
+ from toolbound.core.observe import Event, LoggingObserver
42
+ from toolbound.core.ports import (
43
+ AuthorizationRequest,
44
+ Decision,
45
+ HttpRequest,
46
+ HttpResponse,
47
+ StateStore,
48
+ TokenEstimator,
49
+ Transport,
50
+ )
51
+ from toolbound.core.result import ResultMeta, ToolError, ToolResult
52
+ from toolbound.core.shaping import Omission
53
+ from toolbound.core.state import MemoryStateStore
54
+ from toolbound.transport.mock import MockResponse, MockTransport
55
+
56
+ __version__ = "0.2.0"
57
+
58
+
59
+ def __getattr__(name: str) -> object:
60
+ if name == "SecureTransport":
61
+ from toolbound.transport.http import SecureTransport
62
+
63
+ return SecureTransport
64
+ raise AttributeError(name)
65
+
66
+
67
+ __all__ = [
68
+ "ApiKeyAuth",
69
+ "ApprovalInvalid",
70
+ "AuthorizationRequest",
71
+ "BasicAuth",
72
+ "BearerAuth",
73
+ "ByteEstimator",
74
+ "ConfigurationError",
75
+ "Const",
76
+ "Decision",
77
+ "Effect",
78
+ "EnvCredentials",
79
+ "ErrorCode",
80
+ "Event",
81
+ "ExecutionContext",
82
+ "Field",
83
+ "FromContext",
84
+ "Gateway",
85
+ "HttpRequest",
86
+ "HttpResponse",
87
+ "Limits",
88
+ "LoggingObserver",
89
+ "MemoryStateStore",
90
+ "MockResponse",
91
+ "MockTransport",
92
+ "NetworkBlocked",
93
+ "NetworkPolicy",
94
+ "NoAuth",
95
+ "Omission",
96
+ "Operation",
97
+ "Output",
98
+ "Pagination",
99
+ "PendingApproval",
100
+ "PythonTool",
101
+ "RateLimit",
102
+ "ResultMeta",
103
+ "RetryPolicy",
104
+ "SecureTransport",
105
+ "Service",
106
+ "StateStore",
107
+ "StateStoreError",
108
+ "StaticCredentials",
109
+ "TokenEstimator",
110
+ "Tool",
111
+ "ToolDefinition",
112
+ "ToolError",
113
+ "ToolFailure",
114
+ "ToolResult",
115
+ "ToolSpec",
116
+ "ToolboundError",
117
+ "ToolboundWarning",
118
+ "Transport",
119
+ "__version__",
120
+ "allow_all",
121
+ "deny_all",
122
+ "from_ctx",
123
+ ]
@@ -0,0 +1,9 @@
1
+ """Optional provider adapters.
2
+
3
+ Each adapter converts Toolbound definitions to a provider's tool format and runs
4
+ the provider's tool calls through the Gateway. None of them import a provider
5
+ SDK, and none contain security logic: everything is enforced by the core.
6
+
7
+ * :mod:`toolbound.adapters.anthropic` - Anthropic Messages API
8
+ * :mod:`toolbound.adapters.openai` - OpenAI Chat Completions and Responses APIs
9
+ """
@@ -0,0 +1,84 @@
1
+ """Shared helpers for provider adapters (no third-party imports)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from toolbound.core.approval import PendingApproval
12
+ from toolbound.core.context import ExecutionContext
13
+ from toolbound.core.errors import ErrorCode
14
+ from toolbound.core.gateway import Gateway
15
+ from toolbound.core.result import ToolError, ToolResult
16
+
17
+
18
+ def get(obj: Any, key: str, default: Any = None) -> Any:
19
+ """Read ``key`` from a dict or an SDK object."""
20
+ if isinstance(obj, dict):
21
+ return obj.get(key, default)
22
+ return getattr(obj, key, default)
23
+
24
+
25
+ def optional_id(value: Any) -> str | None:
26
+ """A provider tool-call id, or None when missing (never the string "None",
27
+ which would make every id-less call look like a duplicate of the first)."""
28
+ return None if value is None or value == "" else str(value)
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class Call:
33
+ id: str | None
34
+ name: str
35
+ arguments: Any
36
+ parse_error: str | None = None
37
+
38
+
39
+ def parse_json_arguments(raw: Any) -> tuple[Any, str | None]:
40
+ if isinstance(raw, dict):
41
+ return raw, None
42
+ if raw is None or raw == "":
43
+ return {}, None
44
+ if isinstance(raw, str):
45
+ try:
46
+ return json.loads(raw), None
47
+ except ValueError:
48
+ return None, "tool arguments were not valid JSON"
49
+ return None, "tool arguments must be a JSON object"
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class ToolCallBatch:
54
+ """Results of executing one model turn's tool calls.
55
+
56
+ ``messages`` (or ``blocks``) are ready to append to the conversation.
57
+ ``results`` holds the full :class:`ToolResult` objects for host code, and
58
+ ``pending_approvals`` lists calls waiting for a human decision.
59
+ """
60
+
61
+ results: list[ToolResult] = field(default_factory=list)
62
+ blocks: list[dict[str, Any]] = field(default_factory=list)
63
+
64
+ @property
65
+ def pending_approvals(self) -> list[PendingApproval]:
66
+ return [r.approval for r in self.results if r.approval is not None]
67
+
68
+
69
+ async def execute_calls(
70
+ gateway: Gateway, calls: Sequence[Call], ctx: ExecutionContext, *, parallel: bool
71
+ ) -> list[ToolResult]:
72
+ async def one(call: Call) -> ToolResult:
73
+ if call.parse_error is not None:
74
+ return ToolResult(
75
+ ok=False,
76
+ tool=call.name,
77
+ error=ToolError(ErrorCode.INVALID_ARGUMENTS, call.parse_error),
78
+ call_id=call.id,
79
+ )
80
+ return await gateway.call(call.name, call.arguments, ctx=ctx, call_id=call.id)
81
+
82
+ if parallel:
83
+ return list(await asyncio.gather(*(one(c) for c in calls)))
84
+ return [await one(c) for c in calls]
@@ -0,0 +1,81 @@
1
+ """Anthropic Messages API adapter. Does not import the ``anthropic`` package.
2
+
3
+ ```python
4
+ from toolbound.adapters import anthropic as tb
5
+
6
+ tools = tb.tools(gateway, ctx)
7
+ response = client.messages.create(model=..., tools=tools, messages=messages, max_tokens=...)
8
+ batch = await tb.run_tool_calls(gateway, response, ctx)
9
+ messages.append({"role": "assistant", "content": response.content})
10
+ messages.append(batch.message)
11
+ ```
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable
17
+ from typing import Any
18
+
19
+ from toolbound.core.context import ExecutionContext
20
+ from toolbound.core.exposure import Strategy
21
+ from toolbound.core.gateway import Gateway
22
+
23
+ from ._common import Call, ToolCallBatch, execute_calls, get, optional_id, parse_json_arguments
24
+
25
+ __all__ = ["AnthropicToolCallBatch", "run_tool_calls", "tool_calls", "tools"]
26
+
27
+
28
+ def tools(
29
+ gateway: Gateway,
30
+ ctx: ExecutionContext,
31
+ *,
32
+ tags: Iterable[str] | None = None,
33
+ names: Iterable[str] | None = None,
34
+ strategy: Strategy = "direct",
35
+ ) -> list[dict[str, Any]]:
36
+ """Tool definitions in Anthropic format, filtered for ``ctx``."""
37
+ return [
38
+ {"name": d.name, "description": d.description, "input_schema": d.input_schema}
39
+ for d in gateway.definitions(ctx, tags=tags, names=names, strategy=strategy)
40
+ ]
41
+
42
+
43
+ def tool_calls(message: Any) -> list[Call]:
44
+ """Extract ``tool_use`` blocks from a Message, a content list, or dicts."""
45
+ content = get(message, "content", message)
46
+ calls: list[Call] = []
47
+ for block in content or []:
48
+ if get(block, "type") != "tool_use":
49
+ continue
50
+ arguments, error = parse_json_arguments(get(block, "input"))
51
+ calls.append(Call(optional_id(get(block, "id")), str(get(block, "name")), arguments, error))
52
+ return calls
53
+
54
+
55
+ class AnthropicToolCallBatch(ToolCallBatch):
56
+ @property
57
+ def message(self) -> dict[str, Any]:
58
+ """A ``user`` message containing every ``tool_result`` block."""
59
+ return {"role": "user", "content": list(self.blocks)}
60
+
61
+
62
+ async def run_tool_calls(
63
+ gateway: Gateway, message: Any, ctx: ExecutionContext, *, parallel: bool = False
64
+ ) -> AnthropicToolCallBatch:
65
+ """Execute every ``tool_use`` block and build matching ``tool_result`` blocks.
66
+
67
+ All results belong in a single user message (see :attr:`message`).
68
+ """
69
+ calls = tool_calls(message)
70
+ results = await execute_calls(gateway, calls, ctx, parallel=parallel)
71
+ batch = AnthropicToolCallBatch(results=results)
72
+ for call, result in zip(calls, results, strict=True):
73
+ block: dict[str, Any] = {
74
+ "type": "tool_result",
75
+ "tool_use_id": call.id,
76
+ "content": result.to_json(),
77
+ }
78
+ if not result.ok:
79
+ block["is_error"] = True
80
+ batch.blocks.append(block)
81
+ return batch
@@ -0,0 +1,115 @@
1
+ """OpenAI adapter (Chat Completions and Responses APIs). Does not import ``openai``.
2
+
3
+ ```python
4
+ from toolbound.adapters import openai as tb
5
+
6
+ # Chat Completions
7
+ tools = tb.tools(gateway, ctx)
8
+ completion = client.chat.completions.create(model=..., messages=messages, tools=tools)
9
+ message = completion.choices[0].message
10
+ batch = await tb.run_tool_calls(gateway, message, ctx)
11
+ messages.append(message)
12
+ messages.extend(batch.blocks) # role="tool" messages
13
+
14
+ # Responses API
15
+ tools = tb.tools(gateway, ctx, api="responses")
16
+ response = client.responses.create(model=..., input=items, tools=tools)
17
+ batch = await tb.run_tool_calls(gateway, response, ctx)
18
+ items.extend(response.output)
19
+ items.extend(batch.blocks) # function_call_output items
20
+ ```
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Iterable
26
+ from typing import Any, Literal
27
+
28
+ from toolbound.core.context import ExecutionContext
29
+ from toolbound.core.exposure import Strategy
30
+ from toolbound.core.gateway import Gateway
31
+
32
+ from ._common import Call, ToolCallBatch, execute_calls, get, optional_id, parse_json_arguments
33
+
34
+ __all__ = ["run_tool_calls", "tool_calls", "tools"]
35
+
36
+ Api = Literal["chat", "responses"]
37
+
38
+
39
+ def tools(
40
+ gateway: Gateway,
41
+ ctx: ExecutionContext,
42
+ *,
43
+ api: Api = "chat",
44
+ tags: Iterable[str] | None = None,
45
+ names: Iterable[str] | None = None,
46
+ strategy: Strategy = "direct",
47
+ ) -> list[dict[str, Any]]:
48
+ """Function-tool definitions for Chat Completions (default) or Responses."""
49
+ out: list[dict[str, Any]] = []
50
+ for d in gateway.definitions(ctx, tags=tags, names=names, strategy=strategy):
51
+ if api == "responses":
52
+ out.append(
53
+ {
54
+ "type": "function",
55
+ "name": d.name,
56
+ "description": d.description,
57
+ "parameters": d.input_schema,
58
+ }
59
+ )
60
+ else:
61
+ out.append(
62
+ {
63
+ "type": "function",
64
+ "function": {
65
+ "name": d.name,
66
+ "description": d.description,
67
+ "parameters": d.input_schema,
68
+ },
69
+ }
70
+ )
71
+ return out
72
+
73
+
74
+ def tool_calls(obj: Any) -> tuple[list[Call], Api]:
75
+ """Extract calls from a chat message/completion or a Responses API response."""
76
+ output = get(obj, "output")
77
+ if isinstance(output, list):
78
+ calls = []
79
+ for item in output:
80
+ if get(item, "type") != "function_call":
81
+ continue
82
+ arguments, error = parse_json_arguments(get(item, "arguments"))
83
+ calls.append(
84
+ Call(optional_id(get(item, "call_id")), str(get(item, "name")), arguments, error)
85
+ )
86
+ return calls, "responses"
87
+ choices = get(obj, "choices")
88
+ message = get(choices[0], "message") if choices else obj
89
+ calls = []
90
+ for item in get(message, "tool_calls") or []:
91
+ function = get(item, "function")
92
+ arguments, error = parse_json_arguments(get(function, "arguments"))
93
+ calls.append(
94
+ Call(optional_id(get(item, "id")), str(get(function, "name")), arguments, error)
95
+ )
96
+ return calls, "chat"
97
+
98
+
99
+ async def run_tool_calls(
100
+ gateway: Gateway, obj: Any, ctx: ExecutionContext, *, parallel: bool = False
101
+ ) -> ToolCallBatch:
102
+ """Execute every function call and build the matching result items."""
103
+ calls, api = tool_calls(obj)
104
+ results = await execute_calls(gateway, calls, ctx, parallel=parallel)
105
+ batch = ToolCallBatch(results=results)
106
+ for call, result in zip(calls, results, strict=True):
107
+ if api == "responses":
108
+ batch.blocks.append(
109
+ {"type": "function_call_output", "call_id": call.id, "output": result.to_json()}
110
+ )
111
+ else:
112
+ batch.blocks.append(
113
+ {"role": "tool", "tool_call_id": call.id, "content": result.to_json()}
114
+ )
115
+ return batch
toolbound/cli.py ADDED
@@ -0,0 +1,69 @@
1
+ """``toolbound`` command line: validate contracts and preview tool definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from toolbound.core.budget import ByteEstimator
11
+ from toolbound.core.context import ExecutionContext
12
+ from toolbound.core.errors import ConfigurationError
13
+ from toolbound.core.gateway import Gateway, allow_all
14
+ from toolbound.loaders import CONTRACT_SCHEMA
15
+
16
+
17
+ def _gateway(path: str) -> Gateway:
18
+ return Gateway(authorize=allow_all).load(path)
19
+
20
+
21
+ def main(argv: Sequence[str] | None = None) -> int:
22
+ parser = argparse.ArgumentParser(prog="toolbound", description=__doc__)
23
+ sub = parser.add_subparsers(dest="command", required=True)
24
+ validate = sub.add_parser("validate", help="validate contract files")
25
+ validate.add_argument("path")
26
+ show = sub.add_parser("tools", help="print model-visible tool definitions")
27
+ show.add_argument("path")
28
+ show.add_argument("--format", choices=["anthropic", "openai", "neutral"], default="neutral")
29
+ show.add_argument("--strategy", choices=["direct", "grouped"], default="direct")
30
+ show.add_argument(
31
+ "--scope",
32
+ action="append",
33
+ default=[],
34
+ help="treat the viewer as having this scope (repeatable)",
35
+ )
36
+ sub.add_parser("schema", help="print the JSON Schema of contract files")
37
+ args = parser.parse_args(argv)
38
+
39
+ if args.command == "schema":
40
+ print(json.dumps(CONTRACT_SCHEMA, indent=2))
41
+ return 0
42
+ try:
43
+ gateway = _gateway(args.path)
44
+ except (ConfigurationError, OSError, ValueError) as exc:
45
+ print(f"error: {exc}", file=sys.stderr)
46
+ return 1
47
+ if args.command == "validate":
48
+ print(f"ok: {len(gateway.tool_names)} tool(s): {', '.join(gateway.tool_names)}")
49
+ return 0
50
+ ctx = ExecutionContext(actor="cli", scopes=frozenset(args.scope))
51
+ if args.format == "anthropic":
52
+ from toolbound.adapters import anthropic
53
+
54
+ payload: object = anthropic.tools(gateway, ctx, strategy=args.strategy)
55
+ elif args.format == "openai":
56
+ from toolbound.adapters import openai
57
+
58
+ payload = openai.tools(gateway, ctx, strategy=args.strategy)
59
+ else:
60
+ payload = [d.to_dict() for d in gateway.definitions(ctx, strategy=args.strategy)]
61
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
62
+ print(text)
63
+ compact = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
64
+ print(f"# ~{ByteEstimator().count(compact)} tokens (byte estimate)", file=sys.stderr)
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__": # pragma: no cover
69
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ """Framework-neutral core. Imports nothing from HTTP clients or AI frameworks.
2
+
3
+ An architecture test (``tests/test_architecture.py``) enforces this.
4
+ """