mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(agent-core-v2): align v1.4 wire parity
This commit is contained in:
parent
bc043792e8
commit
d5e1d76fc2
32 changed files with 529 additions and 139 deletions
5
.changeset/v2-wire-record-parity.md
Normal file
5
.changeset/v2-wire-record-parity.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Keep sessions from the new agent engine compatible with existing transcript replay.
|
||||
|
|
@ -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 |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
systemPrompt,
|
||||
activeToolNames: profile.tools,
|
||||
});
|
||||
this.wire.dispatch(configUpdate({}));
|
||||
|
||||
if (agentsMdWarning !== undefined) {
|
||||
this.eventBus.publish({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
120
packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts
Normal file
120
packages/agent-core-v2/src/agent/wireRecord/v1WireSerializer.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ function stubContextMemory(): IAgentContextMemoryService & {
|
|||
append: (...inserted) => {
|
||||
messages.push(...inserted.map(ensureMessageId));
|
||||
},
|
||||
appendLoopEvent: () => {},
|
||||
clear: () => {
|
||||
messages.splice(0);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue