toolnexus 0.1.1__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.
- toolnexus-0.1.1/.gitignore +28 -0
- toolnexus-0.1.1/PKG-INFO +99 -0
- toolnexus-0.1.1/README.md +84 -0
- toolnexus-0.1.1/examples/advanced.py +157 -0
- toolnexus-0.1.1/examples/agent.py +81 -0
- toolnexus-0.1.1/examples/basic.py +42 -0
- toolnexus-0.1.1/examples/hooks.py +127 -0
- toolnexus-0.1.1/examples/memory.py +51 -0
- toolnexus-0.1.1/examples/openrouter_test.py +109 -0
- toolnexus-0.1.1/examples/streaming.py +104 -0
- toolnexus-0.1.1/pyproject.toml +25 -0
- toolnexus-0.1.1/src/toolnexus/__init__.py +96 -0
- toolnexus-0.1.1/src/toolnexus/adapters.py +51 -0
- toolnexus-0.1.1/src/toolnexus/client.py +977 -0
- toolnexus-0.1.1/src/toolnexus/http.py +156 -0
- toolnexus-0.1.1/src/toolnexus/mcp_source.py +272 -0
- toolnexus-0.1.1/src/toolnexus/native.py +212 -0
- toolnexus-0.1.1/src/toolnexus/skill.py +199 -0
- toolnexus-0.1.1/src/toolnexus/toolkit.py +122 -0
- toolnexus-0.1.1/src/toolnexus/types.py +53 -0
- toolnexus-0.1.1/tests/test_client_resilience.py +267 -0
- toolnexus-0.1.1/tests/test_unit.py +273 -0
- toolnexus-0.1.1/uv.lock +783 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# JS
|
|
2
|
+
node_modules/
|
|
3
|
+
js/dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
|
|
16
|
+
# Go
|
|
17
|
+
golang/bin/
|
|
18
|
+
|
|
19
|
+
# OS / editor
|
|
20
|
+
.DS_Store
|
|
21
|
+
*.log
|
|
22
|
+
.env
|
|
23
|
+
|
|
24
|
+
# compiled Go binaries (examples / CLI built in-place)
|
|
25
|
+
golang/agent
|
|
26
|
+
golang/openrouter
|
|
27
|
+
golang/toolnexus
|
|
28
|
+
golang/cmd/toolnexus/toolnexus
|
toolnexus-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolnexus
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Provider-agnostic toolkit: dynamic MCP servers + agent skills for any LLM.
|
|
5
|
+
Author-email: Muthukumaran Navaneethakrishnan <muthuishere@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: mcp>=1.0.0
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == 'test'
|
|
11
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
12
|
+
Provides-Extra: yaml
|
|
13
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# toolnexus (Python)
|
|
17
|
+
|
|
18
|
+
Provider-agnostic toolkit that gives **any LLM** the two dynamic capabilities
|
|
19
|
+
opencode has:
|
|
20
|
+
|
|
21
|
+
1. **Dynamic MCP servers** — read an MCP config file, connect to every server
|
|
22
|
+
(local stdio + remote streamable-HTTP), and expose each server tool as a
|
|
23
|
+
uniform `Tool`.
|
|
24
|
+
2. **Dynamic agent skills** — read a skills folder (`**/SKILL.md`) and expose a
|
|
25
|
+
single `skill` tool that loads a skill's instructions + resources on demand
|
|
26
|
+
(progressive disclosure).
|
|
27
|
+
|
|
28
|
+
Built on the official MCP Python SDK (the `mcp` package). This is the Python
|
|
29
|
+
sibling of the `js/` reference implementation; the contract is shared
|
|
30
|
+
(`../SPEC.md`).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv venv
|
|
36
|
+
uv pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
(Or with stdlib venv: `python -m venv .venv && .venv/bin/pip install -e .`.)
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
The MCP SDK is async, so the toolkit is async. Manage its lifetime with
|
|
44
|
+
`async with`:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from toolnexus import create_toolkit
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
async with await create_toolkit(
|
|
53
|
+
mcp_config="../examples/mcp.json",
|
|
54
|
+
skills_dir="../examples/skills",
|
|
55
|
+
) as tk:
|
|
56
|
+
print(tk.mcp_status()) # {"everything": "connected", ...}
|
|
57
|
+
print([t.name for t in tk.tools()]) # mcp tools + "skill"
|
|
58
|
+
print(tk.skills_prompt()) # ## Available Skills ...
|
|
59
|
+
|
|
60
|
+
tools = tk.to_openai() # or to_anthropic() / to_gemini()
|
|
61
|
+
|
|
62
|
+
# ... call your LLM with `tools` + skills_prompt() as system text ...
|
|
63
|
+
# model returns a tool_call { name, arguments }
|
|
64
|
+
|
|
65
|
+
res = await tk.execute(name, arguments) # routes to the right tool
|
|
66
|
+
print(res.output) # feed back to the model
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`create_toolkit(...)` is an async factory. The returned `Toolkit` is also an
|
|
73
|
+
async context manager; if you do not use `async with`, call `await tk.close()`
|
|
74
|
+
yourself to disconnect every MCP client.
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
| Python | JS / SPEC equivalent |
|
|
79
|
+
|------------------------------|----------------------|
|
|
80
|
+
| `await create_toolkit(...)` | `createToolkit(...)` |
|
|
81
|
+
| `tk.tools()` | `tk.tools()` |
|
|
82
|
+
| `tk.get(name)` | `tk.get(name)` |
|
|
83
|
+
| `await tk.execute(name, args, ctx=None)` | `tk.execute(...)` |
|
|
84
|
+
| `tk.skills_prompt()` | `tk.skillsPrompt()` |
|
|
85
|
+
| `tk.mcp_status()` | `tk.mcpStatus()` |
|
|
86
|
+
| `tk.to_openai()` / `to_anthropic()` / `to_gemini()` | `toOpenAI()` etc. |
|
|
87
|
+
| `await tk.close()` | `tk.close()` |
|
|
88
|
+
|
|
89
|
+
A uniform `Tool` has `name`, `description`, `input_schema`, `source`
|
|
90
|
+
(`"mcp" | "skill" | "custom"`), and an async `execute(args, ctx=None)` returning
|
|
91
|
+
a `ToolResult(output, is_error, metadata)`.
|
|
92
|
+
|
|
93
|
+
## Examples
|
|
94
|
+
|
|
95
|
+
- `examples/basic.py` — connect to the `everything` MCP server, list tools,
|
|
96
|
+
print the skills catalog, and load the `hello-world` skill (progressive
|
|
97
|
+
disclosure). Requires `npx` on PATH (for `@modelcontextprotocol/server-everything`).
|
|
98
|
+
- `examples/openrouter_test.py` — a real OpenRouter tool-calling round trip.
|
|
99
|
+
Reads `OPENROUTER_API_KEY` from the environment (never hardcode it).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# toolnexus (Python)
|
|
2
|
+
|
|
3
|
+
Provider-agnostic toolkit that gives **any LLM** the two dynamic capabilities
|
|
4
|
+
opencode has:
|
|
5
|
+
|
|
6
|
+
1. **Dynamic MCP servers** — read an MCP config file, connect to every server
|
|
7
|
+
(local stdio + remote streamable-HTTP), and expose each server tool as a
|
|
8
|
+
uniform `Tool`.
|
|
9
|
+
2. **Dynamic agent skills** — read a skills folder (`**/SKILL.md`) and expose a
|
|
10
|
+
single `skill` tool that loads a skill's instructions + resources on demand
|
|
11
|
+
(progressive disclosure).
|
|
12
|
+
|
|
13
|
+
Built on the official MCP Python SDK (the `mcp` package). This is the Python
|
|
14
|
+
sibling of the `js/` reference implementation; the contract is shared
|
|
15
|
+
(`../SPEC.md`).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv venv
|
|
21
|
+
uv pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(Or with stdlib venv: `python -m venv .venv && .venv/bin/pip install -e .`.)
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
The MCP SDK is async, so the toolkit is async. Manage its lifetime with
|
|
29
|
+
`async with`:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from toolnexus import create_toolkit
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with await create_toolkit(
|
|
38
|
+
mcp_config="../examples/mcp.json",
|
|
39
|
+
skills_dir="../examples/skills",
|
|
40
|
+
) as tk:
|
|
41
|
+
print(tk.mcp_status()) # {"everything": "connected", ...}
|
|
42
|
+
print([t.name for t in tk.tools()]) # mcp tools + "skill"
|
|
43
|
+
print(tk.skills_prompt()) # ## Available Skills ...
|
|
44
|
+
|
|
45
|
+
tools = tk.to_openai() # or to_anthropic() / to_gemini()
|
|
46
|
+
|
|
47
|
+
# ... call your LLM with `tools` + skills_prompt() as system text ...
|
|
48
|
+
# model returns a tool_call { name, arguments }
|
|
49
|
+
|
|
50
|
+
res = await tk.execute(name, arguments) # routes to the right tool
|
|
51
|
+
print(res.output) # feed back to the model
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
asyncio.run(main())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`create_toolkit(...)` is an async factory. The returned `Toolkit` is also an
|
|
58
|
+
async context manager; if you do not use `async with`, call `await tk.close()`
|
|
59
|
+
yourself to disconnect every MCP client.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
| Python | JS / SPEC equivalent |
|
|
64
|
+
|------------------------------|----------------------|
|
|
65
|
+
| `await create_toolkit(...)` | `createToolkit(...)` |
|
|
66
|
+
| `tk.tools()` | `tk.tools()` |
|
|
67
|
+
| `tk.get(name)` | `tk.get(name)` |
|
|
68
|
+
| `await tk.execute(name, args, ctx=None)` | `tk.execute(...)` |
|
|
69
|
+
| `tk.skills_prompt()` | `tk.skillsPrompt()` |
|
|
70
|
+
| `tk.mcp_status()` | `tk.mcpStatus()` |
|
|
71
|
+
| `tk.to_openai()` / `to_anthropic()` / `to_gemini()` | `toOpenAI()` etc. |
|
|
72
|
+
| `await tk.close()` | `tk.close()` |
|
|
73
|
+
|
|
74
|
+
A uniform `Tool` has `name`, `description`, `input_schema`, `source`
|
|
75
|
+
(`"mcp" | "skill" | "custom"`), and an async `execute(args, ctx=None)` returning
|
|
76
|
+
a `ToolResult(output, is_error, metadata)`.
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
- `examples/basic.py` — connect to the `everything` MCP server, list tools,
|
|
81
|
+
print the skills catalog, and load the `hello-world` skill (progressive
|
|
82
|
+
disclosure). Requires `npx` on PATH (for `@modelcontextprotocol/server-everything`).
|
|
83
|
+
- `examples/openrouter_test.py` — a real OpenRouter tool-calling round trip.
|
|
84
|
+
Reads `OPENROUTER_API_KEY` from the environment (never hardcode it).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Verify parallel tool calling (many calls in one turn) and chained tool calling
|
|
2
|
+
(a later call depends on an earlier result, across turns). Mirrors
|
|
3
|
+
js/examples/advanced.ts.
|
|
4
|
+
|
|
5
|
+
Run from a venv where the package is installed (`uv pip install -e .`):
|
|
6
|
+
python examples/advanced.py # skips live section, exits 0
|
|
7
|
+
OPENROUTER_API_KEY=... python examples/advanced.py # runs the live checks
|
|
8
|
+
|
|
9
|
+
The API key is read from the environment and is NEVER printed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from toolnexus import create_client, create_toolkit, define_tool, http_tool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _max_parallel(messages: list[dict[str, Any]]) -> int:
|
|
22
|
+
"""Largest number of tool calls the model emitted in a single assistant turn."""
|
|
23
|
+
m = 0
|
|
24
|
+
for msg in messages:
|
|
25
|
+
if msg.get("role") == "assistant" and isinstance(msg.get("tool_calls"), list):
|
|
26
|
+
m = max(m, len(msg["tool_calls"]))
|
|
27
|
+
return m
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _tool_turns(messages: list[dict[str, Any]]) -> int:
|
|
31
|
+
"""Number of assistant turns that issued tool calls (chain depth)."""
|
|
32
|
+
return sum(
|
|
33
|
+
1
|
|
34
|
+
for m in messages
|
|
35
|
+
if m.get("role") == "assistant"
|
|
36
|
+
and isinstance(m.get("tool_calls"), list)
|
|
37
|
+
and len(m["tool_calls"])
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def main() -> None:
|
|
42
|
+
tk = await create_toolkit({})
|
|
43
|
+
|
|
44
|
+
# a native (annotation) tool
|
|
45
|
+
def add(a: float, b: float) -> str:
|
|
46
|
+
"""Add two numbers and return the sum."""
|
|
47
|
+
return str(a + b)
|
|
48
|
+
|
|
49
|
+
tk.register(define_tool(add, name="add"))
|
|
50
|
+
|
|
51
|
+
# an HTTP tool (public test API, no auth)
|
|
52
|
+
tk.register(
|
|
53
|
+
http_tool(
|
|
54
|
+
name="get_post",
|
|
55
|
+
description="Fetch a placeholder blog post by id.",
|
|
56
|
+
method="GET",
|
|
57
|
+
url="https://jsonplaceholder.typicode.com/posts/{id}",
|
|
58
|
+
input_schema={
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {"id": {"type": "number"}},
|
|
61
|
+
"required": ["id"],
|
|
62
|
+
"additionalProperties": False,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# opaque: the model CANNOT guess this value, so it must call it first and wait
|
|
68
|
+
def todays_post_id() -> str:
|
|
69
|
+
"""Returns the server-chosen blog post id to read today. Cannot be guessed; you must call it."""
|
|
70
|
+
return "3"
|
|
71
|
+
|
|
72
|
+
tk.register(define_tool(todays_post_id, name="todays_post_id"))
|
|
73
|
+
|
|
74
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
75
|
+
if not key:
|
|
76
|
+
print("no OPENROUTER_API_KEY — skipping")
|
|
77
|
+
await tk.close()
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
agent = create_client(
|
|
81
|
+
base_url="https://openrouter.ai/api/v1",
|
|
82
|
+
style="openai",
|
|
83
|
+
model=os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
84
|
+
api_key=key,
|
|
85
|
+
system_prompt="You are a precise agent. Prefer issuing independent tool calls together in one step.",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ---- A) PARALLEL: two independent adds, ideally in one turn ----
|
|
89
|
+
a = await agent.run(
|
|
90
|
+
"In a single step, call the add tool twice: add 2 and 3, and add 100 and 200. Then report both sums.",
|
|
91
|
+
tk,
|
|
92
|
+
)
|
|
93
|
+
print(
|
|
94
|
+
"A) parallel — tool calls:",
|
|
95
|
+
[f"{c['name']}({json.dumps(c['args'])})={c['output']}" for c in a.tool_calls],
|
|
96
|
+
)
|
|
97
|
+
print(
|
|
98
|
+
"A) max calls in one turn:",
|
|
99
|
+
_max_parallel(a.messages),
|
|
100
|
+
"| answer:",
|
|
101
|
+
a.text.replace("\n", " ")[:60],
|
|
102
|
+
)
|
|
103
|
+
print(
|
|
104
|
+
"A) usage:",
|
|
105
|
+
a.usage,
|
|
106
|
+
"| tool_call_count:",
|
|
107
|
+
a.tool_call_count,
|
|
108
|
+
"| turns:",
|
|
109
|
+
a.turns,
|
|
110
|
+
"| model:",
|
|
111
|
+
a.model,
|
|
112
|
+
)
|
|
113
|
+
print("A) first tool result metadata:", a.tool_calls[0]["metadata"] if a.tool_calls else None)
|
|
114
|
+
|
|
115
|
+
# ---- B) CHAIN: second call depends on an OPAQUE first result (forces a 2nd turn) ----
|
|
116
|
+
b = await agent.run(
|
|
117
|
+
"Call todays_post_id to find which post to read, then use get_post to fetch that exact id and tell me its title.",
|
|
118
|
+
tk,
|
|
119
|
+
)
|
|
120
|
+
print(
|
|
121
|
+
"\nB) chain — tool calls:",
|
|
122
|
+
[f"{c['name']}({json.dumps(c['args'])})" for c in b.tool_calls],
|
|
123
|
+
)
|
|
124
|
+
print("B) tool-calling turns (chain depth):", _tool_turns(b.messages))
|
|
125
|
+
print("B) answer:", b.text.replace("\n", " ")[:100])
|
|
126
|
+
|
|
127
|
+
await tk.close()
|
|
128
|
+
|
|
129
|
+
# ---- assertions ----
|
|
130
|
+
parallel_ok = _max_parallel(a.messages) >= 2
|
|
131
|
+
called_add = sum(1 for c in a.tool_calls if c["name"] == "add") >= 2
|
|
132
|
+
# true chain: todays_post_id THEN get_post(id=3) across ≥2 turns (id=3 is unguessable)
|
|
133
|
+
chain_ok = (
|
|
134
|
+
_tool_turns(b.messages) >= 2
|
|
135
|
+
and any(c["name"] == "todays_post_id" for c in b.tool_calls)
|
|
136
|
+
and any(
|
|
137
|
+
c["name"] == "get_post" and int(c["args"].get("id", 0)) == 3
|
|
138
|
+
for c in b.tool_calls
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
print(
|
|
142
|
+
f"\nparallel: {'✅ multiple calls in one turn' if parallel_ok else '⚠️ model serialized'}"
|
|
143
|
+
f" | ran {sum(1 for c in a.tool_calls if c['name'] == 'add')} adds"
|
|
144
|
+
)
|
|
145
|
+
print(
|
|
146
|
+
f"chain: {'✅ get_post(id=3) used the opaque result across turns' if chain_ok else '❌ chain not observed'}"
|
|
147
|
+
)
|
|
148
|
+
if not called_add or not parallel_ok or not chain_ok:
|
|
149
|
+
raise SystemExit(
|
|
150
|
+
"❌ expected ≥2 parallel adds AND a real cross-turn chain "
|
|
151
|
+
"(todays_post_id → get_post(id=3))"
|
|
152
|
+
)
|
|
153
|
+
print("\n✅ parallel + chained tool calling verified")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Full agent example: MCP tools + skills + a native tool + an HTTP tool, driven
|
|
2
|
+
by the unified client host loop. Mirrors js/examples/agent.ts.
|
|
3
|
+
|
|
4
|
+
Run from a venv where the package is installed (`uv pip install -e .`):
|
|
5
|
+
python examples/agent.py # tools list only, exits cleanly
|
|
6
|
+
OPENROUTER_API_KEY=... python examples/agent.py # runs the live loop
|
|
7
|
+
|
|
8
|
+
Requires `npx` on PATH for the @modelcontextprotocol/server-everything stdio
|
|
9
|
+
server. The API key is read from the environment and never printed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from toolnexus import create_client, create_toolkit, define_tool, http_tool
|
|
17
|
+
|
|
18
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
_EXAMPLES = os.path.normpath(os.path.join(_HERE, "..", "..", "examples"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def main() -> None:
|
|
23
|
+
tk = await create_toolkit(
|
|
24
|
+
mcp_config=os.path.join(_EXAMPLES, "mcp.json"),
|
|
25
|
+
skills_dir=os.path.join(_EXAMPLES, "skills"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# a native (annotation) tool
|
|
29
|
+
def add(a: float, b: float) -> str:
|
|
30
|
+
"""Add two numbers and return the sum."""
|
|
31
|
+
return str(a + b)
|
|
32
|
+
|
|
33
|
+
tk.register(define_tool(add, name="add"))
|
|
34
|
+
|
|
35
|
+
# an HTTP tool (public test API, no auth)
|
|
36
|
+
tk.register(
|
|
37
|
+
http_tool(
|
|
38
|
+
name="get_post",
|
|
39
|
+
description="Fetch a placeholder blog post by id from jsonplaceholder.",
|
|
40
|
+
method="GET",
|
|
41
|
+
url="https://jsonplaceholder.typicode.com/posts/{id}",
|
|
42
|
+
input_schema={
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {"id": {"type": "number", "description": "post id 1-100"}},
|
|
45
|
+
"required": ["id"],
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
print("Tools:", [f"{t.name} ({t.source})" for t in tk.tools()])
|
|
52
|
+
|
|
53
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
54
|
+
if not key:
|
|
55
|
+
print("\n(no OPENROUTER_API_KEY — skipping live loop)")
|
|
56
|
+
await tk.close()
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
agent = create_client(
|
|
60
|
+
base_url="https://openrouter.ai/api/v1",
|
|
61
|
+
style="openai",
|
|
62
|
+
model=os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
63
|
+
api_key=key,
|
|
64
|
+
system_prompt="You are a precise agent. Use tools to compute and fetch facts.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
res = await agent.run(
|
|
68
|
+
"What is 21 + 21? Use the add tool. Then fetch post id 1 and tell me its title.",
|
|
69
|
+
tk,
|
|
70
|
+
)
|
|
71
|
+
print("\nTool calls:", [c["name"] for c in res.tool_calls])
|
|
72
|
+
print("\nFINAL:\n" + res.text)
|
|
73
|
+
|
|
74
|
+
await tk.close()
|
|
75
|
+
if "42" not in res.text:
|
|
76
|
+
raise SystemExit("expected 42 in answer")
|
|
77
|
+
print("\nPython agent loop (native + http + mcp + skills) OK")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Minimal end-to-end example. Mirrors js/examples/basic.ts.
|
|
2
|
+
|
|
3
|
+
Run from a venv where the package is installed (`uv pip install -e .`):
|
|
4
|
+
python examples/basic.py
|
|
5
|
+
|
|
6
|
+
Uses the shared sample fixtures in ../../examples/. Requires `npx` on PATH for
|
|
7
|
+
the `@modelcontextprotocol/server-everything` stdio server.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from toolnexus import create_toolkit
|
|
16
|
+
|
|
17
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
_EXAMPLES = os.path.normpath(os.path.join(_HERE, "..", "..", "examples"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def main() -> None:
|
|
22
|
+
tk = await create_toolkit(
|
|
23
|
+
mcp_config=os.path.join(_EXAMPLES, "mcp.json"),
|
|
24
|
+
skills_dir=os.path.join(_EXAMPLES, "skills"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
print("MCP status:", tk.mcp_status())
|
|
28
|
+
print("Tools:", [f"{t.name} ({t.source})" for t in tk.tools()])
|
|
29
|
+
print("\nSystem-prompt skill catalog:\n" + tk.skills_prompt())
|
|
30
|
+
|
|
31
|
+
print("\nOpenAI tool schema (first 2):")
|
|
32
|
+
print(json.dumps(tk.to_openai()[:2], indent=2))
|
|
33
|
+
|
|
34
|
+
# Load a skill (progressive disclosure) — works with no MCP servers running.
|
|
35
|
+
res = await tk.execute("skill", {"name": "hello-world"})
|
|
36
|
+
print("\nskill(hello-world) ->\n" + res.output)
|
|
37
|
+
|
|
38
|
+
await tk.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Lifecycle hooks: before_llm / after_llm / before_tool / after_tool — observe,
|
|
2
|
+
mutate, and short-circuit. Mirrors js/examples/hooks.ts.
|
|
3
|
+
|
|
4
|
+
Run from a venv where the package is installed (`uv pip install -e .`):
|
|
5
|
+
python examples/hooks.py # import + setup only (no key)
|
|
6
|
+
OPENROUTER_API_KEY=... python examples/hooks.py # runs the live loop
|
|
7
|
+
|
|
8
|
+
The API key is read from the environment and is NEVER printed.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from toolnexus import ToolResult, create_client, create_toolkit, define_tool, http_tool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def main() -> None:
|
|
20
|
+
tk = await create_toolkit()
|
|
21
|
+
|
|
22
|
+
def add(a: float, b: float) -> str:
|
|
23
|
+
"""Add two numbers and return the sum."""
|
|
24
|
+
return str(a + b)
|
|
25
|
+
|
|
26
|
+
tk.register(
|
|
27
|
+
define_tool(add, name="add"),
|
|
28
|
+
http_tool(
|
|
29
|
+
name="get_post",
|
|
30
|
+
description="Fetch a placeholder blog post by id.",
|
|
31
|
+
method="GET",
|
|
32
|
+
url="https://jsonplaceholder.typicode.com/posts/{id}",
|
|
33
|
+
input_schema={
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {"id": {"type": "number", "description": "post id 1-100"}},
|
|
36
|
+
"required": ["id"],
|
|
37
|
+
"additionalProperties": False,
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Spy on whether get_post's real execute ever runs (it must NOT — we deny it
|
|
43
|
+
# in a before_tool hook before it can reach the network).
|
|
44
|
+
real = tk.get("get_post")
|
|
45
|
+
assert real is not None
|
|
46
|
+
http_hits = {"n": 0}
|
|
47
|
+
orig = real.execute
|
|
48
|
+
|
|
49
|
+
async def spy(args, ctx=None):
|
|
50
|
+
http_hits["n"] += 1
|
|
51
|
+
return await orig(args, ctx)
|
|
52
|
+
|
|
53
|
+
real.execute = spy # type: ignore[assignment]
|
|
54
|
+
|
|
55
|
+
counts = {"before_llm": 0, "after_llm": 0, "before_tool": 0, "after_tool": 0, "denied": 0}
|
|
56
|
+
|
|
57
|
+
# Hooks as a plain dict of snake_case callables (sync here; async is also fine).
|
|
58
|
+
def before_llm(ev):
|
|
59
|
+
counts["before_llm"] += 1
|
|
60
|
+
print(f" [before_llm] turn {ev['turn']}")
|
|
61
|
+
|
|
62
|
+
def after_llm(ev):
|
|
63
|
+
counts["after_llm"] += 1
|
|
64
|
+
print(f" [after_llm] turn {ev['turn']} usage {ev['response'].get('usage')}")
|
|
65
|
+
|
|
66
|
+
def before_tool(ev):
|
|
67
|
+
counts["before_tool"] += 1
|
|
68
|
+
print(f" [before_tool] {ev['name']}({json.dumps(ev['args'])})")
|
|
69
|
+
if ev["name"] == "get_post":
|
|
70
|
+
counts["denied"] += 1
|
|
71
|
+
# SHORT-CIRCUIT: deny the tool. The model never sees real data and the
|
|
72
|
+
# real http execute never runs.
|
|
73
|
+
return {"result": ToolResult(output="DENIED: get_post is blocked by policy", is_error=True)}
|
|
74
|
+
|
|
75
|
+
def after_tool(ev):
|
|
76
|
+
counts["after_tool"] += 1
|
|
77
|
+
print(f" [after_tool] {ev['name']} -> {ev['result'].output[:40]}")
|
|
78
|
+
|
|
79
|
+
hooks = {
|
|
80
|
+
"before_llm": before_llm,
|
|
81
|
+
"after_llm": after_llm,
|
|
82
|
+
"before_tool": before_tool,
|
|
83
|
+
"after_tool": after_tool,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
87
|
+
if not key:
|
|
88
|
+
print("(no OPENROUTER_API_KEY — import + setup OK, skipping live loop)")
|
|
89
|
+
await tk.close()
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
agent = create_client(
|
|
93
|
+
base_url="https://openrouter.ai/api/v1",
|
|
94
|
+
style="openai",
|
|
95
|
+
model=os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
96
|
+
api_key=key,
|
|
97
|
+
system_prompt="You are an agent. Use tools when asked.",
|
|
98
|
+
hooks=hooks,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
out = await agent.run(
|
|
102
|
+
"Add 2 and 3 with the add tool, then fetch post id 1 with get_post.", tk
|
|
103
|
+
)
|
|
104
|
+
await tk.close()
|
|
105
|
+
|
|
106
|
+
print("\nFINAL:", out.text.replace("\n", " ")[:100])
|
|
107
|
+
print(
|
|
108
|
+
"hook counts:", counts,
|
|
109
|
+
"| http actually hit:", http_hits["n"],
|
|
110
|
+
"| usage:", out.usage,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
ok = (
|
|
114
|
+
counts["before_llm"] >= 1
|
|
115
|
+
and counts["after_llm"] >= 1
|
|
116
|
+
and counts["before_tool"] >= 2
|
|
117
|
+
and counts["after_tool"] >= 1
|
|
118
|
+
and counts["denied"] >= 1
|
|
119
|
+
and http_hits["n"] == 0 # deny short-circuited the real http execute
|
|
120
|
+
)
|
|
121
|
+
if not ok:
|
|
122
|
+
raise SystemExit("hooks did not all fire / short-circuit failed")
|
|
123
|
+
print("\nall four hooks fired; before_tool short-circuited get_post (0 real http hits)")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Conversation memory: a Conversation retains history across send() calls.
|
|
2
|
+
|
|
3
|
+
Mirrors js/examples/memory.ts. Reads OPENROUTER_API_KEY from the environment
|
|
4
|
+
(never hardcoded, never printed); skips the live call if it is unset.
|
|
5
|
+
|
|
6
|
+
Run from a venv where the package is installed:
|
|
7
|
+
OPENROUTER_API_KEY=... python examples/memory.py
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from toolnexus import create_client, create_toolkit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def main() -> None:
|
|
19
|
+
tk = await create_toolkit()
|
|
20
|
+
|
|
21
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
22
|
+
if not key:
|
|
23
|
+
print("no OPENROUTER_API_KEY — skipping (imports OK)")
|
|
24
|
+
await tk.close()
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
agent = create_client(
|
|
28
|
+
base_url="https://openrouter.ai/api/v1",
|
|
29
|
+
style="openai",
|
|
30
|
+
model=os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o-mini"),
|
|
31
|
+
api_key=key,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
convo = agent.conversation(tk)
|
|
35
|
+
|
|
36
|
+
a = await convo.send("My name is Muthu and my favorite number is 7. Reply with just 'noted'.")
|
|
37
|
+
print("turn 1:", a.text.replace("\n", " ")[:60], "| messages:", len(convo.messages))
|
|
38
|
+
|
|
39
|
+
b = await convo.send("What is my name and favorite number?")
|
|
40
|
+
print("turn 2:", b.text.replace("\n", " ")[:80], "| messages:", len(convo.messages))
|
|
41
|
+
|
|
42
|
+
await tk.close()
|
|
43
|
+
remembered = bool(re.search(r"muthu", b.text, re.I)) and bool(re.search(r"7|seven", b.text, re.I))
|
|
44
|
+
if not remembered:
|
|
45
|
+
print("FAILED conversation did not retain memory across turns")
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
print("\nOK conversation memory verified — turn 2 recalled facts from turn 1")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
asyncio.run(main())
|