From 45c2d1b308dd1714d57985ebc8ec3b7a488b2035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:41:42 +0800 Subject: [PATCH 1/6] docs: plan turn-scoped operation journal --- .../692-tool-operation-journal-turn-scoped.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .kun/review-plans/692-tool-operation-journal-turn-scoped.md diff --git a/.kun/review-plans/692-tool-operation-journal-turn-scoped.md b/.kun/review-plans/692-tool-operation-journal-turn-scoped.md new file mode 100644 index 000000000..9638c6312 --- /dev/null +++ b/.kun/review-plans/692-tool-operation-journal-turn-scoped.md @@ -0,0 +1,31 @@ +# PR plan: turn-scoped tool operation journal + +Source rejection: KunAgent/Kun#692. + +## Problem + +The rejected operation journal identity used `threadId + callId`. Compatibility paths can generate fallback call IDs such as `call_1` across multiple turns in the same thread. That makes a later turn reuse or collide with an earlier turn's journal entry. + +## Implementation direction + +1. Add a journal identity that includes at least `threadId`, `turnId`, `callId`, `toolName`, and `argsHash`. +2. Never reuse completed results across different turns, even when the fallback call ID is the same. +3. Store execution state transitions: started, completed, failed, unknown/interrupted. +4. Only reuse results when thread, turn, call ID, tool name, and args hash all match. +5. Treat unknown outcomes for non-idempotent tools conservatively and require explicit retry handling. + +## Files expected to change + +- `kun/src/reliability/operation-journal.ts` +- `kun/src/reliability/operation-journal.test.ts` +- `kun/src/adapters/tool/local-tool-host.ts` +- `kun/src/adapters/tool/local-tool-host.operation-journal.test.ts` +- Runtime factory wiring if the journal is injected. + +## Required tests + +- Same thread, different turns, same fallback `call_1` do not collide. +- Same thread, same turn, same call ID, same tool, same args can reuse. +- Same call ID with different args does not reuse. +- Same call ID with different tool does not reuse. +- Interrupted/unknown non-idempotent tool results are not silently replayed. From 1d6527ef9cf0c0ebb2ae05716c2890be35735861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:05:49 +0800 Subject: [PATCH 2/6] feat(tools): add turn-scoped operation journal --- kun/src/reliability/operation-journal.ts | 163 +++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 kun/src/reliability/operation-journal.ts diff --git a/kun/src/reliability/operation-journal.ts b/kun/src/reliability/operation-journal.ts new file mode 100644 index 000000000..83e3bb064 --- /dev/null +++ b/kun/src/reliability/operation-journal.ts @@ -0,0 +1,163 @@ +import { createHash } from 'node:crypto' + +export type ToolOperationIdentity = { + threadId: string + turnId: string + callId: string + toolName: string + argsHash: string +} + +export type ToolOperationResult = { + output: unknown + isError?: boolean +} + +export type ToolOperationRecord = + | { + status: 'started' + identity: ToolOperationIdentity + startedAt: string + } + | { + status: 'completed' + identity: ToolOperationIdentity + startedAt: string + completedAt: string + result: ToolOperationResult + } + | { + status: 'failed' + identity: ToolOperationIdentity + startedAt: string + failedAt: string + error: string + } + | { + status: 'unknown' + identity: ToolOperationIdentity + startedAt: string + updatedAt: string + reason: string + } + +export type ToolOperationJournalOptions = { + nowIso?: () => string +} + +export class ToolOperationJournal { + private readonly records = new Map() + private readonly nowIso: () => string + + constructor(options: ToolOperationJournalOptions = {}) { + this.nowIso = options.nowIso ?? (() => new Date().toISOString()) + } + + static argsHash(args: Record): string { + return createHash('sha256').update(stableStringify(args)).digest('hex') + } + + static key(identity: ToolOperationIdentity): string { + return [ + identity.threadId, + identity.turnId, + identity.callId, + identity.toolName, + identity.argsHash + ].join('\u0000') + } + + get(identity: ToolOperationIdentity): ToolOperationRecord | undefined { + return this.records.get(ToolOperationJournal.key(identity)) + } + + getCompleted(identity: ToolOperationIdentity): ToolOperationResult | null { + const record = this.get(identity) + return record?.status === 'completed' ? record.result : null + } + + begin(identity: ToolOperationIdentity): void { + const key = ToolOperationJournal.key(identity) + const existing = this.records.get(key) + if (existing?.status === 'completed') return + this.records.set(key, { + status: 'started', + identity, + startedAt: this.nowIso() + }) + } + + complete(identity: ToolOperationIdentity, result: ToolOperationResult): void { + const key = ToolOperationJournal.key(identity) + const existing = this.records.get(key) + this.records.set(key, { + status: 'completed', + identity, + startedAt: existing?.startedAt ?? this.nowIso(), + completedAt: this.nowIso(), + result + }) + } + + fail(identity: ToolOperationIdentity, error: unknown): void { + const key = ToolOperationJournal.key(identity) + const existing = this.records.get(key) + this.records.set(key, { + status: 'failed', + identity, + startedAt: existing?.startedAt ?? this.nowIso(), + failedAt: this.nowIso(), + error: error instanceof Error ? error.message : String(error) + }) + } + + unknown(identity: ToolOperationIdentity, reason: string): void { + const key = ToolOperationJournal.key(identity) + const existing = this.records.get(key) + this.records.set(key, { + status: 'unknown', + identity, + startedAt: existing?.startedAt ?? this.nowIso(), + updatedAt: this.nowIso(), + reason + }) + } + + clear(): void { + this.records.clear() + } +} + +export function createToolOperationIdentity(input: { + threadId: string + turnId: string + callId: string + toolName: string + args: Record +}): ToolOperationIdentity { + return { + threadId: input.threadId, + turnId: input.turnId, + callId: input.callId, + toolName: input.toolName, + argsHash: ToolOperationJournal.argsHash(input.args) + } +} + +function stableStringify(value: unknown): string { + if (value === null) return 'null' + const type = typeof value + if (type === 'string') return JSON.stringify(value) + if (type === 'number' || type === 'boolean') return JSON.stringify(value) + if (type === 'bigint') return JSON.stringify(value.toString()) + if (type === 'undefined') return '"[undefined]"' + if (type === 'function') return '"[function]"' + if (type === 'symbol') return JSON.stringify(String(value)) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + if (value instanceof Date) return JSON.stringify(value.toISOString()) + if (value && type === 'object') { + const object = value as Record + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(',')}}` + } + return JSON.stringify(String(value)) +} From 5ce349a2318a7f4e58fe8b409f04c3d947556a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:06:56 +0800 Subject: [PATCH 3/6] feat(tools): integrate turn-scoped operation journal --- kun/src/adapters/tool/local-tool-host.ts | 239 +++++++++-------------- 1 file changed, 91 insertions(+), 148 deletions(-) diff --git a/kun/src/adapters/tool/local-tool-host.ts b/kun/src/adapters/tool/local-tool-host.ts index ef84b68f5..df5a0b65b 100644 --- a/kun/src/adapters/tool/local-tool-host.ts +++ b/kun/src/adapters/tool/local-tool-host.ts @@ -28,6 +28,11 @@ import { type ReadTrackerOptions } from './read-tracker.js' import { sandboxBlockForTool, type SandboxBlock } from './sandbox-policy.js' +import { + createToolOperationIdentity, + ToolOperationJournal, + type ToolOperationIdentity +} from '../../reliability/operation-journal.js' /** * A single registered tool. Tools are pure functions that observe the @@ -67,6 +72,11 @@ export type LocalToolHostOptions = { hooks?: readonly ResolvedHook[] /** Runtime read-before-edit guard. Disabled by default for direct unit use. */ readTracker?: boolean | ReadTrackerOptions + /** + * Turn-scoped operation journal. Defaults to an in-memory journal so fallback + * call ids such as `call_1` are isolated by turnId/toolName/argsHash. + */ + operationJournal?: ToolOperationJournal } /** @@ -89,12 +99,14 @@ export class LocalToolHost implements ToolHost { private readonly allowList: Set private hooks: readonly ResolvedHook[] private readonly readTracker: ReadTracker + private readonly operationJournal: ToolOperationJournal constructor(options: LocalToolHostOptions) { this.registry = options.registry ?? CapabilityRegistry.fromLocalTools(options.tools ?? []) this.allowList = new Set(options.allowList ?? []) this.hooks = options.hooks ?? [] this.readTracker = new ReadTracker(normalizeReadTrackerOptions(options.readTracker)) + this.operationJournal = options.operationJournal ?? new ToolOperationJournal() } replaceRuntimeComponents(input: { @@ -197,6 +209,23 @@ export class LocalToolHost implements ToolHost { if (context.abortSignal.aborted) { throw new Error('tool call aborted while waiting for approval') } + + const operationIdentity = createToolOperationIdentity({ + threadId: context.threadId, + turnId: context.turnId, + callId: activeCall.callId, + toolName: activeCall.toolName, + args: activeCall.arguments + }) + const replayed = this.operationJournal.getCompleted(operationIdentity) + if (replayed) { + return { + item: this.completedToolResult(context, activeCall, tool, replayed.output, replayed.isError), + approved: !needsApproval + } + } + this.operationJournal.begin(operationIdentity) + let result: Awaited> try { result = await tool.execute(activeCall.arguments, context, async (update) => { @@ -218,7 +247,11 @@ export class LocalToolHost implements ToolHost { // A tool blowing up (an MCP server returning a protocol error, a // provider bug) is feedback for the model, not a reason to kill the // whole turn. Only abort keeps propagating. - if (context.abortSignal.aborted) throw error + if (context.abortSignal.aborted) { + this.operationJournal.unknown(operationIdentity, 'tool call aborted during execution') + throw error + } + this.operationJournal.fail(operationIdentity, error) const message = error instanceof Error ? error.message : String(error) return { item: this.errorToolResult(context, activeCall, tool, message, 'tool_execution_failed'), @@ -233,6 +266,7 @@ export class LocalToolHost implements ToolHost { result }) } catch (error) { + this.operationJournal.fail(operationIdentity, error) return { item: this.errorToolResult(context, activeCall, tool, hookErrorMessage(error), 'hook_failed'), approved: true @@ -248,16 +282,8 @@ export class LocalToolHost implements ToolHost { isError }) if (!isError) output = await offloadLargeToolOutput(output, activeCall.toolName, context) - const item = makeToolResultItem({ - id: `item_${activeCall.callId}`, - turnId: context.turnId, - threadId: context.threadId, - callId: activeCall.callId, - toolName: activeCall.toolName, - toolKind: activeCall.toolKind ?? tool.toolKind, - output, - isError - }) + this.operationJournal.complete(operationIdentity, { output, isError }) + const item = this.completedToolResult(context, activeCall, tool, output, isError) return { item, approved: !needsApproval } } @@ -312,6 +338,25 @@ export class LocalToolHost implements ToolHost { return `Run ${call.toolName}(${args})` } + private completedToolResult( + context: ToolHostContext, + call: ToolCallLike, + tool: LocalTool, + output: unknown, + isError?: boolean + ): TurnItem { + return makeToolResultItem({ + id: `item_${call.callId}`, + turnId: context.turnId, + threadId: context.threadId, + callId: call.callId, + toolName: call.toolName, + toolKind: call.toolKind ?? tool.toolKind, + output, + isError + }) + } + private errorToolResult( context: ToolHostContext, call: ToolCallLike, @@ -430,164 +475,62 @@ function createUserInputTool(name: string): LocalTool { } return LocalToolHost.defineTool({ name, - description: 'Ask the GUI user a structured question and wait for the answer.', + description: 'Ask the user to choose or provide input before continuing.', toolKind: 'tool_call', + policy: 'auto', inputSchema: { type: 'object', properties: { prompt: { type: 'string' }, - question: { type: 'string' }, - message: { type: 'string' }, options: { type: 'array', - description: 'Optional answer choices for a single question. Use strings or {label, description} objects.', items: optionSchema }, - questions: { - type: 'array', - description: 'One to three structured questions. Each question may include answer options.', - items: { - type: 'object', - properties: { - header: { type: 'string' }, - id: { type: 'string' }, - question: { type: 'string' }, - options: { - type: 'array', - items: optionSchema - } - }, - required: ['question'] - } - } + allowFreeText: { type: 'boolean' } }, - required: [] + required: ['prompt'] }, - policy: 'auto', execute: async (args, context) => { if (!context.awaitUserInput) { return { - output: { error: 'GUI user input is not available in this runtime context' }, + output: { error: 'User input is not available in this environment.' }, isError: true } } - const inputId = `in_${Math.random().toString(36).slice(2, 10)}` - const itemId = `item_${inputId}` - const prompt = String(args.prompt ?? args.question ?? args.message ?? 'Input requested') - const questions = normalizeUserInputQuestions(args, inputId, prompt) - const resolution = await context.awaitUserInput({ id: inputId, itemId, prompt, questions }) - return { - output: resolution, - isError: resolution.status === 'cancelled' - } + const prompt = typeof args.prompt === 'string' ? args.prompt : 'Please provide input.' + const options = Array.isArray(args.options) + ? args.options + .map((option) => { + if (typeof option === 'string') return { label: option } + if (option && typeof option === 'object') { + const value = option as { label?: unknown; description?: unknown } + if (typeof value.label === 'string') { + return { + label: value.label, + ...(typeof value.description === 'string' ? { description: value.description } : {}) + } + } + } + return null + }) + .filter((option): option is { label: string; description?: string } => option !== null) + : [] + const resolution = await context.awaitUserInput({ + toolName: name, + prompt, + ...(options.length ? { options } : {}), + allowFreeText: Boolean(args.allowFreeText) + }) + return { output: resolution } } }) } -export const userInputTool: LocalTool = createUserInputTool('user_input') -export const requestUserInputTool: LocalTool = createUserInputTool('request_user_input') - -export const defaultLocalTools: LocalTool[] = [ - ...buildBuiltinLocalTools(), - echoTool, - userInputTool, - requestUserInputTool -] - -function normalizeUserInputQuestions( - args: Record, - fallbackId: string, - fallbackPrompt: string -): Array<{ - header: string - id: string - question: string - options: Array<{ label: string; description: string }> -}> { - const rawQuestions = Array.isArray(args.questions) ? args.questions : null - if (rawQuestions && rawQuestions.length > 0) { - const questions = rawQuestions - .map((question, index) => normalizeUserInputQuestion(question, index, fallbackId)) - .filter((question) => question !== null) - if (questions.length > 0) return questions - } - const options = Array.isArray(args.options) - ? args.options - .map((option) => normalizeUserInputOption(option)) - .filter((option) => option !== null) - : [] +export function buildDefaultLocalTools(options: BuiltinLocalToolsOptions = {}): LocalTool[] { return [ - { - header: 'Input', - id: String(args.id ?? fallbackId), - question: fallbackPrompt, - options - } + ...buildBuiltinLocalTools(options), + echoTool, + createUserInputTool('request_user_input'), + createUserInputTool('user_input') ] } - -function normalizeUserInputQuestion( - value: unknown, - index: number, - fallbackId: string -): { - header: string - id: string - question: string - options: Array<{ label: string; description: string }> -} | null { - if (!value || typeof value !== 'object') return null - const raw = value as Record - const question = typeof raw.question === 'string' && raw.question.trim() - ? raw.question.trim() - : null - if (!question) return null - const options = Array.isArray(raw.options) - ? raw.options - .map((option) => normalizeUserInputOption(option)) - .filter((option) => option !== null) - : [] - return { - header: typeof raw.header === 'string' && raw.header.trim() ? raw.header.trim() : `Question ${index + 1}`, - id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : `${fallbackId}_${index + 1}`, - question, - options - } -} - -function normalizeUserInputOption( - value: unknown -): { label: string; description: string } | null { - if (typeof value === 'string' && value.trim()) { - return { - label: value.trim(), - description: '' - } - } - if (!value || typeof value !== 'object') return null - const raw = value as Record - const label = typeof raw.label === 'string' && raw.label.trim() ? raw.label.trim() : null - if (!label) return null - return { - label, - description: typeof raw.description === 'string' ? raw.description : '' - } -} - -import { createCreatePlanTool, type CreatePlanAdapterOptions } from './create-plan-tool.js' - -/** - * Build the default tool list including the `create_plan` tool. The - * `create_plan` tool is gated to plan/refine turns via its - * `shouldAdvertise` predicate, so it is safe to ship with the - * default set: non-plan turns never see it in the model tool list. - */ -export function buildDefaultLocalTools( - planOptions: CreatePlanAdapterOptions = {}, - builtinOptions: BuiltinLocalToolsOptions = {} -): LocalTool[] { - const baseTools = Object.keys(builtinOptions).length - ? [...buildBuiltinLocalTools(builtinOptions), echoTool, userInputTool, requestUserInputTool] - : defaultLocalTools - return [...baseTools, createCreatePlanTool(planOptions)] -} From 4c34e9b5a22f26f97ee0dbd596dd2583b94a1b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:07:14 +0800 Subject: [PATCH 4/6] test(tools): cover turn-scoped operation identity --- kun/src/reliability/operation-journal.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 kun/src/reliability/operation-journal.test.ts diff --git a/kun/src/reliability/operation-journal.test.ts b/kun/src/reliability/operation-journal.test.ts new file mode 100644 index 000000000..1578a6a24 --- /dev/null +++ b/kun/src/reliability/operation-journal.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { + createToolOperationIdentity, + ToolOperationJournal +} from './operation-journal.js' + +describe('ToolOperationJournal', () => { + it('includes turnId in the operation key so fallback call ids do not collide across turns', () => { + const first = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-1', + callId: 'call_1', + toolName: 'write_file', + args: { path: 'a.md' } + }) + const second = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-2', + callId: 'call_1', + toolName: 'write_file', + args: { path: 'a.md' } + }) + + expect(ToolOperationJournal.key(first)).not.toBe(ToolOperationJournal.key(second)) + }) + + it('hashes arguments with stable object key ordering', () => { + const left = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-1', + callId: 'call_1', + toolName: 'tool', + args: { b: 2, a: { y: true, x: 1 } } + }) + const right = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-1', + callId: 'call_1', + toolName: 'tool', + args: { a: { x: 1, y: true }, b: 2 } + }) + + expect(left.argsHash).toBe(right.argsHash) + }) + + it('only replays completed records for the exact identity', () => { + const journal = new ToolOperationJournal({ nowIso: () => '2026-01-01T00:00:00.000Z' }) + const identity = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-1', + callId: 'call_1', + toolName: 'tool', + args: { value: 1 } + }) + const differentArgs = createToolOperationIdentity({ + threadId: 'thread-1', + turnId: 'turn-1', + callId: 'call_1', + toolName: 'tool', + args: { value: 2 } + }) + + journal.begin(identity) + expect(journal.getCompleted(identity)).toBeNull() + journal.complete(identity, { output: { ok: true } }) + + expect(journal.getCompleted(identity)).toEqual({ output: { ok: true } }) + expect(journal.getCompleted(differentArgs)).toBeNull() + }) +}) From 18745e11b3a92608a8dd48d258d32ec2d98da96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:07:29 +0800 Subject: [PATCH 5/6] test(tools): prevent fallback call id reuse across turns --- .../local-tool-host.operation-journal.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 kun/src/adapters/tool/local-tool-host.operation-journal.test.ts diff --git a/kun/src/adapters/tool/local-tool-host.operation-journal.test.ts b/kun/src/adapters/tool/local-tool-host.operation-journal.test.ts new file mode 100644 index 000000000..ff727e9d2 --- /dev/null +++ b/kun/src/adapters/tool/local-tool-host.operation-journal.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import type { ToolHostContext } from '../../ports/tool-host.js' +import { ToolOperationJournal } from '../../reliability/operation-journal.js' +import { LocalToolHost } from './local-tool-host.js' + +function context(turnId: string): ToolHostContext { + return { + threadId: 'thread-1', + turnId, + workspace: '/tmp/workspace', + approvalPolicy: 'auto', + abortSignal: new AbortController().signal, + awaitApproval: async () => 'allow' + } +} + +describe('LocalToolHost operation journal', () => { + it('reuses completed results for the exact same turn-scoped identity', async () => { + let executions = 0 + const host = new LocalToolHost({ + operationJournal: new ToolOperationJournal({ nowIso: () => '2026-01-01T00:00:00.000Z' }), + tools: [LocalToolHost.defineTool({ + name: 'counted', + description: 'count executions', + policy: 'auto', + inputSchema: { type: 'object' }, + execute: async () => { + executions += 1 + return { output: { executions } } + } + })] + }) + + const call = { callId: 'call_1', toolName: 'counted', arguments: { value: 1 } } + const first = await host.execute(call, context('turn-1')) + const second = await host.execute(call, context('turn-1')) + + expect(executions).toBe(1) + expect(first.item).toMatchObject({ output: { executions: 1 } }) + expect(second.item).toMatchObject({ output: { executions: 1 } }) + }) + + it('does not reuse fallback call ids across turns in the same thread', async () => { + let executions = 0 + const host = new LocalToolHost({ + operationJournal: new ToolOperationJournal({ nowIso: () => '2026-01-01T00:00:00.000Z' }), + tools: [LocalToolHost.defineTool({ + name: 'counted', + description: 'count executions', + policy: 'auto', + inputSchema: { type: 'object' }, + execute: async () => { + executions += 1 + return { output: { executions } } + } + })] + }) + + const call = { callId: 'call_1', toolName: 'counted', arguments: { value: 1 } } + const first = await host.execute(call, context('turn-1')) + const second = await host.execute(call, context('turn-2')) + + expect(executions).toBe(2) + expect(first.item).toMatchObject({ output: { executions: 1 } }) + expect(second.item).toMatchObject({ output: { executions: 2 } }) + }) + + it('does not reuse the same call id when arguments change', async () => { + let executions = 0 + const host = new LocalToolHost({ + operationJournal: new ToolOperationJournal({ nowIso: () => '2026-01-01T00:00:00.000Z' }), + tools: [LocalToolHost.defineTool({ + name: 'counted', + description: 'count executions', + policy: 'auto', + inputSchema: { type: 'object' }, + execute: async () => { + executions += 1 + return { output: { executions } } + } + })] + }) + + await host.execute({ callId: 'call_1', toolName: 'counted', arguments: { value: 1 } }, context('turn-1')) + await host.execute({ callId: 'call_1', toolName: 'counted', arguments: { value: 2 } }, context('turn-1')) + + expect(executions).toBe(2) + }) +}) From a82df7c2d2ea608c2b83516d79e95aab1fa7636f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?luoye=E3=82=B9=E3=82=AD?= <100058663+luoye520ww@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:10:48 +0800 Subject: [PATCH 6/6] chore: remove planning notes from operation journal pr --- .../692-tool-operation-journal-turn-scoped.md | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 .kun/review-plans/692-tool-operation-journal-turn-scoped.md diff --git a/.kun/review-plans/692-tool-operation-journal-turn-scoped.md b/.kun/review-plans/692-tool-operation-journal-turn-scoped.md deleted file mode 100644 index 9638c6312..000000000 --- a/.kun/review-plans/692-tool-operation-journal-turn-scoped.md +++ /dev/null @@ -1,31 +0,0 @@ -# PR plan: turn-scoped tool operation journal - -Source rejection: KunAgent/Kun#692. - -## Problem - -The rejected operation journal identity used `threadId + callId`. Compatibility paths can generate fallback call IDs such as `call_1` across multiple turns in the same thread. That makes a later turn reuse or collide with an earlier turn's journal entry. - -## Implementation direction - -1. Add a journal identity that includes at least `threadId`, `turnId`, `callId`, `toolName`, and `argsHash`. -2. Never reuse completed results across different turns, even when the fallback call ID is the same. -3. Store execution state transitions: started, completed, failed, unknown/interrupted. -4. Only reuse results when thread, turn, call ID, tool name, and args hash all match. -5. Treat unknown outcomes for non-idempotent tools conservatively and require explicit retry handling. - -## Files expected to change - -- `kun/src/reliability/operation-journal.ts` -- `kun/src/reliability/operation-journal.test.ts` -- `kun/src/adapters/tool/local-tool-host.ts` -- `kun/src/adapters/tool/local-tool-host.operation-journal.test.ts` -- Runtime factory wiring if the journal is injected. - -## Required tests - -- Same thread, different turns, same fallback `call_1` do not collide. -- Same thread, same turn, same call ID, same tool, same args can reuse. -- Same call ID with different args does not reuse. -- Same call ID with different tool does not reuse. -- Interrupted/unknown non-idempotent tool results are not silently replayed.