mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor: unify turn continuation handling
This commit is contained in:
parent
f060872ffc
commit
cc53ae6dd7
12 changed files with 220 additions and 101 deletions
|
|
@ -300,6 +300,7 @@ microCompaction --> loop #34495E
|
|||
externalHooks --> config #34495E
|
||||
externalHooks --> bootstrap #34495E
|
||||
externalHooks --> plugin #34495E
|
||||
externalHooks --> contextMemory #34495E
|
||||
todo --> agent_lifecycle #34495E
|
||||
usage --> wireRecord #34495E
|
||||
usage --> record #34495E
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 259 KiB |
|
|
@ -5,7 +5,8 @@
|
|||
* Listens to hook slots owned by the agent behavior/lifecycle domains
|
||||
* (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`, and
|
||||
* `task`) and translates those minimal contexts into the configured external
|
||||
* HookEngine events. The `SubagentStart` / `SubagentStop` pair is the one
|
||||
* HookEngine events. Appends Stop hook continuation prompts through
|
||||
* `contextMemory`. The `SubagentStart` / `SubagentStop` pair is the one
|
||||
* exception: the `agentLifecycle` tool wrapper has no hook service of its own,
|
||||
* so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on
|
||||
* this service directly.
|
||||
|
|
@ -17,12 +18,13 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
|||
import { isUserCancellation } from '#/_base/utils/abort';
|
||||
import { isPlainRecord } from '#/_base/utils/canonical-args';
|
||||
import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
type FullCompactionDidCompactContext,
|
||||
type FullCompactionWillCompactContext,
|
||||
} from '#/agent/fullCompaction';
|
||||
import { IAgentLoopService, type TurnWillStopContext } from '#/agent/loop';
|
||||
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
|
||||
import {
|
||||
IAgentPermissionGate,
|
||||
type PermissionApprovalResultContext,
|
||||
|
|
@ -81,6 +83,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
|
||||
constructor(
|
||||
private readonly options: ExternalHooksServiceOptions = {},
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IBootstrapService private readonly bootstrap: IBootstrapService,
|
||||
|
|
@ -198,14 +201,27 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
|
||||
private registerLoopHooks(loop: IAgentLoopService): void {
|
||||
this._register(
|
||||
loop.hooks.onWillStop.register('externalHooks', async (ctx, next) => {
|
||||
loop.hooks.afterStep.register('externalHooks', async (ctx, next) => {
|
||||
await next();
|
||||
if (
|
||||
ctx.stopReason === 'tool_calls' ||
|
||||
ctx.stopReason === 'filtered' ||
|
||||
ctx.continue
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reason = await this.runStop(ctx);
|
||||
if (reason !== undefined) {
|
||||
this.stopHookContinuationUsed = true;
|
||||
ctx.continuationPrompt = reason;
|
||||
this.context.splice(this.context.get().length, 0, [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: reason }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'system_trigger', name: 'stop_hook' },
|
||||
}]);
|
||||
ctx.continue = true;
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -326,7 +342,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
|
|||
);
|
||||
}
|
||||
|
||||
private async runStop(ctx: TurnWillStopContext): Promise<string | undefined> {
|
||||
private async runStop(ctx: TurnAfterStepContext): Promise<string | undefined> {
|
||||
ctx.signal.throwIfAborted();
|
||||
if (this.stopHookContinuationUsed) return undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -212,8 +212,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
);
|
||||
this._register(
|
||||
loopService.hooks.afterStep.register('goal-outcome-continuation', async (ctx, next) => {
|
||||
await next();
|
||||
this.handleAfterStep(ctx);
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
|
|
@ -467,7 +467,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
if (this.goalOutcomeContinuationTurns.has(ctx.turnId)) return;
|
||||
if (!isGoalOutcomeReminder(this.context.get().at(-1))) return;
|
||||
this.goalOutcomeContinuationTurns.add(ctx.turnId);
|
||||
ctx.continueTurn = true;
|
||||
ctx.continue = true;
|
||||
}
|
||||
|
||||
private async handleTurnEnded(ctx: TurnEndedContext): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createDecorator } from '#/_base/di';
|
||||
import type { TokenUsage } from '#/app/llmProtocol';
|
||||
import type { FinishReason, TokenUsage } from '#/app/llmProtocol';
|
||||
import type { Hooks } from '#/hooks';
|
||||
|
||||
import type { TurnResult } from './types';
|
||||
|
|
@ -12,7 +12,8 @@ export interface TurnBeforeStepContext {
|
|||
|
||||
export interface TurnAfterStepContext extends TurnBeforeStepContext {
|
||||
readonly usage: TokenUsage;
|
||||
continueTurn: boolean;
|
||||
readonly stopReason: FinishReason;
|
||||
continue: boolean;
|
||||
}
|
||||
|
||||
export interface TurnContextOverflowContext {
|
||||
|
|
@ -22,11 +23,6 @@ export interface TurnContextOverflowContext {
|
|||
handled: boolean;
|
||||
}
|
||||
|
||||
export interface TurnWillStopContext {
|
||||
readonly signal: AbortSignal;
|
||||
continuationPrompt?: string;
|
||||
}
|
||||
|
||||
export interface RunTurnOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
/** Fires on the first model response event for a step, or at step completion. */
|
||||
|
|
@ -39,7 +35,6 @@ export interface IAgentLoopService {
|
|||
beforeStep: TurnBeforeStepContext;
|
||||
afterStep: TurnAfterStepContext;
|
||||
onContextOverflow: TurnContextOverflowContext;
|
||||
onWillStop: TurnWillStopContext;
|
||||
}>;
|
||||
runTurn(turnId: number, options?: RunTurnOptions): Promise<TurnResult>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ import {
|
|||
isAbortError,
|
||||
isMaxStepsExceededError,
|
||||
} from './errors';
|
||||
import { IAgentLoopService, type RunTurnOptions, type TurnWillStopContext } from './loop';
|
||||
import {
|
||||
IAgentLoopService,
|
||||
type RunTurnOptions,
|
||||
type TurnAfterStepContext,
|
||||
} from './loop';
|
||||
import type { LoopInterruptReason, TurnResult } from './types';
|
||||
|
||||
const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>';
|
||||
|
|
@ -44,7 +48,6 @@ export class AgentLoopService implements IAgentLoopService {
|
|||
beforeStep: new OrderedHookSlot(),
|
||||
afterStep: new OrderedHookSlot(),
|
||||
onContextOverflow: new OrderedHookSlot(),
|
||||
onWillStop: new OrderedHookSlot<TurnWillStopContext>(),
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -64,62 +67,45 @@ export class AgentLoopService implements IAgentLoopService {
|
|||
const signal = options.signal ?? new AbortController().signal;
|
||||
this.profile.resolveModelContext();
|
||||
|
||||
let steps = 0;
|
||||
let activeStep: number | undefined;
|
||||
while (true) {
|
||||
let steps = 0;
|
||||
let activeStep: number | undefined;
|
||||
|
||||
try {
|
||||
activeStep = undefined;
|
||||
signal.throwIfAborted();
|
||||
|
||||
const maxSteps = this.config.get<LoopControl>(LOOP_CONTROL_SECTION)?.maxStepsPerTurn;
|
||||
while (true) {
|
||||
signal.throwIfAborted();
|
||||
|
||||
if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) {
|
||||
throw createMaxStepsExceededError(maxSteps);
|
||||
}
|
||||
|
||||
steps += 1;
|
||||
activeStep = steps;
|
||||
const stepResult = await this.executeLoopStep(
|
||||
turnId,
|
||||
signal,
|
||||
steps,
|
||||
options.onStepStarted,
|
||||
);
|
||||
activeStep = undefined;
|
||||
|
||||
if (stepResult.stopReason === 'filtered') {
|
||||
throw new KimiError(
|
||||
ErrorCodes.PROVIDER_FILTERED,
|
||||
'Provider safety policy blocked the response.',
|
||||
{
|
||||
name: 'ProviderFilteredError',
|
||||
details: { finishReason: 'filtered' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (stepResult.stopReason === 'tool_calls') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stepResult.continueTurn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const context: TurnWillStopContext = { signal };
|
||||
await this.hooks.onWillStop.run(context);
|
||||
if (context.continuationPrompt !== undefined) {
|
||||
this.append({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: context.continuationPrompt }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'system_trigger', name: 'stop_hook' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) {
|
||||
throw createMaxStepsExceededError(maxSteps);
|
||||
}
|
||||
|
||||
steps += 1;
|
||||
activeStep = steps;
|
||||
const stepResult = await this.executeLoopStep(
|
||||
turnId,
|
||||
signal,
|
||||
steps,
|
||||
options.onStepStarted,
|
||||
);
|
||||
activeStep = undefined;
|
||||
|
||||
if (stepResult.stopReason === 'filtered') {
|
||||
throw new KimiError(
|
||||
ErrorCodes.PROVIDER_FILTERED,
|
||||
'Provider safety policy blocked the response.',
|
||||
{
|
||||
name: 'ProviderFilteredError',
|
||||
details: { finishReason: 'filtered' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (stepResult.stopReason === 'tool_calls' || stepResult.continue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return { reason: 'completed', steps };
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal.aborted) {
|
||||
this.emitStepInterrupted(turnId, activeStep, 'aborted');
|
||||
|
|
@ -140,12 +126,13 @@ export class AgentLoopService implements IAgentLoopService {
|
|||
} catch (hookError) {
|
||||
return { reason: 'failed', error: hookError, steps };
|
||||
}
|
||||
if (context.handled) continue;
|
||||
if (context.handled) {
|
||||
activeStep = undefined;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { reason: 'failed', error, steps };
|
||||
}
|
||||
|
||||
return { reason: 'completed', steps };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +143,7 @@ export class AgentLoopService implements IAgentLoopService {
|
|||
onStepStarted: ((step: number) => void) | undefined,
|
||||
): Promise<{
|
||||
readonly stopReason: FinishReason;
|
||||
readonly continueTurn: boolean;
|
||||
readonly continue: boolean;
|
||||
}> {
|
||||
await this.hooks.beforeStep.run({ turnId, step: currentStep, signal });
|
||||
signal.throwIfAborted();
|
||||
|
|
@ -237,16 +224,24 @@ export class AgentLoopService implements IAgentLoopService {
|
|||
markStepStarted();
|
||||
this.emitStepCompleted(turnId, currentStep, stepUuid, usage, finishReason, response);
|
||||
|
||||
const afterStepContext = { turnId, step: currentStep, signal, usage, continueTurn: false };
|
||||
const afterStepContext: TurnAfterStepContext = {
|
||||
turnId,
|
||||
step: currentStep,
|
||||
signal,
|
||||
usage,
|
||||
stopReason: finishReason,
|
||||
continue: false,
|
||||
};
|
||||
try {
|
||||
await this.hooks.afterStep.run(afterStepContext);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal.aborted) throw error;
|
||||
// afterStep hook failures must not affect the turn result.
|
||||
}
|
||||
|
||||
return {
|
||||
stopReason: finishReason,
|
||||
continueTurn: finishReason !== 'tool_calls' && afterStepContext.continueTurn,
|
||||
continue: afterStepContext.continue,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
await next();
|
||||
});
|
||||
loopService.hooks.afterStep.register('prompt-service-steer', async (ctx, next) => {
|
||||
await next();
|
||||
if (this.flushSteerQueue()) {
|
||||
ctx.continueTurn = true;
|
||||
ctx.continue = true;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import {
|
|||
type TestInstantiationService,
|
||||
} from '#/_base/di/test';
|
||||
import { Event } from '#/_base/event';
|
||||
import { emptyUsage } from '#/app/llmProtocol/kosong';
|
||||
import { IAgentContextMemoryService, type ContextMessage } from '#/agent/contextMemory';
|
||||
import { IAgentTaskService } from '#/agent/task';
|
||||
import {
|
||||
AgentExternalHooksService,
|
||||
|
|
@ -19,7 +21,7 @@ import {
|
|||
hooksToToml,
|
||||
} from '#/agent/externalHooks/configSection';
|
||||
import { IAgentFullCompactionService } from '#/agent/fullCompaction';
|
||||
import { IAgentLoopService, type TurnWillStopContext } from '#/agent/loop';
|
||||
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
|
||||
import { IAgentPermissionGate } from '#/agent/permissionGate';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
|
||||
import { IAgentTurnService, type Turn } from '#/agent/turn';
|
||||
|
|
@ -55,6 +57,32 @@ function makeTurn(id: number): Turn {
|
|||
};
|
||||
}
|
||||
|
||||
function makeAfterStep(signal: AbortSignal): TurnAfterStepContext {
|
||||
return {
|
||||
turnId: 0,
|
||||
step: 1,
|
||||
signal,
|
||||
usage: emptyUsage(),
|
||||
stopReason: 'completed',
|
||||
continue: false,
|
||||
};
|
||||
}
|
||||
|
||||
function stubContextMemory(): IAgentContextMemoryService & {
|
||||
readonly messages: readonly ContextMessage[];
|
||||
} {
|
||||
const messages: ContextMessage[] = [];
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
get: () => [...messages],
|
||||
splice: (start, deleteCount, inserted) => {
|
||||
messages.splice(start, deleteCount, ...inserted);
|
||||
},
|
||||
hooks: createHooks(['onSpliced']) as IAgentContextMemoryService['hooks'],
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HookEngine integration', () => {
|
||||
it('blocks a dangerous Bash command and allows a safe one via a PreToolUse script hook', async () => {
|
||||
const engine = new HookEngine([
|
||||
|
|
@ -110,6 +138,7 @@ describe('HookEngine integration', () => {
|
|||
try {
|
||||
const loop = stubLoopWithHooks();
|
||||
const turnService = stubTurnWithHooks();
|
||||
const context = stubContextMemory();
|
||||
const stopInputs: unknown[] = [];
|
||||
const hookEngine = {
|
||||
trigger: async () => [],
|
||||
|
|
@ -126,6 +155,7 @@ describe('HookEngine integration', () => {
|
|||
reg.defineInstance(IBootstrapService, stubBootstrap());
|
||||
reg.definePartialInstance(IConfigService, {});
|
||||
reg.definePartialInstance(IPluginService, {});
|
||||
reg.defineInstance(IAgentContextMemoryService, context);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.defineInstance(IAgentTurnService, turnService);
|
||||
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
|
||||
|
|
@ -147,13 +177,29 @@ describe('HookEngine integration', () => {
|
|||
ix.get(IAgentExternalHooksService);
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
const first: TurnWillStopContext = { signal };
|
||||
await loop.hooks.onWillStop.run(first);
|
||||
expect(first.continuationPrompt).toBe('continue 1');
|
||||
const filtered: TurnAfterStepContext = {
|
||||
...makeAfterStep(signal),
|
||||
stopReason: 'filtered',
|
||||
};
|
||||
await loop.hooks.afterStep.run(filtered);
|
||||
expect(filtered.continue).toBe(false);
|
||||
expect(stopInputs).toEqual([]);
|
||||
expect(context.messages).toEqual([]);
|
||||
|
||||
const second: TurnWillStopContext = { signal };
|
||||
await loop.hooks.onWillStop.run(second);
|
||||
expect(second.continuationPrompt).toBeUndefined();
|
||||
const first = makeAfterStep(signal);
|
||||
await loop.hooks.afterStep.run(first);
|
||||
expect(first.continue).toBe(true);
|
||||
expect(context.messages.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'continue 1' }],
|
||||
origin: { kind: 'system_trigger', name: 'stop_hook' },
|
||||
}),
|
||||
);
|
||||
|
||||
const second = makeAfterStep(signal);
|
||||
await loop.hooks.afterStep.run(second);
|
||||
expect(second.continue).toBe(false);
|
||||
expect(stopInputs).toEqual([{ stopHookActive: false }]);
|
||||
|
||||
await turnService.hooks.onEnded.run({
|
||||
|
|
@ -161,9 +207,16 @@ describe('HookEngine integration', () => {
|
|||
result: { reason: 'completed' },
|
||||
});
|
||||
|
||||
const nextTurn: TurnWillStopContext = { signal };
|
||||
await loop.hooks.onWillStop.run(nextTurn);
|
||||
expect(nextTurn.continuationPrompt).toBe('continue 2');
|
||||
const nextTurn = makeAfterStep(signal);
|
||||
await loop.hooks.afterStep.run(nextTurn);
|
||||
expect(nextTurn.continue).toBe(true);
|
||||
expect(context.messages.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'continue 2' }],
|
||||
origin: { kind: 'system_trigger', name: 'stop_hook' },
|
||||
}),
|
||||
);
|
||||
expect(stopInputs).toEqual([{ stopHookActive: false }, { stopHookActive: false }]);
|
||||
} finally {
|
||||
ix?.dispose();
|
||||
|
|
|
|||
|
|
@ -1703,6 +1703,54 @@ describe('FullCompaction', () => {
|
|||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('does not reset the step budget after provider context overflow compaction', async () => {
|
||||
let callCount = 0;
|
||||
const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
throw new APIContextOverflowError(400, 'Context length exceeded', 'req-budget-overflow');
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return textResult('Budget compacted summary.');
|
||||
}
|
||||
await callbacks?.onMessagePart?.({ type: 'text', text: 'Should not run.' });
|
||||
return textResult('Should not run.');
|
||||
};
|
||||
const ctx = testAgent({
|
||||
generate,
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: { maxStepsPerTurn: 1 },
|
||||
},
|
||||
});
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
|
||||
});
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.newEvents();
|
||||
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Retry after provider overflow' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'turn.ended',
|
||||
args: expect.objectContaining({
|
||||
reason: 'failed',
|
||||
error: expect.objectContaining({
|
||||
code: 'loop.max_steps_exceeded',
|
||||
details: expect.objectContaining({
|
||||
maxSteps: 1,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await ctx.expectResumeMatches();
|
||||
});
|
||||
|
||||
it('preserves thinking effort when compacting after provider context overflow', async () => {
|
||||
let callCount = 0;
|
||||
const records: TelemetryRecord[] = [];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
||||
import { IAgentEventSinkService } from '#/agent/eventSink';
|
||||
import { IAgentGoalService, type AgentGoalService } from '#/agent/goal';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
|
||||
import { IAgentRecordService } from '#/agent/record';
|
||||
import { IAgentTurnService, type Turn, type TurnResult } from '#/agent/turn';
|
||||
import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord';
|
||||
|
|
@ -63,16 +63,17 @@ async function runGoalStep(loopService: IAgentLoopService, turn: Turn): Promise<
|
|||
step: 1,
|
||||
signal: turn.abortController.signal,
|
||||
};
|
||||
const afterStep = {
|
||||
const afterStep: TurnAfterStepContext = {
|
||||
turnId: turn.id,
|
||||
step: 1,
|
||||
signal: turn.abortController.signal,
|
||||
usage: zeroUsage,
|
||||
continueTurn: false,
|
||||
stopReason: 'completed' as const,
|
||||
continue: false,
|
||||
};
|
||||
await loopService.hooks.beforeStep.run(step);
|
||||
await loopService.hooks.afterStep.run(afterStep);
|
||||
return afterStep.continueTurn;
|
||||
return afterStep.continue;
|
||||
}
|
||||
|
||||
async function runStepUsageHooks(
|
||||
|
|
@ -81,12 +82,13 @@ async function runStepUsageHooks(
|
|||
turn: Turn,
|
||||
usage: TokenUsage,
|
||||
): Promise<boolean> {
|
||||
const afterStep = {
|
||||
const afterStep: TurnAfterStepContext = {
|
||||
turnId: turn.id,
|
||||
step: 1,
|
||||
signal: turn.abortController.signal,
|
||||
usage,
|
||||
continueTurn: false,
|
||||
stopReason: 'completed' as const,
|
||||
continue: false,
|
||||
};
|
||||
await loopService.hooks.afterStep.run(afterStep);
|
||||
return goals.getGoal().goal?.budget.overBudget === true;
|
||||
|
|
@ -664,12 +666,13 @@ describe('AgentGoalService core workflow hooks', () => {
|
|||
step: 1,
|
||||
signal: turn.abortController.signal,
|
||||
};
|
||||
const afterStep = {
|
||||
const afterStep: TurnAfterStepContext = {
|
||||
turnId: turn.id,
|
||||
step: 1,
|
||||
signal: turn.abortController.signal,
|
||||
usage: zeroUsage,
|
||||
continueTurn: false,
|
||||
stopReason: 'completed' as const,
|
||||
continue: false,
|
||||
};
|
||||
await loopService.hooks.beforeStep.run(step);
|
||||
|
||||
|
|
@ -677,7 +680,7 @@ describe('AgentGoalService core workflow hooks', () => {
|
|||
await loopService.hooks.afterStep.run(afterStep);
|
||||
await endTurn(turnService, turn);
|
||||
|
||||
expect(afterStep.continueTurn).toBe(true);
|
||||
expect(afterStep.continue).toBe(true);
|
||||
expect(goals.getGoal().goal).toBeNull();
|
||||
expect(turnService.launches).toEqual([]);
|
||||
expect(context.get().at(-1)?.origin).toEqual({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import { IAgentProfileService } from '#/index';
|
||||
import { IAgentLLMRequesterService, type LLMStreamTiming } from '#/agent/llmRequester';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
||||
import { IAgentLoopService } from '#/agent/loop';
|
||||
import type { ExecutableTool } from '#/agent/tool';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
|
||||
|
|
@ -183,10 +184,18 @@ describe('Agent loop', () => {
|
|||
it('lets non-external stop hooks continue a turn more than once', async () => {
|
||||
profile.update({ activeToolNames: [] });
|
||||
let continuations = 0;
|
||||
loop.hooks.onWillStop.register('test-repeat-stop-continuation', async (hookCtx, next) => {
|
||||
loop.hooks.afterStep.register('test-repeat-stop-continuation', async (hookCtx, next) => {
|
||||
if (continuations < 2) {
|
||||
continuations += 1;
|
||||
hookCtx.continuationPrompt = `continue ${continuations}`;
|
||||
const prompt = `continue ${continuations}`;
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
context.splice(context.get().length, 0, [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'system_trigger', name: 'stop_hook' },
|
||||
}]);
|
||||
hookCtx.continue = true;
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
|
|||
'beforeStep',
|
||||
'afterStep',
|
||||
'onContextOverflow',
|
||||
'onWillStop',
|
||||
]) as IAgentLoopService['hooks'];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue