From 49cd7dad00ea848e0209243bbf5496e0047b7269 Mon Sep 17 00:00:00 2001 From: Chuck Meyer Date: Wed, 26 Aug 2026 20:36:52 -0400 Subject: [PATCH 1/4] feat(snapshot): write a full config from an agent's current state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of #14. Adds `snapshot `, which writes the API's own representation of an agent to agent-config.json, and teaches `update` to send such a config back verbatim. The API accepts its own GET response as a PATCH body, so nothing has to be reconstructed and nothing can be left out. That removes the need for a defaults registry, a merge layer, and a null-means-remove convention: removal is deleting a key from the file. Only server-owned fields are dropped — identifiers, timestamps, and enhancedDescription, which the platform regenerates. Since the API ignores those on write, a mistake in that list surfaces as a phantom diff rather than destroying anything. Prompts are externalised to files (PROMPT.md, and SYSTEM.md when the agent has a system prompt): a long prompt embedded as a JSON string is unreadable, and it keeps "instructions" meaning a file path in both formats. A native config is literal and is never template-rendered. Three live agents carry {{...}} in their stored prompts — {{INSERT_BRAND}} and incidental prose like {{facet}} — and rendering a snapshot demanded values for text that is simply content. --var, --index, --replica and --provider are rejected on a native config for the same reason. Prompt files are read right-stripped so snapshot -> update is a true no-op rather than merely diff-clean; without it every round-trip rewrote the field with an added trailing newline. _diff also gains providerId, templateType, description and systemPrompt, which phase 2 makes editable. Verified they are preserved when omitted, so only fields the payload actually sends are compared. A provider switch previously reported no change at all. Verified live: snapshot then update --dry-run reports "No changes detected" for all 14 agents on the account, and a real write through the native path leaves two tools, dynamic mode, allowUnlistedIndices, searchControls, four config keys, systemPrompt, description and a literal {{INSERT_BRAND}} intact. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 41 ++++++ src/algolia_agent/cli.py | 289 ++++++++++++++++++++++++++++++++------- tests/test_cli.py | 218 +++++++++++++++++++++++++++++ 3 files changed, 499 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 285bf75..96f7d6d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ algolia-agent list # List all agents algolia-agent get # Full agent config algolia-agent providers # List available LLM providers algolia-agent create [options] # Create a draft agent +algolia-agent snapshot # Write a full config file from the agent's current state algolia-agent update [options] # Update an existing agent algolia-agent publish # Publish a draft agent algolia-agent delete --confirm @@ -40,6 +41,46 @@ algolia-agent delete --confirm Add `--json` to any command except `init` for machine-readable output. +## Config formats + +There are two shapes of `agent-config.json`, and the CLI tells them apart by whether a +`tools` array is present. + +**Friendly** — describes an agent with `index`, `replicas`, a `provider` name, an +`instructions` file path, and `{{template}}` variables. Best for provisioning several +agents from one spec. This is what `init` scaffolds. + +**Native** — the API's own representation, written by `snapshot`. Carries everything the +service stores, including things the friendly format cannot express: additional tools, +`mode`, `allowUnlistedIndices`, and per-index `searchControls`. A native config is +**literal**: `{{...}}` in a prompt is text to preserve, not a variable to substitute, so +`--var` is rejected. `--index`, `--replica` and `--provider` are rejected too; edit the +file instead. + +## Snapshots + +`update` sends a whole payload, and the API **replaces** `config`, the `tools` array and +per-index `searchControls` rather than merging them — so anything a config file cannot +express would be deleted. `update` refuses to do that, and `snapshot` is the remedy: + +```bash +algolia-agent snapshot # -> agent-config.json + PROMPT.md +algolia-agent update --dry-run # -> "No changes detected." +``` + +That round-trip is worth running after any snapshot: it is a completeness check. A clean +dry-run means the file holds everything the service does, so editing one value and +applying it changes only that value. + +`snapshot` also writes `SYSTEM.md` when the agent has a system prompt, refuses to +overwrite existing files without `--force`, and warns before replacing a prompt file +containing `{{template}}` variables — a snapshot holds rendered text and cannot recover +a local template. + +Note that a snapshot is a point-in-time copy. If someone edits the agent in the +dashboard afterwards, applying an older snapshot reverts them; `--dry-run` shows exactly +what would change before you commit to it. + ## Getting started: `init` The fastest way to get started is `algolia-agent init`. It checks for credentials diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index 61f6460..c64db32 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -8,6 +8,7 @@ get Get full agent config providers List available LLM providers create Create a draft agent + snapshot Write a full config file from an agent's current state publish Publish a draft agent delete Delete an agent """ @@ -354,13 +355,26 @@ def _diff(current: dict, new_payload: dict) -> list[str]: # routinely differ only by a trailing newline, which is not a change worth # reporting. Leading whitespace is kept significant — indentation at the start of # the instructions is content, not noise. - curr_instr = (current.get("instructions") or "").rstrip() - new_instr = (new_payload.get("instructions") or "").rstrip() - if curr_instr != new_instr: - lines.append( - f" instructions: changed " - f"({len(curr_instr.splitlines())} lines → {len(new_instr.splitlines())} lines)" - ) + for field in ("instructions", "systemPrompt"): + if field == "systemPrompt" and field not in new_payload: + continue # preserved when omitted, so absence is not a change + curr_text = (current.get(field) or "").rstrip() + new_text = (new_payload.get(field) or "").rstrip() + if curr_text != new_text: + lines.append( + f" {field}: changed " + f"({len(curr_text.splitlines())} lines → {len(new_text.splitlines())} lines)" + ) + + # Verified against the API: these are preserved when omitted, so only compare what + # the payload actually sends. Without this a provider switch, or an edit to a + # snapshot's description, reported no change at all. + for field in ("providerId", "templateType", "description"): + if field not in new_payload: + continue + curr_val, new_val = current.get(field), new_payload.get(field) + if curr_val != new_val: + lines.append(f" {field}: {_fmt(curr_val)[:70]} → {_fmt(new_val)[:70]}") # searchControls live per index. Omitting them from the payload WIPES them — # verified against the live API — so an absent key is a destructive change, not @@ -524,12 +538,214 @@ def _removals(current: dict, new_payload: dict) -> list[str]: return out +# Server-owned fields, excluded from a snapshot. The API ignores them on write, so this +# is tidiness rather than correctness — which means a mistake here surfaces as a phantom +# diff on the very next dry-run instead of destroying anything. +_SNAPSHOT_SKIP_TOP = {"id", "createdAt", "updatedAt", "lastUsedAt"} +# enhancedDescription is regenerated by the platform from the index contents; a copy +# frozen in a checked-in file would only go stale. +_SNAPSHOT_SKIP_INDEX = {"enhancedDescription"} + + +def build_snapshot(agent: dict, instructions_file: str, + system_prompt_file: str | None = None) -> dict: + """A native-format config mirroring the agent's current server state. + + Everything else the API stores is carried through verbatim, because the API accepts + its own GET response as a PATCH body. That is what makes a snapshot safe to send + back: nothing has to be reconstructed, so nothing can be left out. + + Instructions are externalised to a file — a long prompt embedded as a JSON string + is unreadable and uneditable — which also keeps "instructions" meaning a file path + in both config formats. + """ + snap = {k: v for k, v in agent.items() if k not in _SNAPSHOT_SKIP_TOP} + snap["instructions"] = instructions_file + if system_prompt_file and (agent.get("systemPrompt") or "").strip(): + snap["systemPrompt"] = system_prompt_file + if isinstance(snap.get("tools"), list): + snap["tools"] = [ + { + **tool, + **({"indices": [ + {k: v for k, v in idx.items() if k not in _SNAPSHOT_SKIP_INDEX} + for idx in tool["indices"] + ]} if isinstance(tool.get("indices"), list) else {}), + } + for tool in snap["tools"] + ] + return snap + + +def is_native_config(config: dict) -> bool: + """Native configs carry the API's own tools array; friendly ones describe `index`.""" + return isinstance(config.get("tools"), list) + + +def cmd_snapshot(client: AlgoliaAgentClient, args: argparse.Namespace): + agent = client.get_agent(args.agent_id) + out_path = Path(args.output) + instr_path = out_path.parent / args.instructions_file + has_system = bool((agent.get("systemPrompt") or "").strip()) + system_path = out_path.parent / args.system_prompt_file if has_system else None + + targets = [p for p in (out_path, instr_path, system_path) if p is not None] + existing = [p for p in targets if p.exists()] + if existing and not args.force: + raise SystemExit( + "ERROR: refusing to overwrite:\n" + + "\n".join(f" {p}" for p in existing) + + "\nPass --force to overwrite, or -o/--instructions-file to write elsewhere." + ) + + # A snapshot holds rendered server state, so overwriting a templated prompt would + # replace {{placeholders}} with the values they resolved to — an unrecoverable loss, + # since the template only ever existed locally. + for p in existing: + if p.suffix.lower() == ".md" and extract_variables(p.read_text()): + print( + f"WARNING: {p} contains template variables and will be replaced with " + f"rendered text.\n The template exists only locally; a snapshot " + f"cannot recover it.", + file=sys.stderr, + ) + + snapshot = build_snapshot(agent, args.instructions_file, + args.system_prompt_file if has_system else None) + out_path.parent.mkdir(parents=True, exist_ok=True) + instr_path.write_text((agent.get("instructions") or "").rstrip() + "\n") + if system_path: + system_path.write_text((agent.get("systemPrompt") or "").rstrip() + "\n") + out_path.write_text(json.dumps(snapshot, indent=2) + "\n") + + if args.json: + result = {"config": str(out_path), "instructions": str(instr_path)} + if system_path: + result["systemPrompt"] = str(system_path) + print(json.dumps(result)) + return + + tool_types = [t.get("type") for t in agent.get("tools") or []] + print(f"Snapshotted: {agent['name']}") + print(f" Config: {out_path}") + print(f" Instructions: {instr_path}") + if system_path: + print(f" System: {system_path}") + print(f" Tools: {', '.join(tool_types) or 'none'}") + print(f" Config keys: {', '.join(sorted((agent.get('config') or {}).keys())) or 'none'}") + print(f"\nVerify it round-trips: algolia-agent update {args.agent_id} --config {out_path} --dry-run") + + +def _native_payload(config: dict, config_path, args) -> dict: + """Payload for a native config: send what the file says, verbatim. + + No tool is reconstructed and no provider name is resolved — providerId is already in + the file — so there is nothing for the CLI's narrower model to drop. + """ + for flag in ("index", "replica", "provider"): + if getattr(args, flag, None): + raise SystemExit( + f"ERROR: --{flag.replace('_', '-')} cannot be combined with a native config.\n" + "A native config carries the API's own tools array and providerId; edit " + "the file directly instead." + ) + if getattr(args, "var", None): + raise SystemExit( + "ERROR: --var cannot be combined with a native config.\n" + "A native config is literal: it holds rendered server state, so {{...}} in a " + "prompt is content to preserve, not a variable to substitute.\n" + "Use the friendly config format (index/replicas) for templated agents." + ) + + def _read(field: str) -> str: + """Read an externalised prompt file, resolved relative to the config.""" + if not config.get(field): + return "" + path = Path(config[field]) + if not path.exists() and config_path: + path = Path(config_path).parent / config[field] + if not path.exists(): + raise SystemExit(f"ERROR: {field} file not found: {config[field]}") + # Right-stripped so snapshot -> update is a true no-op: the file gets a trailing + # newline that the stored value did not have, and sending it back would rewrite + # the field on every round-trip. + return path.read_text().rstrip() + + payload = {k: v for k, v in config.items() if k not in _SNAPSHOT_SKIP_TOP} + # Deliberately NOT template-rendered. Several live agents carry literal {{...}} in + # their prompts ({{INSERT_BRAND}}, and incidental prose like {{facet}}); rendering a + # snapshot would demand values for text that is simply content, and substituting + # would destroy it. + payload["instructions"] = _read("instructions") + if config.get("systemPrompt"): + payload["systemPrompt"] = _read("systemPrompt") + + for key in ("name", "model"): + val = getattr(args, key, None) + if val is not None: + payload[key] = val + return payload + + +def _apply_update(client, args, current: dict, new_payload: dict, config_path=None): + """Dry-run, guard, send. Shared by the friendly and native config paths so both + get the same reporting and the same refusal.""" + if args.dry_run: + changes = _diff(current, new_payload) + print(f"=== UPDATE DRY RUN: {args.agent_id} ===") + print(f" Agent: {current['name']}") + if changes: + print("\nChanges:") + print("\n".join(changes)) + else: + print("\n No changes detected.") + return + + # The API replaces objects rather than merging, so a config narrower than the + # agent silently deletes the difference. Refuse rather than destroy. + removals = _removals(current, new_payload) + if removals and not getattr(args, "force", False): + source = f"{config_path}" if config_path else "the fields you supplied" + raise SystemExit( + "ERROR: this update would remove configuration the agent currently has:\n" + + "\n".join(removals) + + "\n\nThe Agent Studio API replaces these fields instead of merging them, so\n" + f"anything missing from the payload is lost. {source} cannot express them,\n" + "which is why they are absent.\n\n" + f"Take a full snapshot to keep them: algolia-agent snapshot {args.agent_id}\n" + "Or pass --force to accept the removals. Add --dry-run to see the full diff." + ) + + agent = client.update_agent(args.agent_id, new_payload) + + if args.json: + print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) + return + + print(f"Updated agent: {agent['name']}") + print(f"Agent ID: {agent['id']}") + print(f"Status: {agent['status']}") + + if getattr(args, "publish", False): + agent = client.publish_agent(args.agent_id) + if args.json: + print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) + else: + print(f"Published: {agent['status']}") + + def cmd_update(client: AlgoliaAgentClient, args: argparse.Namespace): current = client.get_agent(args.agent_id) # Load and merge config; auto-detect agent-config.json if --config not given config_path = args.config or (Path("agent-config.json") if Path("agent-config.json").exists() else None) file_config = load_config(config_path) if config_path else {} + + if is_native_config(file_config): + new_payload = _native_payload(file_config, config_path, args) + _apply_update(client, args, current, new_payload) + return + config = merge_config(file_config, args) # Fill in any fields not provided from the current agent state @@ -603,48 +819,7 @@ def cmd_update(client: AlgoliaAgentClient, args: argparse.Namespace): if cfg_block: new_payload["config"] = cfg_block - if args.dry_run: - changes = _diff(current, new_payload) - print(f"=== UPDATE DRY RUN: {args.agent_id} ===") - print(f" Agent: {current['name']}") - if changes: - print("\nChanges:") - print("\n".join(changes)) - else: - print("\n No changes detected.") - return - - # The API replaces objects rather than merging, so a config narrower than the - # agent silently deletes the difference. Refuse rather than destroy. - removals = _removals(current, new_payload) - if removals and not getattr(args, "force", False): - source = f"{config_path}" if config_path else "the fields you supplied" - raise SystemExit( - "ERROR: this update would remove configuration the agent currently has:\n" - + "\n".join(removals) - + "\n\nThe Agent Studio API replaces these fields instead of merging them, so\n" - f"anything missing from the payload is lost. {source} cannot express them,\n" - "which is why they are absent.\n\n" - "Either add the values to your config, or pass --force to accept the removals.\n" - "Run the same command with --dry-run to see the full diff first." - ) - - agent = client.update_agent(args.agent_id, new_payload) - - if args.json: - print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) - return - - print(f"Updated agent: {agent['name']}") - print(f"Agent ID: {agent['id']}") - print(f"Status: {agent['status']}") - - if getattr(args, "publish", False): - agent = client.publish_agent(args.agent_id) - if args.json: - print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) - else: - print(f"Published: {agent['status']}") + _apply_update(client, args, current, new_payload, config_path) def cmd_publish(client: AlgoliaAgentClient, args: argparse.Namespace): @@ -981,6 +1156,20 @@ def build_parser() -> argparse.ArgumentParser: update_p.add_argument("--json", action="store_true", help="Output JSON") # publish + snap_p = sub.add_parser("snapshot", + help="Write a full config file from an agent's current state") + snap_p.add_argument("agent_id", help="Agent ID (UUID)") + snap_p.add_argument("-o", "--output", default="agent-config.json", metavar="FILE", + help="Config file to write (default: agent-config.json)") + snap_p.add_argument("--instructions-file", default="PROMPT.md", metavar="FILE", + help="Instructions file written alongside (default: PROMPT.md)") + snap_p.add_argument("--system-prompt-file", default="SYSTEM.md", metavar="FILE", + help="System prompt file, written only if the agent has one " + "(default: SYSTEM.md)") + snap_p.add_argument("--force", action="store_true", + help="Overwrite existing files") + snap_p.add_argument("--json", action="store_true", help="Output JSON") + pub_p = sub.add_parser("publish", help="Publish a draft agent") pub_p.add_argument("agent_id", help="Agent ID (UUID)") pub_p.add_argument("--json", action="store_true", help="Output JSON") @@ -1045,6 +1234,8 @@ def _main(): cmd_create(client, args) elif args.command == "update": cmd_update(client, args) + elif args.command == "snapshot": + cmd_snapshot(client, args) elif args.command == "publish": cmd_publish(client, args) elif args.command == "delete": diff --git a/tests/test_cli.py b/tests/test_cli.py index cfb841e..b087f6f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1524,3 +1524,221 @@ def test_removals_says_dropped_for_unknown_default(): current["tools"][0]["someFutureField"] = 42 out = "\n".join(_removals(current, _narrow_payload())) assert "someFutureField: 42 would be dropped from the payload" in out + + +# ── snapshot / native config (#14 phase 2) ──────────────────────────────────── + +def _server_agent(): + return { + "id": "agent-uuid", "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", "lastUsedAt": None, + "name": "Rich Agent", "providerId": "provider-uuid", "model": "gemini-3.5-flash", + "instructions": "Line one.\nLine two.\n", "status": "published", + "description": None, "systemPrompt": None, "templateType": "blank", + "tools": [ + {"name": "algolia_search_index", "type": "algolia_search_index", + "mode": "dynamic", "allowUnlistedIndices": True, "description": "API blurb.", + "indices": [{"index": "products", "description": "Catalog.", + "enhancedDescription": "Available Facets...", + "searchControls": {"hitsPerPage": {"exposed": True, "default": 7}}, + "searchParameters": None}]}, + {"name": "algolia_display_results", "type": "algolia_display_results", + "isTerminal": False, "maxGroups": 3}, + ], + "config": {"suggestions": {"enabled": True}, "memory": {"enabled": True}}, + } + + +def test_build_snapshot_drops_only_server_owned_fields(): + from algolia_agent.cli import build_snapshot + + snap = build_snapshot(_server_agent(), "PROMPT.md") + for gone in ("id", "createdAt", "updatedAt", "lastUsedAt"): + assert gone not in snap + # Everything else survives, including what the friendly format cannot express. + assert snap["tools"][0]["mode"] == "dynamic" + assert snap["tools"][0]["allowUnlistedIndices"] is True + assert snap["tools"][1]["type"] == "algolia_display_results" + assert snap["config"]["memory"] == {"enabled": True} + assert snap["templateType"] == "blank" + assert snap["providerId"] == "provider-uuid" + # enhancedDescription is platform-generated, so it would only go stale in a file. + assert "enhancedDescription" not in snap["tools"][0]["indices"][0] + assert snap["tools"][0]["indices"][0]["searchControls"] is not None + # Instructions are externalised to a path, matching the friendly format's meaning. + assert snap["instructions"] == "PROMPT.md" + + +def test_build_snapshot_externalises_system_prompt_only_when_present(): + from algolia_agent.cli import build_snapshot + + agent = _server_agent() + assert "systemPrompt" not in build_snapshot(agent, "PROMPT.md", "SYSTEM.md") or \ + build_snapshot(agent, "PROMPT.md", "SYSTEM.md")["systemPrompt"] is None + + agent["systemPrompt"] = "You are terse." + snap = build_snapshot(agent, "PROMPT.md", "SYSTEM.md") + assert snap["systemPrompt"] == "SYSTEM.md" + + +def test_is_native_config_distinguishes_the_two_formats(): + from algolia_agent.cli import is_native_config + + assert is_native_config({"tools": [], "name": "x"}) # empty tools is still native + assert is_native_config({"tools": [{"type": "t"}]}) + assert not is_native_config({"index": "products", "name": "x"}) + assert not is_native_config({}) + + +def test_snapshot_round_trips_with_no_diff(tmp_path): + """The completeness oracle: a snapshot sent straight back must be a no-op. + + Any field build_snapshot() fails to carry shows up here as a phantom diff. + """ + from algolia_agent.cli import build_snapshot, _native_payload, _diff + + agent = _server_agent() + snap = build_snapshot(agent, "PROMPT.md") + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(snap)) + (tmp_path / "PROMPT.md").write_text(agent["instructions"]) + + args = build_parser().parse_args(["update", "agent-uuid", "--config", str(config_path)]) + payload = _native_payload(snap, config_path, args) + assert _diff(agent, payload) == [] + from algolia_agent.cli import _removals + assert _removals(agent, payload) == [] + + +def test_native_config_preserves_literal_template_markers(tmp_path): + """Live agents carry literal {{...}} in prompts; a snapshot must not resolve them.""" + from algolia_agent.cli import _native_payload + + prompt = tmp_path / "PROMPT.md" + prompt.write_text("You are the {{INSERT_BRAND}} assistant. Show {{5}} results.") + config = {"name": "A", "providerId": "p", "model": "m", "status": "draft", + "instructions": "PROMPT.md", "tools": []} + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(config)) + + args = build_parser().parse_args(["update", "agent-uuid", "--config", str(config_path)]) + payload = _native_payload(config, config_path, args) + assert "{{INSERT_BRAND}}" in payload["instructions"] + assert "{{5}}" in payload["instructions"] + + +def test_native_config_rejects_var_and_index(tmp_path): + from algolia_agent.cli import _native_payload + + config = {"name": "A", "providerId": "p", "model": "m", "tools": []} + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(config)) + + for extra, expected in ([["--var", "x=1"], "--var"], [["--index", "products"], "--index"]): + args = build_parser().parse_args( + ["update", "agent-uuid", "--config", str(config_path)] + extra) + with pytest.raises(SystemExit) as exc: + _native_payload(config, config_path, args) + assert expected in str(exc.value) + + +def test_cmd_snapshot_writes_both_files_and_refuses_to_clobber(tmp_path, capsys): + from algolia_agent.cli import cmd_snapshot + + agent = _server_agent() + agent["systemPrompt"] = "You are terse." + mock_client = MagicMock() + mock_client.get_agent.return_value = agent + out = tmp_path / "agent-config.json" + args = build_parser().parse_args(["snapshot", "agent-uuid", "-o", str(out)]) + cmd_snapshot(mock_client, args) + + written = json.loads(out.read_text()) + assert written["instructions"] == "PROMPT.md" + assert written["systemPrompt"] == "SYSTEM.md" + assert (tmp_path / "PROMPT.md").read_text() == "Line one.\nLine two.\n" + assert (tmp_path / "SYSTEM.md").read_text() == "You are terse.\n" + + # Second run must refuse rather than clobber. + with pytest.raises(SystemExit) as exc: + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out)])) + assert "refusing to overwrite" in str(exc.value) + + +def test_cmd_snapshot_warns_before_replacing_a_templated_prompt(tmp_path, capsys): + """A local template cannot be recovered from rendered server state.""" + from algolia_agent.cli import cmd_snapshot + + mock_client = MagicMock() + mock_client.get_agent.return_value = _server_agent() + out = tmp_path / "agent-config.json" + out.write_text("{}") + (tmp_path / "PROMPT.md").write_text("Hello {{event_name}} from {{booth}}.") + + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), "--force"])) + err = capsys.readouterr().err + assert "contains template variables" in err + assert "PROMPT.md" in err + + +def test_cmd_snapshot_no_warning_for_a_plain_prompt(tmp_path, capsys): + from algolia_agent.cli import cmd_snapshot + + mock_client = MagicMock() + mock_client.get_agent.return_value = _server_agent() + out = tmp_path / "agent-config.json" + out.write_text("{}") + (tmp_path / "PROMPT.md").write_text("No variables here.") + + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), "--force"])) + assert "template variables" not in capsys.readouterr().err + + +def test_diff_detects_system_prompt_change(): + base = {"name": "A", "model": "m", "instructions": "x", "tools": []} + current = dict(base, systemPrompt="You are terse.") + new_payload = dict(base, systemPrompt="You are verbose.\nVery.") + changes = _diff(current, new_payload) + assert any("systemPrompt: changed" in line for line in changes) + + +def test_diff_ignores_system_prompt_trailing_newline(): + """The file round-trip adds a newline the stored value never had.""" + base = {"name": "A", "model": "m", "instructions": "x", "tools": []} + current = dict(base, systemPrompt="You are terse.") + new_payload = dict(base, systemPrompt="You are terse.\n") + assert not _diff(current, new_payload) + + +def test_diff_silent_on_omitted_preserved_fields(): + """description/systemPrompt/templateType survive omission, so absence is no change.""" + current = {"name": "A", "model": "m", "instructions": "x", "tools": [], + "description": "Set.", "systemPrompt": "Set.", "templateType": "blank"} + new_payload = {"name": "A", "model": "m", "instructions": "x", "tools": []} + assert not _diff(current, new_payload) + + +def test_diff_detects_provider_change(): + """A provider switch previously reported nothing at all.""" + base = {"name": "A", "model": "m", "instructions": "x", "tools": []} + changes = _diff(dict(base, providerId="old-uuid"), dict(base, providerId="new-uuid")) + assert any("providerId" in line and "new-uuid" in line for line in changes) + + +def test_native_read_strips_trailing_newline(tmp_path): + """snapshot -> update must be a true no-op, not merely diff-clean.""" + from algolia_agent.cli import _native_payload + + (tmp_path / "PROMPT.md").write_text("Body text.\n") + (tmp_path / "SYSTEM.md").write_text("System text.\n") + config = {"name": "A", "providerId": "p", "model": "m", "tools": [], + "instructions": "PROMPT.md", "systemPrompt": "SYSTEM.md"} + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(config)) + args = build_parser().parse_args(["update", "agent-uuid", "--config", str(config_path)]) + payload = _native_payload(config, config_path, args) + assert payload["instructions"] == "Body text." + assert payload["systemPrompt"] == "System text." From 00ca46570a8a9f95e68eedadb30ab33694de8027 Mon Sep 17 00:00:00 2001 From: Chuck Meyer Date: Wed, 26 Aug 2026 20:40:57 -0400 Subject: [PATCH 2/4] docs: record that templateType is a provenance label, not a selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed: the field accepts any string (only the type is validated — an integer returns 422) and setting it has no side effects on instructions, tools or config. There is no /templates endpoint. Live values are 'blank' on the four dashboard-created agents and null on the ten the CLI created, consistent with the dashboard's picker applying a template at creation and stamping which one. Carrying it through a snapshot preserves provenance, which is correct, but editing it relabels rather than applies. Co-Authored-By: Claude Opus 5 (1M context) --- src/algolia_agent/cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index c64db32..891d71e 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -546,6 +546,12 @@ def _removals(current: dict, new_payload: dict) -> list[str]: # frozen in a checked-in file would only go stale. _SNAPSHOT_SKIP_INDEX = {"enhancedDescription"} +# templateType is carried through, but it is a provenance label rather than a selector: +# the dashboard's template picker populates instructions and tools at creation time and +# stamps which template was used. The field accepts any string (only the type is +# validated) and setting it has no side effects, so editing it in a snapshot relabels the +# agent without applying anything. + def build_snapshot(agent: dict, instructions_file: str, system_prompt_file: str | None = None) -> dict: From adb54db0b4d0fd6890de93804e2ccab22aa65eb8 Mon Sep 17 00:00:00 2001 From: Chuck Meyer Date: Wed, 26 Aug 2026 20:44:23 -0400 Subject: [PATCH 3/4] fix(update): point --var users at the prompt file, document template placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A template-created agent (templateType 'shopping-assistant') confirmed that Agent Studio's own templates ship literal placeholders in the stored prompt: {{INSERT_BRAND}}, {{INSERT_INDUSTRY}}, {{INSERT_LANGUAGE}}, {{INSERT_COMPETITORS_LIST}} and {{5}} — the last an authoring slip in the template, and the clearest reason {{...}} cannot be assumed to be a variable. The --var refusal previously suggested switching to the friendly config format, which is unhelpful when the user just wants to fill in a placeholder. It now names the prompt file to edit and keeps the friendly-format suggestion for the case it actually fits, templating across several agents. Round-trip verified clean on that agent, all five placeholders preserved. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 ++++++ src/algolia_agent/cli.py | 11 +++++++--- tests/test_cli.py | 45 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 96f7d6d..5b42043 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ overwrite existing files without `--force`, and warns before replacing a prompt containing `{{template}}` variables — a snapshot holds rendered text and cannot recover a local template. +Agent Studio's own templates ship prompts containing placeholders such as +`{{INSERT_BRAND}}` and `{{INSERT_LANGUAGE}}`. A snapshot preserves them verbatim, since +a native config is literal — fill them in by editing `PROMPT.md` and running `update` +again. `--var` is rejected on a native config precisely so that an update cannot +overwrite them. + Note that a snapshot is a point-in-time copy. If someone edits the agent in the dashboard afterwards, applying an older snapshot reverts them; `--dry-run` shows exactly what would change before you commit to it. diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index 891d71e..6c88b0d 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -658,9 +658,14 @@ def _native_payload(config: dict, config_path, args) -> dict: if getattr(args, "var", None): raise SystemExit( "ERROR: --var cannot be combined with a native config.\n" - "A native config is literal: it holds rendered server state, so {{...}} in a " - "prompt is content to preserve, not a variable to substitute.\n" - "Use the friendly config format (index/replicas) for templated agents." + "A native config is literal: it holds the prompt as the service stores it, so " + "{{...}} is content to preserve, not a variable to substitute. Agent Studio's\n" + "own templates ship placeholders like {{INSERT_BRAND}}, and substituting them\n" + "on every update would overwrite them.\n\n" + f"To fill them in, edit {config.get('instructions') or 'the prompt file'} " + "directly and run update again.\n" + "For templating across several agents, use the friendly config format " + "(index/replicas)." ) def _read(field: str) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index b087f6f..56029ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1742,3 +1742,48 @@ def test_native_read_strips_trailing_newline(tmp_path): payload = _native_payload(config, config_path, args) assert payload["instructions"] == "Body text." assert payload["systemPrompt"] == "System text." + + +def test_native_preserves_agent_studio_template_placeholders(tmp_path): + """Agent Studio's own templates ship {{...}} placeholders in the stored prompt. + + Verbatim text from the shopping-assistant template, including {{5}} — which is an + authoring slip in the template itself, and exactly why {{...}} cannot be assumed to + be a variable. + """ + from algolia_agent.cli import _native_payload + + body = ( + "**AGENT ROLE**\n" + "You are the {{INSERT_BRAND}} Shopping Assistant for {{INSERT_INDUSTRY}}.\n" + "Language: reply in {{INSERT_LANGUAGE}} fallback to English.\n" + "SearchLimit: max {{5}} search_tool calls per session.\n" + "Prohibited: any mention of competitors {{INSERT_COMPETITORS_LIST}}.\n" + ) + (tmp_path / "PROMPT.md").write_text(body) + config = {"name": "Shopping assistant", "providerId": "p", "model": "claude-fable-5", + "status": "published", "templateType": "shopping-assistant", + "instructions": "PROMPT.md", "tools": []} + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(config)) + + args = build_parser().parse_args(["update", "agent-uuid", "--config", str(config_path)]) + payload = _native_payload(config, config_path, args) + for marker in ("{{INSERT_BRAND}}", "{{INSERT_INDUSTRY}}", "{{INSERT_LANGUAGE}}", + "{{5}}", "{{INSERT_COMPETITORS_LIST}}"): + assert marker in payload["instructions"], marker + assert payload["templateType"] == "shopping-assistant" + + +def test_native_var_error_points_at_the_prompt_file(tmp_path): + from algolia_agent.cli import _native_payload + + config = {"name": "A", "providerId": "p", "model": "m", "tools": [], + "instructions": "PROMPT.md"} + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(config)) + args = build_parser().parse_args( + ["update", "agent-uuid", "--config", str(config_path), "--var", "INSERT_BRAND=Acme"]) + with pytest.raises(SystemExit) as exc: + _native_payload(config, config_path, args) + assert "edit PROMPT.md" in str(exc.value) From 225ac6c2bf2864d1306c4c2a67bc7f00830a2c59 Mon Sep 17 00:00:00 2001 From: Chuck Meyer Date: Wed, 26 Aug 2026 20:48:33 -0400 Subject: [PATCH 4/4] fix(snapshot): never leak raw systemPrompt text, and explain refusals per format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings. A whitespace-only systemPrompt produced an unusable snapshot. cmd_snapshot skips writing SYSTEM.md when the value is blank, but build_snapshot left the stored string in place, so _native_payload read " " as a filename and failed. A snapshot's systemPrompt is now always a path or absent, never the text. Dropping it is safe: the API preserves systemPrompt when the payload omits it. The overwrite hint read "-o/--instructions-file", which looks like one flag with two names. -o/--output is the config; --instructions-file and --system-prompt-file are the prompt files. All three are now named. The refusal message claimed the config "cannot express" the missing fields. True of the friendly format, false of a native config, which can express all of them — absence there is an edit, possibly deliberate. The native wording now says what is missing and offers snapshot --force to restore it. The native path also called _apply_update without config_path, so a refusal could not name the file that produced the payload. Round-trip re-verified clean for all 14 agents. Co-Authored-By: Claude Opus 5 (1M context) --- src/algolia_agent/cli.py | 31 +++++++++++---- tests/test_cli.py | 83 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index 6c88b0d..3871c9a 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -567,6 +567,11 @@ def build_snapshot(agent: dict, instructions_file: str, """ snap = {k: v for k, v in agent.items() if k not in _SNAPSHOT_SKIP_TOP} snap["instructions"] = instructions_file + # systemPrompt is either a path or absent — never the raw text. Leaving the stored + # value in place would have it read back as a filename; a whitespace-only prompt did + # exactly that, producing a snapshot that could not be applied. Dropping it is safe + # because the API preserves systemPrompt when the payload omits it. + snap.pop("systemPrompt", None) if system_prompt_file and (agent.get("systemPrompt") or "").strip(): snap["systemPrompt"] = system_prompt_file if isinstance(snap.get("tools"), list): @@ -601,7 +606,8 @@ def cmd_snapshot(client: AlgoliaAgentClient, args: argparse.Namespace): raise SystemExit( "ERROR: refusing to overwrite:\n" + "\n".join(f" {p}" for p in existing) - + "\nPass --force to overwrite, or -o/--instructions-file to write elsewhere." + + "\nPass --force to overwrite, or write elsewhere with -o/--output (config),\n" + "--instructions-file, or --system-prompt-file." ) # A snapshot holds rendered server state, so overwriting a templated prompt would @@ -698,7 +704,8 @@ def _read(field: str) -> str: return payload -def _apply_update(client, args, current: dict, new_payload: dict, config_path=None): +def _apply_update(client, args, current: dict, new_payload: dict, config_path=None, + native: bool = False): """Dry-run, guard, send. Shared by the friendly and native config paths so both get the same reporting and the same refusal.""" if args.dry_run: @@ -717,14 +724,24 @@ def _apply_update(client, args, current: dict, new_payload: dict, config_path=No removals = _removals(current, new_payload) if removals and not getattr(args, "force", False): source = f"{config_path}" if config_path else "the fields you supplied" + if native: + # A native config can express every one of these, so their absence is an + # edit to the file — possibly a deliberate one. Say what is missing rather + # than blaming the format. + why = f"They are not present in {source}.\n\n" \ + "If you meant to remove them, pass --force. Otherwise restore them —\n" \ + f"algolia-agent snapshot {args.agent_id} --force rewrites the file from\n" \ + "the agent's current state." + else: + why = f"{source} cannot express them, which is why they are absent.\n\n" \ + f"Take a full snapshot to keep them: algolia-agent snapshot {args.agent_id}\n" \ + "Or pass --force to accept the removals." raise SystemExit( "ERROR: this update would remove configuration the agent currently has:\n" + "\n".join(removals) + "\n\nThe Agent Studio API replaces these fields instead of merging them, so\n" - f"anything missing from the payload is lost. {source} cannot express them,\n" - "which is why they are absent.\n\n" - f"Take a full snapshot to keep them: algolia-agent snapshot {args.agent_id}\n" - "Or pass --force to accept the removals. Add --dry-run to see the full diff." + f"anything missing from the payload is lost. {why}\n" + "Add --dry-run to see the full diff." ) agent = client.update_agent(args.agent_id, new_payload) @@ -754,7 +771,7 @@ def cmd_update(client: AlgoliaAgentClient, args: argparse.Namespace): if is_native_config(file_config): new_payload = _native_payload(file_config, config_path, args) - _apply_update(client, args, current, new_payload) + _apply_update(client, args, current, new_payload, config_path, native=True) return config = merge_config(file_config, args) diff --git a/tests/test_cli.py b/tests/test_cli.py index 56029ef..fda2d1d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1573,8 +1573,7 @@ def test_build_snapshot_externalises_system_prompt_only_when_present(): from algolia_agent.cli import build_snapshot agent = _server_agent() - assert "systemPrompt" not in build_snapshot(agent, "PROMPT.md", "SYSTEM.md") or \ - build_snapshot(agent, "PROMPT.md", "SYSTEM.md")["systemPrompt"] is None + assert "systemPrompt" not in build_snapshot(agent, "PROMPT.md", "SYSTEM.md") agent["systemPrompt"] = "You are terse." snap = build_snapshot(agent, "PROMPT.md", "SYSTEM.md") @@ -1787,3 +1786,83 @@ def test_native_var_error_points_at_the_prompt_file(tmp_path): with pytest.raises(SystemExit) as exc: _native_payload(config, config_path, args) assert "edit PROMPT.md" in str(exc.value) + + +def test_build_snapshot_never_leaks_raw_system_prompt_text(tmp_path): + """A snapshot's systemPrompt is a path or absent, never the prompt text itself. + + A whitespace-only value made cmd_snapshot skip writing SYSTEM.md while the snapshot + kept the raw string, which _native_payload then treated as a filename — producing a + snapshot that could not be applied. + """ + from algolia_agent.cli import build_snapshot, _native_payload + + agent = {"name": "A", "providerId": "p", "model": "m", "status": "draft", + "instructions": "x", "systemPrompt": " ", "tools": []} + snap = build_snapshot(agent, "PROMPT.md", None) + assert "systemPrompt" not in snap + + # And the resulting config applies cleanly rather than looking for a file named " ". + (tmp_path / "PROMPT.md").write_text("x") + config_path = tmp_path / "agent-config.json" + config_path.write_text(json.dumps(snap)) + args = build_parser().parse_args(["update", "a", "--config", str(config_path)]) + payload = _native_payload(snap, config_path, args) + assert "systemPrompt" not in payload + + +def test_build_snapshot_drops_system_prompt_when_not_externalised(): + """Without a target filename there is nowhere to put the text, so drop the field. + + Safe because the API preserves systemPrompt when the payload omits it. + """ + from algolia_agent.cli import build_snapshot + + agent = {"name": "A", "providerId": "p", "model": "m", "instructions": "x", + "systemPrompt": "You are terse.", "tools": []} + assert "systemPrompt" not in build_snapshot(agent, "PROMPT.md", None) + + +def test_native_refusal_does_not_claim_the_format_cannot_express(tmp_path): + """A native config can express everything, so absence is an edit, not a limitation.""" + from algolia_agent.cli import cmd_update + + mock_client = MagicMock() + mock_client.get_agent.return_value = _rich_current_agent() + (tmp_path / "PROMPT.md").write_text("Old instructions.") + config = tmp_path / "native.json" + # A native config that has dropped the display-results tool and two config keys. + config.write_text(json.dumps({ + "name": "Rich Agent", "providerId": "provider-uuid", "model": "gemini-2.5-flash", + "status": "published", "instructions": "PROMPT.md", + "tools": [{"name": "algolia_search_index", "type": "algolia_search_index", + "mode": "dynamic", "allowUnlistedIndices": True, + "indices": [{"index": "products", "description": "Product catalog.", + "searchControls": {"hitsPerPage": {"exposed": True, "default": 7}}}]}], + "config": {"suggestions": {"enabled": True}}, + })) + args = build_parser().parse_args(["update", "agent-uuid", "--config", str(config)]) + with pytest.raises(SystemExit) as exc: + cmd_update(mock_client, args) + msg = str(exc.value) + assert "cannot express" not in msg + assert "not present in" in msg + assert "native.json" in msg # finding 3: the file is named + assert "--force" in msg + mock_client.update_agent.assert_not_called() + + +def test_snapshot_overwrite_hint_names_the_real_flags(tmp_path): + from algolia_agent.cli import cmd_snapshot + + mock_client = MagicMock() + mock_client.get_agent.return_value = _server_agent() + out = tmp_path / "agent-config.json" + out.write_text("{}") + with pytest.raises(SystemExit) as exc: + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out)])) + msg = str(exc.value) + assert "-o/--output" in msg + assert "--instructions-file" in msg + assert "-o/--instructions-file" not in msg