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.
- internet2agent-0.1.0/LICENSE +21 -0
- internet2agent-0.1.0/PKG-INFO +166 -0
- internet2agent-0.1.0/README.md +134 -0
- internet2agent-0.1.0/pyproject.toml +55 -0
- internet2agent-0.1.0/setup.cfg +4 -0
- internet2agent-0.1.0/src/internet2agent/__init__.py +17 -0
- internet2agent-0.1.0/src/internet2agent/agent.py +207 -0
- internet2agent-0.1.0/src/internet2agent/cli.py +291 -0
- internet2agent-0.1.0/src/internet2agent/config.py +86 -0
- internet2agent-0.1.0/src/internet2agent/periscope.py +416 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/PKG-INFO +166 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/SOURCES.txt +18 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/dependency_links.txt +1 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/entry_points.txt +3 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/requires.txt +6 -0
- internet2agent-0.1.0/src/internet2agent.egg-info/top_level.txt +1 -0
- internet2agent-0.1.0/tests/test_agent.py +236 -0
- internet2agent-0.1.0/tests/test_config.py +47 -0
- internet2agent-0.1.0/tests/test_live.py +37 -0
- internet2agent-0.1.0/tests/test_periscope.py +160 -0
|
@@ -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,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:]
|