mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(agent-core-v2): align v1 wire records
This commit is contained in:
parent
e6d3b370f7
commit
e5ec0fb005
20 changed files with 404 additions and 95 deletions
|
|
@ -15,9 +15,9 @@
|
|||
* `begin` payload (and is persisted on the record for audit) but is not stored
|
||||
* in the Model; the result numbers are consumed live by the
|
||||
* `compaction.completed` signal and their durable effect (the summary message)
|
||||
* already lives in `contextMemory`. They are still carried on the `complete`
|
||||
* payload so the persisted record stays byte-compatible with the legacy wire
|
||||
* log, but `apply` ignores them and collapses to `idle`. Each `apply` returns
|
||||
* already lives in `contextMemory`. The live `complete` payload is empty; legacy
|
||||
* logs may still carry result numbers, and `apply` accepts and ignores them while
|
||||
* collapsing to `idle`. Each `apply` returns
|
||||
* the same reference on a no-op so the wire's reference-equality gate stays
|
||||
* quiet; it carries no non-determinism.
|
||||
*
|
||||
|
|
@ -84,7 +84,7 @@ export const fullCompactionCancel = defineOp(CompactionModel, 'full_compaction.c
|
|||
apply: (s): CompactionState => (s.phase === 'idle' ? s : { phase: 'idle' }),
|
||||
});
|
||||
|
||||
export type FullCompactionCompletePayload = FullCompactionCompleteData;
|
||||
export type FullCompactionCompletePayload = Partial<FullCompactionCompleteData>;
|
||||
|
||||
export const fullCompactionComplete = defineOp(CompactionModel, 'full_compaction.complete', {
|
||||
apply: (s, _p: FullCompactionCompletePayload): CompactionState =>
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ import {
|
|||
fullCompactionBegin,
|
||||
fullCompactionCancel,
|
||||
fullCompactionComplete,
|
||||
type FullCompactionCompletePayload,
|
||||
} from './compactionOps';
|
||||
import {
|
||||
type CompactionBeginData,
|
||||
type CompactionResult,
|
||||
type FullCompactionCompleteData,
|
||||
} from './types';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ declare module '#/agent/wireRecord/wireRecord' {
|
|||
interface WireRecordMap {
|
||||
'full_compaction.begin': CompactionBeginData;
|
||||
'full_compaction.cancel': {};
|
||||
'full_compaction.complete': FullCompactionCompleteData;
|
||||
'full_compaction.complete': FullCompactionCompletePayload;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,9 +207,9 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
return true;
|
||||
}
|
||||
|
||||
private markCompleted(active: ActiveCompaction, result: FullCompactionCompleteData): boolean {
|
||||
private markCompleted(active: ActiveCompaction): boolean {
|
||||
if (this._compacting !== active) return false;
|
||||
this.wire.dispatch(fullCompactionComplete(result));
|
||||
this.wire.dispatch(fullCompactionComplete({}));
|
||||
this._compacting = null;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -357,7 +357,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
|
||||
if (this._compacting !== active) throw compactionCancelledReason(active);
|
||||
this.lastCompactedTokenCount = finalResult.tokensAfter;
|
||||
if (!this.markCompleted(active, completeData(finalResult))) {
|
||||
if (!this.markCompleted(active)) {
|
||||
throw compactionCancelledReason(active);
|
||||
}
|
||||
const { contextSummary: _contextSummary, ...eventResult } = finalResult;
|
||||
|
|
@ -576,17 +576,6 @@ function collectSummary(finish: LLMRequestFinish): CompactionAttemptResult {
|
|||
return { summary, usage: finish.usage };
|
||||
}
|
||||
|
||||
function completeData(result: CompactionResult): FullCompactionCompleteData {
|
||||
return {
|
||||
compactedCount: result.compactedCount,
|
||||
tokensBefore: result.tokensBefore,
|
||||
tokensAfter: result.tokensAfter,
|
||||
keptUserMessageCount: result.keptUserMessageCount,
|
||||
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
|
||||
droppedCount: result.droppedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function historySafeToCompact(
|
||||
current: readonly ContextMessage[],
|
||||
original: readonly ContextMessage[],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const THINKING_SECTION = 'thinking';
|
|||
export const DEFAULT_THINKING_SECTION = 'defaultThinking';
|
||||
|
||||
export const ThinkingConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.enum(['auto', 'on', 'off']).optional(),
|
||||
effort: z.string().optional(),
|
||||
keep: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@
|
|||
* Op (`configUpdate`) for the agent's persistent configuration slice.
|
||||
*
|
||||
* Declares the persistent profile config — `cwd`, `modelAlias`, `profileName`,
|
||||
* the resolved `thinkingLevel`, and `systemPrompt` — as a wire Model (initial
|
||||
* the resolved thinking effort, and `systemPrompt` — as a wire Model (initial
|
||||
* `defaultProfileModel()`), plus the single Op whose `apply` is a pure merge of
|
||||
* an already-resolved payload. `thinkingLevel` is resolved to a `ThinkingEffort`
|
||||
* at the call site (via `resolveThinkingEffort` + the `thinking` config section)
|
||||
* and carried in the payload, so `apply` stays pure and a resumed agent restores
|
||||
* an already-resolved payload. Live records carry `thinkingEffort` (matching
|
||||
* the v1 wire field); legacy replay still accepts `thinkingLevel`. The value is
|
||||
* resolved to a `ThinkingEffort` at the call site (via `resolveThinkingEffort` +
|
||||
* the `thinking` config section) and carried in the payload, so `apply` stays
|
||||
* pure and a resumed agent restores
|
||||
* the persisted resolved value rather than re-resolving against a possibly-
|
||||
* drifted config. `modelCapabilities` is intentionally NOT in the Model — it is
|
||||
* derived live from `IModelResolver` so resume never pins stale capabilities.
|
||||
|
|
@ -27,6 +29,7 @@
|
|||
*/
|
||||
|
||||
import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { defineModel } from '#/wire/model';
|
||||
import { defineOp } from '#/wire/op';
|
||||
|
||||
|
|
@ -47,6 +50,7 @@ export interface ConfigUpdatePayload {
|
|||
readonly cwd?: string;
|
||||
readonly modelAlias?: string;
|
||||
readonly profileName?: string;
|
||||
readonly thinkingEffort?: ThinkingEffort;
|
||||
readonly thinkingLevel?: ThinkingEffort;
|
||||
readonly systemPrompt?: string;
|
||||
}
|
||||
|
|
@ -63,8 +67,9 @@ export const configUpdate = defineOp(ProfileModel, 'config.update', {
|
|||
if (p.profileName !== undefined && p.profileName !== s.profileName) {
|
||||
next = { ...(next ?? s), profileName: p.profileName };
|
||||
}
|
||||
if (p.thinkingLevel !== undefined && p.thinkingLevel !== s.thinkingLevel) {
|
||||
next = { ...(next ?? s), thinkingLevel: p.thinkingLevel };
|
||||
const thinkingLevel = configUpdateThinkingLevel(p);
|
||||
if (thinkingLevel !== undefined && thinkingLevel !== s.thinkingLevel) {
|
||||
next = { ...(next ?? s), thinkingLevel };
|
||||
}
|
||||
if (p.systemPrompt !== undefined && p.systemPrompt !== s.systemPrompt) {
|
||||
next = { ...(next ?? s), systemPrompt: p.systemPrompt };
|
||||
|
|
@ -73,6 +78,27 @@ export const configUpdate = defineOp(ProfileModel, 'config.update', {
|
|||
},
|
||||
});
|
||||
|
||||
function configUpdateThinkingLevel(p: ConfigUpdatePayload): ThinkingEffort | undefined {
|
||||
if (p.thinkingEffort !== undefined && p.thinkingLevel !== undefined) {
|
||||
if (p.thinkingEffort !== p.thinkingLevel) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.REQUEST_INVALID,
|
||||
`config.update has conflicting thinkingEffort (${p.thinkingEffort}) and legacy thinkingLevel (${p.thinkingLevel})`,
|
||||
{
|
||||
details: {
|
||||
type: 'config.update',
|
||||
thinkingEffort: p.thinkingEffort,
|
||||
thinkingLevel: p.thinkingLevel,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return p.thinkingEffort;
|
||||
}
|
||||
if (p.thinkingEffort !== undefined) return p.thinkingEffort;
|
||||
return p.thinkingLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's active-tool set. `undefined` means "every tool is active" (the
|
||||
* unrestricted default before any `tools.set_active_tools`); a concrete array
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import picomatch from 'picomatch';
|
|||
import { ErrorCodes, KimiError } from "#/errors";
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { resolveThinkingEffort, resolveThinkingKeep } from './thinking';
|
||||
import { resolveThinkingEffort, resolveThinkingKeep, resolveThinkingLevel } from './thinking';
|
||||
import type { LoopControl } from '#/agent/loop/configSection';
|
||||
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
|
||||
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
|
|
@ -63,6 +63,7 @@ import type {
|
|||
} from './profile';
|
||||
import { IAgentProfileService } from './profile';
|
||||
import {
|
||||
DEFAULT_THINKING_SECTION,
|
||||
THINKING_SECTION,
|
||||
type ThinkingConfig,
|
||||
} from './configSection';
|
||||
|
|
@ -153,22 +154,27 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
}
|
||||
// Resolve eagerly so an unknown model id fails the bind here rather than on
|
||||
// the first turn.
|
||||
this.modelFactory.resolve(input.model);
|
||||
const model = this.modelFactory.resolve(input.model);
|
||||
|
||||
const context = await this.buildSystemPromptContext(input.cwd);
|
||||
const systemPrompt = profile.systemPrompt(context);
|
||||
const { agentsMdWarning } = context;
|
||||
this.agentsMdWarning = agentsMdWarning;
|
||||
|
||||
const thinkingLevel = resolveThinkingLevel(input.thinking, {
|
||||
defaultThinking: this.config.get<boolean | undefined>(DEFAULT_THINKING_SECTION),
|
||||
thinking: this.config.get<ThinkingConfig>(THINKING_SECTION),
|
||||
model,
|
||||
});
|
||||
|
||||
this.update({
|
||||
cwd: input.cwd,
|
||||
profileName: profile.name,
|
||||
modelAlias: input.model,
|
||||
thinkingLevel: input.thinking,
|
||||
systemPrompt,
|
||||
activeToolNames: profile.tools,
|
||||
});
|
||||
this.wire.dispatch(configUpdate({}));
|
||||
this.setActiveTools(profile.tools);
|
||||
this.wire.dispatch(configUpdate({ modelAlias: input.model, thinkingEffort: thinkingLevel }));
|
||||
this.afterConfigDispatch({ modelAlias: input.model, thinkingLevel });
|
||||
|
||||
if (agentsMdWarning !== undefined) {
|
||||
this.eventBus.publish({
|
||||
|
|
@ -387,7 +393,7 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
if (changed.profileName !== undefined) payload.profileName = changed.profileName;
|
||||
if (changed.thinkingLevel !== undefined) {
|
||||
const model = this.resolveModelForThinking(changed.modelAlias);
|
||||
payload.thinkingLevel = resolveThinkingEffort(
|
||||
payload.thinkingEffort = resolveThinkingEffort(
|
||||
changed.thinkingLevel,
|
||||
this.config.get<ThinkingConfig>(THINKING_SECTION),
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -56,9 +56,13 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
|
||||
async prompt(message: ContextMessage): Promise<Turn | undefined> {
|
||||
const stamped = ensureMessageId(message);
|
||||
if (await this.blockedByHook(stamped, false)) {
|
||||
this.append(stamped);
|
||||
return undefined;
|
||||
}
|
||||
const turn = this.launch({ input: stamped.content, origin: stamped.origin });
|
||||
this.append(stamped);
|
||||
if (await this.blockedByHook(stamped, false)) return undefined;
|
||||
return this.launch({ input: stamped.content, origin: stamped.origin });
|
||||
return turn;
|
||||
}
|
||||
|
||||
steer(message: ContextMessage): PromptSteerHandle {
|
||||
|
|
|
|||
|
|
@ -65,11 +65,12 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
}
|
||||
|
||||
const turnId = this.wire.getModel(TurnModel).nextTurnId;
|
||||
const origin = prompt?.origin ?? USER_PROMPT_ORIGIN;
|
||||
this.wire.dispatch(
|
||||
promptTurn({
|
||||
turnId,
|
||||
input: prompt?.input,
|
||||
origin: prompt?.origin,
|
||||
origin,
|
||||
steer: prompt?.steer,
|
||||
}),
|
||||
);
|
||||
|
|
@ -83,7 +84,7 @@ export class AgentTurnService implements IAgentTurnService {
|
|||
};
|
||||
void ready.catch(() => undefined);
|
||||
this.activeTurn = turn;
|
||||
this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: prompt?.origin ?? USER_PROMPT_ORIGIN });
|
||||
this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin });
|
||||
turn.result = this.runTurn(turn, ready);
|
||||
return turn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage';
|
||||
import type { LLMRequestSource } from '#/agent/llmRequester/llmRequester';
|
||||
import type { AgentPhase } from '#/agent/runtime/runtime';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { defineModel } from '#/wire/model';
|
||||
import { defineOp } from '#/wire/op';
|
||||
|
||||
|
|
@ -46,29 +47,29 @@ export interface UsageModelState {
|
|||
|
||||
export const UsageModel = defineModel<UsageModelState>('usage', () => ({ byModel: {} }));
|
||||
|
||||
export interface UsageRecordPayload {
|
||||
readonly model: string;
|
||||
readonly usage: TokenUsage;
|
||||
readonly usageScope?: UsageRecordScope;
|
||||
readonly turnId?: number;
|
||||
readonly context?: LLMRequestSource;
|
||||
}
|
||||
|
||||
export const recordUsage = defineOp(UsageModel, 'usage.record', {
|
||||
apply: (
|
||||
s,
|
||||
p: {
|
||||
model: string;
|
||||
usage: TokenUsage;
|
||||
usageScope?: UsageRecordScope;
|
||||
context?: LLMRequestSource;
|
||||
},
|
||||
): UsageModelState => {
|
||||
apply: (s, p: UsageRecordPayload): UsageModelState => {
|
||||
const current = s.byModel[p.model];
|
||||
const byModel = {
|
||||
...s.byModel,
|
||||
[p.model]: current === undefined ? copyUsage(p.usage) : addUsage(current, p.usage),
|
||||
};
|
||||
|
||||
const source = p.context;
|
||||
if (source?.type !== 'turn') {
|
||||
const turnId = turnIdFromUsagePayload(p);
|
||||
if (turnId === undefined) {
|
||||
return { byModel, currentTurnId: s.currentTurnId, currentTurn: s.currentTurn };
|
||||
}
|
||||
|
||||
if (s.currentTurnId !== source.turnId) {
|
||||
return { byModel, currentTurnId: source.turnId, currentTurn: copyUsage(p.usage) };
|
||||
if (s.currentTurnId !== turnId) {
|
||||
return { byModel, currentTurnId: turnId, currentTurn: copyUsage(p.usage) };
|
||||
}
|
||||
return {
|
||||
byModel,
|
||||
|
|
@ -83,6 +84,26 @@ export const recordUsage = defineOp(UsageModel, 'usage.record', {
|
|||
}),
|
||||
});
|
||||
|
||||
function turnIdFromUsagePayload(p: UsageRecordPayload): number | undefined {
|
||||
const legacyContext = p.context;
|
||||
const legacyTurnId = legacyContext?.type === 'turn' ? legacyContext.turnId : undefined;
|
||||
if (p.turnId !== undefined && legacyTurnId !== undefined && p.turnId !== legacyTurnId) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.REQUEST_INVALID,
|
||||
`usage.record has conflicting turnId (${p.turnId}) and legacy context turnId (${legacyTurnId})`,
|
||||
{
|
||||
details: {
|
||||
type: 'usage.record',
|
||||
turnId: p.turnId,
|
||||
legacyTurnId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (p.turnId !== undefined) return p.turnId;
|
||||
return legacyTurnId;
|
||||
}
|
||||
|
||||
function copyUsage(usage: TokenUsage): TokenUsage {
|
||||
return { ...usage };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export class AgentUsageService extends Disposable implements IAgentUsageService
|
|||
|
||||
record(model: string, usage: TokenUsage, source?: LLMRequestSource): void {
|
||||
const usageScope: UsageRecordScope = source?.type === 'turn' ? 'turn' : 'session';
|
||||
this.wire.dispatch(recordUsage({ model, usage, usageScope, context: source }));
|
||||
const turnId = source?.type === 'turn' ? source.turnId : undefined;
|
||||
this.wire.dispatch(recordUsage({ model, usage, usageScope, turnId }));
|
||||
}
|
||||
|
||||
status(): UsageStatus {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] {
|
|||
return [out as PersistedRecord];
|
||||
}
|
||||
case 'context.append_message': {
|
||||
return [record];
|
||||
return [
|
||||
{
|
||||
...record,
|
||||
message: stripMessageId(record['message']),
|
||||
} as PersistedRecord,
|
||||
];
|
||||
}
|
||||
case 'context.splice': {
|
||||
const messages = record['messages'] as
|
||||
|
|
@ -46,7 +51,7 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] {
|
|||
const out: PersistedRecord[] = [];
|
||||
for (const message of messages) {
|
||||
if (message?.origin?.kind === 'injection') {
|
||||
out.push({ type: 'context.append_message', message });
|
||||
out.push({ type: 'context.append_message', message: stripMessageId(message) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -84,16 +89,15 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] {
|
|||
}
|
||||
return [out 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'];
|
||||
let usageScope = rest['usageScope'];
|
||||
if (usageScope === undefined && context !== undefined) {
|
||||
usageScope = context.type;
|
||||
}
|
||||
if (context?.type === 'operation' && context.requestKind === 'full_compaction') {
|
||||
usageScope = 'session';
|
||||
}
|
||||
|
|
@ -121,3 +125,10 @@ function reshape(record: PersistedRecord): readonly PersistedRecord[] {
|
|||
return [record];
|
||||
}
|
||||
}
|
||||
|
||||
function stripMessageId(message: unknown): unknown {
|
||||
if (message === null || typeof message !== 'object') return message;
|
||||
const { id: _id, ...rest } = message as Record<string, unknown>;
|
||||
void _id;
|
||||
return rest;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { resolveThinkingEffortForModel } from './thinking';
|
|||
/** Shape of the `thinking` config section (owned by `profile`); only the
|
||||
* fields the resolver needs to mirror the production default are read here. */
|
||||
interface ThinkingSection {
|
||||
readonly enabled?: boolean;
|
||||
readonly mode?: string;
|
||||
readonly effort?: string;
|
||||
}
|
||||
|
|
@ -175,6 +176,7 @@ export class ModelResolverService extends Disposable implements IModelResolver {
|
|||
undefined,
|
||||
{
|
||||
defaultThinking,
|
||||
enabled: thinking?.enabled,
|
||||
mode: thinking?.mode,
|
||||
effort: thinking?.effort,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { ThinkingEffort } from '#/app/llmProtocol/thinkingEffort';
|
|||
|
||||
export interface ThinkingDefaults {
|
||||
readonly defaultThinking?: boolean;
|
||||
readonly enabled?: boolean;
|
||||
readonly mode?: string;
|
||||
readonly effort?: string;
|
||||
}
|
||||
|
|
@ -99,7 +100,11 @@ export function resolveThinkingEffortForModel(
|
|||
: (normalized as ThinkingEffort);
|
||||
} else if (defaults?.mode === 'on') {
|
||||
effort = configured ?? enabledThinkingEffortForModel(model);
|
||||
} else if (defaults?.defaultThinking === false || defaults?.mode === 'off') {
|
||||
} else if (
|
||||
defaults?.enabled === false ||
|
||||
defaults?.defaultThinking === false ||
|
||||
defaults?.mode === 'off'
|
||||
) {
|
||||
effort = 'off';
|
||||
} else {
|
||||
effort = configured ?? defaultThinkingEffortForModel(model);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@
|
|||
* `agentId === 'main'`; `IAgentLifecycleService` itself knows nothing about
|
||||
* it. What *is* main-specific is session bootstrap business: the plugin
|
||||
* session-start service registers main-agent-only plugin guidance (matching
|
||||
* v1's `pluginSessionStarts: type === 'main' ? … : undefined`). `ensureMainAgent`
|
||||
* concentrates that business in one place so every bootstrapper (session
|
||||
* resume, legacy session/message services, the server edge) creates the main
|
||||
* agent the same way.
|
||||
* v1's `pluginSessionStarts: type === 'main' ? … : undefined`) and the
|
||||
* session cron service registers main-agent-only cron tools before the
|
||||
* main-created notification fires. `ensureMainAgent` concentrates that
|
||||
* business in one place so every bootstrapper (session resume, legacy
|
||||
* session/message services, the server edge) creates the main agent the same
|
||||
* way.
|
||||
*
|
||||
* Not a Service: a pure composition helper over the session handle.
|
||||
*/
|
||||
|
|
@ -16,6 +18,7 @@
|
|||
import type { ISessionScopeHandle, IAgentScopeHandle } from '#/_base/di/scope';
|
||||
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
|
||||
import type { BindAgentInput } from '#/agent/profile/profile';
|
||||
import { ISessionCronService } from '#/session/cron/sessionCronService';
|
||||
|
||||
import { IAgentLifecycleService } from './agentLifecycle';
|
||||
|
||||
|
|
@ -35,6 +38,7 @@ export async function ensureMainAgent(
|
|||
opts?: EnsureMainAgentOptions,
|
||||
): Promise<IAgentScopeHandle> {
|
||||
const agents = session.accessor.get(IAgentLifecycleService);
|
||||
session.accessor.get(ISessionCronService);
|
||||
const existing = agents.getHandle(MAIN_AGENT_ID);
|
||||
if (existing !== undefined) return existing;
|
||||
const main = await agents.create({ agentId: MAIN_AGENT_ID, binding: opts?.binding });
|
||||
|
|
|
|||
|
|
@ -59,8 +59,7 @@ describe('fullCompaction ops (wire-backed)', () => {
|
|||
wire.dispatch(fullCompactionBegin({ source: 'manual', instruction: 'keep facts' }));
|
||||
expect(wire.getModel(CompactionModel).phase).toBe('running');
|
||||
|
||||
// Result numbers ride the payload (persisted) but collapse back to idle.
|
||||
wire.dispatch(fullCompactionComplete({ compactedCount: 2, tokensBefore: 100, tokensAfter: 20 }));
|
||||
wire.dispatch(fullCompactionComplete({}));
|
||||
expect(wire.getModel(CompactionModel).phase).toBe('idle');
|
||||
|
||||
wire.dispatch(fullCompactionBegin({ source: 'auto' }));
|
||||
|
|
@ -84,14 +83,7 @@ describe('fullCompaction ops (wire-backed)', () => {
|
|||
instruction: 'keep facts',
|
||||
}),
|
||||
);
|
||||
expect(records[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'full_compaction.complete',
|
||||
compactedCount: 2,
|
||||
tokensBefore: 100,
|
||||
tokensAfter: 20,
|
||||
}),
|
||||
);
|
||||
expect(records[1]).toEqual({ type: 'full_compaction.complete' });
|
||||
});
|
||||
|
||||
it('apply returns the same reference on a no-op (gate stays quiet)', () => {
|
||||
|
|
@ -132,4 +124,15 @@ describe('fullCompaction ops (wire-backed)', () => {
|
|||
await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' });
|
||||
expect(stranded.wire.getModel(CompactionModel).phase).toBe('running');
|
||||
});
|
||||
|
||||
it('replays legacy complete payloads that carried accounting numbers', async () => {
|
||||
const host = buildHost('full-compaction-legacy-complete-replay');
|
||||
|
||||
await host.wire.replay(
|
||||
{ type: 'full_compaction.begin', source: 'manual' },
|
||||
{ type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 },
|
||||
);
|
||||
|
||||
expect(host.wire.getModel(CompactionModel).phase).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { serializeV1WireRecord } from '#/agent/wireRecord/v1WireSerializer';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
|
||||
import { createTestAgent, hostEnvironmentServices, type TestAgentContext } from '../harness';
|
||||
import {
|
||||
InMemoryWireRecordPersistence,
|
||||
createTestAgent,
|
||||
hostEnvironmentServices,
|
||||
type TestAgentContext,
|
||||
} from '../harness';
|
||||
|
||||
const MOCK_MODEL = 'mock-model';
|
||||
|
||||
|
|
@ -52,6 +60,59 @@ describe('AgentProfileService.bind', () => {
|
|||
expect(svc.getSystemPrompt()).toContain('Kimi Code CLI');
|
||||
});
|
||||
|
||||
it('persists bind bootstrap records in the v1-compatible order', async () => {
|
||||
const persistence = new InMemoryWireRecordPersistence();
|
||||
ctx = createTestAgent(
|
||||
{
|
||||
persistence,
|
||||
initialConfig: {
|
||||
thinking: { enabled: true, effort: 'low' },
|
||||
},
|
||||
},
|
||||
hostEnvironmentServices(homeDir),
|
||||
);
|
||||
const svc = ctx.get(IAgentProfileService);
|
||||
await ctx.get(IAgentWireService).flush();
|
||||
const start = persistence.records.length;
|
||||
|
||||
await svc.bind({
|
||||
profile: DEFAULT_AGENT_PROFILE_NAME,
|
||||
model: MOCK_MODEL,
|
||||
thinking: 'low',
|
||||
cwd: homeDir,
|
||||
});
|
||||
await ctx.get(IAgentWireService).flush();
|
||||
|
||||
const records = persistence.records
|
||||
.slice(start)
|
||||
.flatMap((record) => [
|
||||
...serializeV1WireRecord(record as unknown as PersistedRecord),
|
||||
])
|
||||
.filter(
|
||||
(record) =>
|
||||
record.type === 'config.update' || record.type === 'tools.set_active_tools',
|
||||
);
|
||||
expect(records).toHaveLength(3);
|
||||
expect(records[0]).toMatchObject({
|
||||
type: 'config.update',
|
||||
cwd: homeDir,
|
||||
profileName: DEFAULT_AGENT_PROFILE_NAME,
|
||||
systemPrompt: expect.stringContaining('Kimi Code CLI'),
|
||||
});
|
||||
expect(records[0]).not.toHaveProperty('modelAlias');
|
||||
expect(records[0]).not.toHaveProperty('thinkingEffort');
|
||||
expect(records[1]).toMatchObject({
|
||||
type: 'tools.set_active_tools',
|
||||
names: expect.arrayContaining(['Read', 'Write', 'Bash']),
|
||||
});
|
||||
expect(records[2]).toMatchObject({
|
||||
type: 'config.update',
|
||||
modelAlias: MOCK_MODEL,
|
||||
thinkingEffort: 'low',
|
||||
});
|
||||
expect(records[2]).not.toHaveProperty('thinkingLevel');
|
||||
});
|
||||
|
||||
it('setModel applies the default profile when none is bound yet', async () => {
|
||||
const { profile: svc } = buildContext();
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ function createRecordingModel(
|
|||
}
|
||||
|
||||
describe('AgentProfileService (wire-backed config.update)', () => {
|
||||
it('update persists a flat config.update record and resolves thinkingLevel at the call site', async () => {
|
||||
it('update persists a flat config.update record and resolves thinkingLevel as wire thinkingEffort at the call site', async () => {
|
||||
svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.' });
|
||||
svc.update({ thinkingLevel: 'on' });
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
profileName: DEFAULT_AGENT_PROFILE_NAME,
|
||||
systemPrompt: 'You are helpful.',
|
||||
},
|
||||
{ type: 'config.update', thinkingLevel: 'on' },
|
||||
{ type: 'config.update', thinkingEffort: 'on' },
|
||||
]);
|
||||
expect(records.every((record) => 'payload' in record === false)).toBe(true);
|
||||
});
|
||||
|
|
@ -250,7 +250,7 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
},
|
||||
});
|
||||
|
||||
void host.wire.replay(...records);
|
||||
await host.wire.replay(...records);
|
||||
expect(modelOf(host.wire).cwd).toBe('/work');
|
||||
expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME);
|
||||
expect(replayChdir).toBe(0);
|
||||
|
|
@ -270,10 +270,26 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
// Fresh host whose config section would resolve differently is irrelevant:
|
||||
// the persisted resolved value ('on') is restored verbatim.
|
||||
const host = buildHost('profile-replay-thinking');
|
||||
void host.wire.replay(...records);
|
||||
await host.wire.replay(...records);
|
||||
expect(modelOf(host.wire).thinkingLevel).toBe('on');
|
||||
});
|
||||
|
||||
it('replays legacy config.update thinkingLevel records', async () => {
|
||||
const host = buildHost('profile-replay-legacy-thinking-level');
|
||||
|
||||
await host.wire.replay({ type: 'config.update', thinkingLevel: 'high' });
|
||||
|
||||
expect(modelOf(host.wire).thinkingLevel).toBe('high');
|
||||
});
|
||||
|
||||
it('rejects conflicting config.update thinking aliases during replay', async () => {
|
||||
const host = buildHost('profile-replay-conflicting-thinking-aliases');
|
||||
|
||||
await expect(
|
||||
host.wire.replay({ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }),
|
||||
).rejects.toThrow('conflicting thinkingEffort');
|
||||
});
|
||||
|
||||
it('applies thinking.keep model override when thinking is enabled', () => {
|
||||
const generationKwargs: GenerationKwargs[] = [];
|
||||
const thinkingEfforts: ThinkingEffort[] = [];
|
||||
|
|
|
|||
|
|
@ -88,6 +88,29 @@ describe('AgentPromptService', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('launches the turn before appending the user message', async () => {
|
||||
const { context, prompt, turn } = createHarness();
|
||||
const events: string[] = [];
|
||||
const originalLaunch = turn.launch.bind(turn);
|
||||
turn.launch = (...args) => {
|
||||
events.push('turn.launch');
|
||||
return originalLaunch(...args);
|
||||
};
|
||||
const originalAppend = context.append.bind(context);
|
||||
context.append = (...messages) => {
|
||||
events.push('context.append');
|
||||
originalAppend(...messages);
|
||||
};
|
||||
|
||||
await prompt.prompt(userMessage('ordered', { kind: 'user' }));
|
||||
|
||||
expect(events).toEqual(['turn.launch', 'context.append']);
|
||||
expect(turn.launches).toEqual([0]);
|
||||
expect(context.messages.map((message) => message.content[0])).toMatchObject([
|
||||
{ type: 'text', text: 'ordered' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs submit hooks before queuing active steers', async () => {
|
||||
const { context, loop, prompt, turn } = createHarness({ hasActiveTurn: true });
|
||||
const activeTurn = turn.launch();
|
||||
|
|
@ -163,7 +186,12 @@ describe('AgentPromptService', () => {
|
|||
|
||||
expect(result).toBeUndefined();
|
||||
expect(turn.launches).toEqual([]);
|
||||
expect(context.messages).toHaveLength(1);
|
||||
expect(context.messages).toMatchObject([
|
||||
{
|
||||
content: [{ type: 'text', text: 'blocked' }],
|
||||
origin: { kind: 'system_trigger', name: 'test_block' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('delivers a declared steer through onDidExecuteTool and strips delivery', async () => {
|
||||
|
|
|
|||
|
|
@ -392,11 +392,11 @@ describe('AgentTurnService wire state', () => {
|
|||
expect(ix.get(IAgentWireService).getModel(TurnModel)).toEqual({ nextTurnId: 1 });
|
||||
});
|
||||
|
||||
it('dispatch persists a flat { type, turnId } record (no payload key)', async () => {
|
||||
it('dispatch persists a flat record with the default user origin at the source', async () => {
|
||||
turnService.launch();
|
||||
|
||||
const records = await readRecords();
|
||||
expect(records).toEqual([{ type: 'turn.prompt', turnId: 0 }]);
|
||||
expect(records).toEqual([{ type: 'turn.prompt', turnId: 0, origin: { kind: 'user' } }]);
|
||||
expect('payload' in records[0]!).toBe(false);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt
|
|||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
|
|
@ -46,6 +46,17 @@ async function readRecords(): Promise<PersistedRecord[]> {
|
|||
return out;
|
||||
}
|
||||
|
||||
function createFreshWire(logKey: string): { readonly fresh: IWireService; readonly freshLog: IAppendLogStore } {
|
||||
const freshIx = disposables.add(new TestInstantiationService());
|
||||
freshIx.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
freshIx.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
freshIx.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey }]));
|
||||
return {
|
||||
fresh: freshIx.get(IAgentWireService),
|
||||
freshLog: freshIx.get(IAppendLogStore),
|
||||
};
|
||||
}
|
||||
|
||||
const a1 = { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 };
|
||||
const a2 = { inputOther: 10, output: 20, inputCacheRead: 30, inputCacheCreation: 40 };
|
||||
const b1 = { inputOther: 100, output: 200, inputCacheRead: 300, inputCacheCreation: 400 };
|
||||
|
|
@ -130,7 +141,7 @@ describe('AgentUsageService (wire-backed)', () => {
|
|||
expect('payload' in records[0]!).toBe(false);
|
||||
});
|
||||
|
||||
it('marks turn-scoped sources with usageScope: turn for v1 wire compatibility', async () => {
|
||||
it('marks turn-scoped sources with usageScope and turnId without persisting request context', async () => {
|
||||
svc.record('model-a', a1, { type: 'turn', turnId: 7, step: 2 });
|
||||
|
||||
const records = await readRecords();
|
||||
|
|
@ -140,7 +151,7 @@ describe('AgentUsageService (wire-backed)', () => {
|
|||
model: 'model-a',
|
||||
usage: a1,
|
||||
usageScope: 'turn',
|
||||
context: { type: 'turn', turnId: 7, step: 2 },
|
||||
turnId: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -150,26 +161,50 @@ describe('AgentUsageService (wire-backed)', () => {
|
|||
svc.record('model-a', a2, { type: 'turn', turnId: 1 });
|
||||
const records = await readRecords();
|
||||
|
||||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'usage-replay' }]),
|
||||
);
|
||||
const log2 = ix2.get(IAppendLogStore);
|
||||
const fresh = ix2.get(IAgentWireService);
|
||||
const { fresh, freshLog } = createFreshWire('usage-replay');
|
||||
|
||||
void fresh.replay(...records);
|
||||
await fresh.replay(...records);
|
||||
|
||||
expect(fresh.getModel(UsageModel).byModel).toEqual({
|
||||
'model-a': { inputOther: 11, output: 22, inputCacheRead: 33, inputCacheCreation: 44 },
|
||||
});
|
||||
|
||||
const written: PersistedRecord[] = [];
|
||||
for await (const record of log2.read<PersistedRecord>(SCOPE, 'usage-replay')) {
|
||||
for await (const record of freshLog.read<PersistedRecord>(SCOPE, 'usage-replay')) {
|
||||
written.push(record);
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
|
||||
it('replays legacy turn context records into current turn usage', async () => {
|
||||
const { fresh } = createFreshWire('usage-legacy-context-replay');
|
||||
|
||||
await fresh.replay({
|
||||
type: 'usage.record',
|
||||
model: 'model-a',
|
||||
usage: a1,
|
||||
usageScope: 'turn',
|
||||
context: { type: 'turn', turnId: 9, step: 3 },
|
||||
});
|
||||
|
||||
expect(fresh.getModel(UsageModel)).toMatchObject({
|
||||
currentTurnId: 9,
|
||||
currentTurn: a1,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects conflicting usage turn ids during replay', async () => {
|
||||
const fresh = ix.get(IAgentWireService);
|
||||
|
||||
await expect(
|
||||
fresh.replay({
|
||||
type: 'usage.record',
|
||||
model: 'model-a',
|
||||
usage: a1,
|
||||
usageScope: 'turn',
|
||||
turnId: 1,
|
||||
context: { type: 'turn', turnId: 2 },
|
||||
}),
|
||||
).rejects.toThrow('conflicting turnId');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,101 @@ describe('serializeV1WireRecord', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('strips v2 message ids from context.append_message records', () => {
|
||||
const message: ContextMessage = {
|
||||
id: 'msg_test',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
};
|
||||
|
||||
expect(
|
||||
serializeV1WireRecord({
|
||||
type: 'context.append_message',
|
||||
message,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
},
|
||||
time: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips v2 message ids from injected context.splice projections', () => {
|
||||
const message: ContextMessage = {
|
||||
id: 'msg_injection',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'reminder' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'test' },
|
||||
};
|
||||
|
||||
expect(
|
||||
serializeV1WireRecord({
|
||||
type: 'context.splice',
|
||||
start: 0,
|
||||
deleteCount: 0,
|
||||
messages: [message],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'reminder' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'test' },
|
||||
},
|
||||
time: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes source-shaped config.update records through without field projection', () => {
|
||||
expect(
|
||||
serializeV1WireRecord({
|
||||
type: 'config.update',
|
||||
modelAlias: 'mock-model',
|
||||
thinkingEffort: 'low',
|
||||
} satisfies PersistedRecord),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'config.update',
|
||||
modelAlias: 'mock-model',
|
||||
thinkingEffort: 'low',
|
||||
time: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeV1WireRecord({ type: 'config.update' } satisfies PersistedRecord)).toEqual([
|
||||
{ type: 'config.update', time: expect.any(Number) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults missing turn.prompt origin to the v1 user origin', () => {
|
||||
expect(
|
||||
serializeV1WireRecord({
|
||||
type: 'turn.prompt',
|
||||
input: [{ type: 'text', text: 'hello' }],
|
||||
} satisfies PersistedRecord),
|
||||
).toEqual([
|
||||
{
|
||||
type: 'turn.prompt',
|
||||
input: [{ type: 'text', text: 'hello' }],
|
||||
origin: { kind: 'user' },
|
||||
time: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves v1 context.apply_compaction accounting fields', () => {
|
||||
expect(
|
||||
serializeV1WireRecord({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue