toolbox-gateway 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,35 @@
1
+ # ── Python ─────────────────────────────────────────────────────────────
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .venv/
14
+ venv/
15
+ *.db
16
+ .idea/
17
+ .vscode/
18
+
19
+ # ── JavaScript ─────────────────────────────────────────────────────────
20
+ node_modules/
21
+ dist/
22
+ *.d.ts
23
+ *.js.map
24
+ *.tsbuildinfo
25
+ .next/
26
+ coverage/
27
+ .yalc/
28
+ yalc.lock
29
+
30
+ # ── OS / Editor ────────────────────────────────────────────────────────
31
+ .DS_Store
32
+ Thumbs.db
33
+ *.swp
34
+ *.swo
35
+ *~
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolbox-gateway
3
+ Version: 0.1.0
4
+ Summary: Single-tool gateway pattern for LLM agents — discover, inspect, and execute tools on demand
5
+ Author: Jason McNeal
6
+ License-Expression: MIT
7
+ Keywords: agent,ai,llm,mcp,token-optimization,tools
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.4; extra == 'dev'
21
+ Provides-Extra: langchain
22
+ Requires-Dist: langchain-core>=0.1; extra == 'langchain'
23
+ Requires-Dist: pydantic>=2.0; extra == 'langchain'
24
+ Provides-Extra: redis
25
+ Requires-Dist: redis>=5.0; extra == 'redis'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # toolbox-gateway
29
+
30
+ Python implementation of the single-tool gateway pattern for LLM agents.
31
+
32
+ See the [top-level README](../../README.md) for full documentation.
33
+
34
+ Install: `pip install toolbox-gateway`
35
+ Import: `from toolbox import Toolbox, Tool`
@@ -0,0 +1,8 @@
1
+ # toolbox-gateway
2
+
3
+ Python implementation of the single-tool gateway pattern for LLM agents.
4
+
5
+ See the [top-level README](../../README.md) for full documentation.
6
+
7
+ Install: `pip install toolbox-gateway`
8
+ Import: `from toolbox import Toolbox, Tool`
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toolbox-gateway"
7
+ version = "0.1.0"
8
+ description = "Single-tool gateway pattern for LLM agents — discover, inspect, and execute tools on demand"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Jason McNeal" },
14
+ ]
15
+ keywords = ["llm", "ai", "agent", "tools", "mcp", "token-optimization"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ ]
26
+
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ redis = ["redis>=5.0"]
31
+ langchain = ["langchain-core>=0.1", "pydantic>=2.0"]
32
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.4"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py310"
36
+ line-length = 100
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "N", "UP", "B"]
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/toolbox"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ asyncio_mode = "auto"
@@ -0,0 +1,20 @@
1
+ """Toolbox — a single-tool gateway pattern for LLM agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .core import Toolbox, Tool
6
+ from .hints import HintStore, Hint, MemoryHintStore
7
+ from .backends.sqlite_store import SQLiteHintStore
8
+
9
+ __all__ = [
10
+ "Toolbox",
11
+ "Tool",
12
+ "HintStore",
13
+ "Hint",
14
+ "MemoryHintStore",
15
+ "SQLiteHintStore",
16
+ ]
17
+
18
+ # Opt-in: schema formatting utilities
19
+ # Usage: from toolbox.schema import schema_to_csv, schema_to_markdown, data_to_csv
20
+ # Requires: pip install toolbox-gateway[schema] (future: may add dependencies)
@@ -0,0 +1,98 @@
1
+ """LangChain adapter for Toolbox.
2
+
3
+ Converts the toolbox into a single LangChain BaseTool instance.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional
10
+
11
+ from ..core import Toolbox
12
+
13
+
14
+ class LangChainAdapter:
15
+ """Adapt Toolbox as a single LangChain tool.
16
+
17
+ Requires langchain-core to be installed.
18
+
19
+ Usage::
20
+
21
+ adapter = LangChainAdapter(toolbox)
22
+ tool = adapter.as_tool()
23
+
24
+ # Use in a LangChain agent
25
+ agent = create_react_agent(llm, [tool], prompt)
26
+ """
27
+
28
+ def __init__(self, toolbox: Toolbox) -> None:
29
+ self.toolbox = toolbox
30
+
31
+ def as_tool(self) -> Any:
32
+ """Return a LangChain BaseTool wrapping the toolbox.
33
+
34
+ Raises ImportError if langchain-core is not installed.
35
+ """
36
+ try:
37
+ from langchain_core.tools import StructuredTool
38
+ except ImportError as e:
39
+ raise ImportError(
40
+ "langchain-core is required for the LangChain adapter. "
41
+ "Install it with: pip install langchain-core"
42
+ ) from e
43
+
44
+ def _run(command: str, **kwargs: Any) -> str:
45
+ result = self.toolbox.handle(command=command, **kwargs)
46
+ return json.dumps(result.to_dict(), default=str)
47
+
48
+ async def _arun(command: str, **kwargs: Any) -> str:
49
+ # Synchronous fallback — async execute is not yet supported
50
+ return _run(command, **kwargs)
51
+
52
+ defn = self.toolbox.get_tool_definition()
53
+ params = defn["parameters"]
54
+
55
+ return StructuredTool.from_function(
56
+ func=_run,
57
+ coroutine=_arun,
58
+ name=defn["name"],
59
+ description=defn["description"],
60
+ args_schema=_build_pydantic_schema(params),
61
+ )
62
+
63
+
64
+ def _build_pydantic_schema(json_schema: dict[str, Any]) -> Any:
65
+ """Convert a JSON Schema to a Pydantic model for LangChain."""
66
+ try:
67
+ from pydantic import BaseModel, Field, create_model
68
+ except ImportError:
69
+ raise ImportError("pydantic is required for the LangChain adapter")
70
+
71
+ properties = json_schema.get("properties", {})
72
+ required = set(json_schema.get("required", []))
73
+
74
+ fields: dict[str, Any] = {}
75
+ for name, prop in properties.items():
76
+ field_type = _json_type_to_python(prop)
77
+ is_required = name in required
78
+
79
+ if is_required:
80
+ fields[name] = (field_type, Field(description=prop.get("description", "")))
81
+ else:
82
+ fields[name] = (Optional[field_type], Field(default=None, description=prop.get("description", "")))
83
+
84
+ return create_model("ToolboxInput", **fields)
85
+
86
+
87
+ def _json_type_to_python(prop: dict[str, Any]) -> type:
88
+ """Map JSON Schema types to Python types."""
89
+ type_map = {
90
+ "string": str,
91
+ "integer": int,
92
+ "number": float,
93
+ "boolean": bool,
94
+ "array": list,
95
+ "object": dict,
96
+ }
97
+ json_type = prop.get("type", "string")
98
+ return type_map.get(json_type, str)
@@ -0,0 +1,63 @@
1
+ """OpenAI function calling adapter for Toolbox.
2
+
3
+ Converts the toolbox into a single OpenAI function definition and
4
+ provides a handler for tool call responses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from ..core import Toolbox, ToolboxCommand
13
+
14
+
15
+ class OpenAIAdapter:
16
+ """Adapt Toolbox for OpenAI's function calling API.
17
+
18
+ Usage::
19
+
20
+ adapter = OpenAIAdapter(toolbox)
21
+
22
+ # Get the function definition for your API call
23
+ functions = [adapter.get_function_schema()]
24
+
25
+ # When OpenAI returns a tool call, handle it:
26
+ result = adapter.handle_tool_call(tool_call)
27
+ """
28
+
29
+ def __init__(self, toolbox: Toolbox) -> None:
30
+ self.toolbox = toolbox
31
+
32
+ def get_function_schema(self) -> dict[str, Any]:
33
+ """Return the OpenAI function calling schema for toolbox."""
34
+ defn = self.toolbox.get_tool_definition()
35
+ return {
36
+ "type": "function",
37
+ "function": {
38
+ "name": defn["name"],
39
+ "description": defn["description"],
40
+ "parameters": defn["parameters"],
41
+ },
42
+ }
43
+
44
+ def handle_tool_call(self, tool_call: dict[str, Any]) -> dict[str, Any]:
45
+ """Handle an OpenAI tool call response.
46
+
47
+ Args:
48
+ tool_call: The tool call object from OpenAI's response,
49
+ with 'function' containing 'arguments' as a JSON string.
50
+
51
+ Returns:
52
+ A dict suitable for sending back as a tool message.
53
+ """
54
+ arguments = json.loads(tool_call["function"]["arguments"])
55
+ command = arguments.get("command", "")
56
+ result = self.toolbox.handle(command=command, **arguments)
57
+
58
+ return {
59
+ "tool_call_id": tool_call.get("id", ""),
60
+ "role": "tool",
61
+ "name": "toolbox",
62
+ "content": json.dumps(result.to_dict()),
63
+ }
@@ -0,0 +1,5 @@
1
+ """Toolbox backends — persistent hint storage."""
2
+
3
+ from .sqlite_store import SQLiteHintStore
4
+
5
+ __all__ = ["SQLiteHintStore"]
@@ -0,0 +1,131 @@
1
+ """Redis hint store — production-grade persistent hints with TTL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Optional
8
+
9
+ from ..hints import Hint, HintStore
10
+
11
+
12
+ class RedisHintStore:
13
+ """Redis-backed hint store for production use.
14
+
15
+ Hints are stored with a configurable TTL (default 30 days).
16
+ Uses SCAN for key discovery to avoid blocking Redis.
17
+
18
+ Requires the ``redis`` package: pip install redis
19
+
20
+ Usage::
21
+
22
+ import redis
23
+ client = redis.Redis(host="localhost", port=6379, db=0)
24
+ store = RedisHintStore(client)
25
+ """
26
+
27
+ KEY_PREFIX = "tool-hints"
28
+ DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days
29
+
30
+ def __init__(self, client: Any, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
31
+ self._redis = client
32
+ self._ttl = ttl_seconds
33
+
34
+ # ── Key helpers ────────────────────────────────────────────────
35
+
36
+ def _id_key(self, hint_id: str) -> str:
37
+ return f"{self.KEY_PREFIX}:id:{hint_id}"
38
+
39
+ def _index_key(self, category: str, key: str) -> str:
40
+ return f"{self.KEY_PREFIX}:index:{category}:{key}"
41
+
42
+ # ── Interface ──────────────────────────────────────────────────
43
+
44
+ def read(self, *, category: str | None = None, key: str | None = None) -> list[Hint]:
45
+ all_hints = self._get_all_hints()
46
+ if category:
47
+ all_hints = [h for h in all_hints if h.category == category]
48
+ if key:
49
+ all_hints = [h for h in all_hints if h.key == key]
50
+ return all_hints
51
+
52
+ def get_by_id(self, hint_id: str) -> Optional[Hint]:
53
+ data = self._redis.get(self._id_key(hint_id))
54
+ if data:
55
+ return Hint(**json.loads(data))
56
+ return None
57
+
58
+ def create(self, *, category: str, key: str, hint: str) -> Hint:
59
+ # Idempotent: check for existing hint with same category+key
60
+ existing_id = self._redis.get(self._index_key(category, key))
61
+ if existing_id:
62
+ existing_data = self._redis.get(self._id_key(existing_id.decode() if isinstance(existing_id, bytes) else existing_id))
63
+ if existing_data:
64
+ return Hint(**json.loads(existing_data))
65
+
66
+ from uuid import uuid4
67
+ now = datetime.now(timezone.utc).isoformat()
68
+ hint_obj = Hint(id=str(uuid4()), category=category, key=key, hint=hint, created_at=now, updated_at=now)
69
+
70
+ id_key = self._id_key(hint_obj.id)
71
+ idx_key = self._index_key(category, key)
72
+
73
+ self._redis.setex(id_key, self._ttl, json.dumps({
74
+ "id": hint_obj.id, "category": hint_obj.category,
75
+ "key": hint_obj.key, "hint": hint_obj.hint,
76
+ "created_at": hint_obj.created_at, "updated_at": hint_obj.updated_at,
77
+ }))
78
+ self._redis.setex(idx_key, self._ttl, hint_obj.id)
79
+
80
+ return hint_obj
81
+
82
+ def update(self, *, hint_id: str, hint: str) -> Optional[Hint]:
83
+ data = self._redis.get(self._id_key(hint_id))
84
+ if not data:
85
+ return None
86
+
87
+ existing = Hint(**json.loads(data))
88
+ updated = Hint(
89
+ id=existing.id, category=existing.category, key=existing.key,
90
+ hint=hint, created_at=existing.created_at,
91
+ updated_at=datetime.now(timezone.utc).isoformat(),
92
+ )
93
+
94
+ id_key = self._id_key(updated.id)
95
+ self._redis.setex(id_key, self._ttl, json.dumps({
96
+ "id": updated.id, "category": updated.category,
97
+ "key": updated.key, "hint": updated.hint,
98
+ "created_at": updated.created_at, "updated_at": updated.updated_at,
99
+ }))
100
+
101
+ return updated
102
+
103
+ def delete(self, *, hint_id: str) -> bool:
104
+ data = self._redis.get(self._id_key(hint_id))
105
+ if not data:
106
+ return False
107
+
108
+ existing = Hint(**json.loads(data))
109
+ self._redis.delete(self._id_key(hint_id))
110
+ self._redis.delete(self._index_key(existing.category, existing.key))
111
+ return True
112
+
113
+ # ── Internals ──────────────────────────────────────────────────
114
+
115
+ def _get_all_hints(self) -> list[Hint]:
116
+ """Use SCAN to discover all hint keys without blocking."""
117
+ hints: list[Hint] = []
118
+ pattern = f"{self.KEY_PREFIX}:id:*"
119
+ cursor = 0
120
+
121
+ while True:
122
+ cursor, keys = self._redis.scan(cursor, match=pattern, count=100)
123
+ for key in keys:
124
+ key_str = key.decode() if isinstance(key, bytes) else key
125
+ data = self._redis.get(key_str)
126
+ if data:
127
+ hints.append(Hint(**json.loads(data)))
128
+ if cursor == 0:
129
+ break
130
+
131
+ return hints
@@ -0,0 +1,133 @@
1
+ """SQLite hint store — lightweight persistent hints with no external deps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from ..hints import Hint, HintStore
12
+
13
+
14
+ DEFAULT_DB_PATH = ".toolbox/hints.db"
15
+
16
+
17
+ class SQLiteHintStore:
18
+ """SQLite-backed hint store for production use.
19
+
20
+ Zero external dependencies — uses Python's built-in sqlite3 module.
21
+ Persists hints across restarts with no infrastructure requirements.
22
+
23
+ Usage::
24
+
25
+ store = SQLiteHintStore() # .toolbox/hints.db
26
+ store = SQLiteHintStore(path="data/my_hints.db") # custom path
27
+ """
28
+
29
+ def __init__(self, path: str = DEFAULT_DB_PATH) -> None:
30
+ self._path = path
31
+ self._ensure_db()
32
+
33
+ def _ensure_db(self) -> None:
34
+ """Create the database and table if they don't exist."""
35
+ Path(self._path).parent.mkdir(parents=True, exist_ok=True)
36
+ with self._connect() as conn:
37
+ conn.execute("""
38
+ CREATE TABLE IF NOT EXISTS hints (
39
+ id TEXT PRIMARY KEY,
40
+ category TEXT NOT NULL,
41
+ key TEXT NOT NULL,
42
+ hint TEXT NOT NULL,
43
+ created_at TEXT NOT NULL,
44
+ updated_at TEXT NOT NULL
45
+ )
46
+ """)
47
+ conn.execute("""
48
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_hints_category_key
49
+ ON hints (category, key)
50
+ """)
51
+
52
+ def _connect(self) -> sqlite3.Connection:
53
+ conn = sqlite3.connect(self._path)
54
+ conn.row_factory = sqlite3.Row
55
+ return conn
56
+
57
+ @staticmethod
58
+ def _row_to_hint(row: sqlite3.Row) -> Hint:
59
+ return Hint(
60
+ id=row["id"],
61
+ category=row["category"],
62
+ key=row["key"],
63
+ hint=row["hint"],
64
+ created_at=row["created_at"],
65
+ updated_at=row["updated_at"],
66
+ )
67
+
68
+ # ── Interface ──────────────────────────────────────────────────
69
+
70
+ def read(self, *, category: str | None = None, key: str | None = None) -> list[Hint]:
71
+ with self._connect() as conn:
72
+ query = "SELECT * FROM hints"
73
+ conditions: list[str] = []
74
+ params: list[str] = []
75
+
76
+ if category:
77
+ conditions.append("category = ?")
78
+ params.append(category)
79
+ if key:
80
+ conditions.append("key = ?")
81
+ params.append(key)
82
+
83
+ if conditions:
84
+ query += " WHERE " + " AND ".join(conditions)
85
+
86
+ rows = conn.execute(query, params).fetchall()
87
+ return [self._row_to_hint(row) for row in rows]
88
+
89
+ def get_by_id(self, hint_id: str) -> Optional[Hint]:
90
+ with self._connect() as conn:
91
+ row = conn.execute("SELECT * FROM hints WHERE id = ?", (hint_id,)).fetchone()
92
+ return self._row_to_hint(row) if row else None
93
+
94
+ def create(self, *, category: str, key: str, hint: str) -> Hint:
95
+ from uuid import uuid4
96
+
97
+ with self._connect() as conn:
98
+ # Check for existing (idempotent)
99
+ existing = conn.execute(
100
+ "SELECT * FROM hints WHERE category = ? AND key = ?",
101
+ (category, key),
102
+ ).fetchone()
103
+
104
+ if existing:
105
+ return self._row_to_hint(existing)
106
+
107
+ now = datetime.now(timezone.utc).isoformat()
108
+ hint_obj = Hint(id=str(uuid4()), category=category, key=key, hint=hint, created_at=now, updated_at=now)
109
+
110
+ conn.execute(
111
+ "INSERT INTO hints (id, category, key, hint, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
112
+ (hint_obj.id, hint_obj.category, hint_obj.key, hint_obj.hint, hint_obj.created_at, hint_obj.updated_at),
113
+ )
114
+
115
+ return hint_obj
116
+
117
+ def update(self, *, hint_id: str, hint: str) -> Optional[Hint]:
118
+ now = datetime.now(timezone.utc).isoformat()
119
+ with self._connect() as conn:
120
+ cursor = conn.execute(
121
+ "UPDATE hints SET hint = ?, updated_at = ? WHERE id = ?",
122
+ (hint, now, hint_id),
123
+ )
124
+ if cursor.rowcount == 0:
125
+ return None
126
+
127
+ row = conn.execute("SELECT * FROM hints WHERE id = ?", (hint_id,)).fetchone()
128
+ return self._row_to_hint(row) if row else None
129
+
130
+ def delete(self, *, hint_id: str) -> bool:
131
+ with self._connect() as conn:
132
+ cursor = conn.execute("DELETE FROM hints WHERE id = ?", (hint_id,))
133
+ return cursor.rowcount > 0