mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-12 10:16:17 +00:00
refactor(agent-core-v2): unify model-facing reminder scheduling (#2623)
* refactor(agent-core-v2): unify model-facing reminder scheduling Route every model-facing reminder through the contextInjector boundary scheduler. Past-tense events go through a persisted once-reminder queue (reminderQueue) that delivers exactly once at turn, step, compaction, and restore boundaries; present-tense state renders through context-injection providers reconciled against live history. - interruption, goal (cancel/budget/fork-cleared), image-compression captions, btw, and init reminders enqueue into reminderQueue instead of writing the context directly; the interruptionReminder wire model is removed and its recorded type is retired silently on replay - swarm mode announcements render through a provider seeded from the replayed history on restore, replacing live side effects and the ContextModel pop reducer on swarm_mode.exit - loadable-tools announcements become an isNewTurn-gated provider, dropping the compaction boundary flag - plugin session-start guidance re-renders as a supersedes reminder at the next boundary via a dirty flag instead of appending immediately - legacy system_trigger origins of migrated reminders still fold on replay * fix(agent-core-v2): make system reminders undo-aware * test(agent-core-v2): migrate plugin session-start harness * fix(agent-core-v2): preserve reminder boundary ordering * refactor(agent-core-v2): narrow reminder and swarm helper exposure - drop the swarmInjection re-export from the package index; SwarmInjection stays a domain-internal collaborator like permissionMode/plan injections - move INTERRUPTION_REMINDER text back to a private constant in the service; only the variant stays in the Ops module - make reminderQueue.enqueue return void; no caller consumed the entry id * chore(agent-core-v2): keep comments in module headers * refactor(agent-core-v2): track reminder state via injection disclosure - derive swarm active/inactive state from ctx.lastDisclosure instead of byte-matching rendered markdown, with variant-only fallback for legacy swarm_mode/swarm_mode_exit journal entries - record once_reminder disclosure (entry id) on queue-appended messages and dedupe the crash window by the contiguous tail id set, covering multi-entry drains - move reminderQueue draining behind a sync onWillInject event so the injector no longer depends on the queue domain - centralize the system-reminder wrap format behind wrapSystemReminder / systemReminderContent and use injector-provided positions in the plugin session-start provider - spell out the step-boundary fallback and sync-only contract of registerAtTurnStart via shouldRunAtBoundary * fix(agent-core-v2): isolate failing turn-start providers and warn once per missing sessionStart skill * refactor(agent-core-v2): compute injection positions on read Drop the per-provider positions cache from the context injector: the registration scan, the context.spliced index arithmetic, and the post-restore resync all existed only to mirror what the history already records. Each provider call now derives its injected positions by scanning context memory for its surviving injection messages, so silent history edits (such as vacuous-step folds) can no longer desync a cached index. * refactor(agent-core-v2): formalize injector once-channels and raw message results * refactor(agent-core-v2): declare dynamic tool schemas at injection boundaries Move the dynamic-tool schema declaration out of toolSelect.load(): the loaded names are recorded as pending and drained by a dedicated toolSelectSchemas provider through the contextInjector boundary scheduler, so the declaration message lands at a quiescent boundary instead of mid-step inside a streaming tool exchange. The folded history remains the loaded-tool ledger, so undo, compaction, and resume still self-heal by re-folding. * refactor(agent-core-v2): deliver AGENTS.md reminders through the reminder queue The tool hook now only observes and enqueues a once-per-agent reminder through the reminderQueue once-channel instead of prepending text to the tool result: results stay verbatim for the truncation pipeline and the reminder can never be truncated away with an oversized output. The reminderQueue is resolved lazily through the instantiation service at enqueue time, breaking the contextInjector -> loop -> llmRequester -> profile -> agentsMdReminder constructor cycle. * refactor(agent-core-v2): make injection disclosures opaque and domain-owned contextMemory no longer declares the ContextInjectionDisclosure union: InjectionOrigin.disclosure becomes an opaque unknown, and providers bind their own payload type through register<D>, so lastDisclosure arrives at the provider already typed by its own variant. The date, swarm_mode, and once_reminder payload shapes move into the dateChange, swarm, and reminderQueue domains respectively; reminderQueue keeps a runtime guard for its cross-message tail scan, the only place that reads disclosures it did not write. Persisted origin shapes are byte-identical, so existing journals replay unchanged. * fix(agent-core-v2): isolate failing step context providers A step or compaction boundary provider that threw or rejected made the injector's inject() promise reject, which propagated through the onWillBeginStep hook chain and failed the whole turn, and starved every provider registered after it. Log and skip the bad provider instead, matching the turn-start path's existing isolation. * refactor(agent-core-v2): derive injector isNewTurn per injection boundary Replace the shared read-and-clear isNewTurn flag (set by turn.started and injectAfterCompaction, consumed by the first inject()) with values each trigger supplies from an authoritative source: the loop marks a turn's first step via BeforeStepContext.firstStepOfTurn (standalone runs never count), and the compaction follow-up passes true explicitly, so interleaved triggers can no longer consume or steal the marker. A compaction follow-up that lands inside a step hook chain (the auto-compaction path) doubles as that step's new-turn delivery: the enclosing step then injects with isNewTurn false, so the upcoming request receives one new-turn injection, not two. * refactor(agent-core-v2): unify disclosure placement and injector param naming * fix(agent-core-v2): keep pending tool schemas across compaction splices A load announced by select_tools sits in pendingLoaded until the next injection boundary declares it. A compaction fold in that window publishes a replacement splice, and the splice-time reconciliation dropped the pending entries before the post-compaction inject could declare them — the model was told "Loaded: X" yet X never became available. Drop pending entries only on removal splices (undo/clear, which carry no replacement messages); compaction's replacement splice keeps them so the declaration lands at the post-compaction boundary. * fix(agent-core-v2): consume the plugin session-start refresh after a successful render reconcileSessionStartReminder cleared the refresh-pending flag before awaiting the render, so a throwing render (skipped by the injector's provider isolation) lost the forced refresh until the next catalog change. Consume the flag only after the render resolves, and move the warn-once rationale into the module header per the comment convention. * refactor(agent-core-v2): remove the generic reminder queue * chore(agent-core-v2): drop the stale reminder-queue mention in systemReminder * test(node-sdk): align side-question fork parity with event-point reminders * chore(agent-core-v2): address reminder review standards * docs(agent-core-v2): condense the model-facing reminders section * refactor(agent-core-v2): write all system reminders through wrapSystemReminder * fix(agent-core-v2): preserve reminder lifecycle invariants * refactor(agent-core-v2): reconcile context injections at the step head Unify the injector's delivery timings into one point on the onWillBeginStep chain, before the step's request is built: - providers run before every request instead of after every step, so reminders are visible from the first response of a turn - a compaction splice re-arms the new-turn flag via context.spliced; when compaction runs inside the hook chain (full-compaction's beforeStep), a follow-up inject at the chain tail keeps the first post-compaction request covered - registerAtTurnStart and injectAfterCompaction are removed; reconcileWhenIdle stays as the v1-parity surface for SDK-driven triggers (swarm toggle, plugin reload) * refactor(agent-core-v2): clarify the injector's step-hook handler Name the handler reconcileAroundStep, rename the rearm flag to compactionRearmPending with a single takeCompactionRearm() consumer, and extract isCompactionSplice. Consuming the flag into a local before computing isNewTurn also avoids hiding the side effect inside a || short-circuit.
This commit is contained in:
parent
43c68f58f5
commit
f40bf04998
81 changed files with 2221 additions and 1110 deletions
|
|
@ -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: '<domain_fact>' }`, 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/`.
|
||||
|
|
|
|||
32
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
32
packages/agent-core-v2/docs/state-manifest.d.ts
vendored
|
|
@ -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<string>;
|
||||
'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<string, /* ToolCallDupType — packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts */ 'same_step' | 'cross_step'>;
|
||||
// src/agent/toolSelect/toolSelectAnnouncementsService.ts
|
||||
'toolSelect.needsBoundaryInjection': boolean;
|
||||
// src/agent/toolSelect/toolSelectService.ts
|
||||
'toolSelect.pendingLoaded': Set<string>;
|
||||
// src/agent/usage/usageService.ts
|
||||
|
|
|
|||
111
packages/agent-core-v2/docs/wire-manifest.d.ts
vendored
111
packages/agent-core-v2/docs/wire-manifest.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* `<system-reminder>` 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<void>): Promise<void> => {
|
||||
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<ExecutableToolResult> {
|
||||
if (ctx.outcome !== 'executed') return ctx.result;
|
||||
private async probeAndRemind(ctx: ToolDidExecuteContext): Promise<void> {
|
||||
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 (
|
||||
'<system-reminder>\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</system-reminder>\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,
|
||||
|
|
|
|||
|
|
@ -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<D = unknown> {
|
||||
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<D = unknown> {
|
||||
readonly content: ContextInjectionContent;
|
||||
readonly disclosure?: D;
|
||||
}
|
||||
|
||||
export type ContextInjectionProvider<D = unknown> = (
|
||||
context: ContextInjectionContext<D>,
|
||||
) =>
|
||||
| ContextInjectionContent
|
||||
| ContextInjectionResult
|
||||
| ContextInjectionResult<D>
|
||||
| undefined
|
||||
| Promise<ContextInjectionContent | ContextInjectionResult | undefined>;
|
||||
| Promise<ContextInjectionContent | ContextInjectionResult<D> | undefined>;
|
||||
|
||||
export interface IAgentContextInjectorService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
register(
|
||||
register<D = unknown>(
|
||||
name: string,
|
||||
provider: ContextInjectionProvider,
|
||||
provider: ContextInjectionProvider<D>,
|
||||
): IDisposable;
|
||||
|
||||
injectAfterCompaction(): Promise<void>;
|
||||
reconcileWhenIdle(name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IAgentContextInjectorService = createDecorator<IAgentContextInjectorService>(
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
readonly name: string;
|
||||
readonly positions: number[];
|
||||
}
|
||||
|
||||
export const contextInjectorIsNewTurnKey = defineState<boolean>(
|
||||
'contextInjector.isNewTurn',
|
||||
() => true,
|
||||
);
|
||||
|
||||
export class AgentContextInjectorService extends Service implements IAgentContextInjectorService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly entries = new Set<ContextInjectionEntry>();
|
||||
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<D = unknown>(
|
||||
name: string,
|
||||
provider: ContextInjectionProvider,
|
||||
) {
|
||||
const positions = findInjections(this.context.get(), name);
|
||||
provider: ContextInjectionProvider<D>,
|
||||
): IDisposable {
|
||||
const entry: ContextInjectionEntry = {
|
||||
provider,
|
||||
provider: provider as ContextInjectionProvider<unknown>,
|
||||
name,
|
||||
positions,
|
||||
};
|
||||
this.entries.add(entry);
|
||||
return toDisposable(() => {
|
||||
|
|
@ -105,101 +75,148 @@ export class AgentContextInjectorService extends Service implements IAgentContex
|
|||
});
|
||||
}
|
||||
|
||||
async injectAfterCompaction(): Promise<void> {
|
||||
this.isNewTurn = true;
|
||||
await this.inject();
|
||||
async reconcileWhenIdle(name: string): Promise<void> {
|
||||
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<void> {
|
||||
const isNewTurn = this.isNewTurn;
|
||||
this.isNewTurn = false;
|
||||
const history = this.context.get();
|
||||
private async reconcileAroundStep(
|
||||
ctx: BeforeStepContext,
|
||||
next: (context?: BeforeStepContext) => Promise<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
let content: Awaited<ReturnType<ContextInjectionProvider>>;
|
||||
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<unknown> {
|
||||
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<unknown> | undefined,
|
||||
): void {
|
||||
if (content === undefined) return;
|
||||
const result: ContextInjectionResult<unknown> = 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<string, number[]> | 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<ContextInjectionContent, string>,
|
||||
): content is { readonly message: ContextInjectionMessage } {
|
||||
return !Array.isArray(content);
|
||||
}
|
||||
|
||||
function isInjectionResult(
|
||||
content: ContextInjectionContent | ContextInjectionResult<unknown>,
|
||||
): content is ContextInjectionResult<unknown> {
|
||||
return (
|
||||
typeof content === 'object' &&
|
||||
content !== null &&
|
||||
!Array.isArray(content) &&
|
||||
'content' in content
|
||||
);
|
||||
}
|
||||
|
||||
function findInjections(
|
||||
history: readonly ContextMessage[],
|
||||
|
|
|
|||
|
|
@ -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<K extends ContextInjectionDisclosure['kind']>(
|
||||
disclosure: ContextInjectionDisclosure | undefined,
|
||||
kind: K,
|
||||
): Extract<ContextInjectionDisclosure, { kind: K }> | undefined {
|
||||
return disclosure?.kind === kind
|
||||
? (disclosure as Extract<ContextInjectionDisclosure, { kind: K }>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function pickDisclosureBaseline<T extends { readonly renderGeneration: number }>(
|
||||
...candidates: readonly (T | undefined)[]
|
||||
): T | undefined {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
'<system-reminder>',
|
||||
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.`,
|
||||
'</system-reminder>',
|
||||
].join('\n');
|
||||
);
|
||||
}
|
||||
|
||||
export function collectCompactableUserMessages<T extends MessageLike>(messages: readonly T[]): T[] {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export interface IAgentContextMemoryService {
|
|||
|
||||
appendLoopEvent(event: LoopRecordedEvent): void;
|
||||
|
||||
publishTrailingRemoval(previous: readonly ContextMessage[]): boolean;
|
||||
|
||||
clear(): void;
|
||||
|
||||
undo(count: number): UndoCut;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ContextMessage[]>('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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Checkpointed<unknown>>[] = [];
|
|||
|
||||
export interface CheckpointModelOptions<T> {
|
||||
readonly onAppendMessage?: (current: T, message: ContextMessage) => T;
|
||||
readonly reducers?: ModelReducers<Checkpointed<T>>;
|
||||
}
|
||||
|
||||
export function defineCheckpointedModel<T>(
|
||||
|
|
@ -55,11 +57,13 @@ export function defineCheckpointedModel<T>(
|
|||
initial: () => T,
|
||||
opts?: CheckpointModelOptions<T>,
|
||||
): ModelDef<Checkpointed<T>> {
|
||||
const customReducers = opts?.reducers ?? {};
|
||||
const def = defineModel<Checkpointed<T>>(
|
||||
name,
|
||||
() => ({ current: initial(), checkpoints: [] }),
|
||||
{
|
||||
reducers: {
|
||||
...customReducers,
|
||||
'context.append_message': (state, { message }) => {
|
||||
if (isUndoAnchor(message)) {
|
||||
return { ...state, checkpoints: [...state.checkpoints, state.current] };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DateInjectionDisclosure>(
|
||||
DATE_CHANGE_INJECTION_VARIANT,
|
||||
(ctx) => this.reminder(ctx),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private reminder({
|
||||
lastDisclosure,
|
||||
}: ContextInjectionContext): ContextInjectionResult | undefined {
|
||||
}: ContextInjectionContext<DateInjectionDisclosure>): ContextInjectionResult<DateInjectionDisclosure> | 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<DateDisclosure>(
|
||||
disclosureOfKind(lastDisclosure, 'date'),
|
||||
lastDisclosure,
|
||||
this.dateFromProfile(),
|
||||
this.states.get(dateChangeSeedKey),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<GoalForkNoticeState>(
|
|||
);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<readonly number[]>(
|
||||
export const INTERRUPTION_REMINDER_VARIANT = 'interruption';
|
||||
|
||||
export type InterruptionReminderState = null;
|
||||
|
||||
export const InterruptionReminderModel = defineModel<InterruptionReminderState>(
|
||||
'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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StepExecutionResult> {
|
||||
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<boolean> {
|
||||
const context: AfterStepContext = {
|
||||
turnId,
|
||||
step: currentStep,
|
||||
firstStepOfTurn,
|
||||
signal,
|
||||
usage,
|
||||
finishReason,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<PermissionMode | undefined>
|
|||
export class PermissionModeInjection extends Service {
|
||||
constructor(
|
||||
private readonly permissionMode: Pick<IAgentPermissionModeService, 'mode'>,
|
||||
@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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
|
|||
|
||||
export interface IAgentPluginService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
refreshSessionStart(): Promise<void>;
|
||||
}
|
||||
|
||||
export const IAgentPluginService: ServiceIdentifier<IAgentPluginService> =
|
||||
|
|
|
|||
38
packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts
Normal file
38
packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts
Normal file
|
|
@ -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<PluginSessionStartSnapshotState>(
|
||||
'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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
|
@ -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<boolean>(
|
||||
'agentPlugin.sessionStartRefreshPending',
|
||||
() => false,
|
||||
);
|
||||
|
||||
export class AgentPluginService extends Service implements IAgentPluginService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly warnedMissingSessionStartSkills = new Set<string>();
|
||||
|
||||
// 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<void> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string>;
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SwarmModeInjectionDisclosure>(
|
||||
SWARM_MODE_INJECTION_VARIANT,
|
||||
(ctx) => this.reminder(ctx),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private reminder(
|
||||
ctx: ContextInjectionContext<SwarmModeInjectionDisclosure>,
|
||||
): ContextInjectionResult<SwarmModeInjectionDisclosure> | 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<SwarmModeInjectionDisclosure>,
|
||||
): '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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* `<system-reminder>` 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 = '<system-reminder>\n';
|
||||
const SYSTEM_REMINDER_SUFFIX = '\n</system-reminder>';
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: `<system-reminder>\n${content.trim()}\n</system-reminder>`,
|
||||
text: wrapSystemReminder(content),
|
||||
},
|
||||
],
|
||||
toolCalls: [],
|
||||
|
|
|
|||
|
|
@ -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<system-reminder>\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</system-reminder>';
|
||||
'\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<system-reminder>\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</system-reminder>'
|
||||
'\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<system-reminder>\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</system-reminder>';
|
||||
'\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;
|
||||
|
|
|
|||
|
|
@ -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: `<tools_added>/<tools_removed>` 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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<boolean>(
|
||||
'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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IAgentToolSelectSchemasService> =
|
||||
createDecorator<IAgentToolSelectSchemasService>('agentToolSelectSchemasService');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const planWasActiveKey = defineState<boolean>('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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<ExecutableToolResult> {
|
||||
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<string> {
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>')).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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>'),
|
||||
);
|
||||
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('<system-reminder>');
|
||||
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('<system-reminder>')).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('<system-reminder>')).toBeLessThan(2_000);
|
||||
expect(text).toContain(subAgentsMd);
|
||||
expect(text).not.toContain('<system-reminder>');
|
||||
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('<system-reminder>');
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
await runWillBeginStepHooks(loop);
|
||||
async function runInjectionStep(firstStepOfTurn = false): Promise<void> {
|
||||
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<readonly number[]> = [];
|
||||
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const compactionStarted = new Promise<void>((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 () => {
|
||||
|
|
|
|||
|
|
@ -212,11 +212,13 @@ async function runGoalStep(loopService: StubLoop, turn: Turn): Promise<boolean>
|
|||
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: '<system-reminder>\nlegacy fork cleared\n</system-reminder>' },
|
||||
],
|
||||
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' }]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -17,10 +17,15 @@ import {
|
|||
import { stubAgentSwarm } from '../stubs';
|
||||
|
||||
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
|
||||
type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise<void> };
|
||||
type InjectableContextInjector = IAgentContextInjectorService & {
|
||||
inject(isNewTurn: boolean): Promise<void>;
|
||||
};
|
||||
|
||||
async function injectDynamic(injector: InjectableContextInjector): Promise<void> {
|
||||
await injector.inject();
|
||||
async function injectDynamic(
|
||||
injector: InjectableContextInjector,
|
||||
isNewTurn: boolean,
|
||||
): Promise<void> {
|
||||
await injector.inject(isNewTurn);
|
||||
}
|
||||
|
||||
async function registerLookupTool(
|
||||
|
|
@ -76,7 +81,7 @@ describe('GoalInjection content', () => {
|
|||
configure: (goals: GoalServiceTestManager) => Promise<void>,
|
||||
): Promise<string | undefined> {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@ describe('goal tools', () => {
|
|||
await loopService.hooks.onWillBeginStep.run({
|
||||
turnId,
|
||||
step: 1,
|
||||
firstStepOfTurn: true,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
|
|
@ -118,6 +119,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": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
|
|
@ -337,6 +339,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": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
|
|
@ -578,6 +581,7 @@ describe('Agent loop', () => {
|
|||
it('holds new admissions until an idle quiescence lease is released', async () => {
|
||||
const lease = loop.tryAcquireQuiescence();
|
||||
expect(lease).toBeDefined();
|
||||
expect(loop.tryAcquireQuiescence()).toBeUndefined();
|
||||
const held = loop.enqueue(nextTurnMessage('held'));
|
||||
let assigned = false;
|
||||
void held.assigned.then(() => {
|
||||
|
|
@ -997,14 +1001,14 @@ describe('interruption reminder', () => {
|
|||
).length;
|
||||
}
|
||||
|
||||
it('preserves the partial stream and appends one reminder on user cancel', async () => {
|
||||
it('preserves the partial stream and appends one reminder at the cancellation event point', async () => {
|
||||
ctx.mockNextResponse({ type: 'text', text: 'partial answer' }, { type: 'text', text: ' more' });
|
||||
const subscription = cancelOnFirstDelta();
|
||||
const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn;
|
||||
await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' });
|
||||
subscription.dispose();
|
||||
|
||||
expect(ctx.contextData().history).toEqual([
|
||||
expect(ctx.contextData().history.slice(0, 2)).toEqual([
|
||||
expect.objectContaining({ role: 'user', content: [{ type: 'text', text: 'Hello' }] }),
|
||||
{
|
||||
role: 'assistant',
|
||||
|
|
@ -1012,18 +1016,8 @@ describe('interruption reminder', () => {
|
|||
toolCalls: [],
|
||||
partial: true,
|
||||
},
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
origin: { kind: 'injection', variant: 'interruption' },
|
||||
}),
|
||||
]);
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
expect(interruptionReminders()[0]!.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n</system-reminder>',
|
||||
},
|
||||
]);
|
||||
|
||||
const cancelRecord = ctx.allEvents.find(
|
||||
(entry) => entry.type === '[wire]' && entry.event === 'turn.cancel',
|
||||
|
|
@ -1041,6 +1035,19 @@ describe('interruption reminder', () => {
|
|||
interruptReason: 'user_cancelled',
|
||||
});
|
||||
expect(contentPartRecordsIn(ctx)).toBe(1);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
expect(interruptionReminders()[0]!.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n</system-reminder>',
|
||||
},
|
||||
]);
|
||||
expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(2);
|
||||
});
|
||||
|
||||
it('writes one active cancellation when cancel repeats before the turn settles', async () => {
|
||||
|
|
@ -1055,20 +1062,16 @@ describe('interruption reminder', () => {
|
|||
const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn;
|
||||
await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' });
|
||||
subscription.dispose();
|
||||
await ctx.wire.flush();
|
||||
|
||||
expect(results).toEqual([true, true]);
|
||||
expect(
|
||||
ctx.allEvents.filter(
|
||||
(entry) => entry.type === '[wire]' && entry.event === 'turn.cancel',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
expect(
|
||||
ctx.allEvents.filter(
|
||||
(entry) => entry.type === '[wire]' && entry.event === 'interruptionReminder.recorded',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves the partial stream but appends no reminder on programmatic abort', async () => {
|
||||
|
|
@ -1113,15 +1116,27 @@ describe('interruption reminder', () => {
|
|||
interruptReason: 'user_cancelled',
|
||||
});
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('appends no reminder when a queued turn is user-cancelled before starting', async () => {
|
||||
let release!: () => void;
|
||||
let armed = true;
|
||||
let signalEntered!: () => void;
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
signalEntered = resolve;
|
||||
});
|
||||
loop.hooks.onWillBeginStep.register('test-hang-queued-cancel', async (hookCtx, next) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
if (armed) {
|
||||
armed = false;
|
||||
signalEntered();
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
await next();
|
||||
});
|
||||
ctx.mockNextResponse({ type: 'text', text: 'unreached' });
|
||||
|
|
@ -1130,6 +1145,7 @@ describe('interruption reminder', () => {
|
|||
const queued = (await loop.enqueue(nextTurnMessage('queued')).assigned).turn;
|
||||
expect(loop.cancel(queued.id)).toBe(true);
|
||||
await expect(queued.result).resolves.toMatchObject({ type: 'cancelled', steps: 0 });
|
||||
await entered;
|
||||
release();
|
||||
loop.cancel(active.id);
|
||||
await expect(active.result).resolves.toMatchObject({ type: 'cancelled' });
|
||||
|
|
@ -1142,9 +1158,14 @@ describe('interruption reminder', () => {
|
|||
(entry.args as { target?: string }).target === 'queued',
|
||||
);
|
||||
expect(queuedCancel?.args).toMatchObject({ target: 'queued', reason: 'user_cancelled' });
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends the partial output and reminder ahead of the next user message', async () => {
|
||||
it('sends the partial output and reminder in the next atomic step', async () => {
|
||||
ctx.mockNextResponse({ type: 'text', text: 'partial answer' }, { type: 'text', text: ' more' });
|
||||
const subscription = cancelOnFirstDelta();
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
|
||||
|
|
@ -1165,7 +1186,7 @@ describe('interruption reminder', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
it('removes the reminder together with the undone turn', async () => {
|
||||
it('undo removes the event-point interruption with its cancelled turn', async () => {
|
||||
ctx.mockNextResponse({ type: 'text', text: 'partial answer' });
|
||||
const subscription = cancelOnFirstDelta();
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] });
|
||||
|
|
@ -1176,6 +1197,11 @@ describe('interruption reminder', () => {
|
|||
await ctx.undoHistory(1);
|
||||
|
||||
expect(ctx.contextData().history).toEqual([]);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves partial thinking on user cancel', async () => {
|
||||
|
|
@ -1204,11 +1230,11 @@ describe('interruption reminder', () => {
|
|||
subscription.dispose();
|
||||
|
||||
expect(contentPartRecordsIn(ctx)).toBe(0);
|
||||
expect(ctx.contextData().history).toEqual([
|
||||
expect(ctx.contextData().history.slice(0, 2)).toEqual([
|
||||
expect.objectContaining({ role: 'user' }),
|
||||
{ role: 'assistant', content: [], toolCalls: [], partial: true },
|
||||
expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }),
|
||||
]);
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not stack a second reminder around a vacuous retry turn', async () => {
|
||||
|
|
@ -1234,6 +1260,30 @@ describe('interruption reminder', () => {
|
|||
expect(interruptionReminders()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders a new interruption reminder after an intervening completed turn', async () => {
|
||||
ctx.mockNextResponse({ type: 'text', text: 'first partial answer' });
|
||||
const first = cancelOnFirstDelta();
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
first.dispose();
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'completed answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'completed prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(1);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second partial answer' });
|
||||
const second = cancelOnFirstDelta();
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
second.dispose();
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'final answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'final prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(interruptionReminders()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not duplicate recorded content when cancelled during tool execution', async () => {
|
||||
const local = createTestAgent(permissionModeServices('yolo'));
|
||||
try {
|
||||
|
|
@ -1253,11 +1303,20 @@ describe('interruption reminder', () => {
|
|||
await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' });
|
||||
|
||||
expect(contentPartRecordsIn(local)).toBe(2);
|
||||
expect(remindersIn(local)).toHaveLength(1);
|
||||
|
||||
local.mockNextResponse({ type: 'text', text: 'follow-up answer' });
|
||||
await local.rpc.prompt({ input: [{ type: 'text', text: 'again' }] });
|
||||
await local.untilTurnEnd();
|
||||
|
||||
const history = local.contextData().history;
|
||||
expect(remindersIn(local)).toHaveLength(1);
|
||||
expect(history.at(-1)?.origin).toEqual({ kind: 'injection', variant: 'interruption' });
|
||||
expect(history.at(-2)?.role).toBe('tool');
|
||||
const reminderIndex = history.indexOf(remindersIn(local)[0]!);
|
||||
expect(history.slice(0, reminderIndex).some((message) => message.role === 'tool')).toBe(true);
|
||||
expect(history[reminderIndex + 1]).toMatchObject({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'again' }],
|
||||
});
|
||||
|
||||
await local.expectResumeMatches();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -79,10 +79,14 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop {
|
|||
};
|
||||
return stub;
|
||||
}
|
||||
export async function runWillBeginStepHooks(loop: IAgentLoopService): Promise<void> {
|
||||
export async function runWillBeginStepHooks(
|
||||
loop: IAgentLoopService,
|
||||
firstStepOfTurn = false,
|
||||
): Promise<void> {
|
||||
await loop.hooks.onWillBeginStep.run({
|
||||
turnId: 0,
|
||||
step: 0,
|
||||
firstStepOfTurn,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ describe('AgentMcpService', () => {
|
|||
const loop = ix.get(IAgentLoopService);
|
||||
let settled = false;
|
||||
const step = loop.hooks.onWillBeginStep
|
||||
.run({ turnId: 1, step: 1, signal: new AbortController().signal })
|
||||
.run({ turnId: 1, step: 1, firstStepOfTurn: true, signal: new AbortController().signal })
|
||||
.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,14 +36,14 @@ let registeredInjection:
|
|||
const injectorStub: IAgentContextInjectorService = {
|
||||
_serviceBrand: undefined,
|
||||
register: (name, provider) => {
|
||||
registeredInjection = { name, provider };
|
||||
registeredInjection = { name, provider: provider as ContextInjectionProvider };
|
||||
return {
|
||||
dispose: () => {
|
||||
if (registeredInjection?.provider === provider) registeredInjection = undefined;
|
||||
},
|
||||
};
|
||||
},
|
||||
injectAfterCompaction: async () => {},
|
||||
reconcileWhenIdle: async () => {},
|
||||
};
|
||||
|
||||
let disposables: DisposableStore;
|
||||
|
|
@ -177,10 +177,9 @@ describe('AgentPermissionModeService (wire-backed)', () => {
|
|||
ix2.stub(IAgentContextInjectorService, {
|
||||
_serviceBrand: undefined,
|
||||
register: (_name, provider) => {
|
||||
restoredProvider = provider;
|
||||
restoredProvider = provider as ContextInjectionProvider;
|
||||
return { dispose: () => {} };
|
||||
},
|
||||
injectAfterCompaction: async () => {},
|
||||
});
|
||||
ix2.set(IAgentStateService, new AgentStateService());
|
||||
disposables.add(ix2.createInstance(PermissionModeInjection, svc));
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
|
|||
import { Emitter } from '#/_base/event';
|
||||
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
|
||||
import { AgentPluginService } from '#/agent/plugin/agentPluginService';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import type {
|
||||
|
|
@ -28,6 +28,7 @@ import type { SkillDefinition } from '#/app/skillCatalog/types';
|
|||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
|
||||
import { agentService, appService, createTestAgent, skillServices, type TestAgentContext } from '../../harness';
|
||||
import { stubPluginService } from '../../app/plugin/stubs';
|
||||
|
||||
function pluginSkill(): SkillDefinition {
|
||||
return {
|
||||
|
|
@ -42,40 +43,6 @@ function pluginSkill(): SkillDefinition {
|
|||
};
|
||||
}
|
||||
|
||||
interface PluginServiceStubOptions {
|
||||
readonly sessionStarts: readonly EnabledPluginSessionStart[];
|
||||
readonly reloadEmitter?: Emitter<ReloadSummary>;
|
||||
readonly mutateEmitter?: Emitter<PluginMutationSummary>;
|
||||
}
|
||||
|
||||
function pluginServiceStub(options: PluginServiceStubOptions): IPluginService {
|
||||
const reloadEmitter = options.reloadEmitter;
|
||||
const mutateEmitter = options.mutateEmitter;
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }),
|
||||
onDidMutate: mutateEmitter !== undefined ? mutateEmitter.event : () => ({ dispose: () => {} }),
|
||||
listPlugins: async () => [],
|
||||
installPlugin: async () => ({ id: '' }) as never,
|
||||
setPluginEnabled: async () => {},
|
||||
setPluginMcpServerEnabled: async () => {},
|
||||
removePlugin: async () => {},
|
||||
reloadPlugins: async (): Promise<ReloadSummary> => ({ added: [], removed: [], errors: [] }),
|
||||
getPluginInfo: async () => {
|
||||
throw new Error('getPluginInfo is not used by these tests');
|
||||
},
|
||||
listPluginCommands: async () => [],
|
||||
checkUpdates: async () => [],
|
||||
pluginSkillRoots: async () => [],
|
||||
pluginAgentRoots: async () => [],
|
||||
enabledSessionStarts: async () => options.sessionStarts,
|
||||
enabledSystemPrompts: async () => [],
|
||||
enabledMcpServers: async () => ({}),
|
||||
enabledHooks: async () => [],
|
||||
hasLoadedSnapshot: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
function findPluginSessionStartMessages(ctx: TestAgentContext) {
|
||||
return ctx.contextData().history.filter(
|
||||
(message) =>
|
||||
|
|
@ -83,29 +50,17 @@ function findPluginSessionStartMessages(ctx: TestAgentContext) {
|
|||
);
|
||||
}
|
||||
|
||||
function waitForPluginSessionStartMessage(ctx: TestAgentContext): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const subscription = ctx.get(IEventBus).subscribe('context.spliced', (event) => {
|
||||
if (
|
||||
event.messages.some(
|
||||
(message) =>
|
||||
message.origin?.kind === 'injection' &&
|
||||
message.origin.variant === 'plugin_session_start',
|
||||
)
|
||||
) {
|
||||
subscription.dispose();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function messageText(message: { readonly content: readonly { readonly type: string; readonly text?: string }[] }): string {
|
||||
return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('');
|
||||
}
|
||||
|
||||
async function injectRegistered(ctx: TestAgentContext): Promise<void> {
|
||||
await (ctx.get(IAgentContextInjectorService) as unknown as { inject(): Promise<void> }).inject();
|
||||
async function runInjectionBoundary(ctx: TestAgentContext): Promise<void> {
|
||||
await ctx.get(IAgentLoopService).hooks.onWillBeginStep.run({
|
||||
turnId: 0,
|
||||
step: 1,
|
||||
firstStepOfTurn: true,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentPluginService plugin session-start wiring', () => {
|
||||
|
|
@ -124,7 +79,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
|
||||
stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
|
||||
),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
|
|
@ -135,7 +90,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
const injected = findPluginSessionStartMessages(ctx).at(-1);
|
||||
expect(injected).toBeDefined();
|
||||
|
|
@ -153,7 +108,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
|
||||
stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
|
||||
),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
|
|
@ -164,24 +119,62 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
ctx.get(IEventBus).publish({
|
||||
type: 'turn.started',
|
||||
turnId: 2,
|
||||
origin: USER_PROMPT_ORIGIN,
|
||||
});
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes the frozen session-start guidance through the explicit service path', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
stubPluginService({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
}),
|
||||
),
|
||||
skillServices(catalog),
|
||||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
|
||||
const plugins = ctx.get(IAgentPluginService);
|
||||
await runInjectionBoundary(ctx);
|
||||
expect(messageText(findPluginSessionStartMessages(ctx).at(-1)!)).toContain(
|
||||
'Do the demo thing.',
|
||||
);
|
||||
|
||||
catalog.register(
|
||||
{ ...pluginSkill(), content: 'Do the explicitly refreshed demo thing.' },
|
||||
{ replace: true },
|
||||
);
|
||||
await plugins.refreshSessionStart();
|
||||
|
||||
const messages = findPluginSessionStartMessages(ctx);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messageText(messages.at(-1)!)).toContain(
|
||||
'Do the explicitly refreshed demo thing.',
|
||||
);
|
||||
expect(messageText(messages.at(-1)!)).toContain(
|
||||
'supersedes any earlier plugin_session_start reminder',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not inject when no plugin session starts are enabled', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(IPluginService, pluginServiceStub({ sessionStarts: [] })),
|
||||
appService(IPluginService, stubPluginService({ sessionStarts: [] })),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
IAgentPluginService,
|
||||
|
|
@ -191,7 +184,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -214,7 +207,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({
|
||||
stubPluginService({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
}),
|
||||
),
|
||||
|
|
@ -227,13 +220,13 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
const appended = waitForPluginSessionStartMessage(ctx);
|
||||
sinkChange.fire('plugin');
|
||||
await appended;
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
const messages = findPluginSessionStartMessages(ctx);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
|
@ -261,7 +254,7 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({
|
||||
stubPluginService({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
}),
|
||||
),
|
||||
|
|
@ -274,17 +267,70 @@ describe('AgentPluginService plugin session-start wiring', () => {
|
|||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
const appended = waitForPluginSessionStartMessage(ctx);
|
||||
sinkChange.fire('user');
|
||||
sinkChange.fire('plugin');
|
||||
await appended;
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(2);
|
||||
sinkChange.dispose();
|
||||
});
|
||||
|
||||
it('reconciles the current plugin guidance after undo removes its latest render', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
const sinkChange = new Emitter<string>();
|
||||
const skillCatalog: ISessionSkillCatalog = {
|
||||
_serviceBrand: undefined,
|
||||
catalog,
|
||||
ready: Promise.resolve(),
|
||||
onDidChange: sinkChange.event,
|
||||
load: async () => {},
|
||||
reload: async () => {},
|
||||
list: async () => catalog.listSkills().map(summarizeSkill),
|
||||
};
|
||||
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
stubPluginService({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
}),
|
||||
),
|
||||
skillServices(skillCatalog),
|
||||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'first answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
catalog.register(
|
||||
{ ...pluginSkill(), content: 'Do the updated demo thing.' },
|
||||
{ replace: true },
|
||||
);
|
||||
sinkChange.fire('plugin');
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
await ctx.undoHistory(1);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'third answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'third prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
const latest = findPluginSessionStartMessages(ctx).at(-1);
|
||||
expect(latest).toBeDefined();
|
||||
expect(messageText(latest!)).toContain('Do the updated demo thing.');
|
||||
expect(messageText(latest!)).toContain(
|
||||
'supersedes any earlier plugin_session_start reminder',
|
||||
);
|
||||
sinkChange.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentPluginService plugin-change reminder', () => {
|
||||
|
|
@ -306,7 +352,7 @@ describe('AgentPluginService plugin-change reminder', () => {
|
|||
const mutateEmitter = new Emitter<PluginMutationSummary>();
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(IPluginService, pluginServiceStub({ sessionStarts: [], mutateEmitter })),
|
||||
appService(IPluginService, stubPluginService({ sessionStarts: [], mutateEmitter })),
|
||||
skillServices(new InMemorySkillCatalog()),
|
||||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
|
|
@ -330,7 +376,7 @@ describe('AgentPluginService plugin-change reminder', () => {
|
|||
const reloadEmitter = new Emitter<ReloadSummary>();
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(IPluginService, pluginServiceStub({ sessionStarts: [], reloadEmitter })),
|
||||
appService(IPluginService, stubPluginService({ sessionStarts: [], reloadEmitter })),
|
||||
skillServices(new InMemorySkillCatalog()),
|
||||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
|
|
@ -369,36 +415,41 @@ describe('AgentPluginService plugin-change reminder', () => {
|
|||
catalog.register(pluginSkill());
|
||||
const sinkChange = new Emitter<string>();
|
||||
const mutateEmitter = new Emitter<PluginMutationSummary>();
|
||||
let sessionStarts: readonly EnabledPluginSessionStart[] = [
|
||||
{ pluginId: 'demo', skillName: 'demo-skill' },
|
||||
];
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
mutateEmitter,
|
||||
}),
|
||||
{
|
||||
...stubPluginService({ sessionStarts, mutateEmitter }),
|
||||
enabledSessionStarts: async () => sessionStarts,
|
||||
},
|
||||
),
|
||||
skillServices(skillCatalogWithChange(catalog, sinkChange)),
|
||||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
ctx.get(IAgentPluginService);
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
// Production ordering: onDidMutate fires synchronously inside the
|
||||
// mutation's onDidReload; the catalog change arrives after the async
|
||||
// re-scan.
|
||||
fireMutation(mutateEmitter, 'demo');
|
||||
sessionStarts = [];
|
||||
sinkChange.fire('plugin');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await runInjectionBoundary(ctx);
|
||||
|
||||
expect(findPluginChangeMessages(ctx)).toHaveLength(1);
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
// An explicit reload (no mutation) still refreshes the guidance.
|
||||
const appended = waitForPluginSessionStartMessage(ctx);
|
||||
// An explicit reload (no mutation) still refreshes the guidance: the
|
||||
// catalog change sets the refresh signal and the next injection boundary
|
||||
// re-renders with the supersedes suffix.
|
||||
sinkChange.fire('plugin');
|
||||
await appended;
|
||||
await runInjectionBoundary(ctx);
|
||||
expect(findPluginSessionStartMessages(ctx).length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
sinkChange.dispose();
|
||||
|
|
@ -414,7 +465,7 @@ describe('AgentPluginService plugin-change reminder', () => {
|
|||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({
|
||||
stubPluginService({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }],
|
||||
mutateEmitter,
|
||||
}),
|
||||
|
|
@ -423,7 +474,7 @@ describe('AgentPluginService plugin-change reminder', () => {
|
|||
agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)),
|
||||
);
|
||||
ctx.get(IAgentPluginService);
|
||||
await injectRegistered(ctx);
|
||||
await runInjectionBoundary(ctx);
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
fireMutation(mutateEmitter, 'demo');
|
||||
|
|
|
|||
|
|
@ -134,6 +134,33 @@ describe('AgentPromptService', () => {
|
|||
await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' });
|
||||
});
|
||||
|
||||
it('delivers a blocked prompt’s compression captions right after their host message', async () => {
|
||||
const { prompt, context } = harness();
|
||||
prompt.hooks.onBeforeSubmitPrompt.register('block', async (ctx, next) => { ctx.block = true; await next(); });
|
||||
const handle = await prompt.enqueue({
|
||||
id: 'prompt-caption',
|
||||
message: message(
|
||||
'<system>Image compressed to fit model limits: 800x600</system>look at this',
|
||||
),
|
||||
});
|
||||
await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' });
|
||||
|
||||
const history = context.get();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0]?.origin).toEqual({
|
||||
kind: 'injection',
|
||||
variant: 'image_compression',
|
||||
ownerPromptId: 'prompt-caption',
|
||||
});
|
||||
expect(history[1]?.origin).toEqual({ kind: 'user' });
|
||||
expect(history[1]?.content).toEqual([{ type: 'text', text: 'look at this' }]);
|
||||
const captionPart = history[0]?.content[0];
|
||||
expect(captionPart?.type).toBe('text');
|
||||
expect((captionPart as { text: string }).text).toContain(
|
||||
'Image compressed to fit model limits: 800x600',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles the prompt as failed when the loop throws on launch', async () => {
|
||||
const { prompt, loop } = harness();
|
||||
vi.spyOn(loop, 'enqueue').mockImplementation(() => {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,40 @@
|
|||
/**
|
||||
* Scenario: swarm service policy, context reconciliation, persistence, and
|
||||
* tool execution.
|
||||
*
|
||||
* Exercises the Agent-scoped service through DI and public loop boundaries,
|
||||
* with storage, session swarm execution, and approvals stubbed. Run:
|
||||
* `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
|
||||
* test/agent/swarm/swarm.test.ts`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { stubLog } from '../../_base/log/stubs';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IAgentStateService } from '#/agent/state/agentState';
|
||||
import { AgentStateService } from '#/agent/state/agentStateService';
|
||||
import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting';
|
||||
import {
|
||||
IAgentSystemReminderService,
|
||||
wrapSystemReminder,
|
||||
} from '#/agent/systemReminder/systemReminder';
|
||||
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
|
||||
import { IAgentSwarmService } from '#/agent/swarm/swarm';
|
||||
import { AgentSwarmService } from '#/agent/swarm/swarmService';
|
||||
import SWARM_MODE_ENTER_REMINDER from '../../../src/agent/swarm/enter-reminder.md?raw';
|
||||
import { SwarmModel } from '#/agent/swarm/swarmOps';
|
||||
import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay';
|
||||
import { AgentSwarmToolInputSchema } from '#/agent/tools/agent-swarm/agent-swarm';
|
||||
|
|
@ -38,18 +61,41 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt
|
|||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
|
||||
import { stubContextMemory } from '../contextMemory/stubs';
|
||||
import { executeTool } from '../../tools/fixtures/execute-tool';
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
import { stubLoopWithHooks } from '../loop/stubs';
|
||||
import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs';
|
||||
import { stubFlag } from '../../app/flag/stubs';
|
||||
import { createTestAgent } from '../../harness';
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
async function runInjectionBoundary(loop: IAgentLoopService): Promise<void> {
|
||||
await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 1, firstStepOfTurn: true, signal });
|
||||
}
|
||||
|
||||
function messageText(message: ContextMessage | undefined): string {
|
||||
return (
|
||||
message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function swarmReminder(
|
||||
content: string,
|
||||
disclosure?: { readonly kind: 'swarm_mode'; readonly state: 'active' },
|
||||
): ContextMessage {
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: wrapSystemReminder(content) }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'swarm_mode', disclosure },
|
||||
};
|
||||
}
|
||||
|
||||
function context<Input>(
|
||||
args: Input,
|
||||
toolCallId = 'call_swarm',
|
||||
|
|
@ -171,11 +217,19 @@ describe('AgentSwarmService', () => {
|
|||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IAgentContextMemoryService, stubContextMemory());
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.stub(ILogService, stubLog());
|
||||
ix.stub(IAgentTokenCountingService, {
|
||||
estimateText: () => 0,
|
||||
estimateMessage: () => 0,
|
||||
estimateMessages: () => 0,
|
||||
} as unknown as IAgentTokenCountingService);
|
||||
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.stub(IAgentLoopService, stubLoopWithHooks());
|
||||
ix.set(IAgentStateService, new AgentStateService());
|
||||
ix.set(IAgentContextInjectorService, new SyncDescriptor(AgentContextInjectorService));
|
||||
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
ix.stub(IAgentLifecycleService, {});
|
||||
ix.stub(ISessionSwarmService, {
|
||||
|
|
@ -222,10 +276,147 @@ describe('AgentSwarmService', () => {
|
|||
expect(events).toEqual([
|
||||
{ type: 'agent.status.updated', swarmMode: true },
|
||||
{ type: 'agent.status.updated', swarmMode: false },
|
||||
{ type: 'context.spliced', start: 0, deleteCount: 1, messages: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders enter guidance when manual swarm mode becomes active', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
|
||||
swarm.enter('manual');
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
const reminder = context.get().at(-1);
|
||||
expect(reminder?.origin).toEqual({
|
||||
kind: 'injection',
|
||||
variant: 'swarm_mode',
|
||||
disclosure: { kind: 'swarm_mode', state: 'active' },
|
||||
});
|
||||
expect(messageText(reminder)).toContain('You are now in "agent swarm" mode.');
|
||||
expect(context.get()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps one enter guidance when a later boundary sees the same active state', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
|
||||
swarm.enter('manual');
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(context.get()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes trailing enter guidance when manual swarm mode becomes inactive', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
|
||||
swarm.enter('manual');
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
swarm.exit();
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(context.get()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps enter guidance when a later context message makes it non-trailing', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
|
||||
swarm.enter('manual');
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
context.append({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'later prompt' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
});
|
||||
swarm.exit();
|
||||
|
||||
expect(context.get()).toHaveLength(2);
|
||||
expect(context.get()[0]?.origin).toMatchObject({
|
||||
kind: 'injection',
|
||||
variant: 'swarm_mode',
|
||||
});
|
||||
expect(messageText(context.get()[1])).toBe('later prompt');
|
||||
});
|
||||
|
||||
it('renders no reminder at all for tool-triggered swarms', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
|
||||
swarm.enter('tool');
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
swarm.exit();
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(context.get()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not duplicate the enter guidance on resume while it is still live in history', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
await restoreTestAgentWire(
|
||||
ix.get(IWireService),
|
||||
ix.get(IAppendLogStore),
|
||||
testWireScope('wire', 'swarm-test'),
|
||||
[
|
||||
{ type: 'context.append_message', message: swarmReminder(SWARM_MODE_ENTER_REMINDER) },
|
||||
{ type: 'swarm_mode.enter', trigger: 'manual' },
|
||||
],
|
||||
);
|
||||
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(swarm.isActive).toBe(true);
|
||||
expect(context.get()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replays exit by removing a trailing enter reminder', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
await restoreTestAgentWire(
|
||||
ix.get(IWireService),
|
||||
ix.get(IAppendLogStore),
|
||||
testWireScope('wire', 'swarm-test'),
|
||||
[
|
||||
{ type: 'context.append_message', message: swarmReminder(SWARM_MODE_ENTER_REMINDER) },
|
||||
{ type: 'swarm_mode.enter', trigger: 'manual' },
|
||||
{ type: 'swarm_mode.exit' },
|
||||
],
|
||||
);
|
||||
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(swarm.isActive).toBe(false);
|
||||
expect(context.get()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('derives the rendered state from the disclosure, not the reminder text', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
const context = ix.get(IAgentContextMemoryService);
|
||||
await restoreTestAgentWire(
|
||||
ix.get(IWireService),
|
||||
ix.get(IAppendLogStore),
|
||||
testWireScope('wire', 'swarm-test'),
|
||||
[
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: swarmReminder('outdated enter copy', {
|
||||
kind: 'swarm_mode',
|
||||
state: 'active',
|
||||
}),
|
||||
},
|
||||
{ type: 'swarm_mode.enter', trigger: 'manual' },
|
||||
],
|
||||
);
|
||||
|
||||
await runInjectionBoundary(ix.get(IAgentLoopService));
|
||||
|
||||
expect(swarm.isActive).toBe(true);
|
||||
expect(context.get()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dispatch persists enter/exit records and replay rebuilds the trigger (silent)', async () => {
|
||||
const swarm = ix.get(IAgentSwarmService);
|
||||
swarm.enter('manual');
|
||||
|
|
@ -310,6 +501,41 @@ describe('AgentSwarmService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('swarm context reconciliation', () => {
|
||||
it('renders the corrective exit again when undo removes the latest exit render', async () => {
|
||||
const ctx = createTestAgent();
|
||||
try {
|
||||
const swarm = ctx.get(IAgentSwarmService);
|
||||
swarm.enter('manual');
|
||||
ctx.mockNextResponse({ type: 'text', text: 'first answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
swarm.exit();
|
||||
ctx.mockNextResponse({ type: 'text', text: 'second answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
await ctx.undoHistory(1);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'third answer' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'third prompt' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
const reminders = ctx.contextData().history.filter(
|
||||
(message) =>
|
||||
message.origin?.kind === 'injection' && message.origin.variant === 'swarm_mode',
|
||||
);
|
||||
const latest = reminders.at(-1);
|
||||
const text = latest?.content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('');
|
||||
expect(text).toContain('Swarm Mode has ended.');
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentSwarmTool', () => {
|
||||
it('applies one subagent_type across templated subagents', async () => {
|
||||
const host = mockSwarmHost({
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ describe('AgentTaskService', () => {
|
|||
ix.stub(IEventBus, eventBus);
|
||||
ix.stub(IAgentContextInjectorService, {
|
||||
register: (name, provider) => {
|
||||
injectionProviders.set(name, provider);
|
||||
injectionProviders.set(name, provider as ContextInjectionProvider);
|
||||
return toDisposable(() => {
|
||||
injectionProviders.delete(name);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ function beforeStep(
|
|||
step: number,
|
||||
signal = new AbortController().signal,
|
||||
): Promise<void> {
|
||||
return h.loop.hooks.onWillBeginStep.run({ turnId, step, signal });
|
||||
return h.loop.hooks.onWillBeginStep.run({ turnId, step, firstStepOfTurn: step === 1, signal });
|
||||
}
|
||||
|
||||
function afterStep(
|
||||
|
|
@ -179,6 +179,7 @@ function afterStep(
|
|||
return h.loop.hooks.onDidFinishStep.run({
|
||||
turnId,
|
||||
step,
|
||||
firstStepOfTurn: step === 1,
|
||||
signal,
|
||||
usage: ZERO_USAGE,
|
||||
finishReason: 'completed',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
foldAnnouncedToolNames,
|
||||
isDynamicToolSchemaMessage,
|
||||
isLoadableToolsAnnouncement,
|
||||
LOADABLE_TOOLS_TRIGGER,
|
||||
LOADABLE_TOOLS_VARIANT,
|
||||
renderLoadableToolsAnnouncement,
|
||||
stripDynamicToolContext,
|
||||
} from '#/agent/toolSelect/dynamicTools';
|
||||
|
|
@ -26,7 +26,7 @@ function announcement(added: readonly string[], removed: readonly string[]): Con
|
|||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER },
|
||||
origin: { kind: 'injection', variant: LOADABLE_TOOLS_VARIANT },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
|||
import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag';
|
||||
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
||||
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
|
||||
import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import '#/agent/tools/select-tools/selectToolsTool';
|
||||
|
||||
|
|
@ -113,6 +114,7 @@ describe('progressive tool disclosure end-to-end', () => {
|
|||
ctx = createTestAgent();
|
||||
ctx.get(IAgentToolSelectService);
|
||||
ctx.get(IAgentToolSelectAnnouncementsService);
|
||||
ctx.get(IAgentToolSelectSchemasService);
|
||||
ctx.get(IAgentToolExecutorService);
|
||||
ctx.configure({ modelCapabilities: DISCLOSURE_CAPABILITIES });
|
||||
await ctx.rpc.setPermission({ mode: 'yolo' });
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
|
|||
import type { UndoCut } from '#/agent/contextMemory/contextOps';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService';
|
||||
import {
|
||||
IAgentLoopService,
|
||||
type AfterStepContext,
|
||||
|
|
@ -46,18 +48,21 @@ import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/too
|
|||
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_TRIGGER } from '#/agent/toolSelect/dynamicTools';
|
||||
import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_VARIANT } from '#/agent/toolSelect/dynamicTools';
|
||||
import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag';
|
||||
import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect';
|
||||
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
|
||||
import { AgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncementsService';
|
||||
import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas';
|
||||
import { AgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemasService';
|
||||
import { AgentToolSelectService } from '#/agent/toolSelect/toolSelectService';
|
||||
import { SelectToolsTool } from '#/agent/tools/select-tools/selectToolsTool';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { recordingTelemetry } from '../../app/telemetry/stubs';
|
||||
import { registerStateServices } from '../../state/stubs';
|
||||
import { stubToolExecutor } from '../loop/stubs';
|
||||
import { stubToolExecutor, stubWire } from '../loop/stubs';
|
||||
import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs';
|
||||
|
||||
const MCP_ALPHA = 'mcp__srv__alpha';
|
||||
|
|
@ -262,6 +267,10 @@ class FakeContextMemory implements IAgentContextMemoryService {
|
|||
throw new Error('unused in this suite');
|
||||
}
|
||||
|
||||
publishTrailingRemoval(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history.length = 0;
|
||||
this.appended.length = 0;
|
||||
|
|
@ -285,7 +294,7 @@ class FakeContextMemory implements IAgentContextMemoryService {
|
|||
role: 'user',
|
||||
content: [{ type: 'text', text: `<system-reminder>\n${content.trim()}\n</system-reminder>` }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER },
|
||||
origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_VARIANT },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -319,15 +328,19 @@ function registerSharedServices(
|
|||
reg.definePartialInstance(IFlagService, {
|
||||
enabled: (id: string) => (id === TOOL_SELECT_FLAG_ID ? flagEnabled : false),
|
||||
});
|
||||
reg.defineInstance(IWireService, stubWire());
|
||||
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
reg.define(IAgentToolSelectService, AgentToolSelectService);
|
||||
reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService);
|
||||
reg.define(IAgentToolSelectSchemasService, AgentToolSelectSchemasService);
|
||||
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
|
||||
registerLogServices(reg);
|
||||
}
|
||||
|
||||
function mountAnnouncements(ix: TestInstantiationService): void {
|
||||
ix.get(IAgentToolSelectAnnouncementsService);
|
||||
ix.get(IAgentToolSelectSchemasService);
|
||||
}
|
||||
|
||||
function createHarness(): Harness {
|
||||
|
|
@ -382,8 +395,10 @@ function createExecutorHarness(): ExecutorHarness {
|
|||
};
|
||||
}
|
||||
|
||||
function registerMcp(h: Harness, tool: StubMcpTool): void {
|
||||
disposables.add(h.registry.register(tool, { source: 'mcp' }));
|
||||
function registerMcp(h: Harness, tool: StubMcpTool): IDisposable {
|
||||
const registration = h.registry.register(tool, { source: 'mcp' });
|
||||
disposables.add(registration);
|
||||
return registration;
|
||||
}
|
||||
|
||||
function registerBuiltin(h: Harness, tool: EchoTool): void {
|
||||
|
|
@ -400,25 +415,61 @@ function registerUser(
|
|||
return registration;
|
||||
}
|
||||
|
||||
function announcementText(message: ContextMessage): string {
|
||||
return message.content.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
||||
}
|
||||
|
||||
function isNewAnnouncement(message: ContextMessage): boolean {
|
||||
return message.origin?.kind === 'injection' && message.origin.variant === LOADABLE_TOOLS_VARIANT;
|
||||
}
|
||||
|
||||
async function announce(h: Harness, step = 1): Promise<string | undefined> {
|
||||
const before = h.contextMemory.appended.length;
|
||||
await h.loop.hooks.onWillBeginStep.run({
|
||||
turnId: 1,
|
||||
step,
|
||||
firstStepOfTurn: step === 1,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const announcement = h.contextMemory.appended
|
||||
.slice(before)
|
||||
.find(
|
||||
(message) =>
|
||||
message.origin?.kind === 'system_trigger' &&
|
||||
message.origin.name === LOADABLE_TOOLS_TRIGGER,
|
||||
);
|
||||
const announcement = h.contextMemory.appended.slice(before).find(isNewAnnouncement);
|
||||
h.contextMemory.landAppended();
|
||||
if (announcement === undefined) return undefined;
|
||||
return announcement.content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.join('');
|
||||
return announcementText(announcement);
|
||||
}
|
||||
|
||||
async function announceAfterCompaction(h: Harness): Promise<string | undefined> {
|
||||
h.eventBus.publish({
|
||||
type: 'context.spliced',
|
||||
start: 0,
|
||||
deleteCount: 1,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Compacted summary.' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'compaction_summary' },
|
||||
},
|
||||
],
|
||||
});
|
||||
return announce(h, 99);
|
||||
}
|
||||
|
||||
async function declareSchemas(h: Harness, step = 1): Promise<ContextMessage | undefined> {
|
||||
const before = h.contextMemory.appended.length;
|
||||
await h.loop.hooks.onWillBeginStep.run({
|
||||
turnId: 1,
|
||||
step,
|
||||
firstStepOfTurn: step === 1,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const fresh = h.contextMemory.appended.splice(before);
|
||||
const declared = fresh.find(
|
||||
(message) =>
|
||||
message.origin?.kind === 'injection' &&
|
||||
message.origin.variant === DYNAMIC_TOOL_SCHEMA_VARIANT,
|
||||
);
|
||||
if (declared !== undefined) h.contextMemory.history.push(declared);
|
||||
return declared;
|
||||
}
|
||||
|
||||
async function execute(
|
||||
|
|
@ -678,7 +729,7 @@ describe('AgentToolSelectService.load', () => {
|
|||
flagEnabled = true;
|
||||
});
|
||||
|
||||
it('settles per name: toLoad, alreadyAvailable, unknown', () => {
|
||||
it('settles per name: toLoad, alreadyAvailable, unknown', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
registerMcp(h, new StubMcpTool(MCP_BETA));
|
||||
|
|
@ -689,14 +740,14 @@ describe('AgentToolSelectService.load', () => {
|
|||
expect(result.alreadyAvailable).toEqual([MCP_ALPHA]);
|
||||
expect(result.unknown).toEqual([MCP_GONE]);
|
||||
|
||||
expect(h.contextMemory.appended).toHaveLength(1);
|
||||
const appended = h.contextMemory.appended[0]!;
|
||||
expect(appended.role).toBe('system');
|
||||
expect(appended.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]);
|
||||
expect(appended.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT });
|
||||
expect(h.contextMemory.appended).toHaveLength(0);
|
||||
const declared = await declareSchemas(h);
|
||||
expect(declared?.role).toBe('system');
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]);
|
||||
expect(declared?.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT });
|
||||
});
|
||||
|
||||
it('loads the schema of an opted-in user tool', () => {
|
||||
it('loads the schema of an opted-in user tool', async () => {
|
||||
const h = createHarness();
|
||||
registerUser(h, new EchoTool(USER_DEFERRED), 'deferred');
|
||||
|
||||
|
|
@ -705,24 +756,34 @@ describe('AgentToolSelectService.load', () => {
|
|||
alreadyAvailable: [],
|
||||
unknown: [],
|
||||
});
|
||||
expect(h.contextMemory.appended[0]?.tools?.map((tool) => tool.name)).toEqual([
|
||||
USER_DEFERRED,
|
||||
]);
|
||||
const declared = await declareSchemas(h);
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([USER_DEFERRED]);
|
||||
});
|
||||
|
||||
it('sorts the injected schemas by name', () => {
|
||||
it('sorts the declared schemas by name', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_BETA));
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
h.sut.load([MCP_BETA, MCP_ALPHA]);
|
||||
expect(h.contextMemory.appended[0]!.tools?.map((tool) => tool.name)).toEqual([
|
||||
MCP_ALPHA,
|
||||
MCP_BETA,
|
||||
]);
|
||||
const declared = await declareSchemas(h);
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA, MCP_BETA]);
|
||||
});
|
||||
|
||||
it('reports names filtered out by the profile as unknown', () => {
|
||||
it('declares a selected schema after its MCP tool reconnects before a later boundary', async () => {
|
||||
const h = createHarness();
|
||||
const registration = registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]);
|
||||
registration.dispose();
|
||||
expect(await declareSchemas(h)).toBeUndefined();
|
||||
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
const declared = await declareSchemas(h, 2);
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
||||
it('reports names filtered out by the profile as unknown', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
registerMcp(h, new StubMcpTool(MCP_BETA));
|
||||
|
|
@ -731,9 +792,11 @@ describe('AgentToolSelectService.load', () => {
|
|||
const result = h.sut.load([MCP_ALPHA, MCP_BETA]);
|
||||
expect(result.toLoad).toEqual([MCP_ALPHA]);
|
||||
expect(result.unknown).toEqual([MCP_BETA]);
|
||||
const declared = await declareSchemas(h);
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
||||
it('pending ledger leads the history inside the defer window', () => {
|
||||
it('pending ledger leads the history inside the defer window', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
|
|
@ -743,7 +806,7 @@ describe('AgentToolSelectService.load', () => {
|
|||
expect(reselect.alreadyAvailable).toEqual([MCP_ALPHA]);
|
||||
expect(reselect.toLoad).toEqual([]);
|
||||
|
||||
h.contextMemory.landAppended();
|
||||
await declareSchemas(h);
|
||||
const afterLanding = h.sut.load([MCP_ALPHA]);
|
||||
expect(afterLanding.alreadyAvailable).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
|
@ -753,7 +816,6 @@ describe('AgentToolSelectService.load', () => {
|
|||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
h.sut.load([MCP_ALPHA]);
|
||||
h.contextMemory.appended.length = 0;
|
||||
h.eventBus.emit('compaction.completed');
|
||||
expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
|
@ -763,20 +825,35 @@ describe('AgentToolSelectService.load', () => {
|
|||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
h.sut.load([MCP_ALPHA]);
|
||||
h.contextMemory.appended.length = 0;
|
||||
h.eventBus.emit('context.spliced', { start: 0, deleteCount: 2, messages: [] });
|
||||
expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
||||
it('reconciles the pending ledger with history when a mid-history splice removes schema messages', () => {
|
||||
it('keeps the pending ledger across a compaction replacement splice', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
|
||||
h.sut.load([MCP_ALPHA]);
|
||||
h.eventBus.emit('context.spliced', {
|
||||
start: 0,
|
||||
deleteCount: 2,
|
||||
messages: [userMessage('Compacted summary.')],
|
||||
});
|
||||
|
||||
expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]);
|
||||
const declared = await declareSchemas(h);
|
||||
expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]);
|
||||
});
|
||||
|
||||
it('reconciles the pending ledger with history when a mid-history splice removes schema messages', async () => {
|
||||
const h = createHarness();
|
||||
registerMcp(h, new StubMcpTool(MCP_ALPHA));
|
||||
registerMcp(h, new StubMcpTool(MCP_BETA));
|
||||
|
||||
h.sut.load([MCP_ALPHA]);
|
||||
h.contextMemory.landAppended();
|
||||
await declareSchemas(h);
|
||||
h.sut.load([MCP_BETA]);
|
||||
h.contextMemory.landAppended();
|
||||
await declareSchemas(h, 2);
|
||||
expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]);
|
||||
expect(h.sut.load([MCP_BETA]).alreadyAvailable).toEqual([MCP_BETA]);
|
||||
|
||||
|
|
@ -1015,9 +1092,7 @@ describe('AgentToolSelectService loadable-tools announcements', () => {
|
|||
expect(await announce(h, 2)).toBeUndefined();
|
||||
|
||||
h.contextMemory.clear();
|
||||
h.eventBus.emit('compaction.completed');
|
||||
|
||||
const reannounced = await announce(h, 2);
|
||||
const reannounced = await announceAfterCompaction(h);
|
||||
expect(reannounced).toContain(`<tools_added>\n${MCP_ALPHA}\n${MCP_BETA}\n</tools_added>`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@ describe('Agent config', () => {
|
|||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ function makeAfterStep(signal: AbortSignal): AfterStepContext {
|
|||
return {
|
||||
turnId: 0,
|
||||
step: 1,
|
||||
firstStepOfTurn: true,
|
||||
signal,
|
||||
usage: emptyUsage(),
|
||||
finishReason: 'completed',
|
||||
|
|
@ -107,6 +108,7 @@ function stubContextMemory(): IAgentContextMemoryService & {
|
|||
messages.push(...inserted);
|
||||
},
|
||||
appendLoopEvent: () => {},
|
||||
publishTrailingRemoval: () => false,
|
||||
clear: () => {
|
||||
messages.splice(0);
|
||||
},
|
||||
|
|
|
|||
43
packages/agent-core-v2/test/app/plugin/stubs.ts
Normal file
43
packages/agent-core-v2/test/app/plugin/stubs.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* `plugin` domain test stubs — shared plugin boundary fixtures.
|
||||
*/
|
||||
|
||||
import { Event, type Emitter } from '#/_base/event';
|
||||
import type { IPluginService } from '#/app/plugin/plugin';
|
||||
import type {
|
||||
EnabledPluginSessionStart,
|
||||
PluginMutationSummary,
|
||||
ReloadSummary,
|
||||
} from '#/app/plugin/types';
|
||||
|
||||
interface StubPluginServiceOptions {
|
||||
readonly sessionStarts: readonly EnabledPluginSessionStart[];
|
||||
readonly reloadEmitter?: Emitter<ReloadSummary>;
|
||||
readonly mutateEmitter?: Emitter<PluginMutationSummary>;
|
||||
}
|
||||
|
||||
export function stubPluginService(options: StubPluginServiceOptions): IPluginService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
onDidReload: options.reloadEmitter?.event ?? (Event.None as IPluginService['onDidReload']),
|
||||
onDidMutate: options.mutateEmitter?.event ?? (Event.None as IPluginService['onDidMutate']),
|
||||
listPlugins: async () => [],
|
||||
installPlugin: async () => ({ id: '' }) as never,
|
||||
setPluginEnabled: async () => {},
|
||||
setPluginMcpServerEnabled: async () => {},
|
||||
removePlugin: async () => {},
|
||||
reloadPlugins: async (): Promise<ReloadSummary> => ({ added: [], removed: [], errors: [] }),
|
||||
getPluginInfo: async () => {
|
||||
throw new Error('getPluginInfo is not used by this stub');
|
||||
},
|
||||
listPluginCommands: async () => [],
|
||||
checkUpdates: async () => [],
|
||||
pluginSkillRoots: async () => [],
|
||||
pluginAgentRoots: async () => [],
|
||||
enabledSessionStarts: async () => options.sessionStarts,
|
||||
enabledSystemPrompts: async () => [],
|
||||
enabledMcpServers: async () => ({}),
|
||||
enabledHooks: async () => [],
|
||||
hasLoadedSnapshot: () => true,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,10 +12,12 @@ import { describe, expect, it } from 'vitest';
|
|||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import type { LogContext, LogPayload } from '#/_base/log/log';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
|
||||
import { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
|
||||
import type { SkillDefinition } from '#/app/skillCatalog/types';
|
||||
import { testAgent } from '../../harness';
|
||||
import { appService, logServices, skillServices, testAgent } from '../../harness';
|
||||
import { stubPluginService } from '../plugin/stubs';
|
||||
import { stubSkill } from './stubs';
|
||||
|
||||
type InjectableDynamicInjector = {
|
||||
|
|
@ -35,6 +37,12 @@ interface RecordingLogger {
|
|||
createChild(ctx: LogContext): RecordingLogger;
|
||||
}
|
||||
|
||||
const CURRENT_PLUGIN_SESSION_START_REMINDER = `<system-reminder>
|
||||
<plugin_session_start plugin="superpowers" skill="using-superpowers">
|
||||
body
|
||||
</plugin_session_start>
|
||||
</system-reminder>`;
|
||||
|
||||
function skill(
|
||||
name: string,
|
||||
body: string,
|
||||
|
|
@ -76,12 +84,11 @@ function sessionStartRuntime(input: {
|
|||
for (const skill of input.skills) {
|
||||
skills.register(skill);
|
||||
}
|
||||
const ctx = testAgent({
|
||||
skills,
|
||||
pluginSessionStarts: input.sessionStarts,
|
||||
log: recordingLogger(warnings),
|
||||
});
|
||||
ctx.configure();
|
||||
const ctx = testAgent(
|
||||
appService(IPluginService, stubPluginService({ sessionStarts: input.sessionStarts })),
|
||||
skillServices(skills),
|
||||
logServices(recordingLogger(warnings)),
|
||||
);
|
||||
if (input.history !== undefined) {
|
||||
ctx.context.append(...input.history);
|
||||
}
|
||||
|
|
@ -158,14 +165,14 @@ describe('plugin session-start dynamic injection', () => {
|
|||
expect(pluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not re-inject when a live-spliced history already contains plugin sessionStart', async () => {
|
||||
it('does not re-inject when live-spliced history contains the current plugin sessionStart', async () => {
|
||||
const { ctx } = sessionStartRuntime({
|
||||
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
|
||||
skills: [skill('using-superpowers', 'body', { id: 'superpowers' })],
|
||||
history: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>old</system-reminder>' }],
|
||||
content: [{ type: 'text', text: CURRENT_PLUGIN_SESSION_START_REMINDER }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'plugin_session_start' },
|
||||
},
|
||||
|
|
@ -177,7 +184,7 @@ describe('plugin session-start dynamic injection', () => {
|
|||
expect(pluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not re-inject after a silent wire replay restored a plugin sessionStart (cold resume)', async () => {
|
||||
it('does not re-inject after wire replay restores the current plugin sessionStart', async () => {
|
||||
const { ctx } = sessionStartRuntime({
|
||||
sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }],
|
||||
skills: [skill('using-superpowers', 'body', { id: 'superpowers' })],
|
||||
|
|
@ -188,7 +195,7 @@ describe('plugin session-start dynamic injection', () => {
|
|||
time: 1,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '<system-reminder>old</system-reminder>' }],
|
||||
content: [{ type: 'text', text: CURRENT_PLUGIN_SESSION_START_REMINDER }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'injection', variant: 'plugin_session_start' },
|
||||
},
|
||||
|
|
@ -221,6 +228,21 @@ describe('plugin session-start dynamic injection', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('warns only once for a missing skill across repeated reconciliations', async () => {
|
||||
const { ctx, warnings } = sessionStartRuntime({
|
||||
sessionStarts: [{ pluginId: 'demo', skillName: 'missing' }],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
await injectDynamic(ctx);
|
||||
await injectDynamic(ctx);
|
||||
await injectDynamic(ctx);
|
||||
|
||||
expect(
|
||||
warnings.filter((warning) => warning.message === 'plugin sessionStart skill not found'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emits nothing when no sessionStart declarations are present', async () => {
|
||||
const { ctx } = sessionStartRuntime({ sessionStarts: [], skills: [] });
|
||||
|
||||
|
|
|
|||
|
|
@ -21,14 +21,16 @@ describe('SessionBtwService', () => {
|
|||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let fork: ReturnType<typeof vi.fn>;
|
||||
let appendSystemReminder: ReturnType<typeof vi.fn>;
|
||||
let appendReminder: ReturnType<typeof vi.fn>;
|
||||
let formatDenyMessage: ReturnType<typeof vi.fn>;
|
||||
let executorEvents: ToolExecutorEventStubs;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
appendSystemReminder = vi.fn();
|
||||
appendReminder = vi.fn(() => 'reminder-id');
|
||||
// The suffix mimics the worker-rejection guidance formatDenyMessage appends
|
||||
// for forked sub agents, so the assertion proves the reason went through it.
|
||||
formatDenyMessage = vi.fn((message: string) => `${message} [worker guidance]`);
|
||||
executorEvents = stubToolExecutorEvents();
|
||||
|
||||
|
|
@ -36,7 +38,7 @@ describe('SessionBtwService', () => {
|
|||
id: 'agent-btw-1',
|
||||
accessor: {
|
||||
get: (id: unknown) => {
|
||||
if (id === IAgentSystemReminderService) return { appendSystemReminder };
|
||||
if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder };
|
||||
if (id === IAgentToolApprovalService) return { formatDenyMessage };
|
||||
if (id === IAgentToolExecutorService) return executorEvents.executor;
|
||||
return undefined;
|
||||
|
|
@ -58,9 +60,9 @@ describe('SessionBtwService', () => {
|
|||
|
||||
expect(id).toBe('agent-btw-1');
|
||||
expect(fork).toHaveBeenCalledWith('main');
|
||||
expect(appendSystemReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, {
|
||||
kind: 'system_trigger',
|
||||
name: 'btw',
|
||||
expect(appendReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, {
|
||||
kind: 'injection',
|
||||
variant: 'btw',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from '../../../harness';
|
||||
|
||||
type InjectableDynamicInjector = {
|
||||
inject(): Promise<void>;
|
||||
inject(boundary: undefined, isNewTurn: boolean): Promise<void>;
|
||||
};
|
||||
|
||||
async function enterPlan(
|
||||
|
|
@ -28,7 +28,7 @@ async function enterPlan(
|
|||
}
|
||||
|
||||
async function injectDynamic(injector: InjectableDynamicInjector): Promise<void> {
|
||||
await injector.inject();
|
||||
await injector.inject(undefined, false);
|
||||
}
|
||||
|
||||
function appendAssistantTurn(
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ function createPlanFileFakes(
|
|||
}
|
||||
|
||||
type InjectableDynamicInjector = {
|
||||
inject(): Promise<void>;
|
||||
inject(boundary: undefined, isNewTurn: boolean): Promise<void>;
|
||||
};
|
||||
|
||||
describe('Plan service', () => {
|
||||
|
|
@ -719,6 +719,7 @@ describe('Plan service', () => {
|
|||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] context.spliced { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } }, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
|
|
@ -795,6 +796,7 @@ describe('Plan service', () => {
|
|||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] context.spliced { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } }, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
|
|
@ -907,7 +909,7 @@ describe('Plan service', () => {
|
|||
}
|
||||
|
||||
async function injectDynamic(): Promise<void> {
|
||||
await injector.inject();
|
||||
await injector.inject(undefined, false);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import type { IAgentScopeHandle } from '#/_base/di/scope';
|
|||
import { Emitter, Event } from '#/_base/event';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import type { Promisable, PromisifyMethods } from '#/_base/utils/types';
|
||||
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
|
||||
import type { AgentTaskInfo } from '#/agent/task/task';
|
||||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl';
|
||||
|
|
@ -85,7 +84,6 @@ import type { generate as kosongGenerate } from '#/kosong/contract/generate';
|
|||
import type { ChatProvider, GenerateOptions, StreamedMessage } from '#/kosong/contract/provider';
|
||||
import type { ILogger, LogContext, LogLevel } from '#/_base/log/log';
|
||||
import { ILogOptions } from '#/_base/log/logConfig';
|
||||
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
|
||||
import {
|
||||
WIRE_PROTOCOL_VERSION,
|
||||
AgentTaskService,
|
||||
|
|
@ -969,30 +967,6 @@ class ConfigBackedModelCatalog extends ModelCatalog {
|
|||
}
|
||||
}
|
||||
|
||||
function renderPluginSessionStartReminder(
|
||||
sessionStarts: readonly EnabledPluginSessionStart[],
|
||||
catalog: SkillCatalog,
|
||||
log?: { warn(message: string, payload?: unknown): void },
|
||||
): string | undefined {
|
||||
if (sessionStarts.length === 0) 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,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
blocks.push(
|
||||
`<plugin_session_start plugin="${escapeXmlAttr(sessionStart.pluginId)}" ` +
|
||||
`skill="${escapeXmlAttr(skill.name)}">\n${catalog.renderSkillPrompt(skill, '')}\n</plugin_session_start>`,
|
||||
);
|
||||
}
|
||||
return blocks.length > 0 ? blocks.join('\n') : undefined;
|
||||
}
|
||||
|
||||
export class AgentTestContext {
|
||||
private readonly serviceOverrides: readonly TestAgentScopedServiceOverride[];
|
||||
private readonly options: TestAgentOptions;
|
||||
|
|
@ -1002,7 +976,6 @@ export class AgentTestContext {
|
|||
private readonly agent: Scope;
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private suppressWireSnapshot = false;
|
||||
private pluginSessionStartRegistered = false;
|
||||
kimiConfig: KimiConfig;
|
||||
private cwd = process.cwd();
|
||||
private closed = false;
|
||||
|
|
@ -1404,29 +1377,6 @@ export class AgentTestContext {
|
|||
profile.update({ activeToolNames: [...tools] });
|
||||
}
|
||||
|
||||
const sessionStarts = this.options['pluginSessionStarts'] as
|
||||
| readonly EnabledPluginSessionStart[]
|
||||
| undefined;
|
||||
const skillCatalog = this.options['skills'] as SkillCatalog | undefined;
|
||||
if (
|
||||
!this.pluginSessionStartRegistered &&
|
||||
sessionStarts !== undefined &&
|
||||
skillCatalog !== undefined
|
||||
) {
|
||||
this.pluginSessionStartRegistered = true;
|
||||
this.get(IAgentContextInjectorService).register(
|
||||
'plugin_session_start',
|
||||
async ({ injectedPositions }) => {
|
||||
if (injectedPositions.length > 0) return undefined;
|
||||
return renderPluginSessionStartReminder(
|
||||
sessionStarts,
|
||||
skillCatalog,
|
||||
this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.snapshots.drain();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const V2_RECORD_TYPES: ReadonlySet<string> = new Set([
|
|||
'interaction.resolved',
|
||||
'plan.revision',
|
||||
'interruptionReminder.recorded',
|
||||
'plugin.session_start',
|
||||
'turn.ended',
|
||||
]);
|
||||
|
||||
|
|
@ -385,7 +386,10 @@ describe('AgentRecords persistence metadata', () => {
|
|||
]);
|
||||
expect(ctx.get(IAgentGoalService).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',
|
||||
});
|
||||
expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.');
|
||||
});
|
||||
|
||||
|
|
@ -411,8 +415,8 @@ describe('AgentRecords persistence metadata', () => {
|
|||
objective: 'fork work',
|
||||
});
|
||||
expect(context.get().at(-1)?.origin).toEqual({
|
||||
kind: 'system_trigger',
|
||||
name: 'goal_fork_cleared',
|
||||
kind: 'injection',
|
||||
variant: 'goal_fork_cleared',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ describe('AgentLifecycleService', () => {
|
|||
ix.stub(ILogService, noopLog);
|
||||
ix.stub(IAgentPluginService, {
|
||||
_serviceBrand: undefined,
|
||||
refreshSessionStart: async () => {},
|
||||
});
|
||||
ix.stub(IAgentToolRegistryService, {
|
||||
_serviceBrand: undefined,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ describe('SessionInitService', () => {
|
|||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let events: unknown[];
|
||||
let appendSystemReminder: ReturnType<typeof vi.fn>;
|
||||
let appendReminder: ReturnType<typeof vi.fn>;
|
||||
let seedInjected: ReturnType<typeof vi.fn>;
|
||||
let flush: ReturnType<typeof vi.fn>;
|
||||
let republishStatus: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -42,7 +42,7 @@ describe('SessionInitService', () => {
|
|||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
events = [];
|
||||
appendSystemReminder = vi.fn();
|
||||
appendReminder = vi.fn(() => 'reminder-id');
|
||||
seedInjected = vi.fn();
|
||||
flush = vi.fn(async () => {});
|
||||
republishStatus = vi.fn(() => {
|
||||
|
|
@ -83,7 +83,7 @@ describe('SessionInitService', () => {
|
|||
if (id === ISessionSubagentService) return lifecycle;
|
||||
if (id === IAgentProfileService) return profile;
|
||||
if (id === IAgentPermissionModeService) return permissionMode;
|
||||
if (id === IAgentSystemReminderService) return { appendSystemReminder };
|
||||
if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder };
|
||||
if (id === IAgentAgentsMdReminderService) return { seedInjected };
|
||||
if (id === IWireService) return { flush };
|
||||
if (id === IEventBus) return eventBus;
|
||||
|
|
@ -151,12 +151,15 @@ describe('SessionInitService', () => {
|
|||
expect(runArgs[1]).toMatchObject({ kind: 'prompt' });
|
||||
expect((runArgs[1] as { prompt: string }).prompt).toContain('Task requirements:');
|
||||
|
||||
expect(appendSystemReminder).toHaveBeenCalledTimes(1);
|
||||
const [reminder, origin] = appendSystemReminder.mock.calls[0] as [string, unknown];
|
||||
expect(appendReminder).toHaveBeenCalledTimes(1);
|
||||
const [content, origin] = appendReminder.mock.calls[0] as [
|
||||
string,
|
||||
{ kind: string; variant: string },
|
||||
];
|
||||
expect(origin).toEqual({ kind: 'injection', variant: 'init' });
|
||||
expect(reminder).toContain('The user just ran `/init` slash command.');
|
||||
expect(reminder).toContain('Latest AGENTS.md file content:');
|
||||
expect(reminder).toContain(AGENTS_MD);
|
||||
expect(content).toContain('The user just ran `/init` slash command.');
|
||||
expect(content).toContain('Latest AGENTS.md file content:');
|
||||
expect(content).toContain(AGENTS_MD);
|
||||
|
||||
expect(seedInjected).toHaveBeenCalledWith([AGENTS_MD_PATH], WORK_DIR);
|
||||
|
||||
|
|
|
|||
|
|
@ -3255,9 +3255,10 @@ describe('Agent tools', () => {
|
|||
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[emit] context.spliced { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[emit] context.spliced { "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } } ] }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } }, "time": "<time>" }
|
||||
[wire] plugin.session_start { "content": null, "time": "<time>" }
|
||||
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
||||
[emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [] }
|
||||
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ describe('Agent resume', () => {
|
|||
expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reconciles a pending user interruption after restore when the reminder is missing', async () => {
|
||||
it('does not reconstruct an event-point interruption after restore', async () => {
|
||||
const persistence = new RecordingAgentPersistence([
|
||||
resumeConfigRecord(),
|
||||
{
|
||||
|
|
@ -104,13 +104,19 @@ describe('Agent resume', () => {
|
|||
try {
|
||||
await ctx.restorePersisted();
|
||||
|
||||
expect(ctx.context.get()).toContainEqual(
|
||||
expect(ctx.context.get()).not.toContainEqual(
|
||||
expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }),
|
||||
);
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
expect(ctx.context.get()).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
origin: { kind: 'injection', variant: 'interruption' },
|
||||
}),
|
||||
);
|
||||
expect(persistence.appended).toContainEqual(
|
||||
expect(persistence.appended).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context.append_message',
|
||||
message: expect.objectContaining({
|
||||
|
|
@ -118,9 +124,6 @@ describe('Agent resume', () => {
|
|||
}),
|
||||
}),
|
||||
);
|
||||
expect(persistence.appended).toContainEqual(
|
||||
expect.objectContaining({ type: 'interruptionReminder.recorded', turnId: 0 }),
|
||||
);
|
||||
|
||||
await ctx.expectResumeMatches();
|
||||
} finally {
|
||||
|
|
@ -128,6 +131,42 @@ describe('Agent resume', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('does not reconcile a legacy interruption whose delivery was recorded', async () => {
|
||||
const persistence = new RecordingAgentPersistence([
|
||||
resumeConfigRecord(),
|
||||
{
|
||||
type: 'context.append_message',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Hello' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'turn.prompt',
|
||||
input: [{ type: 'text', text: 'Hello' }],
|
||||
origin: { kind: 'user' },
|
||||
},
|
||||
{ type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled' },
|
||||
{ type: 'interruptionReminder.recorded', turnId: 0 },
|
||||
] as unknown as WireRecord[]);
|
||||
const ctx = testAgent({ persistence, autoConfigure: false });
|
||||
|
||||
try {
|
||||
await ctx.restorePersisted();
|
||||
ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
|
||||
expect(ctx.context.get()).not.toContainEqual(
|
||||
expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }),
|
||||
);
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('replays persisted records without restarting turns, compactions, plan turns, or tools', async () => {
|
||||
const persistence = new RecordingAgentPersistence(resumeHistory() as unknown as WireRecord[]);
|
||||
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume'));
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
type WireModelContributionRecord,
|
||||
} from '#/wire/wireContribution';
|
||||
import { CycleError } from '#/wire/wireService';
|
||||
import '#/agent/interruptionReminder/interruptionReminderOps';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from './stubs';
|
||||
|
||||
|
|
@ -320,6 +321,28 @@ describe('WireService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('replays legacy interruption records without reporting them', async () => {
|
||||
const unexpected: unknown[] = [];
|
||||
setUnexpectedErrorHandler((error) => unexpected.push(error));
|
||||
try {
|
||||
await restoreTestAgentWire(
|
||||
wire,
|
||||
log,
|
||||
testWireScope(SCOPE, KEY),
|
||||
[
|
||||
{ type: 'store.counter.add', by: 2 },
|
||||
{ type: 'interruptionReminder.recorded', turnId: 0 },
|
||||
{ type: 'store.counter.add', by: 3 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(wire.getModel(CounterModel)).toEqual({ value: 5 });
|
||||
expect(unexpected).toEqual([]);
|
||||
} finally {
|
||||
resetUnexpectedErrorHandler();
|
||||
}
|
||||
});
|
||||
|
||||
it('freezes state: getModel is frozen and mutation throws in strict mode', () => {
|
||||
wire.dispatch(counterAdd({ by: 2 }));
|
||||
const state = wire.getModel(CounterModel);
|
||||
|
|
|
|||
|
|
@ -165,9 +165,11 @@ import {
|
|||
ensureKimiHome,
|
||||
ensureMainAgent,
|
||||
IAgentActivityView,
|
||||
IAgentContextInjectorService,
|
||||
IAgentContextMemoryService,
|
||||
IAgentFullCompactionService,
|
||||
IAgentGoalService,
|
||||
IAgentPluginService,
|
||||
IAgentLifecycleService,
|
||||
IAgentLoopService,
|
||||
IAgentPermissionModeService,
|
||||
|
|
@ -729,7 +731,9 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
}
|
||||
|
||||
override async reloadPlugins(): Promise<ReloadSummary> {
|
||||
return this.klient.global.plugins.reload();
|
||||
const summary = await this.klient.global.plugins.reload();
|
||||
await this.refreshPluginSessionStarts();
|
||||
return summary;
|
||||
}
|
||||
|
||||
override async getPluginInfo(id: string): Promise<PluginInfo> {
|
||||
|
|
@ -1240,8 +1244,8 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
* the live session, resume from disk. The v2 busy check reads each live
|
||||
* agent's activity view (turn lane only — background tasks do not block,
|
||||
* matching v1's `hasActiveTurn`). `forcePluginSessionStartReminder` has no
|
||||
* v2 channel (the engine owns plugin session-start injection) and is
|
||||
* ignored.
|
||||
* v2 channel (the engine owns plugin session-start injection), so reload
|
||||
* refreshes the durable guidance snapshot through the Agent service.
|
||||
*/
|
||||
override async reloadSession(input: ReloadSessionRpcInput): Promise<ResumedSessionSummary> {
|
||||
const sessionId = input.sessionId;
|
||||
|
|
@ -1262,6 +1266,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
await this.configReady;
|
||||
await this.klient.global.config.reload();
|
||||
await this.klient.global.plugins.reload();
|
||||
await this.refreshPluginSessionStarts(sessionId);
|
||||
if (live !== undefined) {
|
||||
await closeSessionById(this.engineAccessor, sessionId);
|
||||
}
|
||||
|
|
@ -1270,10 +1275,30 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
this.printSteerStates.delete(sessionId);
|
||||
const handle = await resumeSessionById(this.engineAccessor, sessionId);
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID);
|
||||
await main?.accessor.get(IAgentPluginService).refreshSessionStart();
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle);
|
||||
}
|
||||
|
||||
private async refreshPluginSessionStarts(excludedSessionId?: string): Promise<void> {
|
||||
const workspaceLifecycle = this.engineAccessor.get(IWorkspaceLifecycleService);
|
||||
await Promise.all(
|
||||
workspaceLifecycle.handlers.list().map(async (handler) => {
|
||||
await handler.accessor.get(IWorkspaceSkillCatalog).reload();
|
||||
const sessions = handler.accessor.get(ISessionLifecycleService).list();
|
||||
await Promise.all(
|
||||
sessions.map(async (session) => {
|
||||
if (session.id === excludedSessionId) return;
|
||||
const main = session.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID);
|
||||
if (main === undefined) return;
|
||||
await main.accessor.get(IAgentPluginService).refreshSessionStart();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The base-class contract merges the patch into the session's `custom` map
|
||||
* (v1 routes through the live session and 404s on a closed one; mirrored
|
||||
|
|
@ -1866,9 +1891,10 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
const swarm = agent.accessor.get(IAgentSwarmService);
|
||||
if (input.enabled) {
|
||||
swarm.enter(input.trigger);
|
||||
return;
|
||||
} else {
|
||||
swarm.exit();
|
||||
}
|
||||
swarm.exit();
|
||||
await agent.accessor.get(IAgentContextInjectorService).reconcileWhenIdle('swarm_mode');
|
||||
}
|
||||
|
||||
/** v1's `swarm()` composition: enter with the one-shot `task` trigger, then prompt. */
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { join } from 'node:path';
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
IAgentLifecycleService,
|
||||
ISessionApprovalService,
|
||||
ISessionQuestionService,
|
||||
getLiveSessionById,
|
||||
|
|
@ -171,6 +172,16 @@ function scrubHomePrefixes(value: unknown, home: HomePair): unknown {
|
|||
* the comparison still covers everything not listed here. Keep empty unless a
|
||||
* gap is genuinely accepted; remove entries as gaps close.
|
||||
*/
|
||||
function stripOriginDisclosure(history: readonly unknown[]): readonly unknown[] {
|
||||
return history.map((message) => {
|
||||
const record = message as { readonly origin?: { readonly disclosure?: unknown } };
|
||||
if (record.origin?.disclosure === undefined) return message;
|
||||
const origin = { ...record.origin };
|
||||
delete origin.disclosure;
|
||||
return { ...record, origin };
|
||||
});
|
||||
}
|
||||
|
||||
const KNOWN_DIFFS = {
|
||||
// v2's flag registry is per-domain and already carries flags v1 does not
|
||||
// have (minidb backend, subagent); v1-only flags would be the symmetric
|
||||
|
|
@ -249,10 +260,14 @@ const KNOWN_DIFFS = {
|
|||
// provider-MEASURED prefix. In-memory appends (e.g. importContext) count
|
||||
// identically on both engines via the shared `estimateTokensForMessages`,
|
||||
// but once the provider reports measured usage the counts diverge by
|
||||
// design. Histories compare in full; the count compares only in the
|
||||
// pre-LLM state where both sides still estimate.
|
||||
// design. Histories compare in full except for `origin.disclosure` — v2
|
||||
// records typed reminder-render state there (swarm mode, once-reminder
|
||||
// ids) and v1 has no such field; the count compares only in the pre-LLM
|
||||
// state where both sides still estimate.
|
||||
getContext: (context: { readonly history: readonly unknown[] }): unknown =>
|
||||
context.history.length === 0 ? context : { history: context.history },
|
||||
context.history.length === 0
|
||||
? context
|
||||
: { history: stripOriginDisclosure(context.history) },
|
||||
// Plan ids are random per engine (hero slugs) and the plan path embeds
|
||||
// both the per-home session dir and the id, so the comparison covers the
|
||||
// content and the path LAYOUT (id scrubbed); the id itself is asserted
|
||||
|
|
@ -896,10 +911,30 @@ async function writeFixturePlugin(dir: string): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
async function makePluginParityPair(): Promise<PluginParityPair> {
|
||||
async function writeManagedFixtureSkill(home: HomePair, body: string): Promise<void> {
|
||||
await writeFile(
|
||||
join(
|
||||
home.real,
|
||||
'plugins',
|
||||
'managed',
|
||||
FIXTURE_PLUGIN_ID,
|
||||
'skills',
|
||||
'parity-skill',
|
||||
'SKILL.md',
|
||||
),
|
||||
`---\nname: parity-skill\ndescription: Skill from the parity fixture plugin\n---\n\n${body}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
async function makePluginParityPair(configToml?: string): Promise<PluginParityPair> {
|
||||
const v1HomeDir = await makeTempDir('kimi-sdk-parity-v1-home-');
|
||||
const v2HomeDir = await makeTempDir('kimi-sdk-parity-v2-home-');
|
||||
const sourceDir = await makeTempDir('kimi-sdk-parity-plugin-src-');
|
||||
if (configToml !== undefined) {
|
||||
await writeFile(join(v1HomeDir, 'config.toml'), configToml, 'utf-8');
|
||||
await writeFile(join(v2HomeDir, 'config.toml'), configToml, 'utf-8');
|
||||
}
|
||||
await writeFixturePlugin(sourceDir);
|
||||
return {
|
||||
v1: new SDKRpcClient({ homeDir: v1HomeDir, identity: TEST_IDENTITY }),
|
||||
|
|
@ -1094,6 +1129,54 @@ describe('v1↔v2 plugin parity', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('reloadPlugins refreshes frozen session-start guidance in a live v2 session', async () => {
|
||||
const pair = await makePluginParityPair(AGENT_CONFIG_TOML);
|
||||
const workDir = await makeTempDir('kimi-sdk-parity-work-');
|
||||
try {
|
||||
await pair.v2.installPlugin(pair.sourceDir);
|
||||
await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false);
|
||||
await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false);
|
||||
const session = await pair.v2.createSession({ workDir, permission: 'yolo' });
|
||||
|
||||
await pair.v2.reloadPlugins();
|
||||
expect(JSON.stringify((await pair.v2.getContext({ sessionId: session.id })).history)).toContain(
|
||||
'Parity skill body.',
|
||||
);
|
||||
|
||||
await writeManagedFixtureSkill(pair.v2Home, 'Live reload skill body.');
|
||||
await pair.v2.reloadPlugins();
|
||||
|
||||
const history = (await pair.v2.getContext({ sessionId: session.id })).history;
|
||||
expect(JSON.stringify(history.at(-1))).toContain('Live reload skill body.');
|
||||
} finally {
|
||||
await closePluginPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
it('reloadSession refreshes frozen session-start guidance for a cold v2 session', async () => {
|
||||
const pair = await makePluginParityPair(AGENT_CONFIG_TOML);
|
||||
const workDir = await makeTempDir('kimi-sdk-parity-work-');
|
||||
try {
|
||||
await pair.v2.installPlugin(pair.sourceDir);
|
||||
await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-stdio', false);
|
||||
await pair.v2.setPluginMcpServerEnabled(FIXTURE_PLUGIN_ID, 'parity-http', false);
|
||||
const session = await pair.v2.createSession({ workDir, permission: 'yolo' });
|
||||
await pair.v2.reloadPlugins();
|
||||
await pair.v2.closeSession({ sessionId: session.id });
|
||||
|
||||
await writeManagedFixtureSkill(pair.v2Home, 'Cold reload skill body.');
|
||||
await pair.v2.reloadSession({
|
||||
sessionId: session.id,
|
||||
forcePluginSessionStartReminder: true,
|
||||
});
|
||||
|
||||
const history = (await pair.v2.getContext({ sessionId: session.id })).history;
|
||||
expect(JSON.stringify(history.at(-1))).toContain('Cold reload skill body.');
|
||||
} finally {
|
||||
await closePluginPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
it('listPluginCommands returns the same enabled commands', async () => {
|
||||
const pair = await makePluginParityPair();
|
||||
const workDir = await makeTempDir('kimi-sdk-parity-work-');
|
||||
|
|
@ -4299,10 +4382,9 @@ describe('v1↔v2 event & interaction parity', () => {
|
|||
// Residual surface parity (exportSession / startBtw / swarm mode / listSkills)
|
||||
//
|
||||
// The last migrated methods, driven through `SDKRpcClient` / `SDKRpcClientV2`
|
||||
// directly like the other session batches. No provider calls: export reads
|
||||
// persisted state, btw/swarm are context-only, and the `swarm()` case uses the
|
||||
// model-less prompt failure path (its turn start/end is what drives the
|
||||
// task-trigger auto-exit on both engines).
|
||||
// directly like the other session batches. Reminder assertions force the v2
|
||||
// context-injection boundary explicitly, so they test model-facing
|
||||
// materialization without contacting a provider.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Poll a condition until it holds or the budget runs out (engine-async settle). */
|
||||
|
|
@ -4441,6 +4523,11 @@ describe('v1↔v2 residual surface parity', () => {
|
|||
key === 'origin' ? undefined : value,
|
||||
),
|
||||
);
|
||||
// Both engines materialize the side-question reminder while forking:
|
||||
// v2 appends it at the fork event point (a past-tense one-off fact),
|
||||
// so the inherited contexts are already identical right after fork.
|
||||
expect(v1Context.history).toHaveLength(2);
|
||||
expect(v2Context.history).toHaveLength(2);
|
||||
expect(stripOrigins(v2Context)).toEqual(stripOrigins(v1Context));
|
||||
// Non-vacuous: the inherited import plus the side-question reminder
|
||||
// (byte-identical template on both engines).
|
||||
|
|
@ -4496,8 +4583,8 @@ describe('v1↔v2 residual surface parity', () => {
|
|||
expect(v1Inactive.swarmMode).toBe(false);
|
||||
expect(v2Inactive.swarmMode).toBe(false);
|
||||
const [v1Exited, v2Exited] = await historyOnBoth();
|
||||
expect(project(v2Exited)).toEqual(project(v1Exited));
|
||||
expect(v1Exited.history).toHaveLength(0);
|
||||
expect(project(v2Exited)).toEqual(project(v1Exited));
|
||||
|
||||
// Exit is idempotent too: a second exit is a silent no-op on both.
|
||||
await Promise.all([
|
||||
|
|
@ -4506,7 +4593,7 @@ describe('v1↔v2 residual surface parity', () => {
|
|||
]);
|
||||
const [v1Idle, v2Idle] = await historyOnBoth();
|
||||
expect(v1Idle.history).toHaveLength(0);
|
||||
expect(v2Idle.history).toHaveLength(0);
|
||||
expect(project(v2Idle)).toEqual(project(v1Idle));
|
||||
|
||||
// The `tool` trigger injects no reminder on either engine.
|
||||
await Promise.all([
|
||||
|
|
@ -4518,7 +4605,7 @@ describe('v1↔v2 residual surface parity', () => {
|
|||
expect(v2Tool.swarmMode).toBe(true);
|
||||
const [v1ToolHistory, v2ToolHistory] = await historyOnBoth();
|
||||
expect(v1ToolHistory.history).toHaveLength(0);
|
||||
expect(v2ToolHistory.history).toHaveLength(0);
|
||||
expect(project(v2ToolHistory)).toEqual(project(v1ToolHistory));
|
||||
await Promise.all([
|
||||
pair.v1.setSwarmMode({ ...input, enabled: false }),
|
||||
pair.v2.setSwarmMode({ ...input, enabled: false }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue