Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <page-path>---` / `---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`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
13 changes: 13 additions & 0 deletions src/knowledge-record.test-fixture.ts
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions src/knowledge-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 19 additions & 2 deletions src/knowledge-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <page-path>--- 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) })

/**
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions src/write-protocol.test.ts
Original file line number Diff line number Diff line change
@@ -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: [],
})
})
})
7 changes: 6 additions & 1 deletion src/write-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down