diff --git a/README.md b/README.md index 5b42043..60176df 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,39 @@ That round-trip is worth running after any snapshot: it is a completeness check. 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. +`snapshot` also writes `SYSTEM.md` when the agent has a system prompt, and refuses to +overwrite existing files without `--force`. + +A prompt file containing `{{template}}` variables is never overwritten, **not even with +`--force`**: a snapshot holds rendered text, and a rendered prompt cannot be turned back +into a template. Write beside it instead: + +```bash +algolia-agent snapshot --instructions-file PROMPT.snapshot.md +``` + +Plain prompt files are overwritten by `--force` as normal, since a snapshot can +reproduce them. + +### Cloning an agent + +Because `create` also accepts a native config, a snapshot is a complete clone source: + +```bash +algolia-agent snapshot -o clone/agent-config.json +# edit the name in clone/agent-config.json +algolia-agent create --config clone/agent-config.json +``` + +The copy carries everything the friendly format cannot express — extra tools, `mode`, +`allowUnlistedIndices`, per-index `searchControls`, the full `config` block. Two +differences by design: `status` is forced to `draft`, since creating from a snapshot of a +live agent should not silently publish the copy, and a native config must carry +`providerId` directly (there is no provider-name lookup on that path — use +`algolia-agent providers` to find one). + +Snapshot each agent into its own directory. `PROMPT.md` is the default prompt filename, +so two snapshots in one directory collide — `snapshot` refuses rather than overwriting. Agent Studio's own templates ship prompts containing placeholders such as `{{INSERT_BRAND}}` and `{{INSERT_LANGUAGE}}`. A snapshot preserves them verbatim, since diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index 3871c9a..0ad528e 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -195,10 +195,24 @@ def cmd_providers(client: AlgoliaAgentClient, args: argparse.Namespace): print() +def _report_created(agent: dict, args: argparse.Namespace): + if args.json: + print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) + return + print(f"Created agent: {agent['name']}") + print(f"Agent ID: {agent['id']}") + print(f"Status: {agent['status']}") + print(f"\nTo publish: algolia-agent publish {agent['id']}") + + def cmd_create(client: AlgoliaAgentClient, args: argparse.Namespace): # 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): + _create_from_native(client, args, file_config, config_path) + return + config = merge_config(file_config, args) # Auto-detect PROMPT.md if instructions not specified @@ -272,16 +286,7 @@ def cmd_create(client: AlgoliaAgentClient, args: argparse.Namespace): if config.get("config"): payload["config"] = config["config"] - agent = client.create_agent(payload) - - if args.json: - print(json.dumps({"id": agent["id"], "name": agent["name"], "status": agent["status"]})) - return - - print(f"Created agent: {agent['name']}") - print(f"Agent ID: {agent['id']}") - print(f"Status: {agent['status']}") - print(f"\nTo publish: algolia-agent publish {agent['id']}") + _report_created(client.create_agent(payload), args) _MISSING = object() @@ -611,15 +616,25 @@ def cmd_snapshot(client: AlgoliaAgentClient, args: argparse.Namespace): ) # 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, + # replace {{placeholders}} with the values they resolved to. That loss is + # unrecoverable — the template only ever existed locally and rendered text cannot be + # un-rendered — so --force does not cover it. Overwriting a plain prompt is fine, + # because a snapshot can reproduce it. + prompt_targets = [(instr_path, "--instructions-file")] + if system_path: + prompt_targets.append((system_path, "--system-prompt-file")) + for path, flag in prompt_targets: + if not path.exists(): + continue + template_vars = extract_variables(path.read_text()) + if template_vars: + listed = ", ".join("{{" + v + "}}" for v in sorted(template_vars)) + raise SystemExit( + f"ERROR: {path} contains template variables ({listed}) and cannot be\n" + "recovered from rendered server state.\n\n" + "Write it elsewhere with:\n" + f" {flag} {path.stem}.snapshot{path.suffix}\n" + f"or delete {path.name} first if you meant to replace it." ) snapshot = build_snapshot(agent, args.instructions_file, @@ -669,7 +684,7 @@ def _native_payload(config: dict, config_path, args) -> dict: "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" + "directly and run the command again.\n" "For templating across several agents, use the friendly config format " "(index/replicas)." ) @@ -704,6 +719,47 @@ def _read(field: str) -> str: return payload +def _create_from_native(client, args, file_config: dict, config_path): + """Create an agent from a native config — a snapshot, usually of another agent. + + Unlike update there is no prior state to lose, so no guard is needed. The payload + is sent as the file describes it, with one exception: status is forced to draft. + Creating from a snapshot of a live agent should not silently publish the copy, and + `create` is documented as producing a draft. + """ + payload = _native_payload(file_config, config_path, args) + + missing = [k for k in ("name", "providerId", "model") if not payload.get(k)] + if missing: + hint = f"Add them to {config_path}" if config_path else "Add them to the config" + overridable = [k for k in missing if k in ("name", "model")] + if overridable: + flags = " or ".join(f"--{k}" for k in overridable) + hint += f", or pass {flags}" + lines = [ + f"ERROR: native config is missing required fields: {', '.join(missing)}", + hint + ".", + ] + if "providerId" in missing: + lines.append( + "A native config carries providerId directly rather than a provider " + "name — run `algolia-agent providers` to look one up." + ) + raise SystemExit("\n".join(lines)) + + payload["status"] = "draft" + + if args.dry_run: + print("=== CREATE DRY RUN (native config) ===") + print(f" Source: {config_path}") + print(f" Tools: {', '.join(t.get('type') or '?' for t in payload.get('tools') or []) or 'none'}") + print("\nPayload:") + print(json.dumps(payload, indent=2)) + return + + _report_created(client.create_agent(payload), args) + + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index fda2d1d..0a85546 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1665,21 +1665,59 @@ def test_cmd_snapshot_writes_both_files_and_refuses_to_clobber(tmp_path, capsys) 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.""" +def test_cmd_snapshot_refuses_to_replace_a_templated_prompt_even_with_force(tmp_path): + """A local template cannot be recovered from rendered server state, so --force + does not cover it. Overwriting a plain prompt is fine — a snapshot reproduces it.""" 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}}.") + prompt = tmp_path / "PROMPT.md" + prompt.write_text("Hello {{event_name}} from {{booth}}.") + + with pytest.raises(SystemExit) as exc: + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), "--force"])) + msg = str(exc.value) + assert "contains template variables" in msg + assert "{{event_name}}" in msg and "{{booth}}" in msg + assert "--instructions-file PROMPT.snapshot.md" in msg + assert "--system-prompt-file" not in msg + # The template is still on disk, untouched. + assert prompt.read_text() == "Hello {{event_name}} from {{booth}}." + + +def test_cmd_snapshot_force_overwrites_a_plain_prompt(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("{}") + (tmp_path / "PROMPT.md").write_text("No variables here.") 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 + assert (tmp_path / "PROMPT.md").read_text() == "Line one.\nLine two.\n" + + +def test_cmd_snapshot_templated_prompt_can_be_routed_elsewhere(tmp_path): + """The suggested remedy actually works: keep the template, write beside it.""" + from algolia_agent.cli import cmd_snapshot + + mock_client = MagicMock() + mock_client.get_agent.return_value = _server_agent() + out = tmp_path / "agent-config.json" + (tmp_path / "PROMPT.md").write_text("Hello {{event_name}}.") + + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), + "--instructions-file", "PROMPT.snapshot.md"])) + assert (tmp_path / "PROMPT.md").read_text() == "Hello {{event_name}}." + assert (tmp_path / "PROMPT.snapshot.md").read_text() == "Line one.\nLine two.\n" + assert json.loads(out.read_text())["instructions"] == "PROMPT.snapshot.md" def test_cmd_snapshot_no_warning_for_a_plain_prompt(tmp_path, capsys): @@ -1866,3 +1904,169 @@ def test_snapshot_overwrite_hint_names_the_real_flags(tmp_path): assert "-o/--output" in msg assert "--instructions-file" in msg assert "-o/--instructions-file" not in msg + + +# ── native create (#14 phase 3) ─────────────────────────────────────────────── + +def _native_create_config(tmp_path, **overrides): + (tmp_path / "PROMPT.md").write_text("You are the {{INSERT_BRAND}} assistant.\n") + config = { + "name": "Shopping assistant", "providerId": "provider-uuid", + "model": "claude-fable-5", "status": "published", + "templateType": "shopping-assistant", "instructions": "PROMPT.md", + "tools": [{"name": "algolia_search_index", "type": "algolia_search_index", + "mode": "dynamic", "allowUnlistedIndices": True, + "indices": [{"index": "products", "description": "Catalog.", + "searchControls": {"distinct": {"exposed": True, "default": False}}}]}, + {"name": "algolia_display_results", "type": "algolia_display_results", + "isTerminal": False, "maxGroups": 3}], + "config": {"suggestions": {"enabled": True}, "memory": {"enabled": True}}, + } + config.update(overrides) + path = tmp_path / "agent-config.json" + path.write_text(json.dumps(config)) + return path + + +def test_create_from_native_sends_everything_and_forces_draft(tmp_path): + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path) + mock_client = MagicMock() + mock_client.create_agent.return_value = {"id": "new-uuid", "name": "Shopping assistant", + "status": "draft"} + cmd_create(mock_client, build_parser().parse_args(["create", "--config", str(path)])) + + payload = mock_client.create_agent.call_args.args[0] + # A snapshot of a live agent must not silently publish the copy. + assert payload["status"] == "draft" + # Everything the friendly format cannot express survives. + assert [t["type"] for t in payload["tools"]] == ["algolia_search_index", + "algolia_display_results"] + assert payload["tools"][0]["mode"] == "dynamic" + assert payload["tools"][0]["allowUnlistedIndices"] is True + assert payload["tools"][0]["indices"][0]["searchControls"] is not None + assert sorted(payload["config"]) == ["memory", "suggestions"] + assert payload["templateType"] == "shopping-assistant" + assert payload["providerId"] == "provider-uuid" + # No provider-name resolution happens on this path. + mock_client.resolve_provider_id.assert_not_called() + # Literal, not rendered. + assert "{{INSERT_BRAND}}" in payload["instructions"] + + +def test_create_from_native_requires_provider_id(tmp_path): + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path, providerId=None) + with pytest.raises(SystemExit) as exc: + cmd_create(MagicMock(), build_parser().parse_args(["create", "--config", str(path)])) + msg = str(exc.value) + assert "providerId" in msg + assert "algolia-agent providers" in msg + + +def test_create_from_native_dry_run_needs_no_client(tmp_path, capsys): + """_main calls cmd_create(None, args) for --dry-run, so this path must not use it.""" + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path) + cmd_create(None, build_parser().parse_args( + ["create", "--config", str(path), "--dry-run"])) + out = capsys.readouterr().out + assert "CREATE DRY RUN (native config)" in out + assert '"status": "draft"' in out + assert "algolia_display_results" in out + + +def test_create_from_native_rejects_friendly_flags(tmp_path): + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path) + for extra, expected in ([["--index", "p"], "--index"], [["--var", "INSERT_BRAND=x"], "--var"]): + with pytest.raises(SystemExit) as exc: + cmd_create(MagicMock(), build_parser().parse_args( + ["create", "--config", str(path)] + extra)) + assert expected in str(exc.value) + + +def test_create_friendly_path_unaffected(tmp_path): + """The friendly format must still resolve a provider name and build one tool.""" + from algolia_agent.cli import cmd_create + + (tmp_path / "PROMPT.md").write_text("Hello.") + path = tmp_path / "agent-config.json" + path.write_text(json.dumps({ + "name": "Friendly", "provider": "hackathon-gemini", "model": "gemini-3.5-flash", + "instructions": "PROMPT.md", "index": "products", + })) + mock_client = MagicMock() + mock_client.resolve_provider_id.return_value = "provider-uuid" + mock_client.create_agent.return_value = {"id": "x", "name": "Friendly", "status": "draft"} + cmd_create(mock_client, build_parser().parse_args(["create", "--config", str(path)])) + + mock_client.resolve_provider_id.assert_called_once_with("hackathon-gemini") + payload = mock_client.create_agent.call_args.args[0] + assert payload["providerId"] == "provider-uuid" + assert [t["type"] for t in payload["tools"]] == ["algolia_search_index"] + + +def test_snapshot_templated_system_prompt_points_at_the_right_flag(tmp_path): + """When the templated file is SYSTEM.md, --instructions-file is the wrong remedy.""" + 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" + (tmp_path / "SYSTEM.md").write_text("Act as {{persona}}.") + + with pytest.raises(SystemExit) as exc: + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), "--force"])) + msg = str(exc.value) + assert "SYSTEM.md" in msg + assert "{{persona}}" in msg + assert "--system-prompt-file SYSTEM.snapshot.md" in msg + assert "--instructions-file" not in msg + + +def test_snapshot_detects_a_templated_prompt_with_a_non_md_name(tmp_path): + """--instructions-file PROMPT.txt is legal, so keying on the .md suffix missed it.""" + from algolia_agent.cli import cmd_snapshot + + mock_client = MagicMock() + mock_client.get_agent.return_value = _server_agent() + out = tmp_path / "agent-config.json" + (tmp_path / "PROMPT.txt").write_text("Hello {{event_name}}.") + + with pytest.raises(SystemExit) as exc: + cmd_snapshot(mock_client, build_parser().parse_args( + ["snapshot", "agent-uuid", "-o", str(out), + "--instructions-file", "PROMPT.txt", "--force"])) + assert "--instructions-file PROMPT.snapshot.txt" in str(exc.value) + + +def test_native_create_missing_name_does_not_mention_providers(tmp_path): + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path, name=None) + with pytest.raises(SystemExit) as exc: + cmd_create(MagicMock(), build_parser().parse_args(["create", "--config", str(path)])) + msg = str(exc.value) + assert "name" in msg + assert "--name" in msg # actionable via a flag + assert "algolia-agent providers" not in msg # irrelevant remedy + assert "providerId" not in msg + + +def test_native_create_missing_provider_id_still_mentions_providers(tmp_path): + from algolia_agent.cli import cmd_create + + path = _native_create_config(tmp_path, providerId=None) + with pytest.raises(SystemExit) as exc: + cmd_create(MagicMock(), build_parser().parse_args(["create", "--config", str(path)])) + msg = str(exc.value) + assert "providerId" in msg + assert "algolia-agent providers" in msg