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.
@@ -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:]
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())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ i2a = internet2agent.cli:main
3
+ internet2agent = internet2agent.cli:main
@@ -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