internet2agent 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AstralDeep
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: internet2agent
3
+ Version: 0.1.0
4
+ Summary: Python client and LLM agent for the Internet2 Periscope Looking Glass MCP server
5
+ Author-email: AstralDeep <armstrongsam25@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AstralDeep/internet2agent
8
+ Project-URL: Repository, https://github.com/AstralDeep/internet2agent
9
+ Project-URL: Issues, https://github.com/AstralDeep/internet2agent/issues
10
+ Keywords: internet2,mcp,looking-glass,network,bgp,traceroute,llm,agent
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Intended Audience :: Telecommunications Industry
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Networking :: Monitoring
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: openai>=1.50
27
+ Requires-Dist: mcp>=2.0
28
+ Requires-Dist: python-dotenv>=1.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # internet2agent
34
+
35
+ Python interface and LLM agent for the **Internet2 Periscope Looking Glass MCP
36
+ server** (`https://periscope.ns.internet2.edu/mcp`) - the service announced in
37
+ [Internet2's MCP server post](https://internet2.edu/new-mcp-server-lets-re-community-connect-their-ai-agent/),
38
+ documented in the [Console docs](https://console.internet2.edu/docs/looking-glass.html#mcp-server).
39
+
40
+ Two ways in, no GUI:
41
+
42
+ 1. **Direct client** (`PeriscopeClient` / `AsyncPeriscopeClient`) - typed Python
43
+ access to the five Looking Glass tools. **Works today with zero
44
+ credentials** (the server is currently open; verified live).
45
+ 2. **LLM agent** (`Internet2Agent`) - natural-language questions answered by
46
+ any **OpenAI-compatible** model (OpenAI, Ollama, vLLM, LM Studio,
47
+ OpenRouter, ...). The agent pulls the tool schemas from the MCP server,
48
+ hands them to the model as function tools, executes the model's tool calls
49
+ against Periscope, and loops until it has an answer.
50
+
51
+ ## Setup
52
+
53
+ ```powershell
54
+ cd Y:\WORK\MCP\internet2agent
55
+ .venv\Scripts\activate
56
+ pip install -e ".[dev]"
57
+ ```
58
+
59
+ Then configure your LLM in [.env](.env) (gitignored; template in
60
+ [.env.example](.env.example)):
61
+
62
+ | Variable | Needed for | Notes |
63
+ |---|---|---|
64
+ | `OPENAI_BASE_URL` | `ask` / `chat` / `Internet2Agent` | unset = api.openai.com; Ollama: `http://localhost:11434/v1`; LM Studio: `http://localhost:1234/v1` |
65
+ | `OPENAI_API_KEY` | same | optional for local endpoints that don't check keys |
66
+ | `I2A_MODEL` | same | model name to request (default `gpt-4o` - set to what your endpoint serves) |
67
+ | `PERISCOPE_MCP_URL` | optional | defaults to the public endpoint |
68
+ | `PERISCOPE_AUTH_TOKEN` | **not yet** | future Internet2 credential; sent as `Authorization: Bearer ...` once set |
69
+
70
+ ## CLI
71
+
72
+ Direct Looking Glass (no credentials):
73
+
74
+ ```powershell
75
+ internet2agent info # service limits (rate limit, max targets)
76
+ internet2agent devices # device inventory (name, location, platform)
77
+ internet2agent commands # supported commands per platform
78
+ internet2agent filters # output filters (include/exclude + regex)
79
+ internet2agent exec "show bgp" -t rtr1 rtr2 -p summary -f "include Established"
80
+ ```
81
+
82
+ Add `--json` to any of the above for raw JSON. `i2a` is a short alias for
83
+ `internet2agent`.
84
+
85
+ LLM agent:
86
+
87
+ ```powershell
88
+ internet2agent ask "Is BGP healthy on the Chicago routers?"
89
+ internet2agent chat # interactive multi-turn session
90
+ ```
91
+
92
+ If no LLM endpoint is configured yet, `ask`/`chat` walk you through a one-time
93
+ setup (base URL, API key, model) and offer to save it to `.env`.
94
+
95
+ Tool calls are echoed as `[lg_execute {...}]` lines while the agent works.
96
+
97
+ ## Python API
98
+
99
+ ```python
100
+ from internet2agent import PeriscopeClient
101
+
102
+ with PeriscopeClient() as lg:
103
+ devices = lg.devices() # [{"name": ..., "platform": ...}, ...]
104
+ result = lg.execute("show bgp", ["rtr1"], parameter="summary")
105
+ ```
106
+
107
+ Async variant:
108
+
109
+ ```python
110
+ from internet2agent import AsyncPeriscopeClient
111
+
112
+ async with AsyncPeriscopeClient() as lg:
113
+ print(await lg.config())
114
+ ```
115
+
116
+ Agent:
117
+
118
+ ```python
119
+ from internet2agent import Internet2Agent
120
+
121
+ with Internet2Agent() as agent: # reads .env
122
+ print(agent.ask("Which devices are in Seattle?"))
123
+ print(agent.ask("Run a traceroute from one of them to 8.8.8.8")) # follow-ups keep context
124
+ ```
125
+
126
+ ## Service constraints (from the server)
127
+
128
+ - Max **10 target devices** per `lg_execute`; commands must match documented
129
+ syntax exactly (no abbreviations).
130
+ - Rate limit: **60 requests/min** (check live with `internet2agent info`).
131
+ - `parameter` is appended to the command (`show route` + `10.0.0.0/8`);
132
+ `filter` is a filter name plus case-sensitive regex (`include bgp`,
133
+ `exclude ^$`).
134
+ - Commands/filters are platform-specific - the agent (and you) should check
135
+ `commands`/`filters` against each device's `platform` before executing.
136
+
137
+ ## Tests
138
+
139
+ ```powershell
140
+ pytest # unit tests (offline, mocked)
141
+ pytest -m network # live smoke tests against the real Periscope server
142
+ ```
143
+
144
+ ## Releasing to PyPI
145
+
146
+ Publishing runs through GitHub Actions with [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
147
+ (no API tokens stored anywhere). One-time setup:
148
+
149
+ 1. On [pypi.org](https://pypi.org) -> your account -> Publishing -> "Add a new
150
+ pending publisher": project `internet2agent`, owner `AstralDeep`, repository
151
+ `internet2agent`, workflow `publish.yml`, environment `pypi`.
152
+ 2. On GitHub -> repo Settings -> Environments -> create an environment named `pypi`.
153
+
154
+ Then, for each release: bump `version` in `pyproject.toml`, push, and publish a
155
+ GitHub release with a `vX.Y.Z` tag - the workflow builds and uploads.
156
+
157
+ Manual alternative: `python -m build && twine upload dist/*` with a PyPI API token.
158
+
159
+ ## Notes
160
+
161
+ - The agent requires an endpoint that supports OpenAI-style **function/tool
162
+ calling**; pick a tool-capable model (most current ones are).
163
+ - The agent caps each question at 25 LLM round-trips as a runaway guard
164
+ (`Internet2Agent(max_steps=...)` to change).
165
+ - Tool errors (unknown device, bad syntax, timeouts) are fed back to the model
166
+ as `ERROR:` tool results so it can correct itself instead of crashing.
@@ -0,0 +1,134 @@
1
+ # internet2agent
2
+
3
+ Python interface and LLM agent for the **Internet2 Periscope Looking Glass MCP
4
+ server** (`https://periscope.ns.internet2.edu/mcp`) - the service announced in
5
+ [Internet2's MCP server post](https://internet2.edu/new-mcp-server-lets-re-community-connect-their-ai-agent/),
6
+ documented in the [Console docs](https://console.internet2.edu/docs/looking-glass.html#mcp-server).
7
+
8
+ Two ways in, no GUI:
9
+
10
+ 1. **Direct client** (`PeriscopeClient` / `AsyncPeriscopeClient`) - typed Python
11
+ access to the five Looking Glass tools. **Works today with zero
12
+ credentials** (the server is currently open; verified live).
13
+ 2. **LLM agent** (`Internet2Agent`) - natural-language questions answered by
14
+ any **OpenAI-compatible** model (OpenAI, Ollama, vLLM, LM Studio,
15
+ OpenRouter, ...). The agent pulls the tool schemas from the MCP server,
16
+ hands them to the model as function tools, executes the model's tool calls
17
+ against Periscope, and loops until it has an answer.
18
+
19
+ ## Setup
20
+
21
+ ```powershell
22
+ cd Y:\WORK\MCP\internet2agent
23
+ .venv\Scripts\activate
24
+ pip install -e ".[dev]"
25
+ ```
26
+
27
+ Then configure your LLM in [.env](.env) (gitignored; template in
28
+ [.env.example](.env.example)):
29
+
30
+ | Variable | Needed for | Notes |
31
+ |---|---|---|
32
+ | `OPENAI_BASE_URL` | `ask` / `chat` / `Internet2Agent` | unset = api.openai.com; Ollama: `http://localhost:11434/v1`; LM Studio: `http://localhost:1234/v1` |
33
+ | `OPENAI_API_KEY` | same | optional for local endpoints that don't check keys |
34
+ | `I2A_MODEL` | same | model name to request (default `gpt-4o` - set to what your endpoint serves) |
35
+ | `PERISCOPE_MCP_URL` | optional | defaults to the public endpoint |
36
+ | `PERISCOPE_AUTH_TOKEN` | **not yet** | future Internet2 credential; sent as `Authorization: Bearer ...` once set |
37
+
38
+ ## CLI
39
+
40
+ Direct Looking Glass (no credentials):
41
+
42
+ ```powershell
43
+ internet2agent info # service limits (rate limit, max targets)
44
+ internet2agent devices # device inventory (name, location, platform)
45
+ internet2agent commands # supported commands per platform
46
+ internet2agent filters # output filters (include/exclude + regex)
47
+ internet2agent exec "show bgp" -t rtr1 rtr2 -p summary -f "include Established"
48
+ ```
49
+
50
+ Add `--json` to any of the above for raw JSON. `i2a` is a short alias for
51
+ `internet2agent`.
52
+
53
+ LLM agent:
54
+
55
+ ```powershell
56
+ internet2agent ask "Is BGP healthy on the Chicago routers?"
57
+ internet2agent chat # interactive multi-turn session
58
+ ```
59
+
60
+ If no LLM endpoint is configured yet, `ask`/`chat` walk you through a one-time
61
+ setup (base URL, API key, model) and offer to save it to `.env`.
62
+
63
+ Tool calls are echoed as `[lg_execute {...}]` lines while the agent works.
64
+
65
+ ## Python API
66
+
67
+ ```python
68
+ from internet2agent import PeriscopeClient
69
+
70
+ with PeriscopeClient() as lg:
71
+ devices = lg.devices() # [{"name": ..., "platform": ...}, ...]
72
+ result = lg.execute("show bgp", ["rtr1"], parameter="summary")
73
+ ```
74
+
75
+ Async variant:
76
+
77
+ ```python
78
+ from internet2agent import AsyncPeriscopeClient
79
+
80
+ async with AsyncPeriscopeClient() as lg:
81
+ print(await lg.config())
82
+ ```
83
+
84
+ Agent:
85
+
86
+ ```python
87
+ from internet2agent import Internet2Agent
88
+
89
+ with Internet2Agent() as agent: # reads .env
90
+ print(agent.ask("Which devices are in Seattle?"))
91
+ print(agent.ask("Run a traceroute from one of them to 8.8.8.8")) # follow-ups keep context
92
+ ```
93
+
94
+ ## Service constraints (from the server)
95
+
96
+ - Max **10 target devices** per `lg_execute`; commands must match documented
97
+ syntax exactly (no abbreviations).
98
+ - Rate limit: **60 requests/min** (check live with `internet2agent info`).
99
+ - `parameter` is appended to the command (`show route` + `10.0.0.0/8`);
100
+ `filter` is a filter name plus case-sensitive regex (`include bgp`,
101
+ `exclude ^$`).
102
+ - Commands/filters are platform-specific - the agent (and you) should check
103
+ `commands`/`filters` against each device's `platform` before executing.
104
+
105
+ ## Tests
106
+
107
+ ```powershell
108
+ pytest # unit tests (offline, mocked)
109
+ pytest -m network # live smoke tests against the real Periscope server
110
+ ```
111
+
112
+ ## Releasing to PyPI
113
+
114
+ Publishing runs through GitHub Actions with [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
115
+ (no API tokens stored anywhere). One-time setup:
116
+
117
+ 1. On [pypi.org](https://pypi.org) -> your account -> Publishing -> "Add a new
118
+ pending publisher": project `internet2agent`, owner `AstralDeep`, repository
119
+ `internet2agent`, workflow `publish.yml`, environment `pypi`.
120
+ 2. On GitHub -> repo Settings -> Environments -> create an environment named `pypi`.
121
+
122
+ Then, for each release: bump `version` in `pyproject.toml`, push, and publish a
123
+ GitHub release with a `vX.Y.Z` tag - the workflow builds and uploads.
124
+
125
+ Manual alternative: `python -m build && twine upload dist/*` with a PyPI API token.
126
+
127
+ ## Notes
128
+
129
+ - The agent requires an endpoint that supports OpenAI-style **function/tool
130
+ calling**; pick a tool-capable model (most current ones are).
131
+ - The agent caps each question at 25 LLM round-trips as a runaway guard
132
+ (`Internet2Agent(max_steps=...)` to change).
133
+ - Tool errors (unknown device, bad syntax, timeouts) are fed back to the model
134
+ as `ERROR:` tool results so it can correct itself instead of crashing.
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "internet2agent"
7
+ version = "0.1.0"
8
+ description = "Python client and LLM agent for the Internet2 Periscope Looking Glass MCP server"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "AstralDeep", email = "armstrongsam25@gmail.com" }]
13
+ requires-python = ">=3.10"
14
+ keywords = ["internet2", "mcp", "looking-glass", "network", "bgp", "traceroute", "llm", "agent"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: System Administrators",
19
+ "Intended Audience :: Telecommunications Industry",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: System :: Networking :: Monitoring",
28
+ ]
29
+ dependencies = [
30
+ "openai>=1.50",
31
+ "mcp>=2.0",
32
+ "python-dotenv>=1.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/AstralDeep/internet2agent"
37
+ Repository = "https://github.com/AstralDeep/internet2agent"
38
+ Issues = "https://github.com/AstralDeep/internet2agent/issues"
39
+
40
+ [project.optional-dependencies]
41
+ dev = ["pytest>=8"]
42
+
43
+ [project.scripts]
44
+ internet2agent = "internet2agent.cli:main"
45
+ i2a = "internet2agent.cli:main"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.pytest.ini_options]
51
+ markers = [
52
+ "network: tests that hit the live Periscope MCP server (run with: pytest -m network)",
53
+ ]
54
+ addopts = "-m 'not network'"
55
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Python interface and Claude-powered agent for the Internet2 Periscope
2
+ Looking Glass MCP server (https://periscope.ns.internet2.edu/mcp)."""
3
+
4
+ from .agent import Internet2Agent
5
+ from .config import Settings
6
+ from .periscope import AsyncPeriscopeClient, PeriscopeClient, PeriscopeError
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "AsyncPeriscopeClient",
12
+ "Internet2Agent",
13
+ "PeriscopeClient",
14
+ "PeriscopeError",
15
+ "Settings",
16
+ "__version__",
17
+ ]
@@ -0,0 +1,207 @@
1
+ """Natural-language agent for the Internet2 Periscope service.
2
+
3
+ Works with any OpenAI-compatible chat-completions endpoint (OpenAI, Ollama,
4
+ vLLM, LM Studio, OpenRouter, ...). The agent fetches the Looking Glass tool
5
+ schemas from the Periscope MCP server, exposes them to the model as function
6
+ tools, executes the model's tool calls through :class:`PeriscopeClient`, and
7
+ loops until the model produces a final answer.
8
+
9
+ Configuration (``.env`` / environment):
10
+
11
+ - ``OPENAI_BASE_URL`` - endpoint base URL (unset = api.openai.com)
12
+ - ``OPENAI_API_KEY`` - API key (optional for local endpoints)
13
+ - ``I2A_MODEL`` - model name to request
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any, Callable
20
+
21
+ from openai import OpenAI
22
+
23
+ from .config import Settings
24
+ from .periscope import PeriscopeClient, PeriscopeError
25
+
26
+ SYSTEM_PROMPT = """\
27
+ You are a network operations assistant for the Internet2 research & education
28
+ backbone. You answer questions by running diagnostics through Periscope, the
29
+ Internet2 Looking Glass MCP service, using the tools provided.
30
+
31
+ Workflow for every diagnostic question:
32
+ 1. Call lg_devices to discover device IDs and platforms. Device IDs passed to
33
+ lg_execute must exactly match the `name` field from lg_devices.
34
+ 2. Call lg_commands to confirm the command is supported on the target
35
+ platforms; call lg_filters if you want to narrow the output.
36
+ 3. Call lg_execute with at most 10 targets. Commands must match documented
37
+ syntax exactly - no abbreviations. When targeting mixed platforms, the
38
+ command and filter must be supported on every target platform.
39
+
40
+ Report findings concisely. Quote the raw output lines that support your
41
+ conclusion and name the device each line came from. If a command fails or a
42
+ device is unknown, say so plainly and suggest the closest valid option.
43
+ """
44
+
45
+ _MAX_STEPS = 25
46
+ _MAX_RESULT_CHARS = 60_000 # keep one giant router dump from blowing the context window
47
+
48
+
49
+ class Internet2Agent:
50
+ """Multi-turn natural-language interface to Periscope.
51
+
52
+ >>> with Internet2Agent() as agent:
53
+ ... print(agent.ask("Which devices are in Chicago?"))
54
+
55
+ History is kept on the instance so follow-up questions work; call
56
+ :meth:`reset` to start fresh. Use as a context manager (or call
57
+ :meth:`close`) to release the underlying MCP session.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ settings: Settings | None = None,
63
+ client: OpenAI | None = None,
64
+ periscope: PeriscopeClient | None = None,
65
+ model: str | None = None,
66
+ max_steps: int = _MAX_STEPS,
67
+ max_result_chars: int = _MAX_RESULT_CHARS,
68
+ ) -> None:
69
+ self.settings = settings or Settings.from_env()
70
+ if client is None:
71
+ client = OpenAI(
72
+ base_url=self.settings.openai_base_url,
73
+ # Local OpenAI-compatible servers usually ignore the key, but
74
+ # the SDK requires one - send a placeholder when unset.
75
+ api_key=self.settings.openai_api_key or "not-needed",
76
+ )
77
+ self.client = client
78
+ self.model = model or self.settings.model
79
+ self.max_steps = max_steps
80
+ self.max_result_chars = max_result_chars
81
+ self.periscope = periscope or PeriscopeClient(settings=self.settings)
82
+ self.messages: list[dict[str, Any]] = [{"role": "system", "content": SYSTEM_PROMPT}]
83
+ self._tools: list[dict[str, Any]] | None = None
84
+
85
+ # -- lifecycle ------------------------------------------------------------
86
+
87
+ def close(self) -> None:
88
+ self.periscope.close()
89
+
90
+ def __enter__(self) -> "Internet2Agent":
91
+ return self
92
+
93
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
94
+ self.close()
95
+
96
+ # -- tool plumbing --------------------------------------------------------
97
+
98
+ def _openai_tools(self) -> list[dict[str, Any]]:
99
+ """Looking Glass tools converted to OpenAI function-tool schemas."""
100
+ if self._tools is None:
101
+ self._tools = [
102
+ {
103
+ "type": "function",
104
+ "function": {
105
+ "name": tool["name"],
106
+ "description": tool.get("description") or "",
107
+ "parameters": tool.get("input_schema")
108
+ or {"type": "object", "properties": {}},
109
+ },
110
+ }
111
+ for tool in self.periscope.tools()
112
+ ]
113
+ return self._tools
114
+
115
+ def _run_tool_call(self, name: Any, raw_arguments: str) -> str:
116
+ if not isinstance(name, str) or not name:
117
+ return "ERROR: tool call is missing a valid tool name"
118
+ try:
119
+ arguments = json.loads(raw_arguments) if raw_arguments else {}
120
+ except ValueError:
121
+ return f"ERROR: tool arguments were not valid JSON: {raw_arguments!r}"
122
+ if not isinstance(arguments, dict):
123
+ return f"ERROR: tool arguments must be a JSON object, got: {raw_arguments!r}"
124
+ try:
125
+ result = self.periscope.call(name, arguments)
126
+ except (PeriscopeError, TimeoutError) as exc:
127
+ return f"ERROR: {exc}"
128
+ except Exception as exc: # transport/protocol failures - let the model adapt
129
+ return f"ERROR: {type(exc).__name__}: {exc}"
130
+ content = result if isinstance(result, str) else json.dumps(result, default=str)
131
+ if len(content) > self.max_result_chars:
132
+ dropped = len(content) - self.max_result_chars
133
+ content = content[: self.max_result_chars] + f"\n...[truncated {dropped} characters]"
134
+ return content
135
+
136
+ # -- conversation ---------------------------------------------------------
137
+
138
+ def ask(
139
+ self,
140
+ question: str,
141
+ on_tool: Callable[[str, str], None] | None = None,
142
+ ) -> str:
143
+ """Ask a question and return the assistant's final text.
144
+
145
+ ``on_tool`` (if given) is called with ``(tool_name, raw_arguments)``
146
+ before each tool execution - useful for progress display.
147
+ """
148
+ self.messages.append({"role": "user", "content": question})
149
+ for _ in range(self.max_steps):
150
+ response = self.client.chat.completions.create(
151
+ model=self.model,
152
+ messages=self.messages,
153
+ tools=self._openai_tools(),
154
+ )
155
+ message = response.choices[0].message
156
+
157
+ # Normalize tool calls defensively: lenient OpenAI-compatible servers
158
+ # can yield a missing name or dict-typed arguments.
159
+ tool_calls = []
160
+ for call in message.tool_calls or []:
161
+ function = getattr(call, "function", None)
162
+ raw = getattr(function, "arguments", None)
163
+ if isinstance(raw, dict):
164
+ raw = json.dumps(raw)
165
+ elif not isinstance(raw, str):
166
+ raw = ""
167
+ tool_calls.append(
168
+ {
169
+ "id": getattr(call, "id", None) or "",
170
+ "name": getattr(function, "name", None),
171
+ "arguments": raw,
172
+ }
173
+ )
174
+
175
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": message.content}
176
+ if tool_calls:
177
+ assistant_msg["tool_calls"] = [
178
+ {
179
+ "id": call["id"],
180
+ "type": "function",
181
+ "function": {
182
+ "name": call["name"] or "",
183
+ "arguments": call["arguments"],
184
+ },
185
+ }
186
+ for call in tool_calls
187
+ ]
188
+ self.messages.append(assistant_msg)
189
+
190
+ if not tool_calls:
191
+ return message.content or ""
192
+
193
+ for call in tool_calls:
194
+ if on_tool is not None:
195
+ on_tool(call["name"] or "?", call["arguments"])
196
+ self.messages.append(
197
+ {
198
+ "role": "tool",
199
+ "tool_call_id": call["id"],
200
+ "content": self._run_tool_call(call["name"], call["arguments"]),
201
+ }
202
+ )
203
+ return "[stopped: the model kept calling tools past the step limit]"
204
+
205
+ def reset(self) -> None:
206
+ """Clear the conversation history (keeps the system prompt)."""
207
+ del self.messages[1:]