feat(snapshot): write a full config from an agent's current state - #19
Conversation
Phase 2 of #14. Adds `snapshot <agent_id>`, 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
| Value written | Result |
|---|---|
"blank" (a value seen live) |
accepted, no side effects |
"shopping-assistant" (plausible) |
accepted, no side effects |
"totally-bogus-not-a-template" |
accepted, no side effects |
"" |
accepted, no side effects |
12345 |
rejected — HTTP 422, body.templateType: Input should be a valid string |
"No side effects" means instructions, tools and config were byte-identical before and after each write. There is also no /templates or /agent-templates endpoint (both 404).
Live distribution is consistent with that reading: "blank" on exactly the 4 dashboard-created agents, null on the 10 created through this CLI.
So the dashboard's picker applies a template at creation — populating instructions and tools — and stamps which one was used. The field is writable only in the sense that any string is accepted; writing it relabels the agent rather than applying a template.
No behaviour change needed: carrying it through a snapshot preserves provenance, which is right. Added a comment recording the finding so nobody later reads "writable" as "selects a template". The probe agent was deleted and confirmed gone.
There was a problem hiding this comment.
Pull request overview
Adds a new snapshot workflow and a “native” config format to make snapshot → update a safe, faithful round-trip against the Agent Studio API’s replace-not-merge PATCH semantics. This enables managing an individual agent by writing back the API’s own representation verbatim (including tools/config/searchControls that the friendly format can’t express), while keeping prompts editable via external markdown files.
Changes:
- Introduces
algolia-agent snapshot <agent_id>to write a full nativeagent-config.jsonplusPROMPT.md(andSYSTEM.mdwhen present), with--forceclobber protection and templated-prompt warnings. - Adds native-config detection and a native update payload path that reads externalized prompts and rejects templating flags (
--var,--index,--replica,--provider). - Extends
_diffto handlesystemPromptand additional top-level fields (providerId,templateType,description) with “compare only what’s sent” behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/algolia_agent/cli.py |
Implements snapshot command, native-config update path, and expands diff logic for native round-trips. |
tests/test_cli.py |
Adds unit tests covering snapshot generation, native payload behavior, round-trip no-op guarantees, and diff updates. |
README.md |
Documents friendly vs native config formats and the snapshot workflow/semantics. |
Suppressed comments (2)
src/algolia_agent/cli.py:618
- Using
.rstrip()when writing snapshot prompt files removes all trailing whitespace, not just the trailing newline. That can change prompt content (e.g., a prompt that intentionally ends with spaces) and breaks the “snapshot → update is a true no-op” guarantee. Strip only newline characters instead.
file=sys.stderr,
)
src/algolia_agent/cli.py:672
_native_payload()reads externalized prompt files with.rstrip(), which strips spaces/tabs as well as newlines. That can silently change prompt content; if the goal is to ignore only the trailing newline added by the editor/OS, strip only\r/\n.
path = Path(config_path).parent / config[field]
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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." |
| if system_prompt_file and (agent.get("systemPrompt") or "").strip(): | ||
| snap["systemPrompt"] = system_prompt_file |
| if is_native_config(file_config): | ||
| new_payload = _native_payload(file_config, config_path, args) | ||
| _apply_update(client, args, current, new_payload) | ||
| return |
| + "\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." |
…placeholders
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) <noreply@anthropic.com>
Verified against a template-created agentTested against an agent built from the dashboard's shopping-assistant template — a provenance not represented among the other 15, since those were all made by this CLI or by hand. Round-trip is clean, so the oracle now holds for 15 of 15 agents plus this one. Two findings worth recording. Agent Studio's own templates ship literal
|
… per format 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) <noreply@anthropic.com>
|
All four findings applied. One was a real bug that would have shipped. Whitespace-only
|
Phase 2 of #14. Also closes most of #16 — see below.
What this adds
snapshotwrites the API's own representation of an agent, andupdatesends such a config back verbatim.Why this shape
The API accepts its own GET response as a PATCH body — verified by probe. So a snapshot needs nothing reconstructed, which means nothing can be left out. That removes three things the earlier plan called for:
null-means-remove convention — removal is deleting a key from the fileOnly server-owned fields are dropped:
id,createdAt,updatedAt,lastUsedAt, andenhancedDescription(regenerated by the platform from index contents). The API ignores these on write, so a mistake in that list shows up as a phantom diff on the next dry-run rather than destroying anything.Two config formats
Told apart by the presence of a
toolsarray.index,replicas,providername,{{vars}}A native config is literal and never template-rendered. This is not theoretical: three live agents carry
{{...}}in their stored prompts —{{INSERT_BRAND}},{{INSERT_LANGUAGE}}, and incidental prose like{{facet}}and{{5}}. Rendering a snapshot demanded values for text that is simply content, and substituting would have destroyed it.--var,--index,--replicaand--providerare rejected on a native config for the same reason.Prompt files
Externalised to
PROMPT.md, andSYSTEM.mdwhen the agent has a system prompt. A 47-line prompt embedded as a JSON string with\nescapes is unusable to edit, and this keepsinstructionsmeaning a file path in both formats rather than a path in one and inline text in the other.Files are read right-stripped, so
snapshot→updateis a true no-op. Without that, the newline a file always ends with was absent from the stored value, and every round-trip rewrote the field.snapshotrefuses to 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._diffadditionsproviderId,templateType,descriptionandsystemPrompt, which phase 2 makes editable. Probed and confirmed these are preserved when omitted (unlikeconfig/tools/searchControls), so only fields the payload actually sends are compared — no noise from the friendly path.systemPromptis compared right-stripped likeinstructions.Notably, a provider switch previously reported no change at all.
Verification
147 tests pass (14 new). Live:
update --dry-runis clean for all 14 agents on the account. This is the completeness oracle: any fieldbuild_snapshot()failed to carry would appear as a phantom diff. The first sweep found 3 failures — all three the literal-{{...}}bug above, which is how it was caught.mode: dynamic,allowUnlistedIndices: true,searchControls, fourconfigkeys,systemPrompt,descriptionand a literal{{INSERT_BRAND}}all intact.{{facet}}preserved.All throwaway agents created for probing were deleted and confirmed gone.
Effect on #16
The native format carries per-index
searchControlsfor free, so #16 reduces to friendly-format parity plus the README schema documentation.Not included
Native format in
create.createhas no prior state to lose, so it is not a safety issue — worth doing for symmetry, but separately.🤖 Generated with Claude Code