diff --git a/AGENTS.md b/AGENTS.md
index 2a4d7b85..110fdafd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -128,9 +128,9 @@ Use narrower verification for small edits.
## Agent Studio (`pkg/cmd/agents/...`, `api/agentstudio/`)
-Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `update`, `delete`, `publish`, `unpublish`, `duplicate`, `try`, `run`. Backend source of truth: `github.com/algolia/conversational-ai`.
+Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `update`, `delete`, `publish`, `unpublish`, `duplicate`, `try`, `run`. Sub-groups: `cache` (`invalidate`). Backend source of truth: `github.com/algolia/conversational-ai`.
-**Naming note**: `try` (not `test`) — see "On `--dry-run`" below for why. All other verbs are single-word lowercase to match the CLI-wide convention; no hyphenated subcommand names exist anywhere in the tree.
+**Naming note**: `try` (not `test`) — see "On `--dry-run`" below for why. All flat verbs are single-word lowercase to match the CLI-wide convention; no hyphenated subcommand names exist anywhere in the tree. Sub-groups (`cache`, future `providers` / `conversations` / `keys` / `domains`) read as noun-then-verb (`agents cache invalidate`) — also a single word per token.
### API client (`api/agentstudio/`)
@@ -139,6 +139,9 @@ Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `upda
- Errors: `*APIError` with `StatusCode`, `Detail`, optional `Sentinel`. The detail extractor prefers structured FastAPI `detail[].msg` arrays over the generic `message` field — backends that return both pair them as `{"message":"Input is invalid, see detail/body:","detail":[{"msg":"..."}]}` and the structured form is the actionable one.
- `CreateAgent` / `UpdateAgent` accept `json.RawMessage` bodies on purpose. The backend's `AgentConfigCreate` schema is large, deeply validated, and evolves often. The CLI is a pass-through; the backend validates; our 422-detail surfacing makes errors actionable.
- `Completions(...)` returns the raw `*http.Response`. Caller checks `Content-Type` (`text/event-stream` → `ParseStream`; else copy verbatim). One method, two output shapes.
+- `CompletionOptions.No*` fields (`NoCache`, `NoMemory`, `NoAnalytics`) are **inverted** from the backend's query polarity. Two reasons: the backend defaults all three to true (only the negative is interesting at the CLI), and `memory` in particular has an `anyOf [{const false}, {type null}]` schema — sending `memory=true` would 422. Therefore the wire form omits the param when the No* field is false, and sends `=false` when true. Polarity is enforced end-to-end by `TestCompletions_QueryFlagsAndSecureUserToken` in `api/agentstudio/completions_test.go`.
+- `CompletionOptions.SecureUserToken` populates the `X-Algolia-Secure-User-Token` header when non-empty. It carries a signed JWT scoping the conversation/memory/analytics partition to a specific end-user (see `rag/dependencies/secure_user_token.py` in the backend). Empty means no header — `X-Algolia-User-ID` fallback applies.
+- `InvalidateAgentCache(id, before)` calls `DELETE /1/agents/{id}/cache?before=YYYY-MM-DD` (query omitted when `before` is empty). Date format validation is **deliberately not done client-side** — the backend's Pydantic parser is the source of truth, and our 422 surfacing turns malformed input into an actionable message verbatim. Mirroring the parser in Go would create silent skew.
### Streaming (`api/agentstudio/sse.go`)
@@ -151,6 +154,21 @@ The wire format is **not** standard SSE. Two protocols, both served as `text/eve
Streaming output convention: NDJSON to stdout regardless of TTY, one `{"type":"...","data":{...}}` per line. Plays well with `jq -r 'select(.type=="text-delta") | .data.delta'`. Don't fork rendering between TTY/non-TTY for streaming responses.
+### Completion runtime knobs (`agents try` / `agents run`)
+
+Both commands expose the same set of completion-time flags, mapping directly to backend query params + headers:
+
+| Flag | Wire | Default | Notes |
+|---|---|---|---|
+| `--no-stream` | `?stream=false` | stream | Buffered single-JSON response instead of SSE |
+| `--compatibility v4\|v5` | `?compatibilityMode=ai-sdk-{4,5}` | v5 | **Required** server-side; CLI promotes empty → v5 |
+| `--no-cache` | `?cache=false` | cache on | Bypasses backend completion cache for this call |
+| `--no-memory` | `?memory=false` | memory on | Disables agent memory retrieval/write for this call |
+| `--no-analytics` | `?analytics=false` | analytics on | Skips Agent Studio analytics for this call |
+| `--secure-user-token ` | `X-Algolia-Secure-User-Token` header | (omitted) | Signed JWT, end-user scoping |
+
+The flag set is intentionally duplicated across `try.go` and `run.go` rather than extracted into a `RegisterCompletionFlags` shared helper — there are exactly two consumers and the duplication is mechanical (8 lines per command). If a third consumer appears, extract following the "second use" rule (same as `PrintDryRun` / `NormalizeCompatibility`).
+
### On `--dry-run`
Two distinct concepts share the name and they MUST NOT be conflated:
diff --git a/api/agentstudio/client.go b/api/agentstudio/client.go
index 358384d3..efe02896 100644
--- a/api/agentstudio/client.go
+++ b/api/agentstudio/client.go
@@ -130,19 +130,46 @@ func (c *Client) ListAgents(ctx context.Context, params ListAgentsParams) (*Pagi
return &out, nil
}
-// CompletionOptions configures Completions(...) query parameters.
+// CompletionOptions configures Completions(...) query parameters and
+// per-request headers.
//
// Stream maps to ?stream=true|false; the default zero value (false) gives
// a buffered single-JSON response. Set explicitly via the command layer
-// (`agents test` / `agents run` set Stream=true unless --no-stream).
+// (`agents try` / `agents run` set Stream=true unless --no-stream).
//
// Compatibility maps to ?compatibilityMode=ai-sdk-4|ai-sdk-5. The backend
// requires this query param (no server-side default), so empty here is
// promoted to CompatV5 — its frames are standard SSE with [DONE], easier
// to parse defensively than v4's `:\n` line format.
+//
+// NoCache, NoMemory, and NoAnalytics are inverted from the backend's
+// query-param polarity for two reasons:
+//
+// - The backend defaults all three to true; only the negated case is
+// interesting from the CLI surface.
+// - The flag layer ships them as `--no-cache`/`--no-memory`/`--no-analytics`
+// so the option fields keep that polarity end-to-end.
+//
+// When a No*-field is false (the zero value) the corresponding query
+// param is omitted entirely, which matches the backend's "default ON"
+// behavior. The `memory` schema in particular is `anyOf [{const: false},
+// {type: null}]` — false is the ONLY valid passable value, so always
+// emitting `memory=true` would be a server-side validation error.
+//
+// SecureUserToken populates the X-Algolia-Secure-User-Token header when
+// non-empty. It carries a signed JWT that scopes the conversation /
+// memory / analytics partition to a specific end-user; required by the
+// backend whenever a feature behind SecureUserTokenDep is enabled (see
+// rag/dependencies/secure_user_token.py in algolia/conversational-ai).
+// Empty here means no header is sent — the existing X-Algolia-User-ID
+// fallback applies.
type CompletionOptions struct {
- Stream bool
- Compatibility CompatibilityMode
+ Stream bool
+ Compatibility CompatibilityMode
+ NoCache bool
+ NoMemory bool
+ NoAnalytics bool
+ SecureUserToken string
}
// Completions calls POST /1/agents/{agentID}/completions and returns the
@@ -191,6 +218,19 @@ func (c *Client) Completions(
q := url.Values{}
q.Set("stream", boolToWire(opts.Stream))
q.Set("compatibilityMode", string(mode))
+ // Only emit the negative cases — backend defaults match the omitted
+ // state, so adding `cache=true`/`analytics=true` would be wire noise,
+ // and `memory=true` would actually be a 422 (the schema only allows
+ // `false` or null). See CompletionOptions godoc for the full reasoning.
+ if opts.NoCache {
+ q.Set("cache", "false")
+ }
+ if opts.NoMemory {
+ q.Set("memory", "false")
+ }
+ if opts.NoAnalytics {
+ q.Set("analytics", "false")
+ }
endpoint := c.cfg.BaseURL + "/1/agents/" + url.PathEscape(agentID) + "/completions?" + q.Encode()
@@ -200,6 +240,9 @@ func (c *Client) Completions(
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
+ if opts.SecureUserToken != "" {
+ req.Header.Set("X-Algolia-Secure-User-Token", opts.SecureUserToken)
+ }
// Preferred Accept: streaming responses come back as text/event-stream
// (both v4 and v5); buffered ones as application/json. Listing both
// is safe — the server picks based on ?stream and we inspect the
@@ -260,6 +303,48 @@ func (c *Client) UpdateAgent(ctx context.Context, id string, body json.RawMessag
return c.doAgentMutation(ctx, http.MethodPatch, endpoint, body, "update agent")
}
+// InvalidateAgentCache calls DELETE /1/agents/{id}/cache. The backend
+// removes cached completion responses for this agent.
+//
+// `before` is an optional YYYY-MM-DD date string. When non-empty, only
+// cache entries created strictly before that date are invalidated
+// (exclusive). When empty, all cache entries for the agent are wiped.
+//
+// The format is intentionally not pre-parsed in Go — the backend
+// accepts the literal string and returns a 422 with a structured detail
+// on a malformed value, which our extractDetail surfaces unchanged. Any
+// client-side date parsing here would diverge from whatever Pydantic
+// version the backend ships and create silent skew.
+//
+// Returns nil on the backend's HTTP 204 No Content. Wraps the standard
+// 4xx/5xx APIError otherwise.
+func (c *Client) InvalidateAgentCache(ctx context.Context, id, before string) error {
+ if strings.TrimSpace(id) == "" {
+ return fmt.Errorf("agent studio: agent id is required")
+ }
+
+ endpoint := c.cfg.BaseURL + "/1/agents/" + url.PathEscape(id) + "/cache"
+ if before != "" {
+ q := url.Values{}
+ q.Set("before", before)
+ endpoint += "?" + q.Encode()
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ c.setHeaders(req)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("agent studio: invalidate agent cache: %w", err)
+ }
+ defer resp.Body.Close()
+
+ return checkResponse(resp)
+}
+
// DeleteAgent calls DELETE /1/agents/{id}.
//
// Returns nil on the backend's HTTP 204 No Content. The backend
diff --git a/api/agentstudio/completions_test.go b/api/agentstudio/completions_test.go
index 6ed6600a..5ff2785c 100644
--- a/api/agentstudio/completions_test.go
+++ b/api/agentstudio/completions_test.go
@@ -157,6 +157,81 @@ func readAllString(t *testing.T, r io.Reader) string {
return strings.TrimSpace(string(b))
}
+func TestCompletions_QueryFlagsAndSecureUserToken(t *testing.T) {
+ // Phase 5: validates the new --no-cache / --no-memory / --no-analytics
+ // / --secure-user-token plumbing all the way through the wire.
+ //
+ // Polarity matters here: the No*-fields are inverted from the
+ // backend's query polarity (see CompletionOptions godoc). A `false`
+ // value MUST omit the param — sending `cache=true` would still
+ // match server defaults, but sending `memory=true` would 422 (the
+ // `memory` schema only allows {const false, null}). This is the
+ // regression net for that.
+ cases := []struct {
+ name string
+ opts CompletionOptions
+ wantHas map[string]string // params that must equal a value
+ wantNot []string // params that must be ABSENT
+ wantHdr string // expected X-Algolia-Secure-User-Token; "" = absent
+ }{
+ {
+ name: "all defaults: only stream + compatibilityMode set",
+ opts: CompletionOptions{Stream: true},
+ wantHas: map[string]string{"stream": "true", "compatibilityMode": "ai-sdk-5"},
+ wantNot: []string{"cache", "memory", "analytics"},
+ wantHdr: "",
+ },
+ {
+ name: "--no-cache only",
+ opts: CompletionOptions{Stream: true, NoCache: true},
+ wantHas: map[string]string{"cache": "false"},
+ wantNot: []string{"memory", "analytics"},
+ },
+ {
+ name: "--no-memory only (the most semantically constrained)",
+ opts: CompletionOptions{Stream: true, NoMemory: true},
+ wantHas: map[string]string{"memory": "false"},
+ wantNot: []string{"cache", "analytics"},
+ },
+ {
+ name: "--no-analytics only",
+ opts: CompletionOptions{Stream: true, NoAnalytics: true},
+ wantHas: map[string]string{"analytics": "false"},
+ wantNot: []string{"cache", "memory"},
+ },
+ {
+ name: "all three negative + secure user token header",
+ opts: CompletionOptions{Stream: true, NoCache: true, NoMemory: true, NoAnalytics: true, SecureUserToken: "ey.signed.jwt"},
+ wantHas: map[string]string{"cache": "false", "memory": "false", "analytics": "false"},
+ wantHdr: "ey.signed.jwt",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/test/completions", func(w http.ResponseWriter, r *http.Request) {
+ for k, v := range tc.wantHas {
+ assert.Equal(t, v, r.URL.Query().Get(k), "query param %q", k)
+ }
+ for _, k := range tc.wantNot {
+ assert.False(t, r.URL.Query().Has(k), "query param %q must be absent", k)
+ }
+ assert.Equal(t, tc.wantHdr, r.Header.Get("X-Algolia-Secure-User-Token"))
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{}`))
+ })
+ _, c := newTestClient(t, mux)
+
+ resp, err := c.Completions(context.Background(), "test",
+ json.RawMessage(`{"messages":[{"role":"user","content":"x"}]}`), tc.opts)
+ require.NoError(t, err)
+ _ = resp.Body.Close()
+ })
+ }
+}
+
func TestCompletions_BodyContentRoundTrip(t *testing.T) {
// Confirms we POST exactly the bytes we were handed (no re-encode).
wire := `{"messages":[{"role":"user","content":"x"}],"id":"conv-1"}`
diff --git a/api/agentstudio/mutations_test.go b/api/agentstudio/mutations_test.go
index d2c6914d..a2173a42 100644
--- a/api/agentstudio/mutations_test.go
+++ b/api/agentstudio/mutations_test.go
@@ -223,6 +223,93 @@ func TestLifecycle_RejectsEmptyID(t *testing.T) {
}
}
+func TestInvalidateAgentCache(t *testing.T) {
+ cases := []struct {
+ name string
+ id string
+ before string
+ serverFn func(t *testing.T) http.HandlerFunc
+ wantErr string // substring; "" = expect success
+ isSentinel error
+ }{
+ {
+ name: "no before -> DELETE without query",
+ id: "abc-123",
+ before: "",
+ serverFn: func(t *testing.T) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodDelete, r.Method)
+ assert.Equal(t, "", r.URL.RawQuery, "no before -> no query string")
+ w.WriteHeader(http.StatusNoContent)
+ }
+ },
+ },
+ {
+ name: "with before -> DELETE with ?before",
+ id: "abc-123",
+ before: "2026-01-15",
+ serverFn: func(t *testing.T) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodDelete, r.Method)
+ assert.Equal(t, "2026-01-15", r.URL.Query().Get("before"))
+ w.WriteHeader(http.StatusNoContent)
+ }
+ },
+ },
+ {
+ name: "404 from backend surfaces as ErrNotFound",
+ id: "missing",
+ before: "",
+ serverFn: func(t *testing.T) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"detail":"Agent not found"}`))
+ }
+ },
+ wantErr: "Agent not found",
+ isSentinel: ErrNotFound,
+ },
+ {
+ name: "422 with structured detail (e.g. malformed before) surfaces backend message verbatim",
+ id: "abc-123",
+ before: "not-a-date",
+ serverFn: func(t *testing.T) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"detail":[{"msg":"Input should be a valid date in YYYY-MM-DD format","loc":["query","before"]}]}`))
+ }
+ },
+ wantErr: "valid date",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/"+tc.id+"/cache", tc.serverFn(t))
+ _, c := newTestClient(t, mux)
+
+ err := c.InvalidateAgentCache(context.Background(), tc.id, tc.before)
+ if tc.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.wantErr)
+ if tc.isSentinel != nil {
+ assert.True(t, errors.Is(err, tc.isSentinel))
+ }
+ })
+ }
+}
+
+func TestInvalidateAgentCache_RejectsEmptyID(t *testing.T) {
+ _, c := newTestClient(t, http.NewServeMux())
+ err := c.InvalidateAgentCache(context.Background(), " ", "")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "agent id is required")
+}
+
func TestLifecycle_NotFound(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/agents/missing/publish", func(w http.ResponseWriter, _ *http.Request) {
diff --git a/e2e/testscripts/agents/cache.txtar b/e2e/testscripts/agents/cache.txtar
new file mode 100644
index 00000000..51328e4d
--- /dev/null
+++ b/e2e/testscripts/agents/cache.txtar
@@ -0,0 +1,33 @@
+# `agents cache invalidate` — flag-validation + dry-run contract.
+# None of these hit the network: the failing cases trip cobra/our own
+# validators before client construction; the passing case uses --dry-run.
+#
+# Ungated: runs whenever the standard ALGOLIA_APPLICATION_ID +
+# ALGOLIA_API_KEY are present.
+
+# `cache` parent without verb prints help (cobra default for groups
+# with no Run; exits 0).
+exec algolia agents cache
+stdout 'invalidate'
+
+# `invalidate` requires positional
+! exec algolia agents cache invalidate
+stderr 'requires exactly 1 argument'
+
+# Non-TTY without --confirm is refused (matches `agents delete` rule)
+! exec algolia agents cache invalidate abc-123
+stderr '--confirm required'
+
+# --dry-run skips the confirm requirement and prints the would-be call
+exec algolia agents cache invalidate abc-123 --dry-run
+! stderr .
+stdout 'Dry run: would DELETE /1/agents/abc-123/cache'
+stdout 'all cached completions for this agent'
+! stdout '\?before='
+
+# --dry-run with --before describes the bounded scope and includes the
+# query string in the previewed URL
+exec algolia agents cache invalidate abc-123 --before 2026-01-15 --dry-run
+! stderr .
+stdout 'Dry run: would DELETE /1/agents/abc-123/cache\?before=2026-01-15'
+stdout 'before 2026-01-15'
diff --git a/e2e/testscripts/agents/dry-run.txtar b/e2e/testscripts/agents/dry-run.txtar
index ca777b10..baf510e9 100644
--- a/e2e/testscripts/agents/dry-run.txtar
+++ b/e2e/testscripts/agents/dry-run.txtar
@@ -57,6 +57,23 @@ stdout -count=1 '"messages"'
! exec algolia agents try -c cfg.json -m hi --dry-run
stderr 'unknown flag'
+# ---------------------------------------------------------------------
+# Phase 5 completion runtime flags wire into the dry-run preview body
+# without changing it (they're query params + a header, not body
+# fields). Same for `agents try` which does NOT echo them via
+# --dry-run because it has no --dry-run. The body preview must remain
+# stable across these flag combos so users can diff configs cleanly.
+# ---------------------------------------------------------------------
+exec algolia agents run 11111111-1111-1111-1111-111111111111 -m hi --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt --dry-run
+! stderr .
+stdout -count=1 'Dry run: would POST /1/agents/11111111-1111-1111-1111-111111111111/completions'
+stdout -count=1 '"content": "hi"'
+# completion-runtime knobs are wire-only; never in the body
+! stdout '"cache"'
+! stdout '"memory"'
+! stdout '"analytics"'
+! stdout 'secure-user-token'
+
-- cfg.json --
{"name":"e2e-create-stub","instructions":"e2e","widgetType":"filterSuggestions","enableAlgoliaMcp":false}
diff --git a/pkg/cmd/agents/agents.go b/pkg/cmd/agents/agents.go
index d5356396..8ca1274d 100644
--- a/pkg/cmd/agents/agents.go
+++ b/pkg/cmd/agents/agents.go
@@ -4,6 +4,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
+ "github.com/algolia/cli/pkg/cmd/agents/cache"
"github.com/algolia/cli/pkg/cmd/agents/create"
deletecmd "github.com/algolia/cli/pkg/cmd/agents/delete"
"github.com/algolia/cli/pkg/cmd/agents/duplicate"
@@ -63,6 +64,7 @@ func NewAgentsCmd(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(duplicate.NewDuplicateCmd(f, nil))
cmd.AddCommand(trycmd.NewTryCmd(f, nil))
cmd.AddCommand(run.NewRunCmd(f, nil))
+ cmd.AddCommand(cache.NewCacheCmd(f))
return cmd
}
diff --git a/pkg/cmd/agents/cache/cache.go b/pkg/cmd/agents/cache/cache.go
new file mode 100644
index 00000000..81a1a8f4
--- /dev/null
+++ b/pkg/cmd/agents/cache/cache.go
@@ -0,0 +1,182 @@
+package cache
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/spf13/cobra"
+
+ "github.com/algolia/cli/api/agentstudio"
+ "github.com/algolia/cli/pkg/cmdutil"
+ "github.com/algolia/cli/pkg/iostreams"
+ "github.com/algolia/cli/pkg/prompt"
+ "github.com/algolia/cli/pkg/validators"
+)
+
+// NewCacheCmd is the parent for `algolia agents cache `.
+//
+// Today there's exactly one verb (`invalidate`); kept as a sub-group
+// rather than a flat `agents cache-invalidate` because:
+// - The parent reads as a noun (`cache`), the child as a verb
+// (`invalidate`) — natural language order, matches the established
+// CLI rhythm (`apikeys list`, `objects update`).
+// - Reserves a clean home for follow-ups (`agents cache stats`,
+// `agents cache size`, etc.) without renaming the existing surface.
+// - Mirrors the nested-group pattern Phase 6+ will reuse for
+// `agents providers ` and `agents conversations `.
+func NewCacheCmd(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "cache",
+ Short: "Inspect and invalidate Agent Studio completion caches",
+ Long: heredoc.Doc(`
+ Manage cached completion responses for an agent.
+
+ Agent Studio caches completions per (agent, request hash). Use
+ --no-cache on "agents try" / "agents run" to bypass for a single
+ call; use "agents cache invalidate" to drop entries server-side.
+ `),
+ }
+
+ cmd.AddCommand(newInvalidateCmd(f, nil))
+ return cmd
+}
+
+// InvalidateOptions configures `algolia agents cache invalidate`.
+type InvalidateOptions struct {
+ IO *iostreams.IOStreams
+ Ctx context.Context
+
+ AgentStudioClient func() (*agentstudio.Client, error)
+
+ AgentID string
+ Before string
+ DryRun bool
+ DoConfirm bool
+}
+
+func newInvalidateCmd(f *cmdutil.Factory, runF func(*InvalidateOptions) error) *cobra.Command {
+ opts := &InvalidateOptions{
+ IO: f.IOStreams,
+ AgentStudioClient: f.AgentStudioClient,
+ }
+
+ var confirm bool
+
+ cmd := &cobra.Command{
+ Use: "invalidate [--before YYYY-MM-DD] [--confirm]",
+ Short: "Invalidate cached completions for an agent",
+ Long: heredoc.Doc(`
+ Calls DELETE /1/agents//cache.
+
+ By default, drops every cached completion for the agent. Pass
+ --before YYYY-MM-DD to drop only entries created strictly
+ before that date (exclusive — matches the backend's Pydantic
+ parsing). Date validation is the backend's job; bad input
+ surfaces a structured 422 verbatim.
+
+ Like "agents delete", interactive use prompts to confirm and
+ non-interactive use requires --confirm. Use --dry-run to
+ preview without deleting.
+ `),
+ Example: heredoc.Doc(`
+ # Wipe all cached completions for an agent (interactive)
+ $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111
+
+ # Drop only entries older than a specific date
+ $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 --before 2026-01-15
+
+ # Skip the prompt (required in CI)
+ $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 -y
+
+ # Preview without sending
+ $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 --dry-run
+ `),
+ Args: validators.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ opts.AgentID = args[0]
+ opts.Ctx = cmd.Context()
+ if opts.AgentID == "" {
+ return cmdutil.FlagErrorf("agent-id must not be empty")
+ }
+
+ // Mirror agents delete: --confirm/-y bypasses prompt.
+ // Without it, prompt in TTY, refuse in non-TTY. --dry-run
+ // is non-destructive and bypasses the confirmation entirely.
+ if !confirm && !opts.DryRun {
+ if !opts.IO.CanPrompt() {
+ return cmdutil.FlagErrorf(
+ "--confirm required when non-interactive shell is detected",
+ )
+ }
+ opts.DoConfirm = true
+ }
+
+ if runF != nil {
+ return runF(opts)
+ }
+ return runInvalidateCmd(opts)
+ },
+ }
+
+ cmd.Flags().
+ StringVar(&opts.Before, "before", "", "Drop entries strictly before this date (YYYY-MM-DD, exclusive)")
+ cmd.Flags().BoolVarP(&confirm, "confirm", "y", false, "Skip confirmation prompt")
+ cmd.Flags().
+ BoolVar(&opts.DryRun, "dry-run", false, "Print what would be invalidated without calling the API")
+
+ return cmd
+}
+
+func runInvalidateCmd(opts *InvalidateOptions) error {
+ if opts.DryRun {
+ fmt.Fprintf(opts.IO.Out, "Dry run: would DELETE /1/agents/%s/cache", opts.AgentID)
+ if opts.Before != "" {
+ fmt.Fprintf(opts.IO.Out, "?before=%s", opts.Before)
+ }
+ fmt.Fprintln(opts.IO.Out)
+ if opts.Before == "" {
+ fmt.Fprintln(opts.IO.Out, " scope: all cached completions for this agent")
+ } else {
+ fmt.Fprintf(opts.IO.Out, " scope: cached completions created before %s\n", opts.Before)
+ }
+ return nil
+ }
+
+ if opts.DoConfirm {
+ var confirmed bool
+ msg := fmt.Sprintf("Invalidate completion cache for agent %s?", opts.AgentID)
+ if opts.Before != "" {
+ msg = fmt.Sprintf("Invalidate completion cache for agent %s (entries before %s)?",
+ opts.AgentID, opts.Before)
+ }
+ if err := prompt.Confirm(msg, &confirmed); err != nil {
+ return fmt.Errorf("failed to prompt: %w", err)
+ }
+ if !confirmed {
+ return nil
+ }
+ }
+
+ client, err := opts.AgentStudioClient()
+ if err != nil {
+ return err
+ }
+ ctx := opts.Ctx
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ opts.IO.StartProgressIndicatorWithLabel("Invalidating agent cache")
+ err = client.InvalidateAgentCache(ctx, opts.AgentID, opts.Before)
+ opts.IO.StopProgressIndicator()
+ if err != nil {
+ return err
+ }
+
+ cs := opts.IO.ColorScheme()
+ if opts.IO.IsStdoutTTY() {
+ fmt.Fprintf(opts.IO.Out, "%s Invalidated cache for agent %s\n", cs.SuccessIcon(), opts.AgentID)
+ }
+ return nil
+}
diff --git a/pkg/cmd/agents/cache/cache_test.go b/pkg/cmd/agents/cache/cache_test.go
new file mode 100644
index 00000000..1838a5a3
--- /dev/null
+++ b/pkg/cmd/agents/cache/cache_test.go
@@ -0,0 +1,131 @@
+package cache
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/algolia/cli/api/agentstudio"
+ "github.com/algolia/cli/test"
+)
+
+func newClientForServer(t *testing.T, ts *httptest.Server) func() (*agentstudio.Client, error) {
+ t.Helper()
+ return func() (*agentstudio.Client, error) {
+ return agentstudio.NewClient(agentstudio.Config{
+ BaseURL: ts.URL,
+ ApplicationID: "APP123",
+ APIKey: "k",
+ HTTPClient: ts.Client(),
+ })
+ }
+}
+
+func Test_runInvalidateCmd_NoBefore_HitsBackendWithoutQuery(t *testing.T) {
+ mux := http.NewServeMux()
+ hit := false
+ mux.HandleFunc("/1/agents/abc-123/cache", func(w http.ResponseWriter, r *http.Request) {
+ hit = true
+ assert.Equal(t, http.MethodDelete, r.Method)
+ assert.Equal(t, "", r.URL.RawQuery)
+ w.WriteHeader(http.StatusNoContent)
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewCacheCmd(f)
+ _, err := test.Execute(cmd, "invalidate abc-123 -y", out)
+ require.NoError(t, err)
+ assert.True(t, hit)
+}
+
+func Test_runInvalidateCmd_WithBefore_PassesQueryParam(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/abc-123/cache", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "2026-01-15", r.URL.Query().Get("before"))
+ w.WriteHeader(http.StatusNoContent)
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewCacheCmd(f)
+ _, err := test.Execute(cmd, "invalidate abc-123 --before 2026-01-15 -y", out)
+ require.NoError(t, err)
+}
+
+func Test_runInvalidateCmd_DryRunSkipsAPI(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/abc-123/cache", func(_ http.ResponseWriter, _ *http.Request) {
+ t.Fatal("backend was called during --dry-run")
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewCacheCmd(f)
+ result, err := test.Execute(cmd, "invalidate abc-123 --before 2026-01-15 --dry-run", out)
+ require.NoError(t, err)
+
+ got := result.String()
+ assert.Contains(t, got, "Dry run: would DELETE /1/agents/abc-123/cache?before=2026-01-15")
+ assert.Contains(t, got, "scope: cached completions created before 2026-01-15")
+}
+
+func Test_runInvalidateCmd_DryRunNoBefore_DescribesAllScope(t *testing.T) {
+ f, out := test.NewFactory(false, nil, nil, "")
+ cmd := NewCacheCmd(f)
+ result, err := test.Execute(cmd, "invalidate abc-123 --dry-run", out)
+ require.NoError(t, err)
+
+ got := result.String()
+ assert.Contains(t, got, "Dry run: would DELETE /1/agents/abc-123/cache")
+ assert.NotContains(t, got, "?before=")
+ assert.Contains(t, got, "all cached completions for this agent")
+}
+
+func Test_runInvalidateCmd_RequiresAgentID(t *testing.T) {
+ f, out := test.NewFactory(false, nil, nil, "")
+ cmd := NewCacheCmd(f)
+ _, err := test.Execute(cmd, "invalidate", out)
+ require.Error(t, err)
+}
+
+func Test_runInvalidateCmd_NonTTYWithoutConfirmFails(t *testing.T) {
+ // test.NewFactory(false, ...) configures IO with non-TTY stdin/stdout/stderr.
+ // CanPrompt() must return false in that case, and the command must
+ // refuse without --confirm — same contract as `agents delete`.
+ f, out := test.NewFactory(false, nil, nil, "")
+ cmd := NewCacheCmd(f)
+ _, err := test.Execute(cmd, "invalidate abc-123", out)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--confirm required")
+}
+
+func Test_runInvalidateCmd_PropagatesAPIError(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/missing/cache", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"detail":"Agent not found"}`))
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewCacheCmd(f)
+ _, err := test.Execute(cmd, "invalidate missing -y", out)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Agent not found")
+}
diff --git a/pkg/cmd/agents/run/run.go b/pkg/cmd/agents/run/run.go
index ff437763..777dd951 100644
--- a/pkg/cmd/agents/run/run.go
+++ b/pkg/cmd/agents/run/run.go
@@ -22,12 +22,16 @@ type RunOptions struct {
AgentStudioClient func() (*agentstudio.Client, error)
- AgentID string
- InputFile string
- Message string
- NoStream bool
- Compatibility string
- DryRun bool
+ AgentID string
+ InputFile string
+ Message string
+ NoStream bool
+ Compatibility string
+ NoCache bool
+ NoMemory bool
+ NoAnalytics bool
+ SecureUserToken string
+ DryRun bool
}
func NewRunCmd(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
@@ -80,6 +84,11 @@ func NewRunCmd(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command
cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Request a buffered JSON response instead of SSE")
cmd.Flags().
StringVar(&opts.Compatibility, "compatibility", "", "Streaming protocol: v4 (ai-sdk-4) or v5 (ai-sdk-5, default)")
+ cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass the backend completion cache (default: cache enabled)")
+ cmd.Flags().BoolVar(&opts.NoMemory, "no-memory", false, "Disable agent memory for this completion (default: memory enabled)")
+ cmd.Flags().BoolVar(&opts.NoAnalytics, "no-analytics", false, "Skip Agent Studio analytics for this completion (default: analytics enabled)")
+ cmd.Flags().
+ StringVar(&opts.SecureUserToken, "secure-user-token", "", "Signed JWT scoping the conversation/memory/analytics partition to an end-user (X-Algolia-Secure-User-Token)")
cmd.Flags().
BoolVar(&opts.DryRun, "dry-run", false, "Print the resolved request body without calling the API")
@@ -130,8 +139,12 @@ func runRunCmd(opts *RunOptions) error {
defer stop()
resp, err := client.Completions(ctx, opts.AgentID, body, agentstudio.CompletionOptions{
- Stream: !opts.NoStream,
- Compatibility: mode,
+ Stream: !opts.NoStream,
+ Compatibility: mode,
+ NoCache: opts.NoCache,
+ NoMemory: opts.NoMemory,
+ NoAnalytics: opts.NoAnalytics,
+ SecureUserToken: opts.SecureUserToken,
})
if err != nil {
return err
diff --git a/pkg/cmd/agents/run/run_test.go b/pkg/cmd/agents/run/run_test.go
index 8939696a..7e245b4b 100644
--- a/pkg/cmd/agents/run/run_test.go
+++ b/pkg/cmd/agents/run/run_test.go
@@ -87,6 +87,34 @@ func Test_runRunCmd_RequiresAgentID(t *testing.T) {
require.Error(t, err)
}
+func Test_runRunCmd_ForwardsCompletionFlagsToWire(t *testing.T) {
+ // One end-to-end check that all four Phase 5 flags map onto the
+ // expected query params + header. Exhaustive matrix lives in
+ // api/agentstudio/completions_test.go; this exists so a regression
+ // in the cobra→opts→client wiring (forgetting one field,
+ // transposing No* polarity, etc.) is caught at the cmd layer.
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/abc-123/completions", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "false", r.URL.Query().Get("cache"))
+ assert.Equal(t, "false", r.URL.Query().Get("memory"))
+ assert.Equal(t, "false", r.URL.Query().Get("analytics"))
+ assert.Equal(t, "ey.signed.jwt", r.Header.Get("X-Algolia-Secure-User-Token"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"role":"assistant","content":"ok"}`))
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewRunCmd(f, nil)
+ _, err := test.Execute(cmd,
+ "abc-123 -m hi --no-stream --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt",
+ out)
+ require.NoError(t, err)
+}
+
func Test_runRunCmd_PropagatesAPIError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/agents/missing/completions", func(w http.ResponseWriter, _ *http.Request) {
diff --git a/pkg/cmd/agents/try/try.go b/pkg/cmd/agents/try/try.go
index 11a56490..c3139fa0 100644
--- a/pkg/cmd/agents/try/try.go
+++ b/pkg/cmd/agents/try/try.go
@@ -30,11 +30,15 @@ type TryOptions struct {
AgentStudioClient func() (*agentstudio.Client, error)
- ConfigFile string
- InputFile string
- Message string
- NoStream bool
- Compatibility string
+ ConfigFile string
+ InputFile string
+ Message string
+ NoStream bool
+ Compatibility string
+ NoCache bool
+ NoMemory bool
+ NoAnalytics bool
+ SecureUserToken string
}
func NewTryCmd(f *cmdutil.Factory, runF func(*TryOptions) error) *cobra.Command {
@@ -101,6 +105,11 @@ func NewTryCmd(f *cmdutil.Factory, runF func(*TryOptions) error) *cobra.Command
cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Request a buffered JSON response instead of SSE")
cmd.Flags().
StringVar(&opts.Compatibility, "compatibility", "", "Streaming protocol: v4 (ai-sdk-4) or v5 (ai-sdk-5, default)")
+ cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass the backend completion cache (default: cache enabled)")
+ cmd.Flags().BoolVar(&opts.NoMemory, "no-memory", false, "Disable agent memory for this completion (default: memory enabled)")
+ cmd.Flags().BoolVar(&opts.NoAnalytics, "no-analytics", false, "Skip Agent Studio analytics for this completion (default: analytics enabled)")
+ cmd.Flags().
+ StringVar(&opts.SecureUserToken, "secure-user-token", "", "Signed JWT scoping the conversation/memory/analytics partition to an end-user (X-Algolia-Secure-User-Token)")
cmd.MarkFlagsMutuallyExclusive("input", "message")
@@ -144,8 +153,12 @@ func runTryCmd(opts *TryOptions) error {
defer stop()
resp, err := client.Completions(ctx, "test", body, agentstudio.CompletionOptions{
- Stream: !opts.NoStream,
- Compatibility: mode,
+ Stream: !opts.NoStream,
+ Compatibility: mode,
+ NoCache: opts.NoCache,
+ NoMemory: opts.NoMemory,
+ NoAnalytics: opts.NoAnalytics,
+ SecureUserToken: opts.SecureUserToken,
})
if err != nil {
return err
diff --git a/pkg/cmd/agents/try/try_test.go b/pkg/cmd/agents/try/try_test.go
index 53376c30..c1d292ed 100644
--- a/pkg/cmd/agents/try/try_test.go
+++ b/pkg/cmd/agents/try/try_test.go
@@ -149,6 +149,37 @@ func Test_runTryCmd_CompatibilityV4(t *testing.T) {
assert.Contains(t, result.String(), `"text"`)
}
+func Test_runTryCmd_ForwardsCompletionFlagsToWire(t *testing.T) {
+ // One end-to-end check that all four Phase 5 flags map onto the
+ // expected query params + header. Exhaustive matrix lives in
+ // api/agentstudio/completions_test.go; this test exists so a
+ // regression in the cobra→opts→client wiring (forgetting one
+ // field, transposing No* polarity, etc.) is caught at the cmd
+ // layer.
+ mux := http.NewServeMux()
+ mux.HandleFunc("/1/agents/test/completions", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "false", r.URL.Query().Get("cache"))
+ assert.Equal(t, "false", r.URL.Query().Get("memory"))
+ assert.Equal(t, "false", r.URL.Query().Get("analytics"))
+ assert.Equal(t, "ey.signed.jwt", r.Header.Get("X-Algolia-Secure-User-Token"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"role":"assistant","content":"ok"}`))
+ })
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+
+ cfgPath := writeTempJSON(t, "cfg.json", `{"model":"x"}`)
+
+ f, out := test.NewFactory(false, nil, nil, "")
+ f.AgentStudioClient = newClientForServer(t, ts)
+
+ cmd := NewTryCmd(f, nil)
+ _, err := test.Execute(cmd,
+ "-c "+cfgPath+" -m hi --no-stream --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt",
+ out)
+ require.NoError(t, err)
+}
+
func Test_runTryCmd_RejectsInvalidCompatibility(t *testing.T) {
cfgPath := writeTempJSON(t, "cfg.json", `{"model":"x"}`)
f, out := test.NewFactory(false, nil, nil, "")