fix(agent-core-v2): align v1.4 wire parity

This commit is contained in:
7Sageer 2026-07-08 14:33:17 +08:00
parent bc043792e8
commit d5e1d76fc2
32 changed files with 529 additions and 139 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep sessions from the new agent engine compatible with existing transcript replay.

View file

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before After
Before After

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

@ -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<string, unknown>;
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 {

View file

@ -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<CompactionBeginData>,
initialCompactedCount: number,
): Promise<CompactionResult> {
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();

View file

@ -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 {

View file

@ -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);

View file

@ -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 = '<system>ERROR: Tool execution failed.</system>';
const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>';
const TOOL_EMPTY_ERROR_STATUS =
'<system>ERROR: Tool execution failed. Tool output is empty.</system>';
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<string, string>();
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('<system>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(

View file

@ -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<void> {

View file

@ -168,6 +168,7 @@ export class AgentProfileService implements IAgentProfileService {
systemPrompt,
activeToolNames: profile.tools,
});
this.wire.dispatch(configUpdate({}));
if (agentsMdWarning !== undefined) {
this.eventBus.publish({

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -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 {

View file

@ -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(

View file

@ -15,10 +15,16 @@ export interface Turn {
readonly result: Promise<LoopRunResult>;
}
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;
}

View file

@ -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. */

View file

@ -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<void>();
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;
}

View file

@ -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<string> = 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<string, unknown>), time: Date.now() } as PersistedRecord;
});
}
function reshape(record: PersistedRecord): readonly PersistedRecord[] {
switch (record.type) {
case 'metadata': {
const out: Record<string, unknown> = { 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<string, unknown> = { ...(record as Record<string, unknown>) };
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<string, unknown> = {
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];
}
}

View file

@ -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');

View file

@ -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),
});

View file

@ -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,
});

View file

@ -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;
/**

View file

@ -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<any>): 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<readonly unknown[]>;
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,
});
})

View file

@ -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: '<system>ERROR: Tool execution failed.</system>\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);
});
});

View file

@ -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);
}

View file

@ -99,6 +99,7 @@ function stubContextMemory(): IAgentContextMemoryService & {
append: (...inserted) => {
messages.push(...inserted.map(ensureMessageId));
},
appendLoopEvent: () => {},
clear: () => {
messages.splice(0);
},

View file

@ -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<string, unknown>)[ENV_MODEL_ALIAS_KEY],
).toEqual({

View file

@ -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: <T>() => undefined as T,
});