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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## 19.0.0

Knowledge tools require the digest from knowledge_read when updating an existing page.
Stale writes refuse the complete proposal under the shared mutation lock.
An identical retry preserves the completed write.
Write transactions retain the host actor and run identity, with unknown authors explicitly null.
Knowledge tools retain prior and new page bytes by default; applications can explicitly disable history.
Lower-level proposal writers can select the same conditional-write contract.

# Changelog

## 18.0.0 — 2026-09-29
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,21 @@ const tools = createKnowledgeTools({
`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.
Read an existing page before editing it.
Pass expectedPageDigests with the page path and pageDigest returned by knowledge_read.
The write checks each digest under the store lock.
A stale edit refuses the whole proposal and names the current digest.
New pages need no digest; an explicit null requires the path to be absent.
Retrying an identical completed write is safe.
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`.
Knowledge tools retain write history by default.
Each transaction records the host actorId, runId, and exact prior and new bytes.
An absent author remains explicitly null.
An application can disable history with retainHistory: false.
Lower-level writers opt into history with retainHistory: true and conditional edits with expectedPageDigests.
Completed write transactions then retain their existing manifest and before/after snapshots under `.agent-knowledge/history/<transactionId>`.
The move is atomic and a repeated finish after a lost acknowledgement is idempotent.
The default remains cleanup after completion, so callers choose retention deliberately and account for its unbounded storage growth.
Expand Down
2 changes: 1 addition & 1 deletion api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"AgentMemoryWriteInput": "type 7f41599c27b7",
"AgentMemoryWriteInputSchema": "value 373728f5643d",
"AgentMemoryWriteResult": "type 4c444521a5ca",
"ApplyKnowledgeWriteBlocksOptions": "type 2961ecd91d81",
"ApplyKnowledgeWriteBlocksOptions": "type 20e42ffb8b0d",
"ApplyWriteBlocksResult": "type f4bdaec8e709",
"AuditKnowledgeCitationsOptions": "type 04e80cd4835a",
"Bm25Hit": "type a6c513ea2447",
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": "18.0.0",
"version": "19.0.0",
"description": "Build, search, evaluate, and improve source-backed knowledge bases.",
"homepage": "https://github.com/tangle-network/agent-knowledge#readme",
"repository": {
Expand Down
18 changes: 18 additions & 0 deletions src/file-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const transactionSchema = z
transactionId: z.string().uuid(),
purpose: z.string().min(1),
recoveryOwner: z.string().min(1).max(256).optional(),
actorId: z.string().trim().min(1).max(256).nullable().optional(),
runId: z.string().trim().min(1).max(256).nullable().optional(),
pagesDirectory: pagesDirectorySchema.optional(),
researchState: z.boolean().optional(),
retainHistory: z.boolean().optional(),
Expand Down Expand Up @@ -102,13 +104,17 @@ export interface KnowledgeFileMutation {
path: string
content: string | Buffer | null
mode?: number
/** Compare the exact prior bytes under the transaction lock; null requires a new file. */
expectedBeforeHash?: string | null
}

export async function prepareKnowledgeFileTransaction(input: {
root: string
transactionRoot: string
purpose: string
recoveryOwner?: string
actorId?: string
runId?: string
mutations: readonly KnowledgeFileMutation[]
/** Pages directory the mutations may write under; defaults to `knowledge`. */
pagesDirectory?: string
Expand Down Expand Up @@ -147,6 +153,12 @@ export async function prepareKnowledgeFileTransaction(input: {
}
paths.add(path)
const before = await withSafeDescendant(input.root, path, readRegularFile)
if (mutation.expectedBeforeHash !== undefined) {
const expected = digestSchema.nullable().parse(mutation.expectedBeforeHash)
const actual = before ? hashBytes(before.bytes) : null
if (actual !== expected)
throw new Error(`knowledge file changed before transaction: ${path}`)
}
const after =
mutation.content === null
? null
Expand Down Expand Up @@ -201,6 +213,8 @@ export async function prepareKnowledgeFileTransaction(input: {
kind: 'knowledge-file-transaction',
transactionId: randomUUID(),
purpose: input.purpose,
actorId: input.actorId ?? null,
runId: input.runId ?? null,
...(input.recoveryOwner ? { recoveryOwner: input.recoveryOwner } : {}),
...(pagesDirectory === undefined ? {} : { pagesDirectory }),
...(input.researchState === undefined ? {} : { researchState: input.researchState }),
Expand Down Expand Up @@ -232,6 +246,8 @@ export async function commitKnowledgeFileMutations(input: {
root: string
transactionRoot: string
purpose: string
actorId?: string
runId?: string
mutations: readonly KnowledgeFileMutation[]
/** Pages directory the mutations may write under; defaults to `knowledge`. */
pagesDirectory?: string
Expand All @@ -253,6 +269,8 @@ export async function commitKnowledgeFileMutations(input: {
root: input.root,
transactionRoot: input.transactionRoot,
purpose: input.purpose,
actorId: input.actorId,
runId: input.runId,
mutations: input.mutations,
...(input.researchState === undefined ? {} : { researchState: input.researchState }),
...(input.retainHistory === undefined ? {} : { retainHistory: input.retainHistory }),
Expand Down
17 changes: 14 additions & 3 deletions src/knowledge-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ToolDefinition } from '@tangle-network/agent-interface'
import { sha256DigestSchema, type ToolDefinition } from '@tangle-network/agent-interface'
import { z } from 'zod'
import {
parseKnowledgeCitationReference,
Expand All @@ -28,6 +28,7 @@ import {
type KnowledgeRetrievalReceipt,
knowledgeVisibilityArtifactRef,
} from './knowledge-use-receipts'
import { knowledgePageDigest } from './knowledge-visibility'
import { withKnowledgeMutation } from './mutation-lock'
import { normalizePagesDirectory } from './pages-directory'
import { applyKnowledgeWriteBlocks, type KnowledgeWriteIntakeRequest } from './proposals'
Expand Down Expand Up @@ -69,6 +70,12 @@ const searchInput = z.object({
})
const readInput = z.object({ pageId: z.string().min(1) })
const recordInput = z.object({
expectedPageDigests: z
.record(z.string().min(1), sha256DigestSchema.nullable())
.optional()
.describe(
'For each existing page, pass its path and pageDigest from knowledge_read. Null requires a new page. Missing entries permit new pages only.',
),
proposal: z
.string()
.min(1)
Expand Down Expand Up @@ -160,6 +167,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool
origin: resolution.resolved.origin,
path: resolution.resolved.page.path,
title: resolution.resolved.page.title,
pageDigest: knowledgePageDigest(resolution.resolved.page),
text: resolution.resolved.page.text,
},
candidates: resolution.candidates.map((candidate) => ({
Expand All @@ -173,7 +181,7 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool

tool(
'knowledge_record',
`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.`,
`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. To update a page, first knowledge_read it and pass expectedPageDigests[path] = pageDigest. Stale edits and malformed, unsafe, or empty proposals are rejected before writing any pages.`,
recordInput,
async (input) => {
const parsed = parseKnowledgeWriteBlocks(input.proposal, [`${pagesDirectory}/`])
Expand All @@ -186,7 +194,10 @@ export function createKnowledgeTools(options: CreateKnowledgeToolsOptions): Tool
const intake = options.intake
return applyKnowledgeWriteBlocks(stores.storePath(runId), input.proposal, {
...pages,
retainHistory: options.retainHistory,
actorId: options.actorId,
runId,
expectedPageDigests: input.expectedPageDigests ?? {},
retainHistory: options.retainHistory ?? true,
...(intake === undefined
? {}
: { intake: { ...intake, inheritedPages: await inheritedOf(stores, runId) } }),
Expand Down
81 changes: 75 additions & 6 deletions src/proposals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { contentHash } from '@tangle-network/agent-eval'
import { commitKnowledgeFileMutations } from './file-transaction'
import type { Sha256Digest } from '@tangle-network/agent-interface'
import { isMissingFile, readRegularFileWithinRoot } from './durable-fs'
import { commitKnowledgeFileMutations, type KnowledgeFileMutation } from './file-transaction'
import { knowledgePageDigest } from './knowledge-visibility'
import { withKnowledgeMutation } from './mutation-lock'
import { type KnowledgePagesOptions, normalizePagesDirectory } from './pages-directory'
import { type OriginatedPage, originatedPages } from './run-scoped'
Expand All @@ -24,6 +28,11 @@ export type KnowledgeWriteIntakeRequest = Omit<KnowledgeWriteIntakeOptions, 'vis
}

export interface ApplyKnowledgeWriteBlocksOptions extends KnowledgePagesOptions {
/** Host-provided identity, never inferred from authored page content. */
readonly actorId?: string
readonly runId?: string
/** Expected page identities by proposal path; null requires a new page. */
readonly expectedPageDigests?: Readonly<Record<string, Sha256Digest | null>>
/** Preserve terminal transactions under .agent-knowledge/history; no automatic deletion. */
readonly retainHistory?: boolean
/**
Expand All @@ -48,16 +57,74 @@ export async function applyKnowledgeWriteBlocks(
): Promise<ApplyWriteBlocksResult> {
const pagesDirectory = normalizePagesDirectory(options.pagesDirectory)
const parsed = parseKnowledgeWriteBlocks(proposalText, [`${pagesDirectory}/`])
const purpose = `knowledge-proposal:${contentHash(parsed.blocks)}`
const identity =
options.actorId === undefined &&
options.runId === undefined &&
options.expectedPageDigests === undefined
? parsed.blocks
: {
blocks: parsed.blocks,
actorId: options.actorId ?? null,
runId: options.runId ?? null,
expectedPageDigests: options.expectedPageDigests ?? null,
}
const purpose = 'knowledge-proposal:' + contentHash(identity)
const intake = options.intake
return withKnowledgeMutation(
root,
async (lock) => {
if (parsed.blocks.length > 0) {
const mutations = parsed.blocks.map((block) => ({
path: block.path,
content: block.content.endsWith('\n') ? block.content : `${block.content}\n`,
}))
const mutations: Array<KnowledgeFileMutation & { content: string }> = parsed.blocks.map(
(block) => ({
path: block.path,
content: block.content.endsWith('\n') ? block.content : `${block.content}\n`,
}),
)
if (options.expectedPageDigests !== undefined) {
const paths = new Set(mutations.map((mutation) => mutation.path))
for (const path of Object.keys(options.expectedPageDigests)) {
if (!paths.has(path)) throw new Error(`Expected digest has no proposed page: ${path}`)
}
for (const mutation of mutations) {
let before: Awaited<ReturnType<typeof readRegularFileWithinRoot>> | undefined
try {
before = await readRegularFileWithinRoot(root, mutation.path)
} catch (error) {
if (!isMissingFile(error)) throw error
}
const current =
before === undefined
? null
: knowledgePageDigest(
knowledgePageFromMarkdown(
mutation.path,
Buffer.from(before.bytes).toString('utf8'),
pagesDirectory,
),
)
const expected = Object.hasOwn(options.expectedPageDigests, mutation.path)
? options.expectedPageDigests[mutation.path]
: null
if (expected !== null && !/^sha256:[a-f0-9]{64}$/.test(expected ?? '')) {
throw new Error(`Invalid expected page digest: ${mutation.path}`)
}
// A lost acknowledgement can retry the exact completed write safely.
if (
current !== expected &&
!(
!(Object.hasOwn(options.expectedPageDigests, mutation.path) && expected === null) &&
before !== undefined &&
Buffer.from(before.bytes).equals(Buffer.from(mutation.content ?? ''))
)
) {
throw new Error(
`knowledge page changed: ${mutation.path}; current digest: ${current ?? 'absent'}. Read the current page before editing it.`,
)
}
mutation.expectedBeforeHash =
before === undefined ? null : createHash('sha256').update(before.bytes).digest('hex')
}
}
if (intake) {
const { inheritedPages = [], ...settings } = intake
const here = await loadKnowledgePages(root, { pagesDirectory })
Expand All @@ -77,6 +144,8 @@ export async function applyKnowledgeWriteBlocks(
root,
transactionRoot: lock.transactionRoot,
purpose,
actorId: options.actorId,
runId: options.runId,
mutations,
pagesDirectory,
retainHistory: options.retainHistory,
Expand Down
26 changes: 0 additions & 26 deletions tests/file-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,32 +454,6 @@ describe('knowledge file transactions', () => {
})
})

it('keeps the default journal free of a pages directory field', async () => {
await withRoot(async (root) => {
const transactionRoot = join(root, '.agent-knowledge', 'file-transactions')
const prepared = await prepareKnowledgeFileTransaction({
root,
transactionRoot,
purpose: 'default-pages',
mutations: [{ path: 'knowledge/page.md', content: '# Page\n' }],
})
expect(prepared).not.toHaveProperty('pagesDirectory')
const journal = JSON.parse(
await readFile(
join(transactionRoot, `active-${prepared!.transactionId}`, 'transaction.json'),
'utf8',
),
) as Record<string, unknown>
expect(Object.keys(journal).sort()).toEqual([
'createdAt',
'entries',
'kind',
'purpose',
'transactionId',
])
})
})

it('rejects a forged pages directory in a recovery journal', async () => {
await withRoot(async (root) => {
const packagePath = join(root, 'package.json')
Expand Down
Loading