internet2agent 0.1.0__py3-none-any.whl
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/__init__.py +17 -0
- internet2agent/agent.py +207 -0
- internet2agent/cli.py +291 -0
- internet2agent/config.py +86 -0
- internet2agent/periscope.py +416 -0
- internet2agent-0.1.0.dist-info/METADATA +166 -0
- internet2agent-0.1.0.dist-info/RECORD +11 -0
- internet2agent-0.1.0.dist-info/WHEEL +5 -0
- internet2agent-0.1.0.dist-info/entry_points.txt +3 -0
- internet2agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- internet2agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
internet2agent/agent.py
ADDED
|
@@ -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:]
|
internet2agent/cli.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Command-line interface for internet2agent.
|
|
2
|
+
|
|
3
|
+
Direct Looking Glass access (no credentials needed):
|
|
4
|
+
|
|
5
|
+
internet2agent info
|
|
6
|
+
internet2agent devices
|
|
7
|
+
internet2agent commands
|
|
8
|
+
internet2agent filters
|
|
9
|
+
internet2agent exec "show bgp" -t rtr1 rtr2 -p summary -f "include Established"
|
|
10
|
+
|
|
11
|
+
Natural-language agent (configure your OpenAI-compatible endpoint in .env):
|
|
12
|
+
|
|
13
|
+
internet2agent ask "Is BGP healthy between Chicago and Seattle?"
|
|
14
|
+
internet2agent chat
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import getpass
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import openai
|
|
27
|
+
|
|
28
|
+
from .agent import Internet2Agent
|
|
29
|
+
from .config import DEFAULT_MODEL, Settings, save_env_values
|
|
30
|
+
from .periscope import PeriscopeClient, PeriscopeError
|
|
31
|
+
|
|
32
|
+
_DEVICE_COLUMNS = ["name", "location", "type", "platform"]
|
|
33
|
+
_COMMAND_COLUMNS = ["name", "platforms", "help"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _print_json(data: Any) -> None:
|
|
37
|
+
print(json.dumps(data, indent=2))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _print_table(rows: list[dict[str, Any]], preferred: list[str]) -> None:
|
|
41
|
+
if not rows:
|
|
42
|
+
print("(no results)")
|
|
43
|
+
return
|
|
44
|
+
seen: dict[str, None] = {}
|
|
45
|
+
for row in rows:
|
|
46
|
+
for key in row:
|
|
47
|
+
seen[key] = None
|
|
48
|
+
columns = [c for c in preferred if c in seen] + [c for c in seen if c not in preferred]
|
|
49
|
+
|
|
50
|
+
def cell(row: dict[str, Any], col: str) -> str:
|
|
51
|
+
value = row.get(col, "")
|
|
52
|
+
if isinstance(value, (list, tuple)):
|
|
53
|
+
return ", ".join(str(v) for v in value)
|
|
54
|
+
return "" if value is None else str(value)
|
|
55
|
+
|
|
56
|
+
widths = {c: max(len(c), *(len(cell(r, c)) for r in rows)) for c in columns}
|
|
57
|
+
print(" ".join(c.upper().ljust(widths[c]) for c in columns))
|
|
58
|
+
print(" ".join("-" * widths[c] for c in columns))
|
|
59
|
+
for row in rows:
|
|
60
|
+
print(" ".join(cell(row, c).ljust(widths[c]) for c in columns))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rows(data: Any, *keys: str) -> list[dict[str, Any]] | None:
|
|
64
|
+
"""Coerce a tool result into a list of dicts, unwrapping one level if needed."""
|
|
65
|
+
if isinstance(data, dict):
|
|
66
|
+
for key in keys:
|
|
67
|
+
if isinstance(data.get(key), list):
|
|
68
|
+
data = data[key]
|
|
69
|
+
break
|
|
70
|
+
if isinstance(data, list) and all(isinstance(item, dict) for item in data):
|
|
71
|
+
return data
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _show(data: Any, as_json: bool, preferred: list[str], *unwrap_keys: str) -> None:
|
|
76
|
+
rows = None if as_json else _rows(data, *unwrap_keys)
|
|
77
|
+
if rows is None:
|
|
78
|
+
_print_json(data)
|
|
79
|
+
else:
|
|
80
|
+
_print_table(rows, preferred)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _cmd_info(args: argparse.Namespace) -> None:
|
|
84
|
+
with PeriscopeClient() as lg:
|
|
85
|
+
_print_json(lg.config())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_devices(args: argparse.Namespace) -> None:
|
|
89
|
+
with PeriscopeClient() as lg:
|
|
90
|
+
_show(lg.devices(), args.json, _DEVICE_COLUMNS, "devices")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cmd_commands(args: argparse.Namespace) -> None:
|
|
94
|
+
with PeriscopeClient() as lg:
|
|
95
|
+
_show(lg.commands(), args.json, _COMMAND_COLUMNS, "commands")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_filters(args: argparse.Namespace) -> None:
|
|
99
|
+
with PeriscopeClient() as lg:
|
|
100
|
+
_show(lg.filters(), args.json, ["name", "platforms", "help"], "filters")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cmd_exec(args: argparse.Namespace) -> None:
|
|
104
|
+
with PeriscopeClient() as lg:
|
|
105
|
+
result = lg.execute(
|
|
106
|
+
args.command,
|
|
107
|
+
args.targets,
|
|
108
|
+
parameter=args.parameter,
|
|
109
|
+
filter=args.filter,
|
|
110
|
+
)
|
|
111
|
+
if args.json or not isinstance(result, dict) or "outputs" not in result:
|
|
112
|
+
_print_json(result)
|
|
113
|
+
return
|
|
114
|
+
for output in result["outputs"]:
|
|
115
|
+
if isinstance(output, dict):
|
|
116
|
+
target = output.get("target") or output.get("device") or "?"
|
|
117
|
+
body = output.get("output")
|
|
118
|
+
if not isinstance(body, str):
|
|
119
|
+
body = json.dumps(output, indent=2)
|
|
120
|
+
status = output.get("status")
|
|
121
|
+
else:
|
|
122
|
+
target, body, status = "?", str(output), None
|
|
123
|
+
suffix = f" [status: {status}]" if status not in (None, "ok") else ""
|
|
124
|
+
print(f"=== {target}{suffix} ===")
|
|
125
|
+
print(body.rstrip("\n"))
|
|
126
|
+
print()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _show_tool_call(name: str, raw_arguments: str) -> None:
|
|
130
|
+
print(f" [{name} {raw_arguments}]", flush=True)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _onboard_llm() -> Settings:
|
|
134
|
+
"""First-run setup: collect the OpenAI-compatible endpoint interactively."""
|
|
135
|
+
print("No LLM endpoint is configured yet (OPENAI_BASE_URL / OPENAI_API_KEY are both unset).")
|
|
136
|
+
print("The agent works with any OpenAI-compatible endpoint, for example:")
|
|
137
|
+
print(" OpenAI: leave the base URL blank and paste an API key")
|
|
138
|
+
print(" Ollama: http://localhost:11434/v1 (no key needed)")
|
|
139
|
+
print(" LM Studio: http://localhost:1234/v1 (no key needed)")
|
|
140
|
+
if not sys.stdin.isatty():
|
|
141
|
+
print(
|
|
142
|
+
"error: non-interactive session - set OPENAI_BASE_URL / OPENAI_API_KEY / "
|
|
143
|
+
"I2A_MODEL in .env or the environment.",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
raise SystemExit(2)
|
|
147
|
+
print()
|
|
148
|
+
base_url = input("LLM base URL [blank = api.openai.com]: ").strip()
|
|
149
|
+
api_key = getpass.getpass("API key (hidden) [blank if the endpoint needs none]: ").strip()
|
|
150
|
+
if not base_url and not api_key:
|
|
151
|
+
print("error: nothing entered - the agent needs at least a base URL or an API key.",
|
|
152
|
+
file=sys.stderr)
|
|
153
|
+
raise SystemExit(2)
|
|
154
|
+
model = input(f"Model name [{DEFAULT_MODEL}]: ").strip() or DEFAULT_MODEL
|
|
155
|
+
|
|
156
|
+
values = {"I2A_MODEL": model}
|
|
157
|
+
if base_url:
|
|
158
|
+
values["OPENAI_BASE_URL"] = base_url
|
|
159
|
+
if api_key:
|
|
160
|
+
values["OPENAI_API_KEY"] = api_key
|
|
161
|
+
os.environ.update(values) # takes effect for this run
|
|
162
|
+
|
|
163
|
+
save = input("Save to .env for future runs? [Y/n]: ").strip().lower()
|
|
164
|
+
if save in ("", "y", "yes"):
|
|
165
|
+
print(f"(saved to {save_env_values(values)})")
|
|
166
|
+
print()
|
|
167
|
+
return Settings.from_env()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _make_agent() -> Internet2Agent:
|
|
171
|
+
settings = Settings.from_env()
|
|
172
|
+
if not (settings.openai_api_key or settings.openai_base_url):
|
|
173
|
+
settings = _onboard_llm()
|
|
174
|
+
return Internet2Agent(settings=settings)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _cmd_ask(args: argparse.Namespace) -> None:
|
|
178
|
+
with _make_agent() as agent:
|
|
179
|
+
print(agent.ask(" ".join(args.question), on_tool=_show_tool_call))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _cmd_chat(args: argparse.Namespace) -> None:
|
|
183
|
+
with _make_agent() as agent:
|
|
184
|
+
print("Internet2 Periscope agent. Type 'exit' to quit, 'reset' to clear history.")
|
|
185
|
+
while True:
|
|
186
|
+
try:
|
|
187
|
+
line = input("you> ").strip()
|
|
188
|
+
except (EOFError, KeyboardInterrupt):
|
|
189
|
+
print()
|
|
190
|
+
return
|
|
191
|
+
if not line:
|
|
192
|
+
continue
|
|
193
|
+
if line.lower() in {"exit", "quit"}:
|
|
194
|
+
return
|
|
195
|
+
if line.lower() == "reset":
|
|
196
|
+
agent.reset()
|
|
197
|
+
print("(history cleared)")
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
reply = agent.ask(line, on_tool=_show_tool_call)
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
print("\n(interrupted - session kept, ask again or 'exit')")
|
|
203
|
+
continue
|
|
204
|
+
print(f"agent> {reply}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
208
|
+
parser = argparse.ArgumentParser(
|
|
209
|
+
prog="internet2agent",
|
|
210
|
+
description="Internet2 Periscope Looking Glass - direct client and Claude agent",
|
|
211
|
+
)
|
|
212
|
+
sub = parser.add_subparsers(dest="subcommand", required=True)
|
|
213
|
+
|
|
214
|
+
def lg_sub(name: str, help_text: str, func: Any) -> argparse.ArgumentParser:
|
|
215
|
+
p = sub.add_parser(name, help=help_text)
|
|
216
|
+
p.add_argument("--json", action="store_true", help="print raw JSON")
|
|
217
|
+
p.set_defaults(func=func)
|
|
218
|
+
return p
|
|
219
|
+
|
|
220
|
+
lg_sub("info", "show service limits (rate limit, max targets)", _cmd_info)
|
|
221
|
+
lg_sub("devices", "list available devices", _cmd_devices)
|
|
222
|
+
lg_sub("commands", "list supported commands", _cmd_commands)
|
|
223
|
+
lg_sub("filters", "list output filters", _cmd_filters)
|
|
224
|
+
|
|
225
|
+
p_exec = lg_sub("exec", "run a command on one or more devices", _cmd_exec)
|
|
226
|
+
p_exec.add_argument("command", help='command name, e.g. "show bgp" or "traceroute"')
|
|
227
|
+
p_exec.add_argument(
|
|
228
|
+
"-t", "--targets", nargs="+", required=True, metavar="DEVICE",
|
|
229
|
+
help="device IDs from 'devices' (max 10)",
|
|
230
|
+
)
|
|
231
|
+
p_exec.add_argument("-p", "--parameter", help='appended argument, e.g. "10.0.0.0/8" or "summary"')
|
|
232
|
+
p_exec.add_argument("-f", "--filter", help='output filter, e.g. "include bgp" or "exclude ^$"')
|
|
233
|
+
|
|
234
|
+
p_ask = sub.add_parser("ask", help="ask the LLM agent one question (configure LLM in .env)")
|
|
235
|
+
p_ask.add_argument("question", nargs="+", help="the question to ask")
|
|
236
|
+
p_ask.set_defaults(func=_cmd_ask)
|
|
237
|
+
|
|
238
|
+
p_chat = sub.add_parser("chat", help="interactive agent session (configure LLM in .env)")
|
|
239
|
+
p_chat.set_defaults(func=_cmd_chat)
|
|
240
|
+
|
|
241
|
+
return parser
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: list[str] | None = None) -> int:
|
|
245
|
+
args = build_parser().parse_args(argv)
|
|
246
|
+
try:
|
|
247
|
+
args.func(args)
|
|
248
|
+
except PeriscopeError as exc:
|
|
249
|
+
print(f"periscope error: {exc}", file=sys.stderr)
|
|
250
|
+
return 1
|
|
251
|
+
except openai.AuthenticationError:
|
|
252
|
+
print(
|
|
253
|
+
"error: the LLM endpoint rejected the API key.\n"
|
|
254
|
+
"Set OPENAI_API_KEY (and OPENAI_BASE_URL if not using OpenAI) in .env.",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
257
|
+
return 2
|
|
258
|
+
except openai.NotFoundError as exc:
|
|
259
|
+
print(
|
|
260
|
+
f"error: the LLM endpoint returned 404: {exc}\n"
|
|
261
|
+
"Check I2A_MODEL (and OPENAI_BASE_URL) in .env - the endpoint may not serve that model.",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
return 2
|
|
265
|
+
except openai.RateLimitError:
|
|
266
|
+
print("error: LLM endpoint rate limit hit - try again shortly.", file=sys.stderr)
|
|
267
|
+
return 2
|
|
268
|
+
except openai.APIStatusError as exc:
|
|
269
|
+
print(f"error: LLM endpoint returned {exc.status_code}: {exc.message}", file=sys.stderr)
|
|
270
|
+
return 2
|
|
271
|
+
except openai.APIConnectionError:
|
|
272
|
+
print(
|
|
273
|
+
"error: could not reach the LLM endpoint - check OPENAI_BASE_URL in .env "
|
|
274
|
+
"and that the server is running.",
|
|
275
|
+
file=sys.stderr,
|
|
276
|
+
)
|
|
277
|
+
return 2
|
|
278
|
+
except openai.OpenAIError as exc:
|
|
279
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
280
|
+
return 2
|
|
281
|
+
except TimeoutError:
|
|
282
|
+
print("error: Periscope MCP call timed out.", file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
except KeyboardInterrupt:
|
|
285
|
+
print(file=sys.stderr)
|
|
286
|
+
return 130
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
sys.exit(main())
|
internet2agent/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Environment-driven configuration for internet2agent.
|
|
2
|
+
|
|
3
|
+
Credentials and settings come from environment variables, optionally loaded
|
|
4
|
+
from a ``.env`` file (current directory first, then the project root):
|
|
5
|
+
|
|
6
|
+
- ``OPENAI_BASE_URL`` - OpenAI-compatible endpoint for the agent's LLM
|
|
7
|
+
(unset = api.openai.com; e.g. Ollama:
|
|
8
|
+
``http://localhost:11434/v1``).
|
|
9
|
+
- ``OPENAI_API_KEY`` - API key for that endpoint (optional for local
|
|
10
|
+
endpoints that don't check keys).
|
|
11
|
+
- ``I2A_MODEL`` - model name the agent requests (default: gpt-4o;
|
|
12
|
+
set it to whatever your endpoint serves).
|
|
13
|
+
- ``PERISCOPE_MCP_URL`` - Periscope MCP endpoint (default below).
|
|
14
|
+
- ``PERISCOPE_AUTH_TOKEN`` - future Internet2/Periscope bearer token. The
|
|
15
|
+
server currently requires no credentials; leave
|
|
16
|
+
unset until Internet2 issues one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
|
|
27
|
+
DEFAULT_MCP_URL = "https://periscope.ns.internet2.edu/mcp"
|
|
28
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
29
|
+
|
|
30
|
+
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_env() -> None:
|
|
34
|
+
"""Load ``.env`` from the current directory, then the project root.
|
|
35
|
+
|
|
36
|
+
Real environment variables always win; among ``.env`` files, the first one
|
|
37
|
+
that defines a key wins.
|
|
38
|
+
"""
|
|
39
|
+
load_dotenv()
|
|
40
|
+
load_dotenv(_PROJECT_ROOT / ".env")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clean(value: str | None) -> str | None:
|
|
44
|
+
value = (value or "").strip()
|
|
45
|
+
return value or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def env_file_path() -> Path:
|
|
49
|
+
"""The project-root .env file (may not exist yet)."""
|
|
50
|
+
return _PROJECT_ROOT / ".env"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_env_values(values: dict[str, str], path: Path | None = None) -> Path:
|
|
54
|
+
"""Write ``KEY=value`` pairs into ``.env``, updating lines that already set them."""
|
|
55
|
+
path = path or env_file_path()
|
|
56
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
|
|
57
|
+
remaining = dict(values)
|
|
58
|
+
for index, line in enumerate(lines):
|
|
59
|
+
stripped = line.strip()
|
|
60
|
+
for key in list(remaining):
|
|
61
|
+
if stripped.startswith(f"{key}="):
|
|
62
|
+
lines[index] = f"{key}={remaining.pop(key)}"
|
|
63
|
+
break
|
|
64
|
+
lines.extend(f"{key}={value}" for key, value in remaining.items())
|
|
65
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Settings:
|
|
71
|
+
mcp_url: str = DEFAULT_MCP_URL
|
|
72
|
+
periscope_token: str | None = None
|
|
73
|
+
model: str = DEFAULT_MODEL
|
|
74
|
+
openai_base_url: str | None = None
|
|
75
|
+
openai_api_key: str | None = None
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_env(cls) -> "Settings":
|
|
79
|
+
load_env()
|
|
80
|
+
return cls(
|
|
81
|
+
mcp_url=_clean(os.environ.get("PERISCOPE_MCP_URL")) or DEFAULT_MCP_URL,
|
|
82
|
+
periscope_token=_clean(os.environ.get("PERISCOPE_AUTH_TOKEN")),
|
|
83
|
+
model=_clean(os.environ.get("I2A_MODEL")) or DEFAULT_MODEL,
|
|
84
|
+
openai_base_url=_clean(os.environ.get("OPENAI_BASE_URL")),
|
|
85
|
+
openai_api_key=_clean(os.environ.get("OPENAI_API_KEY")),
|
|
86
|
+
)
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Direct Python client for the Internet2 Periscope Looking Glass MCP server.
|
|
2
|
+
|
|
3
|
+
The server exposes five tools:
|
|
4
|
+
|
|
5
|
+
- ``lg_config`` - service limits (rate limit, max targets per request)
|
|
6
|
+
- ``lg_devices`` - available devices (name/ID, location, type, platform)
|
|
7
|
+
- ``lg_commands`` - supported commands and the platforms they run on
|
|
8
|
+
- ``lg_filters`` - output filters (``include``/``exclude`` + regex pattern)
|
|
9
|
+
- ``lg_execute`` - run a command on up to 10 devices
|
|
10
|
+
|
|
11
|
+
``AsyncPeriscopeClient`` is the asyncio interface; ``PeriscopeClient`` is a
|
|
12
|
+
synchronous facade that runs the MCP session on a background event-loop
|
|
13
|
+
thread so one session can be reused across calls.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import concurrent.futures
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
from contextlib import asynccontextmanager
|
|
23
|
+
from typing import Any, AsyncIterator, Iterable
|
|
24
|
+
|
|
25
|
+
from mcp import Client
|
|
26
|
+
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from mcp.shared.exceptions import MCPError
|
|
30
|
+
except ImportError: # pragma: no cover - name used by pre-2.1 releases
|
|
31
|
+
from mcp.shared.exceptions import McpError as MCPError
|
|
32
|
+
|
|
33
|
+
from .config import Settings
|
|
34
|
+
|
|
35
|
+
_CALL_TIMEOUT = 180.0
|
|
36
|
+
_CONNECT_TIMEOUT = 30.0
|
|
37
|
+
_CLOSE_GRACE = 15.0
|
|
38
|
+
|
|
39
|
+
_LIST_TOOLS = object() # queue sentinel: "run list_tools" (None means "shut down")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PeriscopeError(RuntimeError):
|
|
43
|
+
"""Raised when the Periscope MCP server rejects or fails a request."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _SessionClosedError(PeriscopeError):
|
|
47
|
+
"""A queued call was abandoned because its session went away."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@asynccontextmanager
|
|
51
|
+
async def _transport_with_headers(url: str, headers: dict[str, str]) -> AsyncIterator[Any]:
|
|
52
|
+
# mcp's Client(url) offers no header hook, so build the transport ourselves.
|
|
53
|
+
async with create_mcp_http_client(headers=headers) as http_client:
|
|
54
|
+
async with streamable_http_client(url, http_client=http_client) as streams:
|
|
55
|
+
yield streams
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decode(result: Any) -> Any:
|
|
59
|
+
"""Decode a CallToolResult: structured JSON if present, else (JSON) text."""
|
|
60
|
+
structured = getattr(result, "structuredContent", None) or getattr(result, "structured_content", None)
|
|
61
|
+
if structured:
|
|
62
|
+
return structured
|
|
63
|
+
texts = [b.text for b in (result.content or []) if getattr(b, "type", None) == "text"]
|
|
64
|
+
text = "\n".join(texts)
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(text)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_error(result: Any) -> bool:
|
|
72
|
+
return bool(getattr(result, "isError", False) or getattr(result, "is_error", False))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AsyncPeriscopeClient:
|
|
76
|
+
"""Async MCP client for Periscope. Use as an async context manager.
|
|
77
|
+
|
|
78
|
+
``url`` and ``auth_token`` default to the environment (``PERISCOPE_MCP_URL``,
|
|
79
|
+
``PERISCOPE_AUTH_TOKEN``); the server currently requires no token.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
url: str | None = None,
|
|
85
|
+
auth_token: str | None = None,
|
|
86
|
+
settings: Settings | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
if url is None or auth_token is None:
|
|
89
|
+
settings = settings or Settings.from_env()
|
|
90
|
+
self.url = url or settings.mcp_url
|
|
91
|
+
self.auth_token = auth_token if auth_token is not None else settings.periscope_token
|
|
92
|
+
self._client: Client | None = None
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self) -> "AsyncPeriscopeClient":
|
|
95
|
+
if self.auth_token:
|
|
96
|
+
target: Any = _transport_with_headers(
|
|
97
|
+
self.url, {"Authorization": f"Bearer {self.auth_token}"}
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
target = self.url
|
|
101
|
+
client = Client(target)
|
|
102
|
+
await client.__aenter__()
|
|
103
|
+
self._client = client
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
107
|
+
client, self._client = self._client, None
|
|
108
|
+
if client is not None:
|
|
109
|
+
await client.__aexit__(exc_type, exc, tb)
|
|
110
|
+
|
|
111
|
+
async def call(self, tool: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
112
|
+
"""Call one MCP tool and return its decoded result."""
|
|
113
|
+
if self._client is None:
|
|
114
|
+
raise RuntimeError("not connected - use 'async with AsyncPeriscopeClient() as lg:'")
|
|
115
|
+
try:
|
|
116
|
+
result = await self._client.call_tool(tool, arguments or {})
|
|
117
|
+
except MCPError as exc:
|
|
118
|
+
# Protocol-level rejections (unknown device, invalid characters, rate
|
|
119
|
+
# limit) arrive as JSON-RPC errors, not isError results.
|
|
120
|
+
raise PeriscopeError(f"{tool} failed: {exc}") from exc
|
|
121
|
+
payload = _decode(result)
|
|
122
|
+
if _is_error(result):
|
|
123
|
+
raise PeriscopeError(f"{tool} failed: {payload}")
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
async def tools(self) -> list[dict[str, Any]]:
|
|
127
|
+
"""The server's tool inventory: name, description, and input schema."""
|
|
128
|
+
if self._client is None:
|
|
129
|
+
raise RuntimeError("not connected - use 'async with AsyncPeriscopeClient() as lg:'")
|
|
130
|
+
result = await self._client.list_tools()
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
"name": tool.name,
|
|
134
|
+
"description": tool.description or "",
|
|
135
|
+
"input_schema": getattr(tool, "input_schema", None)
|
|
136
|
+
or getattr(tool, "inputSchema", None),
|
|
137
|
+
}
|
|
138
|
+
for tool in result.tools
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
async def config(self) -> Any:
|
|
142
|
+
"""Service limits: rate limit and max targets per request."""
|
|
143
|
+
return await self.call("lg_config")
|
|
144
|
+
|
|
145
|
+
async def devices(self) -> Any:
|
|
146
|
+
"""All available devices with name (ID), location, type, and platform."""
|
|
147
|
+
return await self.call("lg_devices")
|
|
148
|
+
|
|
149
|
+
async def commands(self) -> Any:
|
|
150
|
+
"""Supported commands with help text and supported platforms."""
|
|
151
|
+
return await self.call("lg_commands")
|
|
152
|
+
|
|
153
|
+
async def filters(self) -> Any:
|
|
154
|
+
"""Available output filters and the platforms they support."""
|
|
155
|
+
return await self.call("lg_filters")
|
|
156
|
+
|
|
157
|
+
async def execute(
|
|
158
|
+
self,
|
|
159
|
+
command: str,
|
|
160
|
+
targets: Iterable[str],
|
|
161
|
+
parameter: str | None = None,
|
|
162
|
+
filter: str | None = None,
|
|
163
|
+
) -> Any:
|
|
164
|
+
"""Run ``command`` on up to 10 devices.
|
|
165
|
+
|
|
166
|
+
``targets`` must exactly match ``name`` values from :meth:`devices`.
|
|
167
|
+
``parameter`` is appended to the command (e.g. a prefix for
|
|
168
|
+
``show route``); ``filter`` is a filter name plus regex pattern,
|
|
169
|
+
e.g. ``"include bgp"`` or ``"exclude ^$"``.
|
|
170
|
+
"""
|
|
171
|
+
arguments: dict[str, Any] = {"command": command, "targets": list(targets)}
|
|
172
|
+
if parameter is not None:
|
|
173
|
+
arguments["parameter"] = parameter
|
|
174
|
+
if filter is not None:
|
|
175
|
+
arguments["filter"] = filter
|
|
176
|
+
return await self.call("lg_execute", arguments)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class PeriscopeClient:
|
|
180
|
+
"""Synchronous facade over :class:`AsyncPeriscopeClient`.
|
|
181
|
+
|
|
182
|
+
The MCP session lives inside a single task on a background event loop
|
|
183
|
+
(mcp's transports use anyio cancel scopes, which must be entered and
|
|
184
|
+
exited by the same task). Calls are handed to that task over a queue.
|
|
185
|
+
|
|
186
|
+
Connects lazily on first use and reconnects automatically if the session
|
|
187
|
+
dies (server restart, dropped connection). Call :meth:`close` (or use it
|
|
188
|
+
as a context manager) to disconnect. Instances are not thread-safe.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
url: str | None = None,
|
|
194
|
+
auth_token: str | None = None,
|
|
195
|
+
settings: Settings | None = None,
|
|
196
|
+
call_timeout: float = _CALL_TIMEOUT,
|
|
197
|
+
connect_timeout: float = _CONNECT_TIMEOUT,
|
|
198
|
+
) -> None:
|
|
199
|
+
self._client_args = (url, auth_token, settings)
|
|
200
|
+
self._call_timeout = call_timeout
|
|
201
|
+
self._connect_timeout = connect_timeout
|
|
202
|
+
self._lock = threading.Lock()
|
|
203
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
204
|
+
self._thread: threading.Thread | None = None
|
|
205
|
+
self._queue: asyncio.Queue[Any] | None = None
|
|
206
|
+
self._worker: "concurrent.futures.Future[None] | None" = None
|
|
207
|
+
self._worker_exited: threading.Event | None = None
|
|
208
|
+
|
|
209
|
+
# -- session lifecycle ----------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _ensure_connected(self) -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
if self._loop is not None:
|
|
214
|
+
return
|
|
215
|
+
loop = asyncio.new_event_loop()
|
|
216
|
+
thread = threading.Thread(target=loop.run_forever, name="periscope-mcp", daemon=True)
|
|
217
|
+
thread.start()
|
|
218
|
+
|
|
219
|
+
queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
220
|
+
ready: "concurrent.futures.Future[None]" = concurrent.futures.Future()
|
|
221
|
+
exited = threading.Event()
|
|
222
|
+
worker = asyncio.run_coroutine_threadsafe(
|
|
223
|
+
self._session_worker(queue, ready, exited), loop
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
ready.result(self._connect_timeout + 10)
|
|
227
|
+
except BaseException:
|
|
228
|
+
worker.cancel()
|
|
229
|
+
exited.wait(5)
|
|
230
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
231
|
+
thread.join(timeout=5)
|
|
232
|
+
loop.close()
|
|
233
|
+
raise
|
|
234
|
+
self._loop = loop
|
|
235
|
+
self._thread = thread
|
|
236
|
+
self._queue = queue
|
|
237
|
+
self._worker = worker
|
|
238
|
+
self._worker_exited = exited
|
|
239
|
+
|
|
240
|
+
async def _session_worker(
|
|
241
|
+
self,
|
|
242
|
+
queue: "asyncio.Queue[Any]",
|
|
243
|
+
ready: "concurrent.futures.Future[None]",
|
|
244
|
+
exited: threading.Event,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Own the MCP session for its whole lifetime and serve queued calls."""
|
|
247
|
+
try:
|
|
248
|
+
client = AsyncPeriscopeClient(*self._client_args)
|
|
249
|
+
try:
|
|
250
|
+
await asyncio.wait_for(client.__aenter__(), self._connect_timeout)
|
|
251
|
+
except BaseException as exc:
|
|
252
|
+
if not ready.done():
|
|
253
|
+
if isinstance(exc, Exception):
|
|
254
|
+
ready.set_exception(exc)
|
|
255
|
+
else:
|
|
256
|
+
ready.set_exception(PeriscopeError(f"connect failed: {exc!r}"))
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
ready.set_result(None)
|
|
260
|
+
while True:
|
|
261
|
+
item = await queue.get()
|
|
262
|
+
if item is None:
|
|
263
|
+
return
|
|
264
|
+
tool, arguments, future = item
|
|
265
|
+
try:
|
|
266
|
+
if tool is _LIST_TOOLS:
|
|
267
|
+
coro = client.tools()
|
|
268
|
+
else:
|
|
269
|
+
coro = client.call(tool, arguments)
|
|
270
|
+
result = await asyncio.wait_for(coro, self._call_timeout)
|
|
271
|
+
except asyncio.CancelledError:
|
|
272
|
+
# close() gave up waiting - fail the caller with a normal
|
|
273
|
+
# exception and let the cancellation complete.
|
|
274
|
+
if not future.done():
|
|
275
|
+
future.set_exception(
|
|
276
|
+
PeriscopeError("client closed while a call was in flight")
|
|
277
|
+
)
|
|
278
|
+
raise
|
|
279
|
+
except asyncio.TimeoutError:
|
|
280
|
+
if not future.done():
|
|
281
|
+
future.set_exception(
|
|
282
|
+
TimeoutError(
|
|
283
|
+
f"Periscope call timed out after {self._call_timeout:g}s"
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
except Exception as exc: # per-call errors keep the session alive
|
|
287
|
+
if not future.done():
|
|
288
|
+
future.set_exception(exc)
|
|
289
|
+
except BaseException as exc:
|
|
290
|
+
if not future.done():
|
|
291
|
+
future.set_exception(exc)
|
|
292
|
+
raise
|
|
293
|
+
else:
|
|
294
|
+
if not future.done():
|
|
295
|
+
future.set_result(result)
|
|
296
|
+
finally:
|
|
297
|
+
try:
|
|
298
|
+
await asyncio.wait_for(client.__aexit__(None, None, None), 15)
|
|
299
|
+
except BaseException:
|
|
300
|
+
pass # best effort - the transport may already be gone
|
|
301
|
+
finally:
|
|
302
|
+
# Fail anything still queued so callers don't wait out their timeout,
|
|
303
|
+
# then signal the sync side that this session is over.
|
|
304
|
+
while True:
|
|
305
|
+
try:
|
|
306
|
+
item = queue.get_nowait()
|
|
307
|
+
except asyncio.QueueEmpty:
|
|
308
|
+
break
|
|
309
|
+
if item is not None and not item[2].done():
|
|
310
|
+
item[2].set_exception(_SessionClosedError("Periscope session closed"))
|
|
311
|
+
exited.set()
|
|
312
|
+
|
|
313
|
+
def _call(self, tool: Any, arguments: dict[str, Any] | None = None, *, _retry: bool = True) -> Any:
|
|
314
|
+
self._ensure_connected()
|
|
315
|
+
exited = self._worker_exited
|
|
316
|
+
if exited is not None and exited.is_set():
|
|
317
|
+
# The session died since the last call (server restart, dropped
|
|
318
|
+
# connection). Reap it and reconnect once.
|
|
319
|
+
self.close()
|
|
320
|
+
self._ensure_connected()
|
|
321
|
+
|
|
322
|
+
loop, queue = self._loop, self._queue
|
|
323
|
+
if loop is None or queue is None:
|
|
324
|
+
raise PeriscopeError("client is closed")
|
|
325
|
+
future: "concurrent.futures.Future[Any]" = concurrent.futures.Future()
|
|
326
|
+
loop.call_soon_threadsafe(queue.put_nowait, (tool, arguments, future))
|
|
327
|
+
try:
|
|
328
|
+
# The worker enforces the real per-call timeout; this is a backstop.
|
|
329
|
+
return future.result(self._call_timeout + 15)
|
|
330
|
+
except _SessionClosedError:
|
|
331
|
+
# Raced a dying session: the call was queued while the worker was
|
|
332
|
+
# unwinding. Reap it and retry once on a fresh session.
|
|
333
|
+
if not _retry:
|
|
334
|
+
raise
|
|
335
|
+
self.close()
|
|
336
|
+
return self._call(tool, arguments, _retry=False)
|
|
337
|
+
except (TimeoutError, concurrent.futures.TimeoutError) as exc:
|
|
338
|
+
if not future.done():
|
|
339
|
+
future.cancel()
|
|
340
|
+
# Normalize to builtin TimeoutError (they differ on Python 3.10).
|
|
341
|
+
raise TimeoutError(
|
|
342
|
+
str(exc) or f"Periscope call timed out after {self._call_timeout:g}s"
|
|
343
|
+
) from None
|
|
344
|
+
|
|
345
|
+
def close(self) -> None:
|
|
346
|
+
"""Disconnect and stop the background loop. Safe to call twice."""
|
|
347
|
+
with self._lock:
|
|
348
|
+
loop, thread, queue, worker, exited = (
|
|
349
|
+
self._loop,
|
|
350
|
+
self._thread,
|
|
351
|
+
self._queue,
|
|
352
|
+
self._worker,
|
|
353
|
+
self._worker_exited,
|
|
354
|
+
)
|
|
355
|
+
self._loop = self._thread = self._queue = self._worker = self._worker_exited = None
|
|
356
|
+
if loop is None:
|
|
357
|
+
return
|
|
358
|
+
try:
|
|
359
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
360
|
+
if not exited.wait(_CLOSE_GRACE):
|
|
361
|
+
worker.cancel() # a call is stuck in flight - force the session down
|
|
362
|
+
exited.wait(10)
|
|
363
|
+
finally:
|
|
364
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
365
|
+
thread.join(timeout=5)
|
|
366
|
+
loop.close()
|
|
367
|
+
|
|
368
|
+
def __enter__(self) -> "PeriscopeClient":
|
|
369
|
+
self._ensure_connected()
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
373
|
+
self.close()
|
|
374
|
+
|
|
375
|
+
# -- Looking Glass tools --------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def call(self, tool: str, arguments: dict[str, Any] | None = None) -> Any:
|
|
378
|
+
"""Call an arbitrary Periscope MCP tool and return its decoded result."""
|
|
379
|
+
if not isinstance(tool, str) or not tool:
|
|
380
|
+
raise PeriscopeError(f"invalid tool name: {tool!r}")
|
|
381
|
+
return self._call(tool, arguments)
|
|
382
|
+
|
|
383
|
+
def tools(self) -> list[dict[str, Any]]:
|
|
384
|
+
"""The server's tool inventory: name, description, and input schema."""
|
|
385
|
+
return self._call(_LIST_TOOLS)
|
|
386
|
+
|
|
387
|
+
def config(self) -> Any:
|
|
388
|
+
"""Service limits: rate limit and max targets per request."""
|
|
389
|
+
return self.call("lg_config")
|
|
390
|
+
|
|
391
|
+
def devices(self) -> Any:
|
|
392
|
+
"""All available devices with name (ID), location, type, and platform."""
|
|
393
|
+
return self.call("lg_devices")
|
|
394
|
+
|
|
395
|
+
def commands(self) -> Any:
|
|
396
|
+
"""Supported commands with help text and supported platforms."""
|
|
397
|
+
return self.call("lg_commands")
|
|
398
|
+
|
|
399
|
+
def filters(self) -> Any:
|
|
400
|
+
"""Available output filters and the platforms they support."""
|
|
401
|
+
return self.call("lg_filters")
|
|
402
|
+
|
|
403
|
+
def execute(
|
|
404
|
+
self,
|
|
405
|
+
command: str,
|
|
406
|
+
targets: Iterable[str],
|
|
407
|
+
parameter: str | None = None,
|
|
408
|
+
filter: str | None = None,
|
|
409
|
+
) -> Any:
|
|
410
|
+
"""Run ``command`` on up to 10 devices. See AsyncPeriscopeClient.execute."""
|
|
411
|
+
arguments: dict[str, Any] = {"command": command, "targets": list(targets)}
|
|
412
|
+
if parameter is not None:
|
|
413
|
+
arguments["parameter"] = parameter
|
|
414
|
+
if filter is not None:
|
|
415
|
+
arguments["filter"] = filter
|
|
416
|
+
return self.call("lg_execute", arguments)
|
|
@@ -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,11 @@
|
|
|
1
|
+
internet2agent/__init__.py,sha256=pCWXEHIb81VA9AFoM4ducIveR7YxR3Kq-PKFfr3qcgQ,452
|
|
2
|
+
internet2agent/agent.py,sha256=z4n35eony0IEYOmzJ5aqdAoIWntgBapP7FQomai7py4,8446
|
|
3
|
+
internet2agent/cli.py,sha256=DlB6E2ni1ZwhuimDoBrafXkjxYODaFqPe4vtj8jhZS0,10358
|
|
4
|
+
internet2agent/config.py,sha256=HK0Qd6wspGAaoTvlpf1IxzrIqo0U_3QhzKBEih0-wSU,3166
|
|
5
|
+
internet2agent/periscope.py,sha256=W_SKByiD6ObXBKx1l-epU-r_inHUpJokzsA7AtxUDRk,16706
|
|
6
|
+
internet2agent-0.1.0.dist-info/licenses/LICENSE,sha256=cx4pn-FOnkf2uUAEabQJYqxV8KAV6bQmCuDBKAp1MfA,1067
|
|
7
|
+
internet2agent-0.1.0.dist-info/METADATA,sha256=DHitxbcVWMMXJXLwxZ6zRDyHEUwocOukBtb2kJyfPHc,6580
|
|
8
|
+
internet2agent-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
internet2agent-0.1.0.dist-info/entry_points.txt,sha256=JXMiWqD3SvmlRiGdtlloPaIFOfODTPq78XJG1uESFoI,89
|
|
10
|
+
internet2agent-0.1.0.dist-info/top_level.txt,sha256=I_pGlG8__dvT5NchwzgzuwqlajeIxPYhsG1RLZlkjqc,15
|
|
11
|
+
internet2agent-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
internet2agent
|