diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 80028d25403..47f0bdc5128 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -19567,6 +19567,261 @@ describe('Session', () => { expect(publishedActivities).toEqual(['idle', 'running']); }); + it('stamps a Goal turn’s tool results with the permit, and its own reads as bookkeeping', async () => { + // The evidence catalog derives provenance from this stamp: an + // unstamped tool result yields no catalog entry at all, so an ACP + // Goal could never cite an external_fact and could never prove a + // completion with anything but the model's own words. + const permit: core.GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-stamp', + }; + mockGoalRuntime.getSnapshot.mockReturnValue({ + v: 2, + activity: 'running', + goal: { + goalId: 'goal-1', + revision: 1, + objective: 'check weather', + status: 'active', + evidenceCursor: { recordId: 'cursor-1' }, + turnCount: 0, + activeTimeMs: 0, + tokensUsed: 0, + createdAt: 1234, + updatedAt: 1234, + }, + }); + mockGoalRuntime.permitForTurn.mockImplementation((turnKey: string) => + turnKey === 'goal-runtime:turn-stamp' ? permit : undefined, + ); + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); + mockToolRegistry.getTool.mockImplementation((name: string) => ({ + name, + displayName: name, + kind: core.Kind.Read, + schema: { name, description: name, parameters: {} }, + validateToolParams: vi.fn().mockReturnValue(null), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue(name), + toolLocations: vi.fn().mockReturnValue([]), + execute: vi.fn().mockResolvedValue( + name === 'run_shell_command' + ? { + llmContent: 'command failed', + returnDisplay: 'command failed', + error: { + message: 'command failed', + type: core.ToolErrorType.EXECUTION_FAILED, + }, + } + : { llmContent: 'ok', returnDisplay: 'ok' }, + ), + })); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { id: 'call-read', name: 'read_file', args: {} }, + { id: 'call-goal', name: 'get_goal', args: {} }, + { id: 'call-failed', name: 'run_shell_command', args: {} }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + expect(boundGoalHost).toBeDefined(); + await boundGoalHost!.startGoalTurn({ + permit, + continuationContext: 'check weather', + }); + + await vi.waitFor(() => { + expect(mockGoalRuntime.finishTurn).toHaveBeenCalledWith(permit); + }); + + const optionsFor = (callId: string) => + mockChatRecordingService.recordToolResult.mock.calls.find( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === callId, + )?.[2]; + expect(optionsFor('call-read')).toEqual({ goalContext: permit }); + expect(optionsFor('call-goal')).toEqual({ + goalContext: permit, + provenance: 'goal_runtime', + }); + // A failed command is evidence too -- it is exactly what an + // `infeasible` completion cites -- so the stamp must not depend on + // the tool succeeding. + expect( + mockChatRecordingService.recordToolResult.mock.calls.find( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === + 'call-failed', + )?.[1], + ).toMatchObject({ status: 'error' }); + expect(optionsFor('call-failed')).toEqual({ goalContext: permit }); + }); + + it('does not stamp a background-notification turn with a permit inherited by lineage', async () => { + // A notification fires from async resources created inside the turn + // that spawned the task, so the Goal store is inherited into it long + // after that turn ended. The notification turn is not a Goal turn: + // its tool results must record unstamped, or they would become + // external_fact evidence for a turn that never made those calls. + const permit: core.GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-stale', + }; + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); + mockToolRegistry.getTool.mockImplementation((name: string) => ({ + name, + displayName: name, + kind: core.Kind.Read, + schema: { name, description: name, parameters: {} }, + validateToolParams: vi.fn().mockReturnValue(null), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue(name), + toolLocations: vi.fn().mockReturnValue([]), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }), + })); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { id: 'call-notified', name: 'read_file', args: {} }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + // Enqueue from inside a Goal turn's store, as a task completion + // handler created during that turn would. + const notification = core.goalTurnContext.run(permit, () => + session.enqueueBackgroundNotification({ + displayText: 'Background agent completed.', + modelText: '', + taskId: 'agent-stale', + status: 'completed', + kind: 'agent', + }), + ); + await expect(notification).resolves.toEqual({ accepted: true }); + await vi.waitFor(() => { + expect( + mockChatRecordingService.recordToolResult.mock.calls.some( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === + 'call-notified', + ), + ).toBe(true); + }); + + const call = mockChatRecordingService.recordToolResult.mock.calls.find( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === + 'call-notified', + )!; + expect(call[2]).toBeUndefined(); + }); + + it('does not stamp a cron turn with a permit inherited by lineage', async () => { + // `#drainCronQueue()` is fired from inside Goal-turn code paths (the + // turn settle's `finally`, among others), so a cron item can drain + // while a Goal permit is live in the store. A cron turn is never a + // Goal turn: its tool results must record unstamped, exactly like + // the notification turn above. + const permit: core.GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-stale-cron', + }; + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.YOLO); + mockToolRegistry.getTool.mockImplementation((name: string) => ({ + name, + displayName: name, + kind: core.Kind.Read, + schema: { name, description: name, parameters: {} }, + validateToolParams: vi.fn().mockReturnValue(null), + getDefaultPermission: vi.fn().mockResolvedValue('allow'), + getDescription: vi.fn().mockReturnValue(name), + toolLocations: vi.fn().mockReturnValue([]), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'ok', returnDisplay: 'ok' }), + })); + let fireCron!: (job: { prompt: string; cronExpr: string }) => void; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn( + (callback: (job: { prompt: string; cronExpr: string }) => void) => { + fireCron = callback; + }, + ), + stop: vi.fn(), + list: vi.fn().mockReturnValue([]), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + mockConfig.isCronEnabled = vi.fn().mockReturnValue(true); + mockConfig.getCronScheduler = vi.fn().mockReturnValue(scheduler); + session.startCronScheduler(); + await vi.waitFor(() => expect(scheduler.start).toHaveBeenCalled()); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValueOnce( + createStreamWithChunks([ + { + type: core.StreamEventType.CHUNK, + value: { + functionCalls: [ + { id: 'call-cron', name: 'read_file', args: {} }, + ], + }, + }, + ]), + ) + .mockResolvedValue(createEmptyStream()); + + // Fire from inside a Goal turn's store, as a drain triggered from a + // Goal-turn code path would be. + core.goalTurnContext.run(permit, () => + fireCron({ prompt: 'cron work', cronExpr: '* * * * *' }), + ); + await vi.waitFor(() => { + expect( + mockChatRecordingService.recordToolResult.mock.calls.some( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === + 'call-cron', + ), + ).toBe(true); + }); + + const call = mockChatRecordingService.recordToolResult.mock.calls.find( + (call: unknown[]) => + (call[1] as { callId?: string } | undefined)?.callId === + 'call-cron', + )!; + expect(call[2]).toBeUndefined(); + }); + it('runs a host-scheduled Goal turn with the canonical permit', async () => { const permit: core.GoalTurnPermit = { goalId: 'goal-1', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 8db04674277..aa7f5042086 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -169,6 +169,7 @@ import { refreshMemoryAfterManagedWrite, refreshMemoryInstruction, GoalPersistenceUnavailableError, + ambientGoalToolResultProvenance, goalTurnContext, sessionIdContext, promptIdContext, @@ -8092,10 +8093,14 @@ export class Session implements SessionContext { * `_meta.source='cron'`, streams the model response, and handles tool calls. */ async #executeCronPrompt(item: CronQueueItem): Promise { - // Same session-ID binding rationale as #executePrompt. - return runWithInvocationContext(undefined, () => - sessionIdContext.run(this.config.getSessionId(), () => - this.#executeCronPromptInner(item), + // Same session-ID binding rationale as #executePrompt, and the same + // reason to leave the Goal store as the notification drain: a cron turn + // is never a Goal turn, whatever lineage it was scheduled from. + return goalTurnContext.exit(() => + runWithInvocationContext(undefined, () => + sessionIdContext.run(this.config.getSessionId(), () => + this.#executeCronPromptInner(item), + ), ), ); } @@ -8872,9 +8877,17 @@ export class Session implements SessionContext { this.currentShellNotificationActive = item.kind === 'shell'; this.#activeWorkChanged(); try { - await runWithInvocationContext(undefined, () => - sessionIdContext.run(this.config.getSessionId(), () => - this.#executeBackgroundNotificationPromptInner(item), + // A notification fires from async resources created inside the + // turn that spawned the task, so a Goal permit can reach here by + // lineage after that turn is long over. This is not a Goal turn: + // leave the store, as #executePrompt does for every non-Goal turn, + // or the notification's tool results would be stamped as evidence + // for a turn that never made those calls. + await goalTurnContext.exit(() => + runWithInvocationContext(undefined, () => + sessionIdContext.run(this.config.getSessionId(), () => + this.#executeBackgroundNotificationPromptInner(item), + ), ), ); } finally { @@ -9731,13 +9744,19 @@ export class Session implements SessionContext { ) { return; } - this.config - .getChatRecordingService() - ?.recordToolResult(finalized[index].responseParts, { + const goalProvenance = ambientGoalToolResultProvenance(record.toolName); + this.config.getChatRecordingService()?.recordToolResult( + finalized[index].responseParts, + { ...record.metadata, persistedOutputFiles: finalized[index].persistedOutputFiles, artifacts: finalized[index].artifacts, - }); + }, + // Passed only inside a Goal turn: outside one this call keeps its + // former two-argument shape, so nothing about ordinary recording + // changes. + ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)), + ); }); return { ...result, diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 92e560e3c77..859c4d235a9 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -2841,6 +2841,154 @@ describe('runNonInteractive', () => { ).toEqual(['not_started', 'not_started', 'not_started']); }); + it('stamps a Goal turn’s tool results with the permit that asked for them', async () => { + // The evidence catalog derives provenance from this stamp; without it a + // headless Goal produces no `external_fact` at all, so a completion can + // only ever cite the model's own words and the verifier refuses it. + setupMetricsMock(); + const recordToolResult = vi.fn(); + ( + mockConfig as Config & { + getChatRecordingService: () => { + recordToolResult: typeof recordToolResult; + finalize: ReturnType; + flush: ReturnType; + }; + } + ).getChatRecordingService = () => ({ + recordToolResult, + finalize: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + }); + vi.mocked(mockToolRegistry.getTool).mockReturnValue({ + kind: Kind.Read, + } as unknown as ReturnType); + const permit = { goalId: 'g-1', revision: 2, turnId: 't-1' }; + mockCoreExecuteToolCall.mockImplementation( + async ( + _config: unknown, + request: { callId: string; name: string }, + ) => ({ + responseParts: [ + { + functionResponse: { + id: request.callId, + name: request.name, + response: + request.callId === 'shell-failed' + ? { error: 'command failed' } + : { output: 'ok' }, + }, + }, + ], + resultDisplay: + request.callId === 'shell-failed' ? 'command failed' : 'ok', + ...(request.callId === 'shell-failed' + ? { + error: new Error('command failed'), + errorType: ToolErrorType.EXECUTION_FAILED, + } + : {}), + }), + ); + const goalToolCall = (callId: string, name: string) => ({ + type: GeminiEventType.ToolCallRequest, + value: { + callId, + name, + args: {}, + isClientInitiated: false, + prompt_id: 'p-goal', + goalContext: permit, + }, + }); + mockGeminiClient.sendMessageStream + .mockReturnValueOnce( + createStreamFromEvents([ + goalToolCall('shell-1', ToolNames.READ_FILE), + goalToolCall('goal-read', ToolNames.GET_GOAL), + goalToolCall('shell-failed', ToolNames.SHELL), + ] as unknown as ServerGeminiStreamEvent[]), + ) + .mockReturnValueOnce(createStreamFromEvents(finishTurn)); + + await runNonInteractive(mockConfig, 'work the goal', 'p-goal'); + + const optionsByCallId = new Map( + recordToolResult.mock.calls.map((call) => [call[1]?.callId, call[2]]), + ); + // An ordinary tool becomes citable external evidence... + expect(optionsByCallId.get('shell-1')).toEqual({ goalContext: permit }); + // ...while the Goal's own reads stay out of the catalog. + expect(optionsByCallId.get('goal-read')).toEqual({ + goalContext: permit, + provenance: 'goal_runtime', + }); + // A failed command is evidence too -- it is exactly what an + // `infeasible` completion cites -- so the stamp must not depend on the + // tool succeeding. + expect( + recordToolResult.mock.calls.find( + (call) => call[1]?.callId === 'shell-failed', + )?.[1], + ).toMatchObject({ status: 'error' }); + expect(optionsByCallId.get('shell-failed')).toEqual({ + goalContext: permit, + }); + }); + + it('leaves tool results outside a Goal turn unstamped', async () => { + setupMetricsMock(); + const recordToolResult = vi.fn(); + ( + mockConfig as Config & { + getChatRecordingService: () => { + recordToolResult: typeof recordToolResult; + finalize: ReturnType; + flush: ReturnType; + }; + } + ).getChatRecordingService = () => ({ + recordToolResult, + finalize: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + }); + vi.mocked(mockToolRegistry.getTool).mockReturnValue({ + kind: Kind.Read, + } as unknown as ReturnType); + mockCoreExecuteToolCall.mockImplementation( + async ( + _config: unknown, + request: { callId: string; name: string }, + ) => ({ + responseParts: [ + { + functionResponse: { + id: request.callId, + name: request.name, + response: { output: 'ok' }, + }, + }, + ], + resultDisplay: 'ok', + }), + ); + mockGeminiClient.sendMessageStream + .mockReturnValueOnce( + createStreamFromEvents( + toolCallEvents(['plain-1'], ToolNames.READ_FILE, 'p-plain'), + ), + ) + .mockReturnValueOnce(createStreamFromEvents(finishTurn)); + + await runNonInteractive(mockConfig, 'plain work', 'p-plain'); + + expect(recordToolResult).toHaveBeenCalled(); + for (const call of recordToolResult.mock.calls) { + expect(call[2]).toBeUndefined(); + } + }); + it('runs a batch of concurrency-safe tool calls concurrently', async () => { setupMetricsMock(); // Kind.Read is concurrency-safe, so the whole batch is one parallel diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index d3d7d2f28b9..9d716f0e3a9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -33,6 +33,7 @@ import { InputFormat, LoopType, ToolNames, + goalToolResultProvenance, uiTelemetryService, parseAndFormatApiError, createDebugLogger, @@ -2245,18 +2246,23 @@ export async function runNonInteractive( const { request, response } = orderedResponses[index]; const finalizedParts = finalized[index].responseParts; toolResponseParts.push(...finalizedParts); - chatRecordingService?.recordToolResult?.(finalizedParts, { - callId: request.callId, - status: - statusByResponse.get(response) ?? - (response.error ? 'error' : 'success'), - resultDisplay: response.resultDisplay, - persistedOutputFiles: finalized[index].persistedOutputFiles, - artifacts: finalized[index].artifacts, - error: response.error, - errorType: response.errorType, - executionStatus: response.executionStatus, - }); + const goalProvenance = goalToolResultProvenance(request); + chatRecordingService?.recordToolResult?.( + finalizedParts, + { + callId: request.callId, + status: + statusByResponse.get(response) ?? + (response.error ? 'error' : 'success'), + resultDisplay: response.resultDisplay, + persistedOutputFiles: finalized[index].persistedOutputFiles, + artifacts: finalized[index].artifacts, + error: response.error, + errorType: response.errorType, + executionStatus: response.executionStatus, + }, + ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)), + ); } return { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d229883b048..e7aeb7ce5e9 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -33,6 +33,7 @@ import { SendMessageType, createDebugLogger, ToolNames, + goalToolResultProvenance, getErrorMessage, isNodeError, MessageSenderType, @@ -3919,7 +3920,6 @@ export const useGeminiStream = ( ); immediateDuplicateToolResponses.responses.forEach( ({ request, response }, index) => { - const goalContext = request.goalContext; config.getChatRecordingService?.()?.recordToolResult?.( finalized[index].responseParts, { @@ -3932,15 +3932,7 @@ export const useGeminiStream = ( errorType: response.errorType, executionStatus: response.executionStatus, }, - goalContext - ? request.name === ToolNames.GET_GOAL || - request.name === ToolNames.UPDATE_GOAL - ? { - goalContext: { ...goalContext }, - provenance: 'goal_runtime' as const, - } - : { goalContext: { ...goalContext } } - : undefined, + goalToolResultProvenance(request), ); }, ); @@ -4860,7 +4852,6 @@ export const useGeminiStream = ( (entry) => entry.responseParts, ); orderedResponses.forEach(({ request, response, status }, index) => { - const goalContext = request.goalContext; config.getChatRecordingService?.()?.recordToolResult?.( finalizedResponses[index].responseParts, { @@ -4874,15 +4865,7 @@ export const useGeminiStream = ( errorType: response.errorType, executionStatus: response.executionStatus, }, - goalContext - ? request.name === ToolNames.GET_GOAL || - request.name === ToolNames.UPDATE_GOAL - ? { - goalContext: { ...goalContext }, - provenance: 'goal_runtime' as const, - } - : { goalContext: { ...goalContext } } - : undefined, + goalToolResultProvenance(request), ); }); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index aae3567fd0e..db59b0cd3f9 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -205,6 +205,7 @@ import { } from '../utils/invocation-context.js'; import { evaluateToolInvocationGuard } from './tool-invocation-guard.js'; import { goalTurnContext } from '../goals/goal-turn-context.js'; +import { goalToolResultProvenance } from '../goals/goal-tool-result-provenance.js'; const debugLogger = createDebugLogger('TOOL_SCHEDULER'); @@ -6284,28 +6285,14 @@ export class CoreToolScheduler { error: call.response.error, errorType: call.response.errorType, }; - const goalContext = call.request.goalContext; - if (!goalContext) { - this.chatRecordingService.recordToolResult( - call.response.responseParts, - result, - ); - } else if ( - call.request.name === ToolNames.GET_GOAL || - call.request.name === ToolNames.UPDATE_GOAL - ) { - this.chatRecordingService.recordToolResult( - call.response.responseParts, - result, - { goalContext: { ...goalContext }, provenance: 'goal_runtime' }, - ); - } else { - this.chatRecordingService.recordToolResult( - call.response.responseParts, - result, - { goalContext: { ...goalContext } }, - ); - } + const goalProvenance = goalToolResultProvenance(call.request); + this.chatRecordingService.recordToolResult( + call.response.responseParts, + result, + // Passed only inside a Goal turn, so recording outside one keeps its + // two-argument shape. + ...(goalProvenance ? ([goalProvenance] as const) : ([] as const)), + ); } } diff --git a/packages/core/src/goals/goal-tool-result-provenance.test.ts b/packages/core/src/goals/goal-tool-result-provenance.test.ts new file mode 100644 index 00000000000..ec9a28eced3 --- /dev/null +++ b/packages/core/src/goals/goal-tool-result-provenance.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { ToolNames } from '../tools/tool-names.js'; +import { goalTurnContext } from './goal-turn-context.js'; +import { + ambientGoalToolResultProvenance, + goalToolResultProvenance, +} from './goal-tool-result-provenance.js'; + +const permit = { goalId: 'g-1', revision: 2, turnId: 't-1' }; + +describe('goalToolResultProvenance', () => { + it('stamps an ordinary tool result with the permit that asked for it', () => { + // Without the stamp the catalog derives no provenance at all, so the + // result cannot become the `external_fact` a completion is proved with. + expect( + goalToolResultProvenance({ + name: 'run_shell_command', + goalContext: permit, + }), + ).toEqual({ goalContext: permit }); + }); + + it('copies the permit rather than aliasing the request', () => { + const request = { name: 'read_file', goalContext: { ...permit } }; + const options = goalToolResultProvenance(request); + expect(options?.goalContext).not.toBe(request.goalContext); + expect(options?.goalContext).toEqual(permit); + }); + + it.each([ToolNames.GET_GOAL, ToolNames.UPDATE_GOAL])( + 'marks %s as the Goal’s own bookkeeping', + (name) => { + // Excluded from the catalog on purpose: a Goal that cited its own reads + // as proof would be arguing in a circle. + expect(goalToolResultProvenance({ name, goalContext: permit })).toEqual({ + goalContext: permit, + provenance: 'goal_runtime', + }); + }, + ); + + it('leaves a tool call made outside a Goal turn unstamped', () => { + expect(goalToolResultProvenance({ name: 'read_file' })).toBeUndefined(); + }); +}); + +describe('ambientGoalToolResultProvenance', () => { + it('reads the permit from the surrounding Goal turn', () => { + const options = goalTurnContext.run(permit, () => + ambientGoalToolResultProvenance('run_shell_command'), + ); + expect(options).toEqual({ goalContext: permit }); + }); + + it('applies the same bookkeeping rule inside a turn', () => { + const options = goalTurnContext.run(permit, () => + ambientGoalToolResultProvenance(ToolNames.GET_GOAL), + ); + expect(options).toEqual({ + goalContext: permit, + provenance: 'goal_runtime', + }); + }); + + it('stamps nothing outside a Goal turn', () => { + expect(ambientGoalToolResultProvenance('read_file')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/goals/goal-tool-result-provenance.ts b/packages/core/src/goals/goal-tool-result-provenance.ts new file mode 100644 index 00000000000..2d69c2dfa5a --- /dev/null +++ b/packages/core/src/goals/goal-tool-result-provenance.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RecordToolResultOptions } from '../services/chatRecordingService.js'; +import { ToolNames } from '../tools/tool-names.js'; +import type { GoalTurnPermit } from './goal-protocol.js'; +import { goalTurnContext } from './goal-turn-context.js'; + +/** The slice of a tool-call request this reads. */ +export interface GoalToolResultRequest { + name: string; + goalContext?: GoalTurnPermit; +} + +/** + * How a finished tool call should be recorded, so its result can become Goal + * evidence. + * + * The evidence catalog derives a record's provenance from the Goal permit + * stamped on it (`goal-evidence.ts`: no parsed goal context, no provenance, + * no catalog entry). A tool result recorded without the stamp is therefore + * invisible to the catalog -- and since `tool_result` is the only provenance + * that maps to `external_fact`, an unstamped host produces a Goal that can + * never prove anything about the world: completions citing only the model's + * own words are refused by the verifier, and the `infeasible` and `external` + * blockers, which require external evidence, are unreachable. + * + * `get_goal` and `update_goal` are stamped as `goal_runtime` instead: they are + * the Goal's own bookkeeping, and a catalog that cited its own reads as proof + * would be circular. + * + * Every site that records tool results during a Goal turn routes through + * here -- the interactive scheduler, both TUI recording paths, headless, and + * ACP -- so the rule cannot drift between them. + */ +export function goalToolResultProvenance( + request: GoalToolResultRequest, +): RecordToolResultOptions | undefined { + const { goalContext } = request; + if (!goalContext) return undefined; + if ( + request.name === ToolNames.GET_GOAL || + request.name === ToolNames.UPDATE_GOAL + ) { + return { goalContext: { ...goalContext }, provenance: 'goal_runtime' }; + } + return { goalContext: { ...goalContext } }; +} + +/** + * The same rule for a host that executes tools straight from the model's + * function calls and so has no `ToolCallRequestInfo` to read a permit from. + * + * The permit comes from the ambient Goal turn context, which the host enters + * around the whole turn -- including the tool calls it issues -- so a result + * recorded here belongs to the turn that asked for it. + */ +export function ambientGoalToolResultProvenance( + toolName: string, +): RecordToolResultOptions | undefined { + const goalContext = goalTurnContext.getStore(); + return goalToolResultProvenance({ + name: toolName, + ...(goalContext ? { goalContext } : {}), + }); +} diff --git a/packages/core/src/goals/index.ts b/packages/core/src/goals/index.ts index a64afd63af6..7395fde648e 100644 --- a/packages/core/src/goals/index.ts +++ b/packages/core/src/goals/index.ts @@ -64,6 +64,7 @@ export type { LegacyGoalTerminal, } from './goal-legacy-projection.js'; export * from './goal-evidence.js'; +export * from './goal-tool-result-provenance.js'; export * from './goal-checkpoint.js'; export * from './goal-checkpoint-verifier.js'; export * from './goal-verifier.js';