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.
@@ -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
@@ -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())