diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db3c5c..7079c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,42 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **AG-UI endpoint (`POST /v1/ag-ui`)** serves the graph over the + [AG-UI protocol](https://docs.ag-ui.com), so AG-UI clients such as CopilotKit + (through its generic `HttpAgent`) can use an Agentflow agent. Off by default: set + `"ag_ui": {"enabled": true}` in `agentflow.json` and install the new `ag-ui` extra + (`pip install "10xscale-agentflow-cli[ag-ui]"`, `ag-ui-protocol>=1.0,<2`). The server + refuses to start with a clear install hint when the endpoint is enabled but the package + is missing. The route uses the `graph:stream` permission and checks `threadId` + ownership when auth is configured. Streams text and reasoning, tool calls and results, + node steps, application state (`STATE_SNAPSHOT`), and `interrupt()` pauses (`RUN_FINISHED` + with an `interrupt` outcome, answered through `RunAgentInput.resume`; `cancelled` resumes + with `None`). The thread's checkpoint is the record of the conversation, so only new user + messages and tool results from the client reach the graph. Browser tools the client sends + (`RunAgentInput.tools`, e.g. CopilotKit `useFrontendTool`) are offered to the model for that + run with no `agentflow.json` entry. `RunAgentInput.context`, `tools` and `forwardedProps` + are available to nodes as `config["ag_ui"]`. `agentflow init` writes + `"ag_ui": {"enabled": false}`, and `agentflow config` shows the new section. Requires the + `10xscale-agentflow` release that adds `interrupt()` and per-run `remote_tools`. +- **`resume` on `/v1/graph/invoke` and `/v1/graph/stream`.** Resumes a thread paused by + `interrupt()`; `messages` may be empty when `resume` is sent. +- **`remote_tools` is a server-owned run-config key.** Client config can no longer set it on + any route; only adapters whose clients bring tools by protocol (AG-UI) fill it. +- `GraphService.stream_chunks()` yields the graph stream as `StreamChunk` objects, with an + optional `server_config` for server-owned run config; `stream_graph()` (NDJSON) is now a + thin wrapper over it. + +- **`agentflow skills --validate PATH`** checks a skill directory, or a folder of + skill directories, against the Agent Skills specification (agentskills.io). + It reports errors and warnings per skill and exits `1` when any skill is + invalid. The option is repeatable, and it requires a `10xscale-agentflow` + release that ships `agentflow.core.skills.validate_skill`. +- **The bundled Agentflow skill conforms to the Agent Skills specification.** Its + `description` was invalid YAML (an unquoted `TRIGGER when:`), so strict parsers, + including Agentflow's own loader, rejected it. Codex, Claude and GitHub now all + receive one identical `SKILL.md` that references `references/...` relative to + the skill directory, instead of three copies hard-wired to one install path + each. - **Persistent full-screen application surface.** On an interactive terminal a command now runs on its own screen with branded chrome pinned in place: a header (gradient rule, identity, version, subtitle) at the top, a footer diff --git a/CLAUDE.md b/CLAUDE.md index fa3f935..e7630af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Importable package: `agentflow_cli/`. Two halves: | Path | What lives there | |---|---| | `agentflow_cli/cli/` | The Typer CLI. `main.py` (command definitions), `commands/` (one class per command: api, build, eval, init, skills, test, version), `core/` (config, output, validation), `constants.py`, `templates/` (project scaffolds: `dev/` minimal, `prod/` full) | -| `agentflow_cli/src/app/` | The FastAPI app. `main.py` + `loader.py` (build app from `agentflow.json`), `routers/` (graph, checkpointer, store, media, ping), `core/auth/`, `core/config/`, `core/middleware/` (rate_limit, security_headers, request_limits), `tasks/`, `utils/`, `worker.py` | +| `agentflow_cli/src/app/` | The FastAPI app. `main.py` + `loader.py` (build app from `agentflow.json`), `routers/` (graph, checkpointer, store, media, ping, ag_ui), `core/auth/`, `core/config/`, `core/middleware/` (rate_limit, security_headers, request_limits), `tasks/`, `utils/`, `worker.py` | Public exports from the package root (`from agentflow_cli import ...`): `BaseAuth`, `SnowFlakeIdGenerator`, `ThreadNameGenerator`. @@ -45,11 +45,11 @@ Public exports from the package root (`from agentflow_cli import ...`): `BaseAut | `agentflow build` | Generate a `Dockerfile` (and optionally `docker-compose.yml` / `k8s.yaml`) | `--output/-o`, `--force/-f`, `--python-version` (3.13), `--port`, `--docker-compose/--no-docker-compose`, `--k8s/--no-k8s`, `--service-name` | | `agentflow eval` | Run agent evaluations; discovers `*_eval.py`/`eval_*.py`, runs cases (optionally `--parallel`), writes HTML+JSON to `eval_reports/` | `--output/-o`, `--no-report`, `--threshold/-t`, `--open`, `--parallel/-p`, `--max-concurrency/-c` | | `agentflow test` | Run project tests via pytest (args after `--` forwarded verbatim) | `--coverage/-C`, `--html`, `-k`, path arg | -| `agentflow skills` | Install bundled Agentflow skills for Codex/Claude/GitHub | `--agent/-a`, `--path/-p`, `--force/-f`, `--all`, `--list/-l` | +| `agentflow skills` | Install the bundled Agentflow skill (Agent Skills spec) for Codex/Claude/GitHub, or validate skills | `--agent/-a`, `--path/-p`, `--force/-f`, `--all`, `--list/-l`, `--validate PATH` | | `agentflow version` | Show CLI + core framework version | both resolve from installed distribution metadata | | `agentflow audit` | Read-only audit of the interpreter, installed CLI/core packages, evaluation-API compatibility, `agentflow.json`, and the default port. Exits `1` on failure, `0` on warnings only, so it works as a CI gate | `-v/--verbose`, `-q/--quiet` | | `agentflow demo` | Preview the animation/timeline/progress themes with no side effects (`Diagnostics` help panel) | `--style` (all\|typing\|network\|init\|build\|eval; `play`/`api` alias to typing/network) | -| `agentflow config ` | Sub-app (`Manage` panel) for user-level preferences in `user_config.py` | dotted keys; `set` parses a JSON value, falling back to a plain string | +| `agentflow config` | Browser editor for `agentflow.json` (`Manage` panel). Loopback-only stdlib HTTP server in `cli/config_editor/` (`schema.py` lists every key, `validation.py` reuses the `graph_config` parsers, `store.py` does conflict-checked atomic writes with a `.bak`). The page is a Preact + Tailwind app whose source lives in `config-editor-ui/`; `npm run build` there writes the committed `static/app.js` and `static/app.css`, so rebuild after editing `config-editor-ui/src` | `--config/-c`, `--port/-p` (0 = any free port), `--open/--no-open` | Defaults (from `cli/constants.py`): `DEFAULT_HOST="127.0.0.1"`, `DEFAULT_PORT=8000`, `DEFAULT_CONFIG_FILE="agentflow.json"`. @@ -59,9 +59,7 @@ Root options apply to every command and are resolved in `main.root`: `--no-color`, `--progress` (auto\|tty\|plain\|json\|quiet), `--animation/--no-animation`, `--fullscreen/--no-fullscreen`, `--cwd`, `-v/--verbose` (counted), `-q/--quiet`, `--debug`, `-y/--yes`, `--non-interactive`, `-V/--version`. -`output.format`, `output.color`, and `output.progress` from the user config file -(`platformdirs`, e.g. `~/.config/agentflow/config.json`) supply the defaults; explicit -flags win. `AGENTFLOW_NO_FULLSCREEN=1` opts out of the alternate-screen surface. +`AGENTFLOW_NO_FULLSCREEN=1` opts out of the alternate-screen surface. ## `agentflow.json` (the config contract) @@ -79,6 +77,7 @@ Parsed by `agentflow_cli/src/app/core/config/graph_config.py`. Supported keys: | `store` | `"module:attr"` -> a `BaseStore` | | `redis` | Redis URL string | | `rate_limit` | Object (see below) | +| `ag_ui` | `{"enabled": bool}`, default off. Mounts `POST /v1/ag-ui` (AG-UI protocol, for CopilotKit and other AG-UI clients). Needs the `ag-ui` extra | `rate_limit` object: `enabled`, `requests` (default 100), `window` secs (60), `by` (`ip` | `global`), `backend` (`memory` | `redis` | `custom`), `trusted_proxy_headers` (honour @@ -115,7 +114,7 @@ and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand U ## HTTP + WebSocket surface (all under `/v1` except ping) - **Graph** (`tags=["Graph"]`): `POST /v1/graph/invoke`, `POST /v1/graph/stream`, - `POST /v1/graph/stop`, `POST /v1/graph/setup`, `POST /v1/graph/fix`, `GET /v1/graph`, + `POST /v1/graph/stop`, `POST /v1/graph/fix`, `GET /v1/graph`, `WS /v1/graph/ws`. - **Checkpointer / threads**: `GET/POST /v1/threads`, `GET/DELETE /v1/threads/{thread_id}`, `GET /v1/threads/{thread_id}/state`, `GET /v1/threads/{thread_id}/messages`, @@ -124,6 +123,14 @@ and for redis backend a `redis` sub-object `{ "url", "prefix" }` (or shorthand U `/v1/store/memories/forget`, `/v1/store/memories/{memory_id}`, `POST /v1/store/search`. - **Media / files** (`tags=["Files"]`): `POST /v1/files/upload`, `GET /v1/files/{file_id}`, `/{file_id}/info`, `/{file_id}/url`, `GET /v1/config/multimodal`. +- **AG-UI** (`tags=["AG-UI"]`, only when `ag_ui.enabled`): `POST /v1/ag-ui`. `routers/ag_ui/`: + `converter.py` (RunAgentInput -> new user/tool messages only; the checkpoint is the record), + `event_mapper.py` (StreamChunk -> AG-UI events), `service.py` (runs `GraphService.stream_chunks`). + `RUN_FINISHED` carries an `outcome` only for `interrupt()` pauses (CopilotKit's pinned + `@ag-ui/core` 0.0.59 rejects the newer `success`/`cancelled` outcome shapes). Browser tools + from `RunAgentInput.tools` go to the graph as the server-owned `remote_tools` run-config key + (via `GraphService.stream_chunks(..., server_config=...)`); `RunAgentInput.resume` becomes the + graph `resume` input. `GraphInputSchema.resume` also resumes over `/v1/graph/invoke|stream`. - **Ping**: `GET /ping`. Routers are wired in `routers/setup_router.py` (`init_routes`). The `a2a.py` / `a2ui.py` stubs @@ -143,7 +150,7 @@ In production: set `MODE=production`, `IS_DEBUG=false`, a non-`*` `ORIGINS`, and ## Optional extras (`pyproject.toml`) -`sentry`, `firebase`, `snowflakekit`, `redis`, `jwt`, `media` (document text extraction via +`sentry`, `firebase`, `snowflakekit`, `redis`, `jwt`, `ag-ui` (AG-UI endpoint), `media` (document text extraction via `textxtract`), `gcloud` (Cloud Logging), `otel` (includes FastAPI instrumentation + OTLP exporter). ## Development workflow diff --git a/README.md b/README.md index 19ef187..a511e3b 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ agentflow skills --all # install for every supported agent agentflow skills --agent claude # install for one agentflow skills --list # show supported agents agentflow skills --force # overwrite an existing install +agentflow skills --validate ./.agents/skills # check skills against the Agent Skills spec ``` Run without flags to get a checklist of the supported agents. Each row shows @@ -225,20 +226,14 @@ run — so it works as a CI gate. ### `agentflow config` -Manage cross-platform user-level CLI preferences, stored as JSON in the per-user -config directory (`platformdirs`, e.g. `~/.config/agentflow/config.json` on Linux). -Keys are dot-separated; the output preferences read at startup are -`output.format` (`human`, `plain`, `json`, `jsonl`), `output.color` (`auto`, -`always`, `never`), and `output.progress` (`auto`, `tty`, `plain`, `json`, `quiet`). -Command-line flags always win over the stored values. +Open a local browser editor for `agentflow.json`: switch optional sections (auth, +authorization, rate limiting, observability, ...) on or off, fill in their fields, +**Validate** without saving, and **Save** to write the file. Saving is blocked while +there are errors, and the previous file is kept as `agentflow.json.bak`. ```bash -agentflow config path # where preferences are stored -agentflow config list # every stored preference -agentflow config set output.format plain -agentflow config get output.format -agentflow config unset output.format -agentflow config validate # parse the file and check known keys +agentflow config # edit ./agentflow.json +agentflow config -c path/to/agentflow.json --port 8765 --no-open ``` ### `agentflow demo` @@ -264,6 +259,7 @@ The configuration file (`agentflow.json`) defines your agent, authentication, an "checkpointer": null, "injectq": null, "store": null, + "remote_tools": [], "redis": null, "thread_name_generator": null, "rate_limit": {} @@ -281,6 +277,7 @@ The configuration file (`agentflow.json`) defines your agent, authentication, an | `checkpointer` | string \| null | Path to a custom checkpointer | | `injectq` | string \| null | Path to an InjectQ container | | `store` | string \| null | Path to a data store | +| `remote_tools` | array | Trusted schemas for tools executed by clients; attached at startup | | `redis` | string \| null | Redis connection URL | | `rate_limit` | object \| null | Sliding-window rate limiting configuration | | `thread_name_generator` | string \| null | Path to a custom thread name generator | diff --git a/agentflow_cli/cli/commands/audit.py b/agentflow_cli/cli/commands/audit.py index 973e89e..b872484 100644 --- a/agentflow_cli/cli/commands/audit.py +++ b/agentflow_cli/cli/commands/audit.py @@ -33,7 +33,8 @@ from typing import Any from agentflow_cli.cli.commands import BaseCommand -from agentflow_cli.cli.constants import DEFAULT_PORT +from agentflow_cli.cli.constants import DEFAULT_CONFIG_FILE, DEFAULT_PORT +from agentflow_cli.src.app.core.config.graph_config import validate_remote_tools @dataclass(frozen=True) @@ -66,13 +67,14 @@ class AuditCommand(BaseCommand): evaluation The installed core exposes the evaluation symbols that ``agentflow eval`` imports, catching a CLI/core version skew before it becomes an ImportError mid-run. - config ``agentflow.json`` exists here, parses, and declares an - ``agent`` key in ``module:attribute`` form. + config ``agentflow.json`` exists here, parses, declares an + ``agent`` key in ``module:attribute`` form, and contains + valid ``remote_tools`` definitions if present. port The default API port is free to bind. ============ ========================================================== """ - def execute(self, **kwargs: Any) -> int: + def execute(self, *, config: str = DEFAULT_CONFIG_FILE, **kwargs: Any) -> int: """Run every check and report the outcome. Returns: @@ -95,7 +97,7 @@ def execute(self, **kwargs: Any) -> int: ("cli", "CLI package", lambda: self._package_check("10xscale-agentflow-cli")), ("core", "Core framework", lambda: self._package_check("10xscale-agentflow")), ("evaluation", "Evaluation API", self._evaluation_api_check), - ("config", "Project configuration", self._config_check), + ("config", "Project configuration", lambda: self._config_check(config)), ("port", f"Port {DEFAULT_PORT}", lambda: self._port_check(DEFAULT_PORT)), ) @@ -165,25 +167,31 @@ def _evaluation_api_check() -> Diagnostic: return Diagnostic("Evaluation API", "pass", "compatible") @staticmethod - def _config_check() -> Diagnostic: + def _config_check(config: str = DEFAULT_CONFIG_FILE) -> Diagnostic: """Validate ``agentflow.json`` in the working directory. - Warns rather than fails when the file is absent, because the audit is - also useful outside a project directory. A file that exists but cannot - be parsed, or that omits a ``module:attribute`` ``agent`` key, is a - hard failure. + Warns when the default file is absent, because audit is also useful + outside a project directory. A missing explicit path, invalid JSON, + missing ``agent``, or invalid remote tool definition is a hard failure. """ - config_path = Path.cwd() / "agentflow.json" + config_path = Path(config).resolve() if not config_path.exists(): - return Diagnostic("Project config", "warn", f"not found at {config_path}") + status = "warn" if config == DEFAULT_CONFIG_FILE else "fail" + return Diagnostic("Project config", status, f"not found at {config_path}") try: data = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: return Diagnostic("Project config", "fail", str(exc)) + if not isinstance(data, dict): + return Diagnostic("Project config", "fail", "agentflow.json must be an object") agent = data.get("agent") if not isinstance(agent, str) or ":" not in agent: return Diagnostic("Project config", "fail", "'agent' must be a module:attribute string") - return Diagnostic("Project config", "pass", str(config_path)) + try: + tools = validate_remote_tools(data.get("remote_tools", [])) + except ValueError as exc: + return Diagnostic("Project config", "fail", str(exc)) + return Diagnostic("Project config", "pass", f"{config_path} ({len(tools)} remote tools)") @staticmethod def _port_check(port: int) -> Diagnostic: diff --git a/agentflow_cli/cli/commands/build.py b/agentflow_cli/cli/commands/build.py index cf2e237..4902902 100644 --- a/agentflow_cli/cli/commands/build.py +++ b/agentflow_cli/cli/commands/build.py @@ -18,7 +18,7 @@ class BuildCommand(BaseCommand): """Command to generate Dockerfile and docker-compose.yml for the application.""" - def execute( + def execute( # noqa: PLR0915 self, output_file: str = "Dockerfile", force: bool = False, @@ -83,11 +83,16 @@ def execute( with timeline.step("sources") as step: requirements_files, requirements_file = self._discover_requirements(Path.cwd()) - step.detail( - f"requirements: {requirements_files[0]}" - if requirements_files - else "no requirements.txt — installing agentflow-cli from PyPI" - ) + has_pyproject = (Path.cwd() / "pyproject.toml").is_file() + if requirements_files: + step.detail(f"requirements: {requirements_files[0]}") + elif has_pyproject: + step.detail("dependencies: pyproject.toml") + else: + step.detail( + "no requirements.txt or pyproject.toml; installing " + "10xscale-agentflow-cli from PyPI" + ) with timeline.step("dockerfile") as step: dockerfile_content = generate_dockerfile_content( @@ -96,6 +101,7 @@ def execute( requirements_file=requirements_file, has_requirements=bool(requirements_files), omit_cmd=docker_compose, + has_pyproject=has_pyproject, ) self._write_dockerfile(output_path, dockerfile_content) step.detail(str(output_path)) diff --git a/agentflow_cli/cli/commands/config.py b/agentflow_cli/cli/commands/config.py new file mode 100644 index 0000000..eec937f --- /dev/null +++ b/agentflow_cli/cli/commands/config.py @@ -0,0 +1,56 @@ +"""Config command implementation: a browser editor for ``agentflow.json``.""" + +import webbrowser +from pathlib import Path +from typing import Any + +from agentflow_cli.cli.commands import BaseCommand +from agentflow_cli.cli.config_editor import ConfigEditorServer +from agentflow_cli.cli.constants import DEFAULT_CONFIG_FILE +from agentflow_cli.cli.exceptions import ServerError + + +class ConfigCommand(BaseCommand): + """Serve the config editor until the user stops it with Ctrl+C.""" + + def execute( + self, + config: str = DEFAULT_CONFIG_FILE, + port: int = 0, + open_browser: bool = True, + **kwargs: Any, + ) -> int: + config_path = Path(config).expanduser().resolve() + try: + server = ConfigEditorServer(config_path, port=port) + except OSError as exc: + return self.handle_error( + ServerError( + f"Could not start the config editor: {exc}", host="127.0.0.1", port=port + ) + ) + + self.output.command_header( + "config", + "Edit agentflow.json in your browser", + hint="Ctrl+C to stop the config editor", + ) + self.output.completion_screen( + "Config editor running", + "Open the link below to edit, validate, and save your configuration.", + details={ + "Editor": server.url, + "File": f"{config_path}{'' if config_path.exists() else ' (new)'}", + }, + next_steps=["Press Ctrl+C to stop the editor when you are done."], + ) + if open_browser: + webbrowser.open_new_tab(server.url) + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.shutdown() + return 0 diff --git a/agentflow_cli/cli/commands/init.py b/agentflow_cli/cli/commands/init.py index a4c995f..7063e6f 100644 --- a/agentflow_cli/cli/commands/init.py +++ b/agentflow_cli/cli/commands/init.py @@ -422,6 +422,8 @@ def _build_config(self, context: dict, is_prod: bool) -> dict: "env": ".env", "auth": None, "thread_name_generator": None, + # AG-UI endpoint for clients such as CopilotKit. Off until turned on here. + "ag_ui": {"enabled": False}, } if not is_prod: @@ -453,7 +455,7 @@ def _build_config(self, context: dict, is_prod: bool) -> dict: "window": context.get("rl_window", 60), "by": context.get("rl_by", "ip"), "trusted_proxy_headers": context.get("rl_trusted_proxy", False), - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"], + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"], } return config diff --git a/agentflow_cli/cli/commands/skills.py b/agentflow_cli/cli/commands/skills.py index a3dfaf7..4bcf554 100644 --- a/agentflow_cli/cli/commands/skills.py +++ b/agentflow_cli/cli/commands/skills.py @@ -43,6 +43,11 @@ def kind(self) -> str: return "file+folder" +# Every agent gets the same spec-conformant skill folder (SKILL.md + references/); +# the SKILL.md uses paths relative to the skill directory, so one copy works in +# any install location. +_SKILL_SOURCE = "agentflow" + _TARGETS: tuple[_AgentTarget, ...] = ( _AgentTarget( name="Codex", @@ -50,14 +55,9 @@ def kind(self) -> str: _InstallArtifact( kind="folder", install_relpath=".agents/skills/agentflow", - source_relpath="agent-skills", + source_relpath=_SKILL_SOURCE, manifest=True, ), - _InstallArtifact( - kind="file", - install_relpath=".agents/skills/agentflow/SKILL.md", - source_relpath="codex/SKILL.md", - ), ), ), _AgentTarget( @@ -66,14 +66,9 @@ def kind(self) -> str: _InstallArtifact( kind="folder", install_relpath=".claude/skills/agentflow", - source_relpath="agent-skills", + source_relpath=_SKILL_SOURCE, manifest=True, ), - _InstallArtifact( - kind="file", - install_relpath=".claude/skills/agentflow/SKILL.md", - source_relpath="claude/SKILL.md", - ), ), ), _AgentTarget( @@ -87,14 +82,9 @@ def kind(self) -> str: _InstallArtifact( kind="folder", install_relpath=".github/skills/agentflow", - source_relpath="agent-skills", + source_relpath=_SKILL_SOURCE, manifest=True, ), - _InstallArtifact( - kind="file", - install_relpath=".github/skills/agentflow/SKILL.md", - source_relpath="copilot/SKILL.md", - ), ), ), ) @@ -108,7 +98,7 @@ def kind(self) -> str: class SkillsCommand(BaseCommand): - """Command to install bundled Agentflow skills for supported agents.""" + """Install bundled Agentflow skills, or validate skills against the spec.""" def execute( self, @@ -117,6 +107,7 @@ def execute( force: bool = False, all_agents: bool = False, list_agents: bool = False, + validate_paths: list[str] | None = None, **kwargs: Any, ) -> int: """Execute the skills command. @@ -127,11 +118,39 @@ def execute( force: Overwrite an existing installation. all_agents: Install for every supported agent. list_agents: Print supported agents and exit. + validate_paths: Skill directories (or folders of skills) to validate + against the Agent Skills specification instead of installing. **kwargs: Additional arguments. Returns: Exit code. """ + if validate_paths: + return self._run_validate(validate_paths) + return self._run_install( + agent, path, force=force, all_agents=all_agents, list_agents=list_agents + ) + + def _run_validate(self, validate_paths: list[str]) -> int: + try: + self.output.command_header( + "skills", + "Validate skills against the Agent Skills specification (agentskills.io).", + color="magenta", + ) + return self._validate(validate_paths) + except ValidationError as e: + return self.handle_error(e) + + def _run_install( + self, + agent: str | None, + path: str, + *, + force: bool, + all_agents: bool, + list_agents: bool, + ) -> int: try: self.output.command_header( "skills", @@ -179,6 +198,62 @@ def execute( file_error.__cause__ = e return self.handle_error(file_error) + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def _validate(self, paths: list[str]) -> int: + """Validate each skill found under *paths*. Returns 1 if any is invalid.""" + try: + # Not in any released core yet (<= 0.9.2); the ImportError branch covers that. + from agentflow.core.skills import validate_skill # type: ignore[attr-defined] + from agentflow.core.skills.loader import ( # type: ignore[attr-defined] + iter_skill_dirs, + ) + except ImportError as exc: + raise ValidationError( + "Skill validation needs a 10xscale-agentflow release that ships " + "agentflow.core.skills.validate_skill. Upgrade 10xscale-agentflow.", + field="validate", + ) from exc + + rows: list[list[str]] = [] + invalid = 0 + checked = 0 + for raw_path in paths: + skill_dirs = iter_skill_dirs(raw_path) + if not skill_dirs: + self.output.error( + f"No skill found at {raw_path}: expected a SKILL.md in it or in its " + "subdirectories." + ) + invalid += 1 + continue + + for skill_dir in skill_dirs: + checked += 1 + issues = validate_skill(skill_dir) + errors = [issue for issue in issues if issue.level == "error"] + warnings = [issue for issue in issues if issue.level == "warning"] + for issue in errors: + self.output.error(f"{skill_dir.name}: {issue.message}") + for issue in warnings: + self.output.warning(f"{skill_dir.name}: {issue.message}") + if errors: + invalid += 1 + status = "invalid" if errors else "valid with warnings" if warnings else "valid" + rows.append([str(skill_dir), status, str(len(errors)), str(len(warnings))]) + + if rows: + self.output.print_table( + ["Skill", "Status", "Errors", "Warnings"], rows, title="Agent Skills validation" + ) + if invalid: + self.output.error(f"{invalid} skill(s) failed validation.") + return 1 + self.output.success(f"{checked} skill(s) conform to the Agent Skills specification.") + return 0 + # ------------------------------------------------------------------ # Selection # ------------------------------------------------------------------ @@ -280,7 +355,7 @@ def _install_targets( if strict: paths = ", ".join(str(dest) for dest in existing) raise FileOperationError( - f"Skill already installed at {paths}. " "Use --force to overwrite.", + f"Skill already installed at {paths}. Use --force to overwrite.", file_path=str(existing[0]), ) step.skip("already installed — pass --force to overwrite") diff --git a/agentflow_cli/cli/config_editor/__init__.py b/agentflow_cli/cli/config_editor/__init__.py new file mode 100644 index 0000000..cf7c759 --- /dev/null +++ b/agentflow_cli/cli/config_editor/__init__.py @@ -0,0 +1,15 @@ +"""Browser-based editor for ``agentflow.json`` served by ``agentflow config``.""" + +from agentflow_cli.cli.config_editor.schema import build_schema +from agentflow_cli.cli.config_editor.server import ConfigEditorServer +from agentflow_cli.cli.config_editor.store import ConfigConflictError, ConfigFileStore +from agentflow_cli.cli.config_editor.validation import validate_config + + +__all__ = [ + "ConfigConflictError", + "ConfigEditorServer", + "ConfigFileStore", + "build_schema", + "validate_config", +] diff --git a/agentflow_cli/cli/config_editor/schema.py b/agentflow_cli/cli/config_editor/schema.py new file mode 100644 index 0000000..de02867 --- /dev/null +++ b/agentflow_cli/cli/config_editor/schema.py @@ -0,0 +1,566 @@ +"""Declarative description of every ``agentflow.json`` option the editor shows. + +The browser page renders itself from :func:`build_schema`, so adding a new +configuration key only needs a new entry here (plus a rule in ``validation``). + +Section shape:: + + { + "id": "rate_limit", # stable id, used by validation issues + "title": "Rate limiting", + "description": "...", + "key": "rate_limit", # top-level key, or None for root-level fields + "optional": True, # renders an on/off toggle for the whole key + "off_summary": "...", # what happens while the key is absent + "default": {...}, # value written when the toggle is switched on + "widget": None, # custom renderer: "auth", "authorization", "remote_tools" + "fields": [...], # generic fields, paths are relative to the root + "notes": ["..."], # extra hints (secrets that belong in .env, ...) + } + +Field shape:: + + { + "path": ["rate_limit", "redis", "url"], + "label": "Redis URL", + "type": "text" | "number" | "bool" | "select" | "list", + "help": "...", + "placeholder": "...", + "options": [...], # select only + "min": 0, + "step": 1, # number only + "required": False, + "default": ..., # value the server assumes when the key is absent + "group": "Backend", # fields sharing a group render together + "show_if": {"path": [...], "equals": value}, + } +""" + +from __future__ import annotations + +import copy +from typing import Any + + +DOCS_URL = "https://agentflow.10xscale.ai/docs/reference/api-cli/configuration" + +# Mirrors authorization.all_scopes(); kept in sync by a unit test so the +# editor never imports the core framework just to render suggestions. +AUTHORIZATION_SCOPES: tuple[str, ...] = ( + "checkpointer:delete", + "checkpointer:read", + "checkpointer:write", + "config:read", + "files:read", + "files:upload", + "graph:fix", + "graph:invoke", + "graph:read", + "graph:setup", + "graph:stop", + "graph:stream", + "store:delete", + "store:read", + "store:write", +) + +BUILTIN_AUTHORIZATION: tuple[str, ...] = ("ownership", "allow_all", "default", "none") + +# Keys read by the CLI or the API server. Anything else is preserved but flagged. +KNOWN_TOP_LEVEL_KEYS: frozenset[str] = frozenset( + { + "agent", + "env", + "auth", + "authorization", + "rate_limit", + "websocket", + "ag_ui", + "observability", + "injectq", + "store", + "checkpointer", + "thread_name_generator", + "redis", + "remote_tools", + "test", + "evaluation", + } +) + +_SECTIONS: list[dict[str, Any]] = [ + { + "id": "core", + "title": "Core", + "description": "The compiled graph the server runs and the dotenv file it loads.", + "key": None, + "optional": False, + "fields": [ + { + "path": ["agent"], + "label": "Agent graph", + "type": "text", + "required": True, + "placeholder": "graph.react:app", + "help": "Import path of the compiled graph, as module:attribute.", + }, + { + "path": ["env"], + "label": "Env file", + "type": "text", + "placeholder": ".env", + "help": "Dotenv file loaded before the graph starts. Leave empty to skip.", + }, + ], + }, + { + "id": "auth", + "title": "Authentication", + "description": "Who is calling the API. Leave off for local development.", + "key": "auth", + "optional": True, + "off_summary": "Every request is accepted without authentication.", + "default": "jwt", + "widget": "auth", + "fields": [], + "notes": [ + "JWT needs JWT_SECRET_KEY and JWT_ALGORITHM in your .env file.", + "Custom auth points at a BaseAuth subclass, as module:attribute.", + ], + }, + { + "id": "authorization", + "title": "Authorization", + "description": ( + "What an authenticated user may do. When off, production uses ownership " + "and development allows everything." + ), + "key": "authorization", + "optional": True, + "off_summary": "Production enforces ownership checks; development allows everything.", + "default": "ownership", + "widget": "authorization", + "fields": [], + "scopes": list(AUTHORIZATION_SCOPES), + }, + { + "id": "rate_limit", + "title": "Rate limiting", + "description": "Sliding-window request limits applied to every REST call.", + "key": "rate_limit", + "optional": True, + "off_summary": "Requests are not rate limited.", + "default": { + "enabled": True, + "backend": "memory", + "requests": 100, + "window": 60, + "by": "ip", + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"], + }, + "fields": [ + { + "path": ["rate_limit", "enabled"], + "group": "Limits", + "default": True, + "label": "Enabled", + "type": "bool", + "help": "Turn off to keep the settings without enforcing them.", + }, + { + "path": ["rate_limit", "requests"], + "group": "Limits", + "label": "Requests per window", + "type": "number", + "min": 1, + "step": 1, + "placeholder": "100", + }, + { + "path": ["rate_limit", "window"], + "group": "Limits", + "label": "Window (seconds)", + "type": "number", + "min": 1, + "step": 1, + "placeholder": "60", + }, + { + "path": ["rate_limit", "by"], + "group": "Limits", + "default": "ip", + "label": "Limit by", + "type": "select", + "options": ["ip", "user", "global"], + "help": "Count requests per client IP, per authenticated user, or all together.", + }, + { + "path": ["rate_limit", "backend"], + "group": "Backend", + "default": "memory", + "label": "Backend", + "type": "select", + "options": ["memory", "redis", "custom"], + "help": ( + "memory counts per process. Use redis for multiple workers. custom " + "expects a BaseRateLimitBackend bound in InjectQ." + ), + }, + { + "path": ["rate_limit", "redis", "url"], + "group": "Backend", + "label": "Redis URL", + "type": "text", + "placeholder": "${REDIS_URL}", + "help": "Supports $VAR and ${VAR}. Not needed if Redis is bound in InjectQ.", + "show_if": {"path": ["rate_limit", "backend"], "equals": "redis"}, + }, + { + "path": ["rate_limit", "redis", "prefix"], + "group": "Backend", + "label": "Redis key prefix", + "type": "text", + "placeholder": "agentflow:rate-limit", + "show_if": {"path": ["rate_limit", "backend"], "equals": "redis"}, + }, + { + "path": ["rate_limit", "fail_open"], + "group": "Backend", + "default": True, + "label": "Fail open", + "type": "bool", + "help": "Allow requests when the backend errors. Off means deny.", + }, + { + "path": ["rate_limit", "trusted_proxy_headers"], + "group": "Proxy", + "default": False, + "label": "Trust X-Forwarded-For", + "type": "bool", + "help": "Only when the app sits behind a proxy you control.", + }, + { + "path": ["rate_limit", "trusted_proxy_hops"], + "group": "Proxy", + "label": "Trusted proxy hops", + "type": "number", + "min": 1, + "step": 1, + "placeholder": "1", + "show_if": {"path": ["rate_limit", "trusted_proxy_headers"], "equals": True}, + }, + { + "path": ["rate_limit", "trusted_proxies"], + "group": "Proxy", + "label": "Trusted proxy networks", + "type": "list", + "placeholder": "10.0.0.0/8", + "help": ( + "IPs or CIDR ranges your proxy connects from. X-Forwarded-For is ignored " + "for anyone else, so clients that reach the app directly cannot spoof it." + ), + "show_if": {"path": ["rate_limit", "trusted_proxy_headers"], "equals": True}, + }, + { + "path": ["rate_limit", "exclude_paths"], + "group": "Excluded paths", + "label": "Excluded paths", + "type": "list", + "placeholder": "/ping", + }, + ], + }, + { + "id": "websocket", + "title": "WebSocket", + "description": "Caps on concurrent realtime and streaming connections, per process.", + "key": "websocket", + "optional": True, + "off_summary": "Concurrent WebSocket connections are not capped.", + "default": {"max_connections": 1000, "max_connections_per_user": 10}, + "fields": [ + { + "path": ["websocket", "max_connections"], + "label": "Max connections", + "type": "number", + "min": 0, + "step": 1, + "placeholder": "1000", + "help": "Per worker process. Defaults to 1000; 0 means unlimited.", + }, + { + "path": ["websocket", "max_connections_per_user"], + "label": "Max connections per user", + "type": "number", + "min": 0, + "step": 1, + "placeholder": "10", + "help": "Stops one account holding every slot. Defaults to 10; 0 means unlimited.", + }, + { + "path": ["websocket", "realtime_models"], + "label": "Realtime models clients may pick", + "type": "list", + "placeholder": "gemini-2.5-flash-live", + "help": ( + "Models a /v1/graph/live client may request. Any other request uses the " + "agent's own model. Empty means clients cannot choose." + ), + }, + ], + }, + { + "id": "ag_ui", + "title": "AG-UI", + "description": "Serve the graph over the AG-UI protocol, for clients such as CopilotKit.", + "key": "ag_ui", + "optional": True, + "off_summary": "The AG-UI endpoint (POST /v1/ag-ui) is not mounted.", + "default": {"enabled": True}, + "fields": [ + { + "path": ["ag_ui", "enabled"], + "default": False, + "label": "Enabled", + "type": "bool", + "help": "Mounts POST /v1/ag-ui. It uses the same auth as /v1/graph/stream.", + }, + { + "path": ["ag_ui", "allow_client_tools"], + "default": True, + "label": "Allow client tools", + "type": "bool", + "help": ( + "Offer the tools a client sends (e.g. CopilotKit useFrontendTool) to the " + "model for that run. Off: only tools listed in remote_tools." + ), + }, + ], + "notes": [ + 'Needs the ag-ui extra: pip install "10xscale-agentflow-cli[ag-ui]".', + "Client tools run in the caller's browser, cannot reuse a server tool's name, and are " + "size-limited.", + ], + }, + { + "id": "observability", + "title": "Observability", + "description": "Trace graph runs to Logfire and/or LangSmith.", + "key": "observability", + "optional": True, + "off_summary": "Graph runs are not traced.", + "default": { + "level": "standard", + "logfire": {"enabled": False}, + "langsmith": {"enabled": False}, + }, + "fields": [ + { + "path": ["observability", "level"], + "group": "Tracing", + "default": "standard", + "label": "Level", + "type": "select", + "options": ["spans", "standard", "full"], + "help": "full records prompts and tool I/O and may contain PII.", + }, + { + "path": ["observability", "logfire", "enabled"], + "group": "Logfire", + "default": False, + "label": "Logfire", + "type": "bool", + }, + { + "path": ["observability", "logfire", "service_name"], + "group": "Logfire", + "label": "Logfire service name", + "type": "text", + "placeholder": "my-agent", + "show_if": {"path": ["observability", "logfire", "enabled"], "equals": True}, + }, + { + "path": ["observability", "logfire", "send_to_logfire"], + "group": "Logfire", + "default": True, + "label": "Send to Logfire", + "type": "bool", + "show_if": {"path": ["observability", "logfire", "enabled"], "equals": True}, + }, + { + "path": ["observability", "logfire", "console"], + "group": "Logfire", + "default": False, + "label": "Print spans to console", + "type": "bool", + "show_if": {"path": ["observability", "logfire", "enabled"], "equals": True}, + }, + { + "path": ["observability", "langsmith", "enabled"], + "group": "LangSmith", + "default": False, + "label": "LangSmith", + "type": "bool", + }, + { + "path": ["observability", "langsmith", "project"], + "group": "LangSmith", + "label": "LangSmith project", + "type": "text", + "placeholder": "my-agent", + "show_if": {"path": ["observability", "langsmith", "enabled"], "equals": True}, + }, + { + "path": ["observability", "langsmith", "endpoint"], + "group": "LangSmith", + "label": "LangSmith endpoint", + "type": "text", + "placeholder": "https://api.smith.langchain.com/otel", + "show_if": {"path": ["observability", "langsmith", "enabled"], "equals": True}, + }, + ], + "notes": ["Tokens stay in .env: LOGFIRE_TOKEN and LANGSMITH_API_KEY."], + }, + { + "id": "plugins", + "title": "Plugins", + "description": "Optional objects loaded by import path. Leave a field empty to skip it.", + "key": None, + "optional": False, + "fields": [ + { + "path": ["injectq"], + "group": "Import paths", + "label": "InjectQ container", + "type": "text", + "placeholder": "graph.dependencies:container", + }, + { + "path": ["store"], + "group": "Import paths", + "label": "Memory store", + "type": "text", + "placeholder": "graph.dependencies:store", + "help": "A BaseStore instance for long-term memory.", + }, + { + "path": ["checkpointer"], + "group": "Import paths", + "label": "Checkpointer", + "type": "text", + "placeholder": "graph.dependencies:checkpointer", + "help": ( + "Not applied by the API server yet. Pass the checkpointer to " + "graph.compile() instead." + ), + }, + { + "path": ["thread_name_generator"], + "group": "Import paths", + "label": "Thread name generator", + "type": "text", + "placeholder": "graph.thread_name_generator:MyNameGenerator", + }, + { + "path": ["redis"], + "group": "Connections", + "label": "Redis URL", + "type": "text", + "placeholder": "redis://localhost:6379/0", + "help": ( + "Shared cache for ownership checks. Used as-is: $VAR is not expanded " + "here, so leave it empty to fall back to REDIS_URL." + ), + }, + ], + }, + { + "id": "remote_tools", + "title": "Remote tools", + "description": "Tools the model can call that run in the client, not on the server.", + "key": "remote_tools", + "optional": True, + "off_summary": "The model can only call tools that run on the server.", + "default": [], + "widget": "remote_tools", + "fields": [], + }, + { + "id": "test", + "title": "Testing", + "description": "Defaults for agentflow test.", + "key": "test", + "optional": True, + "off_summary": "agentflow test uses its built-in defaults.", + "default": {"path": "tests", "coverage": False}, + "fields": [ + {"path": ["test", "path"], "label": "Tests path", "type": "text"}, + {"path": ["test", "coverage"], "label": "Coverage", "type": "bool", "default": False}, + { + "path": ["test", "coverage_threshold"], + "label": "Coverage threshold (%)", + "type": "number", + "min": 0, + "step": 1, + "placeholder": "80", + "show_if": {"path": ["test", "coverage"], "equals": True}, + }, + ], + }, + { + "id": "evaluation", + "title": "Evaluation", + "description": "Defaults for agentflow eval.", + "key": "evaluation", + "optional": True, + "off_summary": "agentflow eval uses its built-in defaults.", + "default": { + "directory": "evals", + "output_dir": "eval_reports", + "threshold": 0.75, + "parallel": False, + "max_concurrency": 4, + }, + "fields": [ + {"path": ["evaluation", "directory"], "label": "Eval directory", "type": "text"}, + {"path": ["evaluation", "output_dir"], "label": "Report directory", "type": "text"}, + { + "path": ["evaluation", "threshold"], + "label": "Pass threshold", + "type": "number", + "min": 0, + "step": 0.05, + "help": "Between 0 and 1.", + }, + { + "path": ["evaluation", "parallel"], + "label": "Run in parallel", + "type": "bool", + "default": False, + }, + { + "path": ["evaluation", "max_concurrency"], + "label": "Max concurrency", + "type": "number", + "min": 1, + "step": 1, + "show_if": {"path": ["evaluation", "parallel"], "equals": True}, + }, + ], + }, +] + + +def build_schema() -> dict[str, Any]: + """Return a fresh, JSON-serialisable copy of the editor schema.""" + return {"docs_url": DOCS_URL, "sections": copy.deepcopy(_SECTIONS)} + + +def section_for_key(key: str) -> str: + """Map a top-level config key to the id of the section that renders it.""" + for section in _SECTIONS: + if section["key"] == key: + return section["id"] + if section["key"] is None and any(f["path"][0] == key for f in section["fields"]): + return section["id"] + return "core" diff --git a/agentflow_cli/cli/config_editor/server.py b/agentflow_cli/cli/config_editor/server.py new file mode 100644 index 0000000..42a8e33 --- /dev/null +++ b/agentflow_cli/cli/config_editor/server.py @@ -0,0 +1,231 @@ +"""Loopback-only HTTP server behind ``agentflow config``. + +Routes: + GET / the editor page + GET /app.js the page's bundled script (built from config-editor-ui/) + GET /app.css the page's compiled Tailwind stylesheet + GET /api/state current file, schema, and validation issues + POST /api/validate validate a config without saving it + POST /api/save validate, then write the config file + +Every ``/api`` call must carry the per-run token (sent by the page from the URL +fragment) and a loopback ``Host`` header. That keeps other sites open in the same +browser from reading or overwriting the file, including via DNS rebinding. +""" + +from __future__ import annotations + +import hmac +import json +import secrets +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from importlib.resources import files +from pathlib import Path +from typing import Any + +from agentflow_cli.cli.config_editor.schema import build_schema +from agentflow_cli.cli.config_editor.store import ConfigConflictError, ConfigFileStore +from agentflow_cli.cli.config_editor.validation import has_errors, validate_config + + +LOOPBACK_HOST = "127.0.0.1" +TOKEN_HEADER = "X-Agentflow-Token" # noqa: S105 - a header name, not a secret # nosec B105 +MAX_BODY_BYTES = 1_000_000 +NEW_FILE_TEMPLATE: dict[str, Any] = {"agent": "graph.agent:app", "env": ".env"} + +# Static files the page loads: route -> (file under static/, content type). +_STATIC_FILES: dict[str, tuple[str, str]] = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/app.css": ("app.css", "text/css; charset=utf-8"), +} + +_CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src https://fonts.gstatic.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'" +) + + +class ConfigEditorServer: + """Serve the editor for one config file on an ephemeral loopback port.""" + + def __init__(self, config_path: Path, port: int = 0, token: str | None = None) -> None: + self.store = ConfigFileStore(config_path) + self.token = token or secrets.token_urlsafe(24) + self._httpd = ThreadingHTTPServer((LOOPBACK_HOST, port), _Handler) + self._httpd.daemon_threads = True + self._httpd.editor = self # type: ignore[attr-defined] + self._thread: threading.Thread | None = None + + @property + def port(self) -> int: + return int(self._httpd.server_address[1]) + + @property + def url(self) -> str: + return f"http://{LOOPBACK_HOST}:{self.port}/#token={self.token}" + + def serve_forever(self) -> None: + self._httpd.serve_forever() + + def start_in_background(self) -> None: + self._thread = threading.Thread( + target=self._httpd.serve_forever, name="agentflow-config-editor", daemon=True + ) + self._thread.start() + + def shutdown(self) -> None: + if self._thread is not None: + self._httpd.shutdown() + self._thread.join(timeout=5) + self._thread = None + self._httpd.server_close() + + # -- request handling --------------------------------------------------- + + def state(self) -> dict[str, Any]: + loaded = self.store.load() + config = loaded.config + issues = validate_config(config, self.store.path.parent) if config is not None else [] + return { + "path": str(self.store.path), + "exists": loaded.version is not None, + "version": loaded.version, + "parse_error": loaded.parse_error, + "config": config if config is not None else dict(NEW_FILE_TEMPLATE), + "schema": build_schema(), + "issues": issues, + } + + def validate(self, config: Any) -> dict[str, Any]: + return {"issues": validate_config(config, self.store.path.parent)} + + def save(self, config: Any, version: str | None) -> tuple[HTTPStatus, dict[str, Any]]: + issues = validate_config(config, self.store.path.parent) + if has_errors(issues): + return HTTPStatus.UNPROCESSABLE_ENTITY, { + "error": "Fix the errors before saving.", + "issues": issues, + } + try: + new_version = self.store.save(config, version) + except ConfigConflictError as exc: + return HTTPStatus.CONFLICT, {"error": str(exc), "issues": issues} + except OSError as exc: + return HTTPStatus.INTERNAL_SERVER_ERROR, { + "error": f"Could not write {self.store.path}: {exc}", + "issues": issues, + } + backup = self.store.backup_path + return HTTPStatus.OK, { + "version": new_version, + "config": self.store.load().config, # as written, in the file's key order + "path": str(self.store.path), + "backup": str(backup) if backup.exists() else None, + "issues": issues, + } + + def allowed_hosts(self) -> set[str]: + return {f"{LOOPBACK_HOST}:{self.port}", f"localhost:{self.port}"} + + +class _Handler(BaseHTTPRequestHandler): + server_version = "AgentflowConfig" + + @property + def editor(self) -> ConfigEditorServer: + return self.server.editor # type: ignore[attr-defined] + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + """Keep the terminal quiet; the CLI prints its own status.""" + + def do_GET(self) -> None: + if not self._host_allowed(): + return + route = self.path.split("?", 1)[0] + if route in _STATIC_FILES: + name, content_type = _STATIC_FILES[route] + asset = files("agentflow_cli.cli.config_editor").joinpath("static", name) + self._send(HTTPStatus.OK, asset.read_bytes(), content_type) + elif route == "/api/state": + if self._authorized(): + self._send_json(HTTPStatus.OK, self.editor.state()) + else: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + + def do_POST(self) -> None: + if not self._host_allowed() or not self._authorized(): + return + body = self._read_json() + if body is None: + return + route = self.path.split("?", 1)[0] + if route == "/api/validate": + self._send_json(HTTPStatus.OK, self.editor.validate(body.get("config"))) + elif route == "/api/save": + status, payload = self.editor.save(body.get("config"), body.get("version")) + self._send_json(status, payload) + else: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + + # -- guards --------------------------------------------------------------- + + def _host_allowed(self) -> bool: + if self.headers.get("Host", "") in self.editor.allowed_hosts(): + return True + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Unexpected Host header"}) + return False + + def _authorized(self) -> bool: + supplied = self.headers.get(TOKEN_HEADER, "") + if hmac.compare_digest(supplied.encode(), self.editor.token.encode()): + return True + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Missing or invalid session token"}) + return False + + def _read_json(self) -> dict[str, Any] | None: + content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip() + if content_type != "application/json": + self._send_json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "Expected JSON"}) + return None + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = -1 + if length < 0 or length > MAX_BODY_BYTES: + self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "Body too large"}) + return None + try: + body = json.loads(self.rfile.read(length) or b"{}") + except json.JSONDecodeError as exc: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": f"Invalid JSON: {exc}"}) + return None + if not isinstance(body, dict): + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Expected a JSON object"}) + return None + return body + + # -- responses ------------------------------------------------------------ + + def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self._send(status, body, "application/json; charset=utf-8") + + def _send(self, status: HTTPStatus, body: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Content-Security-Policy", _CSP) + self.end_headers() + self.wfile.write(body) diff --git a/agentflow_cli/cli/config_editor/static/app.css b/agentflow_cli/cli/config_editor/static/app.css new file mode 100644 index 0000000..7da4297 --- /dev/null +++ b/agentflow_cli/cli/config_editor/static/app.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;--font-mono:"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-canvas:var(--af-canvas);--color-surface:var(--af-surface);--color-raised:var(--af-raised);--color-hover:var(--af-hover);--color-sunken:var(--af-sunken);--color-line:var(--af-line);--color-line-strong:var(--af-line-strong);--color-ink:var(--af-ink);--color-body:var(--af-body);--color-muted:var(--af-muted);--color-faint:var(--af-faint);--color-brand:var(--af-brand);--color-brand-solid:var(--af-brand-solid);--color-brand-solid-hover:var(--af-brand-solid-hover);--color-brand-soft:var(--af-brand-soft);--color-brand-ring:var(--af-brand-ring);--color-ok:var(--af-ok);--color-ok-soft:var(--af-ok-soft);--color-warn:var(--af-warn);--color-warn-soft:var(--af-warn-soft);--color-danger:var(--af-danger);--color-danger-soft:var(--af-danger-soft)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}html{scrollbar-gutter:stable}body{background-color:var(--color-canvas);font-family:var(--font-sans);color:var(--color-body);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*{scrollbar-width:thin;scrollbar-color:var(--af-scrollbar) transparent}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--af-scrollbar) padding-box;border:3px solid #0000;border-radius:9999px}::-webkit-scrollbar-thumb:hover{background:var(--af-scrollbar-hover) padding-box}::-webkit-scrollbar-corner{background:0 0}}@layer components{.input{height:calc(var(--spacing) * 10);border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-line-strong);background-color:var(--color-sunken);width:100%;padding-inline:calc(var(--spacing) * 3);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));color:var(--color-ink);transition-property:border-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s;display:block}.input::placeholder{color:var(--color-faint)}@media (hover:hover){.input:hover{border-color:var(--color-faint)}@supports (color:color-mix(in lab, red, red)){.input:hover{border-color:color-mix(in oklab, var(--color-faint) 60%, transparent)}}}.input:focus{border-color:var(--color-brand);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-ring);--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.input{height:calc(var(--spacing) * 9);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.input-invalid{border-color:var(--color-danger)}.input-invalid:focus{border-color:var(--color-danger);--tw-ring-color:var(--color-danger-soft)}.btn{height:calc(var(--spacing) * 10);cursor:pointer;justify-content:center;align-items:center;gap:calc(var(--spacing) * 2);border-radius:var(--radius-lg);padding-inline:calc(var(--spacing) * 3.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;flex-shrink:0;transition-duration:.15s;display:inline-flex}.btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-ring);--tw-outline-style:none;outline-style:none}.btn:disabled{cursor:not-allowed;opacity:.45}@media (min-width:40rem){.btn{height:calc(var(--spacing) * 9)}}.btn-primary{background-color:var(--color-brand-solid);color:var(--color-white);--tw-shadow:var(--af-shadow);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.btn-primary:hover{background-color:var(--color-brand-solid-hover)}}.btn-outline{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-line-strong);background-color:var(--color-surface);color:var(--color-body)}@media (hover:hover){.btn-outline:hover{background-color:var(--color-raised)}}.btn-ghost{color:var(--color-muted)}@media (hover:hover){.btn-ghost:hover{background-color:var(--color-raised);color:var(--color-ink)}}.icon-btn{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10);cursor:pointer;border-radius:var(--radius-lg);color:var(--color-muted);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;flex-shrink:0;justify-content:center;align-items:center;transition-duration:.15s;display:inline-flex}@media (hover:hover){.icon-btn:hover{background-color:var(--color-raised);color:var(--color-ink)}}.icon-btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-ring);--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.icon-btn{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}}.panel{border-radius:var(--radius-xl);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-line);background-color:var(--color-surface);--tw-shadow:var(--af-shadow);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.kbd{height:calc(var(--spacing) * 5);border-style:var(--tw-border-style);border-width:1px;border-color:#ffffff40;border-radius:.25rem;align-items:center;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.kbd{border-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.kbd{padding-inline:calc(var(--spacing) * 1.5);font-family:var(--font-mono);--tw-font-weight:var(--font-weight-medium);font-size:11px;font-weight:var(--font-weight-medium);color:#fffc}@supports (color:color-mix(in lab, red, red)){.kbd{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.eyebrow{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-faint);text-transform:uppercase}.focus-ring:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-ring);--tw-outline-style:none;outline-style:none}}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\/2{top:50%}.top-14{top:calc(var(--spacing) * 14)}.right-0{right:0}.right-1{right:var(--spacing)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-4{left:calc(var(--spacing) * 4)}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-10{margin-top:calc(var(--spacing) * 10)}.-mr-1{margin-right:calc(var(--spacing) * -1)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2\.5{margin-bottom:calc(var(--spacing) * 2.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.-ml-2\.5{margin-left:calc(var(--spacing) * -2.5)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.size-\[18px\]{width:18px;height:18px}.size-full{width:100%;height:100%}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-auto{height:auto}.min-h-0{min-height:0}.min-h-10{min-height:calc(var(--spacing) * 10)}.w-6{width:calc(var(--spacing) * 6)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-48{width:calc(var(--spacing) * 48)}.w-80{width:calc(var(--spacing) * 80)}.w-\[min\(100vw\,400px\)\]{width:min(100vw,400px)}.w-full{width:100%}.w-px{width:1px}.max-w-4xl{max-width:var(--container-4xl)}.max-w-full{max-width:100%}.max-w-prose{max-width:65ch}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-\[7rem\]{min-width:7rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-\[18px\]{--tw-translate-x:18px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-full{--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.scroll-mt-28{scroll-margin-top:calc(var(--spacing) * 28)}.scroll-mt-32{scroll-margin-top:calc(var(--spacing) * 32)}.\[scrollbar-width\:none\]{scrollbar-width:none}.appearance-none{appearance:none}.grid-cols-\[10rem_minmax\(0\,1fr\)_auto\]{grid-template-columns:10rem minmax(0,1fr) auto}.grid-rows-\[0fr\]{grid-template-rows:0fr}.grid-rows-\[1fr\]{grid-template-rows:1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-line>:not(:last-child)){border-color:var(--color-line)}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-b-xl{border-bottom-right-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-brand{border-color:var(--color-brand)}.border-brand-solid{border-color:var(--color-brand-solid)}.border-brand\/20{border-color:var(--color-brand)}@supports (color:color-mix(in lab, red, red)){.border-brand\/20{border-color:color-mix(in oklab, var(--color-brand) 20%, transparent)}}.border-danger\/25{border-color:var(--color-danger)}@supports (color:color-mix(in lab, red, red)){.border-danger\/25{border-color:color-mix(in oklab, var(--color-danger) 25%, transparent)}}.border-danger\/40{border-color:var(--color-danger)}@supports (color:color-mix(in lab, red, red)){.border-danger\/40{border-color:color-mix(in oklab, var(--color-danger) 40%, transparent)}}.border-line{border-color:var(--color-line)}.border-line-strong{border-color:var(--color-line-strong)}.border-warn\/25{border-color:var(--color-warn)}@supports (color:color-mix(in lab, red, red)){.border-warn\/25{border-color:color-mix(in oklab, var(--color-warn) 25%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-brand-soft{background-color:var(--color-brand-soft)}.bg-brand-solid{background-color:var(--color-brand-solid)}.bg-canvas\/60{background-color:var(--color-canvas)}@supports (color:color-mix(in lab, red, red)){.bg-canvas\/60{background-color:color-mix(in oklab, var(--color-canvas) 60%, transparent)}}.bg-canvas\/90{background-color:var(--color-canvas)}@supports (color:color-mix(in lab, red, red)){.bg-canvas\/90{background-color:color-mix(in oklab, var(--color-canvas) 90%, transparent)}}.bg-danger{background-color:var(--color-danger)}.bg-danger-soft{background-color:var(--color-danger-soft)}.bg-line{background-color:var(--color-line)}.bg-line-strong{background-color:var(--color-line-strong)}.bg-ok-soft{background-color:var(--color-ok-soft)}.bg-raised{background-color:var(--color-raised)}.bg-sunken{background-color:var(--color-sunken)}.bg-surface,.bg-surface\/95{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/95{background-color:color-mix(in oklab, var(--color-surface) 95%, transparent)}}.bg-transparent{background-color:#0000}.bg-warn{background-color:var(--color-warn)}.bg-warn-soft{background-color:var(--color-warn-soft)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-14{padding-block:calc(var(--spacing) * 14)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pr-0\.5{padding-right:calc(var(--spacing) * .5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pb-24{padding-bottom:calc(var(--spacing) * 24)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.break-all{word-break:break-all}.whitespace-pre-wrap{white-space:pre-wrap}.text-body{color:var(--color-body)}.text-brand{color:var(--color-brand)}.text-danger{color:var(--color-danger)}.text-faint,.text-faint\/70{color:var(--color-faint)}@supports (color:color-mix(in lab, red, red)){.text-faint\/70{color:color-mix(in oklab, var(--color-faint) 70%, transparent)}}.text-ink{color:var(--color-ink)}.text-muted{color:var(--color-muted)}.text-ok{color:var(--color-ok)}.text-warn{color:var(--color-warn)}.text-white{color:var(--color-white)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.shadow-card{--tw-shadow:var(--af-shadow);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-pop{--tw-shadow:var(--af-shadow-pop);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-black\/5{--tw-ring-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.ring-black\/5{--tw-ring-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-\[border-color\,box-shadow\]{transition-property:border-color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[grid-template-rows\]{transition-property:grid-template-rows;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.placeholder\:text-faint::placeholder{color:var(--color-faint)}.before\:absolute:before{content:var(--tw-content);position:absolute}.before\:-inset-2\.5:before{content:var(--tw-content);inset:calc(var(--spacing) * -2.5)}.before\:content-\[\'\'\]:before{--tw-content:"";content:var(--tw-content)}.focus-within\:border-brand:focus-within{border-color:var(--color-brand)}.focus-within\:ring-4:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-brand-ring:focus-within{--tw-ring-color:var(--color-brand-ring)}@media (hover:hover){.hover\:border-brand:hover{border-color:var(--color-brand)}.hover\:border-line-strong:hover{border-color:var(--color-line-strong)}.hover\:bg-hover:hover{background-color:var(--color-hover)}.hover\:bg-raised:hover{background-color:var(--color-raised)}.hover\:text-brand:hover{color:var(--color-brand)}.hover\:text-danger:hover{color:var(--color-danger)}.hover\:text-ink:hover{color:var(--color-ink)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:not-sr-only:focus{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.focus\:fixed:focus{position:fixed}.focus\:top-3:focus{top:calc(var(--spacing) * 3)}.focus\:left-3:focus{left:calc(var(--spacing) * 3)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}@media (min-width:40rem){.sm\:left-auto{left:auto}.sm\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.sm\:grid{display:grid}.sm\:inline{display:inline}.sm\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:min-h-9{min-height:calc(var(--spacing) * 9)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10rem_minmax\(0\,1fr\)_auto\]{grid-template-columns:10rem minmax(0,1fr) auto}.sm\:grid-cols-\[minmax\(0\,1fr\)_minmax\(0\,1\.1fr\)\]{grid-template-columns:minmax(0,1fr) minmax(0,1.1fr)}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}.sm\:gap-8{gap:calc(var(--spacing) * 8)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-8{padding-inline:calc(var(--spacing) * 8)}.sm\:py-1{padding-block:var(--spacing)}.sm\:pt-0\.5{padding-top:calc(var(--spacing) * .5)}.sm\:pr-5{padding-right:calc(var(--spacing) * 5)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:48rem){.md\:block{display:block}.md\:flex{display:flex}}@media (min-width:64rem){.lg\:block{display:block}.lg\:inline-flex{display:inline-flex}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-14{top:calc(var(--spacing) * 14)}.xl\:bottom-auto{bottom:auto}.xl\:z-auto{z-index:auto}.xl\:hidden{display:none}.xl\:h-\[calc\(100dvh-3\.5rem\)\]{height:calc(100dvh - 3.5rem)}.xl\:w-\[380px\]{width:380px}.xl\:shrink-0{flex-shrink:0}.xl\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.xl\:self-start{align-self:flex-start}}.\[\&\:\:-webkit-scrollbar\]\:hidden::-webkit-scrollbar{display:none}}:root{color-scheme:light;--af-canvas:#f5f7fb;--af-surface:#fff;--af-raised:#f1f5f9;--af-hover:#e8eef6;--af-sunken:#fff;--af-line:#0f172a17;--af-line-strong:#0f172a2b;--af-ink:#0b1220;--af-body:#1e293b;--af-muted:#475569;--af-faint:#64748b;--af-brand:#2563eb;--af-brand-solid:#2563eb;--af-brand-solid-hover:#1d4ed8;--af-brand-soft:#2563eb14;--af-brand-ring:#2563eb38;--af-ok:#047857;--af-ok-soft:#10b9811a;--af-warn:#b45309;--af-warn-soft:#f59e0b1f;--af-danger:#dc2626;--af-danger-soft:#ef444417;--af-shadow:0 1px 2px #0f172a0a, 0 1px 3px #0f172a0a;--af-shadow-pop:0 12px 32px #0f172a24;--af-scrollbar:#0f172a33;--af-scrollbar-hover:#0f172a59;--af-json-key:#1d4ed8;--af-json-string:#047857;--af-json-number:#b45309;--af-json-literal:#7c3aed}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--af-canvas:#08111f;--af-surface:#0c1727;--af-raised:#131e33;--af-hover:#1a2741;--af-sunken:#07101d;--af-line:#94a3b824;--af-line-strong:#94a3b842;--af-ink:#f8fafc;--af-body:#e2e8f0;--af-muted:#a3b1c6;--af-faint:#8391a7;--af-brand:#60a5fa;--af-brand-solid:#2563eb;--af-brand-solid-hover:#3b82f6;--af-brand-soft:#3b82f624;--af-brand-ring:#3b82f659;--af-ok:#34d399;--af-ok-soft:#10b98124;--af-warn:#fbbf24;--af-warn-soft:#f59e0b21;--af-danger:#f87171;--af-danger-soft:#ef444421;--af-shadow:0 1px 2px #02061766;--af-shadow-pop:0 16px 40px #0206178c;--af-scrollbar:#94a3b838;--af-scrollbar-hover:#94a3b866;--af-json-key:#93c5fd;--af-json-string:#6ee7b7;--af-json-number:#fcd34d;--af-json-literal:#c4b5fd}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:0s!important;animation-duration:0s!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/agentflow_cli/cli/config_editor/static/app.js b/agentflow_cli/cli/config_editor/static/app.js new file mode 100644 index 0000000..1b2e8ad --- /dev/null +++ b/agentflow_cli/cli/config_editor/static/app.js @@ -0,0 +1,3 @@ +var pe,b,et,Kt,D,Ze,tt,nt,Ae,ae,K,ot,Ee,Me,ze,Zt,ce={},ue=[],Yt=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,fe=Array.isArray;function E(e,t){for(var n in t)e[n]=t[n];return e}function Oe(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function Xt(e,t,n){var r,s,a,i={};for(a in t)a=="key"?r=t[a]:a=="ref"?s=t[a]:i[a]=t[a];if(arguments.length>2&&(i.children=arguments.length>3?pe.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(a in e.defaultProps)i[a]===void 0&&(i[a]=e.defaultProps[a]);return ie(e,i,r,s,null)}function ie(e,t,n,r,s){var a={type:e,props:t,key:n,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:s??++et,__i:-1,__u:0};return s==null&&b.vnode!=null&&b.vnode(a),a}function S(e){return e.children}function le(e,t){this.props=e,this.context=t}function F(e,t){if(t==null)return e.__?F(e.__,e.__i+1):null;for(var n;tt&&D.sort(nt),e=D.shift(),t=D.length,Qt(e)}finally{D.length=de.__r=0}}function st(e,t,n,r,s,a,i,c,u,p,_){var m,l,f,g,$,C,k=r&&r.__k||ue,v=t.length;for(u=en(n,t,k,u,v),m=0;m0?i=e.__k[a]=ie(i.type,i.props,i.key,i.ref?i.ref:null,i.__v):e.__k[a]=i,u=a+l,i.__=e,i.__b=e.__b+1,c=null,(p=i.__i=tn(i,n,u,m))!=-1&&(m--,(c=n[p])&&(c.__u|=2)),c==null||c.__v==null?(p==-1&&(s>_?l--:s<_&&l++),typeof i.type!="function"&&(i.__u|=4)):p!=u&&(p==u-1?l--:p==u+1?l++:(p>u?l--:l++,i.__u|=4))):e.__k[a]=null;if(m)for(a=0;a<_;a++)(c=n[a])!=null&&(2&c.__u)==0&&(c.__e==r&&(r=F(c)),ut(c,c));return r}function at(e,t,n){var r,s;if(typeof e.type=="function"){for(r=e.__k,s=0;r&&s(_?1:0)){for(s=n-1,a=n+1;s>=0||a=0?s--:a++])!=null&&(2&p.__u)==0&&c==p.key&&u==p.type)return i}return-1}function Xe(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Yt.test(t)?n:n+"px"}function se(e,t,n,r,s){var a,i;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof r=="string"&&(e.style.cssText=r=""),r)for(t in r)n&&t in n||Xe(e.style,t,"");if(n)for(t in n)r&&n[t]==r[t]||Xe(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")a=t!=(t=t.replace(ot,"$1")),i=t.toLowerCase(),t=i in e||t=="onFocusOut"||t=="onFocusIn"?i.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+a]=n,n?r?n[K]=r[K]:(n[K]=Ee,e.addEventListener(t,a?ze:Me,a)):e.removeEventListener(t,a?ze:Me,a);else{if(s=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function Qe(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t[ae]==null)t[ae]=Ee++;else if(t[ae]0?e:fe(e)?e.map(ct):e.constructor!==void 0?null:E({},e)}function nn(e,t,n,r,s,a,i,c,u){var p,_,m,l,f,g,$,C=n.props||ce,k=t.props,v=t.type;if(v=="svg"?s="http://www.w3.org/2000/svg":v=="math"?s="http://www.w3.org/1998/Math/MathML":s||(s="http://www.w3.org/1999/xhtml"),a!=null){for(p=0;p=n.__.length&&n.__.push({}),n.__[e]}function me(e){return he=1,He(kt,e)}function He(e,t,n){var r=De(Z++,2);if(r.t=e,!r.__c&&(r.__=[n?n(t):kt(void 0,t),function(c){var u=r.__N?r.__N[0]:r.__[0],p=r.t(u,c);u!==p&&(r.__N=[p,r.__[1]],r.__c.setState({}))}],r.__c=y,!y.__f)){var s=function(c,u,p){if(!r.__c.__H)return!0;var _=!1,m=r.__c.props!==c;if(r.__c.__H.__.some(function(f){if(f.__N){_=!0;var g=f.__[0];f.__=f.__N,f.__N=void 0,g!==f.__[0]&&(m=!0)}}),a){var l=a.call(this,c,u,p);return _?l||m:l}return!_||m};y.__f=!0;var a=y.shouldComponentUpdate,i=y.componentWillUpdate;y.componentWillUpdate=function(c,u,p){if(this.__e){var _=a;a=void 0,s(c,u,p),a=_}i&&i.call(this,c,u,p)},y.shouldComponentUpdate=s}return r.__N||r.__}function W(e,t){var n=De(Z++,3);!w.__s&&wt(n.__H,t)&&(n.__=e,n.u=t,y.__H.__h.push(n))}function yt(e){return he=5,rn(function(){return{current:e}},[])}function rn(e,t){var n=De(Z++,7);return wt(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function sn(){for(var e;e=gt.shift();){var t=e.__H;if(e.__P&&t)try{t.__h.some(_e),t.__h.some(Re),t.__h=[]}catch(n){t.__h=[],w.__e(n,e.__v)}}}w.__b=function(e){y=null,ft&&ft(e)},w.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),bt&&bt(e,t)},w.__r=function(e){_t&&_t(e),Z=0;var t=(y=e.__c).__H;t&&(Ne===y?(t.__h=[],y.__h=[],t.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.some(_e),t.__h.some(Re),t.__h=[],Z=0)),Ne=y},w.diffed=function(e){ht&&ht(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(gt.push(t)!==1&&pt===w.requestAnimationFrame||((pt=w.requestAnimationFrame)||an)(sn)),t.__H.__.some(function(n){n.u&&(n.__H=n.u,n.u=void 0)})),Ne=y=null},w.__c=function(e,t){t.some(function(n){try{n.__h.some(_e),n.__h=n.__h.filter(function(r){return!r.__||Re(r)})}catch(r){t.some(function(s){s.__h&&(s.__h=[])}),t=[],w.__e(r,n.__v)}}),mt&&mt(e,t)},w.unmount=function(e){xt&&xt(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.some(function(r){try{_e(r)}catch(s){t=s}}),n.__H=void 0,t&&w.__e(t,n.__v))};var vt=typeof requestAnimationFrame=="function";function an(e){var t,n=function(){clearTimeout(r),vt&&cancelAnimationFrame(t),setTimeout(e)},r=setTimeout(n,35);vt&&(t=requestAnimationFrame(n))}function _e(e){var t=y,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),y=t}function Re(e){var t=y;e.__c=e.__(),y=t}function wt(e,t){return!e||e.length!==t.length||t.some(function(n,r){return n!==e[r]})}function kt(e,t){return typeof t=="function"?t(e):t}var Fe=[{id:"essential",title:"Essentials",tagline:"What the server needs to boot. Start here.",sections:["core","plugins"]},{id:"recommended",title:"Recommended",tagline:"Lock down access and see what your agents do before you ship.",sections:["auth","authorization","rate_limit","observability"]},{id:"advanced",title:"Advanced",tagline:"Tune connection limits, client-side tools, and your test loop.",sections:["websocket","ag_ui","remote_tools","test","evaluation"]}],Le={"auth.path":"auth-path",authorization:"authz-path"},xe=new URLSearchParams(location.hash.slice(1)).get("token")||"",d={schema:null,sections:[],tiers:[],fields:new Map,config:{},version:null,exists:!1,path:"",parseError:null,saved:"",validated:null,issues:[],open:new Set,tab:"problems",inspectorOpen:!1,busy:null,toolParams:{},toasts:[],fatal:null},Ct=()=>{},Tt=e=>{Ct=e};function x(e){e&&e(d),Ct()}var St=e=>JSON.parse(JSON.stringify(e)),P=()=>JSON.stringify(d.config),O=e=>e!==null&&typeof e=="object"&&!Array.isArray(e),Ue=(e,t)=>t.reduce((n,r)=>O(n)?n[r]:void 0,e),I=(e,t)=>`${e} ${t}${e===1?"":"s"}`;function $t(e,t,n){let r=e;for(let s of t.slice(0,-1))O(r[s])||(r[s]={}),r=r[s];r[t[t.length-1]]=n}function It(e,t){let n=[],r=e;for(let s of t.slice(0,-1)){if(!O(r))return;n.push([r,s]),r=r[s]}O(r)&&delete r[t[t.length-1]];for(let s=n.length-1;s>=1;s--){let[a,i]=n[s];if(O(a[i])&&Object.keys(a[i]).length===0)delete a[i];else break}}function At(e){let t=Ue(d.config,e);return t!==void 0?t:d.fields.get(e.join("."))?.default}var be=e=>d.sections.find(t=>t.id===e),j=()=>P()!==d.saved,V=e=>!e.optional||e.key in d.config&&d.config[e.key]!==null,Y=e=>d.issues.filter(t=>t.section===e),X=e=>d.issues.find(t=>t.path===e),Mt=()=>d.validated!==null&&d.validated!==P();function z(e=d.issues){let t=e.filter(n=>n.level==="error").length;return{errors:t,warnings:e.length-t}}var zt=e=>Le[e]||`f-${e.replaceAll(".","-")}`;async function Be(e,t,n){let r={"X-Agentflow-Token":xe};n!==void 0&&(r["Content-Type"]="application/json");let s=await fetch(t,{method:e,headers:r,body:n===void 0?void 0:JSON.stringify(n)}),a={};try{a=await s.json()}catch{}return{ok:s.ok,status:s.status,data:a}}var We=(e,t)=>x(n=>{n.fatal={title:e,text:t}});function Et(e,t){let n=e.indexOf(t);return n===-1?e.length:n}function Ge(){for(let e of d.issues)be(e.section)&&d.open.add(e.section)}async function Ot(){if(!xe)return We("Open the link printed by agentflow config","This page needs the session token included in that link.");let e;try{e=await Be("GET","/api/state")}catch{return We("Cannot reach the config server","Check that agentflow config is still running in your terminal, then reload.")}if(!e.ok)return We("This link has expired",e.data.error||"Restart agentflow config and open the new link it prints.");let t=e.data;x(r=>{r.schema=t.schema;let s=Fe.flatMap(i=>i.sections);r.sections=[...t.schema.sections].sort((i,c)=>Et(s,i.id)-Et(s,c.id)),r.tiers=Fe.map(i=>({...i,list:i.sections.map(be).filter(Boolean)})),r.tiers[r.tiers.length-1].list.push(...r.sections.filter(i=>!s.includes(i.id)));for(let i of r.sections)for(let c of i.fields)r.fields.set(c.path.join("."),c);r.config=t.config,r.version=t.version,r.exists=t.exists,r.path=t.path,r.parseError=t.parse_error,r.issues=t.issues;let a=t.exists&&!t.parse_error;r.saved=a?P():"",r.validated=a?P():null;for(let i of r.sections)(!i.optional||V(i))&&r.open.add(i.id);Ge()});let n=new URLSearchParams(location.hash.slice(1)).get("section");n&&be(n)&&ge(n)}async function Q(){if(!(d.busy||!Pt())){x(e=>{e.busy="validate"});try{let e=await Be("POST","/api/validate",{config:d.config});if(!e.ok)return T("error",e.data.error||"Validation failed.");d.issues=e.data.issues,d.validated=P(),Ge();let{errors:t,warnings:n}=z(),r={label:"Show",run:()=>ye("problems")};t?T("error",`${I(t,"error")} to fix before saving.`,r):n?T("warning",`Valid, with ${I(n,"warning")}.`,r):T("success","Configuration is valid.")}catch{T("error","Cannot reach the config server.")}finally{x(e=>{e.busy=null})}}}async function ve(){if(!(d.busy||!d.schema)){if(d.exists&&!j()&&!d.parseError)return T("success","No changes to save.");if(Pt()){x(e=>{e.busy="save"});try{let e=await Be("POST","/api/save",{config:d.config,version:d.version});if(e.data.issues&&(d.issues=e.data.issues,d.validated=P(),Ge()),e.ok){d.version=e.data.version,d.config=e.data.config,d.toolParams={},d.validated=P(),d.saved=P(),d.exists=!0,d.parseError=null;let t=e.data.path.split(/[\\/]/).pop();T("success",e.data.backup?`Saved ${t}. The previous version is in ${t}.bak.`:`Created ${t}.`)}else if(e.status===409)T("error",e.data.error,{label:"Reload",run:()=>location.reload()});else if(e.status===422){let{errors:t}=z();T("error",`Not saved: ${I(t,"error")} to fix first.`,{label:"Show",run:()=>ye("problems")})}else T("error",e.data.error||"Save failed.")}catch{T("error","Cannot reach the config server.")}finally{x(e=>{e.busy=null})}}}}function Pt(){let e=Object.values(d.toolParams).some(t=>t.error);return e&&(ge("remote_tools"),T("error","A remote tool has invalid parameters JSON.")),!e}function Je(e,t){x(n=>{t?(n.config[e.key]=St(e.default),n.open.add(e.id)):delete n.config[e.key],e.key==="remote_tools"&&(n.toolParams={})})}function jt(e){x(t=>{t.open.has(e)?t.open.delete(e):t.open.add(e)})}function Nt(e){x(t=>{t.open=e?new Set(t.sections.map(n=>n.id)):new Set})}var Ve=()=>window.matchMedia("(prefers-reduced-motion: reduce)").matches?"auto":"smooth";function ge(e,t){x(n=>{n.open.add(e),n.inspectorOpen=!1}),history.replaceState(null,"",`#token=${encodeURIComponent(xe)}§ion=${e}`),requestAnimationFrame(()=>{let n=t?document.getElementById(t):null;if(n){n.scrollIntoView({block:"center",behavior:Ve()}),n.focus({preventScroll:!0});return}document.getElementById(`sec-${e}`)?.scrollIntoView({block:"start",behavior:Ve()}),document.getElementById(`acc-${e}`)?.focus({preventScroll:!0})})}function Rt(e){document.getElementById(`tier-${e}`)?.scrollIntoView({block:"start",behavior:Ve()})}function ye(e){x(t=>{e&&(t.tab=e),t.inspectorOpen=!window.matchMedia("(min-width: 1280px)").matches})}var ee=()=>x(e=>{e.inspectorOpen=!1});async function Dt(){try{await navigator.clipboard.writeText(JSON.stringify(d.config,null,2)+` +`),T("success","Copied agentflow.json to the clipboard.")}catch{T("error","The clipboard is not available here.")}}var ln=0;function T(e,t,n){let r=++ln;x(s=>{s.toasts=[...s.toasts,{id:r,kind:e,message:t,action:n}]}),setTimeout(()=>we(r),n?8e3:4e3)}function we(e){x(t=>{t.toasts=t.toasts.filter(n=>n.id!==e)})}var Ht={box:'',key:'',shield:'',gauge:'',arrows:'',activity:'',layers:'',wrench:'',flask:'',target:'',checkCircle:'',xCircle:'',alert:'',info:'',file:'',copy:'',x:'',plus:'',trash:'',panel:'',chevronDown:'',expand:'',collapse:'',power:'',spinner:'',book:'',external:''},ke={core:"box",auth:"key",authorization:"shield",rate_limit:"gauge",websocket:"arrows",ag_ui:"external",observability:"activity",plugins:"layers",remote_tools:"wrench",test:"flask",evaluation:"target"};var cn=0;function o(e,t,n,r,s,a){t||(t={});var i,c,u=t;if("ref"in u)for(c in u={},t)c=="ref"?i=t[c]:u[c]=t[c];var p={type:e,props:u,key:n,ref:i,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:--cn,__i:-1,__u:0,__source:s,__self:a};if(typeof e=="function"&&(i=e.defaultProps))for(c in i)u[c]===void 0&&(u[c]=i[c]);return b.vnode&&b.vnode(p),p}function h({name:e,class:t="size-4"}){return o("span",{class:`inline-flex shrink-0 ${t}`,"aria-hidden":"true",children:o("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"1.75","stroke-linecap":"round","stroke-linejoin":"round",class:"size-full",dangerouslySetInnerHTML:{__html:Ht[e]}})})}function Ce({class:e,children:t}){return o("span",{class:`inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-semibold ${e}`,children:t})}function Te({id:e,on:t,label:n,onChange:r}){return o("button",{id:e,type:"button",role:"switch","aria-checked":t?"true":"false","aria-label":n,class:`focus-ring relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 before:absolute before:-inset-2.5 before:content-[''] ${t?"bg-brand-solid":"bg-line-strong"}`,onClick:()=>r(!t),children:o("span",{class:`pointer-events-none inline-block size-5 rounded-full bg-white shadow-sm ring-1 ring-black/5 transition-transform duration-200 ease-out ${t?"translate-x-[18px]":"translate-x-0.5"}`})})}function Se({id:e,options:t,value:n,invalid:r,onChange:s}){return o("div",{class:"relative",children:[o("select",{id:e,value:n,class:`input cursor-pointer appearance-none pr-9 font-mono ${r?"input-invalid":""}`,onChange:a=>s(a.currentTarget.value),children:t.map(([a,i])=>o("option",{value:a,children:i},a))}),o(h,{name:"chevronDown",class:"pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-faint"})]})}function te({id:e,values:t,placeholder:n,suggestions:r,onChange:s}){let a=yt(null),i=u=>{let p=u.trim();a.current&&(a.current.value=""),p&&!t.includes(p)&&s([...t,p])},c=r?`${e}-options`:void 0;return o("div",{class:"flex min-h-10 cursor-text flex-wrap items-center gap-1.5 rounded-lg border border-line-strong bg-sunken px-1.5 py-1.5 transition-[border-color,box-shadow] duration-150 focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-ring sm:min-h-9 sm:py-1",onClick:u=>{u.target===u.currentTarget&&a.current?.focus()},children:[t.map((u,p)=>o("span",{class:"inline-flex h-7 items-center gap-0.5 rounded-md border border-line bg-raised pr-0.5 pl-2 font-mono text-xs text-body sm:h-6",children:[u,o("button",{type:"button","aria-label":`Remove ${u}`,class:"inline-flex size-6 cursor-pointer items-center justify-center rounded text-faint transition-colors hover:bg-hover hover:text-ink sm:size-5",onClick:()=>s(t.filter((_,m)=>m!==p)),children:o(h,{name:"x",class:"size-3"})})]},u)),o("input",{ref:a,id:e,type:"text",list:c,placeholder:t.length?"Add...":n,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:"h-7 min-w-[7rem] flex-1 bg-transparent px-1 font-mono text-base text-ink outline-none placeholder:text-faint sm:text-sm",onKeyDown:u=>{let p=u.currentTarget;(u.key==="Enter"||u.key===",")&&p.value.trim()?(u.preventDefault(),i(p.value)):u.key==="Backspace"&&!p.value&&t.length&&s(t.slice(0,-1))},onChange:u=>{r?.includes(u.currentTarget.value.trim())&&i(u.currentTarget.value)},onBlur:u=>i(u.currentTarget.value)}),r?o("datalist",{id:c,children:r.filter(u=>!t.includes(u)).map(u=>o("option",{value:u},u))}):null]})}function N({label:e,path:t,help:n,id:r,required:s}){return o("div",{class:"min-w-0",children:[o("label",{for:r,class:"block text-sm font-medium text-ink",children:[e,s?o("span",{class:"text-danger",children:" *"}):null]}),o("div",{class:"mt-0.5 font-mono text-xs text-faint",children:t}),n?o("p",{class:"mt-1.5 text-[13px] leading-5 text-muted",children:n}):null]})}function L({issue:e}){if(!e)return null;let t=e.level==="error";return o("p",{class:`mt-2 flex gap-1.5 text-[13px] leading-5 ${t?"text-danger":"text-warn"}`,role:t?"alert":void 0,children:[o(h,{name:t?"xCircle":"alert",class:"mt-0.5 size-3.5"}),o("span",{children:e.message})]})}function ne({children:e}){return o("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] sm:gap-8",children:e})}function B({id:e,label:t,path:n,value:r,placeholder:s,onInput:a,issue:i,help:c}){return o(ne,{children:[o(N,{label:t,path:n,help:c,id:e}),o("div",{class:"min-w-0 sm:pt-0.5",children:[o("input",{id:e,type:"text",value:r??"",placeholder:s,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${i?.level==="error"?"input-invalid":""}`,onInput:u=>a(u.currentTarget.value)}),o(L,{issue:i})]})]})}function qe({name:e,label:t,options:n,value:r,onPick:s}){return o("div",{role:"radiogroup","aria-label":t,class:"grid gap-3 sm:grid-cols-2",children:n.map(([a,i,c])=>{let u=a===r;return o("button",{type:"button",role:"radio","aria-checked":u?"true":"false",id:`${e}-${a}`,class:`focus-ring flex cursor-pointer items-start gap-3 rounded-xl border p-4 text-left transition-colors duration-150 ${u?"border-brand bg-brand-soft":"border-line bg-surface shadow-card hover:border-line-strong"}`,onClick:()=>{u||s(a)},children:[o("span",{class:`mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border ${u?"border-brand-solid bg-brand-solid":"border-line-strong"}`,children:u?o("span",{class:"size-1.5 rounded-full bg-white"}):null}),o("span",{class:"min-w-0",children:[o("span",{class:"block text-sm font-medium text-ink",children:i}),o("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:c})]})]},a)})})}function R({title:e,children:t}){return o("section",{children:[e?o("h4",{class:"eyebrow mb-2.5",children:e}):null,t]})}var un={info:["info","border-brand/20 bg-brand-soft","text-brand"],warn:["alert","border-warn/25 bg-warn-soft","text-warn"],error:["xCircle","border-danger/25 bg-danger-soft","text-danger"]};function $e({kind:e,title:t,lines:n}){let[r,s,a]=un[e];return o("div",{class:`flex gap-3 rounded-xl border px-4 py-3 ${s}`,role:e==="error"?"alert":void 0,children:[o(h,{name:r,class:`mt-0.5 size-4 ${a}`}),o("div",{class:"min-w-0 space-y-1 text-[13px] leading-5 text-body",children:[t?o("p",{class:"font-medium text-ink",children:t}):null,n.map((i,c)=>o("p",{children:i},c))]})]})}function Ft({issue:e}){return o($e,{kind:e.level==="error"?"error":"warn",lines:[o("span",{children:[e.path?o("code",{class:"mr-1.5 font-mono text-xs text-muted",children:e.path}):null,e.message]})]})}function oe({icon:e,tint:t,title:n,text:r,children:s}){return o("div",{class:"px-6 py-14 text-center",children:[o("div",{class:`mx-auto flex size-11 items-center justify-center rounded-full bg-raised ${t}`,children:o(h,{name:e,class:"size-5"})}),o("p",{class:"mt-4 text-sm font-semibold text-ink",children:n}),o("p",{class:"mx-auto mt-1 max-w-xs text-sm leading-6 text-muted",children:r}),s]})}function Lt(){let{errors:e,warnings:t}=z(),n=e+t,r=(s,a,i)=>o("button",{type:"button",role:"tab",id:`tab-${s}`,"aria-selected":d.tab===s?"true":"false",class:`focus-ring inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors duration-150 ${d.tab===s?"bg-raised text-ink":"text-muted hover:text-ink"}`,onClick:()=>x(c=>{c.tab=s}),children:[a,i]});return o("aside",{"aria-label":"Inspector",class:`fixed inset-y-0 right-0 z-40 flex w-[min(100vw,400px)] flex-col border-l border-line bg-surface transition-transform duration-200 ease-out xl:sticky xl:top-14 xl:bottom-auto xl:z-auto xl:self-start xl:h-[calc(100dvh-3.5rem)] xl:w-[380px] xl:shrink-0 xl:translate-x-0 ${d.inspectorOpen?"translate-x-0":"translate-x-full"}`,children:[o("div",{class:"flex h-14 shrink-0 items-center gap-1 border-b border-line px-3",children:[o("div",{role:"tablist",class:"flex flex-1 gap-1",children:[r("problems","Problems",d.validated!==null&&n?o("span",{class:`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold ${e?"bg-danger-soft text-danger":"bg-warn-soft text-warn"}`,children:n}):null),r("json","agentflow.json",null)]}),o("button",{type:"button",class:"icon-btn xl:hidden","aria-label":"Close inspector",onClick:ee,children:o(h,{name:"x",class:"size-5"})})]}),o("div",{class:"min-h-0 flex-1 overflow-y-auto overscroll-contain",children:d.tab==="problems"?o(dn,{}):o(fn,{})})]})}function dn(){if(!d.schema)return null;if(d.validated===null)return o(oe,{icon:"checkCircle",tint:"text-faint",title:"Not checked yet",text:"Validate runs the same rules agentflow api applies when it starts.",children:o("button",{type:"button",class:"btn btn-outline mt-5",disabled:!!d.busy,onClick:Q,children:"Validate now"})});let{errors:e,warnings:t}=z(),n=Mt(),r=d.sections.map(s=>[s,Y(s.id)]).filter(([,s])=>s.length);return o("div",{children:[o("div",{class:"flex items-center justify-between gap-3 border-b border-line px-4 py-3",children:[o("p",{class:"text-sm text-muted",children:[o("span",{class:e?"font-medium text-danger":"",children:I(e,"error")})," \xB7 ",o("span",{class:t?"font-medium text-warn":"",children:I(t,"warning")})]}),n?o("button",{type:"button",class:"text-sm font-medium text-brand hover:underline",onClick:Q,children:"Re-check"}):null]}),n?o("div",{class:"mx-4 mt-3 flex gap-2 rounded-lg bg-raised px-3 py-2 text-[13px] text-muted",children:[o(h,{name:"info",class:"mt-0.5 size-3.5 text-faint"}),"You changed the form since this check."]}):null,d.issues.length?o("div",{class:"space-y-5 px-3 py-4",children:r.map(([s,a])=>o("div",{children:[o("p",{class:"eyebrow mb-1.5 flex items-center gap-2 px-2",children:[o(h,{name:ke[s.id]||"box",class:"size-3.5"}),s.title]}),o("ul",{class:"space-y-0.5",children:a.map((i,c)=>{let u=i.level==="error";return o("li",{children:o("button",{type:"button",onClick:()=>ge(s.id,zt(i.path)),class:"focus-ring flex w-full cursor-pointer gap-2.5 rounded-lg px-2 py-2 text-left transition-colors duration-150 hover:bg-raised",children:[o(h,{name:u?"xCircle":"alert",class:`mt-0.5 size-4 ${u?"text-danger":"text-warn"}`}),o("span",{class:"min-w-0",children:[o("span",{class:"block text-[13px] leading-5 text-body",children:i.message}),i.path?o("span",{class:"mt-0.5 block truncate font-mono text-xs text-faint",children:i.path}):null]})]})},c)})})]},s.id))}):o(oe,{icon:"checkCircle",tint:"text-ok",title:"No problems found",text:"This configuration passes every check."})]})}function pn(e){return e.replace(/&/g,"&").replace(//g,">").replace(/("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,n=>{let r="var(--af-json-number)";return n.startsWith('"')?r=n.endsWith(":")?"var(--af-json-key)":"var(--af-json-string)":(n==="true"||n==="false"||n==="null")&&(r="var(--af-json-literal)"),`${n}`})}function fn(){let e=JSON.stringify(d.config,null,2).split(` +`);return o("div",{children:[o("div",{class:"sticky top-0 flex items-center justify-between gap-3 border-b border-line bg-surface px-4 py-2.5",children:[o("p",{class:"text-xs text-faint",children:j()||!d.exists?"Preview of what Save will write":"Matches the file on disk"}),o("button",{type:"button",class:"btn btn-ghost h-8 px-2.5 text-xs",onClick:Dt,children:[o(h,{name:"copy",class:"size-3.5"}),"Copy"]})]}),o("div",{class:"py-3 pr-4 font-mono text-[12.5px] leading-5",children:e.map((t,n)=>o("div",{class:"flex",children:[o("span",{class:"w-10 shrink-0 pr-3 text-right text-faint/70 select-none",children:n+1}),o("span",{class:"min-w-0 flex-1 break-all whitespace-pre-wrap",dangerouslySetInnerHTML:{__html:pn(t)}})]},n))})]})}function Ut(e){let t=[];for(let n of e.fields){if(n.show_if&&At(n.show_if.path)!==n.show_if.equals)continue;let r=n.group||"",s=t.find(a=>a.name===r);s||t.push(s={name:r,fields:[]}),s.fields.push(n)}return t}var G=(e,t)=>x(n=>{t===void 0?It(n.config,e):$t(n.config,e,t)});function _n({id:e,value:t,field:n,invalid:r}){let[s,a]=me(String(t??""));return W(()=>{(s===""?t!==void 0:Number(s)!==t)&&a(String(t??""))},[t]),o("input",{id:e,type:"number",inputmode:"decimal",value:s,min:n.min,step:n.step,placeholder:n.placeholder??n.default??"",class:`input font-mono tabular-nums ${r?"input-invalid":""}`,onInput:i=>{let c=i.currentTarget.value;a(c),c===""?G(n.path,void 0):Number.isNaN(Number(c))||G(n.path,Number(c))}})}function Wt({field:e}){let t=e.path.join("."),n=`f-${e.path.join("-")}`,r=Ue(d.config,e.path),s=X(t),a=s?.level==="error";if(e.type==="bool")return o("div",{class:"flex items-start justify-between gap-6 px-5 py-4",children:[o("div",{class:"min-w-0",children:[o(N,{label:e.label,path:t,help:e.help,id:n}),o(L,{issue:s})]}),o("div",{class:"pt-0.5",children:o(Te,{id:n,on:(r??e.default??!1)===!0,label:e.label,onChange:c=>G(e.path,c)})})]});if(e.type==="list")return o("div",{class:"px-5 py-4",children:[o(N,{label:e.label,path:t,help:e.help,id:n,required:e.required}),o("div",{class:"mt-3",children:[o(te,{id:n,values:Array.isArray(r)?r:[],placeholder:e.placeholder,onChange:c=>G(e.path,c.length?c:void 0)}),o(L,{issue:s})]})]});let i;return e.type==="select"?i=o(Se,{id:n,options:e.options.map(c=>[c,c]),value:r??e.default??e.options[0],invalid:a,onChange:c=>G(e.path,c)}):e.type==="number"?i=o(_n,{id:n,value:r,field:e,invalid:a}):i=o("input",{id:n,type:"text",value:r??"",placeholder:e.placeholder,autocomplete:"off",spellcheck:!1,autocapitalize:"off",class:`input font-mono ${a?"input-invalid":""}`,onInput:c=>G(e.path,c.currentTarget.value===""?void 0:c.currentTarget.value)}),o(ne,{children:[o(N,{label:e.label,path:t,help:e.help,id:n,required:e.required}),o("div",{class:"min-w-0 sm:pt-0.5",children:[i,o(L,{issue:s})]})]})}function Vt(){let e=d.config.auth,t=O(e)?"custom":"jwt";return o("div",{class:"space-y-6",children:[o(R,{title:"Method",children:o(qe,{name:"auth",label:"Authentication method",value:t,options:[["jwt","JWT","Verify bearer tokens signed with JWT_SECRET_KEY."],["custom","Custom backend","Your own BaseAuth subclass decides who is calling."]],onPick:n=>x(r=>{r.config.auth=n==="jwt"?"jwt":{method:"custom",path:""}})})}),t==="custom"?o(R,{title:"Backend",children:o("div",{class:"panel",children:o(B,{id:"auth-path",label:"Auth class",path:"auth.path",value:e.path,placeholder:"auth.custom:MyAuth",help:"Import path of a BaseAuth subclass, as module:attribute.",issue:X("auth.path"),onInput:n=>x(r=>{r.config.auth={...r.config.auth,method:"custom",path:n}})})})}):null]})}function hn(e){if(O(e))return"rbac";if(typeof e=="string"){let t=e.trim().toLowerCase();if(t==="ownership")return"ownership";if(["allow_all","default","none"].includes(t))return"allow_all"}return e==null?null:"custom"}var mn={ownership:()=>"ownership",allow_all:()=>"allow_all",custom:()=>"",rbac:()=>({backend:"rbac",roles:{admin:["*"],member:["graph:invoke","graph:stream","graph:read","checkpointer:read"]},default_scopes:[],isolation:"owner"})};function Bt({sec:e}){let t=d.config.authorization,n=hn(t)??"ownership";return o("div",{class:"space-y-6",children:[o(R,{title:"Policy",children:o(qe,{name:"authz",label:"Authorization policy",value:n,options:[["ownership","Ownership","Users can only reach threads they created."],["allow_all","Allow all","Any authenticated user can do anything."],["rbac","Roles and scopes","Map roles to scopes, on top of owner isolation."],["custom","Custom backend","Your own AuthorizationBackend class."]],onPick:r=>x(s=>{s.config.authorization=mn[r]()})})}),n==="custom"?o(R,{title:"Backend",children:o("div",{class:"panel",children:o(B,{id:"authz-path",label:"Backend class",path:"authorization",placeholder:"auth.permissions:MyBackend",value:typeof t=="string"?t:"",issue:X("authorization"),help:"Import path of an AuthorizationBackend, as module:attribute.",onInput:r=>x(s=>{s.config.authorization=r})})})}):null,n==="rbac"?o(bn,{sec:e,value:t}):null]})}function xn({id:e,name:t,onRename:n}){let[r,s]=me(t);return W(()=>s(t),[t]),o("div",{children:[o("label",{for:e,class:"sr-only",children:"Role name"}),o("input",{id:e,type:"text",value:r,class:"input font-mono",autocomplete:"off",spellcheck:!1,onInput:a=>s(a.currentTarget.value),onChange:a=>{n(a.currentTarget.value.trim())||s(t)}})]})}function bn({sec:e,value:t}){let n="roles"in t||!("role_scopes"in t)?"roles":"role_scopes",r=O(t[n])?t[n]:{},s=["*",...e.scopes||[]],a=Object.entries(r),i=u=>x(()=>{t[n]=u}),c=()=>{let u=a.length+1;for(;`role${u}`in r;)u++;i({...r,[`role${u}`]:[]})};return o(S,{children:[o(R,{title:"Roles",children:o("div",{class:"panel",children:[o("div",{class:"hidden grid-cols-[10rem_minmax(0,1fr)_auto] gap-3 border-b border-line px-5 py-2.5 sm:grid",children:[o("span",{class:"text-xs font-medium text-faint",children:"Role"}),o("span",{class:"text-xs font-medium text-faint",children:"Scopes"}),o("span",{class:"w-9"})]}),a.length?o("div",{class:"divide-y divide-line",children:a.map(([u,p],_)=>o("div",{class:"grid gap-3 px-5 py-4 sm:grid-cols-[10rem_minmax(0,1fr)_auto] sm:items-start",children:[o(xn,{id:`role-name-${_}`,name:u,onRename:m=>!m||m!==u&&m in r?!1:(i(Object.fromEntries(Object.entries(r).map(([l,f])=>[l===u?m:l,f]))),!0)}),o(te,{id:`role-scopes-${_}`,values:Array.isArray(p)?p:[],placeholder:"Add a scope",suggestions:s,onChange:m=>i({...r,[u]:m})}),o("button",{type:"button",class:"icon-btn justify-self-end hover:text-danger","aria-label":`Remove role ${u}`,onClick:()=>i(Object.fromEntries(Object.entries(r).filter(([m])=>m!==u))),children:o(h,{name:"trash"})})]},_))}):o("p",{class:"px-5 py-6 text-center text-sm text-muted",children:"No roles yet. Users only get the default scopes."}),o("div",{class:"flex items-center justify-between gap-4 border-t border-line px-5 py-3",children:[o("p",{class:"text-[13px] text-muted",children:["Read from the user's roles (or role) claim. ",o("code",{class:"font-mono",children:"*"})," grants every scope."]}),o("button",{type:"button",class:"btn btn-ghost",onClick:c,children:[o(h,{name:"plus"}),"Add role"]})]})]})}),o(R,{title:"Defaults",children:o("div",{class:"panel divide-y divide-line",children:[o("div",{class:"px-5 py-4",children:[o(N,{label:"Default scopes",path:"authorization.default_scopes",id:"rbac-default-scopes",help:"Granted to every authenticated user, even without a role."}),o("div",{class:"mt-3",children:o(te,{id:"rbac-default-scopes",values:Array.isArray(t.default_scopes)?t.default_scopes:[],placeholder:"graph:read",suggestions:s,onChange:u=>x(()=>{t.default_scopes=u})})})]}),o(ne,{children:[o(N,{label:"Thread isolation",path:"authorization.isolation",id:"rbac-isolation",help:"owner keeps each user's threads private; none shares them."}),o("div",{class:"min-w-0 sm:pt-0.5",children:o(Se,{id:"rbac-isolation",options:[["owner","owner"],["none","none"]],value:t.isolation??"owner",onChange:u=>x(()=>{t.isolation=u})})})]})]})})]})}function Gt(){let e=Array.isArray(d.config.remote_tools)?d.config.remote_tools:[],t=()=>{x(n=>{e.push({node:"",name:"",description:"",parameters:{type:"object",properties:{},required:[]}}),n.config.remote_tools=e}),requestAnimationFrame(()=>document.getElementById(`tool-${e.length-1}-name`)?.focus())};return e.length?o("div",{class:"space-y-4",children:[e.map((n,r)=>o(vn,{tools:e,tool:n,index:r},r)),o("button",{type:"button",onClick:t,class:"focus-ring flex h-12 w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-line-strong text-sm font-medium text-muted transition-colors duration-150 hover:border-brand hover:text-brand",children:[o(h,{name:"plus"}),"Add remote tool"]})]}):o("div",{class:"rounded-xl border border-dashed border-line-strong bg-surface px-6 py-10 text-center",children:[o("div",{class:"mx-auto flex size-11 items-center justify-center rounded-full bg-raised text-faint",children:o(h,{name:"wrench",class:"size-5"})}),o("p",{class:"mt-4 text-sm font-semibold text-ink",children:"No remote tools yet"}),o("p",{class:"mx-auto mt-1 max-w-sm text-sm leading-6 text-muted",children:"Declare a tool here and the client that calls the API runs it, for example to read the user's location."}),o("button",{type:"button",class:"btn btn-outline mt-5",onClick:t,children:[o(h,{name:"plus"}),"Add remote tool"]})]})}function vn({tools:e,tool:t,index:n}){let r=d.toolParams[n]??{text:JSON.stringify(t.parameters??{},null,2),error:null},s=i=>x(()=>{e[n]={...e[n],...i}}),a=i=>`remote_tools[${n}].${i}`;return o("section",{class:"panel",children:[o("div",{class:"flex items-center gap-3 border-b border-line px-5 py-3",children:[o(h,{name:"wrench",class:"size-4 text-faint"}),o("span",{class:"min-w-0 flex-1 truncate font-mono text-sm font-medium text-ink",children:t.name||`tool_${n+1}`}),t.node?o("span",{class:"rounded-md border border-line bg-raised px-1.5 py-0.5 font-mono text-xs text-muted",children:t.node}):null,o("button",{type:"button",class:"icon-btn hover:text-danger","aria-label":`Remove ${t.name||"tool"}`,onClick:()=>x(i=>{e.splice(n,1),i.toolParams={}}),children:o(h,{name:"trash"})})]}),o("div",{class:"divide-y divide-line",children:[o(B,{id:`tool-${n}-name`,label:"Tool name",path:a("name"),value:t.name,placeholder:"get_location",onInput:i=>s({name:i})}),o(B,{id:`tool-${n}-node`,label:"Graph node",path:a("node"),value:t.node,placeholder:"MAIN",help:"The node whose model may call this tool.",onInput:i=>s({node:i})}),o(B,{id:`tool-${n}-description`,label:"Description",path:a("description"),value:t.description,placeholder:"Returns the user's current city",help:"What the model reads when deciding to call it.",onInput:i=>s({description:i})}),o("div",{class:"px-5 py-4",children:[o(N,{label:"Parameters",path:a("parameters"),help:"JSON schema of the arguments. type must be object.",id:`tool-${n}-params`}),o("textarea",{id:`tool-${n}-params`,rows:7,spellcheck:!1,value:r.text,class:`input mt-3 h-auto py-2.5 font-mono text-[13px] leading-5 ${r.error?"input-invalid":""}`,onInput:i=>{let c=i.currentTarget.value;x(u=>{try{e[n]={...e[n],parameters:JSON.parse(c)},u.toolParams[n]={text:c,error:null}}catch(p){u.toolParams[n]={text:c,error:p.message}}})}}),o(L,{issue:r.error?{level:"error",message:r.error}:null})]})]})]})}function gn(){let e=d.path.split(/[\\/]/).pop()||"agentflow.json",t=d.path.slice(0,d.path.length-e.length),n=null;return d.schema?d.exists?j()?n=o(Ce,{class:"bg-warn-soft text-warn",children:"Unsaved"}):n=o(Ce,{class:"bg-ok-soft text-ok",children:"Saved"}):n=o(Ce,{class:"bg-brand-soft text-brand",children:"New"}):n=null,o("div",{class:"flex min-w-0 flex-1 items-center gap-2",title:d.path||void 0,children:[o(h,{name:"file",class:"size-4 text-faint"}),o("span",{class:"truncate font-mono text-[13px] font-medium text-ink",children:e}),n,t?o("span",{class:"hidden min-w-0 truncate font-mono text-xs text-faint lg:block",dir:"rtl",children:o("bdi",{children:t})}):null]})}function Jt(){let{errors:e,warnings:t}=z(),n=!!d.schema&&!d.fatal,r=d.busy==="validate";return o("header",{class:"sticky top-0 z-30 flex h-14 items-center gap-2 border-b border-line bg-surface/95 px-3 backdrop-blur sm:gap-3 sm:px-4",children:[o("div",{class:"flex shrink-0 items-center gap-2.5",children:[o("svg",{viewBox:"0 0 32 32",class:"size-7 shrink-0","aria-hidden":"true",children:[o("rect",{width:"32",height:"32",rx:"8",fill:"#2563eb"}),o("path",{d:"M10 11 22 11M10 11 16 22M22 11 16 22",stroke:"white","stroke-opacity":".55","stroke-width":"2"}),o("circle",{cx:"10",cy:"11",r:"3",fill:"white"}),o("circle",{cx:"22",cy:"11",r:"3",fill:"white"}),o("circle",{cx:"16",cy:"22",r:"3",fill:"white"})]}),o("div",{class:"hidden items-baseline gap-1.5 md:flex",children:[o("span",{class:"text-[15px] font-semibold tracking-tight text-ink",children:"Agentflow"}),o("span",{class:"text-[15px] text-faint",children:"Config"})]})]}),o("span",{class:"hidden h-6 w-px shrink-0 bg-line md:block","aria-hidden":"true"}),o(gn,{}),o("button",{type:"button",class:"icon-btn relative xl:hidden","aria-label":"Open problems and JSON preview",onClick:()=>ye(),children:[o(h,{name:"panel",class:"size-[18px]"}),e||t?o("span",{class:`absolute top-1 right-1 flex size-4 items-center justify-center rounded-full text-[10px] font-bold text-white ${e?"bg-danger":"bg-warn"}`,children:e||t}):null]}),o("button",{type:"button",class:"btn btn-outline","aria-label":"Validate configuration",disabled:!n||!!d.busy,onClick:Q,children:[o(h,{name:r?"spinner":"checkCircle",class:`size-4 ${r?"animate-spin":""}`}),o("span",{class:"hidden sm:inline",children:"Validate"})]}),o("button",{type:"button",class:"btn btn-primary",onClick:ve,disabled:!n||!!d.busy||d.exists&&!j()&&!d.parseError,children:[d.busy==="save"?o(h,{name:"spinner",class:"size-4 animate-spin"}):null,d.exists?"Save":"Create file",o("span",{class:"kbd hidden lg:inline-flex",children:"Ctrl S"})]})]})}function qt(){return d.fatal?o("div",{class:"panel mt-10",children:o(oe,{icon:"xCircle",tint:"text-danger",title:d.fatal.title,text:d.fatal.text})}):d.schema?o(S,{children:[o("div",{class:"pt-8",children:[o("h1",{class:"text-xl font-semibold tracking-tight text-ink",children:"Server configuration"}),o("p",{class:"mt-1 max-w-prose text-sm leading-6 text-muted",children:"Every agentflow.json option on one page. Fill in the essentials to get running, then switch on what you need on the way to production."})]}),o(yn,{}),d.parseError?o("div",{class:"mt-6",children:o($e,{kind:"warn",title:"The existing file is not valid JSON",lines:[`${d.parseError}. The form starts from defaults; saving replaces the file and keeps the old one as agentflow.json.bak.`]})}):null,d.tiers.map(e=>o(kn,{tier:e},e.id))]}):o("div",{class:"animate-pulse space-y-4 pt-8","aria-label":"Loading",children:[o("div",{class:"h-7 w-48 rounded-md bg-raised"}),o("div",{class:"h-4 w-80 max-w-full rounded-md bg-raised"}),[0,1,2,3].map(e=>o("div",{class:"h-16 rounded-xl bg-raised"},e))]})}function yn(){let e=d.sections.every(t=>d.open.has(t.id));return o("div",{class:"sticky top-14 z-20 -mx-4 mt-6 flex items-center gap-2 border-b border-line bg-canvas/90 px-4 py-2 backdrop-blur sm:-mx-8 sm:px-8",children:[o("nav",{class:"-ml-2.5 flex min-w-0 flex-1 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden","aria-label":"Jump to a tier",children:d.tiers.map(t=>o(wn,{tier:t},t.id))}),o("button",{type:"button",class:"btn btn-ghost","aria-label":e?"Collapse all sections":"Expand all sections",onClick:()=>Nt(!e),children:[o(h,{name:e?"collapse":"expand"}),o("span",{class:"hidden sm:inline",children:e?"Collapse all":"Expand all"})]}),o("a",{href:d.schema.docs_url,target:"_blank",rel:"noopener",class:"btn btn-ghost","aria-label":"Configuration reference",children:[o(h,{name:"book"}),o("span",{class:"hidden sm:inline",children:"Reference"})]})]})}function wn({tier:e}){let t=e.list.filter(V).length,{errors:n,warnings:r}=z(d.issues.filter(s=>e.list.some(a=>a.id===s.section)));return o("a",{href:`#tier-${e.id}`,title:`${t} of ${I(e.list.length,"section")} on`,class:"focus-ring inline-flex h-9 shrink-0 items-center gap-2 rounded-lg px-2.5 text-sm text-muted transition-colors duration-150 hover:bg-raised hover:text-ink",onClick:s=>{s.preventDefault(),Rt(e.id)},children:[o("span",{class:"font-medium",children:e.title}),o("span",{class:"font-mono text-xs text-faint tabular-nums",children:[t,"/",e.list.length]}),n||r?o("span",{class:`size-1.5 rounded-full ${n?"bg-danger":"bg-warn"}`}):null]})}function kn({tier:e}){let t=e.list.filter(V).length;return o("section",{id:`tier-${e.id}`,class:"mt-10 scroll-mt-28","aria-labelledby":`tier-${e.id}-title`,children:[o("div",{class:"mb-3",children:[o("div",{class:"flex items-baseline gap-3",children:[o("h2",{id:`tier-${e.id}-title`,class:"text-base font-semibold tracking-tight text-ink",children:e.title}),o("span",{class:"text-xs text-faint",children:[t," of ",e.list.length," on"]})]}),o("p",{class:"mt-0.5 text-sm text-muted",children:e.tagline})]}),o("div",{class:"space-y-3",children:e.list.map(n=>o(Cn,{sec:n},n.id))})]})}function Cn({sec:e}){let t=V(e),n=d.open.has(e.id),{errors:r,warnings:s}=z(Y(e.id)),a=`body-${e.id}`,i=null;return r?i=o("span",{class:"inline-flex shrink-0 items-center gap-1 text-xs font-medium text-danger",title:I(r,"error"),children:[o(h,{name:"xCircle",class:"size-3.5"}),r]}):s&&(i=o("span",{class:"inline-flex shrink-0 items-center gap-1 text-xs font-medium text-warn",title:I(s,"warning"),children:[o(h,{name:"alert",class:"size-3.5"}),s]})),o("section",{id:`sec-${e.id}`,class:`panel scroll-mt-32 ${r?"border-danger/40":""}`,children:[o("div",{class:"flex items-center gap-3 pr-4 sm:pr-5",children:[o("h3",{class:"min-w-0 flex-1",children:o("button",{type:"button",id:`acc-${e.id}`,"aria-expanded":n?"true":"false","aria-controls":a,class:"focus-ring flex w-full cursor-pointer items-center gap-3 rounded-xl py-3.5 pl-3 text-left sm:pl-4",onClick:()=>jt(e.id),children:[o(h,{name:"chevronDown",class:`size-4 text-faint transition-transform duration-200 ${n?"":"-rotate-90"}`}),o("span",{class:`flex size-9 shrink-0 items-center justify-center rounded-lg border border-line ${t?"bg-brand-soft text-brand":"bg-raised text-faint"}`,children:o(h,{name:ke[e.id]||"box",class:"size-[18px]"})}),o("span",{class:"min-w-0 flex-1",children:[o("span",{class:"flex flex-wrap items-center gap-x-2 gap-y-0.5",children:[o("span",{class:`text-sm font-semibold ${t?"text-ink":"text-muted"}`,children:e.title}),e.key?o("code",{class:"hidden rounded border border-line bg-raised px-1.5 font-mono text-[11px] leading-4 text-faint sm:inline",children:e.key}):null]}),o("span",{class:"mt-0.5 block text-[13px] leading-5 text-muted",children:e.description})]}),i]})}),e.optional?o("div",{class:"flex shrink-0 items-center gap-2.5",children:[o("span",{class:"hidden w-6 text-xs font-medium text-faint sm:inline",children:t?"On":"Off"}),o(Te,{id:`toggle-${e.id}`,on:t,label:`${t?"Turn off":"Turn on"} ${e.title}`,onChange:c=>Je(e,c)})]}):null]}),o("div",{id:a,role:"region","aria-labelledby":`acc-${e.id}`,inert:!n,class:`grid transition-[grid-template-rows] duration-200 ease-out ${n?"grid-rows-[1fr]":"grid-rows-[0fr]"}`,children:o("div",{class:"min-h-0 overflow-hidden",children:o("div",{class:"space-y-6 rounded-b-xl border-t border-line bg-canvas/60 px-4 py-5 sm:px-5",children:o(Tn,{sec:e,on:t})})})})]})}function Tn({sec:e,on:t}){if(!t)return o("div",{class:"flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6",children:[o("p",{class:"text-sm leading-6 text-muted",children:[o("code",{class:"font-mono text-[13px] text-body",children:e.key})," is not in agentflow.json. ",e.off_summary||""]}),o("button",{type:"button",class:"btn btn-outline",onClick:()=>Je(e,!0),children:[o(h,{name:"power"}),"Turn on ",e.title.toLowerCase()]})]});let n=new Set([...e.fields.map(s=>s.path.join(".")),...Object.keys(Le)]),r=Y(e.id).filter(s=>!n.has(s.path));return o(S,{children:[r.length?o("div",{class:"space-y-2",children:r.map((s,a)=>o(Ft,{issue:s},a))}):null,e.notes?.length?o($e,{kind:"info",lines:e.notes}):null,e.widget==="auth"?o(Vt,{}):null,e.widget==="authorization"?o(Bt,{sec:e}):null,e.widget==="remote_tools"?o(Gt,{}):null,Ut(e).map(s=>o(R,{title:s.name,children:o("div",{class:"panel divide-y divide-line",children:s.fields.map(a=>o(Wt,{field:a},a.path.join(".")))})},s.name))]})}var Sn={success:["checkCircle","text-ok"],warning:["alert","text-warn"],error:["xCircle","text-danger"]};function $n(){return o("div",{class:"pointer-events-none fixed right-4 bottom-4 left-4 z-50 flex flex-col items-end gap-2 sm:left-auto","aria-live":"polite",children:d.toasts.map(e=>{let[t,n]=Sn[e.kind];return o("div",{role:e.kind==="error"?"alert":"status",class:"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border border-line bg-surface px-4 py-3 text-sm shadow-pop",children:[o(h,{name:t,class:`mt-0.5 size-4 ${n}`}),o("p",{class:"min-w-0 flex-1 leading-5 text-body",children:e.message}),e.action?o("button",{type:"button",class:"shrink-0 font-medium text-brand hover:underline",onClick:()=>{we(e.id),e.action.run()},children:e.action.label}):null,o("button",{type:"button",class:"-mr-1 shrink-0 cursor-pointer rounded text-faint hover:text-ink","aria-label":"Dismiss",onClick:()=>we(e.id),children:o(h,{name:"x"})})]},e.id)})})}function In(){let[,e]=He(t=>t+1,0);return W(()=>{Tt(e);let t=r=>{(r.ctrlKey||r.metaKey)&&r.key.toLowerCase()==="s"?(r.preventDefault(),ve()):r.key==="Escape"&&d.inspectorOpen&&ee()},n=r=>{d.schema&&j()&&r.preventDefault()};return document.addEventListener("keydown",t),window.addEventListener("beforeunload",n),Ot(),()=>{document.removeEventListener("keydown",t),window.removeEventListener("beforeunload",n)}},[]),o(S,{children:[o("a",{href:"#main",class:"sr-only z-50 rounded-md bg-brand-solid px-3 py-2 text-white focus:not-sr-only focus:fixed focus:top-3 focus:left-3",children:"Skip to content"}),o(Jt,{}),o("div",{class:"flex",children:[o("main",{id:"main",tabindex:-1,class:"min-w-0 flex-1 focus:outline-none",children:o("div",{class:"mx-auto w-full max-w-4xl px-4 pb-24 sm:px-8",children:o(qt,{})})}),o(Lt,{})]}),d.inspectorOpen?o("div",{class:"fixed inset-0 z-30 bg-black/50 xl:hidden",onClick:ee}):null,o($n,{})]})}dt(o(In,{}),document.getElementById("app")); diff --git a/agentflow_cli/cli/config_editor/static/index.html b/agentflow_cli/cli/config_editor/static/index.html new file mode 100644 index 0000000..6b90e82 --- /dev/null +++ b/agentflow_cli/cli/config_editor/static/index.html @@ -0,0 +1,20 @@ + + + + + + Agentflow Config + + + + + + + + + + +
+ + + diff --git a/agentflow_cli/cli/config_editor/store.py b/agentflow_cli/cli/config_editor/store.py new file mode 100644 index 0000000..0a9f8e3 --- /dev/null +++ b/agentflow_cli/cli/config_editor/store.py @@ -0,0 +1,94 @@ +"""Read and atomically write the ``agentflow.json`` file being edited.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class ConfigConflictError(Exception): + """The file changed on disk after the editor loaded it.""" + + +@dataclass(frozen=True) +class LoadedConfig: + config: dict[str, Any] | None + version: str | None # sha256 of the file bytes, None when the file does not exist + parse_error: str | None = None + + +class ConfigFileStore: + """Owns one config file: loads it, detects concurrent edits, and saves it safely.""" + + def __init__(self, path: Path) -> None: + self.path = path + + @property + def backup_path(self) -> Path: + return self.path.with_name(f"{self.path.name}.bak") + + def load(self) -> LoadedConfig: + if not self.path.exists(): + return LoadedConfig(config=None, version=None) + raw = self.path.read_bytes() + version = _digest(raw) + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + return LoadedConfig(config=None, version=version, parse_error=str(exc)) + if not isinstance(data, dict): + return LoadedConfig( + config=None, version=version, parse_error="The file is not a JSON object." + ) + return LoadedConfig(config=data, version=version) + + def save(self, config: dict[str, Any], expected_version: str | None) -> str: + """Write ``config`` and return the new version. + + Raises: + ConfigConflictError: The file on disk no longer matches ``expected_version``. + """ + current = self.load() + if current.version != expected_version: + raise ConfigConflictError( + f"{self.path.name} changed on disk after it was loaded. Reload to see the " + "latest version before saving." + ) + + ordered = _preserve_key_order(config, current.config or {}) + payload = (json.dumps(ordered, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + + self.path.parent.mkdir(parents=True, exist_ok=True) + if self.path.exists(): + shutil.copy2(self.path, self.backup_path) + fd, temporary_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent + ) + temporary_path = Path(temporary_name) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + temporary_path.replace(self.path) + finally: + if temporary_path.exists(): + temporary_path.unlink() + return _digest(payload) + + +def _preserve_key_order(new: dict[str, Any], old: dict[str, Any]) -> dict[str, Any]: + """Keep the file's existing top-level key order; append new keys at the end.""" + ordered = {key: new[key] for key in old if key in new} + ordered.update({key: value for key, value in new.items() if key not in ordered}) + return ordered + + +def _digest(raw: bytes) -> str: + return hashlib.sha256(raw).hexdigest() diff --git a/agentflow_cli/cli/config_editor/validation.py b/agentflow_cli/cli/config_editor/validation.py new file mode 100644 index 0000000..1ae6d8e --- /dev/null +++ b/agentflow_cli/cli/config_editor/validation.py @@ -0,0 +1,377 @@ +"""Validate an ``agentflow.json`` document the same way the CLI and API server read it. + +Errors block a save because the server would refuse to start (or silently misbehave). +Warnings describe things that are valid but probably not what the user meant, such as +an import path whose module file cannot be found next to the config. +""" + +from __future__ import annotations + +import logging +import os +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from agentflow_cli.cli.config_editor.schema import ( + BUILTIN_AUTHORIZATION, + KNOWN_TOP_LEVEL_KEYS, + section_for_key, +) + + +Issue = dict[str, str] + +_IMPORT_PATH = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_][\w.]*$") +_OBSERVABILITY_LEVELS = ("spans", "standard", "full") +_MAX_PERCENT = 100 + + +class _Collector: + def __init__(self) -> None: + self.issues: list[Issue] = [] + + def add(self, level: str, path: str, message: str) -> None: + top = path.split(".", 1)[0].split("[", 1)[0] + self.issues.append( + { + "level": level, + "section": section_for_key(top) if path else "core", + "path": path, + "message": message, + } + ) + + def error(self, path: str, message: str) -> None: + self.add("error", path, message) + + def warning(self, path: str, message: str) -> None: + self.add("warning", path, message) + + +def validate_config(config: Any, base_dir: Path | None = None) -> list[Issue]: + """Return every error and warning for ``config``. + + Args: + config: Parsed ``agentflow.json`` content. + base_dir: Directory that holds the config file. Enables checks that + referenced modules and files exist. + """ + out = _Collector() + if not isinstance(config, dict): + out.error("", "The configuration must be a JSON object.") + return out.issues + + _check_agent(config, out, base_dir) + _check_env(config, out, base_dir) + _check_auth(config, out, base_dir) + _check_authorization(config, out, base_dir) + _check_rate_limit(config, out) + _check_websocket(config, out) + _check_ag_ui(config, out) + _check_observability(config, out) + _check_plugins(config, out, base_dir) + _check_remote_tools(config, out) + _check_test(config, out) + _check_evaluation(config, out) + + for key in config: + if key not in KNOWN_TOP_LEVEL_KEYS: + out.warning(key, f"'{key}' is not read by Agentflow. It is kept as-is.") + return out.issues + + +def has_errors(issues: list[Issue]) -> bool: + return any(issue["level"] == "error" for issue in issues) + + +# --------------------------------------------------------------------------- +# Individual sections +# --------------------------------------------------------------------------- + + +def _check_agent(config: dict, out: _Collector, base_dir: Path | None) -> None: + agent = config.get("agent") + if agent is None or agent == "": + out.error("agent", "agent is required, for example 'graph.react:app'.") + return + _check_import_path("agent", agent, out, base_dir) + + +def _check_env(config: dict, out: _Collector, base_dir: Path | None) -> None: + env = config.get("env") + if env is None: + return + if not isinstance(env, str): + out.error("env", "env must be a file path string or null.") + return + if env and base_dir is not None and not (base_dir / env).exists(): + out.warning("env", f"Env file '{env}' does not exist yet.") + + +def _check_auth(config: dict, out: _Collector, base_dir: Path | None) -> None: + auth = config.get("auth") + if not auth: + return + if isinstance(auth, str): + if "jwt" not in auth: + out.error("auth", f"Unsupported auth '{auth}'. Use 'jwt' or a custom object.") + return + if isinstance(auth, dict): + method, path = auth.get("method"), auth.get("path") + if method != "custom": + out.error("auth.method", 'auth.method must be \'custom\'. For JWT use "auth": "jwt".') + if not path: + out.error("auth.path", "auth.path is required for custom auth.") + else: + _check_import_path("auth.path", path, out, base_dir) + return + out.error("auth", 'auth must be null, \'jwt\', or {"method": "custom", "path": ...}.') + + +def _check_authorization(config: dict, out: _Collector, base_dir: Path | None) -> None: + value = config.get("authorization") + if value is None: + return + if isinstance(value, str): + if ":" in value: + _check_import_path("authorization", value, out, base_dir) + elif value.strip().lower() not in BUILTIN_AUTHORIZATION: + out.error( + "authorization", + f"Unknown authorization '{value}'. Use one of " + f"{', '.join(BUILTIN_AUTHORIZATION)} or a module:attribute path.", + ) + return + if not isinstance(value, dict): + out.error("authorization", "authorization must be a string, an RBAC object, or null.") + return + _check_rbac(value, out) + + +def _check_rbac(value: dict, out: _Collector) -> None: + backend = str(value.get("backend") or value.get("type") or "").lower() + if backend not in ("rbac", "role_based", "roles"): + out.error("authorization.backend", "authorization.backend must be 'rbac'.") + roles = value.get("roles") if "roles" in value else value.get("role_scopes") + if roles is not None: + if not isinstance(roles, dict): + out.error("authorization.roles", "roles must map role names to scope lists.") + else: + for role, scopes in roles.items(): + if not _is_str_list(scopes): + out.error(f"authorization.roles.{role}", "Scopes must be a list of strings.") + if not roles: + out.warning("authorization.roles", "No roles defined. Only default scopes apply.") + default_scopes = value.get("default_scopes") + if default_scopes is not None and not _is_str_list(default_scopes): + out.error("authorization.default_scopes", "default_scopes must be a list of strings.") + isolation = value.get("isolation", "owner") + if isolation not in ("owner", "none"): + out.error("authorization.isolation", "isolation must be 'owner' or 'none'.") + + +def _check_rate_limit(config: dict, out: _Collector) -> None: + data = config.get("rate_limit") + if data is None: + return + if not isinstance(data, dict): + out.error("rate_limit", "rate_limit must be an object.") + return + + from agentflow_cli.src.app.core.config.graph_config import RateLimitConfig + + probe = dict(data) + redis_value = probe.get("redis") + redis_url = redis_value.get("url") if isinstance(redis_value, dict) else redis_value + if isinstance(redis_url, str) and _has_unresolved_env(redis_url): + # The server expands $VAR at startup; it is fine for it to be unset while editing. + out.warning( + "rate_limit.redis.url", + f"{redis_url} is not set in this shell. Make sure it is set when the API runs.", + ) + placeholder = "redis://placeholder" + probe["redis"] = ( + {**redis_value, "url": placeholder} if isinstance(redis_value, dict) else placeholder + ) + + config_obj = _quietly(lambda: RateLimitConfig.from_dict(probe), out, "rate_limit") + if config_obj is None: + return + if config_obj.backend == "redis" and not redis_url: + out.warning( + "rate_limit.redis.url", + "The redis backend needs a URL unless a Redis client is bound in InjectQ.", + ) + if config_obj.enabled and config_obj.backend == "memory": + out.warning( + "rate_limit.backend", + "The memory backend counts per process. Use redis with more than one worker.", + ) + + +def _check_websocket(config: dict, out: _Collector) -> None: + data = config.get("websocket") + if data is None: + return + from agentflow_cli.src.app.core.config.graph_config import WebSocketConfig + + _quietly(lambda: WebSocketConfig.from_dict(data), out, "websocket") + + +def _check_ag_ui(config: dict, out: _Collector) -> None: + data = config.get("ag_ui") + if data is None: + return + from agentflow_cli.src.app.core.config.graph_config import AgUiConfig + + _quietly(lambda: AgUiConfig.from_dict(data), out, "ag_ui") + + +def _check_observability(config: dict, out: _Collector) -> None: + data = config.get("observability") + if data is None: + return + if not isinstance(data, dict): + out.error("observability", "observability must be an object.") + return + level = data.get("level", "standard") + if level not in _OBSERVABILITY_LEVELS: + out.error( + "observability.level", f"level must be one of {', '.join(_OBSERVABILITY_LEVELS)}." + ) + any_enabled = False + for backend in ("logfire", "langsmith"): + block = data.get(backend) + if block is None: + continue + if not isinstance(block, dict): + out.error(f"observability.{backend}", f"{backend} must be an object.") + continue + enabled = block.get("enabled", False) + if not isinstance(enabled, bool): + out.error(f"observability.{backend}.enabled", "enabled must be true or false.") + any_enabled = any_enabled or enabled is True + if not any_enabled: + out.warning( + "observability", "Neither Logfire nor LangSmith is enabled, so nothing is traced." + ) + + +def _check_plugins(config: dict, out: _Collector, base_dir: Path | None) -> None: + for key in ("injectq", "store", "checkpointer", "thread_name_generator"): + value = config.get(key) + if value is None or value == "": + continue + _check_import_path(key, value, out, base_dir) + if config.get("checkpointer"): + out.warning( + "checkpointer", + "The API server does not apply this key yet. Pass the checkpointer to " + "graph.compile() instead.", + ) + + redis = config.get("redis") + if redis is None or redis == "": + return + if not isinstance(redis, str): + out.error("redis", "redis must be a URL string.") + elif "$" in redis: + out.warning("redis", "Top-level redis is used as-is; $VAR is not expanded.") + + +def _check_remote_tools(config: dict, out: _Collector) -> None: + if "remote_tools" not in config or config["remote_tools"] is None: + return + from agentflow_cli.src.app.core.config.graph_config import validate_remote_tools + + _quietly(lambda: validate_remote_tools(config["remote_tools"]), out, "remote_tools") + + +def _check_test(config: dict, out: _Collector) -> None: + data = config.get("test") + if data is None: + return + if not isinstance(data, dict): + out.error("test", "test must be an object.") + return + if "path" in data and not isinstance(data["path"], str): + out.error("test.path", "path must be a string.") + if "coverage" in data and not isinstance(data["coverage"], bool): + out.error("test.coverage", "coverage must be true or false.") + threshold = data.get("coverage_threshold") + if threshold is not None and not (_is_int(threshold) and 0 <= threshold <= _MAX_PERCENT): + out.error("test.coverage_threshold", "coverage_threshold must be a whole number 0-100.") + + +def _check_evaluation(config: dict, out: _Collector) -> None: + data = config.get("evaluation") + if data is None: + return + if not isinstance(data, dict): + out.error("evaluation", "evaluation must be an object.") + return + for key in ("directory", "output_dir"): + if key in data and not isinstance(data[key], str): + out.error(f"evaluation.{key}", f"{key} must be a string.") + threshold = data.get("threshold") + if threshold is not None and not (_is_number(threshold) and 0 <= threshold <= 1): + out.error("evaluation.threshold", "threshold must be a number between 0 and 1.") + if "parallel" in data and not isinstance(data["parallel"], bool): + out.error("evaluation.parallel", "parallel must be true or false.") + concurrency = data.get("max_concurrency") + if concurrency is not None and not (_is_int(concurrency) and concurrency >= 1): + out.error("evaluation.max_concurrency", "max_concurrency must be a whole number >= 1.") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _check_import_path(path: str, value: Any, out: _Collector, base_dir: Path | None) -> None: + if not isinstance(value, str) or not _IMPORT_PATH.match(value): + out.error(path, f"Expected module:attribute, got {value!r}.") + return + if base_dir is None: + return + module = value.split(":", 1)[0] + candidate = base_dir.joinpath(*module.split(".")) + if not (candidate.with_suffix(".py").exists() or (candidate / "__init__.py").exists()): + out.warning(path, f"Module '{module}' was not found next to agentflow.json.") + + +def _quietly(parse: Callable[[], Any], out: _Collector, path: str) -> Any: + """Run a server-side parser, turning its exceptions into an error issue. + + The parsers log operational warnings (for example about the memory rate-limit + backend); those are reported as issues here, so the logger is muted meanwhile. + """ + api_logger = logging.getLogger("agentflow_api") + previous = api_logger.disabled + api_logger.disabled = True + try: + return parse() + except (TypeError, ValueError) as exc: + out.error(path, str(exc)) + return None + finally: + api_logger.disabled = previous + + +def _has_unresolved_env(value: str) -> bool: + # Same rule as graph_config._expand_env, which raises for these at startup. + return os.path.expandvars(value) == value and (value.startswith("$") or "${" in value) + + +def _is_str_list(value: Any) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def _is_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_number(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) diff --git a/agentflow_cli/cli/main.py b/agentflow_cli/cli/main.py index 8cac2b8..790395b 100644 --- a/agentflow_cli/cli/main.py +++ b/agentflow_cli/cli/main.py @@ -1,7 +1,6 @@ """Agentflow CLI entry point and lightweight command registry.""" import importlib -import json import os import sys from pathlib import Path @@ -18,9 +17,8 @@ ) from agentflow_cli.cli.context import CLIContext from agentflow_cli.cli.core.output import OutputFormatter -from agentflow_cli.cli.exceptions import AgentflowCLIError, ConfigurationError, DependencyError +from agentflow_cli.cli.exceptions import AgentflowCLIError, DependencyError from agentflow_cli.cli.logger import setup_cli_logging -from agentflow_cli.cli.user_config import UserConfigStore, parse_config_value def _lazy_command(module_name: str, class_name: str) -> type: @@ -59,6 +57,7 @@ def execute(self, *args: Any, **kwargs: Any) -> int: APICommand = _lazy_command("agentflow_cli.cli.commands.api", "APICommand") AuditCommand = _lazy_command("agentflow_cli.cli.commands.audit", "AuditCommand") BuildCommand = _lazy_command("agentflow_cli.cli.commands.build", "BuildCommand") +ConfigCommand = _lazy_command("agentflow_cli.cli.commands.config", "ConfigCommand") DemoCommand = _lazy_command("agentflow_cli.cli.commands.demo", "DemoCommand") EvalCommand = _lazy_command("agentflow_cli.cli.commands.eval", "EvalCommand") InitCommand = _lazy_command("agentflow_cli.cli.commands.init", "InitCommand") @@ -82,12 +81,6 @@ def execute(self, *args: Any, **kwargs: Any) -> int: rich_markup_mode="rich", pretty_exceptions_enable=False, ) -config_app = typer.Typer( - name="config", - help="Inspect and manage user-level Agentflow CLI preferences.", - no_args_is_help=True, -) -app.add_typer(config_app, name="config", rich_help_panel="Manage") # Initialize global output formatter output = OutputFormatter() @@ -198,38 +191,21 @@ def root( # noqa: PLR0913 if cwd is not None: os.chdir(cwd) - preferences = UserConfigStore() - try: - if json_output and output_format not in {None, OutputFormat.JSON}: - raise typer.BadParameter("--json cannot be combined with a different --format.") - if no_color and color not in {None, ColorMode.NEVER}: - raise typer.BadParameter("--no-color cannot be combined with a different --color.") - if animation is not None and progress is not None: - expected = ProgressMode.TTY if animation else ProgressMode.PLAIN - if progress != expected: - raise typer.BadParameter( - "--animation/--no-animation conflicts with the selected --progress mode." - ) - resolved_format = output_format or OutputFormat( - preferences.get("output.format", OutputFormat.HUMAN) - ) - resolved_color = color or ColorMode(preferences.get("output.color", ColorMode.AUTO)) - resolved_progress = progress or ProgressMode( - preferences.get("output.progress", ProgressMode.AUTO) - ) - if json_output: - resolved_format = OutputFormat.JSON - if no_color: - resolved_color = ColorMode.NEVER - if animation is not None: - resolved_progress = ProgressMode.TTY if animation else ProgressMode.PLAIN - except (ConfigurationError, ValueError) as exc: - if ctx.invoked_subcommand != "config": + if json_output and output_format not in {None, OutputFormat.JSON}: + raise typer.BadParameter("--json cannot be combined with a different --format.") + if no_color and color not in {None, ColorMode.NEVER}: + raise typer.BadParameter("--no-color cannot be combined with a different --color.") + if animation is not None and progress is not None: + expected = ProgressMode.TTY if animation else ProgressMode.PLAIN + if progress != expected: raise typer.BadParameter( - f"Invalid user output configuration: {exc}. Run `agentflow config validate`." - ) from exc - resolved_format = output_format or OutputFormat.HUMAN - resolved_color = color or ColorMode.AUTO + "--animation/--no-animation conflicts with the selected --progress mode." + ) + resolved_format = OutputFormat.JSON if json_output else output_format or OutputFormat.HUMAN + resolved_color = ColorMode.NEVER if no_color else color or ColorMode.AUTO + if animation is not None: + resolved_progress = ProgressMode.TTY if animation else ProgressMode.PLAIN + else: resolved_progress = progress or ProgressMode.AUTO output.configure( @@ -263,109 +239,6 @@ def root( # noqa: PLR0913 raise typer.Exit() -@config_app.command("path") -def config_path() -> None: - """Print the user configuration file path.""" - typer.echo(UserConfigStore().path) - - -@config_app.command("list") -def config_list() -> None: - """List all user-level CLI preferences.""" - store = UserConfigStore() - try: - values = store.load() - except ConfigurationError as exc: - raise typer.Exit(handle_exception(exc)) from exc - output.print_key_value_pairs(_flatten_mapping(values), title="User configuration") - - -@config_app.command("get") -def config_get(key: str = typer.Argument(..., help="Dot-separated preference key.")) -> None: - """Read one user-level CLI preference.""" - store = UserConfigStore() - try: - value = store.get(key) - except ConfigurationError as exc: - raise typer.Exit(handle_exception(exc)) from exc - if value is None: - raise typer.Exit( - handle_exception( - ConfigurationError( - f"Configuration key '{key}' is not set.", - config_path=str(store.path), - ) - ) - ) - if isinstance(value, dict | list): - typer.echo(json.dumps(value, indent=2, ensure_ascii=False)) - else: - typer.echo(value) - - -@config_app.command("set") -def config_set( - key: str = typer.Argument(..., help="Dot-separated preference key."), - value: str = typer.Argument(..., help="JSON value or plain string."), -) -> None: - """Set one user-level CLI preference.""" - store = UserConfigStore() - try: - store.set(key, parse_config_value(value)) - except ConfigurationError as exc: - raise typer.Exit(handle_exception(exc)) from exc - output.success(f"Set {key} in {store.path}") - - -@config_app.command("unset") -def config_unset(key: str = typer.Argument(..., help="Dot-separated preference key.")) -> None: - """Remove one user-level CLI preference.""" - store = UserConfigStore() - try: - removed = store.unset(key) - except ConfigurationError as exc: - raise typer.Exit(handle_exception(exc)) from exc - if not removed: - raise typer.Exit( - handle_exception( - ConfigurationError( - f"Configuration key '{key}' is not set.", - config_path=str(store.path), - ) - ) - ) - output.success(f"Removed {key} from {store.path}") - - -@config_app.command("validate") -def config_validate() -> None: - """Validate the user configuration and supported output preferences.""" - store = UserConfigStore() - try: - store.load() - OutputFormat(store.get("output.format", OutputFormat.HUMAN)) - ColorMode(store.get("output.color", ColorMode.AUTO)) - ProgressMode(store.get("output.progress", ProgressMode.AUTO)) - except (ConfigurationError, ValueError) as exc: - raise typer.Exit(handle_exception(ConfigurationError(str(exc)))) from exc - output.success(f"User configuration is valid: {store.path}") - - -def _flatten_mapping( - values: dict[str, Any], - *, - prefix: str = "", -) -> dict[str, Any]: - flattened: dict[str, Any] = {} - for key, value in values.items(): - dotted = f"{prefix}.{key}" if prefix else key - if isinstance(value, dict): - flattened.update(_flatten_mapping(value, prefix=dotted)) - else: - flattened[dotted] = value - return flattened - - def _configure_command(*, verbose: bool, quiet: bool) -> None: """Apply legacy per-command flags without overriding root output choices.""" setup_cli_logging(verbose=verbose, quiet=quiet) @@ -567,11 +440,18 @@ def dev( epilog=( "Examples:\n" " agentflow audit\n" + " agentflow audit --config custom.json\n" " agentflow --format json audit\n" " agentflow --no-animation audit" ), ) def audit( + config: str = typer.Option( + DEFAULT_CONFIG_FILE, + "--config", + "-c", + help="Project configuration file to validate (default: agentflow.json).", + ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging."), quiet: bool = typer.Option( False, @@ -584,8 +464,8 @@ def audit( Runs six read-only checks and prints them as a table: the Python interpreter, the installed CLI and core packages, whether the core exposes - the evaluation API this CLI expects, whether `agentflow.json` is present - and declares a valid `agent` key, and whether the default port is free. + the evaluation API this CLI expects, whether `agentflow.json` declares a + valid `agent` key and remote-tool schemas, and whether the default port is free. Nothing is written or changed, so it is safe to run anywhere. Exits 1 if any check fails and 0 otherwise, which makes it usable as a CI gate; @@ -594,7 +474,7 @@ def audit( """ _configure_command(verbose=verbose, quiet=quiet) try: - sys.exit(AuditCommand(output).execute()) + sys.exit(AuditCommand(output).execute(config=config)) except Exception as e: sys.exit(handle_exception(e)) @@ -641,6 +521,35 @@ def version( sys.exit(handle_exception(e)) +@app.command(rich_help_panel="Manage") +def config( + config_file: str = typer.Option( + DEFAULT_CONFIG_FILE, + "--config", + "-c", + help="Config file to edit. It is created on first save if missing.", + ), + port: int = typer.Option( + 0, + "--port", + "-p", + help="Port for the local editor (default: any free port).", + ), + open_browser: bool = typer.Option( + True, + "--open/--no-open", + help="Open the editor in your default browser.", + ), +) -> None: + """Edit, validate, and save agentflow.json in a browser UI.""" + try: + command = ConfigCommand(output) + exit_code = command.execute(config=config_file, port=port, open_browser=open_browser) + sys.exit(exit_code) + except Exception as e: + sys.exit(handle_exception(e)) + + @app.command() def init( # noqa: PLR0913 ctx: typer.Context, @@ -837,6 +746,14 @@ def skills( "-l", help="List supported agents and exit", ), + validate: list[str] | None = typer.Option( + None, + "--validate", + help=( + "Validate a skill directory, or a folder of skill directories, against the " + "Agent Skills specification and exit. Repeatable." + ), + ), verbose: bool = typer.Option( False, "--verbose", @@ -850,7 +767,7 @@ def skills( help="Suppress all output except errors", ), ) -> None: - """Install bundled Agentflow skills for Codex, Claude, or GitHub.""" + """Install bundled Agentflow skills for Codex, Claude, or GitHub, or validate skills.""" _configure_command(verbose=verbose, quiet=quiet) try: @@ -861,6 +778,7 @@ def skills( force=force, all_agents=all_agents, list_agents=list_agents, + validate_paths=validate, ) sys.exit(exit_code) except Exception as e: diff --git a/agentflow_cli/cli/templates/defaults.py b/agentflow_cli/cli/templates/defaults.py index 41a8641..a609acc 100644 --- a/agentflow_cli/cli/templates/defaults.py +++ b/agentflow_cli/cli/templates/defaults.py @@ -34,6 +34,7 @@ "checkpointer": None, "injectq": None, "store": None, + "remote_tools": [], "thread_name_generator": None, "observability": None, }, @@ -497,14 +498,24 @@ def should_use_tools(state: AgentState) -> str: # Docker templates +# The published name on PyPI. The bare name "agentflow-cli" belongs to someone else. +CLI_PACKAGE = "10xscale-agentflow-cli" + + def generate_dockerfile_content( python_version: str, port: int, requirements_file: str, has_requirements: bool, omit_cmd: bool = False, + has_pyproject: bool = False, ) -> str: - """Generate the content for the Dockerfile.""" + """Generate the content for the Dockerfile. + + Dependencies come from ``requirements_file`` when there is one, otherwise from the + ``[project].dependencies`` of ``pyproject.toml`` (what ``agentflow init`` scaffolds), and + only as a last resort from the published CLI package alone. + """ dockerfile_lines = [ "# Dockerfile for Agentflow API", "# Generated by agentflow-cli CLI", @@ -548,12 +559,26 @@ def generate_dockerfile_content( "", ] ) + elif has_pyproject: + dockerfile_lines.extend( + [ + "# Install the project's dependencies from pyproject.toml", + "COPY pyproject.toml .", + "RUN python -c \"import tomllib; deps = tomllib.load(open('pyproject.toml', 'rb'))" + "['project'].get('dependencies', []); print('\\\\n'.join(deps))\" " + "> /tmp/requirements.txt \\", + " && pip install --no-cache-dir --upgrade pip \\", + " && pip install --no-cache-dir -r /tmp/requirements.txt \\", + f" && pip install --no-cache-dir {CLI_PACKAGE} gunicorn uvicorn", + "", + ] + ) else: dockerfile_lines.extend( [ - "# Install agentflow-cli (since no requirements.txt found)", + "# No requirements.txt or pyproject.toml: install the Agentflow CLI only", "RUN pip install --no-cache-dir --upgrade pip \\", - " && pip install --no-cache-dir agentflow-cli \\", + f" && pip install --no-cache-dir {CLI_PACKAGE} \\", " && pip install --no-cache-dir gunicorn uvicorn", "", ] @@ -784,6 +809,8 @@ def generate_dockerignore_content() -> str: "data/", "db.sqlite3", "store/", + "uploads/", + "eval_reports/", "", ] ) diff --git a/agentflow_cli/cli/templates/dev/agentflow.json b/agentflow_cli/cli/templates/dev/agentflow.json index 8140d8e..79fe200 100644 --- a/agentflow_cli/cli/templates/dev/agentflow.json +++ b/agentflow_cli/cli/templates/dev/agentflow.json @@ -2,6 +2,10 @@ "agent": "graph.agent:app", "env": ".env", "auth": null, + "remote_tools": [], + "ag_ui": { + "enabled": false + }, "thread_name_generator": null, "evaluation": { "directory": "evals", diff --git a/agentflow_cli/cli/templates/prod/.env.example b/agentflow_cli/cli/templates/prod/.env.example index ce2acc6..77dd0b3 100644 --- a/agentflow_cli/cli/templates/prod/.env.example +++ b/agentflow_cli/cli/templates/prod/.env.example @@ -19,9 +19,10 @@ ALLOWED_HOST="*" ###### Paths #################### ################################# ROOT_PATH="/" -# Disable docs in production for security -DOCS_PATH="/docs" -REDOCS_PATH="/redoc" +# API docs (and /openapi.json) are served only when a path is set. Keep them off in +# production; set e.g. DOCS_PATH="/docs" for a staging deployment. +DOCS_PATH="" +REDOCS_PATH="" ################################# ###### Request Limits ########### diff --git a/agentflow_cli/cli/templates/prod/agentflow.json b/agentflow_cli/cli/templates/prod/agentflow.json index fd5187a..0eea7d9 100644 --- a/agentflow_cli/cli/templates/prod/agentflow.json +++ b/agentflow_cli/cli/templates/prod/agentflow.json @@ -5,6 +5,10 @@ "method": "custom", "path": "auth.agent_auth:AgentAuth" }, + "remote_tools": [], + "ag_ui": { + "enabled": false + }, "thread_name_generator": "graph.thread_name_generator:MyNameGenerator", "injectq": "graph.agent:container", "rate_limit": { @@ -20,7 +24,7 @@ "trusted_proxy_headers": true, "trusted_proxy_hops": 1, "fail_open": false, - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"] }, "test": { "path": "tests", diff --git a/agentflow_cli/cli/templates/prod/auth/agent_auth.py b/agentflow_cli/cli/templates/prod/auth/agent_auth.py index 40b859c..5e85337 100644 --- a/agentflow_cli/cli/templates/prod/auth/agent_auth.py +++ b/agentflow_cli/cli/templates/prod/auth/agent_auth.py @@ -1,16 +1,46 @@ +import logging from typing import Any from agentflow_cli import BaseAuth -from fastapi import Request, Response +from fastapi import HTTPException, Request, Response from fastapi.security import HTTPAuthorizationCredentials +logger = logging.getLogger(__name__) + + class AgentAuth(BaseAuth): + """Verify the caller and return who they are. + + Every request is denied with 401 until ``authenticate`` checks a real credential. + + Contract: + - Return a dict with at least ``user_id``. It becomes the thread and memory owner, so it + must come from the verified credential, never from a header or body field the client + sets. Optional ``roles`` / ``scopes`` feed the authorization backend. + - Raise ``HTTPException(status_code=401)`` for a missing, invalid or expired credential. + """ + + def __init__(self) -> None: + logger.warning( + "AgentAuth.authenticate is not implemented yet: every request will get 401. " + "Implement it in auth/agent_auth.py." + ) + def authenticate( self, request: Request, response: Response, credential: HTTPAuthorizationCredentials, ) -> dict[str, Any] | None: - # TODO: Implement actual authentication logic here - raise NotImplementedError("AgentAuth.authenticate is not implemented") + if credential is None: + raise HTTPException(status_code=401, detail="Missing credentials") + + # Replace this with a real check, for example verifying the bearer token with your + # identity provider: + # + # claims = verify_token(credential.credentials) # raises on a bad token + # return {"user_id": claims["sub"], "roles": claims.get("roles", [])} + # + # Remove the warning in __init__ once this is done. + raise HTTPException(status_code=401, detail="Authentication is not configured") diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/remote-tools.md b/agentflow_cli/cli/templates/skills/agent-skills/references/remote-tools.md deleted file mode 100644 index 6f68ab6..0000000 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/remote-tools.md +++ /dev/null @@ -1,86 +0,0 @@ -# Remote Tools - -Use this when implementing tools that execute in a TypeScript client or browser process while the Python graph runs on the Agentflow API server. - -## Concept - -Remote tools let a graph advertise a tool schema to the model while deferring execution to the client. The server returns a `RemoteToolCallBlock` instead of executing the tool locally. The TypeScript SDK detects that block, runs the registered handler, sends back a `ToolResultBlock`, and continues the invoke or stream loop. - -Use remote tools for: - -- Browser-only capabilities such as DOM access, local files selected by the user, Web APIs, or UI state. -- Client-owned integrations that should not expose credentials to the server. -- Tools whose implementation belongs with the app using `@10xscale/agentflow-client`. - -Use local Python tools, MCP, Composio, or LangChain tools when execution should happen server-side. - -## Registration Flow - -1. In TypeScript, call `client.registerTool({ node, name, description, parameters, handler })`. -2. Call `await client.setup()` before invoking the graph. -3. The client posts remote tool schemas to `POST /v1/graph/setup`. -4. The API groups tools by `node_name` and calls `CompiledGraph.attach_remote_tools(tools, node_name)`. -5. The relevant Python `ToolNode` marks those names as remote. - -Remote tool schema shape: - -```typescript -{ - node_name: "TOOLS", - name: "read_browser_state", - description: "Read selected browser state.", - parameters: { - type: "object", - properties: {}, - required: [] - } -} -``` - -## Execution Flow - -1. Model requests a tool call. -2. `ToolNode` sees the tool name in `remote_tool_names`. -3. Instead of executing locally, the server returns a `Message` with `RemoteToolCallBlock`. -4. TypeScript `invoke` or `stream` detects content blocks with `type === "remote_tool_call"`. -5. `ToolExecutor.executeToolCalls` runs matching registered handlers. -6. The client creates `Message.tool_message([ToolResultBlock(...)] )`. -7. The client sends tool result messages in the next API iteration. - -The client controls the recursion loop and stops when no remote tool calls remain or when `recursion_limit` is reached. - -## Important Types - -Python: - -- `RemoteToolCallBlock`: https://github.com/10xHub/Agentflow/blob/main/agentflow/agentflow/core/state/message_block.py -- `ToolResultBlock`: same module -- `ToolNode.remote_tool_names`: checked during tool execution - -TypeScript: - -- `RemoteTool`: https://github.com/10xHub/agentflow-client/blob/main/src/endpoints/setupGraph.ts -- `ToolRegistration`: https://github.com/10xHub/agentflow-client/blob/main/src/tools.ts -- `ToolExecutor`: https://github.com/10xHub/agentflow-client/blob/main/src/tools.ts -- `RemoteToolCallBlock`: https://github.com/10xHub/agentflow-client/blob/main/src/message.ts - -## Rules - -- `node` / `node_name` must match the graph tool node that should expose the remote tools. -- `name` must match the tool call name the model will emit. -- `parameters` should be JSON-schema-like and precise; this schema is model-facing. -- Register tools and call `client.setup()` before `invoke` or `stream`. -- Return serializable values from TypeScript handlers so they can be placed in `ToolResultBlock.output`. -- Handle missing tools and handler errors as tool result errors, not transport failures. - -## Source Map - -- API setup schema: https://github.com/10xHub/agentflow-cli/blob/main/agentflow_cli/src/app/routers/graph/schemas/graph_schemas.py -- API setup route: https://github.com/10xHub/agentflow-cli/blob/main/agentflow_cli/src/app/routers/graph/router.py -- API setup service: https://github.com/10xHub/agentflow-cli/blob/main/agentflow_cli/src/app/routers/graph/services/graph_service.py -- Python block model: https://github.com/10xHub/Agentflow/blob/main/agentflow/agentflow/core/state/message_block.py -- Python remote handling: https://github.com/10xHub/Agentflow/blob/main/agentflow/agentflow/core/graph/tool_node/base.py -- TS setup endpoint: https://github.com/10xHub/agentflow-client/blob/main/src/endpoints/setupGraph.ts -- TS invoke loop: https://github.com/10xHub/agentflow-client/blob/main/src/endpoints/invoke.ts -- TS stream loop: https://github.com/10xHub/agentflow-client/blob/main/src/endpoints/stream.ts -- TS executor: https://github.com/10xHub/agentflow-client/blob/main/src/tools.ts diff --git a/agentflow_cli/cli/templates/skills/agentflow/SKILL.md b/agentflow_cli/cli/templates/skills/agentflow/SKILL.md new file mode 100644 index 0000000..5a12c66 --- /dev/null +++ b/agentflow_cli/cli/templates/skills/agentflow/SKILL.md @@ -0,0 +1,92 @@ +--- +name: agentflow +description: >- + Expert guidance for building, debugging, and extending applications with AgentFlow + (10xscale-agentflow). Use when code imports from agentflow (e.g. `from agentflow import`, + `StateGraph`, `Agent`, `ToolNode`, `AgentState`); when the user references `agentflow.json` + or CLI commands (`agentflow init`, `agentflow api`, `agentflow play`, `agentflow build`, + `agentflow skills`); or when the user is building graph-based multi-agent workflows, tools, + memory, checkpointing, or streaming with this framework. Skip generic Python or multi-agent + questions that do not reference agentflow, and other frameworks (LangGraph, CrewAI, AutoGen) + unless comparing. +license: MIT +--- + +# Agentflow Project Skill + +Use this skill when working in an Agentflow project. Agentflow is a multi-agent framework that wraps official OpenAI and Google SDK capabilities behind a unified graph, agent, tool, state, storage, API, CLI, and TypeScript client interface. + +Treat https://agentflow.10xscale.ai/ as the first source of truth for public package names, install commands, and user-facing behavior. Use implementation source after the docs establish the intended API. + +## Workflow + +1. Identify the published package or docs surface involved: + - PyPI core Python SDK: `10xscale-agentflow` (`pip install 10xscale-agentflow`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow/agentflow + - PyPI API/CLI SDK: `10xscale-agentflow-cli` (`pip install 10xscale-agentflow-cli`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-api/agentflow_cli + - npm TypeScript SDK: `@10xscale/agentflow-client` (`npm install @10xscale/agentflow-client`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-client/src + - Main docs: https://agentflow.10xscale.ai/ + - Playground/UI: `agentflow play` command after installed cli + +2. Read the matching reference file before changing behavior. Paths are relative to this skill's directory: + + ### Core Python SDK + - Architecture and package flow: `references/architecture.md` + - Agent constructor, provider, reasoning, retry, fallback, output_schema: `references/agents-and-tools.md` + - Graph construction, nodes, edges, compile, interrupts, config keys: `references/state-graph.md` + - State, messages, and content blocks: `references/state-and-messages.md` + - Threads and checkpointing: `references/checkpointing-and-threads.md` + - Dependency injection (InjectQ): `references/dependency-injection.md` + - Multimodal files and media stores: `references/media-and-files.md` + - Long-term memory stores (MemoryConfig, QdrantStore, Mem0Store): `references/memory-and-store.md` + - Streaming, StreamChunk, SSE, ResponseGranularity: `references/streaming.md` + - Stream emitter for tool progress updates: `references/stream-emitter.md` + - Observability hooks, validators, and runtime jumps: `references/callbacks-and-command.md` + - Prebuilt agents (ReactAgent, PlanActReflectAgent, StructuredOutputAgent, SupervisorTeamAgent, SwarmAgent, RAGAgent) and tools: `references/prebuilt-agents-and-tools.md` + - Event publishers and A2A/ACP runtime protocols: `references/publishers-and-runtime-protocols.md` + - Context management, ID generation, and background tasks: `references/context-id-background.md` + - Provider internals and adapters: `references/providers-and-adapters.md` + - Prompt-injection and validation safety: `references/security-and-validators.md` + - Realtime audio-to-audio voice agents (AudioAgent, Gemini Live, `arealtime`, WebSocket bridge): `references/realtime.md` + + ### API/CLI SDK + - CLI commands and generated project files: `references/cli-commands.md` + - `agentflow.json` and dependency loading: `references/api-configuration.md` + - API auth and authorization: `references/auth-and-authorization.md` + - API environment, settings, and middleware: `references/api-settings-and-middleware.md` + - Rate limiting (config, backends, headers, custom backend): `references/rate-limiting.md` + - REST routes and error behavior: `references/rest-api-and-errors.md` + - API Snowflake IDs and thread naming: `references/id-and-thread-name-generators.md` + - API server and deployment runtime: `references/production-runtime.md` + + ### TypeScript client SDK + - REST and TypeScript client surface: `references/api-client.md` + - Browser/client-side tool execution: `references/remote-tools.md` + - TypeScript auth helpers and structured errors: `references/client-auth-and-errors.md` + - TypeScript messages, invoke, and stream details: `references/client-messages-invoke-stream.md` + - TypeScript thread, memory, and file APIs: `references/client-threads-memory-files.md` + + ### Testing and QA + - Unit testing without LLM calls (TestAgent, QuickTest, MockToolRegistry, `agentflow test`): `references/unit-testing.md` + - Evaluation framework (EvalSet, criteria, AgentEvaluator, QuickEval, UserSimulator, `agentflow eval`): `references/evaluation.md` + - Testing helpers overview: `references/testing-and-evaluation.md` + +3. Prefer existing Agentflow abstractions over new custom wiring: + - Build workflows with `StateGraph`, `Agent`, `ToolNode`, `AgentState`, and `Message`. + - Use prebuilt agents (`ReactAgent`, `PlanActReflectAgent`, `StructuredOutputAgent`, `SupervisorTeamAgent`, `SwarmAgent`, `RAGAgent`) for common patterns before hand-writing graph loops. + - Persist conversation state with checkpointers; use stores only for cross-thread memory. + - Put business services in `InjectQ` instead of global variables. + - Keep API/CLI graph modules storage-agnostic and wire dependencies through `agentflow.json`. + +4. Verify against source when implementation details matter. Public names and expected behavior should match https://agentflow.10xscale.ai/; source under https://github.com/10xHub/Agentflow (core), https://github.com/10xHub/agentflow-cli (API/CLI), and https://github.com/10xHub/agentflow-client (TypeScript) explains how that behavior is implemented. + +## Local Conventions + +- A compiled graph is normally loaded once by the API server and reused per request. +- Public package naming matters: use `10xscale-agentflow`, `10xscale-agentflow-cli`, and `@10xscale/agentflow-client` in user-facing docs and examples, not repository folder names. +- Every persisted interaction should include `config.thread_id`. +- Tools need docstrings and type annotations so model-facing schemas are useful. +- Injectable tool and node parameters (`state`, `config`, `tool_call_id`) are hidden from the model schema. +- For production, avoid process-local storage for shared state; use durable checkpointer/store backends. +- Add observability or audit side effects by registering a `GraphLifecycleHook` on `CallbackManager` — do not wrap `ainvoke()` / `astream()` calls in application code to achieve the same result. +- `reasoning_config` is on by default at medium effort; disable explicitly with `reasoning_config=None` when not needed. +- Provider is auto-detected from the model name; use `base_url` for third-party OpenAI-compatible APIs (Ollama, DeepSeek, OpenRouter). diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/agents-and-tools.md b/agentflow_cli/cli/templates/skills/agentflow/references/agents-and-tools.md similarity index 86% rename from agentflow_cli/cli/templates/skills/agent-skills/references/agents-and-tools.md rename to agentflow_cli/cli/templates/skills/agentflow/references/agents-and-tools.md index bb71b7e..c7b1ef9 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/agents-and-tools.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/agents-and-tools.md @@ -344,6 +344,40 @@ graph.add_node("TOOL", tool_node) --- +## Skills (Agent Skills specification) + +`skills=SkillConfig(...)` loads skills that follow https://agentskills.io/specification: one +directory per skill with a `SKILL.md` (YAML frontmatter `name` + `description`, optional +`license`, `compatibility`, `allowed-tools`, `metadata`) and optional `scripts/`, `references/`, +`assets/`. + +```python +from agentflow.core.skills import SkillConfig + +agent = Agent( + model="gpt-4o", + tool_node=ToolNode([]), + skills=SkillConfig(skills_dir=["./.agents/skills", "./shared-skills"]), +) +``` + +- On-demand mode (default) adds an `` catalog (name + description) to the + system prompt and registers `activate_skill(skill_name)`. The result is the SKILL.md body in + `` tags plus a `` file list. +- `read_skill_resource(skill_name, path)` is registered when any skill bundles files. It reads any + file in the skill directory as text (markdown, `.py`, `.sh`, JSON, ...); nothing is executed. +- `mode="session", preload_from="FIELD"` injects the skill named by `state.FIELD` every call. +- Activated skills are recorded in `state.execution_meta.internal_data["active_skills"]` and are + re-injected if context trimming drops their tool result. +- Agentflow extensions live in `metadata` as strings: `triggers: "a; b"`, `tags: "x, y"`, + `priority: "10"`. +- `SkillConfig` options: `skills_dir` (path or ordered list; earlier wins on name clashes), + `inject_catalog`, `hot_reload`, `max_resource_bytes`, `include_skill_path`, `mode`, + `preload_from`. +- Validate with `agentflow skills --validate PATH` or `agentflow.core.skills.validate_skill`. + +--- + ## Source map - Agent: https://github.com/10xHub/Agentflow/blob/main/agentflow/agentflow/core/graph/agent.py diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/api-client.md b/agentflow_cli/cli/templates/skills/agentflow/references/api-client.md similarity index 94% rename from agentflow_cli/cli/templates/skills/agent-skills/references/api-client.md rename to agentflow_cli/cli/templates/skills/agentflow/references/api-client.md index 4793689..55c414c 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/api-client.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/api-client.md @@ -21,7 +21,6 @@ Graph routes include: - `GET /v1/graph` - `GET /v1/graph:StateSchema` - `POST /v1/graph/stop` -- `POST /v1/graph/setup` - `POST /v1/graph/fix` Memory/file routes are summarized in their topic references. @@ -31,11 +30,11 @@ Memory/file routes are summarized in their topic references. `AgentFlowClient` is exported by `@10xscale/agentflow-client`. Its source facade is https://github.com/10xHub/agentflow-client/blob/main/src/client.ts, and it wraps: - Connectivity and metadata: `ping`, `graph`, `graphStateSchema` -- Execution: `invoke`, `stream`, `stopGraph`, `fixGraph`, `setup` +- Execution: `invoke`, `stream`, `stopGraph`, `fixGraph` - Threads/messages: `threadState`, `updateThreadState`, `clearThreadState`, `threadDetails`, `threads`, `threadMessages`, `addThreadMessages`, `singleMessage`, `deleteMessage`, `deleteThread` - Memory: `storeMemory`, `searchMemory`, `getMemory`, `updateMemory`, `deleteMemory`, `listMemories`, `forgetMemories` - Files: `uploadFile`, `getFile`, `getFileInfo`, `getFileAccessUrl`, `getMultimodalConfig` -- Remote tools: `registerTool`, then `setup` +- Remote tools: schemas in server `agentflow.json`, handlers via `registerToolHandler` For remote tools, read `remote-tools.md`; that flow has a client-managed execution loop around `remote_tool_call` blocks. diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/api-configuration.md b/agentflow_cli/cli/templates/skills/agentflow/references/api-configuration.md similarity index 89% rename from agentflow_cli/cli/templates/skills/agent-skills/references/api-configuration.md rename to agentflow_cli/cli/templates/skills/agentflow/references/api-configuration.md index 7f3e910..70ce0ab 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/api-configuration.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/api-configuration.md @@ -24,13 +24,14 @@ Common full shape: "authorization": "graph.auth:my_authorization_backend", "env": ".env", "auth": "jwt", + "remote_tools": [], "rate_limit": { "enabled": true, "backend": "memory", "requests": 100, "window": 60, "by": "ip", - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"] } } ``` @@ -45,6 +46,7 @@ Common full shape: - `authorization`: `null` (mode default: `ownership` in production, `allow_all` in dev), a built-in name (`"ownership"` | `"allow_all"`/`"default"`/`"none"`), an RBAC config object (`{"backend": "rbac", "roles": {...}, "default_scopes": [...], "isolation": "owner"}`), or a `module:attr` import path to a custom `AuthorizationBackend`. See `references/auth-and-authorization.md`. - `env`: optional `.env` path loaded before graph import. - `auth`: `null`, `"jwt"`, or `{"method": "custom", "path": "module:backend"}`. +- `remote_tools`: validated client-executed tool schemas, attached once at startup. - `rate_limit`: optional sliding-window rate limiter config object; omit or set to `null` to disable. See `references/rate-limiting.md` for the full field reference. ## Loading Order @@ -52,7 +54,7 @@ Common full shape: 1. Read `agentflow.json`. 2. Load `.env` when configured. 3. Import the compiled graph from `agent`. -4. Import and bind `checkpointer`, `store`, `injectq`, `thread_name_generator`, and `authorization` when configured. +4. Attach `remote_tools`, then import and bind `checkpointer`, `store`, `injectq`, `thread_name_generator`, and `authorization` when configured. 5. Configure auth. 6. Start FastAPI routes and services. diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/api-settings-and-middleware.md b/agentflow_cli/cli/templates/skills/agentflow/references/api-settings-and-middleware.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/api-settings-and-middleware.md rename to agentflow_cli/cli/templates/skills/agentflow/references/api-settings-and-middleware.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/architecture.md b/agentflow_cli/cli/templates/skills/agentflow/references/architecture.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/architecture.md rename to agentflow_cli/cli/templates/skills/agentflow/references/architecture.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/auth-and-authorization.md b/agentflow_cli/cli/templates/skills/agentflow/references/auth-and-authorization.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/auth-and-authorization.md rename to agentflow_cli/cli/templates/skills/agentflow/references/auth-and-authorization.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/callbacks-and-command.md b/agentflow_cli/cli/templates/skills/agentflow/references/callbacks-and-command.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/callbacks-and-command.md rename to agentflow_cli/cli/templates/skills/agentflow/references/callbacks-and-command.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/checkpointing-and-threads.md b/agentflow_cli/cli/templates/skills/agentflow/references/checkpointing-and-threads.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/checkpointing-and-threads.md rename to agentflow_cli/cli/templates/skills/agentflow/references/checkpointing-and-threads.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/cli-commands.md b/agentflow_cli/cli/templates/skills/agentflow/references/cli-commands.md similarity index 77% rename from agentflow_cli/cli/templates/skills/agent-skills/references/cli-commands.md rename to agentflow_cli/cli/templates/skills/agentflow/references/cli-commands.md index 2206b7a..1970bd8 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/cli-commands.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/cli-commands.md @@ -20,7 +20,7 @@ agentflow = agentflow_cli.cli.main:main `agentflow init` -- Scaffolds `agentflow.json`, `graph/__init__.py`, `graph/react.py`, and `skills/agent-skills`. +- Scaffolds `agentflow.json`, `graph/__init__.py`, and `graph/react.py`, and suggests `agentflow skills` as a next step. - Options include `--path/-p`, `--force/-f`, `--prod`, `--verbose/-v`, and `--quiet/-q`. - `--prod` also adds production project files such as `pyproject.toml` and `.pre-commit-config.yaml`. @@ -43,12 +43,13 @@ agentflow = agentflow_cli.cli.main:main `agentflow skills` -- Installs the bundled Agentflow skill into an agent-specific project directory. +- Installs the bundled Agentflow skill (an Agent Skills spec folder: `SKILL.md` + `references/`) into an agent-specific project directory. Every agent gets the same folder; paths inside it are relative to the skill directory. - Prompts for the target agent when `--agent` is omitted: - `1` / `codex`: `.agents/skills/agentflow` - `2` / `claude`: `.claude/skills/agentflow` - `3` / `github`: `.github/instructions/agentflow.instructions.md` and `.github/skills/agentflow` -- Options include `--agent/-a`, `--path/-p`, `--force/-f`, `--verbose/-v`, and `--quiet/-q`. +- Options include `--agent/-a`, `--path/-p`, `--force/-f`, `--all`, `--list/-l`, `--verbose/-v`, and `--quiet/-q`. +- `--validate PATH` (repeatable) checks a skill directory, or a folder of skill directories, against the Agent Skills specification (https://agentskills.io/specification) and exits non-zero on errors. Nothing is installed. - Source templates: https://github.com/10xHub/agentflow-cli/tree/main/agentflow_cli/cli/templates/skills `agentflow version` diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/client-auth-and-errors.md b/agentflow_cli/cli/templates/skills/agentflow/references/client-auth-and-errors.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/client-auth-and-errors.md rename to agentflow_cli/cli/templates/skills/agentflow/references/client-auth-and-errors.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/client-messages-invoke-stream.md b/agentflow_cli/cli/templates/skills/agentflow/references/client-messages-invoke-stream.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/client-messages-invoke-stream.md rename to agentflow_cli/cli/templates/skills/agentflow/references/client-messages-invoke-stream.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/client-threads-memory-files.md b/agentflow_cli/cli/templates/skills/agentflow/references/client-threads-memory-files.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/client-threads-memory-files.md rename to agentflow_cli/cli/templates/skills/agentflow/references/client-threads-memory-files.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/context-id-background.md b/agentflow_cli/cli/templates/skills/agentflow/references/context-id-background.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/context-id-background.md rename to agentflow_cli/cli/templates/skills/agentflow/references/context-id-background.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/dependency-injection.md b/agentflow_cli/cli/templates/skills/agentflow/references/dependency-injection.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/dependency-injection.md rename to agentflow_cli/cli/templates/skills/agentflow/references/dependency-injection.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/evaluation.md b/agentflow_cli/cli/templates/skills/agentflow/references/evaluation.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/evaluation.md rename to agentflow_cli/cli/templates/skills/agentflow/references/evaluation.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/id-and-thread-name-generators.md b/agentflow_cli/cli/templates/skills/agentflow/references/id-and-thread-name-generators.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/id-and-thread-name-generators.md rename to agentflow_cli/cli/templates/skills/agentflow/references/id-and-thread-name-generators.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/media-and-files.md b/agentflow_cli/cli/templates/skills/agentflow/references/media-and-files.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/media-and-files.md rename to agentflow_cli/cli/templates/skills/agentflow/references/media-and-files.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/memory-and-store.md b/agentflow_cli/cli/templates/skills/agentflow/references/memory-and-store.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/memory-and-store.md rename to agentflow_cli/cli/templates/skills/agentflow/references/memory-and-store.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/prebuilt-agents-and-tools.md b/agentflow_cli/cli/templates/skills/agentflow/references/prebuilt-agents-and-tools.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/prebuilt-agents-and-tools.md rename to agentflow_cli/cli/templates/skills/agentflow/references/prebuilt-agents-and-tools.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/production-runtime.md b/agentflow_cli/cli/templates/skills/agentflow/references/production-runtime.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/production-runtime.md rename to agentflow_cli/cli/templates/skills/agentflow/references/production-runtime.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/providers-and-adapters.md b/agentflow_cli/cli/templates/skills/agentflow/references/providers-and-adapters.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/providers-and-adapters.md rename to agentflow_cli/cli/templates/skills/agentflow/references/providers-and-adapters.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/publishers-and-runtime-protocols.md b/agentflow_cli/cli/templates/skills/agentflow/references/publishers-and-runtime-protocols.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/publishers-and-runtime-protocols.md rename to agentflow_cli/cli/templates/skills/agentflow/references/publishers-and-runtime-protocols.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/rate-limiting.md b/agentflow_cli/cli/templates/skills/agentflow/references/rate-limiting.md similarity index 97% rename from agentflow_cli/cli/templates/skills/agent-skills/references/rate-limiting.md rename to agentflow_cli/cli/templates/skills/agentflow/references/rate-limiting.md index fc0b044..a8efb7c 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/rate-limiting.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/rate-limiting.md @@ -20,7 +20,7 @@ In-memory backend for local development or single-process services: "requests": 100, "window": 60, "by": "ip", - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"] } } ``` @@ -46,7 +46,7 @@ pip install "10xscale-agentflow-cli[redis]" "window": 60, "by": "ip", "trusted_proxy_headers": true, - "exclude_paths": ["/health", "/metrics", "/docs", "/redoc", "/openapi.json"], + "exclude_paths": ["/ping", "/metrics", "/docs", "/redoc", "/openapi.json"], "redis": { "url": "${RATE_LIMIT_REDIS_URL}", "prefix": "agentflow:rate-limit" diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/realtime.md b/agentflow_cli/cli/templates/skills/agentflow/references/realtime.md similarity index 98% rename from agentflow_cli/cli/templates/skills/agent-skills/references/realtime.md rename to agentflow_cli/cli/templates/skills/agentflow/references/realtime.md index 7014289..0d16174 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/realtime.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/realtime.md @@ -46,12 +46,12 @@ app = AudioAgent( - Tools work like a normal `ToolNode` (reason -> tool -> respond, including barge-in). No sub-agents / handoff in v1. - `system_prompt`, `skills`, and `memory` work like `ReactAgent`: the agent's `system_prompt` (plus - the skills trigger table / session-mode skill content and the memory system prompt) is flattened + the skills catalog / session-mode skill content and the memory system prompt) is flattened into the single Gemini Live `system_instruction` at connect, and `{field}` placeholders are interpolated from state exactly like the turn-based path. Skill/memory **tools** are advertised normally. Caveat: `system_instruction` is fixed for the session, so state-dependent content (session-mode skill from a state field, memory preload) is a connect-time snapshot. Mid-session - dynamism goes through `set_skill` / memory tools, which work continuously. + dynamism goes through `activate_skill` / memory tools, which work continuously. `LiveAgent` (the graph root `AudioAgent` wraps) is at `from agentflow.core.realtime.live_agent import LiveAgent`. It is not re-exported from diff --git a/agentflow_cli/cli/templates/skills/agentflow/references/remote-tools.md b/agentflow_cli/cli/templates/skills/agentflow/references/remote-tools.md new file mode 100644 index 0000000..550a5ca --- /dev/null +++ b/agentflow_cli/cli/templates/skills/agentflow/references/remote-tools.md @@ -0,0 +1,55 @@ +# Remote Tools + +Use remote tools for browser or client-owned capabilities while the Python graph runs on the API server. + +## Registration + +Declare the trusted schema in `agentflow.json`: + +```json +{ + "remote_tools": [ + { + "node": "TOOLS", + "name": "read_browser_state", + "description": "Read selected browser state.", + "parameters": {"type": "object", "properties": {}, "required": []} + } + ] +} +``` + +The API validates and attaches these schemas during startup. `node_name` is accepted as an alias for `node`; other unknown keys fail validation. + +Register only the implementation in TypeScript: + +```typescript +client.registerToolHandler("read_browser_state", async (args) => { + return readBrowserState(args); +}); +``` + +No setup endpoint exists. A request cannot add, rename, or replace graph tools. + +## Execution Flow + +1. Model requests a configured remote tool. +2. `ToolNode` returns a `RemoteToolCallBlock` instead of executing locally. +3. TypeScript `invoke` or `stream` runs the matching handler. +4. Client sends a `ToolResultBlock`; graph execution continues. + +## Rules + +- Schema name and client handler name must match exactly. +- `node` must identify a `ToolNode` in the graph. +- Remote tool names must be globally unique. +- Parameters must be an object JSON Schema. +- Handler results must be serializable. +- Use local Python or MCP tools for server-owned execution. + +## Source Map + +- Config model: `agentflow_cli/src/app/core/config/graph_config.py` +- Startup attachment: `agentflow_cli/src/app/loader.py` +- Python remote handling: `agentflow/core/graph/tool_node/base.py` +- TypeScript executor: `agentflow-client/src/tools.ts` diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/rest-api-and-errors.md b/agentflow_cli/cli/templates/skills/agentflow/references/rest-api-and-errors.md similarity index 99% rename from agentflow_cli/cli/templates/skills/agent-skills/references/rest-api-and-errors.md rename to agentflow_cli/cli/templates/skills/agentflow/references/rest-api-and-errors.md index a52d9a0..ea53a7e 100644 --- a/agentflow_cli/cli/templates/skills/agent-skills/references/rest-api-and-errors.md +++ b/agentflow_cli/cli/templates/skills/agentflow/references/rest-api-and-errors.md @@ -19,7 +19,6 @@ Registered in https://github.com/10xHub/agentflow-cli/blob/main/agentflow_cli/sr - `GET /v1/graph` - `GET /v1/graph:StateSchema` - `POST /v1/graph/stop` -- `POST /v1/graph/setup` - `POST /v1/graph/fix` - `WS /v1/graph/live` — realtime audio bridge, exposed only when the configured graph is rooted at a `LiveAgent`. First frame is a JSON control frame (`model`, `thread_id`, `voice`, ... override diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/security-and-validators.md b/agentflow_cli/cli/templates/skills/agentflow/references/security-and-validators.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/security-and-validators.md rename to agentflow_cli/cli/templates/skills/agentflow/references/security-and-validators.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/state-and-messages.md b/agentflow_cli/cli/templates/skills/agentflow/references/state-and-messages.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/state-and-messages.md rename to agentflow_cli/cli/templates/skills/agentflow/references/state-and-messages.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/state-graph.md b/agentflow_cli/cli/templates/skills/agentflow/references/state-graph.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/state-graph.md rename to agentflow_cli/cli/templates/skills/agentflow/references/state-graph.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/stream-emitter.md b/agentflow_cli/cli/templates/skills/agentflow/references/stream-emitter.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/stream-emitter.md rename to agentflow_cli/cli/templates/skills/agentflow/references/stream-emitter.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/streaming.md b/agentflow_cli/cli/templates/skills/agentflow/references/streaming.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/streaming.md rename to agentflow_cli/cli/templates/skills/agentflow/references/streaming.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/testing-and-evaluation.md b/agentflow_cli/cli/templates/skills/agentflow/references/testing-and-evaluation.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/testing-and-evaluation.md rename to agentflow_cli/cli/templates/skills/agentflow/references/testing-and-evaluation.md diff --git a/agentflow_cli/cli/templates/skills/agent-skills/references/unit-testing.md b/agentflow_cli/cli/templates/skills/agentflow/references/unit-testing.md similarity index 100% rename from agentflow_cli/cli/templates/skills/agent-skills/references/unit-testing.md rename to agentflow_cli/cli/templates/skills/agentflow/references/unit-testing.md diff --git a/agentflow_cli/cli/templates/skills/claude/SKILL.md b/agentflow_cli/cli/templates/skills/claude/SKILL.md deleted file mode 100644 index 648c2c9..0000000 --- a/agentflow_cli/cli/templates/skills/claude/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: agentflow -description: Expert guidance for building, debugging, and extending applications with AgentFlow (10xscale-agentflow). TRIGGER when: code imports from agentflow (e.g. `from agentflow import`, `StateGraph`, `Agent`, `ToolNode`, `AgentState`); user references `agentflow.json` or CLI commands (`agentflow init`, `agentflow api`, `agentflow play`, `agentflow build`, `agentflow skills`); user is building graph-based multi-agent workflows, tools, memory, checkpointing, or streaming with this framework. SKIP: generic Python or multi-agent questions not referencing agentflow; other frameworks (LangGraph, CrewAI, AutoGen) unless comparing. ---- - -# Agentflow Project Skill - -Use this skill when working in an Agentflow project. Agentflow is a multi-agent framework that wraps official OpenAI and Google SDK capabilities behind a unified graph, agent, tool, state, storage, API, CLI, and TypeScript client interface. - -Treat https://agentflow.10xscale.ai/ as the first source of truth for public package names, install commands, and user-facing behavior. Use implementation source after the docs establish the intended API. - -## Workflow - -1. Identify the published package or docs surface involved: - - PyPI core Python SDK: `10xscale-agentflow` (`pip install 10xscale-agentflow`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow/agentflow - - PyPI API/CLI SDK: `10xscale-agentflow-cli` (`pip install 10xscale-agentflow-cli`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-api/agentflow_cli - - npm TypeScript SDK: `@10xscale/agentflow-client` (`npm install @10xscale/agentflow-client`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-client/src - - Main docs: https://agentflow.10xscale.ai/ - - Playground/UI: `agentflow play` command after installed cli - -2. Read the matching reference file before changing behavior: - - ### Core Python SDK - - Architecture and package flow: `.claude/skills/agentflow/references/architecture.md` - - Agent constructor, provider, reasoning, retry, fallback, output_schema: `.claude/skills/agentflow/references/agents-and-tools.md` - - Graph construction, nodes, edges, compile, interrupts, config keys: `.claude/skills/agentflow/references/state-graph.md` - - State, messages, and content blocks: `.claude/skills/agentflow/references/state-and-messages.md` - - Threads and checkpointing: `.claude/skills/agentflow/references/checkpointing-and-threads.md` - - Dependency injection (InjectQ): `.claude/skills/agentflow/references/dependency-injection.md` - - Multimodal files and media stores: `.claude/skills/agentflow/references/media-and-files.md` - - Long-term memory stores (MemoryConfig, QdrantStore, Mem0Store): `.claude/skills/agentflow/references/memory-and-store.md` - - Streaming, StreamChunk, SSE, ResponseGranularity: `.claude/skills/agentflow/references/streaming.md` - - Stream emitter for tool progress updates: `.claude/skills/agentflow/references/stream-emitter.md` - - Observability hooks, validators, and runtime jumps: `.claude/skills/agentflow/references/callbacks-and-command.md` - - Prebuilt agents (ReactAgent, PlanActReflectAgent, StructuredOutputAgent, SupervisorTeamAgent, SwarmAgent, RAGAgent) and tools: `.claude/skills/agentflow/references/prebuilt-agents-and-tools.md` - - Event publishers and A2A/ACP runtime protocols: `.claude/skills/agentflow/references/publishers-and-runtime-protocols.md` - - Context management, ID generation, and background tasks: `.claude/skills/agentflow/references/context-id-background.md` - - Provider internals and adapters: `.claude/skills/agentflow/references/providers-and-adapters.md` - - Prompt-injection and validation safety: `.claude/skills/agentflow/references/security-and-validators.md` - - Realtime audio-to-audio voice agents (AudioAgent, Gemini Live, `arealtime`, WebSocket bridge): `.claude/skills/agentflow/references/realtime.md` - - ### API/CLI SDK - - CLI commands and generated project files: `.claude/skills/agentflow/references/cli-commands.md` - - `agentflow.json` and dependency loading: `.claude/skills/agentflow/references/api-configuration.md` - - API auth and authorization: `.claude/skills/agentflow/references/auth-and-authorization.md` - - API environment, settings, and middleware: `.claude/skills/agentflow/references/api-settings-and-middleware.md` - - Rate limiting (config, backends, headers, custom backend): `.claude/skills/agentflow/references/rate-limiting.md` - - REST routes and error behavior: `.claude/skills/agentflow/references/rest-api-and-errors.md` - - API Snowflake IDs and thread naming: `.claude/skills/agentflow/references/id-and-thread-name-generators.md` - - API server and deployment runtime: `.claude/skills/agentflow/references/production-runtime.md` - - ### TypeScript client SDK - - REST and TypeScript client surface: `.claude/skills/agentflow/references/api-client.md` - - Browser/client-side tool execution: `.claude/skills/agentflow/references/remote-tools.md` - - TypeScript auth helpers and structured errors: `.claude/skills/agentflow/references/client-auth-and-errors.md` - - TypeScript messages, invoke, and stream details: `.claude/skills/agentflow/references/client-messages-invoke-stream.md` - - TypeScript thread, memory, and file APIs: `.claude/skills/agentflow/references/client-threads-memory-files.md` - - ### Testing and QA - - Unit testing without LLM calls (TestAgent, QuickTest, MockToolRegistry, `agentflow test`): `.claude/skills/agentflow/references/unit-testing.md` - - Evaluation framework (EvalSet, criteria, AgentEvaluator, QuickEval, UserSimulator, `agentflow eval`): `.claude/skills/agentflow/references/evaluation.md` - - Testing helpers overview: `.claude/skills/agentflow/references/testing-and-evaluation.md` - -3. Prefer existing Agentflow abstractions over new custom wiring: - - Build workflows with `StateGraph`, `Agent`, `ToolNode`, `AgentState`, and `Message`. - - Use prebuilt agents (`ReactAgent`, `PlanActReflectAgent`, `StructuredOutputAgent`, `SupervisorTeamAgent`, `SwarmAgent`, `RAGAgent`) for common patterns before hand-writing graph loops. - - Persist conversation state with checkpointers; use stores only for cross-thread memory. - - Put business services in `InjectQ` instead of global variables. - - Keep API/CLI graph modules storage-agnostic and wire dependencies through `agentflow.json`. - -4. Verify against source when implementation details matter. Public names and expected behavior should match https://agentflow.10xscale.ai/; source under https://github.com/10xHub/Agentflow (core), https://github.com/10xHub/agentflow-cli (API/CLI), and https://github.com/10xHub/agentflow-client (TypeScript) explains how that behavior is implemented. - -## Local Conventions - -- A compiled graph is normally loaded once by the API server and reused per request. -- Public package naming matters: use `10xscale-agentflow`, `10xscale-agentflow-cli`, and `@10xscale/agentflow-client` in user-facing docs and examples, not repository folder names. -- Every persisted interaction should include `config.thread_id`. -- Tools need docstrings and type annotations so model-facing schemas are useful. -- Injectable tool and node parameters (`state`, `config`, `tool_call_id`) are hidden from the model schema. -- For production, avoid process-local storage for shared state; use durable checkpointer/store backends. -- Add observability or audit side effects by registering a `GraphLifecycleHook` on `CallbackManager` — do not wrap `ainvoke()` / `astream()` calls in application code to achieve the same result. -- `reasoning_config` is on by default at medium effort; disable explicitly with `reasoning_config=None` when not needed. -- Provider is auto-detected from the model name; use `base_url` for third-party OpenAI-compatible APIs (Ollama, DeepSeek, OpenRouter). diff --git a/agentflow_cli/cli/templates/skills/codex/SKILL.md b/agentflow_cli/cli/templates/skills/codex/SKILL.md deleted file mode 100644 index 8ebb067..0000000 --- a/agentflow_cli/cli/templates/skills/codex/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: agentflow -description: Expert guidance for building, debugging, and extending applications with AgentFlow (10xscale-agentflow). TRIGGER when: code imports from agentflow (e.g. `from agentflow import`, `StateGraph`, `Agent`, `ToolNode`, `AgentState`); user references `agentflow.json` or CLI commands (`agentflow init`, `agentflow api`, `agentflow play`, `agentflow build`, `agentflow skills`); user is building graph-based multi-agent workflows, tools, memory, checkpointing, or streaming with this framework. SKIP: generic Python or multi-agent questions not referencing agentflow; other frameworks (LangGraph, CrewAI, AutoGen) unless comparing. ---- - -# Agentflow Project Skill - -Use this skill when working in an Agentflow project. Agentflow is a multi-agent framework that wraps official OpenAI and Google SDK capabilities behind a unified graph, agent, tool, state, storage, API, CLI, and TypeScript client interface. - -Treat https://agentflow.10xscale.ai/ as the first source of truth for public package names, install commands, and user-facing behavior. Use implementation source after the docs establish the intended API. - -## Workflow - -1. Identify the published package or docs surface involved: - - PyPI core Python SDK: `10xscale-agentflow` (`pip install 10xscale-agentflow`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow/agentflow - - PyPI API/CLI SDK: `10xscale-agentflow-cli` (`pip install 10xscale-agentflow-cli`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-api/agentflow_cli - - npm TypeScript SDK: `@10xscale/agentflow-client` (`npm install @10xscale/agentflow-client`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-client/src - - Main docs: https://agentflow.10xscale.ai/ - - Playground/UI: `agentflow play` command after installed cli - -2. Read the matching reference file before changing behavior: - - ### Core Python SDK - - Architecture and package flow: `.agents/skills/agentflow/references/architecture.md` - - Agent constructor, provider, reasoning, retry, fallback, output_schema: `.agents/skills/agentflow/references/agents-and-tools.md` - - Graph construction, nodes, edges, compile, interrupts, config keys: `.agents/skills/agentflow/references/state-graph.md` - - State, messages, and content blocks: `.agents/skills/agentflow/references/state-and-messages.md` - - Threads and checkpointing: `.agents/skills/agentflow/references/checkpointing-and-threads.md` - - Dependency injection (InjectQ): `.agents/skills/agentflow/references/dependency-injection.md` - - Multimodal files and media stores: `.agents/skills/agentflow/references/media-and-files.md` - - Long-term memory stores (MemoryConfig, QdrantStore, Mem0Store): `.agents/skills/agentflow/references/memory-and-store.md` - - Streaming, StreamChunk, SSE, ResponseGranularity: `.agents/skills/agentflow/references/streaming.md` - - Stream emitter for tool progress updates: `.agents/skills/agentflow/references/stream-emitter.md` - - Observability hooks, validators, and runtime jumps: `.agents/skills/agentflow/references/callbacks-and-command.md` - - Prebuilt agents (ReactAgent, PlanActReflectAgent, StructuredOutputAgent, SupervisorTeamAgent, SwarmAgent, RAGAgent) and tools: `.agents/skills/agentflow/references/prebuilt-agents-and-tools.md` - - Event publishers and A2A/ACP runtime protocols: `.agents/skills/agentflow/references/publishers-and-runtime-protocols.md` - - Context management, ID generation, and background tasks: `.agents/skills/agentflow/references/context-id-background.md` - - Provider internals and adapters: `.agents/skills/agentflow/references/providers-and-adapters.md` - - Prompt-injection and validation safety: `.agents/skills/agentflow/references/security-and-validators.md` - - Realtime audio-to-audio voice agents (AudioAgent, Gemini Live, `arealtime`, WebSocket bridge): `.agents/skills/agentflow/references/realtime.md` - - ### API/CLI SDK - - CLI commands and generated project files: `.agents/skills/agentflow/references/cli-commands.md` - - `agentflow.json` and dependency loading: `.agents/skills/agentflow/references/api-configuration.md` - - API auth and authorization: `.agents/skills/agentflow/references/auth-and-authorization.md` - - API environment, settings, and middleware: `.agents/skills/agentflow/references/api-settings-and-middleware.md` - - Rate limiting (config, backends, headers, custom backend): `.agents/skills/agentflow/references/rate-limiting.md` - - REST routes and error behavior: `.agents/skills/agentflow/references/rest-api-and-errors.md` - - API Snowflake IDs and thread naming: `.agents/skills/agentflow/references/id-and-thread-name-generators.md` - - API server and deployment runtime: `.agents/skills/agentflow/references/production-runtime.md` - - ### TypeScript client SDK - - REST and TypeScript client surface: `.agents/skills/agentflow/references/api-client.md` - - Browser/client-side tool execution: `.agents/skills/agentflow/references/remote-tools.md` - - TypeScript auth helpers and structured errors: `.agents/skills/agentflow/references/client-auth-and-errors.md` - - TypeScript messages, invoke, and stream details: `.agents/skills/agentflow/references/client-messages-invoke-stream.md` - - TypeScript thread, memory, and file APIs: `.agents/skills/agentflow/references/client-threads-memory-files.md` - - ### Testing and QA - - Unit testing without LLM calls (TestAgent, QuickTest, MockToolRegistry, `agentflow test`): `.agents/skills/agentflow/references/unit-testing.md` - - Evaluation framework (EvalSet, criteria, AgentEvaluator, QuickEval, UserSimulator, `agentflow eval`): `.agents/skills/agentflow/references/evaluation.md` - - Testing helpers overview: `.agents/skills/agentflow/references/testing-and-evaluation.md` - -3. Prefer existing Agentflow abstractions over new custom wiring: - - Build workflows with `StateGraph`, `Agent`, `ToolNode`, `AgentState`, and `Message`. - - Use prebuilt agents (`ReactAgent`, `PlanActReflectAgent`, `StructuredOutputAgent`, `SupervisorTeamAgent`, `SwarmAgent`, `RAGAgent`) for common patterns before hand-writing graph loops. - - Persist conversation state with checkpointers; use stores only for cross-thread memory. - - Put business services in `InjectQ` instead of global variables. - - Keep API/CLI graph modules storage-agnostic and wire dependencies through `agentflow.json`. - -4. Verify against source when implementation details matter. Public names and expected behavior should match https://agentflow.10xscale.ai/; source under https://github.com/10xHub/Agentflow (core), https://github.com/10xHub/agentflow-cli (API/CLI), and https://github.com/10xHub/agentflow-client (TypeScript) explains how that behavior is implemented. - -## Local Conventions - -- A compiled graph is normally loaded once by the API server and reused per request. -- Public package naming matters: use `10xscale-agentflow`, `10xscale-agentflow-cli`, and `@10xscale/agentflow-client` in user-facing docs and examples, not repository folder names. -- Every persisted interaction should include `config.thread_id`. -- Tools need docstrings and type annotations so model-facing schemas are useful. -- Injectable tool and node parameters (`state`, `config`, `tool_call_id`) are hidden from the model schema. -- For production, avoid process-local storage for shared state; use durable checkpointer/store backends. -- Add observability or audit side effects by registering a `GraphLifecycleHook` on `CallbackManager` — do not wrap `ainvoke()` / `astream()` calls in application code to achieve the same result. -- `reasoning_config` is on by default at medium effort; disable explicitly with `reasoning_config=None` when not needed. -- Provider is auto-detected from the model name; use `base_url` for third-party OpenAI-compatible APIs (Ollama, DeepSeek, OpenRouter). diff --git a/agentflow_cli/cli/templates/skills/copilot/SKILL.md b/agentflow_cli/cli/templates/skills/copilot/SKILL.md deleted file mode 100644 index dd18d61..0000000 --- a/agentflow_cli/cli/templates/skills/copilot/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: agentflow -description: Expert guidance for building, debugging, and extending applications with AgentFlow (10xscale-agentflow). TRIGGER when: code imports from agentflow (e.g. `from agentflow import`, `StateGraph`, `Agent`, `ToolNode`, `AgentState`); user references `agentflow.json` or CLI commands (`agentflow init`, `agentflow api`, `agentflow play`, `agentflow build`, `agentflow skills`); user is building graph-based multi-agent workflows, tools, memory, checkpointing, or streaming with this framework. SKIP: generic Python or multi-agent questions not referencing agentflow; other frameworks (LangGraph, CrewAI, AutoGen) unless comparing. ---- - -# Agentflow Project Skill - -Use this skill when working in an Agentflow project. Agentflow is a multi-agent framework that wraps official OpenAI and Google SDK capabilities behind a unified graph, agent, tool, state, storage, API, CLI, and TypeScript client interface. - -Treat https://agentflow.10xscale.ai/ as the first source of truth for public package names, install commands, and user-facing behavior. Use implementation source after the docs establish the intended API. - -## Workflow - -1. Identify the published package or docs surface involved: - - PyPI core Python SDK: `10xscale-agentflow` (`pip install 10xscale-agentflow`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow/agentflow - - PyPI API/CLI SDK: `10xscale-agentflow-cli` (`pip install 10xscale-agentflow-cli`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-api/agentflow_cli - - npm TypeScript SDK: `@10xscale/agentflow-client` (`npm install @10xscale/agentflow-client`), source at https://github.com/10xHub/Agentflow/tree/main/agentflow-client/src - - Main docs: https://agentflow.10xscale.ai/ - - Playground/UI: `agentflow play` command after installed cli - -2. Read the matching reference file before changing behavior: - - ### Core Python SDK - - Architecture and package flow: `.github/skills/agentflow/references/architecture.md` - - Agent constructor, provider, reasoning, retry, fallback, output_schema: `.github/skills/agentflow/references/agents-and-tools.md` - - Graph construction, nodes, edges, compile, interrupts, config keys: `.github/skills/agentflow/references/state-graph.md` - - State, messages, and content blocks: `.github/skills/agentflow/references/state-and-messages.md` - - Thread and checkpointing: `.github/skills/agentflow/references/checkpointing-and-threads.md` - - Dependency injection (InjectQ): `.github/skills/agentflow/references/dependency-injection.md` - - Multimodal files and media stores: `.github/skills/agentflow/references/media-and-files.md` - - Long-term memory stores (MemoryConfig, QdrantStore, Mem0Store): `.github/skills/agentflow/references/memory-and-store.md` - - Streaming, StreamChunk, SSE, ResponseGranularity: `.github/skills/agentflow/references/streaming.md` - - Stream emitter for tool progress updates: `.github/skills/agentflow/references/stream-emitter.md` - - Observability hooks, validators, and runtime jumps: `.github/skills/agentflow/references/callbacks-and-command.md` - - Prebuilt agents (ReactAgent, PlanActReflectAgent, StructuredOutputAgent, SupervisorTeamAgent, SwarmAgent, RAGAgent) and tools: `.github/skills/agentflow/references/prebuilt-agents-and-tools.md` - - Event publishers and A2A/ACP runtime protocols: `.github/skills/agentflow/references/publishers-and-runtime-protocols.md` - - Context management, ID generation, and background tasks: `.github/skills/agentflow/references/context-id-background.md` - - Provider internals and adapters: `.github/skills/agentflow/references/providers-and-adapters.md` - - Prompt-injection and validation safety: `.github/skills/agentflow/references/security-and-validators.md` - - Realtime audio-to-audio voice agents (AudioAgent, Gemini Live, `arealtime`, WebSocket bridge): `.github/skills/agentflow/references/realtime.md` - - ### API/CLI SDK - - CLI commands and generated project files: `.github/skills/agentflow/references/cli-commands.md` - - `agentflow.json` and dependency loading: `.github/skills/agentflow/references/api-configuration.md` - - API auth and authorization: `.github/skills/agentflow/references/auth-and-authorization.md` - - API environment, settings, and middleware: `.github/skills/agentflow/references/api-settings-and-middleware.md` - - Rate limiting (config, backends, headers, custom backend): `.github/skills/agentflow/references/rate-limiting.md` - - REST routes and error behavior: `.github/skills/agentflow/references/rest-api-and-errors.md` - - API Snowflake IDs and thread naming: `.github/skills/agentflow/references/id-and-thread-name-generators.md` - - API server and deployment runtime: `.github/skills/agentflow/references/production-runtime.md` - - ### TypeScript client SDK - - REST and TypeScript client surface: `.github/skills/agentflow/references/api-client.md` - - Browser/client-side tool execution: `.github/skills/agentflow/references/remote-tools.md` - - TypeScript auth helpers and structured errors: `.github/skills/agentflow/references/client-auth-and-errors.md` - - TypeScript messages, invoke, and stream details: `.github/skills/agentflow/references/client-messages-invoke-stream.md` - - TypeScript thread, memory, and file APIs: `.github/skills/agentflow/references/client-threads-memory-files.md` - - ### Testing and QA - - Unit testing without LLM calls (TestAgent, QuickTest, MockToolRegistry, `agentflow test`): `.github/skills/agentflow/references/unit-testing.md` - - Evaluation framework (EvalSet, criteria, AgentEvaluator, QuickEval, UserSimulator, `agentflow eval`): `.github/skills/agentflow/references/evaluation.md` - - Testing helpers overview: `.github/skills/agentflow/references/testing-and-evaluation.md` - -3. Prefer existing Agentflow abstractions over new custom wiring: - - Build workflows with `StateGraph`, `Agent`, `ToolNode`, `AgentState`, and `Message`. - - Use prebuilt agents (`ReactAgent`, `PlanActReflectAgent`, `StructuredOutputAgent`, `SupervisorTeamAgent`, `SwarmAgent`, `RAGAgent`) for common patterns before hand-writing graph loops. - - Persist conversation state with checkpointers; use stores only for cross-thread memory. - - Put business services in `InjectQ` instead of global variables. - - Keep API/CLI graph modules storage-agnostic and wire dependencies through `agentflow.json`. - -4. Verify against source when implementation details matter. Public names and expected behavior should match https://agentflow.10xscale.ai/; source under https://github.com/10xHub/Agentflow (core), https://github.com/10xHub/agentflow-cli (API/CLI), and https://github.com/10xHub/agentflow-client (TypeScript) explains how that behavior is implemented. - -## Local Conventions - -- A compiled graph is normally loaded once by the API server and reused per request. -- Public package naming matters: use `10xscale-agentflow`, `10xscale-agentflow-cli`, and `@10xscale/agentflow-client` in user-facing docs and examples, not repository folder names. -- Every persisted interaction should include `config.thread_id`. -- Tools need docstrings and type annotations so model-facing schemas are useful. -- Injectable tool and node parameters (`state`, `config`, `tool_call_id`) are hidden from the model schema. -- For production, avoid process-local storage for shared state; use durable checkpointer/store backends. -- Add observability or audit side effects by registering a `GraphLifecycleHook` on `CallbackManager` — do not wrap `ainvoke()` / `astream()` calls in application code to achieve the same result. -- `reasoning_config` is on by default at medium effort; disable explicitly with `reasoning_config=None` when not needed. -- Provider is auto-detected from the model name; use `base_url` for third-party OpenAI-compatible APIs (Ollama, DeepSeek, OpenRouter). diff --git a/agentflow_cli/cli/user_config.py b/agentflow_cli/cli/user_config.py deleted file mode 100644 index ad7f50e..0000000 --- a/agentflow_cli/cli/user_config.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Cross-platform user preferences for the Agentflow CLI.""" - -from __future__ import annotations - -import json -import os -import tempfile -from pathlib import Path -from typing import Any - -from platformdirs import user_config_path - -from agentflow_cli.cli.exceptions import ConfigurationError - - -class UserConfigStore: - """Read and atomically update non-secret per-user CLI preferences.""" - - SCHEMA_VERSION = 1 - - def __init__(self, path: Path | None = None) -> None: - self.path = path or user_config_path("agentflow", "10xscale") / "config.json" - - def load(self) -> dict[str, Any]: - if not self.path.exists(): - return {"schema_version": self.SCHEMA_VERSION} - try: - raw = json.loads(self.path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ConfigurationError( - f"Could not read user configuration at {self.path}: {exc}", - config_path=str(self.path), - ) from exc - if not isinstance(raw, dict): - raise ConfigurationError( - f"User configuration at {self.path} must be a JSON object.", - config_path=str(self.path), - ) - return raw - - def get(self, key: str, default: Any = None) -> Any: - value: Any = self.load() - for part in key.split("."): - if not isinstance(value, dict) or part not in value: - return default - value = value[part] - return value - - def set(self, key: str, value: Any) -> None: - data = self.load() - target = data - parts = key.split(".") - if not all(parts): - raise ConfigurationError("Configuration key cannot be empty.") - for part in parts[:-1]: - existing = target.get(part) - if existing is None: - existing = {} - target[part] = existing - if not isinstance(existing, dict): - raise ConfigurationError( - f"Cannot set '{key}': '{part}' already contains a scalar value.", - config_path=str(self.path), - ) - target = existing - target[parts[-1]] = value - data["schema_version"] = self.SCHEMA_VERSION - self._write(data) - - def unset(self, key: str) -> bool: - data = self.load() - target: Any = data - parts = key.split(".") - for part in parts[:-1]: - if not isinstance(target, dict) or part not in target: - return False - target = target[part] - if not isinstance(target, dict) or parts[-1] not in target: - return False - del target[parts[-1]] - self._write(data) - return True - - def _write(self, data: dict[str, Any]) -> None: - try: - self.path.parent.mkdir(parents=True, exist_ok=True) - fd, temporary_name = tempfile.mkstemp( - prefix=f".{self.path.name}.", - suffix=".tmp", - dir=self.path.parent, - text=True, - ) - temporary_path = Path(temporary_name) - try: - with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: - json.dump(data, handle, indent=2, ensure_ascii=False, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - temporary_path.replace(self.path) - finally: - if temporary_path.exists(): - temporary_path.unlink() - except OSError as exc: - raise ConfigurationError( - f"Could not write user configuration at {self.path}: {exc}", - config_path=str(self.path), - ) from exc - - -def parse_config_value(value: str) -> Any: - """Parse JSON scalars/containers while retaining ordinary strings.""" - try: - return json.loads(value) - except json.JSONDecodeError: - return value diff --git a/agentflow_cli/src/app/core/auth/authorization.py b/agentflow_cli/src/app/core/auth/authorization.py index 3b64c45..7412730 100644 --- a/agentflow_cli/src/app/core/auth/authorization.py +++ b/agentflow_cli/src/app/core/auth/authorization.py @@ -12,6 +12,18 @@ logger = logging.getLogger("agentflow-cli.authorization") +# The /v1/store routes require these. Core releases up to 0.9.1 named them "memory:*", so a +# "*" role would never reach the memory routes; they are added here until core is bumped. +STORE_SCOPES = frozenset({"store:read", "store:write", "store:delete"}) +LEGACY_MEMORY_SCOPES = frozenset({"memory:read", "memory:write", "memory:delete"}) + + +def all_scopes() -> frozenset[str]: + """Every scope the API can require, which is what a ``"*"`` role is granted.""" + from agentflow.core.authz import ALL_SCOPES + + return (frozenset(ALL_SCOPES) - LEGACY_MEMORY_SCOPES) | STORE_SCOPES + class AuthorizationBackend(ABC): """ @@ -312,7 +324,7 @@ class RoleBasedAuthorizationBackend(OwnershipAuthorizationBackend): - **Scopes** (what actions are allowed) -- ``scopes_for`` maps ``user["roles"]`` (or ``user["role"]``) to a set of scopes via the ``role_scopes`` table. ``RequirePermission`` then enforces the required ``":"`` scope. A role granting ``"*"`` - gets every scope in :data:`agentflow.core.authz.ALL_SCOPES`. + gets every scope in :func:`all_scopes`. - **Isolation** (which rows are visible) -- inherited from :class:`OwnershipAuthorizationBackend` (owner-only threads), configurable via ``isolation``. @@ -359,11 +371,9 @@ def _roles_of(user: dict[str, Any]) -> list[str]: return [str(r) for r in roles] def scopes_for(self, user: dict[str, Any]) -> list[str]: - from agentflow.core.authz import ALL_SCOPES - granted: set[str] = set(self._default_scopes) for role in self._roles_of(user): granted.update(self._role_scopes.get(role, [])) if "*" in granted: - return sorted(ALL_SCOPES) + return sorted(all_scopes()) return sorted(granted) diff --git a/agentflow_cli/src/app/core/auth/jwt_auth.py b/agentflow_cli/src/app/core/auth/jwt_auth.py index 0f3cecb..37ebe1e 100644 --- a/agentflow_cli/src/app/core/auth/jwt_auth.py +++ b/agentflow_cli/src/app/core/auth/jwt_auth.py @@ -5,7 +5,7 @@ from agentflow_cli.src.app.core import logger from agentflow_cli.src.app.core.auth.base_auth import BaseAuth -from agentflow_cli.src.app.core.config.settings import get_settings +from agentflow_cli.src.app.core.config.settings import Settings, get_settings from agentflow_cli.src.app.core.exceptions import UserAccountError @@ -15,6 +15,34 @@ jwt = None # type: ignore[assignment] +# RFC 7518 3.2: an HMAC key must be at least as long as the hash output (256 bits for HS256). +MIN_HMAC_BYTES = 32 + + +def check_jwt_settings(settings: Settings) -> None: + """Refuse a guessable HMAC secret at startup (production), warn about it otherwise. + + A short HS* secret can be brute-forced offline from any one token, after which anyone can + mint a token for any ``user_id``. + """ + algorithm = (settings.JWT_ALGORITHM or "").upper() + secret = settings.JWT_SECRET_KEY or "" + if not algorithm.startswith("HS") or len(secret.encode()) >= MIN_HMAC_BYTES: + return + message = ( + f"JWT_SECRET_KEY is shorter than {MIN_HMAC_BYTES} bytes, which is too weak for " + f"{algorithm}. Generate one with: " + "python -c 'import secrets; print(secrets.token_urlsafe(48))'" + ) + if settings.MODE == "production": + raise ValueError(message) + logger.warning(message) + + +def _unauthorized(message: str, error_code: str) -> UserAccountError: + return UserAccountError(message=message, error_code=error_code, status_code=401) + + class JwtAuth(BaseAuth): def authenticate( self, @@ -45,10 +73,7 @@ def authenticate( """ if credential is None: - raise UserAccountError( - message="Invalid token, please login again", - error_code="REVOKED_TOKEN", - ) + raise _unauthorized("Invalid token, please login again", "REVOKED_TOKEN") settings = get_settings() jwt_secret_key = settings.JWT_SECRET_KEY @@ -66,33 +91,32 @@ def authenticate( raise UserAccountError( message="JWT settings are not configured", error_code="JWT_SETTINGS_NOT_CONFIGURED", + status_code=500, ) + required = ["exp"] + if settings.JWT_ISSUER: + required.append("iss") + if settings.JWT_AUDIENCE: + required.append("aud") try: decoded_token = jwt.decode( token, jwt_secret_key, algorithms=[jwt_algorithm], - options={"require": ["exp"]}, + issuer=settings.JWT_ISSUER or None, + audience=settings.JWT_AUDIENCE or None, + options={"require": required}, ) except jwt.ExpiredSignatureError: - raise UserAccountError( - message="Token has expired, please login again", - error_code="EXPIRED_TOKEN", - ) + raise _unauthorized("Token has expired, please login again", "EXPIRED_TOKEN") except jwt.InvalidTokenError as err: - logger.exception("JWT AUTH ERROR", exc_info=err) - raise UserAccountError( - message="Invalid token, please login again", - error_code="INVALID_TOKEN", - ) + logger.warning("JWT rejected: %s", type(err).__name__) + raise _unauthorized("Invalid token, please login again", "INVALID_TOKEN") response.headers["WWW-Authenticate"] = 'Bearer realm="auth_required"' # check if user_id exists in the token if "user_id" not in decoded_token: - raise UserAccountError( - message="Invalid token, user_id missing", - error_code="INVALID_TOKEN", - ) + raise _unauthorized("Invalid token, user_id missing", "INVALID_TOKEN") return decoded_token diff --git a/agentflow_cli/src/app/core/auth/ownership_resolver.py b/agentflow_cli/src/app/core/auth/ownership_resolver.py index 0b2be32..4786114 100644 --- a/agentflow_cli/src/app/core/auth/ownership_resolver.py +++ b/agentflow_cli/src/app/core/auth/ownership_resolver.py @@ -7,9 +7,14 @@ Cache tiers: -- **L1**: a bounded in-process LRU (per worker). Immutable owners, no expiry. +- **L1**: a bounded in-process LRU (per worker) with a short TTL. - **L2** (optional): a shared Redis, so workers/instances do not each re-hit the database. +Deleting a thread evicts it from this worker's L1 and from L2, but another worker's L1 cannot +be reached. A deleted id can be created again by a different user, so a stale L1 entry would +keep granting the old owner access on that worker. The L1 TTL bounds how long that lasts; the +L2 TTL covers an eviction that failed to reach Redis. + Only *positive* results (a known owner) are cached. A ``None`` result (the thread does not exist yet) is **never** cached: a nonexistent thread can become owned by the very next request, and a cached ``None`` would let a later caller be treated as the creator of a thread @@ -20,6 +25,7 @@ import asyncio import logging +import time from collections import OrderedDict from collections.abc import Awaitable, Callable @@ -31,6 +37,9 @@ # degrade gracefully instead of treating every thread as unowned. OwnerLookup = Callable[[str], Awaitable["str | int | None"]] +DEFAULT_L1_TTL_SECONDS = 30.0 +DEFAULT_L2_TTL_SECONDS = 3600 + class ThreadOwnershipResolver: """Resolve (and cache) the owning ``user_id`` for a ``thread_id``. @@ -44,6 +53,8 @@ class ThreadOwnershipResolver: never fail a request. max_size: L1 LRU capacity (entries). redis_prefix: key namespace for L2 entries. + l1_ttl: seconds an L1 entry is trusted; bounds staleness after a delete elsewhere. + l2_ttl: seconds an L2 entry lives in Redis. """ def __init__( @@ -53,12 +64,17 @@ def __init__( redis: object | None = None, max_size: int = 10_000, redis_prefix: str = "af:authz:owner", + l1_ttl: float = DEFAULT_L1_TTL_SECONDS, + l2_ttl: int = DEFAULT_L2_TTL_SECONDS, ) -> None: self._lookup = lookup self._redis = redis self._max_size = max(1, max_size) self._prefix = redis_prefix - self._l1: OrderedDict[str, str] = OrderedDict() + self._l1_ttl = l1_ttl + self._l2_ttl = l2_ttl + # key -> (owner, monotonic expiry) + self._l1: OrderedDict[str, tuple[str, float]] = OrderedDict() self._lock = asyncio.Lock() # ------------------------------------------------------------------ public @@ -100,14 +116,19 @@ async def evict(self, thread_id: str | int) -> None: async def _l1_get(self, key: str) -> str | None: async with self._lock: - owner = self._l1.get(key) - if owner is not None: - self._l1.move_to_end(key) # mark most-recently-used + entry = self._l1.get(key) + if entry is None: + return None + owner, expires_at = entry + if time.monotonic() >= expires_at: + del self._l1[key] + return None + self._l1.move_to_end(key) # mark most-recently-used return owner async def _l1_put(self, key: str, owner: str) -> None: async with self._lock: - self._l1[key] = owner + self._l1[key] = (owner, time.monotonic() + self._l1_ttl) self._l1.move_to_end(key) while len(self._l1) > self._max_size: self._l1.popitem(last=False) # evict least-recently-used @@ -133,8 +154,10 @@ async def _redis_set(self, key: str, owner: str) -> None: if self._redis is None: return try: - # No expiry: ownership is immutable, invalidated only by evict() on delete. - await self._redis.set(self._redis_key(key), owner) # type: ignore[attr-defined] + # evict() removes the entry on delete; the TTL covers an eviction that failed. + await self._redis.set( # type: ignore[attr-defined] + self._redis_key(key), owner, ex=self._l2_ttl + ) except Exception as exc: logger.debug("Ownership L2 set failed for %s: %s", key, exc) diff --git a/agentflow_cli/src/app/core/auth/permissions.py b/agentflow_cli/src/app/core/auth/permissions.py index c506a59..724ca86 100644 --- a/agentflow_cli/src/app/core/auth/permissions.py +++ b/agentflow_cli/src/app/core/auth/permissions.py @@ -5,6 +5,7 @@ authorization checks, reducing code duplication across routers. """ +import email.message from collections.abc import Callable from typing import Any, NoReturn @@ -16,7 +17,9 @@ from agentflow_cli.src.app.core import logger from agentflow_cli.src.app.core.auth.auth_backend import BaseAuth from agentflow_cli.src.app.core.auth.authorization import AuthorizationBackend +from agentflow_cli.src.app.core.auth.request_config import normalize_thread_id from agentflow_cli.src.app.core.config.graph_config import GraphConfig +from agentflow_cli.src.app.core.exceptions.general_exception import GeneralException from agentflow_cli.src.app.core.utils.log_sanitizer import sanitize_for_logging @@ -97,6 +100,23 @@ def _extract_credential( WS_POLICY_VIOLATION = 1008 +def _is_json_content_type(content_type: str | None) -> bool: + """Whether FastAPI parses a body with this content type as JSON. + + Mirrors ``fastapi.routing``: a missing content type, ``application/json`` and any + ``application/*+json`` are all parsed as JSON. Reading fewer types here would let a + request skip the thread check while the route still receives its body. + """ + if not content_type: + return True + message = email.message.Message() + message["content-type"] = content_type + if message.get_content_maintype() != "application": + return False + subtype = message.get_content_subtype() + return subtype == "json" or subtype.endswith("+json") + + def _reject(connection: HTTPConnection, status_code: int, detail: str) -> NoReturn: """Reject a connection with the error type appropriate to its protocol. @@ -149,7 +169,7 @@ def __init__( self.action = action self.extract_resource_id_fn = extract_resource_id - async def __call__( + async def __call__( # noqa: PLR0912 self, connection: HTTPConnection, response: Response, @@ -203,6 +223,12 @@ async def __call__( # JWT/custom backends signal auth failure with HTTPException; convert it # to a clean WebSocket close on WS routes (see _reject). _reject(connection, exc.status_code, str(exc.detail)) + except GeneralException as exc: + # JwtAuth raises UserAccountError (401). HTTP routes render it through its + # exception handler; a WebSocket has no handler, so close it cleanly. + if isinstance(connection, WebSocket): + _reject(connection, exc.status_code, exc.message) + raise if user_result and "user_id" not in user_result: logger.error("Authentication failed: 'user_id' not found in user info") user = user_result or {} @@ -293,29 +319,33 @@ def _extract_resource_id_from_path(self, connection: HTTPConnection) -> str | No return None async def _extract_resource_id_from_body(self, connection: HTTPConnection) -> str | None: - """Extract resource ID (like thread_id) from the request body. + """Extract the thread ID a request body targets. - Only parsed if content-type is JSON and connection is a standard HTTP Request. + The body is read exactly when FastAPI would parse it as JSON, and the ID is normalised + the same way the services normalise it, so the thread checked here is the thread that + runs. Routes read either the root ``thread_id`` (stop, fix) or ``config.thread_id`` + (invoke, stream); a body that names two different threads is rejected rather than + letting the check and the service pick different ones. """ from starlette.requests import Request if not isinstance(connection, Request): return None - - content_type = connection.headers.get("content-type", "") - if "application/json" not in content_type.lower(): + if not _is_json_content_type(connection.headers.get("content-type")): return None try: body = await connection.json() - if isinstance(body, dict): - # 1. Root level thread_id - if body.get("thread_id"): - return str(body["thread_id"]) - # 2. Nested inside config block - cfg = body.get("config") - if isinstance(cfg, dict) and "thread_id" in cfg and cfg["thread_id"]: - return str(cfg["thread_id"]) except Exception as exc: - logger.debug("Failed to extract thread_id from request body: %s", exc) - return None + # FastAPI rejects a malformed body itself before the route runs. + logger.debug("Failed to parse request body for thread_id: %s", exc) + return None + if not isinstance(body, dict): + return None + + root_id = normalize_thread_id(body.get("thread_id")) + cfg = body.get("config") + config_id = normalize_thread_id(cfg.get("thread_id")) if isinstance(cfg, dict) else None + if root_id and config_id and root_id != config_id: + _reject(connection, 400, "thread_id and config.thread_id must match") + return root_id or config_id diff --git a/agentflow_cli/src/app/core/auth/request_config.py b/agentflow_cli/src/app/core/auth/request_config.py new file mode 100644 index 0000000..7f63078 --- /dev/null +++ b/agentflow_cli/src/app/core/auth/request_config.py @@ -0,0 +1,130 @@ +"""Keep server-owned keys out of client-supplied request ``config``. + +Request ``config`` is an open dict on purpose: frontends pass their own values through it and +graph nodes and tools read them. Its keys cannot be allow-listed, because the API does not know +what an application sends. + +A small set of keys is owned by the server instead, and a client must never set them: + +- ``authz`` -- the isolation policy and scopes; the trusted copy lives in ``user["authz"]`` +- ``user`` -- the verified identity +- ``user_id`` -- derived from the verified identity +- ``remote_tools`` -- per-run client tool schemas the model may call; only protocol adapters + whose clients legitimately bring tools (AG-UI) set it, from the protocol's own tool list +- ``_*`` -- framework internals (``_skip_interrupt_at``, ``_node_name``, ...) + +Every route and service that forwards client config to the graph, checkpointer or store must +pass it through :func:`client_config` first, then set the server-owned keys itself. + +The thread a request runs on is also server-controlled: routes use the thread the permission +check approved, and both sides normalise it with :func:`normalize_thread_id`. + +Tool calls are server-controlled too: only the model may request a tool. Client messages +are checked with :func:`client_tool_call_error`. A client may send a tool *result* only as the +answer to a remote tool call that is still waiting for one (:func:`unanswered_tool_calls`). +""" + +from __future__ import annotations + +from typing import Any + + +RESERVED_CONFIG_KEYS: frozenset[str] = frozenset({"authz", "user", "user_id", "remote_tools"}) +INTERNAL_KEY_PREFIX = "_" + + +def _is_client_key(key: Any) -> bool: + return ( + isinstance(key, str) + and key not in RESERVED_CONFIG_KEYS + and not key.startswith(INTERNAL_KEY_PREFIX) + ) + + +def client_config(config: Any) -> dict[str, Any]: + """Return a copy of client-supplied config without server-owned keys. + + Application keys pass through unchanged, including ``thread_id``, which each route then + replaces with, or checks against, the thread the permission check approved. + """ + if not isinstance(config, dict): + return {} + return {key: value for key, value in config.items() if _is_client_key(key)} + + +# Content blocks that ask the server to run a tool. Only the model produces these; tool +# *results* (``tool_result``) are fine, since remote tools send them back from the client. +TOOL_CALL_BLOCK_TYPES: frozenset[str] = frozenset({"tool_call", "remote_tool_call"}) + + +def _field(message: Any, name: str) -> Any: + if isinstance(message, dict): + return message.get(name) + return getattr(message, name, None) + + +def client_tool_call_error(messages: Any) -> str | None: + """Describe the first client-supplied message that carries a tool call, or ``None``. + + A ``ToolNode`` executes the ``tools_calls`` of the last message in context, so a client + that can write a tool call can run any server tool with its own arguments, skipping the + model, the system prompt and any guard node. Accepts ``Message`` objects or plain dicts. + """ + if not isinstance(messages, list | tuple): + return None + for index, message in enumerate(messages): + carries_call = bool(_field(message, "tools_calls")) or any( + _field(block, "type") in TOOL_CALL_BLOCK_TYPES + for block in _field(message, "content") or [] + ) + if carries_call: + return f"messages[{index}] carries a tool call; only the model may request tools" + return None + + +def tool_result_ids(messages: Any) -> list[str]: + """The ``call_id`` of every tool result block in ``messages``, in order.""" + if not isinstance(messages, list | tuple): + return [] + return [ + str(_field(block, "call_id")) + for message in messages + for block in _field(message, "content") or [] + if _field(block, "type") == "tool_result" + ] + + +def _tool_calls(message: Any) -> list[tuple[str, str]]: + """``(call_id, tool_name)`` for every tool call a message makes.""" + calls: list[tuple[str, str]] = [] + for call in _field(message, "tools_calls") or []: + if isinstance(call, dict) and call.get("id"): + name = (call.get("function") or {}).get("name") or call.get("name") or "" + calls.append((str(call["id"]), str(name))) + for block in _field(message, "content") or []: + if _field(block, "type") in TOOL_CALL_BLOCK_TYPES and _field(block, "id"): + calls.append((str(_field(block, "id")), str(_field(block, "name") or ""))) + return calls + + +def unanswered_tool_calls(context: Any) -> dict[str, str]: + """Map ``call_id -> tool_name`` for the tool calls in ``context`` that have no result yet.""" + calls: dict[str, str] = {} + for message in context or []: + if _field(message, "role") == "assistant": + for call_id, name in _tool_calls(message): + calls.setdefault(call_id, name) + for call_id in tool_result_ids(list(context or [])): + calls.pop(call_id, None) + return calls + + +def normalize_thread_id(value: Any) -> str | None: + """The one canonical form of a thread id, shared by the permission check and the services. + + Returns ``None`` for a missing or blank id, which means "start a new thread". + """ + if value is None or isinstance(value, bool): + return None + text = str(value).strip() + return text or None diff --git a/agentflow_cli/src/app/core/auth/route_guard.py b/agentflow_cli/src/app/core/auth/route_guard.py index 2a9caa7..01aacf9 100644 --- a/agentflow_cli/src/app/core/auth/route_guard.py +++ b/agentflow_cli/src/app/core/auth/route_guard.py @@ -43,21 +43,47 @@ def _has_permission_guard(dependant) -> bool: return False +def _is_guard(dependency: object) -> bool: + """True for a ``Depends(RequirePermission(...))`` given at ``include_router`` time.""" + return isinstance(getattr(dependency, "dependency", None), RequirePermission) + + +def _iter_routes(routes, prefix: str = "", guarded: bool = False): + """Yield ``(full_path, route, guarded_by_include)`` for every route, however nested. + + FastAPI 0.139 keeps ``include_router`` lazy: ``app.routes`` holds one wrapper per + included router instead of copies of its routes, so a plain walk of ``app.routes`` sees + no ``APIRoute`` at all. The wrapper carries the router and its include prefix and + dependencies; older releases copy the routes, which the plain branch handles. + """ + for route in routes: + included = getattr(route, "original_router", None) + context = getattr(route, "include_context", None) + if included is not None and context is not None: + yield from _iter_routes( + included.routes, + prefix + (getattr(context, "prefix", "") or ""), + guarded or any(_is_guard(dep) for dep in getattr(context, "dependencies", [])), + ) + elif isinstance(route, APIRoute | APIWebSocketRoute): + yield prefix + route.path, route, guarded + + def find_unprotected_routes( app: FastAPI, public_paths: frozenset[str] = DEFAULT_PUBLIC_PATHS ) -> list[str]: - """Return ``"METHODS path"`` for every route missing a RequirePermission guard.""" + """Return ``"METHODS path"`` for every route missing a RequirePermission guard. + + Starlette infra routes (docs, openapi.json, redoc) are not APIRoutes and are skipped. + """ unprotected: list[str] = [] - for route in app.routes: - if not isinstance(route, APIRoute | APIWebSocketRoute): - # Starlette infra routes (docs, openapi.json, redoc) are not APIRoutes. - continue - if route.path in public_paths: + for path, route, guarded in _iter_routes(app.routes): + if path in public_paths or guarded: continue dependant = getattr(route, "dependant", None) if dependant is None or not _has_permission_guard(dependant): methods = ",".join(sorted(getattr(route, "methods", None) or ["WS"])) - unprotected.append(f"{methods} {route.path}") + unprotected.append(f"{methods} {path}") return unprotected diff --git a/agentflow_cli/src/app/core/config/graph_config.py b/agentflow_cli/src/app/core/config/graph_config.py index 32b5f83..81d07c7 100644 --- a/agentflow_cli/src/app/core/config/graph_config.py +++ b/agentflow_cli/src/app/core/config/graph_config.py @@ -1,15 +1,89 @@ +import ipaddress import json import logging import os from dataclasses import dataclass from pathlib import Path +from typing import Any from dotenv import load_dotenv +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator logger = logging.getLogger("agentflow_api") +def _empty_parameters() -> dict[str, Any]: + return {"type": "object", "properties": {}, "required": []} + + +class RemoteToolConfig(BaseModel): + """Trusted model-facing schema for a tool executed by the client.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + node_name: str = Field(alias="node", min_length=1) + name: str = Field(min_length=1) + description: str = Field(min_length=1) + parameters: dict[str, Any] = Field(default_factory=_empty_parameters) + + @field_validator("node_name", "name", "description") + @classmethod + def _must_not_be_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("must not be blank") + return value + + @field_validator("parameters") + @classmethod + def _validate_parameters(cls, value: dict[str, Any]) -> dict[str, Any]: + parameters = dict(value) + parameters.setdefault("type", "object") + parameters.setdefault("properties", {}) + parameters.setdefault("required", []) + if parameters["type"] != "object": + raise ValueError("remote tool parameters.type must be 'object'") + if not isinstance(parameters["properties"], dict): + raise ValueError("remote tool parameters.properties must be an object") + if not isinstance(parameters["required"], list) or not all( + isinstance(item, str) for item in parameters["required"] + ): + raise ValueError("remote tool parameters.required must be a list of strings") + return parameters + + def to_tool_schema(self) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + +_REMOTE_TOOLS_ADAPTER = TypeAdapter(list[RemoteToolConfig]) + + +def validate_remote_tools(raw: object) -> list[RemoteToolConfig]: + """Validate remote-tool declarations for both CLI audit and API startup.""" + if not isinstance(raw, list): + raise ValueError("remote_tools must be a list") + + try: + tools = _REMOTE_TOOLS_ADAPTER.validate_python(raw) + except ValidationError as exc: + raise ValueError(f"Invalid remote_tools configuration: {exc}") from exc + + seen: set[str] = set() + for tool in tools: + if tool.name in seen: + raise ValueError(f"Duplicate remote tool name '{tool.name}'") + seen.add(tool.name) + return tools + + def _parse_bool(value: object, *, field: str) -> bool: if isinstance(value, bool): return value @@ -31,6 +105,18 @@ def _expand_env(value: str | None) -> str | None: return expanded +def _parse_networks(raw: object) -> tuple[str, ...]: + if not isinstance(raw, list | tuple): + raise ValueError("rate_limit.trusted_proxies must be a list of IPs or CIDR ranges") + networks = [] + for item in raw: + try: + networks.append(str(ipaddress.ip_network(str(item).strip(), strict=False))) + except ValueError as exc: + raise ValueError(f"rate_limit.trusted_proxies: invalid network {item!r}") from exc + return tuple(networks) + + @dataclass class RateLimitConfig: """Rate limit configuration parsed from agentflow.json. @@ -44,7 +130,7 @@ class RateLimitConfig: "window": 60, "by": "ip", "trusted_proxy_headers": false, - "exclude_paths": ["/health", "/docs", "/redoc", "/openapi.json"] + "exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"] } Example (Redis backend):: @@ -56,7 +142,7 @@ class RateLimitConfig: "window": 60, "by": "ip", "trusted_proxy_headers": true, - "exclude_paths": ["/health"], + "exclude_paths": ["/ping"], "redis": { "url": "redis://localhost:6379/0", "prefix": "agentflow:rate-limit" @@ -91,6 +177,10 @@ class RateLimitConfig: # infrastructure; anything further left came from the caller and is forgeable. trusted_proxy_hops: int = 1 fail_open: bool = True # on backend error: True=allow, False=deny + # Networks your proxies connect from. When set, X-Forwarded-For is honoured only for + # requests whose peer address is in one of them, so a client that reaches the app + # directly (bypassing the proxy) cannot pick its own address. + trusted_proxies: tuple[str, ...] = () @classmethod def from_dict(cls, data: dict) -> "RateLimitConfig": @@ -129,6 +219,7 @@ def from_dict(cls, data: dict) -> "RateLimitConfig": # written by infrastructure you control; everything to the left of them came # from the caller and is forgeable. See keying._client_ip. trusted_proxy_hops = int(data.get("trusted_proxy_hops", 1)) + trusted_proxies = _parse_networks(data.get("trusted_proxies", [])) # Validation if by not in ("ip", "global", "user"): @@ -166,9 +257,15 @@ def from_dict(cls, data: dict) -> "RateLimitConfig": trusted_proxy_headers=trusted_proxy_headers, trusted_proxy_hops=trusted_proxy_hops, fail_open=fail_open, + trusted_proxies=trusted_proxies, ) +# Finite defaults so an unconfigured server cannot be held open by one client. +DEFAULT_WS_MAX_CONNECTIONS = 1000 +DEFAULT_WS_MAX_CONNECTIONS_PER_USER = 10 + + @dataclass class WebSocketConfig: """WebSocket connection limits parsed from agentflow.json. @@ -176,30 +273,99 @@ class WebSocketConfig: Example:: "websocket": { - "max_connections": 100 + "max_connections": 1000, + "max_connections_per_user": 10, + "realtime_models": ["gemini-2.5-flash-live"] } - ``max_connections`` caps the number of concurrent WebSocket connections this server - *process* accepts (realtime ``/v1/graph/live`` + streaming ``/v1/graph/ws``). ``None`` or - ``0`` means unlimited. It is a per-process limit, like the in-memory rate-limit backend; - run one limiter per worker. WebSocket handshakes are also subject to the global - ``rate_limit`` (they share the same bucket as REST requests), since rate-limit middleware - is HTTP-only and cannot see WebSocket scopes. + ``max_connections`` caps the concurrent WebSocket connections this server *process* + accepts (realtime ``/v1/graph/live`` + streaming ``/v1/graph/ws``). + ``max_connections_per_user`` caps how many of those one verified user may hold, so a + single account cannot take every slot. Both are per process, like the in-memory + rate-limit backend; size them per worker. + + A missing key gets a finite default (1000 and 10). Set a key to ``0`` or ``null`` to make + it unlimited. WebSocket handshakes, and every graph run started over ``/v1/graph/ws``, also + count against the global ``rate_limit`` bucket shared with REST requests. + + ``realtime_models`` lists the models a ``/v1/graph/live`` client may ask for in its init + frame. Any other requested model is ignored and the agent's own model is used. Empty (the + default) means clients cannot choose the model. """ max_connections: int | None + max_connections_per_user: int | None = DEFAULT_WS_MAX_CONNECTIONS_PER_USER + realtime_models: tuple[str, ...] = () @classmethod def from_dict(cls, data: dict) -> "WebSocketConfig": if not isinstance(data, dict): raise ValueError("websocket must be an object") - raw = data.get("max_connections") - if raw in (None, 0): - return cls(max_connections=None) - max_connections = int(raw) - if max_connections < 0: - raise ValueError("websocket.max_connections must be a non-negative integer") - return cls(max_connections=max_connections) + return cls( + max_connections=_connection_limit(data, "max_connections", DEFAULT_WS_MAX_CONNECTIONS), + max_connections_per_user=_connection_limit( + data, "max_connections_per_user", DEFAULT_WS_MAX_CONNECTIONS_PER_USER + ), + realtime_models=_string_list(data, "realtime_models"), + ) + + +def _string_list(data: dict, key: str) -> tuple[str, ...]: + raw = data.get(key) + if raw is None: + return () + if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw): + raise ValueError(f"websocket.{key} must be a list of strings") + return tuple(item.strip() for item in raw if item.strip()) + + +def _connection_limit(data: dict, key: str, default: int) -> int | None: + """A missing key gets ``default``; an explicit ``0`` or ``null`` means unlimited.""" + if key not in data: + return default + raw = data[key] + if raw in (None, 0): + return None + value = int(raw) + if value < 0: + raise ValueError(f"websocket.{key} must be a non-negative integer") + return value + + +@dataclass +class AgUiConfig: + """AG-UI protocol endpoint settings parsed from agentflow.json. + + Example:: + + "ag_ui": { + "enabled": true, + "allow_client_tools": true + } + + When enabled, ``POST /v1/ag-ui`` streams the graph as AG-UI events, so AG-UI clients + such as CopilotKit can use the agent. It needs the ``ag-ui`` extra + (``pip install "10xscale-agentflow-cli[ag-ui]"``). Off unless explicitly enabled. + + ``allow_client_tools`` (default ``true``) offers the tools an AG-UI client sends with each + run to the model for that run. They run in the caller's own browser, cannot take the name of + a server tool, and must pass size limits. Set it to ``false`` to accept only the client tools + declared under ``remote_tools``. + """ + + enabled: bool = False + allow_client_tools: bool = True + + @classmethod + def from_dict(cls, data: dict) -> "AgUiConfig": + if not isinstance(data, dict): + raise ValueError("ag_ui must be an object") + return cls( + enabled=_parse_bool(data.get("enabled", False), field="ag_ui.enabled"), + allow_client_tools=_parse_bool( + data.get("allow_client_tools", True), field="ag_ui.allow_client_tools" + ), + ) class GraphConfig: @@ -240,6 +406,11 @@ def redis_url(self) -> str | None: def thread_name_generator_path(self) -> str | None: return self.data.get("thread_name_generator", None) + @property + def remote_tools(self) -> list[RemoteToolConfig]: + """Validated client-executed tool schemas configured at startup.""" + return validate_remote_tools(self.data.get("remote_tools", [])) + @property def observability(self) -> dict | None: """The declarative ``observability`` block (Logfire / LangSmith). @@ -334,9 +505,16 @@ def rate_limit(self) -> RateLimitConfig | None: def websocket(self) -> "WebSocketConfig": """WebSocket connection limits from agentflow.json (``websocket`` key). - Returns a config with ``max_connections=None`` (unlimited) when the key is absent. + Returns the default limits when the key is absent. """ data = self.data.get("websocket", None) - if data is None: - return WebSocketConfig(max_connections=None) - return WebSocketConfig.from_dict(data) + return WebSocketConfig.from_dict({} if data is None else data) + + @property + def ag_ui(self) -> AgUiConfig: + """AG-UI endpoint settings from agentflow.json (``ag_ui`` key). + + Returns a disabled config when the key is absent. + """ + data = self.data.get("ag_ui", None) + return AgUiConfig.from_dict({} if data is None else data) diff --git a/agentflow_cli/src/app/core/config/media_settings.py b/agentflow_cli/src/app/core/config/media_settings.py index d52126e..85ba862 100644 --- a/agentflow_cli/src/app/core/config/media_settings.py +++ b/agentflow_cli/src/app/core/config/media_settings.py @@ -17,7 +17,6 @@ class MediaStorageType(StrEnum): MEMORY = "memory" LOCAL = "local" CLOUD = "cloud" - PG = "pg" class MediaSettings(BaseSettings): @@ -34,6 +33,10 @@ class MediaSettings(BaseSettings): # be exact (``image/png``) or wildcard subtype (``image/*``). MEDIA_ALLOWED_CONTENT_TYPES: str = "" + # Deny files with no recorded owner (uploaded before ownership tracking, or while auth + # was off) instead of letting any authenticated user read them. + MEDIA_REQUIRE_OWNER: bool = False + def allowed_content_types(self) -> list[str]: """Parsed, normalized allowlist. Empty list == allow all.""" return [t.strip().lower() for t in self.MEDIA_ALLOWED_CONTENT_TYPES.split(",") if t.strip()] diff --git a/agentflow_cli/src/app/core/config/sentry_config.py b/agentflow_cli/src/app/core/config/sentry_config.py index e653c43..9422e0e 100644 --- a/agentflow_cli/src/app/core/config/sentry_config.py +++ b/agentflow_cli/src/app/core/config/sentry_config.py @@ -3,6 +3,11 @@ from agentflow_cli.src.app.core import Settings, get_settings, logger +# Only server faults are Sentry events. A 403 is authorization doing its job; reporting +# every one lets any client flood the project and bury real errors. +SENTRY_FAILED_STATUS_CODES = frozenset(range(500, 600)) + + def init_sentry(settings: Settings = Depends(get_settings)) -> None: """Initialize Sentry for error tracking and performance monitoring. @@ -22,7 +27,7 @@ def init_sentry(settings: Settings = Depends(get_settings)) -> None: if environment not in allowed_environments: logger.warning( f"Sentry is not configured for this environment: {environment}. " - "Allowed environments are: {allowed_environments}" + f"Allowed environments are: {allowed_environments}" ) return @@ -38,15 +43,15 @@ def init_sentry(settings: Settings = Depends(get_settings)) -> None: integrations=[ FastApiIntegration( transaction_style="endpoint", - failed_request_status_codes=[403, range(500, 599)], + failed_request_status_codes=SENTRY_FAILED_STATUS_CODES, ), StarletteIntegration( transaction_style="endpoint", - failed_request_status_codes=[403, range(500, 599)], + failed_request_status_codes=SENTRY_FAILED_STATUS_CODES, ), ], - traces_sample_rate=1.0, - profiles_sample_rate=1.0, + traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE, + profiles_sample_rate=settings.SENTRY_PROFILES_SAMPLE_RATE, ) logger.debug("Sentry initialized") except ImportError: diff --git a/agentflow_cli/src/app/core/config/settings.py b/agentflow_cli/src/app/core/config/settings.py index 09b2b20..2ad65c8 100644 --- a/agentflow_cli/src/app/core/config/settings.py +++ b/agentflow_cli/src/app/core/config/settings.py @@ -2,7 +2,7 @@ import os from functools import lru_cache -from pydantic import ConfigDict, field_validator, model_validator +from pydantic import ConfigDict, Field, field_validator, model_validator from pydantic_settings import BaseSettings @@ -100,6 +100,10 @@ class Settings(BaseSettings): ###### sentry Config ############ ################################# SENTRY_DSN: str | None = None + # Share of requests traced / profiled. 1.0 sends every request, which is costly and + # rarely wanted in production. + SENTRY_TRACES_SAMPLE_RATE: float = Field(default=0.1, ge=0.0, le=1.0) + SENTRY_PROFILES_SAMPLE_RATE: float = Field(default=0.0, ge=0.0, le=1.0) ################################# ###### Auth ############ @@ -116,6 +120,10 @@ class Settings(BaseSettings): ################################# JWT_SECRET_KEY: str | None = None JWT_ALGORITHM: str = "HS256" + # When set, tokens must carry a matching ``iss`` / ``aud`` claim, so a token minted for + # another service that shares the signing key is not accepted here. + JWT_ISSUER: str | None = None + JWT_AUDIENCE: str | None = None ################################# ###### OTEL Config ############## @@ -148,6 +156,12 @@ def warn_cors_wildcard(cls, v: str) -> str: def check_production_security(self): """Check for insecure configurations in production mode.""" if self.MODE == "production": + # Docs (and the OpenAPI schema behind them) are off in production unless a path + # was set explicitly. + for field in ("DOCS_PATH", "REDOCS_PATH"): + if field not in self.model_fields_set: + setattr(self, field, "") + warnings = [] if self.IS_DEBUG: diff --git a/agentflow_cli/src/app/core/config/setup_logs.py b/agentflow_cli/src/app/core/config/setup_logs.py index 6fc73a0..78981bf 100644 --- a/agentflow_cli/src/app/core/config/setup_logs.py +++ b/agentflow_cli/src/app/core/config/setup_logs.py @@ -3,9 +3,15 @@ from fastapi.logger import logger as fastapi_logger +from agentflow_cli.src.app.core.config.settings import LOGGER_NAME from agentflow_cli.src.app.core.utils.log_sanitizer import SanitizingFormatter +# Top-level names of the loggers the API server writes to; children ("agentflow-cli.media", +# "agentflow_api.rate_limit", ...) reach these handlers through propagation. +APP_LOGGER_NAMES = (LOGGER_NAME, "agentflow-cli", "agentflow_api", "agentflow_cli") + + def init_logger(level: int | str = logging.INFO) -> None: """ Initializes and configures logging for the application. @@ -47,9 +53,13 @@ def init_logger(level: int | str = logging.INFO) -> None: # Add console handler to logger fastapi_logger.addHandler(console_handler) - # Route application loggers through the same sanitizing console handler. - # NOTE: addHandler expects a logging.Handler, not a Logger. - for logger_name in ("db_client", "tortoise", "injector", "BACKEND_BASE", "PACKAGE"): + # Route application loggers through the same sanitizing console handler. They stop + # propagating so an unsanitized copy does not also reach the root (or last-resort) handler. + for logger_name in dict.fromkeys(APP_LOGGER_NAMES): app_logger = logging.getLogger(logger_name) app_logger.setLevel(level) + for handler in list(app_logger.handlers): + if isinstance(handler.formatter, SanitizingFormatter): + app_logger.removeHandler(handler) # init_logger may run more than once app_logger.addHandler(console_handler) + app_logger.propagate = False diff --git a/agentflow_cli/src/app/core/config/setup_middleware.py b/agentflow_cli/src/app/core/config/setup_middleware.py index 71daa2b..d95e8e7 100644 --- a/agentflow_cli/src/app/core/config/setup_middleware.py +++ b/agentflow_cli/src/app/core/config/setup_middleware.py @@ -20,6 +20,42 @@ from .settings import get_settings, logger +class HealthCheckAwareTrustedHostMiddleware(TrustedHostMiddleware): + """``TrustedHostMiddleware`` that lets the health check through from any Host. + + Docker and Kubernetes probes call ``/ping`` on ``localhost`` or the pod IP, which are + never in ``ALLOWED_HOST``, so the probe would fail and the container would be restarted. + ``/ping`` returns a constant and reads nothing, so skipping the Host check there is safe. + """ + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http" and _is_health_check(scope): + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + +# Multipart framing (boundaries, part headers, the filename field) on top of the file bytes. +UPLOAD_MULTIPART_OVERHEAD_BYTES = 64 * 1024 + + +def _upload_path_limits(max_request_size: int) -> dict[str, int]: + """A body limit for the upload route that fits ``MEDIA_MAX_SIZE_MB``, if that is larger.""" + from agentflow_cli.src.app.core.config.media_settings import get_media_settings + + media_bytes = int(get_media_settings().MEDIA_MAX_SIZE_MB * 1024 * 1024) + upload_limit = media_bytes + UPLOAD_MULTIPART_OVERHEAD_BYTES + if upload_limit <= max_request_size: + return {} + return {"/v1/files/upload": upload_limit} + + +def _is_health_check(scope: Scope) -> bool: + path = scope.get("path", "") + root_path = (scope.get("root_path") or "").rstrip("/") + return path in ("/ping", f"{root_path}/ping") + + # Paths that should be excluded from GZip compression (streaming endpoints) GZIP_EXCLUDED_PATHS = frozenset({"/v1/graph/stream"}) @@ -383,10 +419,18 @@ def setup_middleware( allow_headers=["*"], ) - app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.ALLOWED_HOST.split(",")) + app.add_middleware( + HealthCheckAwareTrustedHostMiddleware, + allowed_hosts=settings.ALLOWED_HOST.split(","), + ) - # Add request size limit middleware (protects against DoS via large payloads) - app.add_middleware(RequestSizeLimitMiddleware, max_size=settings.MAX_REQUEST_SIZE) + # Add request size limit middleware (protects against DoS via large payloads). The upload + # route gets room for MEDIA_MAX_SIZE_MB, which the route itself enforces on the file. + app.add_middleware( + RequestSizeLimitMiddleware, + max_size=settings.MAX_REQUEST_SIZE, + path_limits=_upload_path_limits(settings.MAX_REQUEST_SIZE), + ) # Add security headers middleware (if enabled) if settings.SECURITY_HEADERS_ENABLED: diff --git a/agentflow_cli/src/app/core/exceptions/handle_errors.py b/agentflow_cli/src/app/core/exceptions/handle_errors.py index a9269a5..16868a2 100644 --- a/agentflow_cli/src/app/core/exceptions/handle_errors.py +++ b/agentflow_cli/src/app/core/exceptions/handle_errors.py @@ -102,14 +102,20 @@ async def http_exception_handler(request: Request, exc: HTTPException): @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): - logger.error(f"Value error exception: url: {request.base_url}", exc_info=exc) - request_id = getattr(request.state, "request_id", "unknown") details = [ErrorSchemas(**error) for error in exc.errors()] - # In production, sanitize validation error details + # Log where and why validation failed, never the submitted values: exc (and its + # traceback) embeds the raw request body, which can hold messages, files or tokens. + logger.warning( + "Request %s - validation failed: %s %s: %s", + request_id, + request.method, + request.url.path, + [f"{'.'.join(map(str, d.loc))}: {d.msg}" for d in details], + ) + if is_production: - logger.error(f"Request {request_id} - Validation errors: {details}") message = "The request data is invalid. Please check your input." else: message = str(exc.body) if exc.body else "Validation error" @@ -145,13 +151,16 @@ async def value_exception_handler(request: Request, exc: ValueError): ######################################## @app.exception_handler(UserAccountError) async def user_account_exception_handler(request: Request, exc: UserAccountError): - logger.error(f"UserAccountError: url: {request.base_url}", exc_info=exc) - return error_response( + logger.warning("UserAccountError on %s: %s", request.url.path, exc.error_code) + response = error_response( request, error_code=exc.error_code, message=exc.message, status_code=exc.status_code, ) + if exc.status_code == 401: # noqa: PLR2004 + response.headers["WWW-Authenticate"] = 'Bearer realm="auth_required"' + return response @app.exception_handler(UserPermissionError) async def user_write_exception_handler(request: Request, exc: UserPermissionError): diff --git a/agentflow_cli/src/app/core/exceptions/user_exception.py b/agentflow_cli/src/app/core/exceptions/user_exception.py index 7ed8ae1..3f05cb8 100644 --- a/agentflow_cli/src/app/core/exceptions/user_exception.py +++ b/agentflow_cli/src/app/core/exceptions/user_exception.py @@ -13,16 +13,18 @@ class UserAccountError(GeneralException): please contact support". error_code (str): Specific error code for this type of error. Defaults to "USER_ACCOUNT_DISABLE". - status_code (int): HTTP status code for the error. Defaults to 403. + status_code (int): HTTP status code for the error. Defaults to 403; a missing, + invalid or expired credential uses 401. """ def __init__( self, message="User account is disabled, please contact support", error_code="USER_ACCOUNT_DISABLE", + status_code=403, ): self.message = message - self.status_code = 403 + self.status_code = status_code self.error_code = error_code super().__init__( message=self.message, diff --git a/agentflow_cli/src/app/core/middleware/rate_limit/keying.py b/agentflow_cli/src/app/core/middleware/rate_limit/keying.py index 767945d..2f01bb8 100644 --- a/agentflow_cli/src/app/core/middleware/rate_limit/keying.py +++ b/agentflow_cli/src/app/core/middleware/rate_limit/keying.py @@ -5,9 +5,11 @@ Works on any ``HTTPConnection`` (both ``Request`` and ``WebSocket`` subclass it). """ +import ipaddress import logging from starlette.requests import HTTPConnection +from starlette.responses import Response from agentflow_cli.src.app.core.config.graph_config import RateLimitConfig @@ -30,11 +32,17 @@ def _client_ip(connection: HTTPConnection, config: RateLimitConfig) -> str: We instead count ``trusted_proxy_hops`` back from the RIGHT. With one proxy in front (the default), the last entry is the address that proxy actually observed -- the real peer. + + All ``X-Forwarded-For`` header lines are combined in order: a proxy that adds its own + line instead of appending to the caller's must not let the caller's line decide. When + ``trusted_proxies`` is configured, the header is only honoured for connections that come + from one of those networks; anyone reaching the app directly is keyed by their peer. """ - if config.trusted_proxy_headers: - forwarded_for = connection.headers.get("X-Forwarded-For") - if forwarded_for: - parts = [p.strip() for p in forwarded_for.split(",") if p.strip()] + peer = connection.client.host if connection.client else "unknown" + if config.trusted_proxy_headers and _from_trusted_proxy(peer, config): + lines = connection.headers.getlist("X-Forwarded-For") + if lines: + parts = [p.strip() for line in lines for p in line.split(",") if p.strip()] hops = max(1, getattr(config, "trusted_proxy_hops", 1)) if len(parts) >= hops: return parts[-hops] @@ -47,8 +55,54 @@ def _client_ip(connection: HTTPConnection, config: RateLimitConfig) -> str: hops, ) - client = connection.client - return client.host if client else "unknown" + return peer + + +def _from_trusted_proxy(peer: str, config: RateLimitConfig) -> bool: + trusted = getattr(config, "trusted_proxies", ()) + if not trusted: + return True # no list configured: trust the header (the hop count still applies) + try: + address = ipaddress.ip_address(peer) + except ValueError: + return False + return any(address in ipaddress.ip_network(net) for net in trusted) + + +def verified_user_id(connection: HTTPConnection) -> str | None: + """Identify the caller from their credential, exactly as ``RequirePermission`` does. + + The rate limiter runs before any route dependency, so it cannot read an authenticated + user from request state. It verifies the credential itself with the configured auth + backend instead. Returns ``None`` when auth is not configured, no credential was sent, or + the credential does not verify; the caller is then limited by address, so forged tokens + cannot buy fresh buckets. + """ + from injectq import InjectQ + + from agentflow_cli.src.app.core.auth.base_auth import BaseAuth + from agentflow_cli.src.app.core.auth.permissions import _extract_credential + from agentflow_cli.src.app.core.config.graph_config import GraphConfig + + credential = _extract_credential(connection) + if credential is None: + return None + try: + container = InjectQ.get_instance() + graph_config = container.try_get(GraphConfig) + if graph_config is None or not graph_config.auth_config(): + return None + auth_backend = container.try_get(BaseAuth) + if auth_backend is None: + return None + # A throwaway Response: anything the backend sets on it belongs to the real + # authentication in RequirePermission, which runs again for the route. + user = auth_backend.authenticate(connection, Response(), credential) + except Exception as exc: # invalid token, backend error: fall back to the address + logger.debug("Rate limit could not verify caller identity: %s", exc) + return None + user_id = user.get("user_id") if isinstance(user, dict) else None + return str(user_id) if user_id else None def client_key_for(connection: HTTPConnection, config: RateLimitConfig) -> str: @@ -71,12 +125,9 @@ def client_key_for(connection: HTTPConnection, config: RateLimitConfig) -> str: return "__global__" if config.by == "user": - state = getattr(connection, "state", None) - user = getattr(state, "user", None) if state is not None else None - if isinstance(user, dict): - user_id = user.get("user_id") - if user_id: - return f"user:{user_id}" + user_id = verified_user_id(connection) + if user_id: + return f"user:{user_id}" return f"ip:{_client_ip(connection, config)}" return _client_ip(connection, config) diff --git a/agentflow_cli/src/app/core/middleware/rate_limit/middleware.py b/agentflow_cli/src/app/core/middleware/rate_limit/middleware.py index c364ed4..d30ccb1 100644 --- a/agentflow_cli/src/app/core/middleware/rate_limit/middleware.py +++ b/agentflow_cli/src/app/core/middleware/rate_limit/middleware.py @@ -12,6 +12,11 @@ from .keying import client_key_for +# Liveness/readiness probes hit /ping. Limiting it (or failing closed on a backend outage) +# would make the orchestrator restart healthy pods. +HEALTH_CHECK_PATHS = frozenset({"/ping"}) + + class RateLimitMiddleware(BaseHTTPMiddleware): """Backend-agnostic rate-limit middleware.""" @@ -24,7 +29,7 @@ def __init__( super().__init__(app) self.config = config self.backend = backend - self._exclude = frozenset(config.exclude_paths) + self._exclude = frozenset(config.exclude_paths) | HEALTH_CHECK_PATHS def _client_key(self, request: Request) -> str: return client_key_for(request, self.config) diff --git a/agentflow_cli/src/app/core/middleware/request_limits.py b/agentflow_cli/src/app/core/middleware/request_limits.py index 76240df..7baab75 100644 --- a/agentflow_cli/src/app/core/middleware/request_limits.py +++ b/agentflow_cli/src/app/core/middleware/request_limits.py @@ -1,89 +1,154 @@ """Request size limit middleware for DoS protection.""" -from fastapi import Request, status +from typing import Any + +from fastapi import status from fastapi.responses import JSONResponse -from starlette.middleware.base import BaseHTTPMiddleware +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException +from starlette.types import ASGIApp, Message, Receive, Scope, Send from agentflow_cli.src.app.core import logger -class RequestSizeLimitMiddleware(BaseHTTPMiddleware): +class RequestBodyTooLarge(HTTPException): + """Raised from ``receive`` once a streamed body passes the limit. + + It is an ``HTTPException`` because FastAPI re-raises those when they come from reading + the body, instead of turning them into a generic 400, so the client gets a 413. """ - Middleware to enforce maximum request body size limits. - This prevents DoS attacks through excessively large request bodies. + def __init__(self, max_size: int) -> None: + super().__init__( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=f"Request body too large. Maximum size is {max_size / (1024 * 1024):.1f}MB", + ) + + +class RequestSizeLimitMiddleware: + """Enforce a maximum request body size, whether or not ``Content-Length`` is sent. + + A declared ``Content-Length`` over the limit is rejected before any body is read. Bodies + without one (``Transfer-Encoding: chunked``) are counted as they stream in and cut off as + soon as they pass the limit. That matters because FastAPI reads and parses the whole body, + including spooling multipart uploads to disk, before auth dependencies run. + + This is a pure ASGI middleware: ``BaseHTTPMiddleware`` cannot see body chunks. Args: app: The ASGI application max_size: Maximum request body size in bytes (default: 10MB) + path_limits: Per-path limits that replace ``max_size``, e.g. a larger one for the + file upload route so ``MEDIA_MAX_SIZE_MB`` is reachable. """ - def __init__(self, app, max_size: int = 10 * 1024 * 1024): - super().__init__(app) + def __init__( + self, + app: ASGIApp, + max_size: int = 10 * 1024 * 1024, + path_limits: dict[str, int] | None = None, + ) -> None: + self.app = app self.max_size = max_size self.max_size_mb = max_size / (1024 * 1024) + self.path_limits = dict(path_limits or {}) - async def dispatch(self, request: Request, call_next): - """ - Check request size and reject if too large. + def _limit_for(self, scope: Scope) -> int: + path = scope.get("path", "") + root_path = (scope.get("root_path") or "").rstrip("/") + if root_path and path.startswith(root_path): + path = path[len(root_path) :] or "/" + return self.path_limits.get(path, self.max_size) - Args: - request: The incoming HTTP request - call_next: The next middleware or route handler + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return - Returns: - Response: Either the normal response or 413 Payload Too Large - """ - # Get content-length header - content_length = request.headers.get("content-length") + max_size = self._limit_for(scope) + max_size_mb = max_size / (1024 * 1024) - if content_length: + content_length = Headers(scope=scope).get("content-length") + if content_length is not None: try: - content_length_bytes = int(content_length) - except (TypeError, ValueError): + declared = int(content_length) + except ValueError: # A malformed Content-Length is a client error, not a server 500. - request_id = getattr(request.state, "request_id", "unknown") - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "error": { - "code": "INVALID_CONTENT_LENGTH", - "message": "Invalid Content-Length header.", - }, - "metadata": { - "request_id": request_id, - "status": "error", - }, - }, + await self._error( + scope, + send, + status.HTTP_400_BAD_REQUEST, + {"code": "INVALID_CONTENT_LENGTH", "message": "Invalid Content-Length header."}, ) - - if content_length_bytes > self.max_size: + return + if declared > max_size: logger.warning( - f"Request rejected: size {content_length_bytes} bytes " - f"exceeds limit of {self.max_size} bytes " - f"({self.max_size_mb:.1f}MB)" + f"Request rejected: size {declared} bytes exceeds limit of " + f"{max_size} bytes ({max_size_mb:.1f}MB)" ) + await self._too_large(scope, send, max_size) + return + + received = 0 + response_started = False + + async def limited_receive() -> Message: + nonlocal received + message = await receive() + if message["type"] == "http.request": + received += len(message.get("body", b"")) + if received > max_size: + logger.warning( + f"Request rejected: streamed body passed the limit of " + f"{max_size} bytes ({max_size_mb:.1f}MB)" + ) + raise RequestBodyTooLarge(max_size) + return message + + async def tracking_send(message: Message) -> None: + nonlocal response_started + if message["type"] == "http.response.start": + response_started = True + await send(message) + + try: + await self.app(scope, limited_receive, tracking_send) + except RequestBodyTooLarge: + # Normally the app's HTTPException handling answers with the 413. This covers a + # body read outside FastAPI's request handling (e.g. by another middleware). + if response_started: + raise + await self._too_large(scope, send, max_size) + + async def _too_large(self, scope: Scope, send: Send, max_size: int) -> None: + max_size_mb = max_size / (1024 * 1024) + await self._error( + scope, + send, + status.HTTP_413_CONTENT_TOO_LARGE, + { + "code": "REQUEST_TOO_LARGE", + "message": f"Request body too large. Maximum size is {max_size_mb:.1f}MB", + "max_size_bytes": max_size, + "max_size_mb": max_size_mb, + }, + ) + + @staticmethod + async def _error(scope: Scope, send: Send, status_code: int, error: dict[str, Any]) -> None: + state = scope.get("state") or {} + response = JSONResponse( + status_code=status_code, + content={ + "error": error, + "metadata": { + "request_id": state.get("request_id", "unknown"), + "status": "error", + }, + }, + ) + await response(scope, _drain_receive, send) - # Get request ID if available - request_id = getattr(request.state, "request_id", "unknown") - - return JSONResponse( - status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - content={ - "error": { - "code": "REQUEST_TOO_LARGE", - "message": ( - f"Request body too large. Maximum size is {self.max_size_mb:.1f}MB" - ), - "max_size_bytes": self.max_size, - "max_size_mb": self.max_size_mb, - }, - "metadata": { - "request_id": request_id, - "status": "error", - }, - }, - ) - # Process request normally - return await call_next(request) +async def _drain_receive() -> Message: + return {"type": "http.disconnect"} diff --git a/agentflow_cli/src/app/core/utils/log_sanitizer.py b/agentflow_cli/src/app/core/utils/log_sanitizer.py index d714d3e..fa1c1d7 100644 --- a/agentflow_cli/src/app/core/utils/log_sanitizer.py +++ b/agentflow_cli/src/app/core/utils/log_sanitizer.py @@ -39,6 +39,19 @@ # Regex pattern to detect bearer tokens BEARER_PATTERN = re.compile(r"^Bearer\s+[A-Za-z0-9\-_.~+/]+=*$", re.IGNORECASE) +# Tokens embedded anywhere in a message (f-string logs carry no args to sanitize). +_INLINE_SECRET_PATTERNS = ( + (re.compile(r"Bearer\s+[A-Za-z0-9\-_.~+/]+=*", re.IGNORECASE), "Bearer ***REDACTED***"), + (re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*"), "***JWT_TOKEN***"), +) + + +def redact_text(text: str) -> str: + """Redact bearer tokens and JWTs that appear anywhere inside ``text``.""" + for pattern, replacement in _INLINE_SECRET_PATTERNS: + text = pattern.sub(replacement, text) + return text + def sanitize_for_logging(data: Any, max_depth: int = 10, _current_depth: int = 0) -> Any: """ @@ -193,8 +206,11 @@ def format(self, record: logging.LogRecord) -> str: Formatted and sanitized log string """ # Sanitize the message arguments - if record.args: + if isinstance(record.args, tuple) and record.args: record.args = tuple(sanitize_for_logging(arg) for arg in record.args) + elif isinstance(record.args, dict): + record.args = sanitize_for_logging(record.args) - # Format using the base formatter - return self.base_formatter.format(record) + # Format using the base formatter, then catch tokens already baked into the text + # (f-string messages, exception text, tracebacks). + return redact_text(self.base_formatter.format(record)) diff --git a/agentflow_cli/src/app/loader.py b/agentflow_cli/src/app/loader.py index 749fcc3..7413ccc 100644 --- a/agentflow_cli/src/app/loader.py +++ b/agentflow_cli/src/app/loader.py @@ -258,12 +258,31 @@ def load_thread_name_generator(path: str | None) -> ThreadNameGenerator | None: def load_and_bind_auth(container: InjectQ, auth_config: dict) -> None: - from agentflow_cli.src.app.core.auth.jwt_auth import JwtAuth + """Bind the authentication backend described by ``GraphConfig.auth_config()``. + + ``jwt`` needs no path; ``custom`` needs a ``path`` to a ``BaseAuth``. Any other method + is a configuration error and fails at startup rather than silently binding no backend. + """ + from agentflow_cli.src.app.core.auth.jwt_auth import JwtAuth, check_jwt_settings + from agentflow_cli.src.app.core.config.settings import get_settings method = auth_config.get("method") - path = auth_config.get("path") - if not path or not method: - raise ValueError("Both 'method' and 'path' must be specified in auth_config.") + if method == "jwt": + check_jwt_settings(get_settings()) + auth_backend: BaseAuth | None = JwtAuth() + elif method == "none": + auth_backend = None + elif method == "custom": + auth_backend = _load_custom_auth(auth_config.get("path")) + else: + raise ValueError(f"Unsupported auth method: {method!r}. Use 'jwt' or 'custom'.") + + container.bind_instance(BaseAuth, auth_backend, allow_none=True) + + +def _load_custom_auth(path: str | None) -> BaseAuth: + if not path: + raise ValueError("Custom auth requires a 'path' in auth_config.") # Extract file path before the ':' for existence check module_or_path = path.split(":", 1)[0] if ":" in path else path @@ -280,14 +299,10 @@ def load_and_bind_auth(container: InjectQ, auth_config: dict) -> None: if not file_path.exists(): raise ValueError(f"Custom auth path does not exist: {module_or_path}") - auth_backends = { - "custom": lambda: load_auth(path), - "jwt": lambda: JwtAuth(), - "none": lambda: None, - } - - auth_backend = auth_backends.get(method, lambda: None)() - container.bind_instance(BaseAuth, auth_backend, allow_none=True) + auth = load_auth(path) + if auth is None: # load_auth only returns None for an empty path, checked above + raise ValueError("Custom auth requires a 'path' in auth_config.") + return auth # Built-in authorization backends selectable by name in ``agentflow.json``. @@ -407,7 +422,19 @@ async def attach_all_modules( config: GraphConfig, container: InjectQ, ) -> CompiledGraph | None: + remote_tools = config.remote_tools graph = await load_graph(config.graph_path) + + if remote_tools: + if graph is None: + raise RuntimeError("Cannot attach remote tools because the graph failed to load") + grouped: dict[str, list[dict]] = {} + for tool in remote_tools: + grouped.setdefault(tool.node_name, []).append(tool.to_tool_schema()) + for node_name, schemas in grouped.items(): + graph.attach_remote_tools(schemas, node_name) + logger.info("Attached %d configured remote tools", len(remote_tools)) + logger.info("All modules attached successfully") # This binding we have done already in the library diff --git a/agentflow_cli/src/app/main.py b/agentflow_cli/src/app/main.py index 92feb4e..4d7770a 100644 --- a/agentflow_cli/src/app/main.py +++ b/agentflow_cli/src/app/main.py @@ -32,14 +32,13 @@ # draining for a fixed window and then moving on. SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 30.0 -settings = get_settings() -# redis_client = Redis( -# host=settings.REDIS_HOST, -# port=settings.REDIS_PORT, -# ) - +# GraphConfig loads the ``env`` file named in agentflow.json, so it must run before Settings +# is first built. `agentflow api` loads that file itself, but plain `gunicorn ...main:app` +# (the generated Dockerfile) does not, and Settings is cached after the first read. graph_path = os.environ.get("GRAPH_PATH", "agentflow.json") graph_config = GraphConfig(graph_path) + +settings = get_settings() # Load the container container: InjectQ = load_container(graph_config.injectq_path) or InjectQ.get_instance() @@ -151,6 +150,8 @@ async def _drain_background_tasks(timeout: float = SHUTDOWN_DRAIN_TIMEOUT_SECOND summary=settings.SUMMARY, docs_url=settings.DOCS_PATH if settings.DOCS_PATH else None, redoc_url=settings.REDOCS_PATH if settings.REDOCS_PATH else None, + # The schema lists every route and model; serve it only alongside the docs. + openapi_url="/openapi.json" if settings.DOCS_PATH or settings.REDOCS_PATH else None, default_response_class=ORJSONResponse, lifespan=lifespan, root_path=settings.ROOT_PATH, @@ -167,7 +168,9 @@ async def _drain_background_tasks(timeout: float = SHUTDOWN_DRAIN_TIMEOUT_SECOND init_errors_handler(app) # init routes -init_routes(app) +# The evals viewer reads local report files and has no auth, so it is a development tool only. +# The AG-UI endpoint is opt-in through agentflow.json. +init_routes(app, evals=settings.MODE != "production", ag_ui=graph_config.ag_ui.enabled) # Secure by construction: refuse to boot if any non-public route forgot its # RequirePermission guard (a forgotten guard would otherwise ship an open endpoint). diff --git a/agentflow_cli/src/app/routers/ag_ui/__init__.py b/agentflow_cli/src/app/routers/ag_ui/__init__.py new file mode 100644 index 0000000..e5de3d9 --- /dev/null +++ b/agentflow_cli/src/app/routers/ag_ui/__init__.py @@ -0,0 +1,10 @@ +"""AG-UI protocol endpoint (``POST /v1/ag-ui``). + +Needs the optional ``ag-ui-protocol`` package, so it is only imported when ``ag_ui.enabled`` +is set in agentflow.json. +""" + +from .router import router + + +__all__ = ["router"] diff --git a/agentflow_cli/src/app/routers/ag_ui/converter.py b/agentflow_cli/src/app/routers/ag_ui/converter.py new file mode 100644 index 0000000..ecb181c --- /dev/null +++ b/agentflow_cli/src/app/routers/ag_ui/converter.py @@ -0,0 +1,195 @@ +"""Turn an AG-UI ``RunAgentInput`` into Agentflow graph input. + +AG-UI clients send the whole conversation on every run, while the thread's checkpoint already +holds everything the graph has seen. Only the trailing user turn (or the tool results that +answer a pending client-side tool call) is new, so that is all the graph receives. Messages the +client claims came from the assistant, system or developer are never forwarded: the server's +checkpoint is the record of what the model said and was told. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from ag_ui.core import RunAgentInput +from agentflow.core.state import ( + AudioBlock, + DocumentBlock, + ImageBlock, + Message, + TextBlock, + ToolResultBlock, + VideoBlock, +) +from agentflow.core.state.message_block import MediaRef +from pydantic import ValidationError + + +_MEDIA_BLOCKS = { + "image": ImageBlock, + "audio": AudioBlock, + "video": VideoBlock, + "document": DocumentBlock, +} + +# Roles the graph accepts from a client. Assistant turns come from the checkpoint, and client +# system/developer prompts would let a caller rewrite the agent's instructions. +_CLIENT_ROLES = frozenset({"user", "tool"}) + + +def parse_run_input(body: Any) -> RunAgentInput: + """Validate a request body as ``RunAgentInput``. + + Older AG-UI clients (CopilotKit up to at least 1.75) still send the legacy ``binary`` + content part, which the 1.0 SDK rejects; it is rewritten to the matching media part first. + + Raises: + ValueError: The body is not a valid ``RunAgentInput``. + """ + if isinstance(body, dict) and isinstance(body.get("messages"), list): + body = {**body, "messages": [_upgrade_message(m) for m in body["messages"]]} + try: + return RunAgentInput.model_validate(body) + except ValidationError as exc: + raise ValueError(f"Invalid AG-UI RunAgentInput: {exc}") from exc + + +# Limits on the tools an AG-UI client may bring to a run. The name rule matches what model +# providers accept for function names. +MAX_CLIENT_TOOLS = 64 +MAX_TOOL_DESCRIPTION_CHARS = 4096 +MAX_TOOL_SCHEMA_BYTES = 16 * 1024 +_TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + +def check_client_tools(tools: list[Any]) -> None: + """Reject client tool lists that are malformed or oversized. + + Client tools put client-written text (names, descriptions, schemas) in front of the model and + in every request to the provider, so they are bounded before anything runs. + + Raises: + ValueError: A tool breaks one of the limits above. + """ + if len(tools) > MAX_CLIENT_TOOLS: + raise ValueError(f"At most {MAX_CLIENT_TOOLS} client tools are allowed per run") + seen: set[str] = set() + for tool in tools: + if not _TOOL_NAME.match(tool.name or ""): + raise ValueError( + f"Invalid client tool name {tool.name!r}: use 1-64 letters, digits, '_' or '-'" + ) + if tool.name in seen: + raise ValueError(f"Duplicate client tool name {tool.name!r}") + seen.add(tool.name) + if len(tool.description or "") > MAX_TOOL_DESCRIPTION_CHARS: + raise ValueError( + f"Client tool {tool.name!r}: description is longer than " + f"{MAX_TOOL_DESCRIPTION_CHARS} characters" + ) + schema_size = len(json.dumps(tool.parameters or {}, default=str).encode()) + if schema_size > MAX_TOOL_SCHEMA_BYTES: + raise ValueError( + f"Client tool {tool.name!r}: parameters schema is larger than " + f"{MAX_TOOL_SCHEMA_BYTES} bytes" + ) + + +def _upgrade_message(message: Any) -> Any: + if not isinstance(message, dict) or not isinstance(message.get("content"), list): + return message + return {**message, "content": [_upgrade_part(p) for p in message["content"]]} + + +def _upgrade_part(part: Any) -> Any: + if not isinstance(part, dict) or part.get("type") != "binary": + return part + mime_type = part.get("mimeType") or part.get("mime_type") or "" + kind = mime_type.split("/", 1)[0] + part_type = kind if kind in ("image", "audio", "video") else "document" + if part.get("data"): + source = {"type": "data", "value": part["data"], "mimeType": mime_type} + elif part.get("url"): + source = {"type": "url", "value": part["url"], "mimeType": mime_type} + else: + source = {"type": "file", "value": part.get("id") or "", "mimeType": mime_type} + return {"type": part_type, "source": source} + + +def select_new_messages( + messages: list[Any], + known_ids: set[str] | frozenset[str] = frozenset(), + answered_call_ids: set[str] | frozenset[str] = frozenset(), +) -> list[Any]: + """The client messages the graph has not seen yet. + + Args: + messages: ``RunAgentInput.messages``, the full client-side history. + known_ids: Message ids already in the thread's checkpoint. + answered_call_ids: Tool call ids the checkpoint already has a result for. Clients mint + a fresh id for each tool message, so results are matched by call id instead. + """ + turn: list[Any] = [] + for message in reversed(messages): + if message.role == "assistant": + break + turn.append(message) + turn.reverse() + return [ + m + for m in turn + if m.role in _CLIENT_ROLES + and m.id not in known_ids + and not (m.role == "tool" and m.tool_call_id in answered_call_ids) + ] + + +def to_agentflow_messages(messages: list[Any]) -> list[Message]: + """Convert AG-UI user and tool messages to Agentflow messages, keeping their ids.""" + converted: list[Message] = [] + for message in messages: + if message.role == "user": + blocks = _content_blocks(message.content) + if blocks: + converted.append(Message(message_id=message.id, role="user", content=blocks)) + elif message.role == "tool": + error = getattr(message, "error", None) + output = error or _text(message.content) + result = ToolResultBlock( + call_id=message.tool_call_id, + output=output, + is_error=bool(error), + status="failed" if error else "completed", + ) + converted.append(Message(message_id=message.id, role="tool", content=[result])) + return converted + + +def _content_blocks(content: Any) -> list[Any]: + if isinstance(content, str): + return [TextBlock(text=content)] if content else [] + blocks: list[Any] = [] + for part in content or []: + if part.type == "text": + if part.text: + blocks.append(TextBlock(text=part.text)) + elif part.type in _MEDIA_BLOCKS: + blocks.append(_MEDIA_BLOCKS[part.type](media=_media_ref(part.source))) + return blocks + + +def _media_ref(source: Any) -> MediaRef: + mime_type = getattr(source, "mime_type", None) + if source.type == "data": + return MediaRef(kind="data", data_base64=source.value, mime_type=mime_type) + if source.type == "url": + return MediaRef(kind="url", url=source.value, mime_type=mime_type) + return MediaRef(kind="file_id", file_id=source.value, mime_type=mime_type) + + +def _text(content: Any) -> str: + if isinstance(content, str): + return content + return "".join(part.text for part in content or [] if part.type == "text") diff --git a/agentflow_cli/src/app/routers/ag_ui/event_mapper.py b/agentflow_cli/src/app/routers/ag_ui/event_mapper.py new file mode 100644 index 0000000..39fe528 --- /dev/null +++ b/agentflow_cli/src/app/routers/ag_ui/event_mapper.py @@ -0,0 +1,353 @@ +"""Translate Agentflow ``StreamChunk`` output into AG-UI protocol events. + +One mapper handles one run. It tracks which text, reasoning and step blocks are open so the +event stream always follows the AG-UI ordering rules: ``RUN_STARTED`` first, every ``START`` +before its ``CONTENT`` and ``END``, everything closed before the single terminal +``RUN_FINISHED`` or ``RUN_ERROR``. + +Agentflow emits a model turn either as a series of delta messages followed by a final message +with a new id (streaming providers), or as one final message (plain function nodes). Text that +was already streamed is not sent again from the final message. Tool calls are only complete in +the final message, so ``TOOL_CALL_START/ARGS/END`` are sent from there in one go. + +A graph paused by ``interrupt()`` ends the run with ``RUN_FINISHED`` and an ``interrupt`` +outcome, which clients answer with ``RunAgentInput.resume``. Otherwise ``RUN_FINISHED`` carries no +``outcome``: CopilotKit's pinned ``@ag-ui/core`` (0.0.59 in CopilotKit 1.75) rejects the +``success`` outcome's newer fields and the ``cancelled`` outcome. +""" + +from __future__ import annotations + +import json +from typing import Any + +import ag_ui.core as agui +from ag_ui.core import ( + BaseEvent, + Interrupt, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + RunErrorEvent, + RunFinishedEvent, + RunFinishedInterruptOutcome, + RunStartedEvent, + StateSnapshotEvent, + StepFinishedEvent, + StepStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) +from agentflow.core.state import AgentState, Message, StreamChunk, StreamEvent, ToolCallBlock +from agentflow.core.state.message_block import RemoteToolCallBlock +from agentflow.utils.constants import END, START + + +# Graph bookkeeping nodes, not steps a user cares about. +_HIDDEN_NODES = frozenset({START, END}) + +# AgentState fields that are not application state: the conversation travels as messages, +# and execution metadata is internal. +_INTERNAL_STATE_FIELDS = frozenset({"context", "context_summary", "execution_meta"}) + + +def app_state(state: AgentState) -> dict[str, Any]: + """The application fields of ``state``, as JSON, for a ``STATE_SNAPSHOT``.""" + return state.model_dump(mode="json", exclude=set(_INTERNAL_STATE_FIELDS)) + + +class AgUiEventMapper: + """Stateful translator for one AG-UI run. + + Call :meth:`start` once, :meth:`map` for every chunk, then :meth:`finish`. After a + ``RUN_ERROR`` (from :meth:`fail` or an error chunk) every later call returns nothing. + """ + + def __init__( + self, + thread_id: str, + run_id: str, + initial_state: dict[str, Any] | None = None, + ) -> None: + self.thread_id = thread_id + self.run_id = run_id + self.finished = False + self._text_id: str | None = None + self._reasoning_id: str | None = None + # The text message the current model turn produced, as parent for its tool calls. + self._turn_text_id: str | None = None + self._turn_streamed = False + self._open_steps: list[str] = [] + self._tool_calls: set[str] = set() + self._tool_results: set[str] = set() + self._last_state = initial_state or None + self._interrupt: dict[str, Any] | None = None + + def start(self) -> list[BaseEvent]: + extra: dict[str, Any] = {} + protocol_version = getattr(agui, "PROTOCOL_VERSION", None) + if protocol_version and "protocol_version" in RunStartedEvent.model_fields: + extra["protocol_version"] = protocol_version + return [RunStartedEvent(thread_id=self.thread_id, run_id=self.run_id, **extra)] + + def map(self, chunk: StreamChunk) -> list[BaseEvent]: + if self.finished: + return [] + if chunk.event == StreamEvent.MESSAGE and chunk.message is not None: + return self._on_message(chunk.message) + if chunk.event == StreamEvent.UPDATES: + return self._on_update(chunk.data or {}) + if chunk.event == StreamEvent.STATE and chunk.state is not None: + return self._on_state(chunk.state) + if chunk.event == StreamEvent.ERROR: + return self._on_error(chunk.data or {}) + return [] + + def map_state(self, state: AgentState) -> list[BaseEvent]: + """A ``STATE_SNAPSHOT`` for ``state``, when it differs from what the client has.""" + if self.finished: + return [] + return self._on_state(state) + + def report_interrupt(self, interrupt: dict[str, Any]) -> None: + """End the run as paused at ``interrupt`` (an ``agentflow`` ``Interrupt`` as JSON).""" + self._interrupt = interrupt + + def finish(self) -> list[BaseEvent]: + if self.finished: + return [] + events = self._close_all() + self.finished = True + extra: dict[str, Any] = {} + if self._interrupt is not None: + extra["outcome"] = RunFinishedInterruptOutcome( + interrupts=[_agui_interrupt(self._interrupt)] + ) + events.append(RunFinishedEvent(thread_id=self.thread_id, run_id=self.run_id, **extra)) + return events + + def fail(self, message: str, code: str | None = None) -> list[BaseEvent]: + if self.finished: + return [] + events = self._close_all() + self.finished = True + events.append(RunErrorEvent(message=message, code=code)) + return events + + # ------------------------------------------------------------------ messages + + def _on_message(self, message: Message) -> list[BaseEvent]: + if message.role == "assistant": + return self._on_delta(message) if message.delta else self._on_final(message) + if message.role == "tool": + return self._on_tool_result(message) + return [] # the user's own message echoed back + + def _on_delta(self, message: Message) -> list[BaseEvent]: + message_id = str(message.message_id) + events: list[BaseEvent] = [] + for block in message.content: + if block.type == "reasoning" and block.summary: + events += self._reasoning(message_id, block.summary) + elif block.type == "text" and block.text: + events += self._text(message_id, block.text) + return events + self._on_tool_calls(message) + + def _on_final(self, message: Message) -> list[BaseEvent]: + message_id = str(message.message_id) + events: list[BaseEvent] = [] + if not self._turn_streamed: + reasoning = "\n\n".join( + b.summary for b in message.content if b.type == "reasoning" and b.summary + ) + text = "".join(b.text for b in message.content if b.type == "text" and b.text) + if reasoning: + events += self._reasoning(message_id, reasoning) + if text: + events += self._text(message_id, text) + events += self._close_reasoning() + self._close_text() + events += self._on_tool_calls(message, fallback_parent=message_id) + self._turn_text_id = None + self._turn_streamed = False + return events + + def _on_tool_calls( + self, message: Message, fallback_parent: str | None = None + ) -> list[BaseEvent]: + calls = [c for c in _tool_calls(message) if c[0] not in self._tool_calls] + if not calls: + return [] + events = self._close_reasoning() + self._close_text() + parent = self._turn_text_id + if parent is None: + # Announce the parent message so the client has something to attach the call to. + parent = fallback_parent or str(message.message_id) + events += [ + TextMessageStartEvent(message_id=parent, role="assistant"), + TextMessageEndEvent(message_id=parent), + ] + self._turn_text_id = parent + for call_id, name, arguments in calls: + self._tool_calls.add(call_id) + events += [ + ToolCallStartEvent( + tool_call_id=call_id, tool_call_name=name, parent_message_id=parent + ), + ToolCallArgsEvent(tool_call_id=call_id, delta=arguments), + ToolCallEndEvent(tool_call_id=call_id), + ] + return events + + def _on_tool_result(self, message: Message) -> list[BaseEvent]: + events: list[BaseEvent] = [] + for block in message.content: + if block.type != "tool_result" or block.call_id in self._tool_results: + continue + message_id = str(message.message_id) + if events: + message_id = f"{message_id}-{block.call_id}" + events.append(self._tool_result(message_id, block.call_id, _stringify(block.output))) + return events + + def _tool_result(self, message_id: str, call_id: str, content: str) -> ToolCallResultEvent: + self._tool_results.add(call_id) + return ToolCallResultEvent( + message_id=message_id, tool_call_id=call_id, content=content, role="tool" + ) + + # --------------------------------------------------------- text and reasoning + + def _text(self, message_id: str, delta: str) -> list[BaseEvent]: + events = self._close_reasoning() + if self._text_id != message_id: + events += self._close_text() + events.append(TextMessageStartEvent(message_id=message_id, role="assistant")) + self._text_id = message_id + self._turn_text_id = message_id + self._turn_streamed = True + events.append(TextMessageContentEvent(message_id=message_id, delta=delta)) + return events + + def _reasoning(self, message_id: str, delta: str) -> list[BaseEvent]: + reasoning_id = f"{message_id}-reasoning" + events = self._close_text() + if self._reasoning_id != reasoning_id: + events += self._close_reasoning() + events += [ + ReasoningStartEvent(message_id=reasoning_id), + ReasoningMessageStartEvent(message_id=reasoning_id), + ] + self._reasoning_id = reasoning_id + self._turn_streamed = True + events.append(ReasoningMessageContentEvent(message_id=reasoning_id, delta=delta)) + return events + + def _close_text(self) -> list[BaseEvent]: + if self._text_id is None: + return [] + events: list[BaseEvent] = [TextMessageEndEvent(message_id=self._text_id)] + self._text_id = None + return events + + def _close_reasoning(self) -> list[BaseEvent]: + if self._reasoning_id is None: + return [] + reasoning_id = self._reasoning_id + self._reasoning_id = None + return [ + ReasoningMessageEndEvent(message_id=reasoning_id), + ReasoningEndEvent(message_id=reasoning_id), + ] + + def _close_all(self) -> list[BaseEvent]: + events = self._close_reasoning() + self._close_text() + while self._open_steps: + events.append(StepFinishedEvent(step_name=self._open_steps.pop())) + return events + + # ------------------------------------------------------ updates, state, errors + + def _on_update(self, data: dict[str, Any]) -> list[BaseEvent]: + if data.get("status") == "interrupted" and isinstance(data.get("interrupt"), dict): + self.report_interrupt(data["interrupt"]) + return [] + node = data.get("node") + if not isinstance(node, str) or node in _HIDDEN_NODES: + return [] + status = data.get("status") + if status == "invoking_node" and node not in self._open_steps: + self._open_steps.append(node) + return [StepStartedEvent(step_name=node)] + if status == "node_invoked" and node in self._open_steps: + self._open_steps.remove(node) + return [StepFinishedEvent(step_name=node)] + return [] + + def _on_state(self, state: AgentState) -> list[BaseEvent]: + snapshot = app_state(state) + if not snapshot or snapshot == self._last_state: + return [] + self._last_state = snapshot + return [StateSnapshotEvent(snapshot=snapshot)] + + def _on_error(self, data: dict[str, Any]) -> list[BaseEvent]: + if data.get("status") == "tool_failed": + # A failed tool is a result the model reacts to, not the end of the run. + call_id = data.get("tool_call_id") + if not call_id or call_id in self._tool_results: + return [] + # ``error`` holds the tool's own message; ``reason`` is a generic summary. + reason = data.get("error") or data.get("reason") or "Tool failed" + return [self._tool_result(f"{call_id}-error", str(call_id), f"Error: {reason}")] + return self.fail(str(data.get("reason") or "Graph execution failed"), "GRAPH_ERROR") + + +def _tool_calls(message: Message) -> list[tuple[str, str, str]]: + """``(id, name, JSON arguments)`` for each tool call in ``message``, without duplicates. + + A call can appear both in ``tools_calls`` (OpenAI shape, drives execution) and as a + content block (drives rendering). + """ + calls: dict[str, tuple[str, str, str]] = {} + for call in message.tools_calls or []: + if not isinstance(call, dict) or not call.get("id"): + continue + function = call.get("function") or {} + name = function.get("name") or call.get("name") or "" + arguments = function.get("arguments", call.get("args")) + if not isinstance(arguments, str): + arguments = json.dumps(arguments or {}) + calls.setdefault(str(call["id"]), (str(call["id"]), str(name), arguments or "{}")) + for block in message.content: + if isinstance(block, ToolCallBlock | RemoteToolCallBlock) and block.id: + calls.setdefault(block.id, (block.id, block.name, json.dumps(block.args or {}))) + return list(calls.values()) + + +def _agui_interrupt(interrupt: dict[str, Any]) -> Interrupt: + """An AG-UI ``Interrupt`` for an ``agentflow`` interrupt; its payload goes in metadata.""" + return Interrupt( + id=str(interrupt.get("id")), + reason=str(interrupt.get("reason") or "input_required"), + message=interrupt.get("message"), + tool_call_id=interrupt.get("tool_call_id"), + response_schema=interrupt.get("response_schema"), + metadata={"value": interrupt.get("value"), "node": interrupt.get("node")}, + ) + + +def _stringify(output: Any) -> str: + if output is None: + return "" + if isinstance(output, str): + return output + if hasattr(output, "model_dump"): + output = output.model_dump(mode="json") + return json.dumps(output, default=str) diff --git a/agentflow_cli/src/app/routers/ag_ui/router.py b/agentflow_cli/src/app/routers/ag_ui/router.py new file mode 100644 index 0000000..d5fd0a5 --- /dev/null +++ b/agentflow_cli/src/app/routers/ag_ui/router.py @@ -0,0 +1,90 @@ +from typing import Any + +from ag_ui.encoder import EventEncoder +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from injectq.integrations import InjectAPI + +from agentflow_cli.src.app.core import logger +from agentflow_cli.src.app.core.auth.authorization import AuthorizationBackend +from agentflow_cli.src.app.core.auth.permissions import RequirePermission +from agentflow_cli.src.app.core.auth.request_config import normalize_thread_id +from agentflow_cli.src.app.core.config.graph_config import GraphConfig +from agentflow_cli.src.app.routers.graph.services.graph_service import GraphService + +from .converter import check_client_tools, parse_run_input +from .service import stream_ag_ui + + +router = APIRouter( + tags=["AG-UI"], +) + + +@router.post( + "/v1/ag-ui", + summary="Run the graph over the AG-UI protocol", + description=( + "Accepts an AG-UI RunAgentInput and streams the graph run as AG-UI events over " + "server-sent events, for AG-UI clients such as CopilotKit. Enabled with " + '"ag_ui": {"enabled": true} in agentflow.json.' + ), + openapi_extra={ + "requestBody": { + "required": True, + "content": {"application/json": {"schema": {"type": "object"}}}, + } + }, +) +async def run_ag_ui( + request: Request, + service: GraphService = InjectAPI(GraphService), + user: dict[str, Any] = Depends(RequirePermission("graph", "stream")), + # Abstract type as the DI key, as in graph/router.py (see the mypy backlog in pyproject). + authz: AuthorizationBackend = InjectAPI(AuthorizationBackend), # type: ignore[type-abstract] + config: GraphConfig = InjectAPI(GraphConfig), +): + """ + Stream one AG-UI run. + + AG-UI names the thread ``threadId``, which the generic permission check does not read, so + thread ownership is checked here, the same way the WebSocket stream checks it: only when + auth is configured, since an unauthenticated server has no owners to tell apart. + """ + try: + body = await request.json() + except ValueError as exc: + raise HTTPException(status_code=422, detail="Request body must be JSON") from exc + try: + run_input = parse_run_input(body) + check_client_tools(run_input.tools or []) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + thread_id = normalize_thread_id(run_input.thread_id) + if thread_id is None: + raise HTTPException(status_code=422, detail="threadId is required") + run_input.thread_id = thread_id + if config.auth_config() and not await authz.authorize( + user, "graph", "stream", resource_id=thread_id + ): + raise HTTPException(status_code=403, detail="Not allowed to access this thread") + + logger.info("AG-UI run %s received for thread %s", run_input.run_id, thread_id) + encoder = EventEncoder(accept=request.headers.get("accept") or "") + return StreamingResponse( + stream_ag_ui( + service, + run_input, + user, + encoder, + allow_client_tools=config.ag_ui.allow_client_tools, + ), + media_type=encoder.get_content_type(), + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + "Content-Encoding": "identity", # Disable any content encoding (bypasses GZip) + }, + ) diff --git a/agentflow_cli/src/app/routers/ag_ui/service.py b/agentflow_cli/src/app/routers/ag_ui/service.py new file mode 100644 index 0000000..ece181d --- /dev/null +++ b/agentflow_cli/src/app/routers/ag_ui/service.py @@ -0,0 +1,193 @@ +"""Run the graph for one AG-UI request and stream the result as AG-UI events.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +from ag_ui.core import RunAgentInput +from ag_ui.encoder import EventEncoder +from agentflow.utils import ResponseGranularity + +from agentflow_cli.src.app.core import logger +from agentflow_cli.src.app.core.auth.request_config import tool_result_ids +from agentflow_cli.src.app.routers.graph.schemas.graph_schemas import GraphInputSchema +from agentflow_cli.src.app.routers.graph.services.graph_service import GraphService + +from .converter import select_new_messages, to_agentflow_messages +from .event_mapper import AgUiEventMapper + + +# Graph config key holding the AG-UI request extras (frontend context, tools, forwarded props), +# so graph nodes and tools can read them from ``config["ag_ui"]``. +AG_UI_CONFIG_KEY = "ag_ui" + +# Client state keys that would overwrite the conversation or execution metadata. +_RESERVED_STATE_KEYS = frozenset({"context", "context_summary", "execution_meta"}) + + +async def stream_ag_ui( + service: GraphService, + run_input: RunAgentInput, + user: dict[str, Any], + encoder: EventEncoder, + allow_client_tools: bool = True, +) -> AsyncIterator[str]: + """Stream one AG-UI run as encoded events. + + With ``allow_client_tools`` the tools the client sends are offered to the model for this run + (see ``ag_ui.allow_client_tools``); without it only ``remote_tools`` from agentflow.json are. + + The thread's checkpoint is the record of the conversation: only client messages it has not + seen are sent to the graph. A run with nothing new (a client syncing state) still gets a + complete ``RUN_STARTED`` / ``RUN_FINISHED`` pair, with the thread's state. + """ + initial_state = _client_state(run_input.state) + mapper = AgUiEventMapper(run_input.thread_id, run_input.run_id, initial_state=initial_state) + for event in mapper.start(): + yield encoder.encode(event) + + try: + async for event in _run_events( + service, run_input, user, mapper, initial_state, allow_client_tools + ): + yield encoder.encode(event) + except ValueError as exc: + logger.warning("AG-UI run rejected: %s", exc) + for event in mapper.fail(str(exc), "INVALID_INPUT"): + yield encoder.encode(event) + except Exception as exc: + # The response has already started, so the failure has to travel in the stream. + logger.error("AG-UI run failed: %s", exc) + for event in mapper.fail("AG-UI run failed", "AG_UI_ERROR"): + yield encoder.encode(event) + + for event in mapper.finish(): + yield encoder.encode(event) + + +async def _run_events( + service: GraphService, + run_input: RunAgentInput, + user: dict[str, Any], + mapper: AgUiEventMapper, + initial_state: dict[str, Any] | None, + allow_client_tools: bool, +) -> AsyncIterator[Any]: + """The events between ``RUN_STARTED`` and the terminal event of one run.""" + checkpoint_state = await _checkpoint_state(service, run_input.thread_id, user) + context = list(getattr(checkpoint_state, "context", None) or []) + new_messages = select_new_messages( + run_input.messages, + known_ids={str(m.message_id) for m in context}, + answered_call_ids=set(tool_result_ids(context)), + ) + messages = to_agentflow_messages(new_messages) + paused_at = _pending_interrupt(checkpoint_state) + resume = _resume_value(paused_at, run_input.resume or []) + + if paused_at is not None and resume is _NO_RESUME: + # A thread paused at interrupt() only moves on with an answer; report the pause again + # instead of running the graph. + mapper.report_interrupt(paused_at.model_dump(mode="json")) + return + if not messages and resume is _NO_RESUME: + if checkpoint_state is not None: + for event in mapper.map_state(checkpoint_state): + yield event + return + + graph_input, server_config = _graph_request(run_input, messages, initial_state, resume) + if not allow_client_tools: + server_config = {} + async for chunk in service.stream_chunks(graph_input, user, server_config): + for event in mapper.map(chunk): + yield event + + +def _client_state(state: Any) -> dict[str, Any] | None: + if not isinstance(state, dict): + return None + cleaned = {k: v for k, v in state.items() if k not in _RESERVED_STATE_KEYS} + return cleaned or None + + +async def _checkpoint_state(service: GraphService, thread_id: str, user: dict[str, Any]) -> Any: + if service.checkpointer is None: + return None + config = { + "thread_id": thread_id, + "user": user, + "user_id": user.get("user_id", "anonymous"), + } + return await service.checkpointer.aget_state(config) + + +# Marks "the client sent no answer for the open interrupt" (a None answer means cancelled). +_NO_RESUME: Any = object() + +# ``remote_tools`` run-config key read by agentflow's ToolNode for per-run client tools. +RUN_REMOTE_TOOLS_KEY = "remote_tools" + + +def _pending_interrupt(state: Any) -> Any: + """The interrupt() the thread is paused at, or ``None`` (and on cores without interrupts).""" + if state is None: + return None + try: + from agentflow.utils.interrupt import pending_interrupt + except ImportError: # agentflow core older than interrupt() + return None + return pending_interrupt(state) + + +def _resume_value(paused_at: Any, entries: list[Any]) -> Any: + """The graph resume value for the AG-UI ``resume`` entries. + + Returns ``_NO_RESUME`` when nothing answers the open interrupt. A ``cancelled`` entry resumes + with ``None``. + + Raises: + ValueError: ``resume`` was sent but the thread is not paused at that interrupt. + """ + if not entries: + return _NO_RESUME + if paused_at is None: + raise ValueError("Nothing to resume: this thread is not paused at an interrupt") + for entry in entries: + if entry.interrupt_id == paused_at.id: + return entry.payload if entry.status == "resolved" else None + raise ValueError(f"resume does not answer the open interrupt '{paused_at.id}'") + + +def _graph_request( + run_input: RunAgentInput, + messages: list[Any], + initial_state: dict[str, Any] | None, + resume: Any, +) -> tuple[GraphInputSchema, dict[str, Any]]: + """The graph input for one AG-UI run, and the server-owned run config that goes with it.""" + extra: dict[str, Any] = {} if resume is _NO_RESUME else {"resume": resume} + graph_input = GraphInputSchema( + messages=messages, + initial_state=initial_state, + config={ + "thread_id": run_input.thread_id, + "run_id": run_input.run_id, + AG_UI_CONFIG_KEY: { + "context": [c.model_dump(mode="json") for c in run_input.context or []], + "tools": [t.model_dump(mode="json") for t in run_input.tools or []], + "forwarded_props": run_input.forwarded_props, + }, + }, + response_granularity=ResponseGranularity.FULL, + **extra, + ) + # The client's own tools, offered to the model for this run only. + server_config = { + RUN_REMOTE_TOOLS_KEY: [ + {"name": t.name, "description": t.description, "parameters": t.parameters} + for t in run_input.tools or [] + ] + } + return graph_input, server_config diff --git a/agentflow_cli/src/app/routers/checkpointer/router.py b/agentflow_cli/src/app/routers/checkpointer/router.py index d1591ef..4454bef 100644 --- a/agentflow_cli/src/app/routers/checkpointer/router.py +++ b/agentflow_cli/src/app/routers/checkpointer/router.py @@ -7,6 +7,7 @@ from injectq.integrations import InjectAPI from agentflow_cli.src.app.core.auth.permissions import RequirePermission +from agentflow_cli.src.app.core.auth.request_config import client_config from agentflow_cli.src.app.utils.response_helper import success_response from agentflow_cli.src.app.utils.swagger_helper import generate_swagger_responses @@ -112,9 +113,9 @@ async def put_state( Success response or error """ validate_thread_id(thread_id) - config = {"thread_id": thread_id} - if payload.config: - config.update(payload.config) + # The path thread_id is the one RequirePermission checked; it is applied last so the + # body config cannot redirect the call to another thread. + config = {**client_config(payload.config), "thread_id": thread_id} res = await service.put_state( config, @@ -198,9 +199,9 @@ async def put_messages( if not payload.messages: raise HTTPException(status_code=422, detail="messages must not be empty") - config = {"thread_id": thread_id} - if payload.config: - config.update(payload.config) + # The path thread_id is the one RequirePermission checked; it is applied last so the + # body config cannot redirect the call to another thread. + config = {**client_config(payload.config), "thread_id": thread_id} res = await service.put_messages( config, @@ -340,9 +341,9 @@ async def delete_message( if not message_id or (isinstance(message_id, str) and not str(message_id).strip()): raise HTTPException(status_code=422, detail="message_id is required and cannot be empty") - config = {"thread_id": thread_id} - if payload.config: - config.update(payload.config) + # The path thread_id is the one RequirePermission checked; it is applied last so the + # body config cannot redirect the call to another thread. + config = {**client_config(payload.config), "thread_id": thread_id} await service.delete_message( config, @@ -465,9 +466,9 @@ async def delete_thread( Success response or error """ validate_thread_id(thread_id) - config = {"thread_id": thread_id} - if payload.config: - config.update(payload.config) + # The path thread_id is the one RequirePermission checked; it is applied last so the + # body config cannot redirect the call to another thread. + config = {**client_config(payload.config), "thread_id": thread_id} res = await service.delete_thread( config, diff --git a/agentflow_cli/src/app/routers/checkpointer/schemas/checkpointer_schemas.py b/agentflow_cli/src/app/routers/checkpointer/schemas/checkpointer_schemas.py index 8e3eb83..bdc0b8c 100644 --- a/agentflow_cli/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +++ b/agentflow_cli/src/app/routers/checkpointer/schemas/checkpointer_schemas.py @@ -3,7 +3,9 @@ from typing import Any from agentflow.core.state import Message -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator + +from agentflow_cli.src.app.core.auth.request_config import client_tool_call_error class ConfigSchema(BaseModel): @@ -32,6 +34,13 @@ class PutMessagesSchema(ConfigSchema): messages: list[Message] = Field(..., description="List of messages to store") metadata: dict[str, Any] | None = Field(None, description="Optional metadata") + @field_validator("messages") + @classmethod + def messages_must_not_call_tools(cls, v: list[Message]) -> list[Message]: + if error := client_tool_call_error(v): + raise ValueError(error) + return v + class GetMessageSchema(ConfigSchema): """Schema for getting a single message.""" diff --git a/agentflow_cli/src/app/routers/checkpointer/services/checkpointer_service.py b/agentflow_cli/src/app/routers/checkpointer/services/checkpointer_service.py index 27fd3a0..59c3854 100644 --- a/agentflow_cli/src/app/routers/checkpointer/services/checkpointer_service.py +++ b/agentflow_cli/src/app/routers/checkpointer/services/checkpointer_service.py @@ -6,6 +6,10 @@ from injectq import inject, singleton from agentflow_cli.src.app.core import logger +from agentflow_cli.src.app.core.auth.request_config import ( + client_config, + client_tool_call_error, +) from agentflow_cli.src.app.core.config.settings import get_settings from agentflow_cli.src.app.core.utils.log_sanitizer import sanitize_for_logging from agentflow_cli.src.app.routers.checkpointer.schemas.checkpointer_schemas import ( @@ -15,6 +19,9 @@ ThreadResponseSchema, ThreadsListResponseSchema, ) +from agentflow_cli.src.app.routers.graph.services.multimodal_preprocessor import ( + check_media_references, +) from agentflow_cli.src.app.utils.parse_output import parse_state_output @@ -38,11 +45,31 @@ def _config(self, config: dict[str, Any] | None, user: dict) -> dict[str, Any]: if not self.checkpointer: raise CheckpointerUnavailableError() - cfg: dict[str, Any] = dict(config or {}) + # Client keys pass through; server-owned keys (authz, user, internals) never do. + cfg: dict[str, Any] = client_config(config) cfg["user"] = user cfg["user_id"] = user.get("user_id", "anonymous") return cfg + async def _check_media_references(self, messages: list[Any], user: dict) -> None: + """Messages written into a thread are resolved on the next run, so the files they + reference must belong to the caller, exactly as for graph input.""" + user_id = user.get("user_id") + if not user_id or not messages: + return + await check_media_references(messages, self._media_service(), str(user_id)) + + @staticmethod + def _media_service() -> Any: + try: + from injectq import InjectQ + + from agentflow_cli.src.app.routers.media import MediaService + + return InjectQ.get_instance().try_get(MediaService) + except Exception: + return None + async def get_state(self, config: dict[str, Any], user: dict) -> StateResponseSchema: cfg = self._config(config, user) @@ -69,6 +96,11 @@ async def put_state( state: dict[str, Any], ) -> StateResponseSchema: cfg = self._config(config, user) + # context is appended, so every message here is new and client-authored. A tool call + # in it would run on the next resume without the model ever requesting it. + if error := client_tool_call_error(state.get("context")): + raise HTTPException(status_code=422, detail=error) + await self._check_media_references(state.get("context") or [], user) old_state: AgentState | None = await self.checkpointer.aget_state(cfg) if not old_state: old_state = await self.checkpointer.aget_state_cache(cfg) @@ -104,6 +136,7 @@ async def put_messages( metadata: dict[str, Any] | None = None, ) -> ResponseSchema: cfg = self._config(config, user) + await self._check_media_references(messages, user) res = await self.checkpointer.aput_messages(cfg, messages, metadata) return ResponseSchema(success=True, message="Messages put successfully", data=res) diff --git a/agentflow_cli/src/app/routers/graph/realtime_guard.py b/agentflow_cli/src/app/routers/graph/realtime_guard.py index 6917ba8..6aa69ee 100644 --- a/agentflow_cli/src/app/routers/graph/realtime_guard.py +++ b/agentflow_cli/src/app/routers/graph/realtime_guard.py @@ -7,7 +7,13 @@ 1. The same global rate limit as REST (shared backend + bucket), so opening a socket counts like any other request. 2. A per-process cap on concurrent WebSocket connections (``websocket.max_connections`` in - agentflow.json). + agentflow.json), and a per-user cap (``websocket.max_connections_per_user``) so one + account cannot hold every slot. + +A socket lives much longer than its handshake, so two helpers apply for its whole lifetime: +:func:`ws_run_allowed` counts every graph run against the same rate-limit bucket, and +:func:`ws_identity_still_valid` re-verifies the credential so an expired or revoked token +cannot keep a socket working. Rejections raise ``WebSocketException`` before ``accept()``, so the handshake fails with a close code instead of leaving a half-open socket. The concurrency slot is released on @@ -15,12 +21,14 @@ """ from collections.abc import AsyncIterator +from typing import Any from fastapi import WebSocket, WebSocketException from injectq.integrations import InjectAPI from agentflow_cli.src.app.core import logger from agentflow_cli.src.app.core.config.graph_config import GraphConfig +from agentflow_cli.src.app.core.middleware.rate_limit import keying from agentflow_cli.src.app.core.middleware.rate_limit.keying import client_key_for @@ -39,6 +47,7 @@ class _ConnectionRegistry: def __init__(self) -> None: self._active = 0 + self._per_user: dict[str, int] = {} @property def active(self) -> int: @@ -54,6 +63,20 @@ def release(self) -> None: if self._active > 0: self._active -= 1 + def try_acquire_user(self, user_key: str, max_per_user: int | None) -> bool: + held = self._per_user.get(user_key, 0) + if max_per_user is not None and held >= max_per_user: + return False + self._per_user[user_key] = held + 1 + return True + + def release_user(self, user_key: str) -> None: + held = self._per_user.get(user_key, 0) + if held <= 1: + self._per_user.pop(user_key, None) + else: + self._per_user[user_key] = held - 1 + # Module-level singleton (per process). _registry = _ConnectionRegistry() @@ -69,14 +92,17 @@ async def realtime_connection_guard( backend = getattr(getattr(websocket, "app", None), "state", None) backend = getattr(backend, "rate_limit_backend", None) if rl_config is not None and backend is not None: + # Kept on the connection so every run on this socket counts too (ws_run_allowed). + websocket.state.ws_rate_limit = (rl_config, backend) key = client_key_for(websocket, rl_config) decision = await backend.check(key, limit=rl_config.requests, window=rl_config.window) if not decision.allowed: logger.warning("WebSocket rate limit exceeded for %s", key) raise WebSocketException(code=WS_TRY_AGAIN_LATER, reason="Rate limit exceeded") - # 2) Per-process concurrent-connection cap. - max_conn = config.websocket.max_connections + # 2) Per-process concurrent-connection cap, then the per-user share of it. + ws_config = config.websocket + max_conn = ws_config.max_connections if not _registry.try_acquire(max_conn): logger.warning( "WebSocket connection limit reached (active=%d, max=%s)", @@ -85,7 +111,51 @@ async def realtime_connection_guard( ) raise WebSocketException(code=WS_TRY_AGAIN_LATER, reason="Too many connections") + user_key = keying.verified_user_id(websocket) + if user_key and not _registry.try_acquire_user(user_key, ws_config.max_connections_per_user): + _registry.release() + logger.warning( + "WebSocket per-user connection limit reached for %s (max=%s)", + user_key, + ws_config.max_connections_per_user, + ) + raise WebSocketException( + code=WS_TRY_AGAIN_LATER, reason="Too many connections for this user" + ) + try: yield finally: _registry.release() + if user_key: + _registry.release_user(user_key) + + +async def ws_run_allowed(websocket: WebSocket) -> int | None: + """Count one graph run against the rate limit. + + Returns ``None`` when the run may start, or the seconds to wait when the caller is over + the limit. Runs share the handshake's bucket, which is also the REST bucket. + """ + limiter = getattr(websocket.state, "ws_rate_limit", None) + if not isinstance(limiter, tuple): # the guard stored none: rate limiting is off + return None + rl_config, backend = limiter + key = client_key_for(websocket, rl_config) + decision = await backend.check(key, limit=rl_config.requests, window=rl_config.window) + if decision.allowed: + return None + logger.warning("WebSocket run rate limit exceeded for %s", key) + return decision.reset_after + + +def ws_identity_still_valid(websocket: Any, user: dict[str, Any]) -> bool: + """Whether the socket's credential still verifies as the user it was opened by. + + Checked before each run: without it, a socket keeps working after its token expires or + is revoked. Always true when auth is not configured (``user`` has no ``user_id``). + """ + user_id = user.get("user_id") if isinstance(user, dict) else None + if not user_id: + return True + return keying.verified_user_id(websocket) == str(user_id) diff --git a/agentflow_cli/src/app/routers/graph/router.py b/agentflow_cli/src/app/routers/graph/router.py index 492ab20..1d4b4ac 100644 --- a/agentflow_cli/src/app/routers/graph/router.py +++ b/agentflow_cli/src/app/routers/graph/router.py @@ -18,13 +18,17 @@ RequirePermission, ws_bearer_subprotocol, ) -from agentflow_cli.src.app.routers.graph.realtime_guard import realtime_connection_guard +from agentflow_cli.src.app.core.auth.request_config import normalize_thread_id +from agentflow_cli.src.app.routers.graph.realtime_guard import ( + realtime_connection_guard, + ws_identity_still_valid, + ws_run_allowed, +) from agentflow_cli.src.app.routers.graph.schemas.graph_schemas import ( FixGraphRequestSchema, GraphInputSchema, GraphInvokeOutputSchema, GraphSchema, - GraphSetupSchema, GraphStopSchema, GraphToolsSchema, ObservabilitySchema, @@ -41,6 +45,9 @@ # returns well within the window; it only bounds a provider that never goes idle. REALTIME_DRAIN_TIMEOUT = 30.0 +# ``config.thread_id`` value a WebSocket client sends to start a fresh thread. +WS_NEW_THREAD = "new" + # Bound the upstream audio queue. WebSocket frames bypass RequestSizeLimitMiddleware (it is # HTTP-only), so the realtime path must guard memory itself. At ~50 input frames/sec a depth # of 1000 is ~20s of buffered audio; if the provider send stalls past that the oldest frames @@ -287,40 +294,6 @@ async def stop_graph( ) -@router.post( - "/v1/graph/setup", - summary="Setup Remote Tool to the Graph Execution", - description="Stop the currently running graph execution for a specific thread", - responses=generate_swagger_responses(dict), # type: ignore - openapi_extra={}, -) -async def setup_graph( - request: Request, - setup_request: GraphSetupSchema, - service: GraphService = InjectAPI(GraphService), - user: dict[str, Any] = Depends(RequirePermission("graph", "setup")), -): - """ - Setup the graph execution for a specific thread. - - Args: - setup_request: Request containing thread_id and optional config - - Returns: - Status information about the setup operation - """ - logger.info("Graph setup request received") - - result = await service.setup(setup_request) - - logger.info("Graph setup completed") - - return success_response( - result, - request, - ) - - @router.post( "/v1/graph/fix", summary="Fix graph state by removing messages with empty tool calls", @@ -383,6 +356,63 @@ async def fix_graph( # ────────────────────────────────────────────────────────────────────────────── +async def _ws_run_gate(websocket: WebSocket, user: dict[str, Any]) -> str: + """Per-run checks for a WebSocket: ``"run"``, ``"skip"`` (rate limited) or ``"close"``. + + The handshake guard runs once, so a socket that stays open must be re-checked before each + run: the token may have expired or been revoked, and every run counts against the limit. + """ + if not ws_identity_still_valid(websocket, user): + logger.info("WebSocket credential no longer valid; closing") + await websocket.send_text( + StreamChunk( + event=StreamEvent.ERROR, + data={"reason": "Session expired. Reconnect with a valid token."}, + ).model_dump_json() + ) + await websocket.close(code=1008) + return "close" + + retry_after = await ws_run_allowed(websocket) + if retry_after is not None: + await websocket.send_text( + StreamChunk( + event=StreamEvent.ERROR, + data={ + "reason": f"Rate limit exceeded. Retry after {retry_after}s.", + "retry_after_seconds": retry_after, + }, + ).model_dump_json() + ) + return "skip" + return "run" + + +def _ws_run_thread_id(ws_input: WsGraphInputSchema) -> str | None: + """Resolve the thread a WebSocket run targets, and make the run use exactly that thread. + + Normalises ``config.thread_id`` the way ``GraphService`` does. ``"new"`` (or a missing + or blank id) means "start a fresh thread": the key is removed so the service generates + one, instead of running on a literal thread named ``"new"`` shared by every client. + """ + config = dict(ws_input.config or {}) + thread_id = _resolve_ws_thread(config) + ws_input.config = config + return thread_id + + +def _resolve_ws_thread(frame: dict[str, Any]) -> str | None: + """Normalise ``frame["thread_id"]`` in place; ``"new"``, blank or missing removes it.""" + thread_id = normalize_thread_id(frame.get("thread_id")) + if thread_id == WS_NEW_THREAD: + thread_id = None + if thread_id is None: + frame.pop("thread_id", None) + else: + frame["thread_id"] = thread_id + return thread_id + + async def _ws_thread_authorized( authz: AuthorizationBackend, user: dict[str, Any], @@ -393,22 +423,22 @@ async def _ws_thread_authorized( Returns ``True`` when the run may proceed. Ownership is only enforced when auth is configured (``GraphConfig.auth_config``); an unauthenticated dev graph and fresh - (``"new"``/absent) threads always pass. + (``"new"``/absent) threads always pass. If the config cannot be read, the check runs + anyway: a lookup failure must not switch authorization off. """ - if not thread_id or thread_id == "new": + if not thread_id or thread_id == WS_NEW_THREAD: return True from injectq import InjectQ - has_auth = False + has_auth = True try: from agentflow_cli.src.app.core.config.graph_config import GraphConfig cfg = InjectQ.get_instance().get(GraphConfig) - if cfg and cfg.auth_config(): - has_auth = True + has_auth = bool(cfg and cfg.auth_config()) except Exception as exc: - logger.debug("Could not resolve GraphConfig for WS auth check: %s", exc) + logger.warning("Could not resolve GraphConfig for WS auth check; enforcing it: %s", exc) if not has_auth: return True @@ -455,10 +485,15 @@ async def websocket_graph( identical to the HTTP stream route. Handshakes are subject to the global rate limit and the ``websocket.max_connections`` cap. + Every run on the socket counts against the same rate limit (an error chunk with + ``retry_after_seconds`` is sent when over it), and the token is re-verified before each + run. + Close codes ----------- 1000 normal closure (client disconnected cleanly) - 1008 rejected: this graph is a live (realtime) agent — use ``/v1/graph/live`` + 1008 rejected: this graph is a live (realtime) agent — use ``/v1/graph/live``; + or the token expired / was revoked since the socket opened 1011 unexpected server error 1013 rejected: rate limit or connection cap exceeded (try again later) """ @@ -514,7 +549,14 @@ async def websocket_graph( ) continue - thread_id = (ws_input.config or {}).get("thread_id", "new") + # The handshake checks happen once; these apply to every run on the socket. + gate = await _ws_run_gate(websocket, user) + if gate == "close": + break + if gate == "skip": + continue + + thread_id = _ws_run_thread_id(ws_input) if not await _ws_thread_authorized(authz, user, thread_id, "stream"): logger.warning( f"WebSocket authorization failed for user {user.get('user_id')} " @@ -553,6 +595,19 @@ async def websocket_graph( await websocket.close(code=1011) +def _client_error(exc: Exception) -> str: + """The error text a client may see: the exception in development, generic in production. + + A ValueError here can come from anywhere under the session (storage, provider SDK), not + only from the init frame, and its text may name internal hosts, paths or queries. + """ + from agentflow_cli.src.app.core.config.settings import get_settings + from agentflow_cli.src.app.core.exceptions.handle_errors import _sanitize_error_message + + is_production = get_settings().MODE == "production" + return _sanitize_error_message(str(exc), "VALIDATION_ERROR", is_production) + + def _realtime_event_json(event: Any) -> str: """Serialize a non-audio RealtimeEvent to a JSON text frame for the client.""" try: @@ -634,7 +689,8 @@ async def realtime_graph_ws( # noqa: PLR0912, PLR0915 return if isinstance(init, dict): - thread_id = init.get("thread_id") + # Same rules as /v1/graph/ws: "new" starts a fresh thread, never a shared one. + thread_id = _resolve_ws_thread(init) if not await _ws_thread_authorized(authz, user, thread_id, "stream"): logger.warning( f"Realtime WebSocket authorization failed for user {user.get('user_id')} " @@ -718,7 +774,7 @@ async def downstream() -> None: with contextlib.suppress(Exception): await websocket.send_text( _realtime_event_json( - ErrorEvent(code="invalid_config", message=str(e), fatal=True) + ErrorEvent(code="invalid_config", message=_client_error(e), fatal=True) ) ) diff --git a/agentflow_cli/src/app/routers/graph/schemas/graph_schemas.py b/agentflow_cli/src/app/routers/graph/schemas/graph_schemas.py index 34222b8..a7c4e68 100644 --- a/agentflow_cli/src/app/routers/graph/schemas/graph_schemas.py +++ b/agentflow_cli/src/app/routers/graph/schemas/graph_schemas.py @@ -4,6 +4,8 @@ from agentflow.utils import ResponseGranularity from pydantic import BaseModel, Field, field_validator, model_validator +from agentflow_cli.src.app.core.auth.request_config import client_tool_call_error + class GraphInputSchema(BaseModel): """ @@ -11,7 +13,17 @@ class GraphInputSchema(BaseModel): """ messages: list[Message] = Field( - ..., description="List of messages to process through the graph" + default_factory=list, + description=( + "List of messages to process through the graph. Required unless `resume` is set." + ), + ) + resume: Any = Field( + default=None, + description=( + "Answer for a thread paused by interrupt(); the paused node runs again and " + "interrupt() returns this value. Send it, possibly as null, only to resume." + ), ) initial_state: dict[str, Any] | None = Field( default=None, @@ -30,11 +42,22 @@ class GraphInputSchema(BaseModel): @field_validator("messages") @classmethod - def messages_must_not_be_empty(cls, v: list[Message]) -> list[Message]: - if not v: - raise ValueError("messages must contain at least one message") + def messages_must_not_carry_tool_calls(cls, v: list[Message]) -> list[Message]: + if error := client_tool_call_error(v): + raise ValueError(error) return v + @model_validator(mode="after") + def messages_or_resume(self) -> "GraphInputSchema": + if not self.messages and not self.is_resume: + raise ValueError("messages must contain at least one message") + return self + + @property + def is_resume(self) -> bool: + """Whether the request resumes an interrupt() (``resume`` was sent, even as null).""" + return "resume" in self.model_fields_set + response_granularity: ResponseGranularity = Field( default=ResponseGranularity.LOW, description="Granularity of the response (full, partial, low)", @@ -132,7 +155,7 @@ class ToolSchema(BaseModel): description=( "Where the tool is defined: 'local' (a Python function on the node), " "'mcp' (provided by a connected MCP server), or 'remote' " - "(client-side tool attached via /v1/graph/setup)." + "(client-side tool declared in agentflow.json)." ), ) parameters: dict[str, Any] = Field( @@ -235,23 +258,6 @@ def thread_id_not_blank(cls, v: str) -> str: ) -class RemoteToolSchema(BaseModel): - """Schema for remote tool execution.""" - - node_name: str = Field(..., description="Name of the node representing the tool") - name: str = Field(..., description="Name of the tool to execute") - description: str = Field(..., description="Description of the tool") - parameters: dict[str, Any] = Field(..., description="Parameters for the tool") - - -class GraphSetupSchema(BaseModel): - """Schema for setting up graph execution.""" - - tools: list[RemoteToolSchema] = Field( - ..., description="List of remote tools available for the graph" - ) - - class FixGraphRequestSchema(BaseModel): """Schema for fixing graph state by removing messages with empty tool call content.""" @@ -354,6 +360,10 @@ def validate_by_invoke_type(self) -> "WsGraphInputSchema": raise ValueError("tool_result must not be empty for invoke_type='resume'") if not (self.config or {}).get("thread_id"): raise ValueError("config.thread_id is required for invoke_type='resume'") + # Resume sends tool *results*; neither run type may carry a tool call. + for messages in (self.messages, self.tool_result): + if error := client_tool_call_error(messages): + raise ValueError(error) return self def to_graph_input(self) -> "GraphInputSchema": diff --git a/agentflow_cli/src/app/routers/graph/services/graph_service.py b/agentflow_cli/src/app/routers/graph/services/graph_service.py index 88d4989..c05d8d5 100644 --- a/agentflow_cli/src/app/routers/graph/services/graph_service.py +++ b/agentflow_cli/src/app/routers/graph/services/graph_service.py @@ -1,4 +1,3 @@ -from collections import defaultdict from collections.abc import AsyncIterable from datetime import datetime from typing import Any @@ -14,13 +13,18 @@ from pydantic import BaseModel from agentflow_cli.src.app.core import logger +from agentflow_cli.src.app.core.auth.request_config import ( + client_config, + normalize_thread_id, + tool_result_ids, + unanswered_tool_calls, +) from agentflow_cli.src.app.core.config.graph_config import GraphConfig from agentflow_cli.src.app.core.utils.log_sanitizer import sanitize_for_logging from agentflow_cli.src.app.routers.graph.schemas.graph_schemas import ( GraphInputSchema, GraphInvokeOutputSchema, GraphSchema, - GraphSetupSchema, GraphToolsSchema, ObservabilitySchema, ObsEventSchema, @@ -37,6 +41,17 @@ from agentflow_cli.src.app.utils.telemetry_store import TelemetryStore +def _run_tool_names(config: dict[str, Any]) -> set[str]: + """Names of the per-run client tools in ``config["remote_tools"]`` (server-owned key).""" + names: set[str] = set() + for item in config.get("remote_tools") or []: + if isinstance(item, dict): + spec = item.get("function") if isinstance(item.get("function"), dict) else item + if isinstance(spec.get("name"), str): + names.add(spec["name"]) + return names + + def _reraise_framework_errors(exc: Exception) -> None: """Re-raise typed errors that have dedicated handlers instead of masking them. @@ -76,6 +91,11 @@ def _reraise_framework_errors(exc: Exception) -> None: raise exc +# A taken generated id is retried with a fresh one; each retry is another prediction the +# claimer would have had to get right. +MAX_THREAD_ID_ATTEMPTS = 5 + + @singleton class GraphService: """ @@ -297,7 +317,7 @@ async def stop_graph( logger.debug(f"User info: {sanitize_for_logging(user)}") # Start with client config if provided, then overlay trusted attributes - stop_config = dict(config) if config else {} + stop_config = client_config(config) stop_config.update( { "thread_id": thread_id, @@ -322,17 +342,107 @@ async def stop_graph( status_code=500, detail=f"Graph stop failed for thread {thread_id}: {e!s}" ) + def _reject_tool_start_node(self, initial_state: dict[str, Any]) -> None: + """Allow a run to start on any node except a ``ToolNode``. + + Clients may pick the start node, e.g. to jump straight to one agent. A ``ToolNode`` + runs the tool calls in the last message of the saved context without asking the + model, so starting there would execute tools the model never requested. + """ + from agentflow.core.graph import ToolNode + + node_name = initial_state.get("current_node") + if not isinstance(node_name, str): + return + state_graph = getattr(self._graph, "_state_graph", None) + nodes = getattr(state_graph, "nodes", {}) if state_graph else {} + node = nodes.get(node_name) + if node is not None and isinstance(getattr(node, "func", None), ToolNode): + raise ValueError(f"current_node '{node_name}' is a tool node; runs cannot start there") + + def _remote_tool_names(self) -> set[str]: + """Names of the tools the client runs (remote tools), across every ToolNode.""" + from agentflow.core.graph import ToolNode + + state_graph = getattr(self._graph, "_state_graph", None) + nodes = getattr(state_graph, "nodes", {}) if state_graph else {} + names: set[str] = set() + for node in nodes.values(): + tool_node = getattr(node, "func", None) + if isinstance(tool_node, ToolNode): + names.update(n for n in getattr(tool_node, "remote_tool_names", None) or [] if n) + return names + + def _server_tool_names(self) -> set[str]: + """Names of the tools the server runs itself (local and MCP), across every ToolNode.""" + from agentflow.core.graph import ToolNode + + state_graph = getattr(self._graph, "_state_graph", None) + nodes = getattr(state_graph, "nodes", {}) if state_graph else {} + names: set[str] = set() + for node in nodes.values(): + tool_node = getattr(node, "func", None) + if isinstance(tool_node, ToolNode): + names.update(getattr(tool_node, "_funcs", None) or {}) + names.update(getattr(tool_node, "mcp_tools", None) or []) + return names + + async def _check_tool_results(self, messages: list[Any], config: dict[str, Any]) -> None: + """Accept a tool result only as the answer to a remote tool call awaiting one. + + Remote tools run on the client, which sends their results back to resume the run. + Anything else is a forged result: an answer to a call the model never made, a second + answer to the same call, or an answer to a server tool the client never ran. + """ + result_ids = tool_result_ids(messages) + if not result_ids: + return + if len(set(result_ids)) != len(result_ids): + raise ValueError("Each tool call can be answered only once") + + state = await self.checkpointer.aget_state(config) if self.checkpointer else None + pending = unanswered_tool_calls(getattr(state, "context", None)) + # A client tool can never carry a server tool's name: otherwise a client could declare + # it and post a "result" for a server tool call (one paused for approval, say). + remote = self._remote_tool_names() | (_run_tool_names(config) - self._server_tool_names()) + for call_id in result_ids: + if call_id not in pending or pending[call_id] not in remote: + raise ValueError(f"No remote tool call '{call_id}' is waiting for a result") + + async def _new_thread_id(self) -> str: + """Generate a thread id that nobody owns yet. + + The permission check approved "a new thread", so the id handed out here must really + be new. Generated ids (Snowflake by default) are predictable, and a client may create + a thread under any id it likes, so another user could have claimed the next id + first; running on it would put this user's messages in their thread. + """ + for _ in range(MAX_THREAD_ID_ATTEMPTS): + thread_id = str(await InjectQ.get_instance().atry_get("generated_id") or uuid4()) + try: + owner = await self.checkpointer.aget_thread_owner(thread_id) + except NotImplementedError: + return thread_id # the checkpointer cannot tell; keep the old behaviour + if owner is None: + return thread_id + logger.warning("Generated thread id %s is already taken; generating another", thread_id) + raise RuntimeError("Could not generate an unused thread id") + async def _prepare_input( self, graph_input: GraphInputSchema, user_id: str | None = None, + server_config: dict[str, Any] | None = None, ): is_new_thread = False - config = dict(graph_input.config or {}) - if config.get("thread_id") and str(config["thread_id"]).strip(): - thread_id = str(config["thread_id"]).strip() - else: - thread_id = await InjectQ.get_instance().atry_get("generated_id") or str(uuid4()) + config = client_config(graph_input.config) + # Server-owned keys set by a trusted caller (a protocol adapter), after the client's + # own config has been stripped of them. + config.update(server_config or {}) + # Same normalisation as the permission check, so the checked thread is the one used. + thread_id = normalize_thread_id(config.get("thread_id")) + if thread_id is None: + thread_id = await self._new_thread_id() is_new_thread = True # update thread id @@ -351,7 +461,10 @@ async def _prepare_input( input_data: dict = { "messages": preprocessed, } + if graph_input.is_resume: + input_data["resume"] = graph_input.resume if graph_input.initial_state: + self._reject_tool_start_node(graph_input.initial_state) input_data["state"] = graph_input.initial_state return ( @@ -388,6 +501,7 @@ async def invoke_graph( # add user inside config config["user"] = user config["user_id"] = user.get("user_id", "anonymous") + await self._check_tool_results(input_data["messages"], config) # Try to save thread info in the db even for existing threads # this will help in updating last accessed time @@ -481,6 +595,26 @@ async def stream_graph( Yields: str: Individual JSON chunks from graph execution with newline delimiters. """ + async for chunk in self.stream_chunks(graph_input, user): + yield chunk.model_dump_json(serialize_as_any=True) + "\n" + + async def stream_chunks( + self, + graph_input: GraphInputSchema, + user: dict[str, Any], + server_config: dict[str, Any] | None = None, + ) -> AsyncIterable[StreamChunk]: + """ + Streams the graph execution as ``StreamChunk`` objects. + + Shared by the NDJSON stream and protocol adapters such as AG-UI. The graph reuses one + chunk object for successive messages, so consume each chunk before asking for the next. + ``server_config`` carries server-owned run config (for example per-run + ``remote_tools``) that a client could not set through ``graph_input.config``. + + Yields: + StreamChunk: Graph output; failures arrive as an ``ERROR`` chunk, never raised. + """ # Initialize meta here so it is available in the except blocks even if # _prepare_input raises before assigning it. meta: dict[str, Any] = {} @@ -490,10 +624,13 @@ async def stream_graph( logger.debug(f"Streaming graph with input: {graph_input.messages}") # Prepare the config - input_data, config, meta = await self._prepare_input(graph_input, user.get("user_id")) + input_data, config, meta = await self._prepare_input( + graph_input, user.get("user_id"), server_config + ) # add user inside config config["user"] = user config["user_id"] = user.get("user_id", "anonymous") + await self._check_tool_results(input_data["messages"], config) # Try to save thread info in the db even for existing threads # this will help in updating last accessed time @@ -528,7 +665,7 @@ async def stream_graph( StreamEvent.ERROR, ): run_status = "error" - yield chunk.model_dump_json(serialize_as_any=True) + "\n" + yield chunk if ( self.config.thread_name_generator_path and meta["is_new_thread"] @@ -549,13 +686,10 @@ async def stream_graph( ) meta["thread_name"] = thread_name - yield ( - StreamChunk( - event=StreamEvent.UPDATES, - data={"status": "completed"}, - metadata=meta, - ).model_dump_json(serialize_as_any=True) - + "\n" + yield StreamChunk( + event=StreamEvent.UPDATES, + data={"status": "completed"}, + metadata=meta, ) except Exception as e: @@ -587,13 +721,10 @@ async def stream_graph( is_production = get_settings().MODE == "production" reason = _sanitize_error_message(str(e), "GRAPH_STREAM_ERROR", is_production) - yield ( - StreamChunk( - event=StreamEvent.ERROR, - data={"reason": reason}, - metadata=meta, - ).model_dump_json(serialize_as_any=True) - + "\n" + yield StreamChunk( + event=StreamEvent.ERROR, + data={"reason": reason}, + metadata=meta, ) async def realtime_graph( @@ -609,7 +740,7 @@ async def realtime_graph( normalized RealtimeEvents. The compiled graph must be rooted at a LiveAgent (e.g. an ``AudioAgent``); otherwise ``arealtime`` raises. """ - thread_id = init.get("thread_id") or str(uuid4()) + thread_id = normalize_thread_id(init.get("thread_id")) or await self._new_thread_id() config: dict[str, Any] = { "thread_id": thread_id, "user": user, @@ -618,7 +749,7 @@ async def realtime_graph( # Map the client init frame onto RealtimeConfig field names so the live agent can # apply per-session overrides (model/voice/modalities/vad/...). Only present keys # are forwarded; absent ones fall back to the agent's build-time config. - realtime = self._realtime_overrides(init) + realtime = self._restrict_realtime_overrides(self._realtime_overrides(init)) if realtime: config["realtime"] = realtime await self._save_thread(config, thread_id) @@ -652,6 +783,43 @@ def _realtime_overrides(init: dict[str, Any]) -> dict[str, Any]: overrides["response_modalities"] = [overrides["response_modalities"]] return overrides + def _live_tools_tags(self) -> set[str] | None: + """The tag filter the live agent was built with, or ``None`` when it has none.""" + find_live_nodes = getattr(self._graph, "_find_live_nodes", None) + for _name, node in find_live_nodes() if callable(find_live_nodes) else []: + realtime_config = getattr(getattr(node, "func", None), "realtime_config", None) + tags = getattr(realtime_config, "tools_tags", None) + if tags: + return {str(tag) for tag in tags} + return None + + def _restrict_realtime_overrides(self, overrides: dict[str, Any]) -> dict[str, Any]: + """Drop the init-frame overrides a client is not allowed to make. + + ``model`` is honoured only when listed in ``websocket.realtime_models``. ``tools_tags`` + may only narrow the agent's own filter: widening it, or clearing it (an empty filter + advertises every tool), would expose tools the agent was built to hide. + """ + restricted = dict(overrides) + model = restricted.get("model") + if model is not None and model not in self.config.websocket.realtime_models: + logger.info( + "Realtime init model %r is not in websocket.realtime_models; ignored", model + ) + restricted.pop("model") + + if "tools_tags" in restricted: + requested = restricted.pop("tools_tags") + if isinstance(requested, str): + requested = [requested] + if not isinstance(requested, list): + requested = [] + base = self._live_tools_tags() + tags = [str(tag) for tag in requested if base is None or str(tag) in base] + if tags: + restricted["tools_tags"] = tags + return restricted + async def graph_details(self) -> GraphSchema: try: logger.info("Getting graph details") @@ -1000,7 +1168,7 @@ async def fix_graph( logger.debug(f"User info: {sanitize_for_logging(user)}") # Start with client config if provided, then overlay trusted attributes - fix_config = dict(config) if config else {} + fix_config = client_config(config) fix_config.update( {"thread_id": thread_id, "user": user, "user_id": user.get("user_id", "anonymous")} ) @@ -1049,64 +1217,3 @@ async def fix_graph( _reraise_framework_errors(e) logger.error(f"Fix graph operation failed: {e}") raise HTTPException(status_code=500, detail=f"Fix graph operation failed: {e!s}") - - async def setup(self, data: GraphSetupSchema) -> dict: - from agentflow_cli.src.app.core.config.settings import get_settings - - settings = get_settings() - has_auth = False - if self.config: - backend = self.config.auth_config() - from unittest.mock import Mock - - if ( - backend - and not isinstance(backend, Mock) - and ( - (isinstance(backend, dict) and backend.get("method") != "none") - or (isinstance(backend, str) and backend != "none") - ) - ): - has_auth = True - if settings.MODE == "production" or has_auth: - # Name the condition that actually tripped: reporting only - # "production/multi-tenant" sends people auditing MODE when it was - # the auth backend, and vice versa. - reasons = [] - if settings.MODE == "production": - reasons.append("MODE is 'production'") - if has_auth: - reasons.append("an auth backend is configured in agentflow.json") - raise HTTPException( - status_code=403, - detail=( - "Dynamic tool setup is disabled because " - + " and ".join(reasons) - + ". Registration mutates process-wide graph state, so it is unsafe to " - "expose once requests can come from more than one tenant. Attach the tools " - "statically instead via CompiledGraph.attach_remote_tools()." - ), - ) - - # lets create tools - remote_tools = defaultdict(list) - for tool in data.tools: - remote_tools[tool.node_name].append( - { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.parameters, - }, - } - ) - - # Now call setup on graph - for node_name, tool in remote_tools.items(): - self._graph.attach_remote_tools(tool, node_name) - - return { - "status": "success", - "details": f"Added tools to nodes: {list(remote_tools.keys())}", - } diff --git a/agentflow_cli/src/app/routers/graph/services/multimodal_preprocessor.py b/agentflow_cli/src/app/routers/graph/services/multimodal_preprocessor.py index 56ff95d..45cf984 100644 --- a/agentflow_cli/src/app/routers/graph/services/multimodal_preprocessor.py +++ b/agentflow_cli/src/app/routers/graph/services/multimodal_preprocessor.py @@ -4,7 +4,7 @@ import inspect import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from agentflow.core.state import Message from agentflow.core.state.message_block import DocumentBlock, TextBlock @@ -15,6 +15,75 @@ logger = logging.getLogger("agentflow-cli.media") +# How uploads are referenced inside the graph; the media store key follows the prefix. +_INTERNAL_MEDIA_SCHEME = "agentflow://media/" + + +def _internal_media_keys(message: Message | dict[str, Any]) -> list[str]: + """Every ``agentflow://media/`` key referenced anywhere in a message's content. + + Walks the whole content tree, not just top-level blocks, so references nested in tool + results or other structured output are found too. + """ + keys: list[str] = [] + + def walk(node: Any) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if ( + key == "url" + and isinstance(value, str) + and value.startswith(_INTERNAL_MEDIA_SCHEME) + ): + keys.append(value.removeprefix(_INTERNAL_MEDIA_SCHEME)) + else: + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + if isinstance(message, dict): + walk(message.get("content")) + else: + walk(message.model_dump(mode="python").get("content")) + return keys + + +async def _ensure_access(media_service: MediaService, file_id: str, user_id: str) -> None: + """Ownership check with one answer for missing and foreign files. + + Both become the same ``ValueError`` (a 422 on invoke), so the response never tells a + caller whether another user's file exists. + """ + try: + await media_service.ensure_can_access(file_id, user_id) + except (KeyError, PermissionError) as exc: + raise ValueError(f"File not found: {file_id}") from exc + + +async def check_media_references( + messages: list[Message] | list[dict[str, Any]], + media_service: MediaService | None, + user_id: str, +) -> None: + """Run the ownership check on every internal media reference a client sent. + + A client may reference ``agentflow://media/`` directly instead of a ``file_id``; + the graph resolves both the same way, so both must be checked. Also used for messages + written straight into a thread, since the next run resolves those references too. + """ + for message in messages: + keys = list(dict.fromkeys(_internal_media_keys(message))) + if not keys: + continue + if media_service is None: + raise ValueError( + "Internal media references cannot be verified: file uploads are not " + "configured on this server." + ) + for key in keys: + await _ensure_access(media_service, key, user_id) + async def _get_cached_extraction(media_service: MediaService, file_id: str) -> str | None: """Read cached extraction from async or sync service APIs.""" @@ -49,8 +118,12 @@ async def preprocess_multimodal_messages( - Convert to a ``agentflow://media/{file_id}`` URL-based reference so the MediaRefResolver can pick it up at LLM-call time. - This is a no-op when ``media_service`` is None. + With an authenticated ``user_id``, every file the messages reference -- by ``file_id`` + or by an internal ``agentflow://media/`` URL -- must belong to that user. + ``media_service`` being None skips the rewriting, but not the ownership rule. """ + if user_id: + await check_media_references(messages, media_service, user_id) if media_service is None: return messages @@ -66,7 +139,7 @@ async def preprocess_multimodal_messages( and block.media.file_id ): if user_id: - await media_service.ensure_can_access(block.media.file_id, user_id) + await _ensure_access(media_service, block.media.file_id, user_id) cached = await _get_cached_extraction(media_service, block.media.file_id) if cached: new_content.append(TextBlock(text=cached)) @@ -76,7 +149,7 @@ async def preprocess_multimodal_messages( if hasattr(block, "media") and block.media.kind == "file_id" and block.media.file_id: fid = block.media.file_id if user_id: - await media_service.ensure_can_access(fid, user_id) + await _ensure_access(media_service, fid, user_id) # Convert file_id → agentflow://media/ URL reference if not (block.media.url and block.media.url.startswith("agentflow://media/")): block.media.kind = "url" diff --git a/agentflow_cli/src/app/routers/media/__init__.py b/agentflow_cli/src/app/routers/media/__init__.py index 991033a..4d52a8a 100644 --- a/agentflow_cli/src/app/routers/media/__init__.py +++ b/agentflow_cli/src/app/routers/media/__init__.py @@ -6,6 +6,7 @@ import json import logging import time +from collections import OrderedDict from typing import Any from agentflow.storage.checkpointer import BaseCheckpointer @@ -24,6 +25,61 @@ _SIGNED_URL_NAMESPACE = "media:signed-url" _EXTRACTION_NAMESPACE = "media:extraction" +# In-process caches in front of the shared checkpointer cache. Bounded so a stream of new +# uploads cannot grow a worker's memory without limit. +MAX_SIGNED_URL_ENTRIES = 4096 +MAX_EXTRACTION_ENTRIES = 512 +MAX_EXTRACTION_CHARS = 32_000_000 + + +class _BoundedCache(OrderedDict): + """A dict that evicts least-recently-used entries past ``max_entries`` or ``max_chars``. + + ``max_chars`` sums ``len()`` of string values, so a few large extractions cannot hold + more memory than the entry count suggests. + """ + + def __init__(self, max_entries: int, max_chars: int | None = None): + super().__init__() + self.max_entries = max_entries + self.max_chars = max_chars + self._chars = 0 + + @staticmethod + def _size(value: Any) -> int: + return len(value) if isinstance(value, str) else 0 + + def get(self, key: Any, default: Any = None) -> Any: + if key not in self: + return default + self.move_to_end(key) + return self[key] + + def __setitem__(self, key: Any, value: Any) -> None: + if key in self: + self._chars -= self._size(self[key]) + super().__setitem__(key, value) + self.move_to_end(key) + self._chars += self._size(value) + while len(self) > self.max_entries or ( + self.max_chars is not None and self._chars > self.max_chars and len(self) > 1 + ): + self.popitem(last=False) + + def __delitem__(self, key: Any) -> None: + self._chars -= self._size(self[key]) + super().__delitem__(key) + + def pop(self, key: Any, *default: Any) -> Any: + if key in self: + self._chars -= self._size(self[key]) + return super().pop(key, *default) + + def popitem(self, last: bool = True) -> tuple[Any, Any]: + key, value = super().popitem(last=last) + self._chars -= self._size(value) + return key, value + def _build_config_instance(config_cls: type[Any], values: dict[str, Any]) -> Any: """Instantiate third-party config classes using only supported kwargs.""" @@ -104,11 +160,6 @@ def _create_media_store(settings: MediaSettings) -> BaseMediaStore: prefix=settings.MEDIA_CLOUD_PREFIX, ) - if stype == MediaStorageType.PG: - from agentflow.media.storage.pg_store import PgBlobStore - - return PgBlobStore() - raise ValueError(f"Unknown MEDIA_STORAGE_TYPE: {stype}") @@ -140,8 +191,10 @@ def __init__( self._checkpointer = checkpointer self._store: BaseMediaStore | None = None self._pipeline: DocumentPipeline | None = None - self._extraction_cache: dict[str, str] = {} # file_id -> extracted text - self._signed_url_cache: dict[str, dict[str, Any]] = {} # file_id -> signed URL payload + # file_id -> extracted text + self._extraction_cache = _BoundedCache(MAX_EXTRACTION_ENTRIES, MAX_EXTRACTION_CHARS) + # file_id -> signed URL payload + self._signed_url_cache = _BoundedCache(MAX_SIGNED_URL_ENTRIES) @property def store(self) -> BaseMediaStore: @@ -198,7 +251,7 @@ async def ensure_can_access(self, file_id: str, user_id: str | None) -> None: owner_id = metadata.get("owner_id") if owner_id is None: - if getattr(self._settings, "MEDIA_REQUIRE_OWNER", False): + if self._settings.MEDIA_REQUIRE_OWNER: raise PermissionError(f"File has no recorded owner: {file_id}") logger.warning( "File %s has no recorded owner (uploaded before ownership tracking); " @@ -265,7 +318,7 @@ async def upload_file( mime_type ): try: - text = await self.pipeline.extractor.extract(data, filename) + text = await self.pipeline.extractor.extract(data, filename, mime_type) if text: result["extracted_text"] = text await self._cache_extraction(storage_key, text) diff --git a/agentflow_cli/src/app/routers/media/router.py b/agentflow_cli/src/app/routers/media/router.py index 5a8eb57..1745da4 100644 --- a/agentflow_cli/src/app/routers/media/router.py +++ b/agentflow_cli/src/app/routers/media/router.py @@ -28,6 +28,52 @@ # is buffered in memory. _UPLOAD_CHUNK_BYTES = 1024 * 1024 +# Stored types come from the uploader. Only types a browser displays without running +# script are served inline; the rest are downloads. +_INLINE_TYPES = frozenset( + { + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/avif", + "image/bmp", + "application/pdf", + "text/plain", + } +) +_INLINE_PREFIXES = ("audio/", "video/") + + +def _is_active_type(mime: str) -> bool: + """Whether a browser would run this type as a page or script, even from a blob: URL. + + Such files are served as opaque bytes, so neither a navigation nor ``response.blob()`` + keeps the active type. + """ + subtype = mime.partition("/")[2] + return ( + subtype in {"html", "xml", "xhtml+xml", "svg+xml"} + or subtype.endswith("+xml") + or "javascript" in subtype + or "ecmascript" in subtype + ) + + +def _download_response(file_id: str, data: bytes, mime_type: str | None) -> Response: + """Serve stored bytes so an uploaded HTML or SVG file cannot run script on this origin.""" + mime = (mime_type or "").split(";", 1)[0].strip().lower() + if mime in _INLINE_TYPES or mime.startswith(_INLINE_PREFIXES): + disposition = "inline" + else: + disposition = f'attachment; filename="{file_id}"' + if not mime or _is_active_type(mime): + mime = "application/octet-stream" + headers = {"Content-Disposition": disposition, "X-Content-Type-Options": "nosniff"} + if mime != "application/pdf": # browsers will not render a PDF under a sandbox CSP + headers["Content-Security-Policy"] = "default-src 'none'; sandbox" + return Response(content=data, media_type=mime or "application/octet-stream", headers=headers) + # ------------------------------------------------------------------ # 4.1 POST /v1/files/upload @@ -114,7 +160,7 @@ async def get_file( # 404, not 403: do not confirm that someone else's file_id exists. raise HTTPException(status_code=404, detail="File not found") - return Response(content=data, media_type=mime_type) + return _download_response(file_id, data, mime_type) # ------------------------------------------------------------------ @@ -189,7 +235,6 @@ async def get_multimodal_config( return success_response( MultimodalConfigResponse( media_storage_type=settings.MEDIA_STORAGE_TYPE.value, - media_storage_path=settings.MEDIA_STORAGE_PATH, media_max_size_mb=settings.MEDIA_MAX_SIZE_MB, document_handling=settings.DOCUMENT_HANDLING, ), diff --git a/agentflow_cli/src/app/routers/media/schemas.py b/agentflow_cli/src/app/routers/media/schemas.py index ff3818b..a7e2e49 100644 --- a/agentflow_cli/src/app/routers/media/schemas.py +++ b/agentflow_cli/src/app/routers/media/schemas.py @@ -35,6 +35,5 @@ class FileAccessUrlResponse(BaseModel): class MultimodalConfigResponse(BaseModel): media_storage_type: str - media_storage_path: str media_max_size_mb: float document_handling: str diff --git a/agentflow_cli/src/app/routers/setup_router.py b/agentflow_cli/src/app/routers/setup_router.py index 61a1d2f..c1f3c40 100644 --- a/agentflow_cli/src/app/routers/setup_router.py +++ b/agentflow_cli/src/app/routers/setup_router.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from .checkpointer.router import router as checkpointer_router from .evals import router as evals_router @@ -8,7 +8,33 @@ from .store import router as store_router -def init_routes(app: FastAPI): +AG_UI_INSTALL_HINT = 'pip install "10xscale-agentflow-cli[ag-ui]"' + + +def _import_ag_ui_router() -> APIRouter: + from .ag_ui import router + + return router + + +def _ag_ui_router() -> APIRouter: + """Import the AG-UI router, which needs the optional ``ag-ui-protocol`` package. + + Imported only when ``ag_ui.enabled`` is set, so servers that leave it off never need the + package installed. + """ + try: + return _import_ag_ui_router() + except ModuleNotFoundError as exc: + if exc.name and exc.name.split(".")[0] == "ag_ui": + raise RuntimeError( + "ag_ui is enabled in agentflow.json but the AG-UI SDK is not installed. " + f"Install it with: {AG_UI_INSTALL_HINT}" + ) from exc + raise + + +def init_routes(app: FastAPI, *, evals: bool = True, ag_ui: bool = False): """ Initialize the routes for the FastAPI application. @@ -18,10 +44,17 @@ def init_routes(app: FastAPI): Args: app (FastAPI): The FastAPI application instance to which the routes will be added. + evals (bool): Mount the evals report viewer. It has no auth, so production + leaves it out. + ag_ui (bool): Mount the AG-UI endpoint (``POST /v1/ag-ui``). Off unless + ``ag_ui.enabled`` is set in agentflow.json. """ app.include_router(graph_router) app.include_router(checkpointer_router) app.include_router(store_router) - app.include_router(evals_router) + if evals: + app.include_router(evals_router) + if ag_ui: + app.include_router(_ag_ui_router()) app.include_router(ping_router) app.include_router(media_router) diff --git a/agentflow_cli/src/app/routers/store/schemas/store_schemas.py b/agentflow_cli/src/app/routers/store/schemas/store_schemas.py index b5e1570..890b318 100644 --- a/agentflow_cli/src/app/routers/store/schemas/store_schemas.py +++ b/agentflow_cli/src/app/routers/store/schemas/store_schemas.py @@ -15,6 +15,13 @@ from pydantic import BaseModel, Field +# Upper bounds so one request cannot make the store (and the embedding / vector backend) +# return or rank an unbounded number of rows. +MAX_SEARCH_LIMIT = 100 +MAX_LIST_LIMIT = 1000 +MAX_SEARCH_TOKENS = 16_000 + + class BaseConfigSchema(BaseModel): """Base schema containing configuration overrides and store options.""" @@ -55,7 +62,12 @@ class SearchMemorySchema(BaseConfigSchema): default=None, description="Optional category filter.", ) - limit: int = Field(default=10, gt=0, description="Maximum number of results to return.") + limit: int = Field( + default=10, + gt=0, + le=MAX_SEARCH_LIMIT, + description="Maximum number of results to return.", + ) score_threshold: float | None = Field( default=None, description="Minimum similarity score required for results.", @@ -75,6 +87,7 @@ class SearchMemorySchema(BaseConfigSchema): max_tokens: int = Field( default=4000, gt=0, + le=MAX_SEARCH_TOKENS, description="Maximum tokens used for truncation in similarity search.", ) @@ -100,7 +113,12 @@ class GetMemorySchema(BaseConfigSchema): class ListMemoriesSchema(BaseConfigSchema): """Schema for listing memories.""" - limit: int = Field(default=100, gt=0, description="Maximum number of memories to return.") + limit: int = Field( + default=100, + gt=0, + le=MAX_LIST_LIMIT, + description="Maximum number of memories to return.", + ) class ForgetMemorySchema(BaseConfigSchema): diff --git a/agentflow_cli/src/app/routers/store/services/store_service.py b/agentflow_cli/src/app/routers/store/services/store_service.py index 10075d7..661edaf 100644 --- a/agentflow_cli/src/app/routers/store/services/store_service.py +++ b/agentflow_cli/src/app/routers/store/services/store_service.py @@ -8,6 +8,7 @@ from injectq import inject, singleton from agentflow_cli.src.app.core import logger +from agentflow_cli.src.app.core.auth.request_config import client_config from agentflow_cli.src.app.routers.store.schemas.store_schemas import ( ForgetMemorySchema, MemoryCreateResponseSchema, @@ -30,6 +31,31 @@ def __init__(self) -> None: ValueError.__init__(self, detail) +# Memory fields the store owns. Client metadata is kept, but these keys are dropped from it: +# user_id and thread_id decide who sees a memory, and memory_id is the id Mem0 reports back. +SYSTEM_METADATA_KEYS = frozenset( + {"content", "user_id", "thread_id", "memory_type", "category", "timestamp", "memory_id"} +) + +# Store keyword options only the server sets. ``astore`` upserts, so a client-chosen +# memory_id could replace an existing memory; the store always generates the id instead. +SERVER_OPTION_KEYS = frozenset({"memory_id"}) + +# Config keys that route storage. A client-chosen collection could read another application's +# collection or create collections without limit. +SERVER_CONFIG_KEYS = frozenset({"collection"}) + + +def _client_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None: + if metadata is None: + return None + return {k: v for k, v in metadata.items() if k not in SYSTEM_METADATA_KEYS} + + +def _client_options(options: dict[str, Any] | None) -> dict[str, Any]: + return {k: v for k, v in (options or {}).items() if k not in SERVER_OPTION_KEYS} + + @singleton class StoreService: """Service layer wrapping interactions with the configured BaseStore.""" @@ -44,9 +70,11 @@ def _get_store(self) -> BaseStore: return self.store def _config(self, config: dict[str, Any] | None, user: dict[str, Any]) -> dict[str, Any]: - cfg: dict[str, Any] = dict(config or {}) - # Overwrite (never setdefault): the trusted user object carries the authz policy; - # a client-supplied "user" must not be able to weaken it. + # Client keys pass through; server-owned keys (authz, user, internals) never do. + # The trusted user object carries the authz policy the store enforces. + cfg: dict[str, Any] = { + k: v for k, v in client_config(config).items() if k not in SERVER_CONFIG_KEYS + } cfg["user"] = user cfg["user_id"] = user.get("user_id", "anonymous") return cfg @@ -58,7 +86,7 @@ async def store_memory( ) -> MemoryCreateResponseSchema: store = self._get_store() cfg = self._config(payload.config, user) - options = payload.options or {} + options = _client_options(payload.options) if isinstance(payload.content, Message): content: str | Message = payload.content @@ -70,7 +98,7 @@ async def store_memory( content, memory_type=payload.memory_type, category=payload.category, - metadata=payload.metadata, + metadata=_client_metadata(payload.metadata), **options, ) logger.debug("Stored memory with id %s", memory_id) @@ -83,7 +111,7 @@ async def search_memories( ) -> MemorySearchResponseSchema: store = self._get_store() cfg = self._config(payload.config, user) - options = payload.options or {} + options = _client_options(payload.options) results = await store.asearch( cfg, @@ -109,7 +137,7 @@ async def get_memory( ) -> MemoryItemResponseSchema: store = self._get_store() cfg = self._config(config, user) - result = await store.aget(cfg, memory_id, **(options or {})) + result = await store.aget(cfg, memory_id, **_client_options(options)) return MemoryItemResponseSchema(memory=result) async def list_memories( @@ -121,7 +149,7 @@ async def list_memories( ) -> MemoryListResponseSchema: store = self._get_store() cfg = self._config(config, user) - memories = await store.aget_all(cfg, limit=limit, **(options or {})) + memories = await store.aget_all(cfg, limit=limit, **_client_options(options)) return MemoryListResponseSchema(memories=memories) async def update_memory( @@ -132,13 +160,13 @@ async def update_memory( ) -> MemoryOperationResponseSchema: store = self._get_store() cfg = self._config(payload.config, user) - options = payload.options or {} + options = _client_options(payload.options) result = await store.aupdate( cfg, memory_id, payload.content, - metadata=payload.metadata, + metadata=_client_metadata(payload.metadata), **options, ) return MemoryOperationResponseSchema(success=True, data=result) @@ -152,7 +180,7 @@ async def delete_memory( ) -> MemoryOperationResponseSchema: store = self._get_store() cfg = self._config(config, user) - result = await store.adelete(cfg, memory_id, **(options or {})) + result = await store.adelete(cfg, memory_id, **_client_options(options)) return MemoryOperationResponseSchema(success=True, data=result) async def forget_memory( @@ -162,7 +190,7 @@ async def forget_memory( ) -> MemoryOperationResponseSchema: store = self._get_store() cfg = self._config(payload.config, user) - options = payload.options or {} + options = _client_options(payload.options) forget_kwargs: dict[str, Any] = { "memory_type": payload.memory_type, "category": payload.category, diff --git a/agentflow_cli/src/app/utils/media/extractor.py b/agentflow_cli/src/app/utils/media/extractor.py index 23b46d3..1b5468d 100644 --- a/agentflow_cli/src/app/utils/media/extractor.py +++ b/agentflow_cli/src/app/utils/media/extractor.py @@ -7,7 +7,11 @@ from __future__ import annotations +import asyncio +import io import logging +import zipfile +from pathlib import PurePosixPath from typing import Any @@ -20,6 +24,75 @@ AsyncTextExtractor = None # type: ignore[assignment] +# The handler textxtract runs is chosen from the file extension. Uploads never pass their +# own file name through: the declared MIME type picks one of these extensions, and a file +# with any other type (archives included) is not extracted. That keeps a file named +# ``a.zip`` out of textxtract's ZIP handler, which unpacks nested archives with no overall +# size budget. +HANDLER_EXTENSIONS: dict[str, str] = { + "application/pdf": ".pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/msword": ".doc", + "text/html": ".html", + "text/xml": ".xml", + "application/xml": ".xml", + "text/markdown": ".md", + "text/csv": ".csv", + "application/json": ".json", + "text/plain": ".txt", +} +_EXTRACTABLE_EXTENSIONS = frozenset(HANDLER_EXTENSIONS.values()) + +# DOCX files are ZIP containers, so they are inspected before parsing: a small upload can +# declare gigabytes of XML. +MAX_ARCHIVE_UNCOMPRESSED_BYTES = 50 * 1024 * 1024 +MAX_COMPRESSION_RATIO = 100 +MAX_ARCHIVE_ENTRIES = 10_000 + +EXTRACTION_TIMEOUT_SECONDS = 30.0 +MAX_EXTRACTED_CHARS = 1_000_000 + + +def _handler_extension(filename: str | None, mime_type: str | None) -> str | None: + """The extension (and so the handler) to extract with, or None to skip extraction.""" + if mime_type: + return HANDLER_EXTENSIONS.get(mime_type.split(";", 1)[0].strip().lower()) + suffix = PurePosixPath(filename or "").suffix.lower() + return suffix if suffix in _EXTRACTABLE_EXTENSIONS else None + + +def _check_zip_container(data: bytes) -> None: + """Reject a ZIP-based document whose contents would expand far beyond its size.""" + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + entries = archive.infolist() + except zipfile.BadZipFile as exc: + raise ValueError("Document is not a valid ZIP-based file") from exc + + if len(entries) > MAX_ARCHIVE_ENTRIES: + raise ValueError("Document has too many parts to extract") + total = sum(entry.file_size for entry in entries) + if total > MAX_ARCHIVE_UNCOMPRESSED_BYTES: + raise ValueError("Document is too large to extract once uncompressed") + for entry in entries: + if entry.file_size > MAX_COMPRESSION_RATIO * max(entry.compress_size, 1): + raise ValueError(f"Document part {entry.filename!r} has a suspicious compression ratio") + + +def _as_text(result: Any) -> str | None: + if result is None: + return None + if isinstance(result, list | tuple): + result = "\n\n".join(str(part) for part in result) + text = str(result) + if len(text) > MAX_EXTRACTED_CHARS: + logger.warning( + "Extracted text truncated from %d to %d chars", len(text), MAX_EXTRACTED_CHARS + ) + text = text[:MAX_EXTRACTED_CHARS] + return text + + class DocumentExtractor: """Wraps textxtract AsyncTextExtractor for API-side document extraction. @@ -42,28 +115,54 @@ def __init__(self, extractor: Any | None = None): self.extractor = AsyncTextExtractor() - async def extract(self, data: bytes | str, filename: str | None = None) -> str | None: + async def extract( + self, + data: bytes | str, + filename: str | None = None, + mime_type: str | None = None, + ) -> str | None: """Extract text from bytes or a local path. + For bytes, the handler comes from ``mime_type`` when given, otherwise from the + extension of ``filename``, and only document types are extracted: archives and + unknown types return ``None``. The client's file name is never handed to textxtract. + Args: data: Raw bytes or local path. - filename: Required when passing bytes so type can be detected. + filename: Required when passing bytes (used only when ``mime_type`` is absent). + mime_type: The declared content type; preferred for picking the handler. Returns: - Extracted text, or ``None`` when file type is unsupported. + Extracted text (capped at ``MAX_EXTRACTED_CHARS``), or ``None`` when the file + type is not extracted. Raises: - ValueError: For missing filename or failed extraction. + ValueError: For a missing filename, an oversized or bomb-like document, a + timeout, or failed extraction. """ if isinstance(data, bytes) and not filename: raise ValueError("filename must be provided when extracting from bytes") try: if isinstance(data, bytes): - if filename is None: - raise ValueError("filename must be provided when extracting from bytes") - return await self.extractor.extract(data, filename) - return await self.extractor.extract(data) + extension = _handler_extension(filename, mime_type) + if extension is None: + logger.info( + "Not extracting %s (%s): not a supported document", filename, mime_type + ) + return None + if extension == ".docx": + _check_zip_container(data) + result = await asyncio.wait_for( + self.extractor.extract(data, f"upload{extension}"), + timeout=EXTRACTION_TIMEOUT_SECONDS, + ) + return _as_text(result) + return _as_text(await self.extractor.extract(data)) + + except TimeoutError as exc: + logger.warning("Document extraction timed out for %s", filename) + raise ValueError("Document extraction timed out") from exc except FileTypeNotSupportedError: # type: ignore logger.warning("Document type not supported for extraction: %s", filename) diff --git a/agentflow_cli/src/app/utils/media/pipeline.py b/agentflow_cli/src/app/utils/media/pipeline.py index 061e0fd..3422703 100644 --- a/agentflow_cli/src/app/utils/media/pipeline.py +++ b/agentflow_cli/src/app/utils/media/pipeline.py @@ -72,7 +72,7 @@ async def process_document(self, document_block: Any) -> Any | None: if media.kind == "data" and media.data_base64: decoded = b64decode(media.data_base64) filename = media.filename or "document.pdf" - extracted = await self.extractor.extract(decoded, filename) + extracted = await self.extractor.extract(decoded, filename, media.mime_type) if extracted: return TextBlock(text=extracted) # Extraction returned None (unsupported type) — keep raw diff --git a/agentflow_cli/src/app/utils/schemas/output_schemas.py b/agentflow_cli/src/app/utils/schemas/output_schemas.py index 92cd088..45e3e0f 100644 --- a/agentflow_cli/src/app/utils/schemas/output_schemas.py +++ b/agentflow_cli/src/app/utils/schemas/output_schemas.py @@ -7,7 +7,7 @@ class ErrorSchemas(BaseModel): - loc: list[str] = Field(..., title="Location") + loc: list[str | int] = Field(..., title="Location") msg: str = Field(..., title="Error message") type: str = Field(..., title="Error type") diff --git a/config-editor-ui/.gitignore b/config-editor-ui/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/config-editor-ui/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/config-editor-ui/package-lock.json b/config-editor-ui/package-lock.json new file mode 100644 index 0000000..f934fdd --- /dev/null +++ b/config-editor-ui/package-lock.json @@ -0,0 +1,1676 @@ +{ + "name": "agentflow-config-editor-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentflow-config-editor-ui", + "dependencies": { + "preact": "10.29.8" + }, + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "esbuild": "0.28.2", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/config-editor-ui/package.json b/config-editor-ui/package.json new file mode 100644 index 0000000..66662dd --- /dev/null +++ b/config-editor-ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "agentflow-config-editor-ui", + "private": true, + "description": "Source for the agentflow config browser editor. Builds into agentflow_cli/cli/config_editor/static.", + "type": "module", + "scripts": { + "build:css": "tailwindcss -i src/app.css -o ../agentflow_cli/cli/config_editor/static/app.css --minify", + "build:js": "esbuild src/main.jsx --bundle --minify --format=esm --target=es2022 --jsx=automatic --jsx-import-source=preact --outfile=../agentflow_cli/cli/config_editor/static/app.js", + "build": "npm run build:css && npm run build:js", + "watch:css": "tailwindcss -i src/app.css -o ../agentflow_cli/cli/config_editor/static/app.css --watch", + "watch:js": "esbuild src/main.jsx --bundle --format=esm --target=es2022 --jsx=automatic --jsx-import-source=preact --outfile=../agentflow_cli/cli/config_editor/static/app.js --watch" + }, + "dependencies": { + "preact": "10.29.8" + }, + "devDependencies": { + "@tailwindcss/cli": "4.3.3", + "esbuild": "0.28.2", + "tailwindcss": "4.3.3" + } +} diff --git a/config-editor-ui/src/actions.js b/config-editor-ui/src/actions.js new file mode 100644 index 0000000..4307e71 --- /dev/null +++ b/config-editor-ui/src/actions.js @@ -0,0 +1,211 @@ +import { + TIERS, TOKEN, clone, commit, counts, isDirty, isOn, plural, section, snapshot, store, +} from "./store.js"; + +// ------------------------------------------------------------------ network + +async function api(method, url, body) { + const headers = { "X-Agentflow-Token": TOKEN }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); + let data = {}; + try { data = await res.json(); } catch { /* empty body */ } + return { ok: res.ok, status: res.status, data }; +} + +const fatal = (title, text) => commit((s) => { s.fatal = { title, text }; }); + +function rank(order, id) { + const i = order.indexOf(id); + return i === -1 ? order.length : i; +} + +function openIssues() { + for (const issue of store.issues) if (section(issue.section)) store.open.add(issue.section); +} + +export async function load() { + if (!TOKEN) return fatal("Open the link printed by agentflow config", "This page needs the session token included in that link."); + let res; + try { + res = await api("GET", "/api/state"); + } catch { + return fatal("Cannot reach the config server", "Check that agentflow config is still running in your terminal, then reload."); + } + if (!res.ok) return fatal("This link has expired", res.data.error || "Restart agentflow config and open the new link it prints."); + + const data = res.data; + commit((s) => { + s.schema = data.schema; + const order = TIERS.flatMap((t) => t.sections); + s.sections = [...data.schema.sections].sort((a, b) => rank(order, a.id) - rank(order, b.id)); + s.tiers = TIERS.map((t) => ({ ...t, list: t.sections.map(section).filter(Boolean) })); + s.tiers[s.tiers.length - 1].list.push(...s.sections.filter((sec) => !order.includes(sec.id))); + for (const sec of s.sections) for (const f of sec.fields) s.fields.set(f.path.join("."), f); + s.config = data.config; + s.version = data.version; + s.exists = data.exists; + s.path = data.path; + s.parseError = data.parse_error; + s.issues = data.issues; + const clean = data.exists && !data.parse_error; + s.saved = clean ? snapshot() : ""; + s.validated = clean ? snapshot() : null; + // Open everything that is required, switched on, or has something to fix. + for (const sec of s.sections) if (!sec.optional || isOn(sec)) s.open.add(sec.id); + openIssues(); + }); + const requested = new URLSearchParams(location.hash.slice(1)).get("section"); + if (requested && section(requested)) go(requested); +} + +export async function validate() { + if (store.busy || !guardJson()) return; + commit((s) => { s.busy = "validate"; }); + try { + const res = await api("POST", "/api/validate", { config: store.config }); + if (!res.ok) return toast("error", res.data.error || "Validation failed."); + store.issues = res.data.issues; + store.validated = snapshot(); + openIssues(); + const { errors, warnings } = counts(); + const show = { label: "Show", run: () => openInspector("problems") }; + if (errors) toast("error", `${plural(errors, "error")} to fix before saving.`, show); + else if (warnings) toast("warning", `Valid, with ${plural(warnings, "warning")}.`, show); + else toast("success", "Configuration is valid."); + } catch { + toast("error", "Cannot reach the config server."); + } finally { + commit((s) => { s.busy = null; }); + } +} + +export async function save() { + if (store.busy || !store.schema) return; + if (store.exists && !isDirty() && !store.parseError) return toast("success", "No changes to save."); + if (!guardJson()) return; + commit((s) => { s.busy = "save"; }); + try { + const res = await api("POST", "/api/save", { config: store.config, version: store.version }); + if (res.data.issues) { + store.issues = res.data.issues; + store.validated = snapshot(); + openIssues(); + } + if (res.ok) { + store.version = res.data.version; + store.config = res.data.config; + store.toolParams = {}; + store.validated = snapshot(); + store.saved = snapshot(); + store.exists = true; + store.parseError = null; + const name = res.data.path.split(/[\\/]/).pop(); + toast("success", res.data.backup ? `Saved ${name}. The previous version is in ${name}.bak.` : `Created ${name}.`); + } else if (res.status === 409) { + toast("error", res.data.error, { label: "Reload", run: () => location.reload() }); + } else if (res.status === 422) { + const { errors } = counts(); + toast("error", `Not saved: ${plural(errors, "error")} to fix first.`, { label: "Show", run: () => openInspector("problems") }); + } else { + toast("error", res.data.error || "Save failed."); + } + } catch { + toast("error", "Cannot reach the config server."); + } finally { + commit((s) => { s.busy = null; }); + } +} + +function guardJson() { + const broken = Object.values(store.toolParams).some((t) => t.error); + if (broken) { + go("remote_tools"); + toast("error", "A remote tool has invalid parameters JSON."); + } + return !broken; +} + +// ------------------------------------------------------------------ sections + +export function setSection(sec, on) { + commit((s) => { + if (on) { + s.config[sec.key] = clone(sec.default); + s.open.add(sec.id); + } else { + delete s.config[sec.key]; + } + if (sec.key === "remote_tools") s.toolParams = {}; + }); +} + +export function toggleOpen(id) { + commit((s) => { + if (s.open.has(id)) s.open.delete(id); + else s.open.add(id); + }); +} + +export function setAllOpen(open) { + commit((s) => { s.open = open ? new Set(s.sections.map((sec) => sec.id)) : new Set(); }); +} + +const smooth = () => (window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"); + +// Expand a section and bring it (or one of its fields) into view. +export function go(id, fieldId) { + commit((s) => { + s.open.add(id); + s.inspectorOpen = false; + }); + history.replaceState(null, "", `#token=${encodeURIComponent(TOKEN)}§ion=${id}`); + requestAnimationFrame(() => { + const field = fieldId ? document.getElementById(fieldId) : null; + if (field) { + field.scrollIntoView({ block: "center", behavior: smooth() }); + field.focus({ preventScroll: true }); + return; + } + document.getElementById(`sec-${id}`)?.scrollIntoView({ block: "start", behavior: smooth() }); + document.getElementById(`acc-${id}`)?.focus({ preventScroll: true }); + }); +} + +export function scrollToTier(id) { + document.getElementById(`tier-${id}`)?.scrollIntoView({ block: "start", behavior: smooth() }); +} + +// ------------------------------------------------------------------ inspector + +export function openInspector(tab) { + commit((s) => { + if (tab) s.tab = tab; + s.inspectorOpen = !window.matchMedia("(min-width: 1280px)").matches; + }); +} + +export const closeInspector = () => commit((s) => { s.inspectorOpen = false; }); + +export async function copyJson() { + try { + await navigator.clipboard.writeText(JSON.stringify(store.config, null, 2) + "\n"); + toast("success", "Copied agentflow.json to the clipboard."); + } catch { + toast("error", "The clipboard is not available here."); + } +} + +// ------------------------------------------------------------------ toasts + +let toastId = 0; + +export function toast(kind, message, action) { + const id = ++toastId; + commit((s) => { s.toasts = [...s.toasts, { id, kind, message, action }]; }); + setTimeout(() => dismissToast(id), action ? 8000 : 4000); +} + +export function dismissToast(id) { + commit((s) => { s.toasts = s.toasts.filter((t) => t.id !== id); }); +} diff --git a/config-editor-ui/src/app.css b/config-editor-ui/src/app.css new file mode 100644 index 0000000..0afdbdf --- /dev/null +++ b/config-editor-ui/src/app.css @@ -0,0 +1,189 @@ +@import "tailwindcss"; +@source "./"; + +/* Agentflow tokens (see agentflow-docs/src/css/tokens.css). Dark is canonical. */ +:root { + color-scheme: light; + --af-canvas: #f5f7fb; + --af-surface: #ffffff; + --af-raised: #f1f5f9; + --af-hover: #e8eef6; + --af-sunken: #ffffff; + --af-line: rgba(15, 23, 42, 0.09); + --af-line-strong: rgba(15, 23, 42, 0.17); + --af-ink: #0b1220; + --af-body: #1e293b; + --af-muted: #475569; + --af-faint: #64748b; + --af-brand: #2563eb; + --af-brand-solid: #2563eb; + --af-brand-solid-hover: #1d4ed8; + --af-brand-soft: rgba(37, 99, 235, 0.08); + --af-brand-ring: rgba(37, 99, 235, 0.22); + --af-ok: #047857; + --af-ok-soft: rgba(16, 185, 129, 0.1); + --af-warn: #b45309; + --af-warn-soft: rgba(245, 158, 11, 0.12); + --af-danger: #dc2626; + --af-danger-soft: rgba(239, 68, 68, 0.09); + --af-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 3px rgba(15, 23, 42, 0.04); + --af-shadow-pop: 0 12px 32px rgba(15, 23, 42, 0.14); + --af-scrollbar: rgba(15, 23, 42, 0.2); + --af-scrollbar-hover: rgba(15, 23, 42, 0.35); + --af-json-key: #1d4ed8; + --af-json-string: #047857; + --af-json-number: #b45309; + --af-json-literal: #7c3aed; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --af-canvas: #08111f; + --af-surface: #0c1727; + --af-raised: #131e33; + --af-hover: #1a2741; + --af-sunken: #07101d; + --af-line: rgba(148, 163, 184, 0.14); + --af-line-strong: rgba(148, 163, 184, 0.26); + --af-ink: #f8fafc; + --af-body: #e2e8f0; + --af-muted: #a3b1c6; + --af-faint: #8391a7; + --af-brand: #60a5fa; + --af-brand-solid: #2563eb; + --af-brand-solid-hover: #3b82f6; + --af-brand-soft: rgba(59, 130, 246, 0.14); + --af-brand-ring: rgba(59, 130, 246, 0.35); + --af-ok: #34d399; + --af-ok-soft: rgba(16, 185, 129, 0.14); + --af-warn: #fbbf24; + --af-warn-soft: rgba(245, 158, 11, 0.13); + --af-danger: #f87171; + --af-danger-soft: rgba(239, 68, 68, 0.13); + --af-shadow: 0 1px 2px rgba(2, 6, 23, 0.4); + --af-shadow-pop: 0 16px 40px rgba(2, 6, 23, 0.55); + --af-scrollbar: rgba(148, 163, 184, 0.22); + --af-scrollbar-hover: rgba(148, 163, 184, 0.4); + --af-json-key: #93c5fd; + --af-json-string: #6ee7b7; + --af-json-number: #fcd34d; + --af-json-literal: #c4b5fd; + } +} + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --color-canvas: var(--af-canvas); + --color-surface: var(--af-surface); + --color-raised: var(--af-raised); + --color-hover: var(--af-hover); + --color-sunken: var(--af-sunken); + --color-line: var(--af-line); + --color-line-strong: var(--af-line-strong); + --color-ink: var(--af-ink); + --color-body: var(--af-body); + --color-muted: var(--af-muted); + --color-faint: var(--af-faint); + --color-brand: var(--af-brand); + --color-brand-solid: var(--af-brand-solid); + --color-brand-solid-hover: var(--af-brand-solid-hover); + --color-brand-soft: var(--af-brand-soft); + --color-brand-ring: var(--af-brand-ring); + --color-ok: var(--af-ok); + --color-ok-soft: var(--af-ok-soft); + --color-warn: var(--af-warn); + --color-warn-soft: var(--af-warn-soft); + --color-danger: var(--af-danger); + --color-danger-soft: var(--af-danger-soft); + --shadow-card: var(--af-shadow); + --shadow-pop: var(--af-shadow-pop); +} + +@layer base { + html { + scrollbar-gutter: stable; + } + body { + @apply bg-canvas font-sans text-body antialiased; + } + /* One thin, theme-coloured scrollbar style everywhere (the window and the inspector). */ + * { + scrollbar-width: thin; + scrollbar-color: var(--af-scrollbar) transparent; + } + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 9999px; + background: var(--af-scrollbar) padding-box; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--af-scrollbar-hover) padding-box; + } + ::-webkit-scrollbar-corner { + background: transparent; + } +} + +@layer components { + .input { + @apply block h-10 w-full rounded-lg border border-line-strong bg-sunken px-3 text-base text-ink + transition-[border-color,box-shadow] duration-150 placeholder:text-faint + hover:border-faint/60 focus:border-brand focus:ring-4 focus:ring-brand-ring focus:outline-none + sm:h-9 sm:text-sm; + } + .input-invalid { + @apply border-danger focus:border-danger focus:ring-danger-soft; + } + .btn { + @apply inline-flex h-10 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg px-3.5 + text-sm font-medium whitespace-nowrap transition-colors duration-150 + focus-visible:ring-4 focus-visible:ring-brand-ring focus-visible:outline-none + disabled:cursor-not-allowed disabled:opacity-45 sm:h-9; + } + .btn-primary { + @apply bg-brand-solid text-white shadow-card hover:bg-brand-solid-hover; + } + .btn-outline { + @apply border border-line-strong bg-surface text-body hover:bg-raised; + } + .btn-ghost { + @apply text-muted hover:bg-raised hover:text-ink; + } + .icon-btn { + @apply inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted + transition-colors duration-150 hover:bg-raised hover:text-ink + focus-visible:ring-4 focus-visible:ring-brand-ring focus-visible:outline-none sm:size-9; + } + .panel { + @apply rounded-xl border border-line bg-surface shadow-card; + } + .kbd { + @apply inline-flex h-5 items-center rounded border border-white/25 px-1.5 font-mono text-[11px] + font-medium text-white/80; + } + .eyebrow { + @apply text-xs font-semibold tracking-wide text-faint uppercase; + } + .focus-ring { + @apply focus-visible:ring-4 focus-visible:ring-brand-ring focus-visible:outline-none; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0s !important; + animation-duration: 0s !important; + scroll-behavior: auto !important; + } +} diff --git a/config-editor-ui/src/fields.jsx b/config-editor-ui/src/fields.jsx new file mode 100644 index 0000000..264feeb --- /dev/null +++ b/config-editor-ui/src/fields.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "preact/hooks"; +import { commit, deletePath, getPath, issueAt, setPath, store, valueOrDefault } from "./store.js"; +import { Chips, FieldIssue, FieldMeta, Row, Select, Toggle } from "./ui.jsx"; + +// Fields grouped by their `group` label, skipping ones whose show_if does not match. +export function fieldGroups(sec) { + const groups = []; + for (const f of sec.fields) { + if (f.show_if && valueOrDefault(f.show_if.path) !== f.show_if.equals) continue; + const name = f.group || ""; + let group = groups.find((g) => g.name === name); + if (!group) groups.push(group = { name, fields: [] }); + group.fields.push(f); + } + return groups; +} + +const put = (path, value) => commit((s) => { + if (value === undefined) deletePath(s.config, path); + else setPath(s.config, path, value); +}); + +// Keeps the raw text while typing so "0." or "1e" do not get rewritten mid-edit. +function NumberInput({ id, value, field, invalid }) { + const [text, setText] = useState(String(value ?? "")); + useEffect(() => { + if (text === "" ? value !== undefined : Number(text) !== value) setText(String(value ?? "")); + }, [value]); + return ( + { + const raw = e.currentTarget.value; + setText(raw); + if (raw === "") put(field.path, undefined); + else if (!Number.isNaN(Number(raw))) put(field.path, Number(raw)); + }} + /> + ); +} + +export function Field({ field: f }) { + const key = f.path.join("."); + const id = `f-${f.path.join("-")}`; + const value = getPath(store.config, f.path); + const issue = issueAt(key); + const invalid = issue?.level === "error"; + + if (f.type === "bool") { + return ( +
+
+ + +
+
+ put(f.path, v)} /> +
+
+ ); + } + + if (f.type === "list") { + return ( +
+ +
+ put(f.path, next.length ? next : undefined)} + /> + +
+
+ ); + } + + let control; + if (f.type === "select") { + control = ( + put(f.path, e.currentTarget.value === "" ? undefined : e.currentTarget.value)} + /> + ); + } + return ( + + +
+ {control} + +
+
+ ); +} diff --git a/config-editor-ui/src/icons.js b/config-editor-ui/src/icons.js new file mode 100644 index 0000000..4b0232c --- /dev/null +++ b/config-editor-ui/src/icons.js @@ -0,0 +1,36 @@ +// Lucide icon paths (ISC licence), inlined so the page works offline. +export const ICONS = { + box: '', + key: '', + shield: '', + gauge: '', + arrows: '', + activity: '', + layers: '', + wrench: '', + flask: '', + target: '', + checkCircle: '', + xCircle: '', + alert: '', + info: '', + file: '', + copy: '', + x: '', + plus: '', + trash: '', + panel: '', + chevronDown: '', + expand: '', + collapse: '', + power: '', + spinner: '', + book: '', + external: '', +}; + +export const SECTION_ICONS = { + core: "box", auth: "key", authorization: "shield", rate_limit: "gauge", websocket: "arrows", + ag_ui: "external", + observability: "activity", plugins: "layers", remote_tools: "wrench", test: "flask", evaluation: "target", +}; diff --git a/config-editor-ui/src/inspector.jsx b/config-editor-ui/src/inspector.jsx new file mode 100644 index 0000000..fcb0cfb --- /dev/null +++ b/config-editor-ui/src/inspector.jsx @@ -0,0 +1,140 @@ +import { closeInspector, copyJson, go, validate } from "./actions.js"; +import { SECTION_ICONS } from "./icons.js"; +import { commit, counts, fieldIdFor, isDirty, isStale, issuesFor, plural, store } from "./store.js"; +import { EmptyPanel, Icon } from "./ui.jsx"; + +// Docked on the right from xl up (sticky, its own scroll); a drawer below that. +export function Inspector() { + const { errors, warnings } = counts(); + const count = errors + warnings; + const tab = (id, label, badge) => ( + + ); + return ( + + ); +} + +function Problems() { + if (!store.schema) return null; + if (store.validated === null) { + return ( + + + + ); + } + const { errors, warnings } = counts(); + const stale = isStale(); + const groups = store.sections.map((sec) => [sec, issuesFor(sec.id)]).filter(([, list]) => list.length); + return ( +
+
+

+ {plural(errors, "error")}{" · "} + {plural(warnings, "warning")} +

+ {stale ? : null} +
+ {stale ? ( +
+ You changed the form since this check. +
+ ) : null} + {!store.issues.length ? ( + + ) : ( +
+ {groups.map(([sec, list]) => ( +
+

+ {sec.title} +

+
    + {list.map((issue, i) => { + const error = issue.level === "error"; + return ( +
  • + +
  • + ); + })} +
+
+ ))} +
+ )} +
+ ); +} + +function highlight(line) { + const escaped = line.replace(/&/g, "&").replace(//g, ">"); + return escaped.replace( + /("(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + (m) => { + let color = "var(--af-json-number)"; + if (m.startsWith('"')) color = m.endsWith(":") ? "var(--af-json-key)" : "var(--af-json-string)"; + else if (m === "true" || m === "false" || m === "null") color = "var(--af-json-literal)"; + return `${m}`; + }, + ); +} + +function JsonView() { + const lines = JSON.stringify(store.config, null, 2).split("\n"); + return ( +
+
+

{isDirty() || !store.exists ? "Preview of what Save will write" : "Matches the file on disk"}

+ +
+
+ {lines.map((line, i) => ( +
+ {i + 1} + +
+ ))} +
+
+ ); +} diff --git a/config-editor-ui/src/main.jsx b/config-editor-ui/src/main.jsx new file mode 100644 index 0000000..f7d700c --- /dev/null +++ b/config-editor-ui/src/main.jsx @@ -0,0 +1,81 @@ +import { render } from "preact"; +import { useEffect, useReducer } from "preact/hooks"; +import { closeInspector, dismissToast, load, save } from "./actions.js"; +import { Inspector } from "./inspector.jsx"; +import { Header, Page } from "./page.jsx"; +import { isDirty, store, subscribe } from "./store.js"; +import { Icon } from "./ui.jsx"; + +const TOAST_LOOK = { + success: ["checkCircle", "text-ok"], + warning: ["alert", "text-warn"], + error: ["xCircle", "text-danger"], +}; + +function Toasts() { + return ( +
+ {store.toasts.map((t) => { + const [icon, tint] = TOAST_LOOK[t.kind]; + return ( +
+ +

{t.message}

+ {t.action ? ( + + ) : null} + +
+ ); + })} +
+ ); +} + +function App() { + const [, rerender] = useReducer((n) => n + 1, 0); + + useEffect(() => { + subscribe(rerender); + const onKey = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); save(); } + else if (e.key === "Escape" && store.inspectorOpen) closeInspector(); + }; + const onUnload = (e) => { if (store.schema && isDirty()) e.preventDefault(); }; + document.addEventListener("keydown", onKey); + window.addEventListener("beforeunload", onUnload); + load(); + return () => { + document.removeEventListener("keydown", onKey); + window.removeEventListener("beforeunload", onUnload); + }; + }, []); + + return ( + <> + + Skip to content + +
+
+
+
+ +
+
+ +
+ {store.inspectorOpen ?
: null} + + + ); +} + +render(, document.getElementById("app")); diff --git a/config-editor-ui/src/page.jsx b/config-editor-ui/src/page.jsx new file mode 100644 index 0000000..7396cd0 --- /dev/null +++ b/config-editor-ui/src/page.jsx @@ -0,0 +1,271 @@ +import { openInspector, save, scrollToTier, setAllOpen, setSection, toggleOpen, validate } from "./actions.js"; +import { Field, fieldGroups } from "./fields.jsx"; +import { SECTION_ICONS } from "./icons.js"; +import { WIDGET_FIELD_IDS, counts, isDirty, isOn, issuesFor, plural, store } from "./store.js"; +import { Callout, EmptyPanel, Group, Icon, IssueCallout, Pill, Toggle } from "./ui.jsx"; +import { AuthWidget, AuthorizationWidget, RemoteToolsWidget } from "./widgets.jsx"; + +// ------------------------------------------------------------------ header + +function FileInfo() { + const name = store.path.split(/[\\/]/).pop() || "agentflow.json"; + const dir = store.path.slice(0, store.path.length - name.length); + let badge = null; + if (!store.schema) badge = null; + else if (!store.exists) badge = New; + else if (isDirty()) badge = Unsaved; + else badge = Saved; + return ( +
+ + {name} + {badge} + {dir ? ( + + ) : null} +
+ ); +} + +export function Header() { + const { errors, warnings } = counts(); + const ready = !!store.schema && !store.fatal; + const validating = store.busy === "validate"; + return ( +
+
+ + +
+
+ ); +} + +// ------------------------------------------------------------------ page + +export function Page() { + if (store.fatal) { + return ( +
+ +
+ ); + } + if (!store.schema) { + return ( +
+
+
+ {[0, 1, 2, 3].map((i) =>
)} +
+ ); + } + return ( + <> +
+

Server configuration

+

+ Every agentflow.json option on one page. Fill in the essentials to get running, then switch on what you + need on the way to production. +

+
+ + {store.parseError ? ( +
+ +
+ ) : null} + {store.tiers.map((tier) => )} + + ); +} + +function Toolbar() { + const allOpen = store.sections.every((s) => store.open.has(s.id)); + return ( +
+ + + + + + +
+ ); +} + +function TierLink({ tier }) { + const on = tier.list.filter(isOn).length; + const { errors, warnings } = counts(store.issues.filter((i) => tier.list.some((s) => s.id === i.section))); + return ( + { e.preventDefault(); scrollToTier(tier.id); }} + > + {tier.title} + {on}/{tier.list.length} + {errors || warnings ? : null} + + ); +} + +function Tier({ tier }) { + const on = tier.list.filter(isOn).length; + return ( +
+
+
+

{tier.title}

+ {on} of {tier.list.length} on +
+

{tier.tagline}

+
+
+ {tier.list.map((sec) => )} +
+
+ ); +} + +function SectionCard({ sec }) { + const on = isOn(sec); + const open = store.open.has(sec.id); + const { errors, warnings } = counts(issuesFor(sec.id)); + const bodyId = `body-${sec.id}`; + + let status = null; + if (errors) { + status = ( + + {errors} + + ); + } else if (warnings) { + status = ( + + {warnings} + + ); + } + + return ( +
+
+

+ +

+ {sec.optional ? ( +
+ + setSection(sec, v)} /> +
+ ) : null} +
+ {/* grid-rows 0fr -> 1fr animates the height without measuring it. */} +
+
+
+ +
+
+
+
+ ); +} + +function SectionBody({ sec, on }) { + if (!on) { + return ( +
+

+ {sec.key} is not in agentflow.json. {sec.off_summary || ""} +

+ +
+ ); + } + const inline = new Set([...sec.fields.map((f) => f.path.join(".")), ...Object.keys(WIDGET_FIELD_IDS)]); + const general = issuesFor(sec.id).filter((i) => !inline.has(i.path)); + return ( + <> + {general.length ?
{general.map((issue, i) => )}
: null} + {sec.notes?.length ? : null} + {sec.widget === "auth" ? : null} + {sec.widget === "authorization" ? : null} + {sec.widget === "remote_tools" ? : null} + {fieldGroups(sec).map((group) => ( + +
+ {group.fields.map((f) => )} +
+
+ ))} + + ); +} + diff --git a/config-editor-ui/src/store.js b/config-editor-ui/src/store.js new file mode 100644 index 0000000..daa5c3c --- /dev/null +++ b/config-editor-ui/src/store.js @@ -0,0 +1,107 @@ +// One mutable store. Components read it directly; every change goes through +// commit(), which re-renders the app. The tree is small, so a full render is cheap +// and Preact's diff keeps focus, selection, and scroll position intact. + +// Sections not listed here land in the last tier, so new schema entries still render. +export const TIERS = [ + { + id: "essential", title: "Essentials", + tagline: "What the server needs to boot. Start here.", + sections: ["core", "plugins"], + }, + { + id: "recommended", title: "Recommended", + tagline: "Lock down access and see what your agents do before you ship.", + sections: ["auth", "authorization", "rate_limit", "observability"], + }, + { + id: "advanced", title: "Advanced", + tagline: "Tune connection limits, client-side tools, and your test loop.", + sections: ["websocket", "ag_ui", "remote_tools", "test", "evaluation"], + }, +]; + +// Issues a widget renders beside its own input rather than at the top of the section. +export const WIDGET_FIELD_IDS = { "auth.path": "auth-path", authorization: "authz-path" }; + +export const TOKEN = new URLSearchParams(location.hash.slice(1)).get("token") || ""; + +export const store = { + schema: null, + sections: [], // in page order + tiers: [], // TIERS with the resolved section objects in `list` + fields: new Map(), // "a.b.c" -> field definition + config: {}, + version: null, + exists: false, + path: "", + parseError: null, + saved: "", // snapshot of the last loaded/saved config + validated: null, // snapshot the current issues belong to + issues: [], + open: new Set(), // ids of expanded sections + tab: "problems", + inspectorOpen: false, // drawer state below the xl breakpoint + busy: null, // "validate" | "save" | null + toolParams: {}, // remote tool index -> {text, error} while JSON is being edited + toasts: [], + fatal: null, // {title, text} when the page cannot load +}; + +let rerender = () => {}; +export const subscribe = (fn) => { rerender = fn; }; +export function commit(mutate) { + if (mutate) mutate(store); + rerender(); +} + +// ------------------------------------------------------------------ helpers + +export const clone = (v) => JSON.parse(JSON.stringify(v)); +export const snapshot = () => JSON.stringify(store.config); +export const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v); +export const getPath = (obj, path) => path.reduce((acc, k) => (isObject(acc) ? acc[k] : undefined), obj); +export const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`; + +export function setPath(obj, path, value) { + let target = obj; + for (const k of path.slice(0, -1)) { + if (!isObject(target[k])) target[k] = {}; + target = target[k]; + } + target[path[path.length - 1]] = value; +} + +export function deletePath(obj, path) { + const parents = []; + let target = obj; + for (const k of path.slice(0, -1)) { + if (!isObject(target)) return; + parents.push([target, k]); + target = target[k]; + } + if (isObject(target)) delete target[path[path.length - 1]]; + // Drop nested objects left empty, but never the section key itself. + for (let i = parents.length - 1; i >= 1; i--) { + const [parent, k] = parents[i]; + if (isObject(parent[k]) && Object.keys(parent[k]).length === 0) delete parent[k]; + else break; + } +} + +export function valueOrDefault(path) { + const v = getPath(store.config, path); + return v !== undefined ? v : store.fields.get(path.join("."))?.default; +} + +export const section = (id) => store.sections.find((s) => s.id === id); +export const isDirty = () => snapshot() !== store.saved; +export const isOn = (sec) => !sec.optional || (sec.key in store.config && store.config[sec.key] !== null); +export const issuesFor = (id) => store.issues.filter((i) => i.section === id); +export const issueAt = (path) => store.issues.find((i) => i.path === path); +export const isStale = () => store.validated !== null && store.validated !== snapshot(); +export function counts(list = store.issues) { + const errors = list.filter((i) => i.level === "error").length; + return { errors, warnings: list.length - errors }; +} +export const fieldIdFor = (path) => WIDGET_FIELD_IDS[path] || `f-${path.replaceAll(".", "-")}`; diff --git a/config-editor-ui/src/ui.jsx b/config-editor-ui/src/ui.jsx new file mode 100644 index 0000000..04d23e5 --- /dev/null +++ b/config-editor-ui/src/ui.jsx @@ -0,0 +1,219 @@ +import { useRef } from "preact/hooks"; +import { ICONS } from "./icons.js"; + +export function Icon({ name, class: cls = "size-4" }) { + return ( + + ); +} + +export function Pill({ class: cls, children }) { + return ( + + {children} + + ); +} + +export function Toggle({ id, on, label, onChange }) { + return ( + + ); +} + +export function Select({ id, options, value, invalid, onChange }) { + return ( +
+ + +
+ ); +} + +export function Chips({ id, values, placeholder, suggestions, onChange }) { + const input = useRef(null); + const add = (raw) => { + const v = raw.trim(); + if (input.current) input.current.value = ""; + if (v && !values.includes(v)) onChange([...values, v]); + }; + const listId = suggestions ? `${id}-options` : undefined; + return ( +
{ if (e.target === e.currentTarget) input.current?.focus(); }} + > + {values.map((v, i) => ( + + {v} + + + ))} + { + const el = e.currentTarget; + if ((e.key === "Enter" || e.key === ",") && el.value.trim()) { e.preventDefault(); add(el.value); } + else if (e.key === "Backspace" && !el.value && values.length) onChange(values.slice(0, -1)); + }} + onChange={(e) => { if (suggestions?.includes(e.currentTarget.value.trim())) add(e.currentTarget.value); }} + onBlur={(e) => add(e.currentTarget.value)} + /> + {suggestions ? ( + + {suggestions.filter((s) => !values.includes(s)).map((s) => + ) : null} +
+ ); +} + +export function FieldMeta({ label, path, help, id, required }) { + return ( +
+ +
{path}
+ {help ?

{help}

: null} +
+ ); +} + +export function FieldIssue({ issue }) { + if (!issue) return null; + const error = issue.level === "error"; + return ( +

+ + {issue.message} +

+ ); +} + +// Label and help on the left, control on the right (stacked on phones). +export function Row({ children }) { + return
{children}
; +} + +export function TextRow({ id, label, path, value, placeholder, onInput, issue, help }) { + return ( + + +
+ onInput(e.currentTarget.value)} + /> + +
+
+ ); +} + +export function ChoiceGroup({ name, label, options, value, onPick }) { + return ( +
+ {options.map(([v, title, detail]) => { + const selected = v === value; + return ( + + ); + })} +
+ ); +} + +export function Group({ title, children }) { + return ( +
+ {title ?

{title}

: null} + {children} +
+ ); +} + +const CALLOUT = { + info: ["info", "border-brand/20 bg-brand-soft", "text-brand"], + warn: ["alert", "border-warn/25 bg-warn-soft", "text-warn"], + error: ["xCircle", "border-danger/25 bg-danger-soft", "text-danger"], +}; + +export function Callout({ kind, title, lines }) { + const [icon, box, tint] = CALLOUT[kind]; + return ( +
+ +
+ {title ?

{title}

: null} + {lines.map((line, i) =>

{line}

)} +
+
+ ); +} + +export function IssueCallout({ issue }) { + return ( + {issue.path ? {issue.path} : null}{issue.message}]} + /> + ); +} + +export function EmptyPanel({ icon, tint, title, text, children }) { + return ( +
+
+ +
+

{title}

+

{text}

+ {children} +
+ ); +} diff --git a/config-editor-ui/src/widgets.jsx b/config-editor-ui/src/widgets.jsx new file mode 100644 index 0000000..02dff12 --- /dev/null +++ b/config-editor-ui/src/widgets.jsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from "preact/hooks"; +import { commit, isObject, issueAt, store } from "./store.js"; +import { ChoiceGroup, Chips, FieldIssue, FieldMeta, Group, Icon, Row, Select, TextRow } from "./ui.jsx"; + +// ------------------------------------------------------------------ auth + +export function AuthWidget() { + const value = store.config.auth; + const mode = isObject(value) ? "custom" : "jwt"; + return ( +
+ + commit((s) => { s.config.auth = m === "jwt" ? "jwt" : { method: "custom", path: "" }; })} + /> + + {mode === "custom" ? ( + +
+ commit((s) => { s.config.auth = { ...s.config.auth, method: "custom", path: v }; })} + /> +
+
+ ) : null} +
+ ); +} + +// ------------------------------------------------------------------ authorization + +function authzModeOf(v) { + if (isObject(v)) return "rbac"; + if (typeof v === "string") { + const key = v.trim().toLowerCase(); + if (key === "ownership") return "ownership"; + if (["allow_all", "default", "none"].includes(key)) return "allow_all"; + } + return v === undefined || v === null ? null : "custom"; +} + +const AUTHZ_PRESETS = { + ownership: () => "ownership", + allow_all: () => "allow_all", + custom: () => "", + rbac: () => ({ + backend: "rbac", + roles: { admin: ["*"], member: ["graph:invoke", "graph:stream", "graph:read", "checkpointer:read"] }, + default_scopes: [], + isolation: "owner", + }), +}; + +export function AuthorizationWidget({ sec }) { + const value = store.config.authorization; + const mode = authzModeOf(value) ?? "ownership"; + return ( +
+ + commit((s) => { s.config.authorization = AUTHZ_PRESETS[m](); })} + /> + + {mode === "custom" ? ( + +
+ commit((s) => { s.config.authorization = v; })} + /> +
+
+ ) : null} + {mode === "rbac" ? : null} +
+ ); +} + +// Renames apply on blur or Enter; the draft lives here so re-renders do not reset it. +function RoleName({ id, name, onRename }) { + const [draft, setDraft] = useState(name); + useEffect(() => setDraft(name), [name]); + return ( +
+ + setDraft(e.currentTarget.value)} + onChange={(e) => { if (!onRename(e.currentTarget.value.trim())) setDraft(name); }} + /> +
+ ); +} + +function Rbac({ sec, value }) { + const roleKey = "roles" in value || !("role_scopes" in value) ? "roles" : "role_scopes"; + const roles = isObject(value[roleKey]) ? value[roleKey] : {}; + const scopeOptions = ["*", ...(sec.scopes || [])]; + const entries = Object.entries(roles); + const setRoles = (next) => commit(() => { value[roleKey] = next; }); + const addRole = () => { + let n = entries.length + 1; + while (`role${n}` in roles) n++; + setRoles({ ...roles, [`role${n}`]: [] }); + }; + + return ( + <> + +
+ + {entries.length ? ( +
+ {entries.map(([name, scopes], index) => ( +
+ { + if (!renamed || (renamed !== name && renamed in roles)) return false; + setRoles(Object.fromEntries(Object.entries(roles).map(([k, v]) => [k === name ? renamed : k, v]))); + return true; + }} + /> + setRoles({ ...roles, [name]: next })} + /> + +
+ ))} +
+ ) : ( +

No roles yet. Users only get the default scopes.

+ )} +
+

+ Read from the user's roles (or role) claim. * grants every scope. +

+ +
+
+
+ +
+
+ +
+ commit(() => { value.default_scopes = next; })} + /> +
+
+ + +
+