From e70bf64446b7e36dc5ebc38022d8de2a8643f91d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sat, 11 Jul 2026 21:16:10 +0800 Subject: [PATCH] refactor(agent-core-v2): centralize loop turn scheduling - add queued turn and step lifecycle handles with explicit admission modes - move continuation and retry scheduling behind the loop service - consolidate legacy prompt scheduling into the prompt domain - align kap-server routes and tests with the new loop contract --- .agents/skills/agent-core-dev/server-align.md | 28 +- .../agent-core-v2/src/activity/activity.ts | 2 + .../src/activity/agentActivityService.ts | 2 +- .../src/agent/contextMemory/messageId.ts | 2 +- .../externalHooks/externalHooksService.ts | 8 +- .../fullCompaction/fullCompactionService.ts | 167 ++-- .../src/agent/goal/goalService.ts | 240 ++--- .../agent/llmRequester/llmRequesterService.ts | 51 +- packages/agent-core-v2/src/agent/loop/loop.ts | 93 +- .../src/agent/loop/loopContinuation.ts | 9 + .../src/agent/loop/loopContinuationService.ts | 48 + .../src/agent/loop/loopService.ts | 907 ++++++++++++------ .../src/agent/loop/stepRequest.ts | 32 +- .../src/agent/loop/stepRequestQueue.ts | 17 +- .../agent-core-v2/src/agent/loop/turnOps.ts | 4 +- .../agent-core-v2/src/agent/prompt/errors.ts | 3 + .../agent-core-v2/src/agent/prompt/prompt.ts | 65 +- .../src/agent/prompt/promptService.ts | 430 ++++----- .../src/agent/prompt/promptStepRequests.ts | 26 +- .../src/agent/promptLegacy/errors.ts | 11 - .../src/agent/promptLegacy/promptLegacy.ts | 65 -- .../agent/promptLegacy/promptLegacyService.ts | 398 -------- .../agent-core-v2/src/agent/rpc/rpcService.ts | 19 +- .../agent/shellCommand/shellCommandService.ts | 2 +- .../src/agent/skill/skillService.ts | 2 +- .../src/agent/stepRetry/stepRetryService.ts | 26 +- .../src/agent/task/taskService.ts | 15 +- .../src/agent/userTool/userToolService.ts | 18 +- .../src/app/gateway/gatewayService.ts | 22 +- packages/agent-core-v2/src/errors.ts | 3 - packages/agent-core-v2/src/index.ts | 6 +- .../agentLifecycle/agentLifecycleService.ts | 5 + .../session/agentLifecycle/runAgentTurn.ts | 10 +- .../src/session/agentLifecycle/tools/agent.ts | 2 +- .../session/cron/sessionCronServiceImpl.ts | 8 +- .../sessionActivity/sessionActivityService.ts | 2 +- .../src/session/swarm/sessionSwarmService.ts | 2 +- .../contextInjector/contextInjector.test.ts | 4 +- .../test/agent/contextMemory/context.test.ts | 30 +- .../fullCompaction/fullCompaction.test.ts | 6 +- .../test/agent/goal/goal.test.ts | 56 +- .../test/agent/goal/goalOps.test.ts | 13 - .../test/agent/goal/tools/goal-tools.test.ts | 10 +- .../test/agent/loop/loop.test.ts | 171 +++- .../agent-core-v2/test/agent/loop/stubs.ts | 239 +---- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 6 +- .../permissionGate/permissionGate.test.ts | 4 +- .../test/agent/plan/plan.test.ts | 8 +- .../test/agent/prompt/promptService.test.ts | 569 ++--------- .../promptLegacy/promptLegacyService.test.ts | 392 -------- .../test/agent/skill/skill.test.ts | 31 +- .../test/agent/stepRetry/stepRetry.test.ts | 7 +- .../test/agent/swarm/swarm.test.ts | 6 +- .../task/idle-notification-repro.test.ts | 20 +- .../test/agent/task/taskService.test.ts | 2 +- .../test/agent/tool/tool.test.ts | 41 +- .../test/agent/toolDedupe/toolDedupe.test.ts | 10 +- .../toolSelect/toolSelectService.test.ts | 19 +- .../test/agent/userTool/userTool.test.ts | 3 + .../test/agent/wireRecord/resume.test.ts | 2 +- .../test/app/config/config.test.ts | 95 +- .../externalHooksRunner/integration.test.ts | 6 +- .../test/app/gateway/gateway.test.ts | 27 +- packages/agent-core-v2/test/harness/agent.ts | 14 +- .../agent-core-v2/test/harness/snapshots.ts | 1 + .../test/lint/domain-layers.test.ts | 12 +- .../sessionActivity/sessionActivity.test.ts | 28 +- .../test/session/swarm/sessionSwarm.test.ts | 12 +- packages/agent-core-v2/test/tmp-tools.test.ts | 28 - packages/kap-server/src/routes/prompts.ts | 139 ++- packages/kap-server/src/routes/snapshot.ts | 4 +- .../src/services/snapshot/snapshotReader.ts | 4 +- packages/kap-server/test/connections.test.ts | 4 +- packages/kap-server/test/eventMap.test.ts | 2 +- packages/kap-server/test/fs-watch.e2e.test.ts | 4 +- packages/kap-server/test/messages.test.ts | 2 +- packages/kap-server/test/prompts.test.ts | 8 +- .../test/sessionEventBroadcaster.test.ts | 2 +- packages/kap-server/test/sessions.test.ts | 11 +- packages/kap-server/test/snapshot.test.ts | 23 +- packages/kap-server/test/tools.test.ts | 4 +- packages/kap-server/test/wsV1Resync.test.ts | 4 +- packages/kap-server/vitest.config.ts | 1 + 83 files changed, 1977 insertions(+), 2857 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/loop/loopContinuation.ts create mode 100644 packages/agent-core-v2/src/agent/loop/loopContinuationService.ts delete mode 100644 packages/agent-core-v2/src/agent/promptLegacy/errors.ts delete mode 100644 packages/agent-core-v2/src/agent/promptLegacy/promptLegacy.ts delete mode 100644 packages/agent-core-v2/src/agent/promptLegacy/promptLegacyService.ts delete mode 100644 packages/agent-core-v2/test/agent/promptLegacy/promptLegacyService.test.ts delete mode 100644 packages/agent-core-v2/test/tmp-tools.test.ts diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 1632c3692..11a7ac4d1 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -11,7 +11,7 @@ Use this when the task is "expose the new v2 Service on the server", "port the v - **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/kap-server/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md). - **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/kap-server/src/routes/*.ts` that **mirror `packages/server/src/routes/*.ts` path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This exists so existing v1 clients keep working against server-v2 unchanged. -The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptLegacyService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible. +The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible. ## Decision: which surface? @@ -90,42 +90,42 @@ packages/agent-core-v2/src/Legacy/ └── errors.ts ← v1-compatible error codes (KimiError codes) ``` -Skeleton (matches `promptLegacy/`): +Skeleton (matches `prompt/`): ```ts -// promptLegacy.ts — contract shaped by @moonshot-ai/protocol +// prompt.ts — contract shaped by @moonshot-ai/protocol import type { PromptSubmitResult, PromptSubmission } from '@moonshot-ai/protocol'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -export interface IAgentPromptLegacyService { +export interface IAgentPromptService { readonly _serviceBrand: undefined; submit(body: PromptSubmission): Promise; // ...the rest of the v1 contract, typed by protocol } -export const IAgentPromptLegacyService: ServiceIdentifier = - createDecorator('agentPromptLegacyService'); +export const IAgentPromptService: ServiceIdentifier = + createDecorator('agentPromptLegacyService'); ``` ```ts -// promptLegacyService.ts — impl delegates to the native v2 Service +// promptService.ts — impl delegates to the native v2 Service constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {} // submit() builds v2-native input, calls the native Service, projects the result // back into the protocol PromptSubmitResult. registerScopedService( LifecycleScope.Agent, // scope = the lifetime of the legacy state - IAgentPromptLegacyService, + IAgentPromptService, AgentPromptLegacyService, InstantiationType.Delayed, - 'promptLegacy', + 'prompt', ); ``` Conventions: -- **Name** the domain `Legacy` and the interface with the scope prefix, `ILegacyService` (e.g. `promptLegacy` / `IAgentPromptLegacyService`), per service-authoring.md. -- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `promptLegacy.ts`). -- **Scope** = the lifetime of the *legacy* state it holds (the `promptLegacy` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. +- **Name** the domain `Legacy` and the interface with the scope prefix, `ILegacyService` (e.g. `prompt` / `IAgentPromptService`), per service-authoring.md. +- **Header comment** must say it is an `L7 edge adapter` and name both the v1 contract it implements and the native v2 Service it leaves untouched (see `prompt.ts`). +- **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. - **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. - **Contract types come from `@moonshot-ai/protocol`**, so the interface cannot drift from the wire shape. @@ -215,11 +215,11 @@ This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:si **The split.** - `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to `IAgentRPCService` (a wire facade over the v2 turn driver) in `actionMap`. The native `IAgentPromptService` is untouched. -- `/api/v1` gets an `AgentPromptLegacyService` (`promptLegacy/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. +- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. **The schema.** Both servers import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from `@moonshot-ai/protocol`. The v1 and v2 route files are therefore byte-compatible by construction; the LegacyService projects v2 turn results back into those protocol shapes. -**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`promptLegacy/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. +**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. **The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service. diff --git a/packages/agent-core-v2/src/activity/activity.ts b/packages/agent-core-v2/src/activity/activity.ts index 81259fccf..cc2b0aa58 100644 --- a/packages/agent-core-v2/src/activity/activity.ts +++ b/packages/agent-core-v2/src/activity/activity.ts @@ -24,6 +24,8 @@ export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'dispos export interface BeginOptions { /** Turn source, forwarded to the lease and the snapshot; admission is origin-agnostic. */ readonly origin?: PromptOrigin; + /** Stable id reserved by the loop when the turn is enqueued. */ + readonly turnId?: number; } export interface ActivityLease { diff --git a/packages/agent-core-v2/src/activity/agentActivityService.ts b/packages/agent-core-v2/src/activity/agentActivityService.ts index 67bb58eaf..3cacae6ba 100644 --- a/packages/agent-core-v2/src/activity/agentActivityService.ts +++ b/packages/agent-core-v2/src/activity/agentActivityService.ts @@ -135,7 +135,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe break; } - const turnId = this.wire.getModel(TurnModel).nextTurnId; + const turnId = opts?.turnId ?? this.wire.getModel(TurnModel).nextTurnId; const origin = opts?.origin ?? USER_PROMPT_ORIGIN; const lease = new LeaseImpl(turnId, origin, this); // Session admission consult + lease registration. Throws `activity.session_rejected` diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts index a42eb4b07..e2ba31586 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageId.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageId.ts @@ -6,7 +6,7 @@ * exactly v1's field set, and public message ids are derived from the * transcript index (see `messageProjection.toProtocolMessage`), which stays * stable across live reads and resume. `newMessageId` remains for callers that - * need an opaque per-process id (e.g. `promptLegacyService` prompt tracking). + * need an opaque per-process id (e.g. `prompt scheduler` prompt tracking). * Provider-assigned ids live on the separate `providerMessageId` field and * never collide with this namespace. */ diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts index 0f271290d..f3ec22dbf 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -201,7 +201,13 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter toolCalls: [], origin: { kind: 'system_trigger', name: 'stop_hook' }, }); - loop.enqueue(new ContinuationStepRequest({ kind: 'stop_hook', mergeable: true })); + loop.enqueue( + new ContinuationStepRequest({ + kind: 'stop_hook', + mergeable: true, + admission: 'activeOrNextTurn', + }), + ); return; } }), diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 4d261040d..f156682d2 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -17,7 +17,7 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { IAgentLLMRequesterService, type LLMRequestFinish } from '#/agent/llmRequester/llmRequester'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; -import { IAgentLoopService, type LoopErrorContext, type LoopErrorRecovery } from '#/agent/loop/loop'; +import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; @@ -144,7 +144,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull @IEventBus private readonly eventBus: IEventBus, @IAgentActivityService private readonly activity: IAgentActivityService, @ILogService private readonly log: ILogService, - @IAgentLoopService loopService: IAgentLoopService, + @IAgentLoopService private readonly loopService: IAgentLoopService, ) { super(); this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax()); @@ -153,19 +153,19 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull this.eventBus.subscribe('turn.started', () => this.resetForTurn()), ); this._register( - loopService.hooks.beforeStep.register('full-compaction', async (ctx, next) => { + this.loopService.hooks.beforeStep.register('full-compaction', async (ctx, next) => { await this.beforeStep(ctx.signal, ctx.turnId); await next(); }), ); this._register( - loopService.hooks.afterStep.register('full-compaction', async (_ctx, next) => { + this.loopService.hooks.afterStep.register('full-compaction', async (_ctx, next) => { await this.afterStep(); await next(); }), ); this._register( - loopService.registerLoopErrorHandler({ + this.loopService.registerLoopErrorHandler({ id: 'full-compaction', match: (context) => this.shouldRecoverFromContextOverflow(context.error), handle: (context) => this.recoverFromContextOverflow(context), @@ -254,53 +254,73 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull begin(input: FullCompactionInput): boolean { if (this._compacting) return false; const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; - if (data.source === 'manual') { + if (!this.reserveCompactionSlot(data.source)) return false; + + const tokenCount = this.validateCompactionStart(data.source); + this.wire.dispatch(fullCompactionBegin(data)); + + const active = this.createActiveCompaction(data.source, tokenCount); + this._compacting = active.task; + active.task.abortController.signal.addEventListener( + 'abort', + () => this.cancelActive(active.task), + { once: true }, + ); + void this.compactionWorker(active.task, data).then(active.resolve, active.reject); + void active.task.promise.catch(() => undefined); + return true; + } + + private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { + if (source === 'manual') { this.compactionCountInTurn = 0; } else { this.compactionCountInTurn += 1; } - if (this.compactionCountInTurn > this.strategy.maxCompactionPerTurn) return false; + return this.compactionCountInTurn <= this.strategy.maxCompactionPerTurn; + } + private validateCompactionStart(source: CompactionBeginData['source']): number { const history = this.context.get(); if (history.length === 0) { throw new KimiError(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.'); } - if (data.source === 'manual' && this.activity.lane() !== 'idle') { + if (source === 'manual' && this.activity.lane() !== 'idle') { throw new KimiError( ErrorCodes.COMPACTION_UNABLE, 'Cannot compact while a turn is active. Wait for it to finish, then retry.', ); } - const tokenCount = estimateTokensForMessages(history); - - this.wire.dispatch(fullCompactionBegin(data)); + return estimateTokensForMessages(history); + } + private createActiveCompaction( + trigger: CompactionBeginData['source'], + tokenCount: number, + ): { + readonly task: ActiveCompaction; + readonly resolve: (result: CompactionResult) => void; + readonly reject: (reason: unknown) => void; + } { const abortController = new AbortController(); - let resolveCompaction!: (result: CompactionResult) => void; - let rejectCompaction!: (reason: unknown) => void; - const promise = new Promise((resolve, reject) => { - resolveCompaction = resolve; - rejectCompaction = reject; + let resolve!: (result: CompactionResult) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; }); - const active: ActiveCompaction = { - abortController, - promise, - trigger: data.source, - tokenCount, - blockedByTurn: false, - bgRegistration: this.activity.registerBackground('compaction', abortController), + return { + task: { + abortController, + promise, + trigger, + tokenCount, + blockedByTurn: false, + bgRegistration: this.activity.registerBackground('compaction', abortController), + }, + resolve, + reject, }; - this._compacting = active; - abortController.signal.addEventListener('abort', () => { - this.cancelActive(active); - }, { once: true }); - void this.compactionWorker( - active, - data, - ) - .then(resolveCompaction, rejectCompaction); - void active.promise.catch(() => undefined); - return true; } private cancelActive(active: ActiveCompaction): boolean { @@ -340,30 +360,36 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private async recoverFromContextOverflow( context: LoopErrorContext, - ): Promise { - const estimatedRequestTokens = this.estimateCurrentRequestTokens(); - this.observeContextOverflow(estimatedRequestTokens); + ): Promise { + this.recordOverflowRecovery(context.error); + const didStartCompaction = this.beginAutoCompaction(); + if (!didStartCompaction && !this._compacting) return false; + + await this.block(context.signal, context.turnId); + return this.retryFailedDriver(context); + } + + private recordOverflowRecovery(error: unknown): void { + this.observeContextOverflow(this.estimateCurrentRequestTokens()); this.consecutiveOverflowCompactions += 1; const maxAttempts = this.strategy.maxOverflowCompactionAttempts; - if (this.consecutiveOverflowCompactions > maxAttempts) { - throw new KimiError( - ErrorCodes.CONTEXT_OVERFLOW, - `Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`, - { cause: context.error instanceof Error ? context.error : undefined }, - ); - } - const didStartCompaction = this.beginAutoCompaction(); - if (!didStartCompaction && !this._compacting) { - return undefined; - } - await this.block(context.signal, context.turnId); + if (this.consecutiveOverflowCompactions <= maxAttempts) return; + throw new KimiError( + ErrorCodes.CONTEXT_OVERFLOW, + `Compaction failed to bring the context under the model window after ${String(maxAttempts)} attempts.`, + { cause: error instanceof Error ? error : undefined }, + ); + } + + private retryFailedDriver(context: LoopErrorContext): boolean { // The failed driver is already materialized, so re-running it does not - // append its messages a second time. Unlike `stepRetry`'s free re-attempt, - // an overflow recovery rides through the normal step numbering (no - // `resumeStep`): compacting must not reset the per-turn maxSteps budget. - return { - requests: context.failedDriver === undefined ? [] : [context.failedDriver], - }; + // append its messages a second time. The loop only learns that the error + // was caught; the re-run rides the normal step numbering and keeps + // consuming the per-turn maxSteps budget — compacting must not reset it. + const driver = context.failedDriver; + if (driver === undefined || context.currentStep?.signal.aborted === true) return false; + context.retry(driver, { at: 'head' }); + return true; } private async beforeStep(signal: AbortSignal, turnId?: number): Promise { @@ -413,22 +439,37 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull const active = this._compacting; if (active === null) return; active.blockedByTurn = true; - if (signal !== undefined) { - signal.addEventListener('abort', () => { - if (this._compacting === active) { - active.abortController.abort(); - } - }, { once: true }); - } + this.propagateBlockingAbort(active, signal); this.eventBus.publish({ type: 'compaction.blocked', turnId }); try { await active.promise; } catch (error) { - if (signal?.aborted === true && (active.abortController.signal.aborted || isAbortError(error))) return; + if (this.wasBlockingWaitAborted(active, signal, error)) return; throw error; } } + private propagateBlockingAbort(active: ActiveCompaction, signal: AbortSignal | undefined): void { + signal?.addEventListener( + 'abort', + () => { + if (this._compacting === active) active.abortController.abort(); + }, + { once: true }, + ); + } + + private wasBlockingWaitAborted( + active: ActiveCompaction, + signal: AbortSignal | undefined, + error: unknown, + ): boolean { + return ( + signal?.aborted === true && + (active.abortController.signal.aborted || isAbortError(error)) + ); + } + private async compactionWorker( active: ActiveCompaction, data: Readonly, diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 91ecd343e..3cedf4175 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -12,9 +12,9 @@ * at a fork boundary; the `goal.*` record shapes stay declared in * `WireRecordMap` because they still ride the shared wire log read by * `getRecords()` and replayed into the Model. Injects reminders through - * `contextInjector`, drives continuation turns by - * enqueueing `nextTurn` `StepRequest`s onto `loop` (the continuation message - * materializes when the loop pops it), accounts live + * `contextInjector`, drives continuation turns by enqueueing `newTurn` + * `StepRequest`s onto `loop` (the continuation message materializes when the + * loop pops it), accounts live * turn usage through `usage`, writes system reminders through * `systemReminder`, registers model tools through `toolRegistry`, and reports * telemetry through `telemetry`. Bound at Agent scope. @@ -47,7 +47,6 @@ import { IConfigService } from '#/app/config/config'; import { ErrorCodes, KimiError, - isKimiError, toKimiErrorPayload, type KimiErrorPayload, } from '#/errors'; @@ -214,6 +213,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private readonly goalOutcomeToolResultTurns = new Set(); private readonly goalOutcomeContinuationTurns = new Set(); private readonly budgetGraceTurns = new Set(); + private pendingContinuation: import('#/agent/loop/loop').EnqueueReceipt | undefined; constructor( @IAgentWireService private readonly wire: IWireService, @@ -308,27 +308,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } async createGoal(input: CreateGoalInput, actor: GoalActor = 'user'): Promise { - const objective = input.objective.trim(); - if (objective.length === 0) { - throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); - } - if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { - throw new KimiError( - ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, - `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, - ); - } - - if (this.goalState !== null) { - if (input.replace !== true) { - throw new KimiError( - ErrorCodes.GOAL_ALREADY_EXISTS, - 'A goal already exists; use replace to start a new one', - ); - } - this.clearInternal('system'); - } - + const objective = this.validateObjective(input.objective); + this.prepareForGoalCreation(input.replace === true); this.wire.dispatch( createGoal({ goalId: randomUUID(), @@ -344,6 +325,31 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { return this.toSnapshot(state); } + private validateObjective(value: string): string { + const objective = value.trim(); + if (objective.length === 0) { + throw new KimiError(ErrorCodes.GOAL_OBJECTIVE_EMPTY, 'Goal objective cannot be empty'); + } + if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { + throw new KimiError( + ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + ); + } + return objective; + } + + private prepareForGoalCreation(replace: boolean): void { + if (this.goalState === null) return; + if (!replace) { + throw new KimiError( + ErrorCodes.GOAL_ALREADY_EXISTS, + 'A goal already exists; use replace to start a new one', + ); + } + this.clearInternal('system'); + } + async pauseGoal(input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise { const state = this.requireState(); if (state.status === 'paused') return this.toSnapshot(state); @@ -422,25 +428,36 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ): Promise { const state = this.goalState; if (state === null || state.status !== 'active') return null; - const wallClockMs = this.settleWallClock(state); - this.wallClockResumedAt = undefined; - this.wire.dispatch( - updateGoal({ status: 'complete', reason: input.reason, wallClockMs, actor }), - ); + this.dispatchCompletion(state, input.reason, actor); const completed = this.requireState(); const snapshot = this.toSnapshot(completed); - this.emitGoalUpdated(snapshot, { - kind: 'completion', - status: 'complete', - reason: input.reason, - stats: this.statsOf(completed), - actor, - }); + this.emitCompletion(completed, snapshot, input.reason, actor); this.trackStatusChanged(completed, actor); this.clearInternal(actor); return snapshot; } + private dispatchCompletion(state: GoalState, reason: string | undefined, actor: GoalActor): void { + const wallClockMs = this.settleWallClock(state); + this.wallClockResumedAt = undefined; + this.wire.dispatch(updateGoal({ status: 'complete', reason, wallClockMs, actor })); + } + + private emitCompletion( + state: GoalState, + snapshot: GoalSnapshot, + reason: string | undefined, + actor: GoalActor, + ): void { + this.emitGoalUpdated(snapshot, { + kind: 'completion', + status: 'complete', + reason, + stats: this.statsOf(state), + actor, + }); + } + async pauseOnInterrupt(input: GoalReasonInput = {}): Promise { return this.pauseActiveGoal(input, 'user'); } @@ -512,46 +529,37 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private handleAfterStep(ctx: AfterStepContext): void { + if (this.stopAfterBudgetReached(ctx)) return; + this.enqueueGoalOutcomeContinuation(ctx); + } + + private stopAfterBudgetReached(ctx: AfterStepContext): boolean { const state = this.goalState; if ( - this.goalDrivenTurns.has(ctx.turnId) && - state !== null && - this.toSnapshot(state).budget.overBudget + !this.goalDrivenTurns.has(ctx.turnId) || + state === null || + !this.toSnapshot(state).budget.overBudget ) { - // A reached hard goal budget is a deterministic ceiling. Usage - // accounting already blocked the goal (so this accepts any remaining - // goal record, not just an active one); here the turn winds down. A - // step that requested tool calls gets exactly one grace step: a - // reminder appended after the tool results tells the model to write a - // brief final status message without tools (further tool calls are - // answered by the goal-budget-reject gate without executing). The - // grace step is the loop's own tool-call continuation, so nothing is - // enqueued here. After the grace step — or when the step ended without - // tool calls — the backstop fires: stopTurn wins in the run loop over - // requested tool calls and any queued step requests, so the turn ends - // at this step boundary. - const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; - if ( - ctx.finishReason === 'tool_calls' && - !this.budgetGraceTurns.has(ctx.turnId) && - hasStepBudgetRemaining(maxSteps, ctx.step) - ) { - this.budgetGraceTurns.add(ctx.turnId); - this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { - kind: 'system_trigger', - name: GOAL_BUDGET_STOP_REMINDER_NAME, - }); - return; - } - ctx.stopTurn = true; - return; + return false; } - // After UpdateGoal marks a goal terminal, its tool result carries the - // final-message reminder. Let the model read that result and produce one - // user-facing outcome message before the turn ends — unless the step - // budget is already exhausted, in which case the turn ends 'completed'. - // The loop enqueues no continuation for a stopTurn tool result, so the - // extra step is requested explicitly. + const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; + if ( + ctx.finishReason === 'tool_calls' && + !this.budgetGraceTurns.has(ctx.turnId) && + hasStepBudgetRemaining(maxSteps, ctx.step) + ) { + this.budgetGraceTurns.add(ctx.turnId); + this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { + kind: 'system_trigger', + name: GOAL_BUDGET_STOP_REMINDER_NAME, + }); + return true; + } + ctx.stopTurn = true; + return true; + } + + private enqueueGoalOutcomeContinuation(ctx: AfterStepContext): void { if (this.goalOutcomeContinuationTurns.has(ctx.turnId)) return; if (!this.goalOutcomeToolResultTurns.delete(ctx.turnId)) return; this.goalOutcomeContinuationTurns.add(ctx.turnId); @@ -564,6 +572,24 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { turnId: number, result: Pick, ): Promise { + const starterTurn = this.clearTurnTracking(turnId); + if ( + result.reason === 'blocked' || + result.reason === 'cancelled' || + result.reason === 'failed' + ) { + await this.settleAbnormalTurn(result); + return; + } + if (starterTurn) await this.incrementTurn(); + + const state = this.goalState; + if (state === null || state.status !== 'active') return; + if (this.blockIfBudgetReached(state) !== null) return; + this.launchContinuationTurn(); + } + + private clearTurnTracking(turnId: number): boolean { if (this.liveTurnId === turnId) this.liveTurnId = undefined; const starterTurn = this.goalStarterTurns.delete(turnId); this.goalDrivenTurns.delete(turnId); @@ -571,27 +597,25 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.goalOutcomeToolResultTurns.delete(turnId); this.goalOutcomeContinuationTurns.delete(turnId); this.budgetGraceTurns.delete(turnId); + return starterTurn; + } + private async settleAbnormalTurn( + result: Pick, + ): Promise { if (result.reason === 'blocked') { await this.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' }); - return; + return true; } - if (result.reason === 'cancelled') { await this.pauseOnInterrupt({ reason: 'Paused after interruption' }); - return; + return true; } if (result.reason === 'failed') { await this.pauseActiveGoal({ reason: goalFailurePauseReason(result.error) }); - return; + return true; } - - if (starterTurn) await this.incrementTurn(); - - const state = this.goalState; - if (state === null || state.status !== 'active') return; - if (this.blockIfBudgetReached(state) !== null) return; - this.launchContinuationTurn(); + return false; } // A rejected turn-ended handler (e.g. a continuation launch losing a race @@ -611,29 +635,29 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } } - // Drives the next goal turn the same way `prompt` drives user turns: hand - // the `loop` a `nextTurn` request and the loop owns admission — it takes - // the turn lane synchronously, so a lost admission race (another activity - // holds the lane, or the session is closing) throws before the request ever - // enters the queue and the continuation simply defers: the goal stays - // active and the next turn-ended event re-runs the admission check. Any - // other failure propagates so the goal settles instead of stranding with - // nothing driving it. private launchContinuationTurn(): void { + if (this.pendingContinuation !== undefined) return; const message: ContextMessage = { role: 'user', content: [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }], toolCalls: [], origin: GOAL_CONTINUATION_ORIGIN, }; - try { - this.loopService.enqueue( - new MessageStepRequest(message, { kind: 'goal_continuation', priority: 'nextTurn' }), - ); - } catch (error) { - if (isActivityAdmissionError(error)) return; - throw error; - } + const request = new MessageStepRequest(message, { + kind: 'goal_continuation', + admission: 'newTurn', + }); + const receipt = this.loopService.enqueue(request); + this.pendingContinuation = receipt; + void receipt.assigned.then(({ turn }) => turn.result).finally(() => { + if (this.pendingContinuation === receipt) this.pendingContinuation = undefined; + }); + } + + private cancelPendingContinuation(): void { + const receipt = this.pendingContinuation; + this.pendingContinuation = undefined; + receipt?.abort(); } private normalizeAfterReplay(): void { @@ -672,6 +696,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { opts: { readonly emit?: boolean; readonly track?: boolean } = {}, ): void { if (this.goalState === null) return; + this.cancelPendingContinuation(); this.wallClockResumedAt = undefined; this.wire.dispatch(clearGoal({})); if (opts.emit !== false) this.emitGoalUpdated(null); @@ -689,6 +714,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.wallClockResumedAt = Date.now(); this.adoptStarterTurn(); } else if (state.status === 'active') { + this.cancelPendingContinuation(); this.wallClockResumedAt = undefined; } this.wire.dispatch(updateGoal({ status, reason, wallClockMs, actor })); @@ -872,20 +898,6 @@ function pauseReasonWithMessage(prefix: string, message: string | undefined): st return trimmed === undefined || trimmed.length === 0 ? prefix : `${prefix}: ${trimmed}`; } -// The coded failures `activity.begin('turn')` can raise: each one means "the -// turn lane could not be taken right now", never a real turn failure. -const ACTIVITY_ADMISSION_CODES: ReadonlySet = new Set([ - ErrorCodes.ACTIVITY_AGENT_BUSY, - ErrorCodes.ACTIVITY_DISPOSING, - ErrorCodes.ACTIVITY_DISPOSED, - ErrorCodes.ACTIVITY_INITIALIZING, - ErrorCodes.ACTIVITY_SESSION_REJECTED, -]); - -function isActivityAdmissionError(error: unknown): boolean { - return isKimiError(error) && ACTIVITY_ADMISSION_CODES.has(error.code); -} - registerScopedService( LifecycleScope.Agent, IAgentGoalService, diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 73cf44155..32a889f36 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -22,7 +22,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; -import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -104,10 +104,22 @@ interface LLMRequestLogInput { readonly fields?: LLMRequestLogFields; } +/** + * The profile-derived request config one turn runs on: the resolved Model, + * its model context, and the system prompt, captured once on the turn's + * first step request and reused by every later step of the same turn. + */ +interface TurnRequestConfig { + readonly resolved: ProfileModelContext; + readonly model: Model; + readonly systemPrompt: string; +} + export class AgentLLMRequesterService implements IAgentLLMRequesterService { declare readonly _serviceBrand: undefined; private lastConfigLogSignature: string | undefined; + private readonly turnConfigs = new Map(); constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -260,10 +272,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } private resolveRequest(overrides: LLMRequestOverrides): ResolvedLLMRequest { - const resolved = this.profile.resolveModelContext(); - let model = this.profile.getProvider(); - model = applyCompletionBudget({ - model, + const turnConfig = this.resolveTurnConfig(overrides.source); + const resolved = turnConfig?.resolved ?? this.profile.resolveModelContext(); + const model = applyCompletionBudget({ + model: turnConfig?.model ?? this.profile.getProvider(), budget: resolveCompletionBudget({ maxOutputSize: overrides.maxOutputSize ?? resolved.maxOutputSize, reservedContextSize: resolved.reservedContextSize, @@ -285,7 +297,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { model, modelAlias: resolved.modelAlias, thinkingEffort: resolved.thinkingLevel, - systemPrompt: overrides.systemPrompt ?? this.profile.getSystemPrompt(), + systemPrompt: overrides.systemPrompt ?? turnConfig?.systemPrompt ?? this.profile.getSystemPrompt(), tools: [...(overrides.tools ?? this.defaultTools())], messages: [...messages], source: overrides.source, @@ -293,6 +305,33 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { }; } + /** + * Per-turn request-config snapshot (v1 parity): model + system prompt + * captured on the turn's first step request and reused by every later step + * of that turn, so a mid-turn `config.update` only takes effect on the NEXT + * turn. Tools are deliberately NOT snapshotted — they are re-read per step + * so a `select_tools` load or `setActiveTools` lands on the very next step + * of the same turn. Turn ids are monotonic per agent, so a newer turn + * evicts every older entry; no `turn.ended` subscription is needed. + */ + private resolveTurnConfig(source: LLMRequestSource | undefined): TurnRequestConfig | undefined { + if (source?.type !== 'turn') return undefined; + const turnId = source.turnId; + for (const id of this.turnConfigs.keys()) { + if (id < turnId) this.turnConfigs.delete(id); + } + let snapshot = this.turnConfigs.get(turnId); + if (snapshot === undefined) { + snapshot = { + resolved: this.profile.resolveModelContext(), + model: this.profile.getProvider(), + systemPrompt: this.profile.getSystemPrompt(), + }; + this.turnConfigs.set(turnId, snapshot); + } + return snapshot; + } + private logRequest(input: LLMRequestLogInput): void { const logFields: LLMRequestLogFields = input.fields ?? {}; const wireTools = providerVisibleTools(input.tools); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 2079d11bd..cdb31fdd1 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -48,6 +48,7 @@ export interface AfterStepContext extends BeforeStepContext { } export interface LoopErrorContext { + readonly currentStep?: Step; readonly turnId: number; /** The currently executing step, or undefined for turn-level failures. */ readonly step?: number; @@ -56,20 +57,13 @@ export interface LoopErrorContext { readonly signal: AbortSignal; readonly error: unknown; /** - * The driver whose step failed; already popped from the queue. Handlers - * re-run it by returning it in the recovery's `requests`. + * The driver whose step failed; already popped from the queue. A handler + * that recovers by re-running the step enqueues it back (at the head of + * the queue) itself before reporting the error as caught. */ readonly failedDriver?: StepRequest; -} - -export interface LoopErrorRecovery { - /** Head-inserted as a sequence: `requests[0]` drives the next step. */ - readonly requests: readonly StepRequest[]; - /** - * Reuse the failed step's number for the next step (loop-level retry): it - * neither increments the step counter nor trips the maxSteps budget check. - */ - readonly resumeStep?: boolean; + /** Reinsert recovery work into the failed driver's original Turn. */ + retry(request: StepRequest, options?: StepEnqueueOptions): Step; } export interface LoopErrorHandler { @@ -79,11 +73,14 @@ export interface LoopErrorHandler { /** * Recover from a claimed error. Awaiting inside the handler (backoff sleeps, * compaction) suspends the loop in its catch path — aborting `context.signal` - * still cancels the turn. Return the requests that continue the turn, or - * undefined to fail the turn with the original error; throwing fails the - * turn with the handler's error. + * still cancels the turn. Resolve `true` when the error is caught: the + * handler has already arranged how the turn continues (typically by + * enqueueing the requests it wants run next) and the loop simply drains on, + * learning nothing but caught-or-not. Resolve `false`/`undefined` to fail + * the turn with the original error; throwing fails it with the handler's + * error. */ - handle(context: LoopErrorContext): Promise; + handle(context: LoopErrorContext): Promise; } export interface LoopErrorHandlerRegistrationOptions { @@ -117,8 +114,25 @@ export type LoopRunResult = export type TurnResult = LoopRunResult; +export type StepState = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export type StepResult = + | { readonly type: 'completed' } + | { readonly type: 'failed'; readonly error: unknown } + | { readonly type: 'cancelled'; readonly reason: unknown }; + +export interface Step { + readonly id: string; + readonly turnId: number; + readonly state: StepState; + readonly signal: AbortSignal; + readonly result: Promise; + cancel(reason?: unknown): boolean; +} + export interface Turn { readonly id: number; + readonly state?: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; /** * Cancellation signal owned by the `activity` kernel's turn lease. Abort it * through `IAgentLoopService.cancel(...)` rather than holding a controller; @@ -131,20 +145,24 @@ export interface Turn { */ readonly ready: Promise; readonly result: Promise; + cancel(reason?: unknown): boolean; +} + +export interface StepAssignment { + readonly turn: Turn; + readonly step: Step; } -/** - * What `enqueue` hands back for one queued request: the turn it belongs to - * plus a retract handle. `turn` is the newly started turn for a `nextTurn` - * request, the joined turn for a `tryInTurn` request enqueued mid-turn, and - * `undefined` for a `tryInTurn` request queued with no active turn — it rides - * the next turn. `abort` retracts a still-pending request and reports false - * once it has materialized (a `nextTurn` driver's first step materializes - * before `enqueue` returns, so its `abort` always reports false). - */ export interface EnqueueReceipt { - readonly turn: Turn | undefined; - abort(): boolean; + readonly assigned: Promise; + abort(reason?: unknown): boolean; +} + +export interface AgentLoopStatus { + readonly state: 'idle' | 'running'; + readonly activeTurnId?: number; + readonly pendingTurnIds: readonly number[]; + readonly hasPendingRequests: boolean; } export interface StepEnqueueOptions { @@ -155,19 +173,14 @@ export interface StepEnqueueOptions { export interface IAgentLoopService { readonly _serviceBrand: undefined; - /** - * Enqueue a step request. Turn membership comes from the request's - * `priority`: a `nextTurn` request starts a fresh turn synchronously — - * admission through the `activity` kernel throws its coded error when - * another turn is active, and the request never enters the queue in that - * case — while a `tryInTurn` request joins the active turn or waits in the - * queue for the next one. Turn-scoped requests enqueued during a run are - * aborted if the turn ends before they are popped. - */ + /** Atomically admits a request according to its admission semantics. */ enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt; - /** The running turn's handle, or `undefined` between turns. */ - getActiveTurn(): Turn | undefined; + /** Low-level loop runner used by focused loop tests and recovery integrations. */ + run(options: LoopRunOptions): Promise; + + /** Read-only scheduling state. */ + status(): AgentLoopStatus; /** * Cancel the active turn (optionally only when its id matches `turnId`), @@ -183,7 +196,9 @@ export interface IAgentLoopService { * Register a recovery handler for step failures. Handlers dispatch in * registration order, first match wins — the loop itself knows nothing * about concrete error types: retry policies (`stepRetry`) and overflow - * recovery (`fullCompaction`) plug in here. + * recovery (`fullCompaction`) plug in here. A handler that catches an + * error arranges the turn's continuation itself; the loop only learns + * whether the error was caught. */ registerLoopErrorHandler( handler: LoopErrorHandler, diff --git a/packages/agent-core-v2/src/agent/loop/loopContinuation.ts b/packages/agent-core-v2/src/agent/loop/loopContinuation.ts new file mode 100644 index 000000000..90a9803f0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/loopContinuation.ts @@ -0,0 +1,9 @@ +import { createDecorator } from '#/_base/di/instantiation'; + +export interface IAgentLoopContinuationService { + readonly _serviceBrand: undefined; +} + +export const IAgentLoopContinuationService = createDecorator( + 'agentLoopContinuationService', +); diff --git a/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts b/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts new file mode 100644 index 000000000..b25b5b3ee --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts @@ -0,0 +1,48 @@ +/** + * `loop` domain (L4) — tool-step continuation aspect. + * + * A step that executed tools must drive one more step so the model consumes + * the tool results: this service watches the loop's `afterStep` and enqueues + * a `ContinuationStepRequest` whenever a step ends with `tool_calls` — which + * is exactly when the step ran tools without a stopTurn tool result (the + * loop maps that combination onto the `tool_calls` finish reason). The loop + * itself only drains the queue and dispatches errors; it never enqueues. A + * hook-set `stopTurn` still wins over the continuation: the turn ends at the + * step boundary and the turn-scoped request is discarded by the run-end + * cleanup. Bound at Agent scope; Eager so the hook registers before the + * first turn runs (same rationale as `stepRetry`). + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +import { IAgentLoopContinuationService } from './loopContinuation'; +import { IAgentLoopService } from './loop'; +import { ContinuationStepRequest } from './stepRequest'; + +export class AgentLoopContinuationService + extends Disposable + implements IAgentLoopContinuationService +{ + declare readonly _serviceBrand: undefined; + + constructor(@IAgentLoopService loop: IAgentLoopService) { + super(); + this._register( + loop.hooks.afterStep.register('loop-continuation', async (ctx, next) => { + await next(); + if (ctx.stopTurn || ctx.finishReason !== 'tool_calls') return; + loop.enqueue(new ContinuationStepRequest()); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentLoopContinuationService, + AgentLoopContinuationService, + InstantiationType.Eager, + 'loop', +); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3e4ddd3cd..7bf09cce5 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -1,32 +1,30 @@ /** * `loop` domain (L4) — `IAgentLoopService` implementation. * - * Owns the whole turn lifecycle: a turn is one drain of the agent-scoped - * `StepRequestQueue`, and it starts when `enqueue` receives a `nextTurn` - * request while the loop is idle. Starting a turn synchronously takes the - * turn lane through the `activity` kernel (`activity.begin('turn')`, whose - * coded admission errors propagate to the caller before the request enters - * the queue), records `turn.prompt`, publishes `turn.started`, and kicks the - * run; ending it publishes `turn.ended` / `error` after `lease.end()` returns - * the lane to idle (so `turn.ended` subscribers can start the next turn). - * A `tryInTurn` request never starts a turn: it joins the active run or waits - * in the queue for the next one. + * Owns a FIFO of Turn jobs, each with its own `StepRequestQueue`. Admission + * reserves a stable Turn handle immediately; the head job alone takes the + * activity lease, records `turn.prompt`, publishes `turn.started`, and drains + * its Steps. Ending publishes `turn.ended` after `lease.end()` and pumps the + * next queued Turn. Requests without an active Turn remain in the Loop-owned + * pending-input queue and bind to the next admitted Turn. * * The run drains the queue one batch per step: each batch's driver request * (plus any mergeable requests folded into it) materializes its context * messages, then one LLM step runs (`beforeStep` → streamed request → content - * parts → tool execution → `step.end` → `afterStep`). A step that executed - * tools enqueues a `ContinuationStepRequest` for the next step; a plain + * parts → tool execution → `step.end` → `afterStep`). The loop itself never + * enqueues — it only runs requests and dispatches errors. What drives the + * next step lives entirely in the aspects: the `loopContinuation` aspect + * enqueues a `ContinuationStepRequest` when a step executed tools (a plain * assistant message enqueues nothing, so the queue empties and the turn - * completes. A failed step is dispatched to the registered error handlers - * (first match wins); a handler that claims the error continues the turn with - * the recovery's requests head-inserted into the queue — `stepRetry` re-runs - * the failed driver after backoff, `fullCompaction` recovers provider - * overflow — while an unclaimed error fails the turn. Orchestrators - * (`prompt`, `goal`, `externalHooks`, `task`) steer the turn purely by - * enqueueing further requests. Emits `turn.*` / delta events through `event`, - * persists loop events through `contextMemory`, and reads the step budget - * from `config`. Bound at Agent scope. + * completes), and orchestrators (`prompt`, `goal`, `externalHooks`, `task`) + * steer the turn by enqueueing further requests. A failed step is dispatched + * to the registered error handlers (first match wins); a handler that claims + * and catches the error has already enqueued the turn's continuation itself — + * `stepRetry` re-enqueues the failed driver after backoff, `fullCompaction` + * compacts and re-enqueues it — so the loop only learns caught-or-not, while + * an unclaimed or uncaught error fails the turn. Emits `turn.*` / delta + * events through `event`, persists loop events through `contextMemory`, and + * reads the step budget from `config`. Bound at Agent scope. */ import { randomUUID } from 'node:crypto'; @@ -34,7 +32,7 @@ import { randomUUID } from 'node:crypto'; import { createControlledPromise } from '@antfu/utils'; import { InstantiationType } from '#/_base/di/extensions'; -import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { toErrorMessage } from '#/_base/errors/errorMessage'; @@ -71,23 +69,25 @@ import { IAgentLoopService, isMaxStepsExceededError, type AfterStepContext, + type AgentLoopStatus, type EnqueueReceipt, type LoopErrorContext, type LoopErrorHandler, type LoopErrorHandlerRegistrationOptions, type LoopRunOptions, type LoopRunResult, + type Step, type StepEnqueueOptions, + type StepResult, type Turn, type TurnResult, } from './loop'; import { - ContinuationStepRequest, type StepRequest, type TurnSeed, } from './stepRequest'; import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; -import { cancelTurn, promptTurn } from './turnOps'; +import { cancelTurn, promptTurn, TurnModel } from './turnOps'; declare module '#/app/event/eventBus' { interface DomainEventMap { @@ -106,7 +106,7 @@ declare module '#/app/event/eventBus' { export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; -export class AgentLoopService implements IAgentLoopService { +export class AgentLoopService extends Disposable implements IAgentLoopService { declare readonly _serviceBrand: undefined; readonly hooks: IAgentLoopService['hooks'] = { @@ -114,9 +114,13 @@ export class AgentLoopService implements IAgentLoopService { afterStep: new OrderedHookSlot(), }; - private readonly stepQueue = new StepRequestQueue(); + private readonly standaloneStepQueue = new StepRequestQueue(); + private readonly pendingAssignments = new Map>>(); private readonly errorHandlers: LoopErrorHandler[] = []; - private activeTurn: Turn | undefined; + private readonly pendingTurns: TurnJob[] = []; + private activeTurnJob: TurnJob | undefined; + private nextReservedTurnId: number | undefined; + private disposing = false; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -128,67 +132,227 @@ export class AgentLoopService implements IAgentLoopService { @IAgentWireService private readonly wire: IWireService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, - ) { } - - enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt { - const retract = (): boolean => request.abort(); - if (request.priority === 'nextTurn') { - const seed = request.turnSeed; - if (seed === undefined) { - throw new BugIndicatingError( - `Step request "${request.kind}" is nextTurn but carries no turnSeed`, - ); - } - // `startTurn` admits through the activity kernel BEFORE the request - // enters the queue: a rejected admission throws with no queue residue, - // so callers never need an abort-on-failure cleanup. - return { turn: this.startTurn(request, seed), abort: retract }; - } - this.stepQueue.enqueue(request, options?.at ?? 'tail'); - return { turn: this.activeTurn, abort: retract }; + ) { + super(); } - getActiveTurn(): Turn | undefined { - return this.activeTurn; + override dispose(): void { + if (this.disposing) return; + this.disposing = true; + const reason = abortError('Agent loop disposed'); + for (const job of [...this.pendingTurns]) this.cancel(job.turn.id, reason); + this.activeTurnJob?.turn.cancel(reason); + for (const request of this.standaloneStepQueue.drain()) { + request.abort(); + this.rejectAssignment(request, reason); + } + super.dispose(); + } + + enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt { + if (this.disposing) throw abortError('Agent loop disposed'); + const assignment = createControlledPromise(); + void assignment.catch(() => undefined); + this.pendingAssignments.set(request, assignment); + + const active = this.activeTurnJob; + switch (request.admission) { + case 'newTurn': + this.createAndQueueTurn(request); + break; + case 'activeOrNewTurn': + if (active === undefined) this.createAndQueueTurn(request); + else this.assignStep(active, request, options); + break; + case 'activeOrNextTurn': + if (active === undefined) this.standaloneStepQueue.enqueue(request, options?.at ?? 'tail'); + else this.assignStep(active, request, options); + break; + case 'activeTurnOnly': + if (active === undefined) { + const error = new BugIndicatingError(`Step request "${request.kind}" requires an active turn`); + this.rejectAssignment(request, error); + throw error; + } + this.assignStep(active, request, options); + break; + } + return { + assigned: assignment, + abort: (reason) => this.abortRequest(request, reason), + }; + } + + private createAndQueueTurn(request: StepRequest): void { + const seed = request.turnSeed; + if (seed === undefined) { + const error = new BugIndicatingError(`Step request "${request.kind}" cannot start a turn without turnSeed`); + this.rejectAssignment(request, error); + throw error; + } + const job = this.createPendingTurn(request, seed); + this.pendingTurns.push(job); + this.pumpTurns(); + } + + status(): AgentLoopStatus { + return { + state: this.activeTurnJob === undefined ? 'idle' : 'running', + activeTurnId: this.activeTurnJob?.turn.id, + pendingTurnIds: this.pendingTurns.map((job) => job.turn.id), + hasPendingRequests: this.hasPendingRequests(), + }; } cancel(turnId?: number, reason?: unknown): boolean { + const cancellation = reason ?? userCancellationReason(); + return ( + this.cancelActiveTurn(turnId, cancellation) || + (turnId !== undefined && this.cancelQueuedTurn(turnId, cancellation)) + ); + } + + private cancelActiveTurn(turnId: number | undefined, cancellation: unknown): boolean { + const turn = this.activeTurnJob?.turn; + if (turn === undefined || (turnId !== undefined && turn.id !== turnId)) return false; this.wire.dispatch(cancelTurn({ turnId })); - const turn = this.activeTurn; - if (turn === undefined) return false; - if (turnId !== undefined && turn.id !== turnId) return false; - return this.activity.cancel(reason ?? userCancellationReason()); + return this.activity.cancel(cancellation); + } + + private cancelQueuedTurn(turnId: number, cancellation: unknown): boolean { + const index = this.pendingTurns.findIndex((job) => job.turn.id === turnId); + if (index < 0) return false; + const [job] = this.pendingTurns.splice(index, 1); + if (job === undefined || job.turn.state !== 'queued') return false; + this.wire.dispatch(cancelTurn({ turnId })); + for (const step of job.steps.values()) step.cancel(cancellation); + job.controller.abort(cancellation); + job.turn.state = 'cancelled'; + job.ready.reject(cancellation instanceof Error ? cancellation : abortError('Turn cancelled')); + job.result.resolve({ type: 'cancelled', steps: 0, reason: cancellation }); + return true; } hasPendingRequests(): boolean { - return this.stepQueue.hasPendingRequests(); + return ( + this.activeTurnJob?.queue.hasPendingRequests() === true || + this.standaloneStepQueue.hasPendingRequests() || + this.pendingTurns.length > 0 + ); } - /** - * Open a turn around the queue: admission → seed the driver → `turn.prompt` - * record → `turn.started` → run. The whole prefix up to the first - * `beforeStep` hook runs synchronously inside the caller's `enqueue`. - */ - private startTurn(request: StepRequest, seed: TurnSeed): Turn { - const lease = this.activity.begin('turn', { origin: seed.origin }); - this.stepQueue.enqueue(request); - this.wire.dispatch(promptTurn({ input: seed.input, origin: lease.origin })); + private createPendingTurn(request: StepRequest, seed: TurnSeed): TurnJob { + const id = this.reserveTurnId(); + const controller = new AbortController(); const ready = createControlledPromise(); - const turn: MutableTurn = { - id: lease.turnId, - signal: lease.signal, - ready, - result: Promise.resolve({ - type: 'failed', - steps: 0, - error: new BugIndicatingError('Turn result was not initialized'), - }), - }; + const result = createControlledPromise(); + const queue = new StepRequestQueue(); + const steps = new Map(); void ready.catch(() => undefined); - this.activeTurn = turn; - this.eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: lease.origin }); - turn.result = this.runTurn(turn, lease, ready); - return turn; + const turn: MutableTurn = { + id, + state: 'queued', + signal: controller.signal, + ready, + result, + cancel: (reason) => this.cancel(id, reason), + }; + const job = { request, seed, controller, ready, result, queue, steps, turn }; + this.assignStep(job, request); + this.moveStandaloneStepsTo(job); + return job; + } + + private reserveTurnId(): number { + const modelNextId = this.wire.getModel(TurnModel).nextTurnId; + const id = Math.max(modelNextId, this.nextReservedTurnId ?? modelNextId); + this.nextReservedTurnId = id + 1; + return id; + } + + private moveStandaloneStepsTo(job: TurnJob): void { + for (const pending of this.standaloneStepQueue.drain()) { + if (!pending.aborted) this.assignStep(job, pending); + } + } + + private assignStep(job: TurnJob, request: StepRequest, options?: StepEnqueueOptions): Step { + const step = this.enqueueStep(job, request, options); + const assignment = this.pendingAssignments.get(request); + assignment?.resolve({ turn: job.turn, step }); + this.pendingAssignments.delete(request); + return step; + } + + private rejectAssignment(request: StepRequest, reason: unknown): void { + const assignment = this.pendingAssignments.get(request); + assignment?.reject(reason instanceof Error ? reason : abortError('Step request aborted')); + this.pendingAssignments.delete(request); + } + + private abortRequest(request: StepRequest, reason?: unknown): boolean { + for (const job of [this.activeTurnJob, ...this.pendingTurns]) { + if (job === undefined) continue; + if (job.turn.state === 'queued' && job.request === request) { + return this.cancel(job.turn.id, reason); + } + const step = job.steps.get(request.id); + if (step !== undefined) return step.cancel(reason); + } + if (!request.abort()) return false; + this.rejectAssignment(request, reason ?? userCancellationReason()); + return true; + } + + private enqueueStep(job: TurnJob, request: StepRequest, options?: StepEnqueueOptions): Step { + const existing = job.steps.get(request.id); + if (existing !== undefined && existing.state !== 'cancelled') { + job.queue.enqueue(request, options?.at ?? 'tail'); + existing.state = 'queued'; + return existing; + } + const controller = new AbortController(); + const result = createControlledPromise(); + const step: MutableStep = { + id: request.id, + turnId: job.turn.id, + state: 'queued', + signal: controller.signal, + result, + controller, + resultControl: result, + cancel: (reason) => this.cancelStep(job, step, request, reason), + }; + job.steps.set(step.id, step); + job.queue.enqueue(request, options?.at ?? 'tail'); + return step; + } + + private cancelStep(job: TurnJob, step: MutableStep, request: StepRequest, reason?: unknown): boolean { + if (step.state === 'completed' || step.state === 'failed' || step.state === 'cancelled') return false; + const cancellation = reason ?? userCancellationReason(); + step.state = 'cancelled'; + request.abort(); + step.controller?.abort(cancellation); + step.resultControl?.resolve({ type: 'cancelled', reason: cancellation }); + return true; + } + + private pumpTurns(): void { + if (this.disposing || this.activeTurnJob !== undefined) return; + const job = this.pendingTurns.shift(); + if (job === undefined) return; + this.startTurn(job); + } + + private startTurn(job: TurnJob): void { + const lease = this.activity.begin('turn', { origin: job.seed.origin, turnId: job.turn.id }); + this.wire.dispatch(promptTurn({ input: job.seed.input, origin: lease.origin })); + job.turn.state = 'running'; + job.turn.signal = lease.signal; + this.activeTurnJob = job; + this.eventBus.publish({ type: 'turn.started', turnId: job.turn.id, origin: lease.origin }); + void this.runTurn(job.turn, lease, job.ready).then(job.result.resolve, job.result.reject); } private async runTurn( @@ -208,30 +372,11 @@ export class AgentLoopService implements IAgentLoopService { }); return result; } catch (error) { - if (lease.signal.aborted) { - result = { - type: 'cancelled', - steps: 0, - reason: lease.signal.reason ?? error, - }; - return result; - } - result = { type: 'failed', error, steps: 0 }; + result = this.resultFromTurnError(lease, error); return result; } finally { - // `ready` rejects with the turn's own outcome: the real failure error, - // the cancellation reason (control flow), or — for a turn that ended - // before any step produced a response — an internal placeholder. - if (result?.type === 'failed') { - ready.reject(result.error); - } else if (result?.type === 'cancelled') { - ready.reject(result.reason instanceof Error ? result.reason : abortError('Turn cancelled')); - } else { - ready.reject(new KimiError(ErrorCodes.INTERNAL, 'Turn ended before first step')); - } - if (this.activeTurn === turn) { - this.activeTurn = undefined; - } + this.settleTurnReady(ready, result); + this.releaseActiveTurn(turn, result); const outcome = result?.type ?? 'failed'; lease.end(outcome, result?.type === 'failed' ? { error: result.error } : undefined); if (result !== undefined) { @@ -243,18 +388,44 @@ export class AgentLoopService implements IAgentLoopService { error, durationMs: Date.now() - startedAt, }); - if (error !== undefined) { - this.eventBus.publish({ type: 'error', ...error }); - } + if (error !== undefined) this.eventBus.publish({ type: 'error', ...error }); if (result.type !== 'completed') { turnTelemetry.track('turn_interrupted', { at_step: result.steps }); } } - // `turn.ended` is published to `IEventBus` above; subscribers (swarm / - // goal / externalHooks) react there — no hook slot to run here. + this.pumpTurns(); } } + private resultFromTurnError(lease: ActivityLease, error: unknown): TurnResult { + if (!lease.signal.aborted) return { type: 'failed', error, steps: 0 }; + return { type: 'cancelled', steps: 0, reason: lease.signal.reason ?? error }; + } + + private settleTurnReady( + ready: ReturnType>, + result: TurnResult | undefined, + ): void { + if (result?.type === 'failed') { + ready.reject(result.error); + } else if (result?.type === 'cancelled') { + ready.reject(result.reason instanceof Error ? result.reason : abortError('Turn cancelled')); + } else { + ready.reject(new KimiError(ErrorCodes.INTERNAL, 'Turn ended before first step')); + } + } + + private releaseActiveTurn(turn: Turn, result: TurnResult | undefined): void { + (turn as MutableTurn).state = result?.type ?? 'failed'; + const job = this.activeTurnJob?.turn === turn ? this.activeTurnJob : undefined; + if (job === undefined) return; + const reason = result?.type === 'cancelled' ? result.reason : abortError('Turn ended'); + for (const step of job.steps.values()) { + if (step.state === 'queued' || step.state === 'running') step.cancel(reason); + } + this.activeTurnJob = undefined; + } + registerLoopErrorHandler( handler: LoopErrorHandler, options: LoopErrorHandlerRegistrationOptions = {}, @@ -291,146 +462,178 @@ export class AgentLoopService implements IAgentLoopService { * merges into) one step, and the turn completes once the queue empties. * Only `runTurn` calls this — turns start exclusively through `enqueue`. */ - private async run(options: LoopRunOptions): Promise { - const { turnId } = options; - const signal = options.signal ?? new AbortController().signal; - - let steps = 0; - let activeStep: number | undefined; - let resumeStep: number | undefined; - let lastStopReason: FinishReason | undefined; + async run(options: LoopRunOptions): Promise { + const runtime = this.createLoopRuntime(options); try { while (true) { - let failedDriver: StepRequest | undefined; - let stepUuid: string | undefined; try { - activeStep = undefined; - signal.throwIfAborted(); - - if (!this.stepQueue.hasPendingRequests()) { - return { type: 'completed', steps, truncated: lastStopReason === 'truncated' }; - } - - // A handler that resumes the failed step (a loop-level retry) keeps - // the failed step's number: the counter does not increment and the - // maxSteps budget check is skipped. - if (resumeStep !== undefined) { - activeStep = resumeStep; - resumeStep = undefined; - } else { - const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; - if (maxSteps !== undefined && maxSteps > 0 && steps >= maxSteps) { - throw createMaxStepsExceededError(maxSteps); - } - steps += 1; - activeStep = steps; - } - - const batch = this.stepQueue.takeNextBatch()!; - failedDriver = batch.driver; - stepUuid = randomUUID(); - this.materializeBatch(batch); - const stepResult = await this.executeLoopStep( - turnId, - signal, - activeStep, - stepUuid, + const begun = this.beginLoopStep(runtime); + if ('result' in begun) return begun.result; + runtime.current = begun.step; + const result = await this.executeLoopStep( + runtime.turnId, + begun.step.signal, + begun.step.number, + begun.step.uuid, options.onStarted, ); - activeStep = undefined; - lastStopReason = stepResult.stopReason; - - if (stepResult.stopReason === 'filtered') { - throw new KimiError( - ErrorCodes.PROVIDER_FILTERED, - 'Provider safety policy blocked the response.', - { - name: 'ProviderFilteredError', - details: { finishReason: 'filtered' }, - }, - ); - } - - // A hook-set stopTurn is a hard stop: it wins over both requested - // tool calls and any queued step requests, so the turn always ends - // at this step boundary. Queued steers survive into the next turn. - if (stepResult.hookStopTurn) { - return { type: 'completed', steps, truncated: stepResult.stopReason === 'truncated' }; - } - - if (stepResult.enqueueContinuation) { - this.stepQueue.enqueue(new ContinuationStepRequest()); - } + const completed = this.completeLoopStep(runtime, result); + if (completed !== undefined) return completed; } catch (error) { - // ① Control flow first: cancellation is not an error. It never - // reaches the error events, the error handlers, or the failure - // path — `signal` is the single source of truth for turn - // cancellation. - if (isAbortError(error) || signal.aborted) { - const abortReason = signal.reason ?? error; - this.emitStepInterrupted( - turnId, - activeStep, - 'aborted', - isUserCancellation(abortReason) ? undefined : toErrorMessage(abortReason), - ); - return { type: 'cancelled', reason: abortReason, steps }; - } - - // ② Recovery: the first registered handler that claims the error - // decides how (and whether) the turn continues — the loop knows - // nothing about concrete error types. Awaiting inside the handler - // suspends the loop here; an abort during it is still cancellation. - const context: LoopErrorContext = { - turnId, - step: activeStep, - stepId: stepUuid, - signal, - error, - failedDriver, - }; - const handler = this.errorHandlers.find((entry) => entry.match(context)); - if (handler !== undefined) { - let recovery: Awaited>; - try { - recovery = await handler.handle(context); - } catch (handlerError) { - if (isAbortError(handlerError) || signal.aborted) { - const abortReason = signal.reason ?? handlerError; - this.emitStepInterrupted( - turnId, - activeStep, - 'aborted', - isUserCancellation(abortReason) ? undefined : toErrorMessage(abortReason), - ); - return { type: 'cancelled', reason: abortReason, steps }; - } - this.emitStepInterrupted(turnId, activeStep, 'error', toErrorMessage(handlerError)); - return { type: 'failed', error: handlerError, steps }; - } - if (recovery !== undefined && recovery.requests.length > 0) { - if (recovery.resumeStep === true && activeStep !== undefined) { - resumeStep = activeStep; - } - this.stepQueue.enqueueFront(recovery.requests); - activeStep = undefined; - continue; - } - } - - // ③ Terminal failure: the interruption is reported only once the - // error is known unrecoverable, so a recovered error never surfaces - // as an interruption. - const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; - this.emitStepInterrupted(turnId, activeStep, reason, toErrorMessage(error)); - return { type: 'failed', error, steps }; + const disposition = await this.handleLoopStepError(runtime, error); + if (disposition.type === 'return') return disposition.result; } } } finally { - this.stepQueue.abortTurnScoped(); + runtime.queue.abortTurnScoped(); } } + private createLoopRuntime(options: LoopRunOptions): LoopRuntime { + const job = this.activeTurnJob?.turn.id === options.turnId ? this.activeTurnJob : undefined; + return { + turnId: options.turnId, + turnSignal: options.signal ?? new AbortController().signal, + job, + queue: job?.queue ?? this.standaloneStepQueue, + steps: 0, + lastStopReason: undefined, + current: undefined, + }; + } + + private beginLoopStep(runtime: LoopRuntime): BeginStepResult { + runtime.current = undefined; + runtime.turnSignal.throwIfAborted(); + if (!runtime.queue.hasPendingRequests()) { + return { + result: { + type: 'completed', + steps: runtime.steps, + truncated: runtime.lastStopReason === 'truncated', + }, + }; + } + const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; + if (maxSteps !== undefined && maxSteps > 0 && runtime.steps >= maxSteps) { + throw createMaxStepsExceededError(maxSteps); + } + const batch = runtime.queue.takeNextBatch()!; + const mutableStep = runtime.job?.steps.get(batch.driver.id); + if (mutableStep !== undefined) { + mutableStep.state = 'running'; + mutableStep.controller = new AbortController(); + mutableStep.signal = mutableStep.controller.signal; + } + const step: StepRuntime = { + number: ++runtime.steps, + uuid: randomUUID(), + batch, + mutableStep, + signal: mutableStep?.controller === undefined + ? runtime.turnSignal + : AbortSignal.any([runtime.turnSignal, mutableStep.controller.signal]), + }; + this.materializeBatch(batch); + return { step }; + } + + private completeLoopStep( + runtime: LoopRuntime, + result: StepExecutionResult, + ): LoopRunResult | undefined { + const current = runtime.current!; + if (current.mutableStep !== undefined) { + current.mutableStep.state = 'completed'; + current.mutableStep.resultControl?.resolve({ type: 'completed' }); + } + runtime.current = undefined; + runtime.lastStopReason = result.stopReason; + if (result.stopReason === 'filtered') { + throw new KimiError(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { + name: 'ProviderFilteredError', + details: { finishReason: 'filtered' }, + }); + } + if (!result.hookStopTurn) return undefined; + return { type: 'completed', steps: runtime.steps, truncated: result.stopReason === 'truncated' }; + } + + private async handleLoopStepError( + runtime: LoopRuntime, + error: unknown, + ): Promise { + const cancellation = this.handleLoopCancellation(runtime, error); + if (cancellation !== undefined) return cancellation; + const recovery = await this.tryRecoverLoopError(runtime, error); + return recovery ?? this.failLoopStep(runtime, error); + } + + private handleLoopCancellation( + runtime: LoopRuntime, + error: unknown, + ): LoopErrorDisposition | undefined { + const step = runtime.current?.mutableStep; + if (!isAbortError(error) && !runtime.turnSignal.aborted && step?.signal.aborted !== true) return undefined; + const reason = runtime.turnSignal.reason ?? step?.signal.reason ?? error; + this.emitStepInterrupted( + runtime.turnId, + runtime.current?.number, + 'aborted', + isUserCancellation(reason) ? undefined : toErrorMessage(reason), + ); + if (!runtime.turnSignal.aborted && step?.state === 'cancelled') { + runtime.current = undefined; + return { type: 'continue' }; + } + return { type: 'return', result: { type: 'cancelled', reason, steps: runtime.steps } }; + } + + private async tryRecoverLoopError( + runtime: LoopRuntime, + error: unknown, + ): Promise { + const current = runtime.current; + const context: LoopErrorContext = { + currentStep: current?.mutableStep, + turnId: runtime.turnId, + step: current?.number, + stepId: current?.uuid, + signal: runtime.turnSignal, + error, + failedDriver: current?.batch.driver, + retry: (request, options) => { + if (runtime.job !== undefined) return this.enqueueStep(runtime.job, request, options); + runtime.queue.enqueue(request, options?.at ?? 'tail'); + return current?.mutableStep ?? { + id: request.id, + turnId: runtime.turnId, + state: 'queued', + signal: runtime.turnSignal, + result: Promise.resolve({ type: 'completed' }), + cancel: () => request.abort(), + }; + }, + }; + const handler = this.errorHandlers.find((entry) => entry.match(context)); + if (handler === undefined) return undefined; + try { + if (await handler.handle(context)) { + runtime.current = undefined; + return { type: 'continue' }; + } + return undefined; + } catch (handlerError) { + return this.handleLoopCancellation(runtime, handlerError) ?? this.failLoopStep(runtime, handlerError); + } + } + + private failLoopStep(runtime: LoopRuntime, error: unknown): LoopErrorDisposition { + const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; + this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(error)); + return { type: 'return', result: { type: 'failed', error, steps: runtime.steps } }; + } + /** * Append the batch's context messages (driver first, then merged requests) * before `beforeStep` hooks run, so compaction / injection hooks observe the @@ -459,14 +662,41 @@ export class AgentLoopService implements IAgentLoopService { currentStep: number, stepUuid: string, onStarted: ((step: number) => void) | undefined, - ): Promise<{ - readonly stopReason: FinishReason; - readonly enqueueContinuation: boolean; - readonly hookStopTurn: boolean; - }> { + ): Promise { await this.hooks.beforeStep.run({ turnId, step: currentStep, signal }); - signal.throwIfAborted(); + const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); + const response = await this.llmRequester.request( + { source: { type: 'turn', turnId, step: currentStep } }, + this.createStreamPartHandler(turnId, markStepStarted), + signal, + ); + this.appendResponseContent(turnId, currentStep, stepUuid, response); + const finishReason = await this.executeStepTools( + turnId, + signal, + currentStep, + stepUuid, + response, + ); + this.finishStep(turnId, signal, currentStep, stepUuid, response, finishReason, markStepStarted); + const hookStopTurn = await this.runAfterStep( + turnId, + signal, + currentStep, + response.usage, + finishReason, + ); + return { stopReason: finishReason, hookStopTurn }; + } + private beginStep( + turnId: number, + signal: AbortSignal, + currentStep: number, + stepUuid: string, + onStarted: ((step: number) => void) | undefined, + ): () => void { + signal.throwIfAborted(); this.eventBus.publish({ type: 'turn.step.started', turnId, step: currentStep, stepId: stepUuid }); this.context.appendLoopEvent({ type: 'step.begin', @@ -474,93 +704,96 @@ export class AgentLoopService implements IAgentLoopService { turnId: String(turnId), step: currentStep, }); - let stepStarted = false; - const markStepStarted = (): void => { + return () => { if (stepStarted) return; stepStarted = true; onStarted?.(currentStep); }; - const emitStreamPart = this.createStreamPartHandler(turnId, markStepStarted); - const response = await this.llmRequester.request( - { - source: { type: 'turn', turnId, step: currentStep }, - }, - emitStreamPart, - signal, - ); + } - const usage = response.usage; - const { providerFinishReason, message } = response; - let finishReason = providerFinishReason ?? 'completed'; - - const turnIdStr = String(turnId); - const toolCallUuids = new Map(); - for (const part of message.content) { + private appendResponseContent( + turnId: number, + currentStep: number, + stepUuid: string, + response: LLMRequestFinish, + ): void { + for (const part of response.message.content) { this.context.appendLoopEvent({ type: 'content.part', uuid: randomUUID(), - turnId: turnIdStr, + turnId: String(turnId), step: currentStep, stepUuid, part, }); } + } - const hasToolCalls = message.toolCalls.length > 0; - let toolResultStopTurn = false; - if (hasToolCalls) { - 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.context.appendLoopEvent({ - type: 'tool.result', - parentUuid: toolCallUuids.get(toolResult.toolCallId) ?? randomUUID(), - toolCallId: toolResult.toolCallId, - result: { output: result.output, isError: result.isError, note: result.note }, - }); - if (result.stopTurn === true) toolResultStopTurn = true; - } - if (toolResultStopTurn) { - finishReason = 'completed'; - } else { - finishReason = 'tool_calls'; - } - } else if (finishReason === 'tool_calls') { - // The provider signaled a tool step but emitted no tool call structure. - // Treat it as a terminal, non-tool step (v1 'unknown') instead of looping - // on the bare signal, which would re-issue the model call until maxSteps. - finishReason = 'other'; + private async executeStepTools( + turnId: number, + signal: AbortSignal, + currentStep: number, + stepUuid: string, + response: LLMRequestFinish, + ): Promise { + let finishReason = response.providerFinishReason ?? 'completed'; + if (response.message.toolCalls.length === 0) { + return finishReason === 'tool_calls' ? 'other' : finishReason; } + const toolCallUuids = new Map(); + let stopTurn = false; + 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: String(turnId), + step: currentStep, + stepUuid, + toolCallId, + name, + args, + }); + }, + })) { + const { result } = toolResult; + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: toolCallUuids.get(toolResult.toolCallId) ?? randomUUID(), + toolCallId: toolResult.toolCallId, + result: { output: result.output, isError: result.isError, note: result.note }, + }); + if (result.stopTurn === true) stopTurn = true; + } + finishReason = stopTurn ? 'completed' : 'tool_calls'; + return finishReason; + } + private finishStep( + turnId: number, + signal: AbortSignal, + currentStep: number, + stepUuid: string, + response: LLMRequestFinish, + finishReason: FinishReason, + markStepStarted: () => void, + ): void { signal.throwIfAborted(); - markStepStarted(); const timing = response.timing; const stepFinishReason = normalizeFinishReason(finishReason); this.context.appendLoopEvent({ type: 'step.end', uuid: stepUuid, - turnId: turnIdStr, + turnId: String(turnId), step: currentStep, finishReason: stepFinishReason, - usage, + usage: response.usage, llmFirstTokenLatencyMs: timing?.firstTokenLatencyMs, llmStreamDurationMs: timing?.streamDurationMs, llmRequestBuildMs: timing?.requestBuildMs, @@ -568,12 +801,27 @@ export class AgentLoopService implements IAgentLoopService { llmServerDecodeMs: timing?.serverDecodeMs, llmClientConsumeMs: timing?.clientConsumeMs, messageId: response.providerMessageId, - providerFinishReason, + providerFinishReason: response.providerFinishReason, rawFinishReason: response.rawFinishReason, }); - this.emitStepCompleted(turnId, currentStep, stepUuid, usage, stepFinishReason, response); + this.emitStepCompleted( + turnId, + currentStep, + stepUuid, + response.usage, + stepFinishReason, + response, + ); + } - const afterStepContext: AfterStepContext = { + private async runAfterStep( + turnId: number, + signal: AbortSignal, + currentStep: number, + usage: TokenUsage, + finishReason: FinishReason, + ): Promise { + const context: AfterStepContext = { turnId, step: currentStep, signal, @@ -582,19 +830,11 @@ export class AgentLoopService implements IAgentLoopService { stopTurn: false, }; try { - await this.hooks.afterStep.run(afterStepContext); + await this.hooks.afterStep.run(context); } catch (error) { if (isAbortError(error) || signal.aborted) throw error; - // afterStep hook failures must not affect the turn result. } - - return { - stopReason: finishReason, - // A step that ran tools drives the next step; a plain assistant message - // enqueues nothing, so the queue drains and the turn completes. - enqueueContinuation: hasToolCalls && !toolResultStopTurn, - hookStopTurn: afterStepContext.stopTurn, - }; + return context.stopTurn; } private emitStepCompleted( @@ -713,6 +953,53 @@ type MutableTurn = { -readonly [K in keyof Turn]: Turn[K]; }; +type MutableStep = { + -readonly [K in keyof Step]: Step[K]; +} & { + controller?: AbortController; + resultControl?: ReturnType>; +}; + +interface TurnJob { + readonly request: StepRequest; + readonly seed: TurnSeed; + readonly controller: AbortController; + readonly ready: ReturnType>; + readonly result: ReturnType>; + readonly queue: StepRequestQueue; + readonly steps: Map; + readonly turn: MutableTurn; +} + +interface LoopRuntime { + readonly turnId: number; + readonly turnSignal: AbortSignal; + readonly job: TurnJob | undefined; + readonly queue: StepRequestQueue; + steps: number; + lastStopReason: FinishReason | undefined; + current: StepRuntime | undefined; +} + +interface StepRuntime { + readonly number: number; + readonly uuid: string; + readonly batch: StepRequestBatch; + readonly mutableStep: MutableStep | undefined; + readonly signal: AbortSignal; +} + +type BeginStepResult = { readonly step: StepRuntime } | { readonly result: LoopRunResult }; + +type StepExecutionResult = { + readonly stopReason: FinishReason; + readonly hookStopTurn: boolean; +}; + +type LoopErrorDisposition = + | { readonly type: 'continue' } + | { readonly type: 'return'; readonly result: LoopRunResult }; + registerScopedService( LifecycleScope.Agent, IAgentLoopService, diff --git a/packages/agent-core-v2/src/agent/loop/stepRequest.ts b/packages/agent-core-v2/src/agent/loop/stepRequest.ts index 5a6d69977..3f61d7a0e 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequest.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequest.ts @@ -20,19 +20,13 @@ import { USER_PROMPT_ORIGIN, type ContextMessage, type PromptOrigin } from '#/ag export type StepRequestState = 'pending' | 'materialized' | 'aborted'; -/** - * Which turn a queued request belongs to: - * - `tryInTurn` joins the active turn when one is running; with no active turn - * it waits in the queue and rides the next turn (steers, continuations, - * task notifications). This is the default. - * - `nextTurn` starts a fresh turn: `enqueue` takes the turn lane through the - * `activity` kernel and throws its coded admission error when another turn - * is active (prompts, retries, goal continuations). The request must carry - * a `turnSeed` for the `turn.prompt` record. - */ -export type StepRequestPriority = 'tryInTurn' | 'nextTurn'; +export type StepRequestAdmission = + | 'newTurn' + | 'activeOrNewTurn' + | 'activeOrNextTurn' + | 'activeTurnOnly'; -/** Input/origin recorded through `turn.prompt` when a `nextTurn` request starts a turn. */ +/** Input/origin recorded through `turn.prompt` when a request starts a turn. */ export interface TurnSeed { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; @@ -43,8 +37,8 @@ export interface StepRequestOptions { readonly mergeable?: boolean; /** Turn-scoped requests are aborted when the owning run ends; agent-scoped ones (steers) carry into the next turn. */ readonly turnScoped?: boolean; - /** Turn membership; see {@link StepRequestPriority}. Defaults to `tryInTurn`. */ - readonly priority?: StepRequestPriority; + /** Turn admission semantics. Defaults to `activeOrNextTurn`. */ + readonly admission?: StepRequestAdmission; } export abstract class StepRequest { @@ -52,21 +46,17 @@ export abstract class StepRequest { abstract readonly kind: string; readonly mergeable: boolean; readonly turnScoped: boolean; - readonly priority: StepRequestPriority; + readonly admission: StepRequestAdmission; private _state: StepRequestState = 'pending'; constructor(options: StepRequestOptions = {}) { this.mergeable = options.mergeable ?? false; this.turnScoped = options.turnScoped ?? true; - this.priority = options.priority ?? 'tryInTurn'; + this.admission = options.admission ?? 'activeOrNextTurn'; } - /** - * Seed for the `turn.prompt` record when a `nextTurn` request starts a turn. - * `undefined` for requests that never start turns; the loop rejects a - * `nextTurn` request without one. - */ + /** Seed for the `turn.prompt` record when this request starts a turn. */ get turnSeed(): TurnSeed | undefined { return undefined; } diff --git a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts index 37331224d..381e01db3 100644 --- a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts +++ b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts @@ -1,9 +1,9 @@ /** * `loop` domain (L4) — the step queue held by `AgentLoopService`. * - * Agent-scoped FIFO with head insertion: senders enqueue `StepRequest`s (tail - * for ordered work, head for retries of a failed step), and the loop drains - * the queue one batch per step. A batch is one *driver* (the first + * Turn-owned FIFO with head insertion: senders enqueue `StepRequest`s (tail + * for ordered work, head for retries of a failed step), and one Turn drains + * its queue one batch per step. A batch is one *driver* (the first * non-mergeable request) plus every *mergeable* request folded into the * driver's step — this is how steers land in the same LLM request as pending * tool results or a fresh prompt instead of each costing its own step. Extra @@ -31,13 +31,6 @@ export class StepRequestQueue { } } - /** Head-insert a sequence preserving order: `requests[0]` is popped first. */ - enqueueFront(requests: readonly StepRequest[]): void { - for (let index = requests.length - 1; index >= 0; index -= 1) { - this.items.unshift(requests[index]!); - } - } - /** True while any non-aborted request is queued. */ hasPendingRequests(): boolean { return this.items.some((item) => !item.aborted); @@ -62,6 +55,10 @@ export class StepRequestQueue { return { driver, merged }; } + drain(): StepRequest[] { + return this.items.splice(0); + } + /** Abort every queued turn-scoped request (run-end cleanup); agent-scoped requests survive. */ abortTurnScoped(): void { for (const item of this.items) { diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 14936da25..5516f461a 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -6,8 +6,8 @@ * `turn.prompt` record carries exactly v1's field set (`{ input, origin }` — * no `turnId`), and `apply` mirrors v1's `restorePrompt()`: every record * advances the counter by one, so the counter is restored by counting - * turn starts. Every turn is started by `loopService.enqueue` receiving a - * `nextTurn` request while the loop is idle, which dispatches one + * turn starts. Every turn is started by `loopService.enqueue` admitting a + * request that creates a new Turn, which dispatches one * `turn.prompt` per start. As a belt-and-suspenders for v1-written logs whose * internally-driven turns (goal continuations) have no `turn.prompt` record, * `TurnModel` also registers a cross-model reducer on diff --git a/packages/agent-core-v2/src/agent/prompt/errors.ts b/packages/agent-core-v2/src/agent/prompt/errors.ts index ce5651d60..a3e6b1436 100644 --- a/packages/agent-core-v2/src/agent/prompt/errors.ts +++ b/packages/agent-core-v2/src/agent/prompt/errors.ts @@ -9,6 +9,9 @@ export const PromptErrors = { REQUEST_INVALID: 'request.invalid', REQUEST_WORK_DIR_REQUIRED: 'request.work_dir_required', REQUEST_PROMPT_INPUT_EMPTY: 'request.prompt_input_empty', + PROMPT_NOT_FOUND: 'prompt.not_found', + PROMPT_ALREADY_COMPLETED: 'prompt.already_completed', + SESSION_BUSY: 'session.busy', }, } as const satisfies ErrorDomain; diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index c64f2abe6..9fbe533ee 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -1,6 +1,6 @@ -import { createDecorator } from "#/_base/di/instantiation"; -import type { ContextMessage } from "#/agent/contextMemory/types"; -import type { Turn } from "#/agent/loop/loop"; +import { createDecorator } from '#/_base/di/instantiation'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { Turn, TurnResult } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; export interface PromptSubmitContext { @@ -9,30 +9,55 @@ export interface PromptSubmitContext { block: boolean; } -export interface PromptSteerHandle { - removeFromQueue(): void; +export interface PromptInput { + readonly id?: string; + readonly message: ContextMessage; +} + +export type PromptState = + | 'pending' + | 'running' + | 'steered' + | 'completed' + | 'failed' + | 'cancelled' + | 'blocked'; + +export interface PromptCompletion { + readonly promptId: string; + readonly result: TurnResult | undefined; + readonly state: Extract; +} + +export interface PromptSnapshot { + readonly id: string; + readonly userMessageId: string; + readonly createdAt: string; + readonly state: PromptState; + readonly message: ContextMessage; +} + +export interface PromptHandle extends PromptSnapshot { readonly launched: Promise; + readonly completion: Promise; +} + +export interface PromptQueueSnapshot { + readonly active: PromptSnapshot | undefined; + readonly pending: readonly PromptSnapshot[]; } export interface IAgentPromptService { readonly _serviceBrand: undefined; - - prompt(message: ContextMessage): Promise; - steer(message: ContextMessage): PromptSteerHandle; - retry(): Turn | undefined; - /** - * Remove the trailing `count` real-user prompts and the exchange that follows - * them. Returns the number of prompts removed. Throws - * `session.undo_unavailable` (with a structured `reason` of `empty` / - * `compaction_boundary` / `insufficient`) when fewer than `count` prompts can - * be undone — no state is removed in that case. - */ + enqueue(input: PromptInput): Promise; + list(): PromptQueueSnapshot; + steer(promptIds: readonly string[]): Promise; + abort(promptId: string, reason?: Error): boolean; + inject(message: ContextMessage): Promise; + retry(): Promise; undo(count: number): number; clear(): void; - - readonly hooks: Hooks<{ - onWillSubmitPrompt: PromptSubmitContext; - }>; + readonly hooks: Hooks<{ onWillSubmitPrompt: PromptSubmitContext }>; } export const IAgentPromptService = createDecorator('agentPromptService'); diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 94cea210e..bfead2cf8 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -1,53 +1,71 @@ /** - * `prompt` domain (L4) — `IAgentPromptService` implementation. + * `prompt` domain (L4) — owns the per-agent prompt scheduler. * - * Ingests user input and turns it into `StepRequest`s on the `loop` queue - * instead of holding any queue of its own: `prompt` / `retry` send `nextTurn` - * requests (`PromptStepRequest` / `RetryStepRequest`) so the loop starts a - * fresh turn around them, while `steer` enqueues a mergeable `tryInTurn` - * `SteerStepRequest` into the active turn (or delegates to `prompt` when no - * turn is active) and records `turn.steer` on the wire when it materializes. - * Image-compression captions are rerouted into hidden `systemReminder` - * injections when the request materializes. `undo` / `clear` mutate - * `contextMemory` directly without any request, and input arriving while a - * full compaction holds an idle agent is deferred and replayed through - * `fullCompaction`'s finish hook. Consumes tool-declared `delivery: steer` - * results from `toolExecutor`. Bound at Agent scope. + * Assigns prompt and message identities, serializes user prompts through an + * active slot and FIFO, converts selected pending prompts into active-turn + * steers, settles lifecycle handles, and keeps system input outside the prompt + * resource model. Bound at Agent scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { extractImageCompressionCaptions } from '#/_base/tools/support/image-compress'; +import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { newMessageId } from '#/agent/contextMemory/messageId'; import { formatUndoUnavailableMessage, precheckUndo } from '#/agent/contextMemory/contextOps'; import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; -import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; +import { steerTurn } from '#/agent/loop/turnOps'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/agent/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/tool/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { type Turn } from '#/agent/loop/loop'; -import { steerTurn } from '#/agent/loop/turnOps'; import type { ContentPart } from '#/app/llmProtocol/message'; +import { IEventBus } from '#/app/event/eventBus'; import { ErrorCodes, KimiError } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; -import { IAgentPromptService, type PromptSubmitContext, type PromptSteerHandle } from './prompt'; +import { + IAgentPromptService, + type PromptCompletion, + type PromptHandle, + type PromptInput, + type PromptQueueSnapshot, + type PromptSnapshot, + type PromptState, + type PromptSubmitContext, +} from './prompt'; import { PromptStepRequest, RetryStepRequest, SteerStepRequest } from './promptStepRequests'; +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'prompt.completed': { type: 'prompt.completed'; promptId: string; finishedAt: string; reason: 'completed' | 'failed' | 'blocked' }; + 'prompt.aborted': { type: 'prompt.aborted'; promptId: string; abortedAt: string }; + 'prompt.steered': { type: 'prompt.steered'; activePromptId: string; promptIds: string[]; content: ContentPart[]; steeredAt: string }; + } +} + +interface Deferred { readonly promise: Promise; resolve(value: T): void; reject(reason: unknown): void } +interface Record extends PromptSnapshot { + state: PromptState; + readonly launchedDeferred: Deferred; + readonly completionDeferred: Deferred; + handle: PromptHandle; +} + export class AgentPromptService implements IAgentPromptService { declare readonly _serviceBrand: undefined; - private readonly compactionDeferred: ContextMessage[] = []; - private readonly pendingSteers = new Set(); + private active: (Record & { turn: Turn }) | undefined; + private readonly pending: Record[] = []; + private readonly steered = new Map(); + private launching = false; private fullCompactionService: IAgentFullCompactionService | undefined; - - readonly hooks = { - onWillSubmitPrompt: new OrderedHookSlot(), - }; + readonly hooks = { onWillSubmitPrompt: new OrderedHookSlot() }; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -56,6 +74,7 @@ export class AgentPromptService implements IAgentPromptService { @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentWireService private readonly wire: IWireService, + @IEventBus private readonly eventBus: IEventBus, ) { toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => { await this.deliverToolResult(ctx); @@ -63,275 +82,162 @@ export class AgentPromptService implements IAgentPromptService { }); } - async prompt(message: ContextMessage): Promise { - if (this.deferWhileCompacting(message)) return undefined; - const { message: rerouted, captions } = this.extractCompressionCaptions(message); - if (await this.blockedByHook(rerouted, false)) { - this.appendPrompt(rerouted, captions); - return undefined; - } - // A `nextTurn` request: the loop starts the turn around it synchronously, - // so the receipt always carries the new turn (or `enqueue` threw on - // admission, before the request entered the queue). - return this.loop.enqueue(new PromptStepRequest(rerouted, captions, this.reminders)).turn; - } - - steer(message: ContextMessage): PromptSteerHandle { - if (this.loop.getActiveTurn() === undefined) { - return { - removeFromQueue: () => { - throw steerAlreadyEmittedError(); - }, - launched: this.prompt(message), - }; - } - - const { message: rerouted, captions } = this.extractCompressionCaptions(message); - const request = new SteerStepRequest( - rerouted, - captions, - this.reminders, - (materialized) => - this.wire.dispatch( - steerTurn({ - input: materialized.content, - origin: materialized.origin ?? USER_PROMPT_ORIGIN, - }), - ), - (settled) => this.pendingSteers.delete(settled), - ); - return { - removeFromQueue: () => { - if (!request.abort()) throw steerAlreadyEmittedError(); - }, - launched: this.enqueueSteer(request, message), + async enqueue(input: PromptInput): Promise { + const id = input.id ?? input.message.id ?? newMessageId(); + const message = { ...input.message, id }; + const launchedDeferred = deferred(); + const completionDeferred = deferred(); + const record = {} as Record; + Object.assign(record, { + id, userMessageId: id, createdAt: new Date().toISOString(), state: 'pending', message, + launchedDeferred, completionDeferred, + }); + record.handle = { + get id() { return record.id; }, get userMessageId() { return record.userMessageId; }, + get createdAt() { return record.createdAt; }, get state() { return record.state; }, + get message() { return record.message; }, launched: launchedDeferred.promise, + completion: completionDeferred.promise, }; - } - - private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { - const delivery = ctx.result.delivery; - if (delivery === undefined) return; - - // Consume the side channel: strip it from the result so it never reaches the - // loop / persistence, then perform the declared delivery here on the agent - // (L4) side where `steer` lives (the L3 executor only threads it through). - const { delivery: _consumed, ...rest } = ctx.result; - ctx.result = rest as ExecutableToolResult; - - switch (delivery.kind) { - case 'steer': - // The tool built a full user `ContextMessage`; the L3 contract carries it - // as an opaque `ToolDeliveryMessage`, so restore the type at the L4 edge. - await this.steer(delivery.message as ContextMessage).launched; - return; - default: { - const _exhaustive: never = delivery.kind; - void _exhaustive; + this.pending.push(record); + if (this.active === undefined && !this.launching) { + if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { + return record.handle; } + void this.startNext(); + await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); } + return record.handle; } - retry(): Turn | undefined { - const retryMessage: ContextMessage = { - role: 'user', - content: [], - toolCalls: [], - origin: { kind: 'retry' }, - }; - if (this.deferWhileCompacting(retryMessage)) return undefined; - return this.loop.enqueue(new RetryStepRequest()).turn; + list(): PromptQueueSnapshot { + return { active: this.active === undefined ? undefined : snapshot(this.active), pending: this.pending.map(snapshot) }; } + async steer(promptIds: readonly string[]): Promise { + if (promptIds.length === 0) throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt_ids must not be empty'); + if (this.active === undefined) throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'no active prompt to steer into'); + const ids = new Set(promptIds); + if (ids.size !== promptIds.length || this.pending.filter((item) => ids.has(item.id)).length !== ids.size) { + throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not pending'); + } + const selected = this.pending.filter((item) => ids.has(item.id)); + for (const item of selected) this.pending.splice(this.pending.indexOf(item), 1); + const message: ContextMessage = { + role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN, + }; + const { message: rerouted, captions } = this.extractCompressionCaptions(message); + const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); + }, () => {}); + const turn = (await this.loop.enqueue(request).assigned).turn; + if (turn === undefined) throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'no active turn to steer into'); + for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); } + this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]); + this.eventBus.publish({ type: 'prompt.steered', activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: rerouted.content as ContentPart[], steeredAt: new Date().toISOString() }); + return selected.map((item) => item.handle); + } + + abort(promptId: string, reason: Error = userCancellationReason()): boolean { + if (this.active?.id === promptId) { this.loop.cancel(this.active.turn.id, reason); return true; } + const index = this.pending.findIndex((item) => item.id === promptId); + if (index < 0) throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${promptId} not found`); + const [item] = this.pending.splice(index, 1) as [Record]; + item.state = 'cancelled'; item.launchedDeferred.resolve(undefined); + item.completionDeferred.resolve({ promptId, result: undefined, state: 'cancelled' }); + this.publishAborted(promptId); + return true; + } + + async inject(message: ContextMessage): Promise { + const { message: rerouted, captions } = this.extractCompressionCaptions(message); + const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); + }, () => {}, 'activeOrNewTurn'); + return (await this.loop.enqueue(request).assigned).turn; + } + + async retry(): Promise { return (await this.loop.enqueue(new RetryStepRequest()).assigned).turn; } + undo(count: number): number { if (count <= 0) return 0; - - // Precheck on the live history so a request that cannot be fully satisfied - // fails with `session.undo_unavailable` (and a structured reason) BEFORE any - // state is removed. `context.undo` is a no-op when the cut is short, but - // surfacing *why* (`empty` / `compaction_boundary` / `insufficient`) is the - // caller's signal — mirrors v1's `canUndoHistory` gate. - const precheck = precheckUndo(this.context.get(), count); - if (!precheck.ok) { - throw new KimiError( - ErrorCodes.SESSION_UNDO_UNAVAILABLE, - formatUndoUnavailableMessage(precheck), - { - details: { - reason: precheck.reason, - requestedCount: count, - undoableCount: precheck.undoable, - }, - }, - ); - } + const check = precheckUndo(this.context.get(), count); + if (!check.ok) throw new KimiError(ErrorCodes.SESSION_UNDO_UNAVAILABLE, formatUndoUnavailableMessage(check), { details: { reason: check.reason, requestedCount: count, undoableCount: check.undoable } }); return this.context.undo(count).removedCount; } clear(): void { - // abort() settles each request, which unregisters it from this set; - // Set iteration tolerates removing the element currently being visited. - for (const request of this.pendingSteers) { - request.abort(); - } + for (const item of [...this.pending]) this.abort(item.id); + if (this.active !== undefined) this.abort(this.active.id); this.context.clear(); } - private append(...messages: ContextMessage[]): void { - this.context.append(...messages); + private async startNext(): Promise { + if (this.active !== undefined || this.launching) return; + const item = this.pending.shift(); if (item === undefined) return; + this.launching = true; + try { + if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } + const { message, captions } = this.extractCompressionCaptions(item.message); + if (await this.blockedByHook(message, false)) { + this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined); + item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); + this.publishCompleted(item.id, 'blocked'); return; + } + const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminders)).assigned).turn; + if (turn === undefined) { this.pending.unshift(item); return; } + item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); + void turn.result.then((result) => this.settle(item, result)); + } finally { + this.launching = false; + if (this.active === undefined) void this.startNext(); + } + } + + private settle(item: Record, result: TurnResult): void { + if (this.active?.id !== item.id) return; + this.active = undefined; + const state = result.type === 'cancelled' ? 'cancelled' : result.type === 'failed' ? 'failed' : 'completed'; + item.state = state; item.completionDeferred.resolve({ promptId: item.id, result, state }); + for (const child of this.steered.get(item.id) ?? []) { child.state = state; child.completionDeferred.resolve({ promptId: child.id, result, state }); } + this.steered.delete(item.id); + if (state === 'cancelled') this.publishAborted(item.id); else this.publishCompleted(item.id, state); + void this.startNext(); } private async blockedByHook(promptMessage: ContextMessage, isSteer: boolean): Promise { - const hookContext: PromptSubmitContext = { - promptMessage, - isSteer, - block: false, - }; - await this.hooks.onWillSubmitPrompt.run(hookContext); - return hookContext.block; + const ctx = { promptMessage, isSteer, block: false }; await this.hooks.onWillSubmitPrompt.run(ctx); return ctx.block; } - - /** - * While a full compaction holds the context and no turn is active, defer the - * input instead of launching: a turn started now would append assistant/tool - * output and force the in-flight compaction to cancel. The buffer replays - * from the compaction's `onDidFinishCompaction` hook — on completion, - * cancellation, and failure — so deferred input is never lost. - */ - private deferWhileCompacting(message: ContextMessage): boolean { - if (this.fullCompaction.compacting === null) return false; - if (this.loop.getActiveTurn() !== undefined) return false; - this.compactionDeferred.push(message); - return true; - } - - /** - * Resolved lazily (not constructor-injected): prompt is constructed early in - * agent setup, and pulling the whole compaction subtree (context size, LLM - * requester, profile, tool registry/select, todo, …) in from this - * constructor would reorder eager service startup for every agent. The - * registered `onDidFinishCompaction` hook replays input deferred by - * `deferWhileCompacting`. - */ private get fullCompaction(): IAgentFullCompactionService { if (this.fullCompactionService === undefined) { - this.fullCompactionService = this.instantiation.invokeFunction((accessor) => - accessor.get(IAgentFullCompactionService), - ); - this.fullCompactionService.hooks.onDidFinishCompaction.register( - 'prompt-service-compaction-replay', - async (_ctx, next) => { - await this.replayCompactionDeferred(); - await next(); - }, - ); + this.fullCompactionService = this.instantiation.invokeFunction((a) => a.get(IAgentFullCompactionService)); + this.fullCompactionService.hooks.onDidFinishCompaction.register('prompt-service-compaction-replay', async (_ctx, next) => { void this.startNext(); await next(); }); } return this.fullCompactionService; } - - private async replayCompactionDeferred(): Promise { - const deferred = this.compactionDeferred.splice(0); - for (const message of deferred) { - await this.steer(message).launched; + private extractCompressionCaptions(message: ContextMessage): { message: ContextMessage; captions: readonly string[] } { + if ((message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return { message, captions: [] }; + const captions: string[] = []; const parts: ContentPart[] = []; + for (const part of message.content) { + if (part.type !== 'text') { parts.push(part); continue; } + const extracted = extractImageCompressionCaptions(part.text); captions.push(...extracted.captions); + if (extracted.text.trim().length > 0) parts.push({ type: 'text', text: extracted.text }); } + return { message: captions.length === 0 ? message : { ...message, content: parts }, captions }; } - - /** - * Split inline image-compression captions out of a user message so they can - * be delivered through the built-in system-reminder injection instead. - * - * Prompt ingestion (server upload/base64 route, TUI paste, ACP) annotates a - * compressed image with an inline `` caption next to the image. Left - * inside the user message, that raw markup is user-visible in every history - * projection (TUI replay, vis, export). The reminder's `injection` origin is - * hidden by every UI, while the model still receives the full note. - */ - private extractCompressionCaptions(message: ContextMessage): { - message: ContextMessage; - captions: readonly string[]; - } { - if ((message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') { - return { message, captions: [] }; - } - const { captions, parts } = splitImageCompressionCaptions(message.content); - if (captions.length === 0) { - return { message, captions }; - } - return { message: { ...message, content: parts }, captions }; - } - - /** - * Append a prompt message preceded by its rerouted caption reminders. A - * message whose content was caption-only is dropped entirely rather than - * appended empty. Used for input that never enters the step queue (blocked - * by a submit hook); queued input goes through `StepRequest` - * materialization, which applies the same ordering. - */ private appendPrompt(message: ContextMessage, captions: readonly string[]): void { - for (const caption of captions) { - this.reminders.appendSystemReminder(caption, { - kind: 'injection', - variant: 'image_compression', - }); - } - if (message.content.length > 0) this.append(message); + for (const caption of captions) this.reminders.appendSystemReminder(caption, { kind: 'injection', variant: 'image_compression' }); + if (message.content.length > 0) this.context.append(message); } - - private async enqueueSteer( - request: SteerStepRequest, - originalMessage: ContextMessage, - ): Promise { - if (await this.blockedByHook(originalMessage, true)) return undefined; - if (request.aborted) return undefined; - - this.pendingSteers.add(request); - // The turn that was active when `steer` ran may have ended while the - // submit hook awaited; the receipt reports the turn the steer actually - // joined (`undefined` when it parked and now rides the next turn). - return this.loop.enqueue(request).turn; + private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { + const delivery = ctx.result.delivery; if (delivery === undefined) return; + const { delivery: _delivery, ...rest } = ctx.result; ctx.result = rest as ExecutableToolResult; + if (delivery.kind === 'steer') await this.inject(delivery.message as ContextMessage); } + private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { this.eventBus.publish({ type: 'prompt.completed', promptId, finishedAt: new Date().toISOString(), reason }); } + private publishAborted(promptId: string): void { this.eventBus.publish({ type: 'prompt.aborted', promptId, abortedAt: new Date().toISOString() }); } } -function steerAlreadyEmittedError(): KimiError { - return new KimiError( - ErrorCodes.REQUEST_INVALID, - 'Cannot remove a steer after it has been emitted', - { details: { reason: 'steer_already_emitted' } }, - ); -} +function snapshot(item: Record): PromptSnapshot { return { id: item.id, userMessageId: item.userMessageId, createdAt: item.createdAt, state: item.state, message: item.message }; } +function deferred(): Deferred { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } -// Split inline image-compression captions (see buildImageCompressionCaption) -// out of user prompt content. A caption may be a standalone text part (server -// route, ACP) or merged into an adjacent text segment (TUI paste), so each -// text part is scanned rather than matched whole. Text left empty once its -// captions are removed is dropped entirely. -function splitImageCompressionCaptions(content: readonly ContentPart[]): { - captions: string[]; - parts: ContentPart[]; -} { - const captions: string[] = []; - const parts: ContentPart[] = []; - for (const part of content) { - if (part.type !== 'text') { - parts.push(part); - continue; - } - const extracted = extractImageCompressionCaptions(part.text); - if (extracted.captions.length === 0) { - parts.push(part); - continue; - } - captions.push(...extracted.captions); - if (extracted.text.trim().length > 0) { - parts.push({ type: 'text', text: extracted.text }); - } - } - return { captions, parts }; -} - -registerScopedService( - LifecycleScope.Agent, - IAgentPromptService, - AgentPromptService, - InstantiationType.Delayed, - 'prompt', -); +registerScopedService(LifecycleScope.Agent, IAgentPromptService, AgentPromptService, InstantiationType.Delayed, 'prompt'); diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index f66603472..cb8561494 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -4,13 +4,12 @@ * `PromptStepRequest` / `SteerStepRequest` carry an already-built user * `ContextMessage` (image-compression captions pre-split) and materialize it * at pop time — caption reminders first, message second, mirroring the old - * `appendPrompt` ordering. `PromptStepRequest` is `nextTurn` (it starts a - * fresh turn, seeding the `turn.prompt` record from its message); - * `SteerStepRequest` is `tryInTurn`, mergeable (folds into the next step's - * driver) and survives turn boundaries (drained by a later run); it records + * `appendPrompt` ordering. `PromptStepRequest` uses `newTurn`, seeding the + * `turn.prompt` record from its message. `SteerStepRequest` uses + * `activeOrNewTurn`, is mergeable, and survives turn boundaries; it records * the `turn.steer` wire op on materialization and unregisters itself from the - * service's pending-steer set once settled. `RetryStepRequest` is `nextTurn` - * too: it contributes no message and simply drives one more step over the + * service's pending-steer set once settled. `RetryStepRequest` uses `newTurn`: + * it contributes no message and simply drives one more step over the * existing context. Constructed by the prompt service with its collaborators * captured — these are plain runtime objects, not DI services. */ @@ -29,6 +28,10 @@ abstract class UserMessageStepRequest extends StepRequest { super(options); } + override get turnSeed(): TurnSeed { + return { input: this.message.content, origin: this.message.origin ?? USER_PROMPT_ORIGIN }; + } + override onWillMaterialize(): void { for (const caption of this.captions) { this.reminders.appendSystemReminder(caption, { @@ -53,7 +56,7 @@ export class PromptStepRequest extends UserMessageStepRequest { captions: readonly string[], reminders: IAgentSystemReminderService, ) { - super(message, captions, reminders, { priority: 'nextTurn' }); + super(message, captions, reminders, { admission: 'newTurn' }); } override get turnSeed(): TurnSeed { @@ -70,8 +73,13 @@ export class SteerStepRequest extends UserMessageStepRequest { reminders: IAgentSystemReminderService, private readonly recordSteer: (message: ContextMessage) => void, private readonly forgetSteer: (request: SteerStepRequest) => void, + admission: 'activeTurnOnly' | 'activeOrNewTurn' = 'activeTurnOnly', ) { - super(message, captions, reminders, { mergeable: true, turnScoped: false }); + super(message, captions, reminders, { + mergeable: true, + turnScoped: false, + admission, + }); } override onWillMaterialize(): void { @@ -88,7 +96,7 @@ export class RetryStepRequest extends StepRequest { readonly kind = 'retry'; constructor() { - super({ priority: 'nextTurn' }); + super({ admission: 'newTurn' }); } override get turnSeed(): TurnSeed { diff --git a/packages/agent-core-v2/src/agent/promptLegacy/errors.ts b/packages/agent-core-v2/src/agent/promptLegacy/errors.ts deleted file mode 100644 index 46e7d5a3b..000000000 --- a/packages/agent-core-v2/src/agent/promptLegacy/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * `promptLegacy` domain error codes — v1-compatible prompt failures. - */ - -export const PromptLegacyErrors = { - codes: { - PROMPT_NOT_FOUND: 'prompt.not_found', - SESSION_BUSY: 'session.busy', - PROMPT_ALREADY_COMPLETED: 'prompt.already_completed', - }, -} as const; diff --git a/packages/agent-core-v2/src/agent/promptLegacy/promptLegacy.ts b/packages/agent-core-v2/src/agent/promptLegacy/promptLegacy.ts deleted file mode 100644 index e8a0d3961..000000000 --- a/packages/agent-core-v2/src/agent/promptLegacy/promptLegacy.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * `promptLegacy` domain (L7 edge adapter) — v1-compatible prompt scheduler. - * - * Implements the legacy `/api/v1` prompt contract (`submit` / `list` / `steer` - * / `abort` with `prompt_id`, a FIFO queue, and `prompt.*` lifecycle events) on - * top of the v2 turn-driver (`IAgentPromptService`). v2's native `IAgentPromptService` - * (turn-is-the-submission, no queue) is untouched and continues to serve - * `/api/v2`. This service exists purely so clients of the v1 server keep - * working against server-v2. Bound at Agent scope — the queue and the active - * submission are per-agent state. - */ - -import type { - PromptAbortResponse, - PromptListResponse, - PromptSteerResult, - PromptSubmission, - PromptSubmitResult, -} from '@moonshot-ai/protocol'; - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { TurnResult } from '#/agent/loop/loop'; - -/** - * Outcome of a prompt that was launched (or queued and later launched) by - * {@link IAgentPromptLegacyService.submitAndSettle}. `result` is the underlying - * turn's settled `TurnResult` — the same signal the legacy scheduler already - * observes internally to advance its queue, now exposed to in-process callers - * so they can await turn completion authoritatively instead of reverse - * engineering it from the event stream. - */ -export interface PromptCompletion { - readonly promptId: string; - readonly result: TurnResult; -} - -export interface PromptSettleResult { - readonly submit: PromptSubmitResult; - /** - * Resolves when the submitted prompt's turn settles (covering prompts that - * were queued and run later). Rejects if the prompt is dropped before it ever - * launches (e.g. the agent is busy and the submission is `blocked`, or it is - * aborted while still queued). - */ - readonly completion: Promise; -} - -export interface IAgentPromptLegacyService { - readonly _serviceBrand: undefined; - - list(): PromptListResponse; - submit(body: PromptSubmission): Promise; - /** - * Submit like {@link submit}, but also return a `completion` promise of the - * launched turn's settled result. Used by in-process callers (e.g. `kimi -p`) - * that need to await turn completion authoritatively; server callers that - * only need the serializable `PromptSubmitResult` keep using {@link submit}. - */ - submitAndSettle(body: PromptSubmission): Promise; - steer(promptIds: readonly string[]): Promise; - abort(promptId: string): Promise; -} - -export const IAgentPromptLegacyService: ServiceIdentifier = - createDecorator('agentPromptLegacyService'); diff --git a/packages/agent-core-v2/src/agent/promptLegacy/promptLegacyService.ts b/packages/agent-core-v2/src/agent/promptLegacy/promptLegacyService.ts deleted file mode 100644 index 935a879d2..000000000 --- a/packages/agent-core-v2/src/agent/promptLegacy/promptLegacyService.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * `promptLegacy` domain — `IAgentPromptLegacyService` implementation. - * - * Per-agent v1-compatible scheduler. Owns the active submission and a FIFO - * queue; gates submissions through `auth`, launches turns through `prompt`, - * observes active turns through `turn`, applies request overrides through - * `profile` / `permissionMode`, persists prompt metadata through - * `sessionMetadata`, publishes updates through `event`, and reads the - * session identity from `sessionContext`. Also synthesizes the legacy - * `prompt.completed` / `prompt.aborted` / `prompt.steered` lifecycle events - * onto the per-agent `IEventBus` so the v1-compatible WS edge can forward - * them (the v2 core engine emits only `turn.ended`). Bound at Agent scope. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { userCancellationReason } from '#/_base/utils/abort'; -import { newMessageId } from '#/agent/contextMemory/messageId'; -import { ErrorCodes, KimiError } from '#/errors'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; -import { - applyPromptMetadataUpdate, - promptMetadataTextFromContentParts, -} from '#/agent/rpc/prompt-metadata'; -import type { ContentPart } from '#/app/llmProtocol/message'; -import { IAuthSummaryService } from '#/app/auth/auth'; -import { IEventService } from '#/app/event/event'; -import { IEventBus } from '#/app/event/eventBus'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import type { - PromptAbortResponse, - PromptAbortedEvent, - PromptCompletedEvent, - PromptItem, - PromptListResponse, - PromptStatus, - PromptSteeredEvent, - PromptSteerResult, - PromptSubmission, - PromptSubmitResult, -} from '@moonshot-ai/protocol'; - -import { - IAgentPromptLegacyService, - type PromptCompletion, - type PromptSettleResult, -} from './promptLegacy'; - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'prompt.completed': PromptCompletedEvent; - 'prompt.aborted': PromptAbortedEvent; - 'prompt.steered': PromptSteeredEvent; - } -} - -interface PromptRecord { - readonly promptId: string; - readonly userMessageId: string; - readonly body: PromptSubmission; - readonly createdAt: string; -} - -interface ActivePrompt extends PromptRecord { - readonly turn: Turn; -} - -export class AgentPromptLegacyService implements IAgentPromptLegacyService { - declare readonly _serviceBrand: undefined; - - private active: ActivePrompt | undefined; - private readonly queued: PromptRecord[] = []; - /** Prompts whose abort was requested; their turn settles asynchronously. */ - private readonly abortedPromptIds = new Set(); - /** - * Per-prompt completion deferreds created by {@link submitAndSettle}; resolved - * when the prompt's turn settles, rejected if the prompt is dropped before it - * launches. Only populated for in-process callers that asked for completion. - */ - private readonly completions = new Map>(); - - constructor( - @IAgentPromptService private readonly prompt: IAgentPromptService, - @IAgentLoopService private readonly loop: IAgentLoopService, - @IAgentProfileService private readonly profile: IAgentProfileService, - @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, - @ISessionMetadata private readonly metadata: ISessionMetadata, - @IEventService private readonly eventService: IEventService, - @IEventBus private readonly eventBus: IEventBus, - @ISessionContext private readonly sessionContext: ISessionContext, - @IAuthSummaryService private readonly authSummary: IAuthSummaryService, - ) {} - - list(): PromptListResponse { - return { - active: this.active === undefined ? null : toItem(this.active, 'running'), - queued: this.queued.map((record) => toItem(record, 'queued')), - }; - } - - async submit(body: PromptSubmission): Promise { - return this.submitInternal(body, undefined); - } - - async submitAndSettle(body: PromptSubmission): Promise { - const deferred = makeDeferred(); - const submit = await this.submitInternal(body, deferred); - return { submit, completion: deferred.promise }; - } - - private async submitInternal( - body: PromptSubmission, - completion: Deferred | undefined, - ): Promise { - await this.authSummary.ensureReady(); - await this.applyOverrides(body); - - const record = this.createRecord(body); - if (completion !== undefined) { - this.completions.set(record.promptId, completion); - } - if (this.active !== undefined) { - this.queued.push(record); - return toItem(record, 'queued'); - } - const status = await this.launch(record); - if (status === 'blocked') { - // `launch` drops the record (does not queue it) when it cannot start a - // turn, so it will never settle — reject the completion instead of - // leaving it pending forever. - this.rejectCompletion( - record.promptId, - new Error('Prompt submission was blocked and will not run'), - ); - } - return toItem(record, status); - } - - async steer(promptIds: readonly string[]): Promise { - if (promptIds.length === 0) { - throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt_ids must not be empty'); - } - if (this.active === undefined) { - throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'no active prompt to steer into'); - } - - const selectedIds = new Set(promptIds); - const selected: PromptRecord[] = []; - for (let i = this.queued.length - 1; i >= 0; i--) { - const record = this.queued[i]!; - if (selectedIds.has(record.promptId)) { - selected.push(record); - this.queued.splice(i, 1); - } - } - if (selected.length !== selectedIds.size) { - throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, 'one or more prompts are not queued'); - } - selected.reverse(); - - const content = selected.flatMap((record) => contentToCoreParts(record.body.content)); - const steeredContent = selected.flatMap((record) => record.body.content); - const activePromptId = this.active.promptId; - await this.prompt.steer({ - role: 'user', - content, - toolCalls: [], - origin: { kind: 'user' }, - }).launched; - this.publishSteered(activePromptId, promptIds, steeredContent); - return { steered: true, prompt_ids: [...promptIds] }; - } - - async abort(promptId: string): Promise { - if (this.active?.promptId === promptId) { - // Mark and cancel; the turn settles asynchronously and `onTurnSettled` - // clears `active`, starts the next queued prompt, and emits the - // `prompt.aborted` lifecycle event (so we do not double-emit here). - this.abortedPromptIds.add(promptId); - this.loop.cancel(this.active.turn.id, userCancellationReason()); - return { aborted: true }; - } - - const index = this.queued.findIndex((item) => item.promptId === promptId); - if (index >= 0) { - this.queued.splice(index, 1); - // The prompt never launched, so no turn will settle it — reject any - // completion waiter instead of leaving it pending, and emit the - // `prompt.aborted` lifecycle event here since no `turn.ended` will. - this.rejectCompletion(promptId, userCancellationReason()); - this.publishAborted(promptId); - return { aborted: true }; - } - - throw new KimiError(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${promptId} not found`); - } - - // --- internals ------------------------------------------------------------- - - private createRecord(body: PromptSubmission): PromptRecord { - // `prompt_id` IS the user-message id: the same `msg_` is stamped onto - // the ContextMessage appended in `launch`, so the prompt and its message - // share one identity across the wire, the turn, and the snapshot. - const promptId = newMessageId(); - return { - promptId, - userMessageId: promptId, - body, - createdAt: new Date().toISOString(), - }; - } - - private async launch(record: PromptRecord): Promise { - const parts = contentToCoreParts(record.body.content); - if (parts.length === 0) { - throw new KimiError(ErrorCodes.REQUEST_INVALID, 'prompt content has no supported parts'); - } - // Mirror v1 (web REST submit -> core.rpc.prompt -> updatePromptMetadata): - // persist `lastPrompt` and derive an easy title from the first prompt so the - // web session title is populated as soon as the conversation starts. This is - // the entry the web actually uses (`POST /api/v1/sessions/{id}/prompts`). - await applyPromptMetadataUpdate( - { - metadata: this.metadata, - eventService: this.eventService, - sessionId: this.sessionContext.sessionId, - }, - promptMetadataTextFromContentParts(parts), - ); - const turn = await this.prompt.prompt({ - id: record.promptId, - role: 'user', - content: parts, - toolCalls: [], - origin: { kind: 'user' }, - }); - if (turn === undefined) { - if (this.loop.getActiveTurn() !== undefined) { - // Busy with a turn started outside the legacy service (e.g. via /api/v2); - // keep the record queued so it runs once the agent is idle. - this.queued.unshift(record); - return 'queued'; - } - return 'blocked'; - } - this.active = { ...record, turn }; - void turn.result.then((result) => this.onTurnSettled(record.promptId, result)); - return 'running'; - } - - private publishCompleted(promptId: string, reason: 'completed' | 'failed'): void { - this.eventBus.publish({ - type: 'prompt.completed', - promptId, - finishedAt: new Date().toISOString(), - reason, - }); - } - - private publishAborted(promptId: string): void { - this.eventBus.publish({ - type: 'prompt.aborted', - promptId, - abortedAt: new Date().toISOString(), - }); - } - - private publishSteered( - activePromptId: string, - promptIds: readonly string[], - content: PromptSubmission['content'], - ): void { - this.eventBus.publish({ - type: 'prompt.steered', - activePromptId, - promptIds: [...promptIds], - content, - steeredAt: new Date().toISOString(), - }); - } - - private onTurnSettled(promptId: string, result: TurnResult): void { - if (this.active?.promptId !== promptId) return; - this.active = undefined; - this.abortedPromptIds.delete(promptId); - this.resolveCompletion(promptId, result); - if (result.type === 'cancelled') { - this.publishAborted(promptId); - } else { - this.publishCompleted(promptId, result.type === 'failed' ? 'failed' : 'completed'); - } - this.startNextQueued(); - } - - private resolveCompletion(promptId: string, result: TurnResult): void { - const deferred = this.completions.get(promptId); - if (deferred === undefined) return; - this.completions.delete(promptId); - deferred.resolve({ promptId, result }); - } - - private rejectCompletion(promptId: string, reason: unknown): void { - const deferred = this.completions.get(promptId); - if (deferred === undefined) return; - this.completions.delete(promptId); - deferred.reject(reason); - } - - private startNextQueued(): void { - if (this.active !== undefined) return; - const next = this.queued.shift(); - if (next === undefined) return; - void this.launch(next); - } - - private async applyOverrides(body: PromptSubmission): Promise { - if (body.model !== undefined) { - await this.profile.setModel(body.model); - } - if (body.thinking !== undefined) { - this.profile.setThinking(body.thinking); - } - if (body.permission_mode !== undefined) { - this.permissionMode.setMode(body.permission_mode); - } - } -} - -function toItem(record: PromptRecord, status: PromptStatus): PromptItem { - return { - prompt_id: record.promptId, - user_message_id: record.userMessageId, - status, - content: record.body.content, - created_at: record.createdAt, - }; -} - -function contentToCoreParts(content: PromptSubmission['content']): ContentPart[] { - const parts: ContentPart[] = []; - for (const part of content) { - switch (part.type) { - case 'text': - parts.push({ type: 'text', text: part.text }); - break; - case 'image': - if (part.source.kind === 'url') { - parts.push({ type: 'image_url', imageUrl: { url: part.source.url } }); - } else if (part.source.kind === 'base64') { - parts.push({ - type: 'image_url', - imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }); - } - break; - case 'video': - if (part.source.kind === 'url') { - parts.push({ type: 'video_url', videoUrl: { url: part.source.url } }); - } else if (part.source.kind === 'base64') { - parts.push({ - type: 'video_url', - videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }); - } - break; - // tool_use / tool_result / file / thinking are not valid user-prompt input. - } - } - return parts; -} - -interface Deferred { - readonly promise: Promise; - resolve(value: T): void; - reject(reason: unknown): void; -} - -function makeDeferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -registerScopedService( - LifecycleScope.Agent, - IAgentPromptLegacyService, - AgentPromptLegacyService, - InstantiationType.Delayed, - 'promptLegacy', -); diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 203d12ca6..24a8ec565 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -107,12 +107,14 @@ export class AgentRPCService implements IAgentRPCService { // prompt BEFORE launching the turn, so the web session title is populated as // soon as the conversation starts (gap closed — v2 used to leave it empty). await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); - const turn = await this.promptService.prompt({ + const handle = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], toolCalls: [], origin: { kind: 'user' }, - }); + } }); + if (handle.state === 'pending') return undefined; + const turn = await handle.launched; return turn === undefined ? undefined : { turn_id: turn.id }; } @@ -126,17 +128,18 @@ export class AgentRPCService implements IAgentRPCService { async steer(payload: SteerPayload): Promise { this.telemetry.track('input_steer', { parts: payload.input.length }); - const steer = this.promptService.steer({ + const queued = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], toolCalls: [], - }); - const turn = await steer.launched; + } }); + const [steered] = await this.promptService.steer([queued.id]); + const turn = await steered?.launched; return turn === undefined ? undefined : { turn_id: turn.id }; } cancel({ turnId }: CancelPayload): void { - if (this.loop.getActiveTurn() !== undefined) { + if (this.loop.status().state === 'running') { this.telemetry.track('cancel', { from: 'streaming' }); } this.loop.cancel(turnId); @@ -272,12 +275,12 @@ export class AgentRPCService implements IAgentRPCService { commandArgs: origin.commandArgs, trigger: origin.trigger, }); - await this.promptService.prompt({ + await this.promptService.enqueue({ message: { role: 'user', content: [{ type: 'text', text: expanded }], toolCalls: [], origin, - }); + } }); await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload)); } diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts index e7a469b84..93dd83812 100644 --- a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -158,7 +158,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { } private notifyBackgrounded(output: string): void { - this.promptService.steer({ + void this.promptService.inject({ role: 'user', content: [{ type: 'text', text: output }], toolCalls: [], diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index 372667672..1ad953df2 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -113,7 +113,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService toolCalls: [], origin, }; - return this.prompt.prompt(message); + return (await this.prompt.enqueue({ message })).launched; } private renderSkillPrompt(skill: SkillDefinition, rawArgs: string): string { diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index 460d2b805..f321977a8 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -3,9 +3,10 @@ * * Loop error-recovery plugin: claims retryable provider failures (HTTP 429 / * 5xx, connection, timeout, empty response — `isRetryableGenerateError`) from - * the loop's error-handler registry and re-runs the failed step's driver - * after exponential backoff (`retryBackoffDelays`). The retry resumes the - * failed step's number, so attempts consume no `maxSteps` budget; each + * the loop's error-handler registry and re-enqueues the failed step's driver + * at the head of the queue after exponential backoff (`retryBackoffDelays`). + * The loop only learns that the error was caught; the retry rides the normal + * step numbering and consumes `maxSteps` budget like any other step. Each * claimed failure publishes `turn.step.retrying`. Consecutive attempts are * counted per failed driver and reset when any step succeeds (`afterStep`) * or a new turn starts. Bound at Agent scope; Eager so the handler registers @@ -30,7 +31,6 @@ import { unwrapErrorCause } from '#/errors'; import { IAgentLoopService, type LoopErrorContext, - type LoopErrorRecovery, } from '#/agent/loop/loop'; import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; @@ -49,20 +49,20 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry private failedAttempts = 0; constructor( - @IAgentLoopService loopService: IAgentLoopService, + @IAgentLoopService private readonly loopService: IAgentLoopService, @IConfigService private readonly config: IConfigService, @IEventBus private readonly eventBus: IEventBus, ) { super(); this._register( - loopService.registerLoopErrorHandler({ + this.loopService.registerLoopErrorHandler({ id: 'step-retry', match: (context) => isRetryableGenerateError(unwrapErrorCause(context.error)), handle: (context) => this.recover(context), }), ); this._register( - loopService.hooks.afterStep.register('step-retry', async (_ctx, next) => { + this.loopService.hooks.afterStep.register('step-retry', async (_ctx, next) => { this.resetAttempts(); await next(); }), @@ -75,9 +75,9 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry this.failedAttempts = 0; } - private async recover(context: LoopErrorContext): Promise { + private async recover(context: LoopErrorContext): Promise { const driver = context.failedDriver; - if (driver === undefined || context.step === undefined) return undefined; + if (driver === undefined || context.step === undefined) return false; if (this.lastFailedDriverId !== driver.id) { this.lastFailedDriverId = driver.id; @@ -92,7 +92,7 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry ); if (this.failedAttempts >= maxAttempts) { this.resetAttempts(); - return undefined; + return false; } const delayMs = retryBackoffDelays(maxAttempts)[this.failedAttempts - 1] ?? 0; @@ -110,8 +110,10 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry await sleepForRetry(delayMs, context.signal); // The driver is already materialized, so its messages are not appended a - // second time; re-running it resumes the same step number. - return { requests: [driver], resumeStep: true }; + // second time; re-running it drives another step over the same context. + if (context.currentStep?.signal.aborted === true) return false; + context.retry(driver, { at: 'head' }); + return true; } } diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index f6e1598a3..e28b8ab7b 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -973,14 +973,13 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private async notifyAgentTask(info: AgentTaskInfo): Promise { const context = await this.buildAgentTaskNotificationContext(info); if (context === undefined) return; - this.loop.enqueue( - new TaskNotificationStepRequest({ - role: 'user', - content: [...context.content], - toolCalls: [], - origin: context.origin, - }), - ); + const request = new TaskNotificationStepRequest({ + role: 'user', + content: [...context.content], + toolCalls: [], + origin: context.origin, + }); + this.loop.enqueue(request); this.fireNotificationHook(context.notification); } diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index 6ec2233a7..30682f4f0 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -9,7 +9,10 @@ * after the dispatch, and are re-derived from the rebuilt Model by * `wire.onRestored` after `wire.replay`, so a resumed agent re-registers exactly * the tools the persisted ops describe without re-firing any live notification. - * The per-tool `IDisposable` handles stay live-only (they cannot be persisted). + * The restore re-registers into the tool registry only: the active-tool set is + * owned by the persisted `ActiveToolsModel`, so the ephemeral `addActiveTool` + * overlay is not rebuilt (it is live-only by design). The per-tool + * `IDisposable` handles stay live-only (they cannot be persisted). * Bound at Agent scope. */ @@ -74,12 +77,20 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe } private restoreRegisteredTools(): void { + // The persisted `ActiveToolsModel` is the source of truth for the active + // set on resume. Re-activating a tool whose registration predates the + // final `tools.set_active_tools` would resurrect a stale ephemeral + // overlay on top of an explicit base, so only activate tools the base + // does not exclude. + const persistedActive = this.profile.getActiveToolNames(); for (const registration of this.wire.getModel(UserToolModel).values()) { - this.applyRegister(registration); + const activate = + persistedActive === undefined || persistedActive.includes(registration.name); + this.applyRegister(registration, { activate }); } } - private applyRegister(input: UserToolRegistration): void { + private applyRegister(input: UserToolRegistration, options?: { readonly activate?: boolean }): void { const { name, description, parameters } = input; this.applyUnregister(name); const tool: ExecutableTool = { @@ -92,6 +103,7 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe }), }; this.registrations.set(name, this._register(this.registry.register(tool, { source: 'user' }))); + if (options?.activate === false) return; this.profile.addActiveTool(name); } diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 070a6f9d3..73e95400a 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -42,12 +42,15 @@ export class RestGateway implements IRestGateway { agentId: string, input: string, ): Promise<{ readonly turn_id: number } | undefined> { - const turn = await this.agent(sessionId, agentId).accessor.get(IAgentPromptService).prompt({ - role: 'user', - content: [{ type: 'text', text: input }], - toolCalls: [], - origin: { kind: 'user' }, + const handle = await this.agent(sessionId, agentId).accessor.get(IAgentPromptService).enqueue({ + message: { + role: 'user', + content: [{ type: 'text', text: input }], + toolCalls: [], + origin: { kind: 'user' }, + }, }); + const turn = await handle.launched; return turn === undefined ? undefined : { turn_id: turn.id }; } async steer( @@ -55,14 +58,15 @@ export class RestGateway implements IRestGateway { agentId: string, content: string, ): Promise<{ readonly turn_id: number } | undefined> { - const agent = this.agent(sessionId, agentId); - const steer = agent.accessor.get(IAgentPromptService).steer({ + const service = this.agent(sessionId, agentId).accessor.get(IAgentPromptService); + const queued = await service.enqueue({ message: { role: 'user', content: [{ type: 'text', text: content }], toolCalls: [], origin: { kind: 'user' }, - }); - const turn = await steer.launched; + } }); + const [steered] = await service.steer([queued.id]); + const turn = await steered?.launched; return turn === undefined ? undefined : { turn_id: turn.id }; } cancel(sessionId: string, agentId: string, reason?: string): Promise { diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 8bd2512e3..30d85a5ca 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -25,7 +25,6 @@ import { ModelCatalogErrors } from '#/app/modelCatalog/errors'; import { PluginErrors } from '#/app/plugin/errors'; import { ProfileErrors } from '#/agent/profile/errors'; import { PromptErrors } from '#/agent/prompt/errors'; -import { PromptLegacyErrors } from '#/agent/promptLegacy/errors'; import { SessionExportErrors } from '#/app/sessionExport/errors'; import { SessionErrors } from '#/session/errors'; import { SkillErrors } from '#/app/skillCatalog/errors'; @@ -55,7 +54,6 @@ export { ModelCatalogErrors } from '#/app/modelCatalog/errors'; export { PluginErrors } from '#/app/plugin/errors'; export { ProfileErrors } from '#/agent/profile/errors'; export { PromptErrors } from '#/agent/prompt/errors'; -export { PromptLegacyErrors } from '#/agent/promptLegacy/errors'; export { SessionExportErrors } from '#/app/sessionExport/errors'; export { SessionErrors } from '#/session/errors'; export { SkillErrors } from '#/app/skillCatalog/errors'; @@ -82,7 +80,6 @@ export const ErrorCodes = { ...PluginErrors.codes, ...ProfileErrors.codes, ...PromptErrors.codes, - ...PromptLegacyErrors.codes, ...SessionExportErrors.codes, ...SessionErrors.codes, ...SkillErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index b6add4908..37d392a75 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -368,6 +368,8 @@ export * from '#/_base/utils/retry'; import '#/agent/loop/configSection'; export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; +export * from '#/agent/loop/loopContinuation'; +export * from '#/agent/loop/loopContinuationService'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; @@ -394,9 +396,6 @@ export * from '#/agent/profile/profileService'; export * from '#/agent/profile/context'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; -import '#/agent/promptLegacy/errors'; -export * from '#/agent/promptLegacy/promptLegacy'; -export * from '#/agent/promptLegacy/promptLegacyService'; import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; @@ -406,6 +405,7 @@ export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; export * from '#/agent/rpc/rpc'; export * from '#/agent/rpc/rpcService'; +export * from '#/agent/rpc/prompt-metadata'; export * from '#/agent/scopeContext/scopeContext'; export * from '#/agent/stepRetry/stepRetry'; export * from '#/agent/stepRetry/stepRetryService'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 44c017f48..dbc19af21 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -48,6 +48,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activity'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentLoopContinuationService } from '#/agent/loop/loopContinuation'; import { IAgentStepRetryService } from '#/agent/stepRetry/stepRetry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; @@ -284,6 +285,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // retryable provider failures. Nothing injects it directly — it observes // the loop — so it must be ignited before the first turn. handle.accessor.get(IAgentStepRetryService); + // Loop-continuation aspect: enqueues the next step whenever a step ran + // tools. It only observes the loop's afterStep hook, so without ignition + // every tool-using turn would stop after a single step. + handle.accessor.get(IAgentLoopContinuationService); } private async bindBootstrap( diff --git a/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts index df1439651..662d93868 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts @@ -69,13 +69,13 @@ export async function runAgentTurn( const promptService = target.accessor.get(IAgentPromptService); const turn = request.kind === 'prompt' - ? await promptService.prompt({ + ? await (await promptService.enqueue({ message: { role: 'user', content: [{ type: 'text', text: request.prompt }], toolCalls: [], origin: AGENT_RUN_PROMPT_ORIGIN, - }) - : promptService.retry(); + } })).launched + : await promptService.retry(); if (turn === undefined) throw new Error('Agent turn could not be started'); if (options.onReady !== undefined) { @@ -150,12 +150,12 @@ async function distillSummary( const promptService = target.accessor.get(IAgentPromptService); for (let attempt = 0; attempt < policy.retries; attempt++) { - const turn = await promptService.prompt({ + const turn = await (await promptService.enqueue({ message: { role: 'user', content: [{ type: 'text', text: policy.continuationPrompt }], toolCalls: [], origin: AGENT_RUN_PROMPT_ORIGIN, - }); + } })).launched; if (turn === undefined) break; setTurn(turn); const result = await awaitTurn(turn, controller, cancelTurn); diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts index c94a95bc0..89a49f374 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts @@ -316,7 +316,7 @@ export class AgentTool implements BuiltinTool { if (subagentParentAgentId(meta) !== this.callerAgentId) { throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); } - if (target.accessor.get(IAgentLoopService).getActiveTurn() !== undefined) { + if (target.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); } } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 6d3233152..3f6fca382 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -313,7 +313,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe if (!mainHandle) return; const loop = mainHandle.accessor.get(IAgentLoopService); - if (loop.getActiveTurn() !== undefined) return; + if (loop.status().state === 'running') return; const now = this.clocks.wallNow(); @@ -425,7 +425,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe toolCalls: [], origin, }; - void promptService.steer(message).launched.catch(() => {}); + void promptService.inject(message).catch(() => {}); this.telemetry.track(CRON_MISSED, { count: tasks.length }); return undefined; } @@ -481,11 +481,11 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe toolCalls: [], origin, }; - const buffered = mainHandle.accessor.get(IAgentLoopService).getActiveTurn() !== undefined; + const buffered = mainHandle.accessor.get(IAgentLoopService).status().state === 'running'; let launched: Promise; try { - launched = promptService.steer(message).launched; + launched = promptService.inject(message); } catch (error) { this.debugLog( `steer threw for task ${task.id}: ${ diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index c808558e1..d98be5361 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -37,7 +37,7 @@ export class SessionActivity implements ISessionActivity { private hasActiveTurn(): boolean { for (const handle of this.agents.list()) { const loop = handle.accessor.get(IAgentLoopService); - if (loop.getActiveTurn() !== undefined) return true; + if (loop.status().state === 'running') return true; } return false; } diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 678713738..d54610baf 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -242,7 +242,7 @@ export class SessionSwarmService implements ISessionSwarmService { } private requireIdleSubagent(agentId: string, child: IAgentScopeHandle): void { - if (child.accessor.get(IAgentLoopService).getActiveTurn() !== undefined) { + if (child.accessor.get(IAgentLoopService).status().state === 'running') { throw new Error(`Agent instance "${agentId}" is already running and cannot run concurrently`); } } diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 61aabc3dd..da159d54d 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -13,10 +13,9 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; -import { IAgentTurnService } from '#/agent/turn/turn'; import { IEventBus } from '#/app/event/eventBus'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; -import { stubLoopWithHooks, stubTurnWithHooks } from '../turn/stubs'; +import { stubLoopWithHooks } from '../loop/stubs'; type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise; @@ -62,7 +61,6 @@ describe('AgentContextInjectorService', () => { strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); - reg.defineInstance(IAgentTurnService, stubTurnWithHooks()); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentContextInjectorService, AgentContextInjectorService); }, diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 9ef16d03b..19cb5b6b0 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -185,7 +185,7 @@ describe('Agent context', () => { expect(history[1]?.content).toEqual([{ type: 'text', text: '' }]); }); - it('rejects tool result messages left empty by LLM projection cleanup', () => { + it('renders tool result messages left empty by LLM projection cleanup as empty output', () => { const history: ContextMessage[] = [ { role: 'assistant', @@ -200,9 +200,21 @@ describe('Agent context', () => { }, ]; - expect(() => ctx.project(history)).toThrow( - 'Tool result message content cannot be empty after removing empty text blocks.', - ); + // Empty tool output never reaches the model as a blank block (and no + // longer throws): the projection renders the empty-output status text. + expect(ctx.project(history)).toEqual([ + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'call_empty', name: 'empty', arguments: '{}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'Tool output is empty.' }], + toolCalls: [], + toolCallId: 'call_empty', + }, + ]); }); it('projects hook result messages into LLM projection', async () => { @@ -642,7 +654,8 @@ describe('Agent context', () => { expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); - it('preserves injection messages when undo removes the surrounding turn', () => { + it('removes injection messages inside the undone turn', () => { + context.append(userMessage('earlier question', { kind: 'user' })); context.append(userMessage('do the work', { kind: 'user' })); context.append( userMessage('Plan mode is active', { @@ -661,10 +674,15 @@ describe('Agent context', () => { ctx.undoHistory(1); + // v2 undo cuts at the oldest undone real-user prompt regardless of origin: + // injections inside the removed range go with the turn (unlike v1, which + // kept them); dynamic context such as plan-mode notices and tool schemas + // self-heals via re-injection on the next turn boundary. expect(context.get()).toEqual([ expect.objectContaining({ role: 'user', - origin: { kind: 'injection', variant: 'plan_mode' }, + content: [{ type: 'text', text: 'earlier question' }], + origin: { kind: 'user' }, }), ]); }); diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 8c0bc8f40..c7fb2422d 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -43,7 +43,7 @@ import { type ResolvedAgentProfile, type ToolExecution, } from '#/index'; -import { IAgentTurnService } from '#/agent/turn/turn'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentContextSizeService } from '#/agent/contextSize/contextSize'; import { IAgentGoalService } from '#/agent/goal/goal'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; @@ -364,7 +364,7 @@ describe('FullCompaction', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start the active turn' }] }); const approval = await ctx.takeApprovalRequest(); - expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeDefined(); + expect(ctx.get(IAgentLoopService).status().activeTurnId).toBeDefined(); await expect(ctx.rpc.beginCompaction({})).rejects.toMatchObject({ code: 'compaction.unable', @@ -379,7 +379,7 @@ describe('FullCompaction', () => { ctx.mockNextResponse({ type: 'text', text: 'Turn done.' }); approval.respond({ decision: 'rejected', selectedLabel: 'reject' }); await ctx.untilTurnEnd(); - expect(ctx.get(IAgentTurnService).getActiveTurn()).toBeUndefined(); + expect(ctx.get(IAgentLoopService).status().activeTurnId).toBeUndefined(); }); it('projects the compacted prefix before sending the summary request', async () => { diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index bf823eea4..146bf1597 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -7,9 +7,8 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentGoalService } from '#/agent/goal/goal'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/update-goal'; -import { IAgentLoopService, type AfterStepContext } from '#/agent/loop/loop'; +import { IAgentLoopService, type AfterStepContext, type Turn } from '#/agent/loop/loop'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentTurnService, type Turn } from '#/agent/turn/turn'; import { IAgentUsageService } from '#/agent/usage/usage'; import type { PersistedWireRecord, WireRecord } from '#/agent/wireRecord/wireRecord'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; @@ -29,7 +28,7 @@ import { type TestAgentOptions, } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; -import { stubLoopWithHooks, stubTurn, type StubLoop, type StubTurn } from '../turn/stubs'; +import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; type GoalRecord = Extract; @@ -66,6 +65,7 @@ function makeTurn(id: number): Turn { signal: new AbortController().signal, ready: Promise.resolve(), result: Promise.resolve({ type: 'completed', steps: 0, truncated: false }), + cancel: () => true, }; } @@ -595,17 +595,14 @@ describe('AgentGoalService core workflow hooks', () => { let ctx: TestAgentContext | undefined; let context: IAgentContextMemoryService; let goals: IAgentGoalService; - let turnService: StubTurn; let loopService: StubLoop; let toolExecutor: IAgentToolExecutorService; let usageService: IAgentUsageService; let eventBus: IEventBus; beforeEach(() => { - turnService = stubTurn({ hasActiveTurn: true }); - loopService = stubLoopWithHooks(); + loopService = stubLoopWithHooks({ hasActiveTurn: true }); ctx = createTestAgent( - agentService(IAgentTurnService, turnService), agentService(IAgentLoopService, loopService), ); context = ctx.get(IAgentContextMemoryService); @@ -631,7 +628,7 @@ describe('AgentGoalService core workflow hooks', () => { status: 'active', turnsUsed: 1, }); - expect(turnService.launches).toHaveLength(1); + expect(loopService.launches).toHaveLength(1); // The continuation message is carried by a queued step request and only // lands in context when the loop pops it. expect(loopService.drainNextBatch(context)).toBeDefined(); @@ -656,14 +653,14 @@ describe('AgentGoalService core workflow hooks', () => { turnsUsed: 1, terminalReason: 'Blocked after goal budget reached: turn budget 1', }); - expect(turnService.launches).toEqual([]); + expect(loopService.launches).toEqual([]); }); it('accounts recorded turn usage for active goal turns', async () => { await goals.createGoal({ objective: 'finish the task' }); await goals.setBudgetLimits({ budgetLimits: { tokenBudget: 7 } }, 'model'); - const turn = turnService.launch(); + const turn = loopService.startTurn(); eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN }); expect( @@ -717,7 +714,7 @@ describe('AgentGoalService core workflow hooks', () => { await goals.createGoal({ objective: 'finish the task' }, 'model'); endTurn(eventBus, turn); - await vi.waitFor(() => expect(turnService.launches).toHaveLength(1)); + await vi.waitFor(() => expect(loopService.launches).toHaveLength(1)); expect(goals.getGoal().goal).toMatchObject({ status: 'active', turnsUsed: 1, @@ -738,7 +735,7 @@ describe('AgentGoalService core workflow hooks', () => { turnsUsed: 1, terminalReason: 'Blocked after goal budget reached: turn budget 1', }); - expect(turnService.launches).toEqual([]); + expect(loopService.launches).toEqual([]); }); it('charges post-creation step output tokens for the goal-creating turn', async () => { @@ -789,7 +786,7 @@ describe('AgentGoalService core workflow hooks', () => { // The outcome continuation is a queued step request now, not a ctx flag. expect(loopService.hasPendingRequests()).toBe(true); expect(goals.getGoal().goal).toBeNull(); - expect(turnService.launches).toEqual([]); + expect(loopService.launches).toEqual([]); expect(JSON.stringify(context.get())).not.toContain('goal_completion_summary'); expect(JSON.stringify(context.get())).not.toContain('goal_blocked_reason'); @@ -819,7 +816,7 @@ describe('AgentGoalService core workflow hooks', () => { status: 'paused', terminalReason: 'Paused after runtime error: boom', }); - expect(turnService.launches).toEqual([]); + expect(loopService.launches).toEqual([]); }); it('blocks active goals when the user prompt hook blocks the turn', async () => { @@ -833,12 +830,12 @@ describe('AgentGoalService core workflow hooks', () => { status: 'blocked', terminalReason: 'Blocked by UserPromptSubmit hook', }); - expect(turnService.launches).toEqual([]); + expect(loopService.launches).toEqual([]); }); it('pauses the goal when the continuation launch fails', async () => { await goals.createGoal({ objective: 'finish the task' }); - vi.spyOn(turnService, 'launch').mockImplementation(() => { + vi.spyOn(loopService, 'enqueue').mockImplementation(() => { throw new Error('wire dispatch exploded'); }); const updates: GoalUpdatedEvent[] = []; @@ -858,38 +855,17 @@ describe('AgentGoalService core workflow hooks', () => { expect(updates.at(-1)?.snapshot).toMatchObject({ status: 'paused' }); }); - it('defers the continuation while another turn is active and relaunches at its end', async () => { + it('queues one continuation and lets the loop start it automatically', async () => { await goals.createGoal({ objective: 'finish the task' }); const goalTurn = makeTurn(31); eventBus.publish({ type: 'turn.started', turnId: goalTurn.id, origin: USER_PROMPT_ORIGIN }); await runGoalStep(loopService, goalTurn); - - // The turn service owns admission: while another activity holds the lane - // its launch rejects with the coded busy error. - const busyLaunch = vi.spyOn(turnService, 'launch').mockImplementation(() => { - throw new KimiError( - ErrorCodes.ACTIVITY_AGENT_BUSY, - 'Cannot begin a new turn while turn 32 is active', - ); - }); - const busyTurn = makeTurn(32); - eventBus.publish({ type: 'turn.started', turnId: busyTurn.id, origin: USER_PROMPT_ORIGIN }); endTurn(eventBus, goalTurn); - // A lost admission race only defers the continuation: the goal stays - // active and the aborted request leaves nothing queued behind. - await vi.waitFor(() => expect(busyLaunch).toHaveBeenCalled()); - expect(goals.getGoal().goal?.status).toBe('active'); - expect(turnService.launches).toEqual([]); - expect(loopService.hasPendingRequests()).toBe(false); - - // Free the lane; the next turn end re-runs the continuation admission check. - busyLaunch.mockRestore(); - endTurn(eventBus, busyTurn); - - await vi.waitFor(() => expect(turnService.launches).toHaveLength(1)); + await vi.waitFor(() => expect(loopService.launches).toHaveLength(1)); expect(goals.getGoal().goal?.status).toBe('active'); + expect(loopService.hasPendingRequests()).toBe(true); }); }); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index 5b94c729d..18ce0cf1e 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -14,7 +14,6 @@ import { GoalModel } from '#/agent/goal/goalOps'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentTurnService } from '#/agent/turn/turn'; import { IAgentUsageService } from '#/agent/usage/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; @@ -36,17 +35,6 @@ function hookSlot(): { register: () => { dispose: () => void } } { return { register: () => noopDisposable() }; } -function createTurnStub(): IAgentTurnService { - return { - _serviceBrand: undefined, - hooks: { onLaunched: hookSlot(), onEnded: hookSlot() }, - getActiveTurn: () => undefined, - launch: () => { - throw new Error('not exercised'); - }, - } as unknown as IAgentTurnService; -} - function createLoopStub(): IAgentLoopService { return { _serviceBrand: undefined, @@ -114,7 +102,6 @@ function buildHost(key: string): { ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); ix.set(IEventBus, new SyncDescriptor(EventBusService)); - ix.stub(IAgentTurnService, createTurnStub()); ix.stub(IAgentLoopService, createLoopStub()); ix.stub(IAgentUsageService, { hooks: { onDidRecord: hookSlot() }, diff --git a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts index 9c32dbac0..d0f91fa8a 100644 --- a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts +++ b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts @@ -12,11 +12,10 @@ import { UpdateGoalToolInputSchema, } from '#/agent/goal/tools/update-goal'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentTurnService } from '#/agent/turn/turn'; import { IEventBus } from '#/app/event/eventBus'; import { agentService, createTestAgent, type TestAgentContext } from '../../../harness'; -import { stubLoopWithHooks, stubTurn } from '../../turn/stubs'; +import { stubLoopWithHooks } from '../../loop/stubs'; const signal = new AbortController().signal; @@ -29,11 +28,8 @@ describe('goal tools', () => { let updateGoalTool: UpdateGoalTool; beforeEach(() => { - loopService = stubLoopWithHooks(); - ctx = createTestAgent( - agentService(IAgentTurnService, stubTurn({ hasActiveTurn: true })), - agentService(IAgentLoopService, loopService), - ); + loopService = stubLoopWithHooks({ hasActiveTurn: true }); + ctx = createTestAgent(agentService(IAgentLoopService, loopService)); goals = ctx.get(IAgentGoalService); eventBus = ctx.get(IEventBus); setGoalBudgetTool = new SetGoalBudgetTool(goals); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 6726232c3..3826225e0 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -5,9 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { IAgentProfileService } from '#/index'; import { IAgentLLMRequesterService, type LLMStreamTiming } from '#/agent/llmRequester/llmRequester'; import { IAgentGoalService } from '#/agent/goal/goal'; -import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; -import { IAgentTurnService } from '#/agent/turn/turn'; import type { ExecutableTool } from '#/agent/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentUsageService } from '#/agent/usage/usage'; @@ -56,8 +55,8 @@ describe('Agent loop', () => { [wire] tools.set_active_tools { "names": [], "time": "