From b757697f59464ad4d7a69a5fa2df9069f39aa738 Mon Sep 17 00:00:00 2001 From: kuitos Date: Fri, 9 Oct 2026 22:38:09 +0800 Subject: [PATCH] fix(recall): limit text fallback to the failing request --- AGENTS.md | 4 +-- README.md | 7 +++-- src/host/v1/host.ts | 8 ++---- test/recall/structuredFallback.test.ts | 39 ++++++++++++++++++++++++-- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58925da..76e7e51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,8 +158,8 @@ bun run build # dist/ via tsconfig.build.json - Plugin state: `/opencode-memory//extraction-state.json` (+ `extraction-state.lock` around every update, + `maintenance.lock` shared by extraction forks and auto-dream across processes, + `opencode-memory.log` on V2). A v1 `.consolidate-lock` is migrated on first catch-up; a v1 shell hook still present in an rc file is reported with a warn log. - Agent names are fixed: `opencode-memory-recall`, `opencode-memory-extract`, `opencode-memory-dream`. V1's `config` hook merges defaults under whatever the user configured (`agent.`); V2's `agent.transform` does the same for `agents.`. - OpenCode dedupes `plugin` entries by package name across global/project config, last one wins — plugin options are not merged across files. -- `readOnly` (config): nothing under `CLAUDE_CONFIG_DIR` is written — `parseConfig` forces `extract` / `autodream` off, `MemoryStore` skips `mkdir` and its `save` / `delete` throw, `buildMemoryToolSpecs` drops `memory_save` / `memory_delete`, only the recall agent is registered, V2 logs nowhere, and the prompt is the read-only variant (`WHEN_TO_ACCESS_READ_ONLY`). -- V1 recall falls back to text: a `StructuredOutputError` from the json_schema fork switches that host to `buildGeneratePrompt` (`src/host/generate.ts`, shared with V2) and `parseSelectedMemories`, which also takes a bare JSON array. +- `readOnly` (config): the plugin writes nothing under `CLAUDE_CONFIG_DIR` — `parseConfig` forces `extract` / `autodream` off, `MemoryStore` skips `mkdir` and its `save` / `delete` throw, `buildMemoryToolSpecs` drops `memory_save` / `memory_delete`, only the recall agent is registered, session deletion leaves persistent state unchanged, V2 logs nowhere, and the prompt is the read-only variant (`WHEN_TO_ACCESS_READ_ONLY`). Host shell/file tools and other plugins are outside this guarantee; enforced read-only access requires host permissions or filesystem access controls. +- V1 recall falls back to text per request: a `StructuredOutputError` from the json_schema fork retries that request once with `buildGeneratePrompt` (`src/host/generate.ts`, shared with V2) and `parseSelectedMemories`, which also takes a bare JSON array. Later requests still try structured output; there is no host-wide capability cache. Models that consistently fail structured output require two attempts per recall request. - Design history for v2 lives in `docs/v2/`. ## V2 notes diff --git a/README.md b/README.md index 46d43fd..5a8e5ff 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ All behaviour is configured through OpenCode's own configuration. There are no ` - When the same plugin is listed in both the global and the project `opencode.json`, OpenCode keeps the **last** declaration (project wins); options are not merged across files. - The three agents are registered hidden with a memory-only tool sandbox. On 1.x, override any field (`model`, `steps`, `temperature`, …) under `agent.`; the plugin fills in the rest. - `CLAUDE_CONFIG_DIR` is honoured exactly like Claude Code does, and is the only environment variable the plugin reads. -- `readOnly: true` uses Claude Code's memory without ever writing to the Claude config directory: no `memory_save` / `memory_delete`, no extraction or auto-dream (whatever `extract` / `autodream` say), no plugin state or log file, and the memory folder is not created. The index, recall and `memory_list` / `memory_search` / `memory_read` keep working, and the prompt tells the model the memory is read-only. +- `readOnly: true` prevents this plugin from writing to the Claude config directory: no `memory_save` / `memory_delete`, no extraction or auto-dream (whatever `extract` / `autodream` say), no plugin state or log writes, and the memory folder is not created. The index, recall and `memory_list` / `memory_search` / `memory_read` keep working, and the prompt tells the model the memory is read-only. +- This option does not restrict the host's shell or file-editing tools, or other plugins. To enforce read-only access for the entire host, also configure host permissions or filesystem access controls; the memory prompt alone does not enforce it. ```jsonc // opencode.json — OpenCode 2.x @@ -207,9 +208,9 @@ Everything the environment variables used to control now lives under `extract`, **Can I disable extraction, auto-dream or recall?** Yes — `extract.enabled`, `autodream.enabled`, `recall.enabled` in the plugin options. -**Can OpenCode read Claude Code's memory without adding to it?** Yes — `readOnly: true`. Nothing is written under `CLAUDE_CONFIG_DIR`; see [Configuration](#-configuration). +**Can I make this plugin read Claude Code's memory without changing it?** Yes — `readOnly: true`. The plugin does not write under `CLAUDE_CONFIG_DIR`. Enforcing read-only access for the entire host also requires host permissions or filesystem access controls; see [Configuration](#-configuration). -**My recall model has no structured output.** On 1.x the selector asks for structured output first; when the server answers with a `StructuredOutputError` the plugin retries once with the schema described in the prompt and parses the text, and keeps doing that for the rest of the session. +**My recall model has no structured output.** On 1.x each selector request asks for structured output first. If the server answers with a `StructuredOutputError`, the plugin retries that request once with the schema described in the prompt and parses the text. Later requests still try structured output: a single failure does not establish that the model lacks support. A model that consistently fails structured output therefore needs two attempts per recall request. Other errors are not retried. **Why did my first answer take a moment longer?** The system prompt waits up to `recall.waitMs` for the selector. Set it to `0` to never wait (recalled memories then appear from the second LLM call of a turn onwards). diff --git a/src/host/v1/host.ts b/src/host/v1/host.ts index 41ea097..3c430eb 100644 --- a/src/host/v1/host.ts +++ b/src/host/v1/host.ts @@ -39,9 +39,6 @@ function isStructuredOutputError(error: unknown): boolean { } export function createV1Host({ client, directory, toolsFor }: V1HostOptions): MemoryHost { - // Cleared by the first StructuredOutputError: from then on this host describes the schema in the - // prompt and the caller parses the text, as the V2 host always does. - let structuredOutput = true return { async readTranscript(sessionID) { const response = await withDeadline("session.messages", SDK_READ_TIMEOUT_MS, (signal) => @@ -107,12 +104,13 @@ export function createV1Host({ client, directory, toolsFor }: V1HostOptions): Me onFinished: input.onFinished, onCleanupFailed: input.onCleanupFailed, }) - if (structuredOutput && input.schema) { + if (input.schema) { try { return forkAnswerText(await run(true)) } catch (error) { if (!isStructuredOutputError(error)) throw error - structuredOutput = false + // One failed answer does not establish a model capability. Retry only this request as + // text, leaving later requests (in this session or another) free to use structured output. } } return forkAnswerText(await run(false)) diff --git a/test/recall/structuredFallback.test.ts b/test/recall/structuredFallback.test.ts index 7a5ed59..39ecdd2 100644 --- a/test/recall/structuredFallback.test.ts +++ b/test/recall/structuredFallback.test.ts @@ -38,17 +38,52 @@ describe("recall without structured output", () => { timeoutMs: 5_000, } - test("retries as text once, then stays in text mode for that host", async () => { + test("retries as text once per request without disabling structured output for later requests", async () => { const { client, prompts } = textOnlyClient('["deploy.md"]') const host = createV1Host({ client, directory: tempGitRepo() }) expect(await host.generate(input)).toBe('["deploy.md"]') expect(await host.generate(input)).toBe('["deploy.md"]') - expect(prompts.map((body) => body.format !== undefined)).toEqual([true, false, false]) + expect(prompts.map((body) => body.format !== undefined)).toEqual([true, false, true, false]) const parts = (prompts[1]?.parts ?? []) as Array<{ text: string }> const text = parts[0]?.text expect(text).toContain("Respond with only a JSON object matching this JSON schema") }) + test.each(["ses_first", "ses_other"])( + "a transient structured-output failure does not downgrade the next request from %s", + async (parentSessionID) => { + const selector = makeSelectorClient() + const formats: boolean[] = [] + selector.raw.session.prompt = async (options?: unknown) => { + const body = (options as { body: Record }).body + formats.push(body.format !== undefined) + if (formats.length === 1) return { data: { info: { error: { name: "StructuredOutputError" } }, parts: [] } } + if (body.format !== undefined) + return { data: { info: { structured: { selected_memories: ["recovered.md"] } }, parts: [] } } + return { data: { info: {}, parts: [{ type: "text", text: '["fallback.md"]' }] } } + } + const host = createV1Host({ client: selector.client, directory: tempGitRepo() }) + expect(parseSelectedMemories(await host.generate({ ...input, parentSessionID: "ses_first" }))).toEqual([ + "fallback.md", + ]) + expect(parseSelectedMemories(await host.generate({ ...input, parentSessionID }))).toEqual(["recovered.md"]) + expect(formats).toEqual([true, false, true]) + }, + ) + + test("a failed text retry rejects without another retry", async () => { + const selector = makeSelectorClient() + const formats: boolean[] = [] + selector.raw.session.prompt = async (options?: unknown) => { + const body = (options as { body: Record }).body + formats.push(body.format !== undefined) + return { data: { info: { error: { name: "StructuredOutputError" } }, parts: [] } } + } + const host = createV1Host({ client: selector.client, directory: tempGitRepo() }) + await expect(host.generate(input)).rejects.toThrow("StructuredOutputError") + expect(formats).toEqual([true, false]) + }) + test("any other model error still fails the call", async () => { const selector = makeSelectorClient() selector.raw.session.prompt = async () => ({ data: { info: { error: { name: "APIError" } }, parts: [] } })