diff --git a/agent/tools/write_card.ts b/agent/tools/write_card.ts index a47dcf62..45aa6156 100644 --- a/agent/tools/write_card.ts +++ b/agent/tools/write_card.ts @@ -235,7 +235,8 @@ export default defineTool({ .describe( "ТОЛЬКО для SUPERSEDE: одна строка о прежней истине, переносимая в ## History, " + "в формате 'YYYY-MM-DD: факт' (своя дата сохраняется; без неё ставится сегодняшняя). " + - "На ADD отбрасывается как шум, на UPDATE/NOOP — ошибка.", + "На ADD отбрасывается как шум, на UPDATE/NOOP непустое значение — ошибка " + + "(пустая строка равна отсутствию поля).", ), confidence: z .enum(["EXTRACTED", "INFERRED", "AMBIGUOUS"]) @@ -299,8 +300,13 @@ export default defineTool({ const file = id.file; const rel = relative(VAULT(), file).split(sep).join("/"); + // Пустой/пробельный history_entry ничего не вытесняет и не подделывает архив: для UPDATE + // и NOOP он равен отсутствующему — так же, как SUPERSEDE читает его через trim(). Модели, + // заполняющие все поля схемы, шлют "" и без этого зацикливаются на одном отказе. + const historyEntry = history_entry?.trim() ? history_entry : undefined; + if (operation === "NOOP") { - if (replace_body || history_entry !== undefined) { + if (replace_body || historyEntry !== undefined) { return { ok: false, error: "NOOP не принимает replace_body или history_entry.", @@ -345,7 +351,7 @@ export default defineTool({ }); // history_entry несёт вытесненную истину, которой у ADD ещё нет: там он шум и молча // отбрасывается (с записью в журнал), а у UPDATE — попытка подделать архив. - if (history_entry !== undefined && effectiveOperation === "UPDATE") { + if (historyEntry !== undefined && effectiveOperation === "UPDATE") { return { ok: false, error: "history_entry допустим только для SUPERSEDE.", @@ -403,7 +409,9 @@ export default defineTool({ replaceBody: replace_body === true, // Сырая operation: по её отсутствию mergeCard узнаёт легаси-путь replace_body. operation, - historyEntry: history_entry, + // ADD получает сырое поле: пустую строку он отбрасывает сам и фиксирует это в журнале. + historyEntry: + effectiveOperation === "ADD" ? history_entry : historyEntry, }); if (action !== "noop") atomicWrite(file, content); if (ignoredHistoryEntry) logIgnoredHistoryEntry(); diff --git a/scripts/write-card.test.ts b/scripts/write-card.test.ts index 09e7bb09..a7750710 100644 --- a/scripts/write-card.test.ts +++ b/scripts/write-card.test.ts @@ -566,6 +566,61 @@ test("ADD отбрасывает шумовой history_entry один раз, } }); +test("UPDATE и NOOP читают пустой history_entry как отсутствующий, непустой — по-прежнему отказ", async () => { + const base = { + operation: "ADD", + type: "note", + title: "Blank history entry", + description: "пустое поле не подделывает архив", + tags: ["note", "blank"], + body: "Текущая истина.", + }; + const created = await call(base); + assert.equal(created.ok, true); + // Тела разные намеренно: повтор уже лежащего факта даёт "updated", а не "merged". + for (const [blank, fact] of [ + ["", "Первый новый факт."], + [" ", "Второй новый факт."], + ]) { + const updated = await call({ + ...base, + operation: "UPDATE", + body: fact, + history_entry: blank, + }); + assert.equal(updated.ok, true, updated.error); + assert.equal(updated.action, "merged"); + const noop = await call({ + ...base, + operation: "NOOP", + history_entry: blank, + }); + assert.equal(noop.ok, true, noop.error); + assert.equal(noop.action, "noop"); + } + const out = read(created.file); + assert.doesNotMatch(out, /^## History$/gm); + assert.match(out, /^## Log$/m); + + const before = out; + const forged = await call({ + ...base, + operation: "UPDATE", + body: "Ещё факт.", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forged.ok, false); + assert.match(forged.error, /допустим только для SUPERSEDE/); + assert.equal(read(created.file), before); + const forgedNoop = await call({ + ...base, + operation: "NOOP", + history_entry: "2026-01-01: прежняя истина", + }); + assert.equal(forgedNoop.ok, false); + assert.match(forgedNoop.error, /NOOP не принимает/); +}); + test("ADD отбрасывает пустой и длинный однострочный history_entry, не создавая пустых полей", async () => { const previousWarn = console.warn; const warnings: string[] = [];