Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent_id> --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 <source_id> -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
Expand Down
96 changes: 76 additions & 20 deletions src/algolia_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)."
)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading