diff --git a/.changeset/v2-wire-record-parity.md b/.changeset/v2-wire-record-parity.md new file mode 100644 index 000000000..d4539b55e --- /dev/null +++ b/.changeset/v2-wire-record-parity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep sessions from the new agent engine compatible with existing transcript replay. diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 3729840f5..42d2e3b1b 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -209,11 +209,10 @@ turn --> wire #34495E turn --> contextMemory #34495E turn --> telemetry #34495E loop --> contextMemory #34495E -loop --> contextSize #34495E loop --> llmRequester #34495E -loop --> wire #34495E +loop --> usage #34495E +loop --> event #34495E loop --> toolExecutor #34495E -loop --> profile #34495E loop --> config #34495E llmRequester --> contextMemory #34495E llmRequester --> contextProjector #34495E @@ -242,13 +241,12 @@ permissionMode --> contextInjector #34495E permissionRules --> wire #34495E permissionRules --> config #34495E plan --> contextMemory #34495E -plan --> wireRecord #34495E -plan --> record #34495E -plan --> sessionFs #34495E -plan --> profile #34495E -plan --> toolRegistry #34495E +plan --> hostFs #34495E plan --> contextInjector #34495E plan --> telemetry #34495E +plan --> wire #34495E +plan --> session_context #34495E +plan --> scopeContext #34495E goal --> wireRecord #34495E goal --> wire #34495E goal --> systemReminder #34495E diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index 8d08738cd..fb73b0e9e 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceplatformAppIPlatformServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceplatformAppIPlatformServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmenteventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 15648e99a..e5f6d5094 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -70,14 +70,7 @@ export function buildContextCompactionShape( } const compactableUserMessages = collectCompactableUserMessages(history); - const selection = input.keptHeadUserMessageCount === undefined - ? { - head: [], - tail: selectRecentUserMessages(compactableUserMessages), - elided: false, - omittedTokens: 0, - } - : selectCompactionUserMessages(compactableUserMessages); + const selection = selectCompactionUserMessages(compactableUserMessages); const elisionMessage = selection.elided ? createCompactionElisionMessage(selection.omittedTokens) : undefined; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index bcf6fb167..999524b4b 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -1,6 +1,7 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { UndoCut } from './contextOps'; +import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; export interface ContextCompactionInput { @@ -33,6 +34,8 @@ export interface IAgentContextMemoryService { /** Append one or more already-folded messages (`context.append_message`). */ append(...messages: readonly ContextMessage[]): void; + appendLoopEvent(event: LoopRecordedEvent): void; + /** Drop the entire history (`context.clear`). No-op when already empty. */ clear(): void; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 0183e5e07..6e3eccc4c 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -39,6 +39,7 @@ import { buildContextCompactionShape } from './compactionHandoff'; import { computeUndoCut, ContextModel, + contextAppendLoopEvent, contextAppendMessage, contextApplyCompaction, contextClear, @@ -46,6 +47,7 @@ import { contextUndo, type UndoCut, } from './contextOps'; +import type { LoopRecordedEvent } from './loopEventFold'; import { ensureMessageId } from './messageId'; import type { ContextMessage } from './types'; @@ -97,6 +99,9 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte }); } + appendLoopEvent(event: LoopRecordedEvent): void { + this.wire.dispatch(contextAppendLoopEvent({ event })); + } clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index bd671f2d5..13bbe7a2c 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -84,6 +84,23 @@ async function dehydrateRecord( if (parts === message.content) return record; return { ...record, message: { ...message, content: [...parts] } }; } + if (record.type === 'context.append_loop_event') { + const event = record['event'] as LoopRecordedEvent | undefined; + if (event === undefined) return record; + if (event.type === 'content.part') { + const parts = await transform([event.part]); + if (parts[0] === event.part) return record; + return { ...record, event: { ...event, part: parts[0] } }; + } + if (event.type === 'tool.result') { + const output = event.result.output; + if (!Array.isArray(output)) return record; + const parts = await transform(output); + if (parts === output) return record; + return { ...record, event: { ...event, result: { ...event.result, output: [...parts] } } }; + } + return record; + } return record; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 1b5a69d73..7ce9ae4dd 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -33,6 +33,7 @@ */ import { createToolMessage, type ContentPart, type ToolCall } from '#/app/llmProtocol/message'; +import type { TokenUsage } from '#/app/llmProtocol/usage'; import type { ContextMessage } from './types'; @@ -46,11 +47,38 @@ const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; -/** Subset of v1 `LoopRecordedEvent` read by the fold (wire shape, structural). */ export type LoopRecordedEvent = - | { readonly type: 'step.begin'; readonly uuid: string } - | { readonly type: 'step.end'; readonly uuid: string } - | { readonly type: 'content.part'; readonly stepUuid: string; readonly part: ContentPart } + | { + readonly type: 'step.begin'; + readonly uuid: string; + readonly turnId?: string; + readonly step?: number; + } + | { + readonly type: 'step.end'; + readonly uuid: string; + readonly turnId?: string; + readonly step?: number; + readonly finishReason?: string; + readonly usage?: TokenUsage; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly messageId?: string; + readonly providerFinishReason?: string; + readonly rawFinishReason?: string; + } + | { + readonly type: 'content.part'; + readonly stepUuid: string; + readonly part: ContentPart; + readonly uuid?: string; + readonly turnId?: string; + readonly step?: number; + } | { readonly type: 'tool.call'; readonly stepUuid: string; @@ -58,11 +86,15 @@ export type LoopRecordedEvent = readonly name: string; readonly args?: unknown; readonly extras?: Record; + readonly uuid?: string; + readonly turnId?: string; + readonly step?: number; } | { readonly type: 'tool.result'; readonly toolCallId: string; readonly result: { readonly output: string | readonly ContentPart[]; readonly isError?: boolean }; + readonly parentUuid?: string; }; interface FoldCtx { diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 643d00b01..b680cad9a 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -324,7 +324,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let compactedCount = initialCompactedCount; for (let round = 1; ; round++) { - const result = await this.compactionRound(active, round, data, compactedCount); + const result = await this.compactionRound(active, round, data); if (this._compacting !== active) throw compactionCancelledReason(active); finalResult.summary = result.summary; @@ -375,7 +375,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull active: ActiveCompaction, round: number, data: Readonly, - initialCompactedCount: number, ): Promise { const startedAt = Date.now(); const originalHistory = [...this.context.get()]; @@ -383,7 +382,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull let retryCount = 0; try { - let compactedCount = initialCompactedCount; + let compactedCount = originalHistory.length; const signal = active.abortController.signal; signal.throwIfAborted(); diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index c4eb1a8d6..a1c7e0c81 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -429,7 +429,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { origin: GOAL_CONTINUATION_ORIGIN, }); this.context.append(message); - this.turnService.launch(GOAL_CONTINUATION_ORIGIN); + this.turnService.launch({ input: message.content, origin: GOAL_CONTINUATION_ORIGIN }); } private normalizeAfterReplay(): void { diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 9996ff2d1..1c3b49663 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -262,7 +262,9 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } const usageModel = request.modelAlias; - this.usage.record(usageModel, usage, request.source); + if (request.source?.type !== 'turn') { + this.usage.record(usageModel, usage, request.source); + } this.contextSize.measured(request.messages, [message], usage); this.logResponse(request.logFields, usage, timing); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 043a6c20d..3b6a46921 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -12,19 +12,17 @@ import type { TurnStepStartedEvent, } from '@moonshot-ai/protocol'; import { IAgentLLMRequesterService, type LLMRequestFinish } from '#/agent/llmRequester/llmRequester'; -import type { ToolResult } from '#/agent/tool/toolContract'; +import { IAgentUsageService } from '#/agent/usage/usage'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { type FinishReason } from '#/app/llmProtocol/finishReason'; -import { createToolMessage, type ContentPart, type StreamedMessagePart } from '#/app/llmProtocol/message'; +import { type StreamedMessagePart } from '#/app/llmProtocol/message'; import { type TokenUsage } from '#/app/llmProtocol/usage'; import { ErrorCodes, KimiError } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { newMessageId } from '#/agent/contextMemory/messageId'; -import { type ContextMessage } from '#/agent/contextMemory/types'; import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection'; import { createMaxStepsExceededError, @@ -51,12 +49,6 @@ declare module '#/app/event/eventBus' { } } -const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; -const TOOL_EMPTY_STATUS = 'Tool output is empty.'; -const TOOL_EMPTY_ERROR_STATUS = - 'ERROR: Tool execution failed. Tool output is empty.'; -const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; - export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; export class AgentLoopService implements IAgentLoopService { @@ -71,6 +63,7 @@ export class AgentLoopService implements IAgentLoopService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, + @IAgentUsageService private readonly usage: IAgentUsageService, @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IConfigService private readonly config: IConfigService, @@ -158,6 +151,12 @@ export class AgentLoopService implements IAgentLoopService { const stepUuid = randomUUID(); this.eventBus.publish({ type: 'turn.step.started', turnId, step: currentStep, stepId: stepUuid }); + this.context.appendLoopEvent({ + type: 'step.begin', + uuid: stepUuid, + turnId: String(turnId), + step: currentStep, + }); let stepStarted = false; const markStepStarted = (): void => { @@ -196,13 +195,18 @@ export class AgentLoopService implements IAgentLoopService { const { providerFinishReason, message } = response; let finishReason = providerFinishReason ?? 'completed'; - this.append({ - id: newMessageId(), - role: 'assistant', - content: response.message.content, - toolCalls: response.message.toolCalls, - providerMessageId: response.providerMessageId, - }); + const turnIdStr = String(turnId); + const toolCallUuids = new Map(); + for (const part of message.content) { + this.context.appendLoopEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId: turnIdStr, + step: currentStep, + stepUuid, + part, + }); + } const hasToolCalls = message.toolCalls.length > 0; if (hasToolCalls) { @@ -210,12 +214,27 @@ export class AgentLoopService implements IAgentLoopService { for await (const toolResult of this.toolExecutor.execute(response.message.toolCalls, { signal, turnId, + onToolCall: ({ toolCallId, name, args }) => { + const callUuid = randomUUID(); + toolCallUuids.set(toolCallId, callUuid); + this.context.appendLoopEvent({ + type: 'tool.call', + uuid: callUuid, + turnId: turnIdStr, + step: currentStep, + stepUuid, + toolCallId, + name, + args, + }); + }, })) { const { result } = toolResult; - this.append({ - ...createToolMessage(toolResult.toolCallId, toolResultOutputForModel(result)), - role: 'tool', - isError: result.isError, + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: toolCallUuids.get(toolResult.toolCallId) ?? randomUUID(), + toolCallId: toolResult.toolCallId, + result: { output: result.output, isError: result.isError }, }); if (result.stopTurn === true) stopTurn = true; } @@ -229,6 +248,33 @@ export class AgentLoopService implements IAgentLoopService { signal.throwIfAborted(); markStepStarted(); + const timing = response.timing; + const stepProviderFinishReason = + response.providerFinishReason !== undefined && + response.providerFinishReason !== finishReason + ? response.providerFinishReason + : undefined; + this.context.appendLoopEvent({ + type: 'step.end', + uuid: stepUuid, + turnId: turnIdStr, + step: currentStep, + finishReason: normalizeFinishReason(finishReason), + usage, + llmFirstTokenLatencyMs: timing?.firstTokenLatencyMs, + llmStreamDurationMs: timing?.streamDurationMs, + llmRequestBuildMs: timing?.requestBuildMs, + llmServerFirstTokenMs: timing?.serverFirstTokenMs, + llmServerDecodeMs: timing?.serverDecodeMs, + llmClientConsumeMs: timing?.clientConsumeMs, + messageId: response.providerMessageId, + providerFinishReason: stepProviderFinishReason, + rawFinishReason: + stepProviderFinishReason !== undefined ? response.rawFinishReason : undefined, + }); + if (response.model !== undefined) { + this.usage.record(response.model, usage, { type: 'turn', turnId, step: currentStep }); + } this.emitStepCompleted(turnId, currentStep, stepUuid, usage, finishReason, response); const afterStepContext: AfterStepContext = { @@ -252,10 +298,6 @@ export class AgentLoopService implements IAgentLoopService { }; } - private append(message: ContextMessage): void { - this.context.append(message); - } - private emitStepCompleted( turnId: number, step: number, @@ -366,32 +408,10 @@ export class AgentLoopService implements IAgentLoopService { } } -function toolResultOutputForModel(result: ToolResult): string | ContentPart[] { - const output = result.output; - if (typeof output === 'string') { - if (result.isError === true) { - if (output.length === 0) return TOOL_EMPTY_ERROR_STATUS; - if (output.trimStart().startsWith('ERROR:')) return output; - return `${TOOL_ERROR_STATUS}\n${output}`; - } - if (output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT) { - return TOOL_EMPTY_STATUS; - } - return output; - } - - if (output.length === 0) { - return [ - { - type: 'text', - text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS, - }, - ]; - } - if (result.isError === true) { - return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output]; - } - return output; +function normalizeFinishReason(reason: string): string { + if (reason === 'tool_calls') return 'tool_use'; + if (reason === 'completed') return 'end_turn'; + return reason; } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 8d92f28bd..f699d06d4 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -1,22 +1,25 @@ +/** + * `plan` domain (L3) — `IAgentPlanService` implementation. + * + * Manages plan-mode state through `wire`, injects plan-mode context through + * `contextInjector`, writes optional plan files through `hostFileSystem`, + * and tags mode telemetry through `telemetry`. Bound at Agent scope. + */ + +import { randomUUID } from 'node:crypto'; +import { dirname, join } from 'pathe'; + +import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { - randomUUID -} from 'node:crypto'; -import { - dirname, - resolve -} from 'pathe'; - -import { Disposable } from "#/_base/di/lifecycle"; -import { generateHeroSlug } from "#/_base/utils/hero-slug"; +import { generateHeroSlug } from '#/_base/utils/hero-slug'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { PlanModeInjection } from '#/agent/plan/injection/planModeInjection'; -import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { IAgentProfileService } from '#/agent/profile/profile'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; import { @@ -37,11 +40,11 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, - @IAgentProfileService private readonly profile: IAgentProfileService, - @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IAgentWireService private readonly wire: IWireService, + @ISessionContext private readonly sessionCtx: ISessionContext, + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, ) { super(); @@ -61,10 +64,6 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } private restoreTelemetryMode(): void { - // `wire.replay` rebuilds `PlanModel` silently, so the live telemetry - // context (set on the enter/exit path) is not re-applied by replay. Re-derive - // it here from the restored model so a resumed plan-mode session keeps - // tagging telemetry with `mode: 'plan'` (mirroring the legacy restoreEnter). if (this.isActive) { this.telemetryContext.set({ mode: 'plan' }); } @@ -80,16 +79,19 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } const planFilePath = this.planFilePathFor(id); - this.wire.dispatch(planModeEnter({ id, planFilePath })); - this.telemetryContext.set({ mode: 'plan' }); - + let enterRecorded = false; try { await this.ensurePlanDirectory(planFilePath); + this.wire.dispatch(planModeEnter({ id, planFilePath })); + this.telemetryContext.set({ mode: 'plan' }); + enterRecorded = true; if (createFile) { await this.writeEmptyPlanFile(planFilePath); } } catch (error) { - this.cancel(id); + if (enterRecorded) { + this.cancel(id); + } throw error; } } @@ -128,19 +130,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } private planFilePathFor(id: string): string { - // Anchor the plan file to the same root the file tools resolve relative - // paths against. When the profile carries a cwd (normal CLI / TUI case) it - // is used; otherwise fall back to the session workDir so the plan path is - // always absolute and matches the tool-resolved path. A relative plan path - // would both land in `process.cwd()` (via `IHostFileSystem`) and fail the - // plan-mode guard's absolute-path comparison (server-v2 binds the agent - // without a cwd, leaving the profile cwd empty). - return resolve(this.planRoot(), 'plan', `${id}.md`); - } - - private planRoot(): string { - const cwd = this.profile.data().cwd; - return cwd.length > 0 ? cwd : this.workspace.workDir; + return join(this.sessionCtx.sessionDir, 'agents', this.agentCtx.agentId, 'plans', `${id}.md`); } private async writeEmptyPlanFile(path: string): Promise { diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 19387ac8d..1ec53ee64 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -168,6 +168,7 @@ export class AgentProfileService implements IAgentProfileService { systemPrompt, activeToolNames: profile.tools, }); + this.wire.dispatch(configUpdate({})); if (agentsMdWarning !== undefined) { this.eventBus.publish({ diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 3697e4bc5..3e445f72b 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -4,10 +4,10 @@ import { ErrorCodes, KimiError } from '#/errors'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { ensureMessageId } from '#/agent/contextMemory/messageId'; -import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentTurnService, type Turn } from '#/agent/turn/turn'; +import { IAgentTurnService, type Turn, type TurnPromptInfo } from '#/agent/turn/turn'; import type { ExecutableToolResult } from '#/agent/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/tool/toolHooks'; import { OrderedHookSlot } from '#/hooks'; @@ -58,7 +58,7 @@ export class AgentPromptService implements IAgentPromptService { const stamped = ensureMessageId(message); this.append(stamped); if (await this.blockedByHook(stamped, false)) return undefined; - return this.launch(stamped.origin); + return this.launch({ input: stamped.content, origin: stamped.origin }); } steer(message: ContextMessage): PromptSteerHandle { @@ -107,7 +107,7 @@ export class AgentPromptService implements IAgentPromptService { } retry(): Turn | undefined { - return this.launch({ kind: 'retry' }); + return this.launch({ origin: { kind: 'retry' } }); } undo(count: number): number { @@ -140,8 +140,8 @@ export class AgentPromptService implements IAgentPromptService { this.context.append(...messages); } - private launch(origin?: PromptOrigin): Turn { - const turn = this.turnService.launch(origin); + private launch(prompt?: TurnPromptInfo): Turn { + const turn = this.turnService.launch(prompt); this.observe(turn); return turn; } diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 4eb77de5d..829734943 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -118,6 +118,7 @@ export class AgentRPCService implements IAgentRPCService { role: 'user', content: [...payload.input], toolCalls: [], + origin: { kind: 'user' }, }); return turn === undefined ? undefined : { turn_id: turn.id }; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index f4abaad54..c48a90585 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -4,9 +4,16 @@ import type { ToolDidExecuteContext, ToolWillExecuteContext } from '#/agent/tool import type { ToolCall } from '#/app/llmProtocol/message'; import type { OrderedHookSlot } from '#/hooks'; +export interface ToolCallStartedPayload { + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; +} + export interface ToolExecutorExecuteOptions { readonly signal: AbortSignal; readonly turnId: number; + readonly onToolCall?: (payload: ToolCallStartedPayload) => void; } export interface ToolExecutionResult { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index df7309d05..b93e72045 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -451,6 +451,11 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { description: displayFields?.description, display: displayFields?.display, }); + options.onToolCall?.({ + toolCallId: call.toolCall.id, + name: call.toolName, + args, + }); } private dispatchToolResult( diff --git a/packages/agent-core-v2/src/agent/turn/turn.ts b/packages/agent-core-v2/src/agent/turn/turn.ts index ff8398a05..3649da044 100644 --- a/packages/agent-core-v2/src/agent/turn/turn.ts +++ b/packages/agent-core-v2/src/agent/turn/turn.ts @@ -15,10 +15,16 @@ export interface Turn { readonly result: Promise; } +export interface TurnPromptInfo { + readonly input?: unknown; + readonly origin?: PromptOrigin; + readonly steer?: unknown; +} + export interface IAgentTurnService { readonly _serviceBrand: undefined; - launch(origin?: PromptOrigin): Turn; + launch(prompt?: TurnPromptInfo): Turn; getActiveTurn(): Turn | undefined; } diff --git a/packages/agent-core-v2/src/agent/turn/turnOps.ts b/packages/agent-core-v2/src/agent/turn/turnOps.ts index f85f5f444..401d2a110 100644 --- a/packages/agent-core-v2/src/agent/turn/turnOps.ts +++ b/packages/agent-core-v2/src/agent/turn/turnOps.ts @@ -34,8 +34,23 @@ function advanceTurnId(s: TurnModelState, turnId: number): TurnModelState { return s; } +export interface PromptTurnPayload { + readonly turnId: number; + readonly input?: unknown; + readonly origin?: unknown; + readonly steer?: unknown; +} + export const promptTurn = defineOp(TurnModel, 'turn.prompt', { - apply: (s, p: { turnId: number }): TurnModelState => advanceTurnId(s, p.turnId), + apply: (s, p: PromptTurnPayload): TurnModelState => { + if (Number.isInteger(p.turnId) && p.turnId >= s.nextTurnId) { + return { nextTurnId: p.turnId + 1 }; + } + if (!Number.isInteger(p.turnId)) { + return { nextTurnId: s.nextTurnId + 1 }; + } + return s; + }, }); /** @deprecated Legacy 1.5 record type; kept registered for replay of old sessions. */ diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index c33d2c40e..49872dfe7 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -20,14 +20,14 @@ import type { TurnEndedEvent, TurnStartedEvent } from '@moonshot-ai/protocol'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ErrorCodes, KimiError, toKimiErrorPayload } from '#/errors'; -import { USER_PROMPT_ORIGIN, type PromptOrigin } from '#/agent/contextMemory/types'; +import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; -import type { Turn, TurnResult } from './turn'; +import type { Turn, TurnPromptInfo, TurnResult } from './turn'; import { IAgentTurnService } from './turn'; import { promptTurn, TurnModel } from './turnOps'; @@ -52,7 +52,7 @@ export class AgentTurnService implements IAgentTurnService { @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, ) {} - launch(origin: PromptOrigin = USER_PROMPT_ORIGIN): Turn { + launch(prompt?: TurnPromptInfo): Turn { if (this.activeTurn !== undefined) { throw new KimiError( ErrorCodes.TURN_AGENT_BUSY, @@ -62,7 +62,14 @@ export class AgentTurnService implements IAgentTurnService { } const turnId = this.wire.getModel(TurnModel).nextTurnId; - this.wire.dispatch(promptTurn({ turnId })); + this.wire.dispatch( + promptTurn({ + turnId, + input: prompt?.input, + origin: prompt?.origin, + steer: prompt?.steer, + }), + ); const abortController = new AbortController(); const ready = createControlledPromise(); const turn: MutableTurn = { @@ -73,7 +80,7 @@ export class AgentTurnService implements IAgentTurnService { }; void ready.catch(() => undefined); this.activeTurn = turn; - this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin }); + this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: prompt?.origin ?? USER_PROMPT_ORIGIN }); turn.result = this.runTurn(turn, ready); return turn; } diff --git a/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts b/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts new file mode 100644 index 000000000..b5d72a251 --- /dev/null +++ b/packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts @@ -0,0 +1,120 @@ +/** + * `wireRecord` domain (L4) — v1.4 wire serializer for the agent scope. + * + * Rewrites Agent-scope persisted records into the v1-compatible wire shape + * while keeping live in-memory emissions native to v2. + */ + +import type { PersistedRecord } from '#/wire/wireService'; + +const DROPPED_RECORD_TYPES: ReadonlySet = new Set([ + 'context_size.measured', + 'task.started', + 'task.terminated', + 'skill.activate', + 'cron.add', + 'cron.delete', + 'cron.cursor', +]); + +export function serializeV1WireRecord(record: PersistedRecord): readonly PersistedRecord[] { + if (DROPPED_RECORD_TYPES.has(record.type)) return []; + const records = reshape(record); + return records.map((out) => { + if (out.type === 'metadata' || out['time'] !== undefined) return out; + return { ...(out as Record), time: Date.now() } as PersistedRecord; + }); +} + +function reshape(record: PersistedRecord): readonly PersistedRecord[] { + switch (record.type) { + case 'metadata': { + const out: Record = { type: 'metadata' }; + for (const key of ['protocol_version', 'created_at', 'app_version', 'kimi_version', 'producer', 'resumed']) { + if (record[key] !== undefined) out[key] = record[key]; + } + return [out as PersistedRecord]; + } + case 'context.append_message': { + const message = record['message'] as { role?: unknown } | undefined; + if (message?.role !== 'user') return []; + return [record]; + } + case 'context.splice': { + const messages = record['messages'] as + | readonly { role?: string; content?: unknown; origin?: { kind?: string } }[] + | undefined; + if (!Array.isArray(messages) || messages.length === 0) return []; + const out: PersistedRecord[] = []; + for (const message of messages) { + if (message?.origin?.kind === 'injection') { + out.push({ type: 'context.append_message', message }); + } + } + return out; + } + case 'todo.set': + return [ + { + type: 'tools.update_store', + key: 'todo', + value: record['todos'], + }, + ]; + case 'plan_mode.enter': + case 'plan_mode.exit': + case 'plan_mode.cancel': + return [ + { + type: record.type, + id: record['id'], + }, + ]; + case 'context.apply_compaction': { + return [ + { + type: 'context.apply_compaction', + summary: record['summary'], + compactedCount: record['compactedCount'], + tokensBefore: record['tokensBefore'], + tokensAfter: record['tokensAfter'], + } as PersistedRecord, + ]; + } + case 'full_compaction.begin': + return [record]; + case 'full_compaction.complete': + return [{ type: 'full_compaction.complete' } as PersistedRecord]; + case 'usage.record': { + const rest: Record = { ...(record as Record) }; + const context = rest['context'] as + | { type?: string; requestKind?: string } + | undefined; + let usageScope = context?.type ?? rest['usageScope']; + if (context?.type === 'operation' && context.requestKind === 'full_compaction') { + usageScope = 'session'; + } + delete rest['context']; + delete rest['turnId']; + return [ + { + ...rest, + type: 'usage.record', + usageScope, + } as PersistedRecord, + ]; + } + case 'turn.prompt': { + if (record['input'] === undefined) return []; + const out: Record = { + type: 'turn.prompt', + input: record['input'], + origin: record['origin'] ?? { kind: 'user' }, + }; + if (record['steer'] !== undefined) out['steer'] = record['steer']; + return [out as PersistedRecord]; + } + default: + return [record]; + } +} diff --git a/packages/agent-core-v2/src/app/model/envOverlay.ts b/packages/agent-core-v2/src/app/model/envOverlay.ts index 39b2b3742..51f64773b 100644 --- a/packages/agent-core-v2/src/app/model/envOverlay.ts +++ b/packages/agent-core-v2/src/app/model/envOverlay.ts @@ -162,6 +162,16 @@ export const kimiModelEnvOverlay: ConfigEffectiveOverlay = { effective['models'] = validate('models', nextModels); changed.push('models'); + const providers = asRecord(effective['providers']); + const envProvider = asRecord(providers[ENV_MODEL_PROVIDER_KEY]); + if (envProvider['type'] === undefined) { + effective['providers'] = validate('providers', { + ...providers, + [ENV_MODEL_PROVIDER_KEY]: { ...envProvider, type: 'kimi' }, + }); + changed.push('providers'); + } + effective['defaultModel'] = ENV_MODEL_ALIAS_KEY; changed.push('defaultModel'); diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 26832ff14..e5ec53d18 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -299,6 +299,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true, forkedFrom: sourceId, archived: false, + lastPrompt: sourceMeta?.lastPrompt, custom: forkCustomMetadata(sourceMeta?.custom, opts.metadata), }); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 2fe1ce134..ac71cd53a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -56,6 +56,7 @@ import { AgentWireRecordService, WIRE_RECORD_FILENAME, } from '#/agent/wireRecord/wireRecordService'; +import { serializeV1WireRecord } from '#/agent/wireRecord/v1WireSerializer'; import { type WireMetadataPayload, wireMetadata } from '#/agent/wireRecord/metadataOps'; import { IAgentWireService } from '#/wire/tokens'; import { WireService } from '#/wire/wireServiceImpl'; @@ -150,7 +151,16 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } satisfies IAgentScopeContext, ], [IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{ homedir: agentHomedir }])], - [IAgentWireService, new SyncDescriptor(WireService, [{ logScope: agentScope, logKey: WIRE_RECORD_FILENAME }])], + [ + IAgentWireService, + new SyncDescriptor(WireService, [ + { + logScope: agentScope, + logKey: WIRE_RECORD_FILENAME, + serializeRecord: serializeV1WireRecord, + }, + ]), + ], [IAgentBlobService, new SyncDescriptor(AgentBlobServiceImpl)], [ IAgentMcpService, @@ -169,6 +179,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // enumerate every agent and relocate its wire log. await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir, + type: agentId === 'main' ? 'main' : 'sub', + parentAgentId: agentId === 'main' ? undefined : 'main', forkedFrom: opts.forkedFrom, labels: opts.labels, }); diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 6952198b7..be89f7807 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -15,6 +15,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface AgentMeta { /** Per-agent directory used as the wire-record `homedir` (persistence key). */ readonly homedir: string; + readonly type?: 'main' | 'sub' | 'independent'; + readonly parentAgentId?: string; /** Agent this one was forked / derived from (provenance only; not used by business logic). */ readonly forkedFrom?: string; /** diff --git a/packages/agent-core-v2/src/wire/wireServiceImpl.ts b/packages/agent-core-v2/src/wire/wireServiceImpl.ts index 2b17391e0..5cd716e16 100644 --- a/packages/agent-core-v2/src/wire/wireServiceImpl.ts +++ b/packages/agent-core-v2/src/wire/wireServiceImpl.ts @@ -78,6 +78,7 @@ export class CycleError extends Error { export interface WireServiceOptions { readonly logScope: string; readonly logKey: string; + readonly serializeRecord?: (record: PersistedRecord) => readonly PersistedRecord[]; } interface ModelInstance { @@ -230,8 +231,11 @@ export class WireService extends Disposable implements IWireService { inst.state = Object.freeze(op.descriptor.apply(prev, op.payload)); if (!group.silent) { const record = this.toRecord(op); - this.appendToWireLog(record, op.descriptor.model); this.emissionEmitter.fire({ type: 'record', record }); + const serialized = this.options.serializeRecord?.(record) ?? [record]; + for (const out of serialized) { + this.appendToWireLog(out, op.descriptor.model); + } const event = op.descriptor.toEvent?.(op.payload, inst.state); if (event !== undefined && this.eventBus !== undefined) { this.eventBus.publish(event as DomainEvent); @@ -284,26 +288,25 @@ export class WireService extends Disposable implements IWireService { // eslint-disable-next-line @typescript-eslint/no-explicit-any private appendToWireLog(record: PersistedRecord, model: ModelDef): void { if (this.log === undefined) return; - // When the model declares a blob codec and the blob service is available, - // every append rides the serialized queue so a record with no offloadable - // targets cannot leapfrog a pending dehydrate and reorder the log; otherwise - // append directly (no microtask, no queue). - if (this.blobService === undefined || model.blobs?.dehydrate === undefined) { + if (this.blobService === undefined) { this.log.append(this.options.logScope, this.options.logKey, record, { onError: onUnexpectedError, }); return; } - const dehydrate = model.blobs.dehydrate.bind(model.blobs); + const dehydrate = model.blobs?.dehydrate?.bind(model.blobs); const transform: PartsTransformer = (parts) => this.blobService!.offloadParts( parts as readonly ContentPart[], ) as Promise; this.persistQueue = this.persistQueue .then(async () => { - const prepared = dehydrate(record, transform); - const offloaded = isPromise(prepared) ? await prepared : prepared; - this.log?.append(this.options.logScope, this.options.logKey, offloaded, { + let out = record; + if (dehydrate !== undefined) { + const prepared = dehydrate(record, transform); + out = isPromise(prepared) ? await prepared : prepared; + } + this.log?.append(this.options.logScope, this.options.logKey, out, { onError: onUnexpectedError, }); }) diff --git a/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts b/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts new file mode 100644 index 000000000..0f3705316 --- /dev/null +++ b/packages/agent-core-v2/test/contextMemory/loop-event-fold-parity.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentContextMemoryService } from '#/index'; + +import { createTestAgent, type TestAgentContext } from '../harness'; + +describe('loop-event fold parity', () => { + let ctx: TestAgentContext; + let context: IAgentContextMemoryService; + + beforeEach(() => { + ctx = createTestAgent(); + context = ctx.get(IAgentContextMemoryService); + }); + + afterEach(async () => { + await ctx.dispose(); + }); + + function comparable(messages: readonly ContextMessage[]): unknown { + return messages.map((m) => ({ + role: m.role, + content: m.content, + toolCalls: m.toolCalls, + toolCallId: m.toolCallId, + isError: m.isError, + })); + } + + it('folds a text + tool-call + tool-result step into the append_message shape', () => { + context.append( + { + role: 'assistant', + content: [{ type: 'text', text: 'I will call.' }], + toolCalls: [{ type: 'function', id: 'c1', name: 'Lookup', arguments: '{"q":"moon"}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + toolCalls: [], + toolCallId: 'c1', + isError: false, + }, + ); + const baseline = comparable(context.get()); + context.clear(); + + context.appendLoopEvent({ type: 'step.begin', uuid: 's1' }); + context.appendLoopEvent({ + type: 'content.part', + stepUuid: 's1', + part: { type: 'text', text: 'I will call.' }, + }); + context.appendLoopEvent({ + type: 'tool.call', + stepUuid: 's1', + toolCallId: 'c1', + name: 'Lookup', + args: { q: 'moon' }, + }); + context.appendLoopEvent({ + type: 'tool.result', + toolCallId: 'c1', + result: { output: 'lookup result', isError: false }, + }); + context.appendLoopEvent({ type: 'step.end', uuid: 's1' }); + const folded = comparable(context.get()); + + expect(folded).toEqual(baseline); + }); + + it('folds an errored tool result into the append_message shape', () => { + context.append( + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c2', name: 'Bash', arguments: '{}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'ERROR: Tool execution failed.\nboom' }], + toolCalls: [], + toolCallId: 'c2', + isError: true, + }, + ); + const baseline = comparable(context.get()); + context.clear(); + + context.appendLoopEvent({ type: 'step.begin', uuid: 's2' }); + context.appendLoopEvent({ + type: 'tool.call', + stepUuid: 's2', + toolCallId: 'c2', + name: 'Bash', + args: {}, + }); + context.appendLoopEvent({ + type: 'tool.result', + toolCallId: 'c2', + result: { output: 'boom', isError: true }, + }); + context.appendLoopEvent({ type: 'step.end', uuid: 's2' }); + const folded = comparable(context.get()); + + expect(folded).toEqual(baseline); + }); +}); diff --git a/packages/agent-core-v2/test/contextMemory/stubs.ts b/packages/agent-core-v2/test/contextMemory/stubs.ts index 69179d3bb..34935fe66 100644 --- a/packages/agent-core-v2/test/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/contextMemory/stubs.ts @@ -17,6 +17,7 @@ import { type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps'; +import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import { ensureMessageId } from '#/agent/contextMemory/messageId'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; @@ -79,6 +80,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { messages.push(...stamped); publishSplice(eventBus, { start, deleteCount: 0, messages: [...stamped] }); }, + appendLoopEvent: () => {}, clear: () => { const deleteCount = messages.length; if (deleteCount === 0) return; @@ -145,6 +147,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { clear(): void { this.impl.clear(); } + appendLoopEvent(event: LoopRecordedEvent): void { + this.impl.appendLoopEvent(event); + } undo(count: number): UndoCut { return this.impl.undo(count); } diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts index 32eea23bc..94df5964d 100644 --- a/packages/agent-core-v2/test/externalHooks/integration.test.ts +++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts @@ -99,6 +99,7 @@ function stubContextMemory(): IAgentContextMemoryService & { append: (...inserted) => { messages.push(...inserted.map(ensureMessageId)); }, + appendLoopEvent: () => {}, clear: () => { messages.splice(0); }, diff --git a/packages/agent-core-v2/test/model/model.test.ts b/packages/agent-core-v2/test/model/model.test.ts index 183ce7970..c61f5f500 100644 --- a/packages/agent-core-v2/test/model/model.test.ts +++ b/packages/agent-core-v2/test/model/model.test.ts @@ -217,7 +217,7 @@ describe('kimiModelEnvOverlay', () => { KIMI_MODEL_NAME: 'kimi-for-coding', }); - expect(changed).toEqual(['models', 'defaultModel']); + expect(changed).toEqual(['models', 'providers', 'defaultModel']); expect(effective['defaultModel']).toBe(ENV_MODEL_ALIAS_KEY); expect(effective['models']).toEqual({ [ENV_MODEL_ALIAS_KEY]: { @@ -227,6 +227,21 @@ describe('kimiModelEnvOverlay', () => { capabilities: ['image_in', 'thinking'], }, }); + expect(effective['providers']).toEqual({ + [ENV_MODEL_PROVIDER_KEY]: { type: 'kimi' }, + }); + }); + + it('keeps an explicit env provider type instead of the kimi default', () => { + const { changed, effective } = applyKimiModelEnvOverlay( + { KIMI_MODEL_NAME: 'env-model' }, + { providers: { [ENV_MODEL_PROVIDER_KEY]: { type: 'openai', baseUrl: 'http://x' } } }, + ); + + expect(changed).toEqual(['models', 'defaultModel']); + expect(effective['providers']).toEqual({ + [ENV_MODEL_PROVIDER_KEY]: { type: 'openai', baseUrl: 'http://x' }, + }); }); it('preserves configured aliases while adding the env alias', () => { @@ -258,7 +273,7 @@ describe('kimiModelEnvOverlay', () => { KIMI_MODEL_MAX_TOKENS: '2048', }); - expect(changed).toEqual(['models', 'defaultModel', 'modelOverrides']); + expect(changed).toEqual(['models', 'providers', 'defaultModel', 'modelOverrides']); expect( (effective['models'] as Record)[ENV_MODEL_ALIAS_KEY], ).toEqual({ diff --git a/packages/agent-core-v2/test/turn/turn-ready.test.ts b/packages/agent-core-v2/test/turn/turn-ready.test.ts index de3ce2bf3..e538b3258 100644 --- a/packages/agent-core-v2/test/turn/turn-ready.test.ts +++ b/packages/agent-core-v2/test/turn/turn-ready.test.ts @@ -12,6 +12,7 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentTurnService } from '#/agent/turn/turn'; import { AgentTurnService } from '#/agent/turn/turnService'; import { TurnModel } from '#/agent/turn/turnOps'; +import { IAgentUsageService } from '#/agent/usage/usage'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { emptyUsage } from '#/app/llmProtocol/usage'; @@ -171,6 +172,11 @@ describe('AgentLoopService onStarted', () => { additionalServices: (reg) => { reg.defineInstance(IAgentContextMemoryService, stubContextMemory()); reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); + reg.defineInstance(IAgentUsageService, { + _serviceBrand: undefined, + record: () => {}, + status: () => ({}), + }); reg.definePartialInstance(IConfigService, { get: () => undefined as T, });