diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index d7509008c..ea24ad481 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -86,6 +86,10 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at `context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +## Model-facing reminders + +Two delivery paths only — never introduce a third (no deferred-delivery queues, no mid-step splice channels): reminders that restate current state (goal state, plan mode, date change, …) register a `contextInjector` provider (`register`) that reconciles at every step head (before the step's request is built) and re-emits after compaction or undo; reminders that report a one-off event (goal cancelled, AGENTS.md discovered, `/init` finished, …) append at the event point through `IAgentSystemReminderService.appendSystemReminder` with origin `{ kind: 'injection', variant: '' }`, where the event point must itself be a safe position (a step/restore hook, an idle moment, or the loop-event fold's deferred append). `kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim; prompt-owned attachments additionally carry `ownerPromptId` so undo treats them as part of their host prompt. + ## Docs Per-domain references live in `docs/`. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 9c3c18472..6698b6bb7 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 69 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -57,10 +57,10 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts +// agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts -// contextInjector.isNewTurn src/agent/contextInjector/contextInjectorService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // dateChange.seed src/agent/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts @@ -118,7 +118,6 @@ // toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts // toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts // toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts -// toolSelect.needsBoundaryInjection src/agent/toolSelect/toolSelectAnnouncementsService.ts // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts @@ -753,12 +752,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -883,12 +877,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -945,12 +934,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -1013,8 +997,6 @@ export interface AgentStateSnapshot { 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; 'agentsMdReminder.seeded': boolean; - // src/agent/contextInjector/contextInjectorService.ts - 'contextInjector.isNewTurn': boolean; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; // src/agent/dateChange/dateChangeService.ts @@ -1126,6 +1108,8 @@ export interface AgentStateSnapshot { }>; // src/agent/permissionMode/injection/permissionModeInjection.ts 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; + // src/agent/plugin/agentPluginService.ts + 'agentPlugin.sessionStartRefreshPending': boolean; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; @@ -1198,8 +1182,6 @@ export interface AgentStateSnapshot { // src/agent/toolExecutor/toolExecutorService.ts 'toolExecutor.dupTypeTurnId': number | undefined; 'toolExecutor.toolCallDupTypes': Map; - // src/agent/toolSelect/toolSelectAnnouncementsService.ts - 'toolSelect.needsBoundaryInjection': boolean; // src/agent/toolSelect/toolSelectService.ts 'toolSelect.pendingLoaded': Set; // src/agent/usage/usageService.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index aaf5e4a41..1b3a0fed0 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,55 +21,56 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (48 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/features/plan/planOps.ts -// plan_mode.enter plan persisted src/features/plan/planOps.ts -// plan_mode.exit plan persisted src/features/plan/planOps.ts -// plan.revision plan persisted src/features/plan/planOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts +// Index (49 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/features/plan/planOps.ts +// plan_mode.enter plan persisted src/features/plan/planOps.ts +// plan_mode.exit plan persisted src/features/plan/planOps.ts +// plan.revision plan persisted src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot persisted src/agent/plugin/agentPluginOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.ended turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts /** * model: profile · persisted @@ -448,6 +449,15 @@ interface PlanRevisionPayload { bytes: number; } +/** + * model: pluginSessionStartSnapshot · persisted + * owner: src/agent/plugin/agentPluginOps.ts + */ +interface PluginSessionStartPayload { + _name: 'plugin.session_start'; + content: string | null; +} + /** * model: profile · persisted · cross-reducers: profile.activeTools * owner: src/agent/profile/profileOps.ts @@ -610,7 +620,7 @@ interface ToolsUpdateStorePayload { } /** - * model: turn · persisted · cross-reducers: interruptionReminder + * model: turn · persisted * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -746,6 +756,7 @@ interface WirePayloadMap { "plan_mode.enter": PlanModeEnterPayload; "plan_mode.exit": PlanModeExitPayload; "plan.revision": PlanRevisionPayload; + "plugin.session_start": PluginSessionStartPayload; "profile.bind": ProfileBindPayload; "skill.activate": SkillActivatePayload; "swarm_mode.enter": SwarmModeEnterPayload; diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 0076f8560..61a135f9b 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -2,62 +2,12 @@ * `agentsMdReminder` domain — `IAgentAgentsMdReminderService` * implementation. * - * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` - * that probes the directories a tool call touches for AGENTS.md files the - * system prompt did not inject, and prepends a once-per-agent - * `` to the result suggesting the model read them (head - * insertion on purpose: oversized results are truncated to a short head - * preview later in the execution pipeline, and a tail reminder would be - * silently dropped after the file was already counted as reminded). - * `Read`/`Edit`/`Write` consume the canonical file access declared by their - * resolved execution (a successful touch landing on an AGENTS.md itself marks - * just that file known), `Glob`/`Grep` consume their canonical search root, - * and `Bash` contributes its explicit `cwd` plus the literal directory - * operands extracted from the command's syntax tree (see `./bashTargets`), - * resolved against the frozen - * `sessionContext.cwd` exactly like the Bash tool itself (`args.cwd ?? - * sessionContext.cwd` — a base that deliberately differs from the live agent - * cwd after a chdir). Only calls whose `ToolDidExecuteContext.outcome` is - * `executed` are probed: preflight rejects, resolution failures, aborts, - * permission vetoes, and synthetic/duplicate results have not touched the - * requested resource and are left unchanged. The hook is ordered before - * `toolDedupe` so an executed original carries the reminder into the - * deferred result returned for a duplicate; no dedupe implementation state is - * needed here. The ordered registration throws when its target is absent, so - * scopes without `toolDedupe` fall back to plain append-order registration, - * which still lands ahead of a `toolDedupe` hook constructed later. - * - * Known-set discipline: candidates are claimed synchronously per discovered - * file into an in-memory `claimed` set (parallel calls can never duplicate a - * reminder and a failed attempt releases the claim), while `agentState` - * (`agentsMdReminder.known`) is only ever whole-value replaced after the - * reminder text is attached and the telemetry emitted — never mutated in - * place, and never ahead of the reminder it records. Probing anchors at the - * nearest existing ancestor (so `Write` into a not-yet-created directory - * still resolves), walks `findProjectRoot → touched dir`, skips chain - * directories whose candidates are all known, and applies the same - * per-directory candidate rules as the init-time load (shared through - * `profile/context`'s `findAgentsMdInDir`; blank files are included in - * neither). Directories with unknown candidates are re-statted on every - * qualifying call — deliberate, so an AGENTS.md created mid-session is - * picked up on the next touch; there is no negative cache. Probing is - * lexical like the tools' own path policy: a symlinked directory's AGENTS.md - * is discovered through the link at its lexical address, never by realpath. - * The hook never throws — a probe failure yields the untouched result. - * - * Seeding: `profile` reports the injected paths after every successful - * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can - * also commit without any of those entry points — session resume and forks - * restore the already-rendered system prompt (AGENTS.md content included) - * from the wire journal or a binding snapshot. The wire restore hook seeds - * the exact persisted paths (legacy prompts recover their source annotations), - * so the first qualifying call of a never-seeded agent does not confuse the - * current filesystem with the restored prompt. The seeded cwd lives in - * `agentState` as well; restored provenance comes from `wire`/`profile`; fs - * probes go through the os `IHostFileSystem`, the home directory through - * `IHostEnvironment`, the brand home through `bootstrap`, syntax - * trees through `bashParser`, and the shown-event - * through `telemetry`. Bound at Agent scope. + * Discovers AGENTS.md files reached through `toolExecutor` and the tool path + * policy, parsing Bash targets through `bashParser` and probing through the os + * services. Restores prompt provenance through `wire` and `profile`, resolves + * roots through `sessionContext` and `bootstrap`, stores discovery state in + * `agentState`, appends through `systemReminder`, and reports through + * `telemetry`. Bound at Agent scope. */ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; @@ -70,11 +20,9 @@ import { IBashParserService } from '#/app/bashParser/bashParser'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ContentPart } from '#/kosong/contract/message'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { ExecutableToolOutput, ExecutableToolResult } from '#/tool/toolContract'; import { normalizeUserPath } from '#/tool/path-access'; import { AGENTS_MD_PLAIN_NAMES, @@ -87,6 +35,7 @@ import { } from '#/agent/profile/context'; import { ProfileModel } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IWireService } from '#/wire/wire'; @@ -119,6 +68,7 @@ export class AgentAgentsMdReminderService constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentStateService private readonly states: IAgentStateService, @ISessionContext private readonly sessionContext: ISessionContext, @IHostFileSystem private readonly fs: IHostFileSystem, @@ -142,14 +92,10 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - ctx.result = await this.augmentWithReminder(ctx); + await this.probeAndRemind(ctx); await next(); }; - try { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler, { before: 'toolDedupe' })); - } catch { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); - } + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); } seedInjected(paths: readonly string[], cwd: string): void { @@ -180,8 +126,8 @@ export class AgentAgentsMdReminderService this.seedInjected(paths, this.agentCwd); } - private async augmentWithReminder(ctx: ToolDidExecuteContext): Promise { - if (ctx.outcome !== 'executed') return ctx.result; + private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { + if (ctx.outcome !== 'executed') return; const discovered: string[] = []; try { await this.ensureSeeded(); @@ -196,9 +142,8 @@ export class AgentAgentsMdReminderService } if (discovered.length === 0) { this.publishKnown(selfKnown); - return ctx.result; + return; } - const result = prependReminder(ctx.result, reminderText(discovered)); const properties: AgentsMdReminderShownEvent = { turn_id: ctx.turnId, tool_name: ctx.toolCall.name, @@ -206,11 +151,12 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); + this.reminders.appendSystemReminder(reminderText(discovered), { + kind: 'injection', + variant: 'agents_md', + }); this.publishKnown([...selfKnown, ...discovered]); - return result; - } catch { - return ctx.result; - } finally { + } catch {} finally { for (const path of discovered) this.claimed.delete(path); } } @@ -333,34 +279,12 @@ function stringArg(args: unknown, key: string): string | undefined { function reminderText(paths: readonly string[]): string { return ( - '\n' + - 'The path(s) touched by this call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + + 'The path(s) touched by a recent tool call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + paths.map((path) => `- ${path}`).join('\n') + - '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' + - '\n\n\n' + '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' ); } -function prependReminder(result: ExecutableToolResult, text: string): ExecutableToolResult { - const output = result.output; - let newOutput: ExecutableToolOutput; - if (typeof output === 'string') { - newOutput = text + output; - } else { - const parts: ContentPart[] = [...output]; - const first = parts[0]; - if (first !== undefined && first.type === 'text') { - parts[0] = { type: 'text', text: text + first.text }; - } else { - parts.unshift({ type: 'text', text }); - } - newOutput = parts; - } - return result.isError === true - ? { ...result, output: newOutput, isError: true } - : { ...result, output: newOutput }; -} - registerScopedService( LifecycleScope.Agent, IAgentAgentsMdReminderService, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index e0114977f..a7ed9d678 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -1,40 +1,50 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; import type { ContentPart } from "#/kosong/contract/message"; -import type { ContextInjectionDisclosure, ContextMessage } from '#/agent/contextMemory/types'; +import type { Tool } from "#/kosong/contract/tool"; +import type { ContextMessage } from '#/agent/contextMemory/types'; -export interface ContextInjectionContext { +export interface ContextInjectionContext { readonly injectedPositions: readonly number[]; readonly lastInjectedAt: number | null; readonly lastInjection?: ContextMessage; - readonly lastDisclosure?: ContextInjectionDisclosure; + readonly lastDisclosure?: D; readonly isNewTurn: boolean; } -export type ContextInjectionContent = string | readonly ContentPart[]; - -export interface ContextInjectionResult { - readonly content: ContextInjectionContent; - readonly disclosure?: ContextInjectionDisclosure; +export interface ContextInjectionMessage { + readonly role: 'user' | 'system'; + readonly content: readonly ContentPart[]; + readonly tools?: readonly Tool[]; } -export type ContextInjectionProvider = ( - context: ContextInjectionContext, +export type ContextInjectionContent = + | string + | readonly ContentPart[] + | { readonly message: ContextInjectionMessage }; + +export interface ContextInjectionResult { + readonly content: ContextInjectionContent; + readonly disclosure?: D; +} + +export type ContextInjectionProvider = ( + context: ContextInjectionContext, ) => | ContextInjectionContent - | ContextInjectionResult + | ContextInjectionResult | undefined - | Promise; + | Promise | undefined>; export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; - register( + register( name: string, - provider: ContextInjectionProvider, + provider: ContextInjectionProvider, ): IDisposable; - injectAfterCompaction(): Promise; + reconcileWhenIdle(name: string): Promise; } export const IAgentContextInjectorService = createDecorator( diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 0bb8cc085..2419db729 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,103 +1,73 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * Injects registered context providers through `loop` and `systemReminder`, - * tracks their positions in `contextMemory` through `eventBus`, and reconciles - * those positions after `wire` restoration. Each provider call receives the - * newest surviving injection of its own variant (`lastInjection`) and the - * typed disclosure recorded on it (`lastDisclosure`), so providers never read - * context layout or position indexes themselves. The plain-data `isNewTurn` - * flag is registered into `agentState` (`IAgentStateService`) and read/written - * through it; `entries` stays a plain instance field (its values hold provider - * functions, not plain data). Bound at Agent scope. + * Reconciles registered model-context providers against `contextMemory` at the + * head of every loop step (before the step's request is built), so every LLM + * request sees the freshest injections. A compaction splice re-arms the + * new-turn flag for the next step. `reconcileWhenIdle` lets out-of-loop + * callers (SDK RPC surfaces) refresh one provider immediately while the loop + * is quiet. Writes reminders through `systemReminder` and reports provider + * failures through `log`. Bound at Agent scope. */ -import { toDisposable } from "#/_base/di/lifecycle"; +import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { Service } from "#/_base/di/service"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; +import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; +import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; +import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionContent, + type ContextInjectionContext, + type ContextInjectionMessage, type ContextInjectionProvider, type ContextInjectionResult, } from './contextInjector'; interface ContextInjectionEntry { - readonly provider: ContextInjectionProvider; + readonly provider: ContextInjectionProvider; readonly name: string; - readonly positions: number[]; } -export const contextInjectorIsNewTurnKey = defineState( - 'contextInjector.isNewTurn', - () => true, -); - export class AgentContextInjectorService extends Service implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); + private compactionRearmPending = false; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentLoopService loopService: IAgentLoopService, + @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, - @IWireService wire: IWireService, - @IAgentStateService private readonly states: IAgentStateService, + @ILogService private readonly log: ILogService, ) { super(); - this.states.register(contextInjectorIsNewTurnKey); this._register( - loopService.hooks.onWillBeginStep.register('context-injector', async (_ctx, next) => { - await next(); - await this.inject(); - }), + loopService.hooks.onWillBeginStep.register('context-injector', (ctx, next) => + this.reconcileAroundStep(ctx, next), + ), ); this._register( - this.eventBus.subscribe('turn.started', () => { - this.isNewTurn = true; - }), - ); - this._register( - this.eventBus.subscribe('context.spliced', (e) => { - this.handleSplice(e); - }), - ); - this._register( - wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - this.resyncPositions(); - await next(); + this.eventBus.subscribe('context.spliced', (splice) => { + if (isCompactionSplice(splice)) this.compactionRearmPending = true; }), ); } - private get isNewTurn(): boolean { - return this.states.get(contextInjectorIsNewTurnKey); - } - - private set isNewTurn(value: boolean) { - this.states.set(contextInjectorIsNewTurnKey, value); - } - - register( + register( name: string, - provider: ContextInjectionProvider, - ) { - const positions = findInjections(this.context.get(), name); + provider: ContextInjectionProvider, + ): IDisposable { const entry: ContextInjectionEntry = { - provider, + provider: provider as ContextInjectionProvider, name, - positions, }; this.entries.add(entry); return toDisposable(() => { @@ -105,101 +75,148 @@ export class AgentContextInjectorService extends Service implements IAgentContex }); } - async injectAfterCompaction(): Promise { - this.isNewTurn = true; - await this.inject(); + async reconcileWhenIdle(name: string): Promise { + const quiescence = this.loopService.tryAcquireQuiescence(); + if (quiescence === undefined) return; + try { + for (const entry of this.entries) { + if (entry.name !== name) continue; + await this.injectEntry(entry, false); + } + } finally { + quiescence.dispose(); + } } - private async inject(): Promise { - const isNewTurn = this.isNewTurn; - this.isNewTurn = false; - const history = this.context.get(); + private async reconcileAroundStep( + ctx: BeforeStepContext, + next: (context?: BeforeStepContext) => Promise, + ): Promise { + const rearmed = this.takeCompactionRearm(); + await this.inject(ctx.firstStepOfTurn || rearmed); + await next(); + // Compaction can run inside a later handler of this same chain + // (full-compaction's beforeStep). Its splice always drops injection + // messages, so re-reconcile here — still before the step's request. + if (this.takeCompactionRearm()) { + await this.inject(true); + } + } + + /** Reads and clears the flag set when a compaction splice arrives. */ + private takeCompactionRearm(): boolean { + const pending = this.compactionRearmPending; + this.compactionRearmPending = false; + return pending; + } + + private async inject(isNewTurn: boolean): Promise { for (const entry of this.entries) { - const injectedPositions: readonly number[] = [...entry.positions]; - const lastInjectedAt = injectedPositions.at(-1) ?? null; - const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; - const content = await entry.provider({ - injectedPositions, - lastInjectedAt, - lastInjection, - lastDisclosure: - lastInjection?.origin?.kind === 'injection' - ? lastInjection.origin.disclosure - : undefined, - isNewTurn, - }); - if (!this.entries.has(entry)) continue; - if (content === undefined) continue; - const result: ContextInjectionResult = - typeof content === 'object' && content !== null && !Array.isArray(content) - ? (content as ContextInjectionResult) - : { content: content as ContextInjectionContent }; - const origin = { - kind: 'injection' as const, - variant: entry.name, - disclosure: result.disclosure, - }; - if (typeof result.content === 'string') { - if (result.content.trim().length === 0) continue; - this.reminders.appendSystemReminder(result.content, origin); - continue; + await this.injectEntry(entry, isNewTurn); + } + } + + private async injectEntry(entry: ContextInjectionEntry, isNewTurn: boolean): Promise { + let content: Awaited>; + try { + content = await entry.provider(this.providerContext(entry, isNewTurn)); + } catch (error) { + this.log.error('context provider failed; skipping it', { name: entry.name, error }); + return; + } + if (!this.entries.has(entry)) return; + this.appendResult(entry, content); + } + + private providerContext( + entry: ContextInjectionEntry, + isNewTurn: boolean, + ): ContextInjectionContext { + const history = this.context.get(); + const injectedPositions = findInjections(history, entry.name); + const lastInjectedAt = injectedPositions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; + return { + injectedPositions, + lastInjectedAt, + lastInjection, + lastDisclosure: + lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn, + }; + } + + private appendResult( + entry: ContextInjectionEntry, + content: ContextInjectionContent | ContextInjectionResult | undefined, + ): void { + if (content === undefined) return; + const result: ContextInjectionResult = isInjectionResult(content) + ? content + : { content }; + const origin = { + kind: 'injection' as const, + variant: entry.name, + disclosure: result.disclosure, + }; + const resolved = result.content; + if (typeof resolved === 'string') { + if (resolved.trim().length === 0) return; + this.reminders.appendSystemReminder(resolved, origin); + return; + } + if (isRawInjectionMessage(resolved)) { + const message = resolved.message; + if ( + message.content.length === 0 && + (message.tools === undefined || message.tools.length === 0) + ) { + return; } - if (result.content.length === 0) continue; this.context.append({ - role: 'user', - content: [...result.content], + role: message.role, + content: [...message.content], toolCalls: [], + tools: message.tools, origin, }); + return; } - } - - private resyncPositions(): void { - const history = this.context.get(); - for (const entry of this.entries) { - const found = findInjections(history, entry.name); - entry.positions.length = 0; - entry.positions.push(...found); - } - } - - private handleSplice(splice: ContextSplice): void { - let insertedInjections: Map | undefined; - splice.messages.forEach((message, offset) => { - if (message.origin?.kind !== 'injection') return; - insertedInjections ??= new Map(); - const positions = insertedInjections.get(message.origin.variant); - if (positions === undefined) { - insertedInjections.set(message.origin.variant, [splice.start + offset]); - } else { - positions.push(splice.start + offset); - } + if (resolved.length === 0) return; + this.context.append({ + role: 'user', + content: [...resolved], + toolCalls: [], + origin, }); - if (insertedInjections === undefined && splice.deleteCount === 0) return; - - const deletedEnd = splice.start + splice.deleteCount; - const delta = splice.messages.length - splice.deleteCount; - for (const entry of this.entries) { - const adopted = insertedInjections?.get(entry.name) ?? []; - const positions = entry.positions; - if (adopted.length === 0 && positions.length === 0) continue; - let lo = 0; - while (lo < positions.length && positions[lo]! < splice.start) lo++; - let hi = lo; - while (hi < positions.length && positions[hi]! < deletedEnd) hi++; - for (let index = hi; index < positions.length; index++) { - positions[index] = positions[index]! + delta; - } - positions.splice(lo, hi - lo, ...adopted); - } } } -type ContextSplice = { - readonly start: number; +function isCompactionSplice(splice: { readonly deleteCount: number; readonly messages: readonly ContextMessage[]; -}; +}): boolean { + return splice.deleteCount > 0 && splice.messages.some(isCompactionSummaryMessage); +} + +function isRawInjectionMessage( + content: Exclude, +): content is { readonly message: ContextInjectionMessage } { + return !Array.isArray(content); +} + +function isInjectionResult( + content: ContextInjectionContent | ContextInjectionResult, +): content is ContextInjectionResult { + return ( + typeof content === 'object' && + content !== null && + !Array.isArray(content) && + 'content' in content + ); +} function findInjections( history: readonly ContextMessage[], diff --git a/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts b/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts index ceff302ca..5932a1eda 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts @@ -1,5 +1,5 @@ /** - * `contextInjector` domain (L4) — disclosure-baseline helpers for reminder + * `contextInjector` domain (L4) — disclosure-baseline helper for reminder * providers (currently `date_change`). * * A provider's baseline answers "what has the model already seen" from up to @@ -11,17 +11,6 @@ * part of the barrel export. */ -import type { ContextInjectionDisclosure } from '#/agent/contextMemory/types'; - -export function disclosureOfKind( - disclosure: ContextInjectionDisclosure | undefined, - kind: K, -): Extract | undefined { - return disclosure?.kind === kind - ? (disclosure as Extract) - : undefined; -} - export function pickDisclosureBaseline( ...candidates: readonly (T | undefined)[] ): T | undefined { diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 63a8af0e4..913fb6ba8 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -13,6 +13,7 @@ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; import type { ContentPart } from '#/kosong/contract/message'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; import type { ContextMessage, PromptOrigin } from './types'; @@ -162,11 +163,9 @@ export function createCompactionElisionMessage(omittedTokens: number): ContextMe } export function buildCompactionElisionText(omittedTokens: number): string { - return [ - '', + return wrapSystemReminder( `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, - '', - ].join('\n'); + ); } export function collectCompactableUserMessages(messages: readonly T[]): T[] { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 89b5be4f6..46950e989 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -44,6 +44,8 @@ export interface IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void; + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean; + clear(): void; undo(count: number): UndoCut; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index fb5e00e7e..d095be615 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -3,11 +3,11 @@ * * Owns per-agent conversation history through `wire`, maintains measurements * with `tokenCounting`, and broadcasts live mutations through `event`. Every - * splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes - * `context.spliced` from the live path only — replay rebuilds silently — and - * `undo` additionally truncates the measured-anchor ledger when the cut - * crosses an anchor, letting `tokenCounting` restore the surviving prefix's - * REAL size from the remaining anchors. Bound at Agent scope. + * splice-shaped mutation (`clear` / `applyCompaction` / `undo`, plus verified + * cross-model trailing removal) publishes `context.spliced` from the live path + * only — replay rebuilds silently — and truncates the measured-anchor ledger + * when a cut crosses an anchor, letting `tokenCounting` restore the surviving + * prefix's REAL size from the remaining anchors. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -89,6 +89,21 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.wire.dispatch(contextAppendLoopEvent({ event })); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + const cutIndex = previous.length - 1; + if (cutIndex < 0) return false; + const current = this.get(); + if ( + current.length !== cutIndex || + current.some((message, index) => message !== previous[index]) + ) { + return false; + } + this.wire.dispatch(...this.sizeOpsForCut(cutIndex)); + this.publishSplice({ start: cutIndex, deleteCount: 1, messages: [] }); + return true; + } + clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 7c9610cdb..a6ebad053 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -17,10 +17,9 @@ * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds * them into assistant / tool messages both at live dispatch time and on * replay, so v1- and v2-written sessions reduce - * identically. The swarm-mode exit reminder removal is a cross-model fold: - * `ContextModel` registers a reducer on `swarm_mode.exit` (see - * `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record - * itself. + * identically. Swarm-mode announcements are owned by the `swarm` domain's + * context-injection provider; `swarm_mode.exit` additionally pops a trailing + * enter reminder through a replayable cross-model reducer. * * `context.undo` counts conversation ticks with the single `isUndoAnchor` * predicate — the same definition the checkpoint @@ -124,11 +123,9 @@ export const ContextModel = defineModel('contextMemory', () => }, }); -function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): ContextMessage[] { - const last = state[state.length - 1]; - if (last === undefined) return state; - const origin = last.origin; - if (origin?.kind !== 'injection' || origin.variant !== 'swarm_mode') return state; +function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { + const last = state.at(-1); + if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') return state; return resetFold(state.slice(0, -1)) as ContextMessage[]; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index 5cc735999..94060145a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -10,6 +10,7 @@ */ import { defineModel, type ModelDef } from '#/wire/model'; +import type { ModelReducers } from '#/wire/types'; import type { ContextMessage } from './types'; @@ -48,6 +49,7 @@ export const CHECKPOINTED_MODELS: ModelDef>[] = []; export interface CheckpointModelOptions { readonly onAppendMessage?: (current: T, message: ContextMessage) => T; + readonly reducers?: ModelReducers>; } export function defineCheckpointedModel( @@ -55,11 +57,13 @@ export function defineCheckpointedModel( initial: () => T, opts?: CheckpointModelOptions, ): ModelDef> { + const customReducers = opts?.reducers ?? {}; const def = defineModel>( name, () => ({ current: initial(), checkpoints: [] }), { reducers: { + ...customReducers, 'context.append_message': (state, { message }) => { if (isUndoAnchor(message)) { return { ...state, checkpoints: [...state.checkpoints, state.current] }; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 5b8c59cdb..b21fe3c7a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -34,16 +34,9 @@ export interface InjectionOrigin { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: ContextInjectionDisclosure; + readonly disclosure?: unknown; } -export type ContextInjectionDisclosure = { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; -}; - export interface ShellCommandOrigin { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts index cccb3396e..d6c19dc53 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts @@ -1,13 +1,19 @@ /** * `dateChange` domain (L4) — `IAgentDateChangeService` contract. * - * Defines the Agent-scope marker service that announces calendar-date changes - * through a `date_change` context-injection reminder when a session outlives - * the date rendered into its system prompt. + * Defines the Agent-scope marker service and typed disclosure for model-facing + * calendar-date reminders. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +export interface DateInjectionDisclosure { + readonly kind: 'date'; + readonly renderGeneration: number; + readonly localDate: string; + readonly timeZone: string; +} + export interface IAgentDateChangeService { readonly _serviceBrand: undefined; } diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts index dda955440..ac5e9a2ce 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts @@ -24,16 +24,13 @@ import { type ContextInjectionContext, type ContextInjectionResult, } from '#/agent/contextInjector/contextInjector'; -import { - disclosureOfKind, - pickDisclosureBaseline, -} from '#/agent/contextInjector/disclosureBaseline'; +import { pickDisclosureBaseline } from '#/agent/contextInjector/disclosureBaseline'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentDateChangeService } from './dateChange'; +import { type DateInjectionDisclosure, IAgentDateChangeService } from './dateChange'; const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; @@ -46,7 +43,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan declare readonly _serviceBrand: undefined; constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentStateService private readonly states: IAgentStateService, @IHostClock private readonly clock: IHostClock, @@ -55,13 +52,16 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan super(); this.states.register(dateChangeSeedKey); this._register( - dynamicInjector.register(DATE_CHANGE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + injector.register( + DATE_CHANGE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), ); } private reminder({ lastDisclosure, - }: ContextInjectionContext): ContextInjectionResult | undefined { + }: ContextInjectionContext): ContextInjectionResult | undefined { const profileData = this.profile.data(); const environment = profileData.environmentDisclosure; if ( @@ -74,7 +74,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan const renderGeneration = profileData.renderGeneration ?? 0; const current = currentDateDisclosure(this.clock); const baseline = pickDisclosureBaseline( - disclosureOfKind(lastDisclosure, 'date'), + lastDisclosure, this.dateFromProfile(), this.states.get(dateChangeSeedKey), ); diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 27cd51d08..7994b1bb6 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -10,15 +10,16 @@ * `consecutiveOverflowCompactions`, `activeTurnId`) is registered into * `agentState` (`IAgentStateService`) and read/written through it; * `_compacting` (the in-flight job — AbortController / Promise / trace), the - * `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, the - * `strategy`, and the lazily-resolved `contextInjectorService` stay instance - * fields (mechanism, not plain data). Bound at Agent scope and constructed with + * `hooks.onWillCompact` slot, the `_onDidFinishCompaction` Emitter, and the + * `strategy` stay instance fields (mechanism, not plain data). The compaction + * splice re-arms `contextInjector`'s new-turn flag, so providers re-reconcile + * at the next step head. Bound at Agent scope and constructed with * the scope so the overflow recovery handler registers before the first turn * runs. */ +import type { IDisposable } from '#/_base/di/lifecycle'; import { Service } from "#/_base/di/service"; -import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; @@ -26,7 +27,6 @@ import { defineState } from '#/_base/state/stateRegistry'; import { renderPrompt } from "#/_base/utils/render-prompt"; import { estimateTokensForMessage } from "#/kosong/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; @@ -97,6 +97,7 @@ type CompactionTelemetryProperties = Pick< interface ActiveCompaction extends FullCompactionTask { readonly originTurnId?: number; + readonly quiescence?: IDisposable; trace?: LLMRequestTrace; blockedByTurn: boolean; } @@ -145,7 +146,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private readonly strategy: CompactionStrategy; private _compacting: ActiveCompaction | null = null; - private contextInjectorService: IAgentContextInjectorService | undefined; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -154,7 +154,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, - @IInstantiationService private readonly instantiation: IInstantiationService, @ISessionTodoService private readonly todo: ISessionTodoService, @ITelemetryService private readonly telemetry: ITelemetryService, @IWireService private readonly wire: IWireService, @@ -329,22 +328,37 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (!this.reserveCompactionSlot(data.source)) return false; const tokenCount = this.validateCompactionStart(data.source); - this.wire.dispatch(fullCompactionBegin(data)); + const quiescence = data.source === 'manual' + ? this.loopService.tryAcquireQuiescence() + : undefined; + if (data.source === 'manual' && quiescence === undefined) { + throw new Error2( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active or another context change is running. Wait for it to finish, then retry.', + ); + } + try { + this.wire.dispatch(fullCompactionBegin(data)); - const active = this.createActiveCompaction( - data.source, - tokenCount, - data.source === 'auto' ? this.activeTurnId : undefined, - ); - 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; + const active = this.createActiveCompaction( + data.source, + tokenCount, + data.source === 'auto' ? this.activeTurnId : undefined, + quiescence, + ); + 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; + } catch (error) { + quiescence?.dispose(); + throw error; + } } private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { @@ -374,6 +388,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger: CompactionBeginData['source'], tokenCount: number, originTurnId: number | undefined, + quiescence: IDisposable | undefined, ): { readonly task: ActiveCompaction; readonly resolve: (result: CompactionResult) => void; @@ -393,6 +408,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger, tokenCount, originTurnId, + quiescence, get traceId() { return this.trace?.traceId; }, @@ -558,8 +574,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom this.log.error('failed to refresh system prompt after compaction', { error }); } this.lastCompactedTokenCount = result.tokensAfter; - await this.contextInjector.injectAfterCompaction(); - this.lastCompactedTokenCount = this.tokenCountWithPending(); if (!this.markCompleted(active)) { throw compactionCancelledReason(active); } @@ -585,7 +599,11 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom }); throw error; } finally { - this._onDidFinishCompaction.fire(active); + try { + this._onDidFinishCompaction.fire(active); + } finally { + active.quiescence?.dispose(); + } } } @@ -779,15 +797,6 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private tokenCountWithPending(): number { return this.tokenCounting.get().size; } - - private get contextInjector(): IAgentContextInjectorService { - if (this.contextInjectorService === undefined) { - this.contextInjectorService = this.instantiation.invokeFunction((accessor) => - accessor.get(IAgentContextInjectorService), - ); - } - return this.contextInjectorService; - } } function findAPIStatusError(error: unknown): APIStatusError | undefined { diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index ae56369e3..98911f53d 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -16,7 +16,7 @@ * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live * turn usage through `usage`, observes terminal goal tool results through - * `toolExecutor`, writes system reminders through `systemReminder`, reports + * `toolExecutor`, appends one-time reminder events through `systemReminder`, reports * telemetry through `telemetry`, and checks main-agent eligibility through * `scopeContext`. Measures time and arms hard deadlines through `goal`'s * App-scoped deadline scheduler. Two `onBeforeExecuteTool` veto listeners @@ -59,9 +59,9 @@ import { import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { LoopErrors } from '#/agent/loop/errors'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -218,10 +218,9 @@ const GoalForkNoticeModel = defineModel( ); function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean { - return ( - message?.origin?.kind === 'system_trigger' && - message.origin.name === GOAL_FORK_CLEARED_REMINDER_NAME - ); + const origin = message?.origin; + if (origin?.kind === 'injection') return origin.variant === GOAL_FORK_CLEARED_REMINDER_NAME; + return origin?.kind === 'system_trigger' && origin.name === GOAL_FORK_CLEARED_REMINDER_NAME; } function isGoalContinuationOrigin(origin: TurnStartedEvent['origin']): boolean { @@ -289,7 +288,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IEventBus private readonly eventBus: IEventBus, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @@ -319,7 +318,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { { getGoal: () => this.getGoal().goal, }, - dynamicInjector, + injector, ), ); this._register( @@ -629,8 +628,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.clearInternal(actor); if (actor === 'user') { this.reminders.appendSystemReminder(GOAL_CANCELLED_REMINDER, { - kind: 'system_trigger', - name: 'goal_cancelled', + kind: 'injection', + variant: 'goal_cancelled', }); } return snapshot; @@ -807,8 +806,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { ) { this.budgetGraceTurns.add(ctx.turnId); this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { - kind: 'system_trigger', - name: GOAL_BUDGET_STOP_REMINDER_NAME, + kind: 'injection', + variant: GOAL_BUDGET_STOP_REMINDER_NAME, }); return true; } @@ -1021,8 +1020,8 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private appendForkClearedReminder(): void { if (!this.wire.getModel(GoalForkNoticeModel).reminderPending) return; this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { - kind: 'system_trigger', - name: GOAL_FORK_CLEARED_REMINDER_NAME, + kind: 'injection', + variant: GOAL_FORK_CLEARED_REMINDER_NAME, }); } diff --git a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts index bc39fd260..6b6d979e3 100644 --- a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts @@ -13,11 +13,11 @@ export interface GoalInjectionOptions { export class GoalInjection extends Service { constructor( private readonly options: GoalInjectionOptions, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); this._register( - dynamicInjector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), + injector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), ); } diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index 0c7a39e13..ae12da6f0 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -1,31 +1,23 @@ /** - * `interruptionReminder` domain (L4) — persists and restores pending - * user-interruption reminders. + * `interruptionReminder` domain — legacy wire compatibility tombstone. * - * Projects the `loop` domain's `turn.cancel` fact into the set of turns whose - * interruption reminder still has to reach the conversation, and owns the op - * that records a reminder's delivery. Consumed by the Agent-scope - * `interruptionReminderService`. + * Retains the historical `interruptionReminder.recorded` Op as a no-op so old + * Agent journals replay without unknown-record diagnostics. New interruption + * reminders append at the cancellation event point and write no domain-owned + * delivery state. Scope-agnostic. */ import { z } from 'zod'; import { defineModel } from '#/wire/model'; -export const InterruptionReminderModel = defineModel( +export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; + +export type InterruptionReminderState = null; + +export const InterruptionReminderModel = defineModel( 'interruptionReminder', - () => [], - { - reducers: { - 'turn.cancel': (state, { turnId, target, reason }) => { - if (target !== 'active' || reason !== 'user_cancelled' || turnId === undefined) { - return state; - } - if (state.includes(turnId)) return state; - return [...state, turnId].toSorted((a, b) => a - b); - }, - }, - }, + () => null, ); declare module '#/wire/types' { @@ -38,6 +30,6 @@ export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( 'interruptionReminder.recorded', { schema: z.object({ turnId: z.number().int().nonnegative() }), - apply: (state, { turnId }) => state.filter((pendingTurnId) => pendingTurnId !== turnId), + apply: (state) => state, }, ); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index e3dadecb0..4fa1f48aa 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -1,13 +1,12 @@ /** - * `interruptionReminder` domain (L4) — `IAgentInterruptionReminderService` implementation. + * `interruptionReminder` domain — `IAgentInterruptionReminderService` implementation. * - * Observes turn completion through `event`, persists reminder completion through - * its own wire model, reads conversation history through `contextMemory`, and - * appends model-visible notices through `systemReminder`. Reconciles reminders - * left pending by an interrupted restore. Bound at Agent scope. + * Observes completed turns through `eventBus`, appends user-cancellation facts + * through `systemReminder` at the event point, and reads `contextMemory` to + * collapse retry-only duplicate notices. Bound at Agent scope. */ -import { Service } from '#/_base/di/service'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -15,12 +14,9 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; -import { IWireService } from '#/wire/wire'; import { IAgentInterruptionReminderService } from './interruptionReminder'; -import { interruptionReminderRecorded, InterruptionReminderModel } from './interruptionReminderOps'; - -export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; +import { INTERRUPTION_REMINDER_VARIANT } from './interruptionReminderOps'; const INTERRUPTION_REMINDER = [ 'The previous turn was interrupted by the user before completion;', @@ -29,7 +25,7 @@ const INTERRUPTION_REMINDER = [ ].join(' '); export class AgentInterruptionReminderService - extends Service + extends Disposable implements IAgentInterruptionReminderService { declare readonly _serviceBrand: undefined; @@ -38,55 +34,25 @@ export class AgentInterruptionReminderService @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IWireService private readonly wire: IWireService, ) { super(); - this._register( - this.wire.hooks.onDidRestore.register('interruption-reminder', async (_ctx, next) => { - this.reconcilePendingReminders(); - await next(); - }), - ); this._register( eventBus.subscribe('turn.ended', (event) => { if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; - this.recordReminder(event.turnId, true); + const origin = lastComparableMessage(this.context.get())?.origin; + if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; + this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { + kind: 'injection', + variant: INTERRUPTION_REMINDER_VARIANT, + }); }), ); } - - private reconcilePendingReminders(): void { - const pending = this.wire.getModel(InterruptionReminderModel); - for (const turnId of pending) this.recordReminder(turnId); - } - - private recordReminder(turnId: number, allowUntracked = false): void { - const pending = this.wire.getModel(InterruptionReminderModel).includes(turnId); - if (!pending && !allowUntracked) return; - if (!this.appendInterruptionReminder()) return; - if (pending) this.wire.dispatch(interruptionReminderRecorded({ turnId })); - } - - private appendInterruptionReminder(): boolean { - const before = this.context.get(); - const origin = lastDurableMessageOrigin(before); - if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return true; - this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { - kind: 'injection', - variant: INTERRUPTION_REMINDER_VARIANT, - }); - const after = this.context.get(); - if (after === before) return false; - const appended = lastDurableMessageOrigin(after); - return appended?.kind === 'injection' && appended.variant === INTERRUPTION_REMINDER_VARIANT; - } } -function lastDurableMessageOrigin( - messages: readonly ContextMessage[], -): ContextMessage['origin'] | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; +function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; if ( message.role === 'assistant' && message.partial === true && @@ -95,7 +61,7 @@ function lastDurableMessageOrigin( ) { continue; } - return message.origin; + return message; } return undefined; } diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d13c066c6..38853feb4 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -32,6 +32,7 @@ export function isMaxStepsExceededError(error: unknown): boolean { export interface BeforeStepContext { readonly turnId: number; readonly step: number; + readonly firstStepOfTurn: boolean; readonly signal: AbortSignal; } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index a6940a27b..9e57491c0 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -252,7 +252,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); - if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; + if ( + this.quiescenceDepth > 0 || + this.activeTurnJob !== undefined || + this.hasPendingRequests() + ) { + return undefined; + } this.quiescenceDepth += 1; return toDisposable(() => this.releaseQuiescence()); } @@ -620,6 +626,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { begun.step.signal, runtime.turnSignal, begun.step.number, + runtime.job !== undefined && begun.step.number === 1, begun.step.uuid, options.onStarted, ); @@ -804,11 +811,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { signal: AbortSignal, turnSignal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, stepUuid: string, onStarted: ((step: number) => void) | undefined, ): Promise { this.activeRequestTrace = undefined; - await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, signal }); + await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, firstStepOfTurn, signal }); const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); const streamParts = this.createStreamPartHandler(turnId, markStepStarted); const request = this.llmRequester.start( @@ -839,6 +847,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId, signal, currentStep, + firstStepOfTurn, response.usage, finishReason, ); @@ -996,12 +1005,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId: number, signal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, usage: TokenUsage, finishReason: FinishReason, ): Promise { const context: AfterStepContext = { turnId, step: currentStep, + firstStepOfTurn, signal, usage, finishReason, diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 9901b077e..7c033c03b 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -6,8 +6,7 @@ * legacy loop-event observations. Also persists the terminal `turn.ended` * record (reason / error / durationMs) so downstream history rebuilds and * cold-resumed read models (e.g. the activity view) can recover how the last - * turn ended. Consumed by the Agent-scope `loopService`; the - * `interruptionReminder` domain projects `turn.cancel` into its own model. + * turn ended. Consumed by the Agent-scope `loopService`. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts index ab415d49a..d5328d76b 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -3,10 +3,10 @@ * * Owns the `permission_mode` context-injection provider. It reads the live mode * from `IAgentPermissionModeService` and registers reminders through - * `contextInjector`. Dedup is history-derived: the framework mirrors this - * variant's live positions across splices, so a reminder folded away by - * compaction (or undo) is re-announced on the next inject, matching v1's - * compaction behavior. The plain-data state (`lastMode`) is registered into + * `contextInjector`. Dedup is history-derived: the framework derives this + * variant's live positions from the surviving history, so a reminder folded + * away by compaction (or undo) is re-announced on the next inject, matching + * v1's compaction behavior. The plain-data state (`lastMode`) is registered into * `agentState` (`IAgentStateService`) and read/written through it. */ @@ -32,13 +32,13 @@ export const permissionModeLastModeKey = defineState export class PermissionModeInjection extends Service { constructor( private readonly permissionMode: Pick, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); this.states.register(permissionModeLastModeKey); this._register( - dynamicInjector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + injector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), ); } diff --git a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts index 512f1a565..a1ab9517f 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts @@ -9,6 +9,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface IAgentPluginService { readonly _serviceBrand: undefined; + + refreshSessionStart(): Promise; } export const IAgentPluginService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts new file mode 100644 index 000000000..64a15dfbf --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -0,0 +1,38 @@ +/** + * `agentPlugin` domain — durable session-start guidance snapshot. + * + * Owns the Agent wire Model that freezes the main agent's rendered plugin + * session-start guidance until an explicit reload replaces it. Bound at Agent + * scope through `wire`. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export interface PluginSessionStartSnapshotState { + readonly initialized: boolean; + readonly content?: string; +} + +export const PluginSessionStartSnapshotModel = defineModel( + 'pluginSessionStartSnapshot', + () => ({ initialized: false }), +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'plugin.session_start': typeof pluginSessionStartSnapshotSet; + } +} + +export const pluginSessionStartSnapshotSet = PluginSessionStartSnapshotModel.defineOp( + 'plugin.session_start', + { + schema: z.object({ content: z.string().nullable() }), + apply: (_state, { content }) => ({ + initialized: true, + content: content ?? undefined, + }), + }, +); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index bfc29c438..9d19e896c 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -1,40 +1,59 @@ /** * `agentPlugin` domain — `IAgentPluginService` implementation. * - * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects - * them through `contextInjector` and `systemReminder`, and uses `contextMemory` - * to neutralize stale guidance. The session-start refresh on plugin-source - * catalog changes fires only for an explicit plugin reload: a mutation-driven - * reload (install / enable / disable / remove) skips it — the live session - * keeps the guidance it started with — and instead appends a `plugin_change` + * Renders session-start skills from `plugin` and `sessionSkillCatalog` through + * `contextInjector`, reconciling the desired instructions against the latest + * surviving render reported by the injector (`lastInjection`) and unwrapped + * through `systemReminder`. The rendered guidance is frozen through a durable + * `wire` snapshot until an explicit reload. The session-start refresh on + * plugin-source catalog changes fires only for an explicit plugin reload: a + * mutation-driven reload (install / enable / disable / remove) skips it — the + * live session keeps the guidance it started with — and instead appends a `plugin_change` * system reminder through `systemReminder` (`plugin` `onDidMutate` — never on * an explicit reload, whose resumed session would otherwise inherit a stale * notice), naming the mutated plugin and telling the model the live session * keeps its original prompt and tool set until `/new` or `/reload`. * Main-agent-only (v1 parity): the service * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for - * every agent, so other agents construct it as a no-op. Resolves - * session prompt context through `sessionContext` and reports missing skills - * through `log`. Bound at Agent scope. + * every agent, so other agents construct it as a no-op. Resolves session + * prompt context through `sessionContext` and reports missing skills through + * `log` (once per plugin:skill key — the provider re-renders on every + * boundary, so an unguarded warn would repeat every step); stores the + * refresh signal through `agentState`, consumed only after a successful + * render so a failed render retries at the next boundary. Bound at Agent + * scope. */ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { + IAgentSystemReminderService, + systemReminderContent, +} from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart, PluginMutation } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IWireService } from '#/wire/wire'; import { IAgentPluginService } from './agentPlugin'; +import { + PluginSessionStartSnapshotModel, + pluginSessionStartSnapshotSet, +} from './agentPluginOps'; const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; @@ -58,8 +77,20 @@ function renderPluginChangeReminder(mutation: PluginMutation): string { const MAIN_AGENT_ID = 'main'; +const SUPERSEDES_SUFFIX = + 'This supersedes any earlier plugin_session_start reminder in this session.'; + +const NO_ACTIVE_SESSION_STARTS = + `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; + +export const pluginSessionStartRefreshPendingKey = defineState( + 'agentPlugin.sessionStartRefreshPending', + () => false, +); + export class AgentPluginService extends Service implements IAgentPluginService { declare readonly _serviceBrand: undefined; + private readonly warnedMissingSessionStartSkills = new Set(); // Count of mutation-driven plugin reloads whose catalog change has not // reached this agent yet. `reloadAndNotify` fires `onDidMutate` @@ -69,24 +100,23 @@ export class AgentPluginService extends Service implements IAgentPluginService { private pendingMutationCatalogChanges = 0; constructor( - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentContextInjectorService private readonly injector: IAgentContextInjectorService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, + @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, ) { super(); if (scopeContext.agentId !== MAIN_AGENT_ID) return; + this.states.register(pluginSessionStartRefreshPendingKey); this._register( - injector.register( - SESSION_START_INJECTION_VARIANT, - async ({ injectedPositions }) => { - if (injectedPositions.length > 0) return undefined; - return this.renderSessionStartReminder(); - }, + injector.register(SESSION_START_INJECTION_VARIANT, (injection) => + this.reconcileSessionStartReminder(injection), ), ); this._register( @@ -101,7 +131,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { this.pendingMutationCatalogChanges--; return; } - void this.appendFreshSessionStartReminder(); + this.refreshPending = true; }), ); this._register( @@ -115,6 +145,21 @@ export class AgentPluginService extends Service implements IAgentPluginService { ); } + private get refreshPending(): boolean { + return this.states.get(pluginSessionStartRefreshPendingKey); + } + + private set refreshPending(value: boolean) { + this.states.set(pluginSessionStartRefreshPendingKey, value); + } + + async refreshSessionStart(): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + this.refreshPending = true; + await this.skillCatalog.ready; + await this.injector.reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); + } + private async renderSessionStartReminder(): Promise { const sessionStarts = await this.plugins.enabledSessionStarts(); if (sessionStarts.length === 0) return undefined; @@ -124,24 +169,68 @@ export class AgentPluginService extends Service implements IAgentPluginService { catalog: this.skillCatalog.catalog, log: this.log, sessionId: this.sessionContext.sessionId, + warnedSkills: this.warnedMissingSessionStartSkills, }); } - async appendFreshSessionStartReminder(): Promise { - const reminder = await this.renderSessionStartReminder(); - if (reminder !== undefined) { - this.reminders.appendSystemReminder( - `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); - } else if (shouldNeutralizePluginSessionStart(this.context.get())) { - this.reminders.appendSystemReminder( - 'There are currently no active plugin session starts. ' + - 'This supersedes any earlier plugin_session_start reminder in this session.', - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); + private async reconcileSessionStartReminder( + injection: ContextInjectionContext, + ): Promise { + const forceRefresh = this.refreshPending; + const desired = await this.resolveDesiredSessionStart(injection, forceRefresh); + this.refreshPending = false; + const latest = injection.lastInjection; + if (desired === undefined) { + if ( + latest === undefined && + (!forceRefresh || !shouldNeutralizePluginSessionStart(this.context.get())) + ) { + return undefined; + } + if (latest !== undefined && systemReminderContent(latest) === NO_ACTIVE_SESSION_STARTS) { + return undefined; + } + return NO_ACTIVE_SESSION_STARTS; } + if (latest === undefined) return desired; + const rendered = systemReminderContent(latest); + if ( + !forceRefresh && + (rendered === desired.trim() || rendered === `${desired}\n\n${SUPERSEDES_SUFFIX}`.trim()) + ) { + return undefined; + } + return `${desired}\n\n${SUPERSEDES_SUFFIX}`; } + + private async resolveDesiredSessionStart( + injection: ContextInjectionContext, + forceRefresh: boolean, + ): Promise { + const snapshot = this.wire.getModel(PluginSessionStartSnapshotModel); + if (!forceRefresh && snapshot.initialized) return snapshot.content; + if (!forceRefresh && injection.lastInjection !== undefined) { + const rendered = systemReminderContent(injection.lastInjection); + if (rendered !== undefined) { + const content = frozenSessionStartContent(rendered); + this.recordSessionStartSnapshot(content); + return content; + } + } + const content = await this.renderSessionStartReminder(); + this.recordSessionStartSnapshot(content); + return content; + } + + private recordSessionStartSnapshot(content: string | undefined): void { + this.wire.dispatch(pluginSessionStartSnapshotSet({ content: content ?? null })); + } +} + +function frozenSessionStartContent(rendered: string): string | undefined { + if (rendered === NO_ACTIVE_SESSION_STARTS) return undefined; + const suffix = `\n\n${SUPERSEDES_SUFFIX}`; + return rendered.endsWith(suffix) ? rendered.slice(0, -suffix.length) : rendered; } interface RenderPluginSessionStartReminderInput { @@ -149,22 +238,27 @@ interface RenderPluginSessionStartReminderInput { readonly catalog: SkillCatalog | undefined; readonly log?: { warn(message: string, payload?: unknown): void }; readonly sessionId?: string; + readonly warnedSkills: Set; } function renderPluginSessionStartReminder( input: RenderPluginSessionStartReminderInput, ): string | undefined { - const { sessionStarts, catalog, log, sessionId } = input; + const { sessionStarts, catalog, log, sessionId, warnedSkills } = input; if (sessionStarts.length === 0) return undefined; if (catalog === undefined) return undefined; const blocks: string[] = []; for (const sessionStart of sessionStarts) { const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { - log?.warn('plugin sessionStart skill not found', { - pluginId: sessionStart.pluginId, - skillName: sessionStart.skillName, - }); + const key = `${sessionStart.pluginId}:${sessionStart.skillName}`; + if (!warnedSkills.has(key)) { + warnedSkills.add(key); + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + } continue; } blocks.push( diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index f84ede987..c67687180 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -5,8 +5,9 @@ * `PromptStepRequest` / `SteerStepRequest` carry an already-built user * `ContextMessage` (image-compression captions pre-split), apply the image * format gate as the last funnel before the history, and materialize it - * at pop time — caption reminders first, message second, mirroring the old - * `appendPrompt` ordering. `PromptStepRequest` uses `newTurn`, seeding the + * at pop time — caption reminders are appended before the host message, + * preserving the prompt-owned undo boundary. + * `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 diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts new file mode 100644 index 000000000..694372757 --- /dev/null +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -0,0 +1,83 @@ +/** + * `swarm` domain — swarm-mode context injection. + * + * Registers swarm-mode guidance through `contextInjector` and reads + * `contextMemory` for restored legacy state. Used by the Agent-scoped swarm + * service. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, + type ContextInjectionResult, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; + +import SWARM_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; +import SWARM_MODE_EXIT_REMINDER from '../exit-reminder.md?raw'; +import type { SwarmModeTrigger } from '../swarm'; + +const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; +const LEGACY_SWARM_MODE_EXIT_VARIANT = 'swarm_mode_exit'; + +interface SwarmModeInjectionDisclosure { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; +} + +export interface SwarmInjectionOptions { + readonly getTrigger: () => SwarmModeTrigger | null; +} + +export class SwarmInjection extends Disposable { + constructor( + private readonly options: SwarmInjectionOptions, + @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + ) { + super(); + this._register( + injector.register( + SWARM_MODE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), + ); + } + + private reminder( + ctx: ContextInjectionContext, + ): ContextInjectionResult | undefined { + const trigger = this.options.getTrigger(); + const active = trigger !== null && trigger !== 'tool'; + const rendered = this.renderedState(ctx); + if (active) { + return rendered === 'active' + ? undefined + : { + content: SWARM_MODE_ENTER_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'active' }, + }; + } + return rendered === 'active' + ? { + content: SWARM_MODE_EXIT_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'inactive' }, + } + : undefined; + } + + private renderedState( + ctx: ContextInjectionContext, + ): 'active' | 'inactive' | undefined { + if (ctx.lastDisclosure !== undefined) return ctx.lastDisclosure.state; + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const origin = history[i]!.origin; + if (origin?.kind !== 'injection') continue; + if (origin.variant === LEGACY_SWARM_MODE_EXIT_VARIANT) return 'inactive'; + if (origin.variant === SWARM_MODE_INJECTION_VARIANT) return 'active'; + } + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index fde33429b..b27cdfe1d 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -3,33 +3,28 @@ * * Tracks swarm-mode enter/exit in the `wire` `SwarmModel` (mutated only through * the `swarm_mode.enter` / `swarm_mode.exit` Ops, read through `wire.getModel`), - * mirrors it into `systemReminder` as live-only side effects, derives - * `agent.status.updated` from the Ops' `toEvent`, and auto-exits on turn end via - * `turn`. The enter-reminder removal on exit is a cross-model fold on - * `ContextModel`: dispatching `swarm_mode.exit` pops the - * reminder when it is the last message, both live and on replay — exactly like - * v1's restore-time `popMatchedMessage`. The service only publishes the - * live-only `context.spliced` event for that pop (so injector bookkeeping - * stays in step) and appends the exit reminder when nothing was - * popped. Bound at Agent scope. The service also guards AgentSwarm batch - * exclusivity through an `onBeforeExecuteTool` veto - * listener: an AgentSwarm call must be the only tool call in its batch, + * derives `agent.status.updated` from the Ops' `toEvent`, announces the mode + * through the `swarm_mode` context-injection provider (`SwarmInjection`), + * mirrors replayable trailing-enter removal through `contextMemory`, and + * auto-exits on turn end via `turn`. Bound at Agent scope. The service also + * guards AgentSwarm batch exclusivity through an `onBeforeExecuteTool` veto + * listener: an AgentSwarm call must be the only tool call in its batch; * anything else is vetoed with a `toolApproval.formatDenyMessage`-formatted * reason. */ import { Service } from '#/_base/di/service'; +import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; -import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; -import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; + +import { SwarmInjection } from './injection/swarmInjection'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; import { swarmEnter, swarmExit, SwarmModel } from './swarmOps'; @@ -38,15 +33,20 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { constructor( @IWireService private readonly wire: IWireService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IInstantiationService instantiation: IInstantiationService, + @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IEventBus private readonly eventBus: IEventBus, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); this._register( - this.eventBus.subscribe('turn.ended', () => { + instantiation.createInstance(SwarmInjection, { + getTrigger: () => this.wire.getModel(SwarmModel), + }), + ); + this._register( + eventBus.subscribe('turn.ended', () => { if (this.shouldAutoExit) { this.exit(); } @@ -76,36 +76,13 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { enter(trigger: SwarmModeTrigger): void { if (this.wire.getModel(SwarmModel) !== null) return; this.wire.dispatch(swarmEnter({ trigger })); - if (trigger !== 'tool') { - this.reminders.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { - kind: 'injection', - variant: 'swarm_mode', - }); - } } exit(): void { - const trigger = this.wire.getModel(SwarmModel); - if (trigger === null) return; + if (this.wire.getModel(SwarmModel) === null) return; const history = this.context.get(); - const last = history[history.length - 1]; - const willPop = - last?.origin?.kind === 'injection' && last.origin.variant === 'swarm_mode'; this.wire.dispatch(swarmExit({})); - if (trigger === 'tool') return; - if (willPop) { - this.eventBus.publish({ - type: 'context.spliced', - start: history.length - 1, - deleteCount: 1, - messages: [], - }); - return; - } - this.reminders.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { - kind: 'injection', - variant: 'swarm_mode_exit', - }); + this.context.publishTrailingRemoval(history); } get isActive(): boolean { diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index 3ecf30eae..106c2de2a 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -1,7 +1,32 @@ +/** + * `systemReminder` domain — low-level model-facing reminder write contract. + * + * Defines the Agent-scoped write head used by context injection, event-point + * one-off reminders, and prompt-owned media annotations, and owns the + * `` text format: `wrapSystemReminder` is the only writer, + * `systemReminderContent` the only reader, so no consumer reconstructs the + * format by hand. Bound at Agent scope. + */ + import { createDecorator } from "#/_base/di/instantiation"; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +const SYSTEM_REMINDER_PREFIX = '\n'; +const SYSTEM_REMINDER_SUFFIX = '\n'; + +export function wrapSystemReminder(content: string): string { + return `${SYSTEM_REMINDER_PREFIX}${content.trim()}${SYSTEM_REMINDER_SUFFIX}`; +} + +export function systemReminderContent(message: ContextMessage): string | undefined { + const text = message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + if (!text.startsWith(SYSTEM_REMINDER_PREFIX) || !text.endsWith(SYSTEM_REMINDER_SUFFIX)) { + return undefined; + } + return text.slice(SYSTEM_REMINDER_PREFIX.length, text.length - SYSTEM_REMINDER_SUFFIX.length); +} + export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts index 317fa17a9..e2cf5d37d 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts @@ -1,10 +1,17 @@ -import { Service } from "#/_base/di/service"; +/** + * `systemReminder` domain — `IAgentSystemReminderService` implementation. + * + * Appends model-facing reminder messages, wrapped by `wrapSystemReminder`, + * into the conversation through `contextMemory`. Bound at Agent scope. + */ + +import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; -import { IAgentSystemReminderService } from './systemReminder'; +import { IAgentSystemReminderService, wrapSystemReminder } from './systemReminder'; export class AgentSystemReminderService extends Service implements IAgentSystemReminderService { declare readonly _serviceBrand: undefined; @@ -21,7 +28,7 @@ export class AgentSystemReminderService extends Service implements IAgentSystemR content: [ { type: 'text', - text: `\n${content.trim()}\n`, + text: wrapSystemReminder(content), }, ], toolCalls: [], diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 617336bd9..0418dc571 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -28,35 +28,39 @@ import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; const REMINDER_TEXT_1 = - '\n\n\n' + - 'The same tool call has been repeated several times in a row. ' + - 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + - 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.', + ); function makeReminderText2(repeatCount: number): string { return ( - '\n\n\n' + - `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + - 'Choose exactly one of the following and state your choice before acting:\n' + - '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + - '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + - '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + - '\n' + '\n\n' + + wrapSystemReminder( + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.', + ) ); } const REMINDER_TEXT_3 = - '\n\n\n' + - 'Write your final response now, without any further tool calls. ' + - 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + - 'Text only.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.', + ); const REPEAT_REMINDER_1_START = 3; const REPEAT_REMINDER_2_START = 5; diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts index b3535f399..483bf9ba3 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts @@ -14,9 +14,10 @@ * first real user prompt it finds regardless of origin: schema messages * survive only when the cut lands before them. * - loadable-tools announcements: `/` system - * reminders (origin `{kind: 'system_trigger', name: 'loadable-tools'}`) — - * undo removes them (they are not `injection`-origin), and the next - * turn-boundary diff self-heals by re-announcing the folded delta. + * reminders (origin `{kind: 'injection', variant: 'loadable-tools'}`; + * legacy journals used `{kind: 'system_trigger', name: 'loadable-tools'}` + * and both are folded) — the next turn-boundary diff self-heals by + * re-announcing the folded delta whenever the ledger drifts. * * The loaded-tool ledger is the history itself: there is deliberately no * separate persisted ledger, so undo/compaction/resume all self-heal by @@ -29,17 +30,16 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; export const DYNAMIC_TOOL_SCHEMA_VARIANT = 'dynamic_tool_schema'; -export const LOADABLE_TOOLS_TRIGGER = 'loadable-tools'; +export const LOADABLE_TOOLS_VARIANT = 'loadable-tools'; export function isDynamicToolSchemaMessage(message: ContextMessage): boolean { return message.tools !== undefined && message.tools.length > 0; } export function isLoadableToolsAnnouncement(message: ContextMessage): boolean { - return ( - message.origin?.kind === 'system_trigger' && - message.origin.name === LOADABLE_TOOLS_TRIGGER - ); + const origin = message.origin; + if (origin?.kind === 'injection') return origin.variant === LOADABLE_TOOLS_VARIANT; + return origin?.kind === 'system_trigger' && origin.name === LOADABLE_TOOLS_VARIANT; } export function stripDynamicToolContext( diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts index e3ce761ab..b58f3d601 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts @@ -2,12 +2,13 @@ * `toolSelect` domain — progressive tool disclosure contract. * * Defines the Agent-scope service that shapes provider-visible tool/history - * views, loads selected dynamic schemas, and reports loadable-tool - * announcements. + * views, records selected dynamic schemas as pending declarations, and + * reports loadable-tool announcements. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { Tool } from '#/kosong/contract/tool'; import type { ToolInfo } from '#/tool/toolContract'; export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; @@ -33,6 +34,8 @@ export interface IAgentToolSelectService { load(names: readonly string[]): LoadToolsResult; + drainPendingToolSchemas(): readonly Tool[] | undefined; + loadableToolsAnnouncement(): string | undefined; } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts index 6be3c1f1a..7a80fd17d 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts @@ -1,8 +1,8 @@ /** * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` contract. * - * Defines the Agent-scope marker service that appends v1-compatible - * loadable-tools announcements through `systemReminder` at loop boundaries. + * Defines the Agent-scope marker service that announces v1-compatible + * loadable-tools diffs through the `contextInjector` boundary scheduler. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index 3338fbdc2..94e32f8b1 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -2,75 +2,37 @@ * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` * implementation. * - * Appends v1-compatible loadable-tools diff announcements at turn boundaries - * through `systemReminder`, hooks into `loop` before each step, reads - * announcement text from `IAgentToolSelectService`, and observes compaction - * boundaries from `event`. Turn boundaries need no state: every turn starts - * at loop step 1, which always evaluates injection. The compaction-boundary - * flag (`needsBoundaryInjection`) is registered into `agentState` - * (`IAgentStateService`) and read/written through it. Bound at Agent scope. + * Registers v1-compatible loadable-tools diff announcements as a + * `contextInjector` provider (variant `loadable-tools`). The injector's + * `isNewTurn` covers exactly the old boundary set — every turn's first step + * and the post-compaction inject — so no local boundary state is needed. + * Reads announcement text from `IAgentToolSelectService`; the folded history + * itself remains the ledger, so undo/compaction/resume all self-heal by + * re-folding. Bound at Agent scope. */ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { IEventBus } from '#/app/event/eventBus'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { LOADABLE_TOOLS_TRIGGER } from './dynamicTools'; +import { LOADABLE_TOOLS_VARIANT } from './dynamicTools'; import { IAgentToolSelectService } from './toolSelect'; import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements'; -export const toolSelectNeedsBoundaryInjectionKey = defineState( - 'toolSelect.needsBoundaryInjection', - () => false, -); - export class AgentToolSelectAnnouncementsService extends Service implements IAgentToolSelectAnnouncementsService { declare readonly _serviceBrand: undefined; constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IEventBus eventBus: IEventBus, - @IAgentLoopService loopService: IAgentLoopService, - @IAgentStateService private readonly states: IAgentStateService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); - this.states.register(toolSelectNeedsBoundaryInjectionKey); this._register( - eventBus.subscribe('compaction.completed', () => { - this.needsBoundaryInjection = true; - }), + injector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => + isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, + ), ); - this._register( - loopService.hooks.onWillBeginStep.register('toolSelectAnnouncements', async (ctx, next) => { - await next(); - if (ctx.step !== 1 && !this.needsBoundaryInjection) return; - this.needsBoundaryInjection = false; - this.inject(toolSelect); - }), - ); - } - - private get needsBoundaryInjection(): boolean { - return this.states.get(toolSelectNeedsBoundaryInjectionKey); - } - - private set needsBoundaryInjection(value: boolean) { - this.states.set(toolSelectNeedsBoundaryInjectionKey, value); - } - - private inject(toolSelect: IAgentToolSelectService): void { - const announcement = toolSelect.loadableToolsAnnouncement(); - if (announcement === undefined) return; - this.reminders.appendSystemReminder(announcement, { - kind: 'system_trigger', - name: LOADABLE_TOOLS_TRIGGER, - }); } } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts new file mode 100644 index 000000000..2cbaf96d0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts @@ -0,0 +1,15 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` contract. + * + * Defines the Agent-scope marker service that declares pending dynamic-tool + * schemas into the history through the `contextInjector` boundary scheduler. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolSelectSchemasService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolSelectSchemasService: ServiceIdentifier = + createDecorator('agentToolSelectSchemasService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts new file mode 100644 index 000000000..b491164ec --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -0,0 +1,41 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` implementation. + * + * Declares pending dynamic-tool schemas from `toolSelect` through + * `contextInjector`. Bound at Agent scope. + */ + +import { Service } from '#/_base/di/service'; +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; + +import { DYNAMIC_TOOL_SCHEMA_VARIANT } from './dynamicTools'; +import { IAgentToolSelectService } from './toolSelect'; +import { IAgentToolSelectSchemasService } from './toolSelectSchemas'; + +export class AgentToolSelectSchemasService extends Service implements IAgentToolSelectSchemasService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolSelectService toolSelect: IAgentToolSelectService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, + ) { + super(); + this._register( + injector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + const tools = toolSelect.drainPendingToolSchemas(); + if (tools === undefined) return undefined; + return { message: { role: 'system', content: [], tools } }; + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectSchemasService, + AgentToolSelectSchemasService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index fa4c3489e..5d936f542 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -2,13 +2,18 @@ * `toolSelect` domain — `IAgentToolSelectService` implementation. * * Shapes the provider-visible tool and history views for progressive tool - * disclosure, loads dynamic schemas into `contextMemory`, and exposes - * loadable-tools announcement text. Reads live tools from `toolRegistry`, - * active-tool and capability state from `profile`, gates through `flag`, - * hooks into `toolExecutor`, and listens to context lifecycle events through - * `event`. The mutable load-tracking state (`pendingLoaded`) is registered - * into `agentState` (`IAgentStateService`) and read/written through it. Bound - * at Agent scope. + * disclosure, tracks loaded dynamic schemas as pending declarations drained + * by the `contextInjector` boundary provider (the declaration lands at a + * quiescent boundary instead of mid-step inside a streaming tool exchange), + * and exposes loadable-tools announcement text. Removal splices + * (`undo`/`clear`) drop pending entries whose announcing exchange left the + * conversation, while compaction's replacement splice keeps them, so the + * declaration still lands at the post-compaction boundary. Reads live tools from + * `toolRegistry`, active-tool and capability state from `profile`, gates + * through `flag`, hooks into `toolExecutor`, and listens to context + * lifecycle events through `event`. The mutable load-tracking state + * (`pendingLoaded`) is registered into `agentState` (`IAgentStateService`) + * and read/written through it. Bound at Agent scope. */ import { Service } from '#/_base/di/service'; @@ -29,7 +34,6 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { collectLoadedDynamicToolNames, - DYNAMIC_TOOL_SCHEMA_VARIANT, foldAnnouncedToolNames, renderLoadableToolsAnnouncement, stripDynamicToolContext, @@ -75,7 +79,7 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS ); this._register( eventBus.subscribe('context.spliced', (splice) => { - if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return; + if (splice.deleteCount === 0 || splice.messages.length > 0) return; this.dropPendingLoadedNotLanded(); }), ); @@ -144,22 +148,24 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS } } if (toLoad.length > 0) { - toLoad.sort((a, b) => a.localeCompare(b)); - const tools = toLoad - .map((name) => this.schemaOf(name)) - .filter((tool): tool is Tool => tool !== undefined); - this.context.append({ - role: 'system', - content: [], - toolCalls: [], - tools, - origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }, - }); for (const name of toLoad) this.pendingLoaded.add(name); } return { toLoad, alreadyAvailable, unknown }; } + drainPendingToolSchemas(): readonly Tool[] | undefined { + if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; + const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); + const tools: Tool[] = []; + for (const name of names) { + const tool = this.schemaOf(name); + if (tool === undefined) continue; + this.pendingLoaded.delete(name); + tools.push(tool); + } + return tools.length === 0 ? undefined : tools; + } + loadableToolsAnnouncement(): string | undefined { if (!this.enabled()) return undefined; const loadable = this.loadableToolNames(); diff --git a/packages/agent-core-v2/src/features/btw/btwService.ts b/packages/agent-core-v2/src/features/btw/btwService.ts index 74f45a0d1..ca1b5556d 100644 --- a/packages/agent-core-v2/src/features/btw/btwService.ts +++ b/packages/agent-core-v2/src/features/btw/btwService.ts @@ -5,12 +5,12 @@ * `IAgentLifecycleService.fork`, then disables tool calls via an * `onBeforeExecuteTool` veto listener (blocks every tool call with the * `toolApproval.formatDenyMessage`-formatted TOOL_CALL_DISABLED_MESSAGE) and - * appends the side-channel system reminder. Contributed at Session scope by - * `BtwFeature` (`features/btw/btwFeature`) — `fork('main')` is a - * session-level operation, so the service injects the session's - * `IAgentLifecycleService` directly rather than resolving it through the main - * agent's accessor. Callers materialize the main agent first; forking a - * missing source throws. + * appends the side-channel reminder through the child's `systemReminder`. + * Contributed at Session scope by `BtwFeature` (`features/btw/btwFeature`) — + * `fork('main')` is a session-level operation, so the service injects the + * session's `IAgentLifecycleService` directly rather than resolving it through + * the main agent's accessor. Callers materialize the main agent first; forking + * a missing source throws. */ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -33,8 +33,8 @@ export class SessionBtwService implements ISessionBtwService { child.accessor .get(IAgentSystemReminderService) ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'system_trigger', - name: 'btw', + kind: 'injection', + variant: 'btw', }); const reason = child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( diff --git a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts index 951c99320..66d6a32d5 100644 --- a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts +++ b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts @@ -34,7 +34,7 @@ export const planWasActiveKey = defineState('plan.wasActive', () => fal export class PlanModeInjection extends Service { constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentPlanService private readonly plan: IAgentPlanService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentStateService private readonly states: IAgentStateService, @@ -43,7 +43,7 @@ export class PlanModeInjection extends Service { this.states.register(planWasActiveKey); this._register( - dynamicInjector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { + injector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { const data = await this.plan.status(); if (data === null) { if (!this.states.get(planWasActiveKey)) return undefined; diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 80aa641d4..9babb3154 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -74,7 +74,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, @IBlobStore private readonly blobs: IBlobStore, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IEventBus eventBus: IEventBus, @IWireService private readonly wire: IWireService, @@ -106,7 +106,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { }), ); - this._register(new PlanModeInjection(dynamicInjector, this, this.context, states)); + this._register(new PlanModeInjection(injector, this, this.context, states)); this._register(this.registerPlanGuard(toolExecutor)); } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e41a76d8f..3af2678ad 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -336,6 +336,8 @@ export * from '#/agent/toolSelect/toolSelect'; export * from '#/agent/toolSelect/toolSelectService'; export * from '#/agent/toolSelect/toolSelectAnnouncements'; export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; +export * from '#/agent/toolSelect/toolSelectSchemas'; +export * from '#/agent/toolSelect/toolSelectSchemasService'; import '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/evaluate'; @@ -569,6 +571,7 @@ export * from '#/agent/tokenCounting/tokenCountingService'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; export * from '#/agent/plugin/agentPlugin'; +export * from '#/agent/plugin/agentPluginOps'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; export * from '#/agent/externalHooks/externalHooks'; diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index b9309eb3d..6dd7cf4a3 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -1,6 +1,6 @@ /** * Scenario: discover uninjected AGENTS.md files from canonical tool accesses and Bash targets. - * Responsibilities: seeding, once-only reminders, result delivery, probing, and path extraction. + * Responsibilities: seeding, once-only reminders, queue delivery, probing, and path extraction. * Wiring: real reminder, executor, parser, and host filesystem with telemetry/event stubs. * Run: pnpm exec vitest run test/agent/agentsMdReminder/agentsMdReminder.test.ts */ @@ -46,12 +46,11 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import type { PromptOrigin } from '#/agent/contextMemory/types'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; -import type { - ResolvedToolExecutionHookContext, - ToolDidExecuteContext, -} from '#/agent/toolExecutor/toolHooks'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { AgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; @@ -77,12 +76,18 @@ afterEach(async () => { await rm(workDir, { recursive: true, force: true }); }); +interface CapturedReminder { + readonly content: string; + readonly origin: PromptOrigin; +} + interface Harness { readonly ix: TestInstantiationService; readonly events: ToolExecutorEventStubs; readonly reminder: IAgentAgentsMdReminderService; readonly wire: IWireService; readonly telemetryEvents: TelemetryRecord[]; + readonly reminders: CapturedReminder[]; } function createHarness( @@ -100,6 +105,7 @@ function createHarness( } = {}, ): Harness { const telemetryEvents: TelemetryRecord[] = []; + const reminders: CapturedReminder[] = []; const events = stubToolExecutorEvents(); const ix = createServices(disposables, { additionalServices: (reg) => { @@ -137,6 +143,13 @@ function createHarness( reg.defineInstance(IWireService, wire); reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); reg.defineInstance(IAgentStateService, new AgentStateService()); + reg.defineInstance(IAgentSystemReminderService, { + _serviceBrand: undefined, + appendSystemReminder: (content: string, origin: PromptOrigin) => { + reminders.push({ content, origin }); + return { role: 'user', content: [], toolCalls: [], origin }; + }, + } satisfies IAgentSystemReminderService); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -168,7 +181,7 @@ function createHarness( }); const reminder = ix.get(IAgentAgentsMdReminderService); const wire = ix.get(IWireService); - return { ix, events, reminder, wire, telemetryEvents }; + return { ix, events, reminder, wire, telemetryEvents, reminders }; } function didCtx( @@ -215,23 +228,6 @@ function testAccesses(name: string, args: unknown): ToolAccessesType | undefined return undefined; } -function willCtx(id: string, name: string, args: unknown): ResolvedToolExecutionHookContext { - const toolCall: ToolCall = { - type: 'function', - id, - name, - arguments: JSON.stringify(args), - }; - return { - turnId: 1, - signal: new AbortController().signal, - toolCall, - toolCalls: [toolCall], - args, - execution: { approvalRule: 'x', execute: async () => ({ output: '' }) }, - }; -} - async function fire(h: Harness, ctx: ToolDidExecuteContext): Promise { await h.events.didExecuteSlot.run(ctx); return ctx.result; @@ -246,6 +242,10 @@ function outputText(result: ExecutableToolResult): string { .join(''); } +function reminderText(h: Harness): string { + return h.reminders.map((entry) => entry.content).join('\n'); +} + async function writeAgentsMd(dir: string, content = 'instructions'): Promise { await mkdir(dir, { recursive: true }); const path = join(dir, 'AGENTS.md'); @@ -263,9 +263,14 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - const text = outputText(result); - expect(text).toContain('original result'); - expect(text).toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(h.reminders[0]?.origin).toEqual({ kind: 'injection', variant: 'agents_md' }); + expect(h.reminders[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( + true, + ); + expect(h.reminders[0]?.content).not.toContain(''); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -278,8 +283,10 @@ describe('agentsMdReminder path-carrying tools', () => { const first = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); const second = await fire(h, didCtx('Edit', { path: join(subDir, 'b.ts') })); - expect(outputText(first)).toContain(subAgentsMd); - expect(outputText(second)).not.toContain(''); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { @@ -288,10 +295,12 @@ describe('agentsMdReminder path-carrying tools', () => { const subAgentsMd = await writeAgentsMd(subDir); const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); - expect(outputText(direct)).not.toContain(''); + expect(outputText(direct)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - expect(outputText(after)).not.toContain(subAgentsMd); + expect(outputText(after)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -303,7 +312,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(dotKimi); expect(text).toContain(plain); }); @@ -319,7 +329,8 @@ describe('agentsMdReminder path-carrying tools', () => { didCtx('Write', { path: join(workDir, 'new-pkg', 'src', 'index.ts'), content: 'x' }), ); - expect(outputText(result)).toContain(rootAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(rootAgentsMd); }); it('does not remind for seeded paths on the injected chain', async () => { @@ -329,7 +340,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('tracks the shown event through telemetry', async () => { @@ -356,7 +368,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls packages/kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('rebases relative operands across a literal cd', async () => { @@ -365,7 +378,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'cd packages && ls kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts find roots and stops at the expression', async () => { @@ -377,7 +391,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: "find packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts quoted directory operands', async () => { @@ -386,7 +401,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls "packages/kap-server"' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('probes an explicit cwd even when the command lists nothing', async () => { @@ -398,7 +414,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: 'git status', cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips operands that are not statically resolvable', async () => { @@ -407,13 +424,14 @@ describe('agentsMdReminder Bash coverage', () => { for (const command of ['ls $DIR', 'ls *.ts', 'ls $(pwd)', 'echo packages/kap-server']) { const result = await fire(h, didCtx('Bash', { command })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); } + expect(h.reminders).toHaveLength(0); }); }); describe('agentsMdReminder result shapes and edge cases', () => { - it('prepends the reminder to the first text part of ContentPart[] outputs', async () => { + it('leaves ContentPart[] results untouched and enqueues the reminder', async () => { const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -426,10 +444,9 @@ describe('agentsMdReminder result shapes and edge cases', () => { ), ); - expect(Array.isArray(result.output)).toBe(true); - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain('part one'); - expect(outputText(result)).toContain(subAgentsMd); + expect(result.output).toEqual([{ type: 'text', text: 'part one' }]); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('does not mark an AGENTS.md known when the direct read failed', async () => { @@ -441,33 +458,29 @@ describe('agentsMdReminder result shapes and edge cases', () => { h, didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), ); - expect(outputText(failed)).not.toContain(''); + expect(outputText(failed)).toBe('not found'); + expect(h.reminders).toHaveLength(0); await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(reminderText(h)).toContain(agentsMdPath); }); }); -describe('agentsMdReminder toolDedupe interplay', () => { - it('delivers the reminder through a same-step duplicate resolved by toolDedupe', async () => { - const h = createHarness({ withDedupe: true }); - h.ix.get(IAgentToolDedupeService); +describe('agentsMdReminder duplicate calls', () => { + it('reminds exactly once for two same-step calls touching the same directory', async () => { + const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); const args = { path: join(workDir, 'packages', 'kap-server', 'index.ts') }; - await h.events.fireBeforeExecute(willCtx('call-1', 'Read', args)); - const did1 = didCtx('Read', args, { id: 'call-1' }); - await h.events.didExecuteSlot.run(did1); - expect(outputText(did1.result)).toContain(subAgentsMd); + const first = await fire(h, didCtx('Read', args, { id: 'call-1' })); + const second = await fire(h, didCtx('Read', args, { id: 'call-2' })); - const decision = await h.events.fireBeforeExecute(willCtx('call-2', 'Read', args)); - const did2 = didCtx('Read', args, { - id: 'call-2', - result: decision?.veto ?? { output: '' }, - }); - await h.events.didExecuteSlot.run(did2); - expect(outputText(did2.result)).toContain(subAgentsMd); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('leaves the vetoed placeholder untouched and reminds exactly once on the visible results', async () => { @@ -503,10 +516,10 @@ describe('agentsMdReminder toolDedupe interplay', () => { expect(results).toHaveLength(2); for (const item of results) { - const text = outputText(item.result); - expect(text).toContain('file contents'); - expect(text).toContain(subAgentsMd); + expect(outputText(item.result)).toBe('file contents'); } + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); expect(shown).toHaveLength(1); }); @@ -523,19 +536,20 @@ describe('agentsMdReminder lazy seeding after a restore', () => { didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), ); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); it('treats the brand-home AGENTS.md as injected after a restore', async () => { const h = createHarness(); - const brandAgentsMd = await writeAgentsMd(homeDir, 'brand instructions'); + await writeAgentsMd(homeDir, 'brand instructions'); const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); expect(outputText(result)).toBe('original result'); - expect(outputText(result)).not.toContain(brandAgentsMd); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); }); @@ -555,7 +569,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('recovers injected paths from a legacy restored prompt without path provenance', async () => { @@ -569,7 +584,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); }); @@ -581,7 +597,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { const result = await fire(h, didCtx('Bash', { command: 'ls -w 80 packages/kap-server' })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(eighty); }); @@ -595,7 +612,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { didCtx('Bash', { command: "find -L packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -608,7 +626,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('still reminds when the triggering call ended in an error result', async () => { @@ -623,7 +642,8 @@ describe('agentsMdReminder probing boundaries', () => { }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('not found'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when it is written directly', async () => { @@ -633,10 +653,12 @@ describe('agentsMdReminder probing boundaries', () => { const agentsMdPath = normalize(join(subDir, 'AGENTS.md')); const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); - expect(outputText(written)).not.toContain(''); + expect(outputText(written)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).not.toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -649,10 +671,9 @@ describe('agentsMdReminder probing boundaries', () => { fire(h, didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' })), ]); - const reminders = [first, second].filter((result) => - outputText(result).includes(''), - ); - expect(reminders).toHaveLength(1); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.reminders).toHaveLength(1); }); it('re-judges the project root at a nested repository', async () => { @@ -664,7 +685,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(nested, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(nestedAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -679,7 +701,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(leaf, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(leafAgentsMd); expect(text).not.toContain(outerAgentsMd); } finally { @@ -696,7 +719,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(workDir, 'link', 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = reminderText(h); expect(text).toContain(normalize(join(workDir, 'link', 'AGENTS.md'))); expect(text).not.toContain(targetAgentsMd); } finally { @@ -717,6 +741,7 @@ describe('agentsMdReminder round-2 hardening', () => { ); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); @@ -729,9 +754,11 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Bash', { command: 'true' })); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); - expect(outputText(listed)).toContain(subAgentsMd); + expect(outputText(listed)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('ignores a whitespace-only AGENTS.md just like the init-time load', async () => { @@ -742,7 +769,8 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); it('keeps known-sets isolated between agents', async () => { @@ -754,8 +782,12 @@ describe('agentsMdReminder round-2 hardening', () => { const firstResult = await fire(first, didCtx('Read', { path: join(subDir, 'index.ts') })); const secondResult = await fire(second, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(firstResult)).toContain(subAgentsMd); - expect(outputText(secondResult)).toContain(subAgentsMd); + expect(outputText(firstResult)).toBe('original result'); + expect(outputText(secondResult)).toBe('original result'); + expect(first.reminders).toHaveLength(1); + expect(second.reminders).toHaveLength(1); + expect(reminderText(first)).toContain(subAgentsMd); + expect(reminderText(second)).toContain(subAgentsMd); }); it('releases the claim when attaching the reminder fails, so the next touch retries', async () => { @@ -772,26 +804,16 @@ describe('agentsMdReminder round-2 hardening', () => { const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); expect(outputText(failed)).toBe('original result'); + expect(h.reminders).toHaveLength(0); shouldThrow = false; const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); - expect(outputText(retried)).toContain(subAgentsMd); + expect(outputText(retried)).toBe('original result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); - it('prepends the reminder so it survives head-only truncation', async () => { - const h = createHarness(); - const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); - - const result = await fire( - h, - didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), - ); - - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain(subAgentsMd); - }); - - it('survives the real executor pipeline with oversized results', async () => { + it('leaves oversized results to the truncation pipeline and enqueues the reminder instead', async () => { const h = createHarness({ withRealExecutor: true }); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -827,8 +849,10 @@ describe('agentsMdReminder round-2 hardening', () => { expect(typeof output).toBe('string'); const text = output as string; expect(text).toContain('output_path:'); - expect(text.indexOf('')).toBeLessThan(2_000); - expect(text).toContain(subAgentsMd); + expect(text).not.toContain(''); + expect(text).not.toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('uses the resolved file access instead of reparsing the raw path', async () => { @@ -867,13 +891,15 @@ describe('agentsMdReminder round-2 hardening', () => { } expect(results).toHaveLength(1); - expect(outputText(results[0]!.result)).toContain(homeAgentsMd); + expect(outputText(results[0]!.result)).toBe('home file contents'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(homeAgentsMd); }); it('does not probe or remind when permission vetoes an access-bearing call', async () => { const h = createHarness({ withRealExecutor: true }); const subDir = join(workDir, 'packages', 'kap-server'); - const subAgentsMd = await writeAgentsMd(subDir); + await writeAgentsMd(subDir); const hostFs = h.ix.get(IHostFileSystem); const stat = vi.spyOn(hostFs, 'stat'); const readText = vi.spyOn(hostFs, 'readText'); @@ -915,7 +941,7 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('permission denied'); - expect(outputText(results[0]!.result)).not.toContain(subAgentsMd); + expect(h.reminders).toHaveLength(0); expect(stat).not.toHaveBeenCalled(); expect(readText).not.toHaveBeenCalled(); expect( @@ -1005,7 +1031,7 @@ describe('agentsMdReminder cancellation outcomes', () => { const results = await pending; const queued = results.find((item) => item.toolCallId === 'call-queued-read'); expect(queued).toBeDefined(); - expect(outputText(queued!.result)).not.toContain(''); + expect(h.reminders).toHaveLength(0); expect( h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), ).toEqual([]); @@ -1027,7 +1053,9 @@ describe('agentsMdReminder cancellation outcomes', () => { )) { real.push(item); } - expect(outputText(real[0]!.result)).toContain(subAgentsMd); + expect(outputText(real[0]!.result)).toBe('read result'); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -1041,7 +1069,8 @@ describe('agentsMdReminder Bash parse degradation', () => { didCtx('Bash', { command: "ls '", cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips entirely when an unparseable command has no explicit cwd', async () => { @@ -1051,6 +1080,7 @@ describe('agentsMdReminder Bash parse degradation', () => { const result = await fire(h, didCtx('Bash', { command: "ls '" })); expect(outputText(result)).toBe('original result'); + expect(h.reminders).toHaveLength(0); }); }); @@ -1094,7 +1124,8 @@ describe('agentsMdReminder Windows Bash paths', () => { const result = await fire(h, didCtx('Bash', args)); - expect(outputText(result)).toContain(agentsMdPath); + expect(outputText(result)).toBe('original result'); + expect(reminderText(h)).toContain(agentsMdPath); } }); }); 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 ea376e9e9..7353102ff 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -14,7 +14,9 @@ import { createServices, type TestInstantiationService, } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, +} from '#/agent/contextInjector/contextInjector'; import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -26,6 +28,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; +import { registerLogServices } from '../../_base/log/stubs'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; import { runWillBeginStepHooks, @@ -72,7 +75,7 @@ describe('AgentContextInjectorService', () => { disposables = new DisposableStore(); loop = stubLoopWithHooks(); ix = createServices(disposables, { - base: [registerContextMemoryServices], + base: [registerContextMemoryServices, registerLogServices], strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentLoopService, loop); @@ -89,8 +92,8 @@ describe('AgentContextInjectorService', () => { disposables.dispose(); }); - async function runInjectionStep(): Promise { - await runWillBeginStepHooks(loop); + async function runInjectionStep(firstStepOfTurn = false): Promise { + await runWillBeginStepHooks(loop, firstStepOfTurn); } function spliceContext( @@ -191,6 +194,41 @@ describe('AgentContextInjectorService', () => { expect(context.get()).toHaveLength(1); }); + it('reconciles only providers registered under the requested name while idle', async () => { + const seen: string[] = []; + injector(ix).register('target', () => { + seen.push('target'); + return 'target reminder'; + }); + injector(ix).register('other', () => { + seen.push('other'); + return 'other reminder'; + }); + + await injector(ix).reconcileWhenIdle('target'); + + expect(seen).toEqual(['target']); + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'injection', variant: 'target' }); + }); + + it('leaves reconciliation to the next step head when quiescence cannot be acquired', async () => { + let calls = 0; + injector(ix).register('target', () => { + calls++; + return 'target reminder'; + }); + loop.settled = async () => { + throw new Error('idle reconciliation must not wait for an active turn'); + }; + loop.tryAcquireQuiescence = () => undefined; + + await injector(ix).reconcileWhenIdle('target'); + + expect(calls).toBe(0); + expect(context.get()).toHaveLength(0); + }); + it('exposes all live injection positions alongside the newest one', async () => { const seen: Array = []; @@ -308,17 +346,17 @@ describe('AgentContextInjectorService', () => { ]); }); - it('re-arms per-turn providers when injectAfterCompaction runs', async () => { + it('re-arms per-turn providers at the first step after a compaction splice', async () => { const seen: boolean[] = []; injector(ix).register('per_turn_test', ({ isNewTurn }) => { seen.push(isNewTurn); return isNewTurn ? 'per-turn reminder' : undefined; }); - await runInjectionStep(); + await runInjectionStep(true); await runInjectionStep(); spliceContext(0, 1, [compactionSummary('Compacted summary.')]); - await injector(ix).injectAfterCompaction(); + await runInjectionStep(); expect(seen).toEqual([true, false, true]); expect(context.get().map((message) => message.origin)).toEqual([ @@ -326,4 +364,102 @@ describe('AgentContextInjectorService', () => { { kind: 'injection', variant: 'per_turn_test' }, ]); }); + + it('does not re-arm the new-turn flag for non-compaction splices', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return undefined; + }); + + await runInjectionStep(true); + spliceContext(0, 0, [userMessage('between steps')]); + await runInjectionStep(); + + expect(seen).toEqual([true, false]); + }); + + it('re-reconciles within the same step when compaction lands inside the step hook chain', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return isNewTurn ? 'per-turn reminder' : undefined; + }); + loop.hooks.onWillBeginStep.register('test-compaction', async (_ctx, next) => { + spliceContext(0, 1, [compactionSummary('Compacted summary.')]); + await next(); + }); + + await runInjectionStep(true); + + expect(seen).toEqual([true, true]); + expect(context.get().map((message) => message.origin)).toEqual([ + { kind: 'compaction_summary' }, + { kind: 'injection', variant: 'per_turn_test' }, + ]); + }); + + it('appends tagged raw messages verbatim with the injection origin stamped', async () => { + injector(ix).register('schema_test', () => ({ + message: { + role: 'system', + content: [], + tools: [{ name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }], + }, + })); + + await runInjectionStep(); + + const message = context.get().at(-1); + expect(message?.role).toBe('system'); + expect(message?.tools).toEqual([ + { name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }, + ]); + expect(message?.origin).toEqual({ kind: 'injection', variant: 'schema_test' }); + }); + + it('stamps the disclosure on tagged raw messages returned through the result wrapper', async () => { + injector(ix).register('schema_test', () => ({ + content: { message: { role: 'user', content: [{ type: 'text', text: 'raw' }] } }, + disclosure: { kind: 'test_receipt', id: 'r1' }, + })); + + await runInjectionStep(); + + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'schema_test', + disclosure: { kind: 'test_receipt', id: 'r1' }, + }); + }); + + it('skips tagged raw messages with neither content nor tools', async () => { + injector(ix).register('empty_raw_test', () => ({ message: { role: 'system', content: [] } })); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(0); + }); + + it('skips a throwing step provider and still runs the rest', async () => { + injector(ix).register('step_throwing', () => { + throw new Error('boom'); + }); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); + + it('skips a rejecting step provider and still runs the rest', async () => { + injector(ix).register('step_rejecting', () => Promise.reject(new Error('boom'))); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); }); 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 0b3b2ca20..7165aebba 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -672,7 +672,7 @@ describe('Agent context', () => { ]); }); - it('removes a pre-anchor image compression reminder when undoing its prompt', async () => { + it('removes the prompt-owned image compression reminder when undoing its prompt', async () => { profile.update({ activeToolNames: [] }); const caption = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -686,8 +686,14 @@ describe('Agent context', () => { await ctx.untilTurnEnd(); expect(context.get()).toMatchObject([ - { origin: { kind: 'injection', variant: 'image_compression' } }, - { origin: { kind: 'user' } }, + { + origin: { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: expect.any(String), + }, + }, + { origin: { kind: 'user' }, id: expect.any(String) }, { role: 'assistant' }, ]); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef562..ea8155fe2 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -53,6 +53,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { publishSplice(eventBus, { start, deleteCount: 0, messages: [...inserted] }); }, appendLoopEvent: () => {}, + publishTrailingRemoval: () => false, clear: () => { const deleteCount = messages.length; if (deleteCount === 0) return; @@ -106,6 +107,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + return this.impl.publishTrailingRemoval(previous); + } undo(count: number): UndoCut { return this.impl.undo(count); } 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 69e844bbc..2aab73c8c 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -318,6 +318,44 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('holds the loop quiescence lease for the full manual compaction', async () => { + const ctx = testAgent(); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + let release!: () => void; + const canCompact = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const compactionStarted = new Promise((resolve) => { + started = resolve; + }); + const hook = ctx.get(IAgentFullCompactionService).hooks.onWillCompact.register( + 'test-quiescence', + async (_task, next) => { + started(); + await canCompact; + await next(); + }, + ); + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + + expect(ctx.get(IAgentFullCompactionService).begin({ source: 'manual' })).toBe(true); + await compactionStarted; + expect(ctx.get(IAgentLoopService).tryAcquireQuiescence()).toBeUndefined(); + + release(); + await ctx.get(IAgentFullCompactionService).compacting?.promise; + const lease = ctx.get(IAgentLoopService).tryAcquireQuiescence(); + expect(lease).toBeDefined(); + lease?.dispose(); + hook.dispose(); + }); + it('refreshes the active profile system prompt after compaction without resetting active tools', async () => { const homeDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-home-')); const workDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-work-')); @@ -1460,6 +1498,7 @@ describe('FullCompaction', () => { ctx.get(IAgentFullCompactionService).begin({ source: 'auto', instruction: undefined }); await completed; + await ctx.wire.flush(); const events = ctx.newEvents(); const compactedPrefixSizes = ctx.llmCalls.map((call) => @@ -3306,11 +3345,13 @@ describe('goal reminder re-injection after full compaction', () => { await ctx.untilTurnEnd(); expect(ctx.llmCalls.length).toBeGreaterThanOrEqual(2); - expect(goalReminderCount(ctx.llmCalls[0]!.history)).toBe(0); + // The goal reminder now enters at the first step head (before the + // overflow triggers compaction), so the summarizer request sees it too. + expect(goalReminderCount(ctx.llmCalls[0]!.history)).toBe(1); expect(goalReminderCount(ctx.llmCalls[1]!.history)).toBe(1); }); - it('counts the re-injected goal reminder into the post-compaction token floor', async () => { + it('re-injects the goal reminder at the first step after compaction', async () => { const records: TelemetryRecord[] = []; const ctx = testAgent({ telemetry: recordingTelemetry(records) }); ctx.configure({ @@ -3326,12 +3367,14 @@ describe('goal reminder re-injection after full compaction', () => { await ctx.rpc.beginCompaction({}); await completed; + // Re-injection is deferred to the next step head, so nothing is appended + // at compaction time and the token floor is exactly the compaction result. const reminderMessages = ctx.context .get() .filter( (message) => message.origin?.kind === 'injection' && message.origin.variant === 'goal', ); - expect(reminderMessages).toHaveLength(1); + expect(reminderMessages).toHaveLength(0); const tokensAfter = records.find((record) => record.event === 'compaction_finished') ?.properties?.['tokens_after']; @@ -3342,12 +3385,12 @@ describe('goal reminder re-injection after full compaction', () => { } ).lastCompactedTokenCount; expect(floor).toBe(ctx.get(IAgentTokenCountingService).get().size); - expect(floor!).toBeGreaterThan(tokensAfter as number); + expect(floor).toBe(tokensAfter); ctx.mockNextResponse({ type: 'text', text: 'Reply after compaction.' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'next prompt' }] }); await ctx.untilTurnEnd(); - expect(goalReminderCount(ctx.llmCalls.at(-1)!.history)).toBe(2); + expect(goalReminderCount(ctx.llmCalls.at(-1)!.history)).toBe(1); }); it('replays a deferred prompt whose first request carries the re-injected goal reminder', 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 07232ac97..722284b24 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -212,11 +212,13 @@ async function runGoalStep(loopService: StubLoop, turn: Turn): Promise const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -498,7 +500,10 @@ describe('AgentGoalService', () => { expect(removed.status).toBe('active'); expect(goals.getGoal()).toEqual({ goal: null }); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_cancelled', + }); expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders'); await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND }); }); @@ -899,6 +904,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -924,6 +930,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -969,6 +976,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1000,6 +1008,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1027,6 +1036,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, }); recordStepUsage(usageService, goals, oldTurn, { ...zeroUsage, output: 5 }); @@ -1052,6 +1062,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onDidFinishStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1138,6 +1149,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuationTurn.id, step: 1, + firstStepOfTurn: true, signal: continuationTurn.signal, }); recordStepUsage(usageService, goals, continuationTurn, { ...zeroUsage, output: 7 }); @@ -1301,6 +1313,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -1312,6 +1325,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1351,6 +1365,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuation.id, step: 1, + firstStepOfTurn: true, signal: continuation.signal, }); @@ -1371,6 +1386,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); await goals.markBlocked({}, 'model'); @@ -1379,6 +1395,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1502,11 +1519,13 @@ describe('AgentGoalService core workflow hooks', () => { const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -1528,6 +1547,7 @@ describe('AgentGoalService core workflow hooks', () => { const secondAfterStep: AfterStepContext = { turnId: turn.id, step: 2, + firstStepOfTurn: false, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -1998,7 +2018,7 @@ describe('AgentGoalService mid-turn budget stop', () => { const toolResultIndex = history.findIndex((message) => message.role === 'tool'); const reminderIndex = history.findIndex( (message) => - message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_budget_stop', + message.origin?.kind === 'injection' && message.origin.variant === 'goal_budget_stop', ); expect(toolResultIndex).toBeGreaterThanOrEqual(0); expect(reminderIndex).toBeGreaterThan(toolResultIndex); @@ -2301,13 +2321,37 @@ describe('AgentGoalService fork boundaries', () => { expect(goals.getGoal().goal).toBeNull(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + }); const text = JSON.stringify(reminder?.content); expect(text).toContain('This fork does not have a current goal.'); expect(text).toContain('Ignore earlier active-goal reminders from the source session.'); expect(text).toContain('Handle requests normally unless the user starts a new goal.'); }); + it('does not re-deliver a fork-cleared reminder recorded with the legacy system_trigger origin', async () => { + await restoreGoalRecords(ctx, goals, [ + { type: 'goal.create', goalId: 'source-goal', objective: 'source work' }, + { type: 'forked' }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [ + { type: 'text', text: '\nlegacy fork cleared\n' }, + ], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'goal_fork_cleared' }, + }, + }, + ]); + + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + }); + it('does not append a fork-cleared reminder when the fork had no goal', async () => { await restoreGoalRecords(ctx, goals, [{ type: 'forked' }]); 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 b23e0da32..eb877db25 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -71,10 +71,10 @@ function createInjectorStub(): IAgentContextInjectorService { } as unknown as IAgentContextInjectorService; } -function createRemindersStub(): IAgentSystemReminderService { +function createSystemReminderStub(): IAgentSystemReminderService { return { _serviceBrand: undefined, - appendSystemReminder: () => undefined, + appendSystemReminder: () => ({}), } as unknown as IAgentSystemReminderService; } @@ -124,7 +124,7 @@ function buildHost(key: string): { } as unknown as IAgentUsageService); ix.stub(IAgentContextMemoryService, createContextStub()); ix.stub(IAgentContextInjectorService, createInjectorStub()); - ix.stub(IAgentSystemReminderService, createRemindersStub()); + ix.stub(IAgentSystemReminderService, createSystemReminderStub()); ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index d5379b463..068d38727 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -17,10 +17,15 @@ import { import { stubAgentSwarm } from '../stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; -type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise }; +type InjectableContextInjector = IAgentContextInjectorService & { + inject(isNewTurn: boolean): Promise; +}; -async function injectDynamic(injector: InjectableContextInjector): Promise { - await injector.inject(); +async function injectDynamic( + injector: InjectableContextInjector, + isNewTurn: boolean, +): Promise { + await injector.inject(isNewTurn); } async function registerLookupTool( @@ -76,7 +81,7 @@ describe('GoalInjection content', () => { configure: (goals: GoalServiceTestManager) => Promise, ): Promise { await configure(goals); - await injectDynamic(injector); + await injectDynamic(injector, true); return lastGoalReminder(context); } @@ -292,7 +297,7 @@ describe('GoalInjection integration', () => { it('main-agent dynamic injection writes a context.append_message with origin.variant goal', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); + await injectDynamic(injector, true); const goalRecords = await flushedGoalReminderRecords(ctx, persistence); expect(goalRecords).toHaveLength(1); @@ -303,8 +308,8 @@ describe('GoalInjection integration', () => { it('dynamic injection writes at most once for one turn boundary', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); - await injectDynamic(injector); + await injectDynamic(injector, true); + await injectDynamic(injector, false); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(1); }); @@ -363,7 +368,7 @@ describe('GoalInjection integration', () => { }); it('writes no goal record when there is no active goal', async () => { - await injectDynamic(injector); + await injectDynamic(injector, true); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); 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 27984824f..07f36943f 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 @@ -402,6 +402,7 @@ describe('goal tools', () => { await loopService.hooks.onWillBeginStep.run({ turnId, step: 1, + firstStepOfTurn: true, signal: abortController.signal, }); } 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 3c3f1274e..888ae1a8b 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -64,6 +64,7 @@ describe('Agent loop', () => { [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "