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 @@
-
\ No newline at end of file
+
\ 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,
});