From b9b590e0bd3734cd2bf671b725180e97d00179a7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 19:44:44 -0700 Subject: [PATCH] fix(knowledge): reject malformed record proposals before writing --- CHANGELOG.md | 7 ++++ README.md | 4 ++ package.json | 2 +- src/knowledge-record.test-fixture.ts | 13 ++++++ src/knowledge-tools.test.ts | 59 ++++++++++++++++++++++++++++ src/knowledge-tools.ts | 21 +++++++++- src/write-protocol.test.ts | 48 ++++++++++++++++++++++ src/write-protocol.ts | 7 +++- 8 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 src/knowledge-record.test-fixture.ts create mode 100644 src/write-protocol.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 897faad..9d8f8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 17.0.2 — 2026-09-16 + +`knowledge_record` documents the complete FILE block grammar and rejects malformed, unsafe, or empty proposals before writing. +The parser no longer absorbs a later page into an unterminated earlier block. +Delimiters inside code fences remain page content. +Direct `applyKnowledgeWriteBlocks` callers retain the explicit partial-result contract and must inspect `written` and `warnings`. + ## 17.0.1 — 2026-09-15 Requires Eval `>=0.182.0 <0.183.0` and tests against 0.182.0. diff --git a/README.md b/README.md index 6817248..b2d0a65 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,10 @@ const tools = createKnowledgeTools({ `knowledge_search` builds a brief and mints a retrieval receipt on every call, so retrieval is recorded by the infrastructure rather than claimed by the run. `knowledge_read` reports an id visible at two origins as `ambiguous` with both candidates, and never chooses one. `knowledge_record` writes into this run's store through the intake gate. +Each proposal must contain complete `---FILE: ---` / `---END FILE---` blocks, with delimiters on separate lines. +The tool rejects malformed, unsafe, or empty proposals before writing any pages. +It does not report a partial write as tool success. +The lower-level `applyKnowledgeWriteBlocks` API retains its explicit `written` and `warnings` result for callers that inspect partial proposals. `knowledge_resolve` returns the resolution status of each reference. When a pursuit must preserve every edit for later refinement or branch reconciliation, pass `retainHistory: true`. diff --git a/package.json b/package.json index 9f4385d..7ff68dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "17.0.1", + "version": "17.0.2", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { diff --git a/src/knowledge-record.test-fixture.ts b/src/knowledge-record.test-fixture.ts new file mode 100644 index 0000000..c90643c --- /dev/null +++ b/src/knowledge-record.test-fixture.ts @@ -0,0 +1,13 @@ +// Exact public arguments from mech-interp-foundations-pi-20260915k's first result, +// blob sha256:0cce0601a94050e27acd4c918b2b7bb9037265f43bfc5a38408140b3a177882b. +export const malformedKnowledgeRecordCalls = [ + { + callId: 'call_c7f00a4f364547e689127ba0', + proposal: '---FILE: pages/glm-b/tmp-format-probe.md---\nprobe line one\n---END---', + }, + { + callId: 'call_c41bc0d3c0644fb0936b78f3', + proposal: + '---FILE: pages/glm-b/tmp-format-probe-a.md---\nprobe a\n---FILE-END---\n---FILE: pages/glm-b/tmp-format-probe-b.md---\nprobe b\n---END FILE---\n---FILE: pages/glm-b/tmp-format-probe-c.md---\nprobe c\n---EOF---', + }, +] as const diff --git a/src/knowledge-tools.test.ts b/src/knowledge-tools.test.ts index 530cc05..88e958e 100644 --- a/src/knowledge-tools.test.ts +++ b/src/knowledge-tools.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import type { ToolDefinition } from '@tangle-network/agent-interface' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { KnowledgeCitationResolutionError } from './citation-resolution' +import { malformedKnowledgeRecordCalls } from './knowledge-record.test-fixture' import { createKnowledgeTools } from './knowledge-tools' import { assertKnowledgeRetrievalMatchesVisibility, @@ -121,6 +122,64 @@ describe('createKnowledgeTools', () => { expect(written.written).toEqual(['knowledge/claim.md']) }) + it.each(malformedKnowledgeRecordCalls)( + 'rejects archived malformed write $callId without writing any page', + async ({ proposal }) => { + const scoped = createRunScopedStores({ root, pagesDirectory: 'pages' }) + await scoped.init('record-regression') + const record = createKnowledgeTools({ + stores: scoped, + runId: 'record-regression', + retrieverVersion: 'test', + pagesDirectory: 'pages', + }).find((entry) => entry.name === 'knowledge_record')! + const pages = scoped.storePath('record-regression') + const before = await readdir(pages, { recursive: true }) + + await expect(record.handler({ proposal }, {})).rejects.toThrow('---END FILE---') + + expect(await readdir(pages, { recursive: true })).toEqual(before) + }, + ) + + it.each([ + 'Recorded the claim.', + '---FILE: knowledge/claim.md---\nClaim\n---FILE-END---', + '---FILE: ../escape.md---\nClaim\n---END FILE---', + '---FILE: knowledge/valid.md---\nValid\n---END FILE---\n---FILE: knowledge/open.md---\nOpen', + ])('fails closed on an invalid or empty proposal: %s', async (proposal) => { + const pages = join(stores.storePath('run-a'), 'knowledge') + const before = await readdir(pages) + await expect(call('knowledge_record', { proposal })).rejects.toThrow('knowledge_record') + expect(await readdir(pages)).toEqual(before) + }) + + it('advertises and accepts the complete FILE grammar under the configured directory', async () => { + const scoped = createRunScopedStores({ root, pagesDirectory: 'pages' }) + await scoped.init('record-valid') + const record = createKnowledgeTools({ + stores: scoped, + runId: 'record-valid', + retrieverVersion: 'test', + pagesDirectory: 'pages', + }).find((entry) => entry.name === 'knowledge_record')! + expect(record.description).toContain('---FILE: pages/example.md---') + expect(record.description).toContain('---END FILE---') + expect(JSON.stringify(record.inputSchemaJson)).toContain('---END FILE---') + + const proposal = malformedKnowledgeRecordCalls[0].proposal.replace( + '---END---', + '---END FILE---', + ) + expect(await record.handler({ proposal }, {})).toEqual({ + written: ['pages/glm-b/tmp-format-probe.md'], + warnings: [], + }) + await expect( + readFile(join(scoped.storePath('record-valid'), 'pages/glm-b/tmp-format-probe.md'), 'utf8'), + ).resolves.toBe('probe line one\n') + }) + it('reports an id visible at two origins instead of choosing one', async () => { await writePage(stores.storePath('run-a'), 'budget', 'The run-local version of the budget.') await initKnowledgeBase(shared) diff --git a/src/knowledge-tools.ts b/src/knowledge-tools.ts index 27bef37..29708b7 100644 --- a/src/knowledge-tools.ts +++ b/src/knowledge-tools.ts @@ -29,8 +29,10 @@ import { knowledgeVisibilityArtifactRef, } from './knowledge-use-receipts' import { withKnowledgeMutation } from './mutation-lock' +import { normalizePagesDirectory } from './pages-directory' import { applyKnowledgeWriteBlocks, type KnowledgeWriteIntakeRequest } from './proposals' import type { OriginatedPage, RunScopedStores } from './run-scoped' +import { parseKnowledgeWriteBlocks } from './write-protocol' export interface CreateKnowledgeToolsOptions { readonly stores: RunScopedStores @@ -60,7 +62,14 @@ const searchInput = z.object({ limit: z.int().min(1).max(50).optional(), }) const readInput = z.object({ pageId: z.string().min(1) }) -const recordInput = z.object({ proposal: z.string().min(1) }) +const recordInput = z.object({ + proposal: z + .string() + .min(1) + .describe( + 'One or more complete FILE blocks. Each begins with ---FILE: --- and ends with ---END FILE---, each on its own line. Put the page content between them. Every block must be valid; malformed or empty proposals write nothing and return an error.', + ), +}) const resolveInput = z.object({ references: z.array(z.string().min(1)).min(1) }) /** @@ -80,6 +89,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool } const pages = options.pagesDirectory === undefined ? {} : { pagesDirectory: options.pagesDirectory } + const pagesDirectory = normalizePagesDirectory(options.pagesDirectory) return [ tool( @@ -152,9 +162,16 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool tool( 'knowledge_record', - 'Write pages into the store of this run from ---FILE: ...--- blocks.', + `Write pages into this run's store. Use complete blocks exactly like:\n---FILE: ${pagesDirectory}/example.md---\n# Example\nPage content.\n---END FILE---\nUse paths under ${pagesDirectory}/. Both delimiters must be on their own lines. Malformed, unsafe, or empty proposals are rejected before writing any pages.`, recordInput, async (input) => { + const parsed = parseKnowledgeWriteBlocks(input.proposal, [`${pagesDirectory}/`]) + // Tool success must mean the complete proposal was admitted, not a silent partial write. + if (parsed.blocks.length === 0 || parsed.warnings.length > 0) { + throw new Error( + `knowledge_record rejected the proposal without writing any pages. Use ---FILE: ${pagesDirectory}/example.md--- followed by content and ---END FILE---, each delimiter on its own line. ${parsed.warnings.join(' ')}`, + ) + } const intake = options.intake return applyKnowledgeWriteBlocks(stores.storePath(runId), input.proposal, { ...pages, diff --git a/src/write-protocol.test.ts b/src/write-protocol.test.ts new file mode 100644 index 0000000..ae72196 --- /dev/null +++ b/src/write-protocol.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { malformedKnowledgeRecordCalls } from './knowledge-record.test-fixture' +import { parseKnowledgeWriteBlocks } from './write-protocol' + +describe('knowledge FILE boundaries', () => { + it('does not swallow the next page when the previous block lacks a closer', () => { + const parsed = parseKnowledgeWriteBlocks(malformedKnowledgeRecordCalls[1].proposal, ['pages/']) + + expect(parsed.blocks).toEqual([ + { path: 'pages/glm-b/tmp-format-probe-b.md', content: 'probe b' }, + ]) + expect(parsed.warnings).toHaveLength(2) + expect(parsed.warnings[0]).toContain('next FILE block') + expect(parsed.warnings[0]).toContain('---END FILE---') + }) + + it.each(['```', '~~~'])('preserves FILE delimiters inside %s fences', (fence) => { + const content = [ + '# Write syntax', + fence, + '---FILE: knowledge/example.md---', + 'Example content', + '---END FILE---', + fence, + ].join('\n') + const parsed = parseKnowledgeWriteBlocks( + `---FILE: knowledge/syntax.md---\n${content}\n---END FILE---`, + ) + + expect(parsed).toEqual({ + blocks: [{ path: 'knowledge/syntax.md', content }], + warnings: [], + }) + }) + + it('preserves case-insensitive delimiters, whitespace, CRLF, and valid adjacent pages', () => { + const parsed = parseKnowledgeWriteBlocks( + '--- file: knowledge/a.md ---\r\nA\r\n--- end file ---\r\n---FILE: knowledge/b.md---\r\nB\r\n---END FILE---', + ) + expect(parsed).toEqual({ + blocks: [ + { path: 'knowledge/a.md', content: 'A' }, + { path: 'knowledge/b.md', content: 'B' }, + ], + warnings: [], + }) + }) +}) diff --git a/src/write-protocol.ts b/src/write-protocol.ts index 5274b87..dc9b5ce 100644 --- a/src/write-protocol.ts +++ b/src/write-protocol.ts @@ -62,12 +62,17 @@ export function parseKnowledgeWriteBlocks( i++ break } + // A later page must not become content in an unterminated earlier page. + if (fenceMarker === null && OPENER_LINE.test(line)) break contentLines.push(line) i++ } if (!closed) { - warnings.push(`FILE block "${path || '(empty)'}" was not closed before end of stream.`) + const boundary = i < lines.length ? 'next FILE block' : 'end of stream' + warnings.push( + `FILE block "${path || '(empty)'}" was not closed before ${boundary}. Expected ---END FILE--- on its own line.`, + ) continue } if (!isSafeKnowledgePath(path, allowedPrefixes)) {