commit 8840ba74f7e17ac1b8e64fb0f6994d43d7f0ed0f Author: Laurence Horrocks-Barlow Date: Wed May 20 01:36:59 2026 +0100 Initial commit: agentic chat app for IBM Storage Scale REST API v3 Adds a Python package that wraps the Storage Scale /scalemgmt/v3 management API behind a Claude-driven tool-use loop. Ships a Rich-styled terminal REPL and a Streamlit web UI sharing the same Agent/Dispatcher/GPFSClient stack. Guardrails are env-driven (.env): read-only by default; writes and DELETE gated by GPFS_ALLOW_WRITES / GPFS_ALLOW_DESTRUCTIVE; optional path allow/deny regex; mutating calls require operator confirmation. Free-text GPFS_INSTRUCTIONS (or GPFS_INSTRUCTIONS_FILE) appended to the system prompt. The system prompt also includes a curated v3 endpoint reference compiled from IBM Storage Scale 5.2.3 / 6.0.0 documentation so the agent doesn't have to guess endpoint shapes for common operations. Co-Authored-By: Claude Opus 4.7 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb25201 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# ────────────────────────────────────────────────────────────────────────────── +# GPFS Agent configuration +# Copy this file to `.env` and fill in your values. +# ────────────────────────────────────────────────────────────────────────────── + +# ── Anthropic / model ──────────────────────────────────────────────────────── +ANTHROPIC_API_KEY=sk-ant-xxx +# Model is overridable. Defaults to claude-sonnet-4-6. +# Other valid IDs: claude-opus-4-7, claude-haiku-4-5-20251001 +ANTHROPIC_MODEL=claude-sonnet-4-6 +ANTHROPIC_MAX_TOKENS=4096 + +# ── GPFS REST API (IBM Storage Scale, formerly Spectrum Scale) ─────────────── +# Base URL of the GUI node hosting the REST API, including the version prefix. +# v3 is the current native REST API on Storage Scale 5.2.x / 6.0.x. +# Default GUI port is 443 (CNSA / container deploys) or 46443 (native install). +# The interactive Swagger UI lives at https:///openapi/ — useful for +# discovering endpoints your specific release ships. +GPFS_API_BASE=https://gpfs-gui.example.com:443/scalemgmt/v3 +GPFS_USERNAME=admin +GPFS_PASSWORD=changeme + +# Set to "false" to skip TLS verification (self-signed certs in lab gear). +GPFS_VERIFY_TLS=true +# Optional path to a CA bundle to trust. +# GPFS_CA_BUNDLE=C:\path\to\ca.pem + +# Per-request timeout in seconds. +GPFS_TIMEOUT=30 + +# ── Guardrails ─────────────────────────────────────────────────────────────── +# The agent is read-only by default. Flip these to expand its powers. +GPFS_ALLOW_WRITES=false # enables POST and PUT +GPFS_ALLOW_DESTRUCTIVE=false # enables DELETE (requires ALLOW_WRITES=true) + +# Optional regex allow/deny applied to the request path *after* the API base. +# e.g. ^/filesystems(/|$) to restrict to filesystem endpoints. +# GPFS_PATH_ALLOW=^/.* +# GPFS_PATH_DENY=^/(perfmon|auth)/ + +# Additional natural-language instructions appended to the system prompt. +# Use this for site-specific guardrails (e.g. "never touch fs_prod_archive"). +# You can also point GPFS_INSTRUCTIONS_FILE at a text file for longer prompts. +GPFS_INSTRUCTIONS= +# GPFS_INSTRUCTIONS_FILE=./instructions.md + +# If "true", the agent will ask for human confirmation before any mutating call +# even when writes are allowed. Recommended in production. +GPFS_CONFIRM_MUTATIONS=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..949802e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +.venv/ +venv/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..82c6878 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# gpfs-agent + +Agentic chat front-end for the IBM Spectrum Scale (GPFS) REST API, driven by +Claude with tool-use. Read-only by default; writes and destructive operations +are gated behind explicit env flags. Ships with both a terminal REPL and a +pure-Python web UI (Streamlit). + +## Quick start + +```powershell +cd D:\Projects\gpfs-agent +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt + +Copy-Item .env.example .env +# edit .env: ANTHROPIC_API_KEY, GPFS_API_BASE, GPFS_USERNAME, GPFS_PASSWORD +``` + +### Web UI + +```powershell +streamlit run gpfs_agent\web.py +``` + +Opens http://localhost:8501 with a chat panel, a sidebar showing the mode and +system prompt, a tool-call log, and (when writes are enabled in `.env`) a +session-scoped "Auto-approve mutations" toggle. + +### Terminal REPL + +```powershell +python -m gpfs_agent +``` + +Commands inside the REPL: `/reset`, `/system`, `/quit`. + +## How the guardrails work + +Everything is configured in `.env`: + +| Variable | Effect | +| -------------------------- | ---------------------------------------------------------------------- | +| `GPFS_ALLOW_WRITES` | When `false` (default) the model is given a tool that only accepts `GET`. | +| `GPFS_ALLOW_DESTRUCTIVE` | When `false` (default) `DELETE` is stripped even if writes are on. | +| `GPFS_CONFIRM_MUTATIONS` | When `true` (default) every `POST/PUT/DELETE` is gated by a confirm step. | +| `GPFS_PATH_ALLOW` / `_DENY`| Optional regexes applied to the request path. | +| `GPFS_INSTRUCTIONS` | Free-text instructions appended to the system prompt. | +| `GPFS_INSTRUCTIONS_FILE` | Path to a file with longer instructions; merged with `GPFS_INSTRUCTIONS`. | + +Guardrails are enforced in three layers: + +1. **Tool schema** — the `method` enum given to the model is built from + `cfg.allowed_methods`, so it cannot ask for `DELETE` when disabled. +2. **Dispatcher** — `tools.py` re-checks method and allow/deny regex and + triggers the confirm hook before any mutating HTTP call. +3. **System prompt** — `agent.py` injects natural-language guardrails so the + model behaves sensibly even when a guardrail is loose. + +### Mutation confirmation + +- **REPL**: prints the planned call and waits for `y` at the terminal. +- **Web UI**: mutations are denied unless the sidebar checkbox + *"Auto-approve mutations this session"* is on. The checkbox only appears + when `GPFS_ALLOW_WRITES=true`. + +## Project layout + +``` +gpfs_agent/ +├── __init__.py +├── __main__.py # python -m gpfs_agent (REPL) +├── agent.py # Anthropic tool-use loop +├── cli.py # Rich REPL +├── config.py # .env loading & validation +├── gpfs_client.py # httpx wrapper around the GPFS REST API +├── tools.py # Tool schemas + guarded dispatcher +└── web.py # Streamlit chat app +``` + +## Notes on the GPFS REST API + +- `GPFS_API_BASE` should include the version prefix, e.g. + `https://gui:443/scalemgmt/v3`. Shipping Spectrum Scale builds currently + serve `/v2`; set whichever your cluster exposes. +- Auth is HTTP Basic against the Spectrum Scale GUI user. Use an account + scoped to the operations you intend to allow. +- For self-signed lab gear, set `GPFS_VERIFY_TLS=false` or point + `GPFS_CA_BUNDLE` at a PEM file. diff --git a/gpfs_agent/__init__.py b/gpfs_agent/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/gpfs_agent/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/gpfs_agent/__main__.py b/gpfs_agent/__main__.py new file mode 100644 index 0000000..d7a53f5 --- /dev/null +++ b/gpfs_agent/__main__.py @@ -0,0 +1,4 @@ +from .cli import run + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/gpfs_agent/agent.py b/gpfs_agent/agent.py new file mode 100644 index 0000000..d802fda --- /dev/null +++ b/gpfs_agent/agent.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Callable, Iterable, Optional + +from anthropic import Anthropic + +from .config import Config +from .endpoints import V3_REFERENCE +from .tools import ToolDispatcher, tool_schemas + + +BASE_SYSTEM_PROMPT = """\ +You are a careful operations assistant for an IBM Spectrum Scale (GPFS) cluster. + +You have a single tool, `gpfs_request`, that calls the cluster's REST API. \ +The operator has wired the base URL, credentials and TLS settings for you; \ +you only supply the method, path, and (when relevant) query/body. + +Operating principles: +1. Read before you write. Inspect with GET requests before proposing any \ +mutation. If the operator's question can be answered by reading, don't write. +2. Stay in scope. Only call paths that are relevant to the user's question. \ +Don't enumerate the whole cluster when one filesystem is being discussed. +3. Explain what you did. Summarise findings in plain English. Cite the \ +endpoint(s) you used. Show raw fields only when they materially answer the \ +question or the user asks for them. +4. Be honest about errors. If a call returns a non-2xx, report the status \ +and message rather than retrying blindly. Suggest the next diagnostic step. +5. Defer destructive work. For any DELETE or large-scale change, restate \ +the plan in a single sentence and wait for the human to confirm before calling \ +the tool — the harness will also prompt for confirmation. +6. Never invent endpoints. If you are unsure whether a path exists, try the \ +parent collection first (e.g. GET `/filesystems` before guessing a name). +""" + + +def build_system_prompt(cfg: Config) -> str: + methods = ", ".join(sorted(cfg.allowed_methods)) + lines = [ + BASE_SYSTEM_PROMPT, + "\n" + V3_REFERENCE, + f"\nThis session's allowed HTTP methods: {methods}.", + ] + if not cfg.allow_writes: + lines.append( + "Writes are disabled. Do not suggest POST/PUT/DELETE; explain what " + "you would do instead and let the operator run it themselves." + ) + if cfg.allow_writes and not cfg.allow_destructive: + lines.append( + "DELETE is disabled. Stop short of destructive operations and " + "explain what would be required." + ) + if cfg.path_allow: + lines.append(f"Only paths matching {cfg.path_allow.pattern!r} are accessible.") + if cfg.path_deny: + lines.append(f"Paths matching {cfg.path_deny.pattern!r} are blocked.") + if cfg.extra_instructions: + lines.append("\n[Operator instructions]\n" + cfg.extra_instructions) + return "\n".join(lines) + + +ToolEventFn = Callable[[str, dict[str, Any], dict[str, Any]], None] + + +@dataclass +class AgentState: + messages: list[dict[str, Any]] = field(default_factory=list) + + +class Agent: + def __init__( + self, + cfg: Config, + dispatcher: ToolDispatcher, + on_tool_event: Optional[ToolEventFn] = None, + ) -> None: + self._cfg = cfg + self._client = Anthropic(api_key=cfg.anthropic_api_key) + self._dispatcher = dispatcher + self._tools = tool_schemas(cfg) + self.system_prompt = build_system_prompt(cfg) + self._on_tool_event = on_tool_event or (lambda *_args: None) + self.state = AgentState() + + def reset(self) -> None: + self.state = AgentState() + + def chat(self, user_message: str, max_steps: int = 12) -> str: + self.state.messages.append({"role": "user", "content": user_message}) + + for _ in range(max_steps): + response = self._client.messages.create( + model=self._cfg.model, + max_tokens=self._cfg.max_tokens, + system=self.system_prompt, + tools=self._tools, + messages=self.state.messages, + ) + + self.state.messages.append( + {"role": "assistant", "content": [b.model_dump() for b in response.content]} + ) + + if response.stop_reason != "tool_use": + return _extract_text(response.content) + + tool_results = [] + for block in response.content: + if block.type != "tool_use": + continue + tool_input = dict(block.input) if isinstance(block.input, dict) else {} + result = self._dispatcher.dispatch(block.name, tool_input) + self._on_tool_event(block.name, tool_input, result) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": json.dumps(result, default=str), + "is_error": "error" in result, + } + ) + self.state.messages.append({"role": "user", "content": tool_results}) + + return ( + "[Reached max tool-use iterations without a final answer. " + "Try restating the question or narrowing the scope.]" + ) + + +def _extract_text(blocks: Iterable[Any]) -> str: + parts: list[str] = [] + for block in blocks: + if getattr(block, "type", None) == "text": + parts.append(block.text) + return "\n".join(parts).strip() diff --git a/gpfs_agent/cli.py b/gpfs_agent/cli.py new file mode 100644 index 0000000..fb05a09 --- /dev/null +++ b/gpfs_agent/cli.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from typing import Any + +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel + +from .agent import Agent +from .config import Config, load_config +from .gpfs_client import GPFSClient +from .tools import ToolDispatcher + + +console = Console() + + +def _banner(cfg: Config) -> None: + write_state = ( + "writes + DELETE" + if cfg.allow_writes and cfg.allow_destructive + else "writes (no DELETE)" + if cfg.allow_writes + else "read-only" + ) + body = ( + f"[bold]GPFS Agent[/bold] — {cfg.model}\n" + f"API base: {cfg.gpfs_base}\n" + f"Mode: {write_state}" + f"{' · confirm-on-mutate' if cfg.confirm_mutations else ''}\n" + "Type your question. Commands: /reset, /system, /quit" + ) + console.print(Panel.fit(body, border_style="cyan")) + + +def _on_tool_event(name: str, tool_input: dict[str, Any], result: dict[str, Any]) -> None: + method = tool_input.get("method", "?") + path = tool_input.get("path", "?") + status = result.get("status", "ERR") + ok = result.get("ok", False) + colour = "green" if ok else "red" + console.print( + f" [dim]→ {name}[/dim] [{colour}]{method} {path} → {status}[/{colour}]" + ) + + +def make_confirm(console: Console): + def _confirm(message: str, call: dict[str, Any]) -> bool: + console.print(Panel.fit( + f"[yellow]{message}[/yellow]\n" + f"[bold]{call['method']}[/bold] {call['path']}\n" + + (f"body: {call.get('body')}\n" if call.get("body") else "") + + (f"reason: {call.get('reason')}" if call.get("reason") else ""), + border_style="yellow", + title="confirm mutation", + )) + answer = console.input("[yellow]proceed?[/yellow] [y/N] ").strip().lower() + return answer in {"y", "yes"} + return _confirm + + +def run() -> int: + parser = argparse.ArgumentParser(prog="gpfs-agent") + parser.add_argument("--env", type=Path, default=None, help="Path to a .env file") + args = parser.parse_args() + + try: + cfg = load_config(args.env) + except RuntimeError as exc: + console.print(f"[red]config error:[/red] {exc}") + return 2 + + _banner(cfg) + + with GPFSClient(cfg) as client: + dispatcher = ToolDispatcher(cfg, client, confirm=make_confirm(console)) + agent = Agent(cfg, dispatcher, on_tool_event=_on_tool_event) + + while True: + try: + user = console.input("\n[bold cyan]you[/bold cyan] » ").strip() + except (EOFError, KeyboardInterrupt): + console.print("\nbye.") + return 0 + + if not user: + continue + if user in {"/quit", "/exit"}: + return 0 + if user == "/reset": + agent.reset() + console.print("[dim]conversation reset[/dim]") + continue + if user == "/system": + console.print(Panel(agent.system_prompt, title="system prompt", border_style="magenta")) + continue + + try: + reply = agent.chat(user) + except Exception as exc: + console.print(f"[red]agent error:[/red] {exc!r}") + continue + + console.print() + console.print(Markdown(reply or "_(no text returned)_")) + + +if __name__ == "__main__": + sys.exit(run()) diff --git a/gpfs_agent/config.py b/gpfs_agent/config.py new file mode 100644 index 0000000..5c53185 --- /dev/null +++ b/gpfs_agent/config.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Pattern + +from dotenv import load_dotenv + + +def _bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return raw.strip().lower() in {"1", "true", "yes", "on", "y"} + + +def _required(name: str) -> str: + val = os.getenv(name) + if not val: + raise RuntimeError( + f"Missing required env var {name}. Copy .env.example to .env and fill it in." + ) + return val + + +def _optional_pattern(name: str) -> Optional[Pattern[str]]: + raw = os.getenv(name) + if not raw: + return None + try: + return re.compile(raw) + except re.error as exc: + raise RuntimeError(f"Invalid regex in {name}: {exc}") from exc + + +@dataclass(frozen=True) +class Config: + anthropic_api_key: str + model: str + max_tokens: int + + gpfs_base: str + gpfs_username: str + gpfs_password: str + verify_tls: bool | str + timeout: float + + allow_writes: bool + allow_destructive: bool + confirm_mutations: bool + + path_allow: Optional[Pattern[str]] = None + path_deny: Optional[Pattern[str]] = None + extra_instructions: str = "" + + @property + def allowed_methods(self) -> set[str]: + methods = {"GET"} + if self.allow_writes: + methods |= {"POST", "PUT"} + if self.allow_writes and self.allow_destructive: + methods.add("DELETE") + return methods + + +def load_config(env_file: Optional[Path] = None) -> Config: + if env_file is not None: + load_dotenv(env_file, override=False) + else: + load_dotenv(override=False) + + verify_raw = os.getenv("GPFS_VERIFY_TLS", "true").strip().lower() + ca_bundle = os.getenv("GPFS_CA_BUNDLE") + if ca_bundle: + verify: bool | str = ca_bundle + elif verify_raw in {"0", "false", "no", "off", "n"}: + verify = False + else: + verify = True + + instructions = os.getenv("GPFS_INSTRUCTIONS", "").strip() + instructions_file = os.getenv("GPFS_INSTRUCTIONS_FILE") + if instructions_file: + path = Path(instructions_file) + if path.is_file(): + file_text = path.read_text(encoding="utf-8").strip() + instructions = f"{instructions}\n\n{file_text}".strip() if instructions else file_text + + return Config( + anthropic_api_key=_required("ANTHROPIC_API_KEY"), + model=os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6"), + max_tokens=int(os.getenv("ANTHROPIC_MAX_TOKENS", "4096")), + gpfs_base=_required("GPFS_API_BASE").rstrip("/"), + gpfs_username=_required("GPFS_USERNAME"), + gpfs_password=_required("GPFS_PASSWORD"), + verify_tls=verify, + timeout=float(os.getenv("GPFS_TIMEOUT", "30")), + allow_writes=_bool("GPFS_ALLOW_WRITES", False), + allow_destructive=_bool("GPFS_ALLOW_DESTRUCTIVE", False), + confirm_mutations=_bool("GPFS_CONFIRM_MUTATIONS", True), + path_allow=_optional_pattern("GPFS_PATH_ALLOW"), + path_deny=_optional_pattern("GPFS_PATH_DENY"), + extra_instructions=instructions, + ) diff --git a/gpfs_agent/endpoints.py b/gpfs_agent/endpoints.py new file mode 100644 index 0000000..59d3859 --- /dev/null +++ b/gpfs_agent/endpoints.py @@ -0,0 +1,98 @@ +V3_REFERENCE = """\ +# IBM Storage Scale (GPFS) management REST API — v3 reference + +Compiled from IBM Storage Scale documentation 5.2.3 / 6.0.0. The native REST +API is served by the Storage Scale GUI node. + +## Connection facts +- Base path: `/scalemgmt/v3` +- Default GUI port: 443 (containerised CNSA) or 46443 (native install) +- Protocol: HTTPS only +- Auth: HTTP Basic, against a Storage Scale GUI user with an admin role +- OpenAPI: live spec is at `/openapi/` on the GUI node (interactive Swagger UI) +- v3 is the current native REST API; v2 is still served on most clusters for + backwards compatibility but new endpoints are only added under v3 + +## Common endpoints (non-exhaustive) + +### Cluster & nodes +- `GET /clusters` List local cluster info (use `?view=BASIC` for a compact summary) +- `GET /cluster` Cluster configuration (legacy alias) +- `POST /clusters/remote` Register an owning cluster as a remote +- `GET /nodes` List all nodes +- `GET /nodes/{name}` Node details +- `DELETE /nodes/{name}` Remove a node (destructive) +- `GET /nodeclasses` List node classes + +### NSDs & pools +- `GET /nsds` List all network-shared disks +- `GET /nsds/{nsd}` NSD details +- `GET /filesystems/{fs}/storagepools` Storage pools for a filesystem + +### Filesystems +- `GET /filesystems` List all filesystems +- `POST /filesystems` Create a filesystem +- `GET /filesystems/{fs}` Filesystem detail (`?fields=mount` for mount state) +- `DELETE /filesystems/{fs}` Delete a filesystem (destructive) +- `PUT /filesystems/{fs}` Modify a filesystem + +### Filesets +- `GET /filesystems/{fs}/filesets` List filesets +- `POST /filesystems/{fs}/filesets` Create a fileset +- `GET /filesystems/{fs}/filesets/{fileset}` Fileset detail +- `PUT /filesystems/{fs}/filesets/{fileset}` Modify a fileset +- `DELETE /filesystems/{fs}/filesets/{fileset}` Delete (destructive) +- `POST /filesystems/{fs}/filesets/{fileset}:link` Link a fileset to a path +- `POST /filesystems/{fs}/filesets/{fileset}:unlink` Unlink a fileset + +### Snapshots +- `GET /filesystems/{fs}/snapshots` List global snapshots +- `POST /filesystems/{fs}/snapshots` Create a global snapshot +- `DELETE /filesystems/{fs}/snapshots/{snap}` Delete a global snapshot +- `GET /filesystems/{fs}/filesets/{fileset}/snapshots` List fileset snapshots +- `POST /filesystems/{fs}/filesets/{fileset}/snapshots` Create a fileset snapshot +- `DELETE /filesystems/{fs}/filesets/{fileset}/snapshots/{snap}` Delete a fileset snapshot +- `POST /filesystems/{fs}/filesets/snapshots:batchDelete` Batch-delete fileset snapshots + +### Quotas +- `GET /filesystems/{fs}/quotas` List quotas on a filesystem +- `PUT /filesystems/{fs}/quotas` Set/modify quotas +- `GET /filesystems/{fs}/quotamanagement` Quota management state +- `GET /filesystems/{fs}/quotadefaults` Default quotas + +### Policies & rules +- `GET /filesystems/{fs}/policies` Active policy +- `PUT /filesystems/{fs}/policies` Install / replace policy +- `GET /filesystems/{fs}/policyrules` Inspect rules + +### Jobs (async operations) +- `GET /jobs` List jobs +- `GET /jobs/{jobId}` Poll a job (mutating endpoints return a jobId) + +### Performance monitoring +- `GET /perfmon/data` Query perfmon sensors +- `GET /perfmon/sensors` List configured sensors + +### Auth / config +- `GET /info` Product info / API version +- `GET /config` GUI/cluster config + +## Conventions +- Async-by-default: most `POST/PUT/DELETE` calls return `202 Accepted` with a + `jobs[0].jobId`. Poll `/jobs/{jobId}` until the status is `COMPLETED` or + `FAILED`. Don't assume a mutation has taken effect until the job is done. +- Many GETs accept `?fields=` to narrow the response payload. +- Collection endpoints generally accept `?filter=` for server-side + filtering — when in doubt, fetch the collection and filter client-side. +- A colon segment like `:link` / `:unlink` / `:batchDelete` denotes a custom + verb on a sub-resource; treat the whole thing as one path segment. + +## Behaviour rules for the agent +- Use the OpenAPI spec at `/openapi/` (HTML) or `/openapi.json` if the user + reports an endpoint that isn't listed here — it's the source of truth for + the specific cluster version. +- If a v3 endpoint 404s, retry once at the v2 equivalent before declaring + failure (some sites haven't upgraded the GUI yet). +- For any mutating call: state the plan in one sentence, wait for the harness + confirmation, then poll the returned jobId. +""" diff --git a/gpfs_agent/gpfs_client.py b/gpfs_agent/gpfs_client.py new file mode 100644 index 0000000..e9ea03f --- /dev/null +++ b/gpfs_agent/gpfs_client.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import json +from typing import Any, Optional + +import httpx + +from .config import Config + + +class GPFSError(RuntimeError): + def __init__(self, status: int, body: Any, method: str, url: str) -> None: + super().__init__(f"{method} {url} -> HTTP {status}") + self.status = status + self.body = body + self.method = method + self.url = url + + +class GPFSClient: + def __init__(self, cfg: Config) -> None: + self._cfg = cfg + self._client = httpx.Client( + base_url=cfg.gpfs_base, + auth=(cfg.gpfs_username, cfg.gpfs_password), + verify=cfg.verify_tls, + timeout=cfg.timeout, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "GPFSClient": + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def request( + self, + method: str, + path: str, + params: Optional[dict[str, Any]] = None, + body: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + if not path.startswith("/"): + path = "/" + path + + response = self._client.request( + method=method.upper(), + url=path, + params=params or None, + content=json.dumps(body) if body is not None else None, + ) + + text = response.text + data: Any + try: + data = response.json() if text else None + except json.JSONDecodeError: + data = None + + return { + "status": response.status_code, + "ok": response.is_success, + "url": str(response.request.url), + "method": method.upper(), + "data": data, + "text": None if data is not None else text, + } diff --git a/gpfs_agent/tools.py b/gpfs_agent/tools.py new file mode 100644 index 0000000..aa696fc --- /dev/null +++ b/gpfs_agent/tools.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import Any, Callable, Optional + +from .config import Config +from .gpfs_client import GPFSClient + + +MUTATING_METHODS = {"POST", "PUT", "DELETE"} + + +def tool_schemas(cfg: Config) -> list[dict[str, Any]]: + methods = sorted(cfg.allowed_methods) + return [ + { + "name": "gpfs_request", + "description": ( + "Call the IBM Spectrum Scale (GPFS) REST API. The base URL is " + "configured by the operator; you only supply the path after " + "the version prefix (e.g. '/filesystems', '/cluster', " + "'/nodes', '/filesystems/{fs}/filesets').\n\n" + f"Allowed HTTP methods in this session: {', '.join(methods)}. " + "Calls with other verbs will be rejected.\n\n" + "Prefer the smallest scope when a question is specific." + ), + "input_schema": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": methods, + "description": "HTTP verb.", + }, + "path": { + "type": "string", + "description": ( + "Path under the API base, starting with '/'. " + "Example: '/filesystems/gpfs0/filesets'." + ), + }, + "query": { + "type": "object", + "description": "Optional query-string parameters.", + "additionalProperties": True, + }, + "body": { + "type": "object", + "description": "Optional JSON request body.", + "additionalProperties": True, + }, + "reason": { + "type": "string", + "description": ( + "One sentence explaining why this call is needed. " + "Shown to the human operator for mutating calls." + ), + }, + }, + "required": ["method", "path", "reason"], + }, + } + ] + + +ConfirmFn = Callable[[str, dict[str, Any]], bool] + + +def _default_confirm(message: str, call: dict[str, Any]) -> bool: + print(f"\n[confirm] {message}") + print(f" {call['method']} {call['path']}") + if call.get("body"): + print(f" body: {call['body']}") + if call.get("reason"): + print(f" reason: {call['reason']}") + answer = input(" proceed? [y/N] ").strip().lower() + return answer in {"y", "yes"} + + +class ToolDispatcher: + def __init__( + self, + cfg: Config, + client: GPFSClient, + confirm: Optional[ConfirmFn] = None, + ) -> None: + self._cfg = cfg + self._client = client + self._confirm = confirm or _default_confirm + + def dispatch(self, name: str, tool_input: dict[str, Any]) -> dict[str, Any]: + if name != "gpfs_request": + return {"error": f"Unknown tool: {name}"} + + method = str(tool_input.get("method", "")).upper() + path = str(tool_input.get("path", "")) + query = tool_input.get("query") or None + body = tool_input.get("body") or None + reason = str(tool_input.get("reason", "")).strip() + + if not method or not path: + return {"error": "Both 'method' and 'path' are required."} + + if method not in self._cfg.allowed_methods: + return { + "error": ( + f"Method {method} is not allowed in this session. " + f"Allowed: {sorted(self._cfg.allowed_methods)}." + ) + } + + if not path.startswith("/"): + path = "/" + path + + if self._cfg.path_allow and not self._cfg.path_allow.search(path): + return {"error": f"Path {path!r} is not in the configured allow-list."} + if self._cfg.path_deny and self._cfg.path_deny.search(path): + return {"error": f"Path {path!r} matches the configured deny-list."} + + if method in MUTATING_METHODS and self._cfg.confirm_mutations: + ok = self._confirm( + f"Claude wants to perform a mutating {method} call.", + {"method": method, "path": path, "body": body, "reason": reason}, + ) + if not ok: + return { + "error": "Operator denied this call. Do not retry without " + "asking the user." + } + + try: + result = self._client.request(method, path, params=query, body=body) + except Exception as exc: + return {"error": f"Transport error: {exc!r}"} + + return result diff --git a/gpfs_agent/web.py b/gpfs_agent/web.py new file mode 100644 index 0000000..50281d1 --- /dev/null +++ b/gpfs_agent/web.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +from typing import Any + +import streamlit as st + +from .agent import Agent +from .config import load_config +from .gpfs_client import GPFSClient +from .tools import ToolDispatcher + + +st.set_page_config(page_title="GPFS Agent", page_icon="🗄️", layout="wide") + + +def _init_session() -> None: + if "cfg" in st.session_state: + return + try: + cfg = load_config() + except RuntimeError as exc: + st.error(f"Config error: {exc}") + st.stop() + + st.session_state.cfg = cfg + st.session_state.client = GPFSClient(cfg) + st.session_state.tool_log: list[dict[str, Any]] = [] + st.session_state.transcript: list[dict[str, str]] = [] + st.session_state.allow_mutations_session = False + + def confirm(message: str, call: dict[str, Any]) -> bool: + return bool(st.session_state.get("allow_mutations_session", False)) + + dispatcher = ToolDispatcher(st.session_state.cfg, st.session_state.client, confirm=confirm) + + def on_tool(name: str, tool_input: dict[str, Any], result: dict[str, Any]) -> None: + st.session_state.tool_log.append( + {"name": name, "input": tool_input, "result": result} + ) + + st.session_state.agent = Agent(cfg, dispatcher, on_tool_event=on_tool) + + +def _mode_label(cfg) -> str: + if cfg.allow_writes and cfg.allow_destructive: + return "writes + DELETE" + if cfg.allow_writes: + return "writes (no DELETE)" + return "read-only" + + +def _render_sidebar() -> None: + cfg = st.session_state.cfg + with st.sidebar: + st.subheader("Session") + st.markdown(f"**Model** \n`{cfg.model}`") + st.markdown(f"**API base** \n`{cfg.gpfs_base}`") + st.markdown(f"**Mode** \n{_mode_label(cfg)}") + + if cfg.allow_writes and cfg.confirm_mutations: + st.checkbox( + "Auto-approve mutations this session", + key="allow_mutations_session", + help=( + "When off, the agent's mutating calls are denied. " + "Turn this on to let it run POST/PUT" + (" / DELETE" if cfg.allow_destructive else "") + "." + ), + ) + elif not cfg.allow_writes: + st.caption("Writes disabled in .env (GPFS_ALLOW_WRITES=false).") + + st.divider() + if st.button("Reset conversation", use_container_width=True): + st.session_state.agent.reset() + st.session_state.transcript = [] + st.session_state.tool_log = [] + st.rerun() + + with st.expander("System prompt"): + st.code(st.session_state.agent.system_prompt, language="markdown") + + with st.expander(f"Tool log ({len(st.session_state.tool_log)})"): + for entry in reversed(st.session_state.tool_log[-25:]): + ti = entry["input"] + res = entry["result"] + status = res.get("status", "ERR") + ok = res.get("ok", False) + icon = "✅" if ok else "⚠️" + st.markdown( + f"{icon} `{ti.get('method','?')} {ti.get('path','?')}` → **{status}**" + ) + if ti.get("reason"): + st.caption(ti["reason"]) + with st.expander("payload"): + st.code(json.dumps(res, indent=2, default=str), language="json") + + +def _render_transcript() -> None: + for turn in st.session_state.transcript: + with st.chat_message(turn["role"]): + st.markdown(turn["content"]) + + +def _handle_user_input(prompt: str) -> None: + st.session_state.transcript.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + with st.chat_message("assistant"): + with st.spinner("Thinking…"): + try: + reply = st.session_state.agent.chat(prompt) + except Exception as exc: + reply = f"_Agent error: `{exc!r}`_" + st.markdown(reply) + st.session_state.transcript.append({"role": "assistant", "content": reply}) + + +def main() -> None: + _init_session() + _render_sidebar() + + st.title("GPFS Agent") + st.caption("Chat with your IBM Spectrum Scale cluster. Guardrails come from `.env`.") + + _render_transcript() + + prompt = st.chat_input("Ask about the cluster…") + if prompt: + _handle_user_input(prompt) + + +main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..78d4dee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +anthropic>=0.40.0 +httpx>=0.27.0 +python-dotenv>=1.0.1 +rich>=13.7.0 +streamlit>=1.39.0