Skip to content

feat(snapshot): write a full config from an agent's current state - #19

Merged
chuckmeyer merged 4 commits into
mainfrom
feat/snapshot
Aug 27, 2026
Merged

chuckmeyer merged 4 commits into
mainfrom
feat/snapshot

Conversation

@chuckmeyer

Copy link
Copy Markdown
Contributor

Phase 2 of #14. Also closes most of #16 — see below.

What this adds

algolia-agent snapshot <agent_id>              # -> agent-config.json + PROMPT.md (+ SYSTEM.md)
algolia-agent update <agent_id> --dry-run      # -> "No changes detected."

snapshot writes the API's own representation of an agent, and update sends 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:

  • no defaults registry — omitting a field already at its default is a no-op, so writing back what you read is sufficient. The server stays the only authority on defaults, with nothing to hand-maintain and nothing to go stale.
  • no merge layer
  • no null-means-remove convention — removal is deleting a key from the file

Only server-owned fields are dropped: id, createdAt, updatedAt, lastUsedAt, and enhancedDescription (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 tools array.

Shape For
friendly (existing, unchanged) index, replicas, provider name, {{vars}} provisioning several agents from one spec
native (new) the API's representation faithfully managing one agent

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, --replica and --provider are rejected on a native config for the same reason.

Prompt files

Externalised to PROMPT.md, and SYSTEM.md when the agent has a system prompt. A 47-line prompt embedded as a JSON string with \n escapes is unusable to edit, and this keeps instructions meaning 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 → update is 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.

snapshot refuses 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.

_diff additions

providerId, templateType, description and systemPrompt, which phase 2 makes editable. Probed and confirmed these are preserved when omitted (unlike config/tools/searchControls), so only fields the payload actually sends are compared — no noise from the friendly path. systemPrompt is compared right-stripped like instructions.

Notably, a provider switch previously reported no change at all.

Verification

147 tests pass (14 new). Live:

  • snapshot → update --dry-run is clean for all 14 agents on the account. This is the completeness oracle: any field build_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.
  • A real write through the native path left two tools, mode: dynamic, allowUnlistedIndices: true, searchControls, four config keys, systemPrompt, description and a literal {{INSERT_BRAND}} all intact.
  • SYSTEM.md end-to-end on a throwaway agent with a system prompt (no existing agent has one), round-tripping clean with {{facet}} preserved.

All throwaway agents created for probing were deleted and confirmed gone.

Effect on #16

The native format carries per-index searchControls for free, so #16 reduces to friendly-format parity plus the README schema documentation.

Not included

Native format in create. create has no prior state to lose, so it is not a safety issue — worth doing for symmetry, but separately.

🤖 Generated with Claude Code

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>
@chuckmeyer

Copy link
Copy Markdown
Contributor Author

templateType is a provenance label, not a selector

Flagged in review that templateType being writable is surprising, since a template is something you pick in the dashboard. Probed it on a throwaway agent:

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 native agent-config.json plus PROMPT.md (and SYSTEM.md when present), with --force clobber 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 _diff to handle systemPrompt and 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.

Comment thread src/algolia_agent/cli.py Outdated
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."
Comment thread src/algolia_agent/cli.py
Comment on lines +564 to +565
if system_prompt_file and (agent.get("systemPrompt") or "").strip():
snap["systemPrompt"] = system_prompt_file
Comment thread src/algolia_agent/cli.py
Comment on lines +744 to +747
if is_native_config(file_config):
new_payload = _native_payload(file_config, config_path, args)
_apply_update(client, args, current, new_payload)
return
Comment thread src/algolia_agent/cli.py Outdated
Comment on lines +712 to +716
+ "\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>
@chuckmeyer

Copy link
Copy Markdown
Contributor Author

Verified against a template-created agent

Tested 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 {{...}} placeholders

The stored prompt contains:

{{5}} {{INSERT_BRAND}} {{INSERT_COMPETITORS_LIST}} {{INSERT_INDUSTRY}} {{INSERT_LANGUAGE}}

All five survive the snapshot verbatim. This is decisive for the literal-native rule in this PR: template-created agents carry these placeholders until someone fills them in, so template-rendering a native config would break snapshot + update for that entire class of agent — not an edge case.

Note SearchLimit: max {{5}} search_tool calls per session. That is an authoring slip in Algolia's own template, and it is the strongest available argument that {{...}} cannot be assumed to be a variable: the platform emits ones that aren't.

It also explains the three agents that failed the first sweep — all template-derived, all still holding placeholders.

templateType carries the template slug

"shopping-assistant", confirming the earlier probe's reading: the picker applies a template at creation and stamps which one. (Coincidentally the exact string that probe guessed, which is why it looked plausible while still having no effect.)

Change in this push

The --var refusal previously advised switching to the friendly config format. That is unhelpful for the common case — someone snapshots a template agent and wants to fill in {{INSERT_BRAND}}. It now names the prompt file to edit, and keeps the friendly-format suggestion for the case it actually fits:

$ algolia-agent update <id> --config agent-config.json --var INSERT_BRAND=Acme --dry-run
ERROR: --var cannot be combined with a native config.
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
own templates ship placeholders like {{INSERT_BRAND}}, and substituting them
on every update would overwrite them.

To fill them in, edit PROMPT.md directly and run update again.
For templating across several agents, use the friendly config format (index/replicas).

Documented in the README's Snapshots section. 149 tests (2 new, one using the template's verbatim placeholder set).

Incidentally the first non-Gemini agent on the account: claude-fable-5 via the "Agent Quickstart" provider. No schema impact.

… 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>
@chuckmeyer

Copy link
Copy Markdown
Contributor Author

All four findings applied. One was a real bug that would have shipped.

Whitespace-only systemPrompt produced an unusable snapshot

Confirmed by reproduction, not just reading:

agent = {..., "systemPrompt": "   "}
build_snapshot(agent, "PROMPT.md", None)["systemPrompt"]  # -> '   '

cmd_snapshot correctly skips writing SYSTEM.md when the value is blank, but build_snapshot left the stored string in the snapshot — so _native_payload treated " " as a filename and the config could not be applied.

Fixed at the source rather than at the read: a snapshot's systemPrompt is now always a path or absent, never the text. Dropping it is safe because the API preserves systemPrompt when the payload omits it (probed).

Worth noting one of my existing tests was written permissively enough to hide this:

assert "systemPrompt" not in snap or snap["systemPrompt"] is None

That or made the assertion unfalsifiable for the failing case. Tightened, and the review's scenario is now a test of its own.

-o/--instructions-file named a flag that does not exist

Correct — that reads as 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 separately.

"cannot express" is wrong for native configs

The sharpest of the four. That explanation is true of the friendly format and false of a native config, which can express every one of those fields — so their absence is an edit, quite possibly deliberate. Blaming the format there would send someone looking for a limitation that is not there. The wording now branches:

anything missing from the payload is lost. They are not present in .../agent-config.json.

If you meant to remove them, pass --force. Otherwise restore them —
algolia-agent snapshot <id> --force rewrites the file from the agent's current state.

config_path not passed on the native path

Correct, and it compounded the above: a native refusal could not name the file that produced the payload. Both fixed together, verified live.


153 tests (4 new). Round-trip re-verified clean for all 14 agents after these changes.

Also correcting something in my own earlier comment: I wrote "15 of 15" agents. The account holds 14, with the template-created agent among them — so it is 14/14 including it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants