From 6552677fd52bebc203175c9a67931e311c7f10cd Mon Sep 17 00:00:00 2001 From: antra-tess Date: Fri, 2 Oct 2026 19:23:09 -0700 Subject: [PATCH] chore(deprecate): context injection (gatherContext + MCPL beforeInference) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injected context blocks are per-compile overlays: never stored, and re-anchored to the latest user-participant message on every compile. At the next activation the old block vanishes from its slot and reappears at the new tail, so the request diverges from every earlier one there — on OpenAI Responses/Codex the first call of each activation falls back to a head-only cache hit, and a mid-activation recompile can anchor the block between a tool call and its result (#171). Deprecate both sources without changing behavior: - `Module.gatherContext`, `Module.contextTimeoutMs` and `BeforeInferenceResult.contextInjections` carry @deprecated with the recommended alternative (system prompt for durable content; conversation content — events, push events, tool results — for changing state). - ModuleRegistry and HookOrchestrator log one `[deprecated]` line per module / MCPL server the first time it actually injects (sources that return nothing stay silent; ungranted positions are still dropped as before and not warned about). - README notes + changelog fragment. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- .../deprecate-context-injection.changed.md | 13 +++ src/context-injection-deprecation.ts | 57 +++++++++ src/mcpl/hook-orchestrator.ts | 4 + src/mcpl/types.ts | 10 +- src/module-registry.ts | 8 +- src/types/module.ts | 11 ++ test/context-injection-deprecation.test.ts | 108 ++++++++++++++++++ 8 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 changelog.d/deprecate-context-injection.changed.md create mode 100644 src/context-injection-deprecation.ts create mode 100644 test/context-injection-deprecation.test.ts diff --git a/README.md b/README.md index 180c94114..dded654ad 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,12 @@ interface Module { // Optional onAgentSpeech?(agentName: string, content: ContentBlock[], context: SpeechContext): Promise; - gatherContext?(agentName: string): Promise; + gatherContext?(agentName: string): Promise; // DEPRECATED — see below } ``` +> **Deprecated: `gatherContext` and context injection.** Injected blocks are per-compile overlays — never stored, re-anchored to the latest user message on every compile — so they break prompt-cache prefixes across activations (head-only cache hits on OpenAI lanes) and can land between a tool call and its result ([#171](https://github.com/anima-research/agent-framework/issues/171)). Don't add new implementations: put durable content in the system prompt and deliver changing state as conversation content (an `EventResponse` that adds a message, a push event, a tool result). Existing injections still apply; the framework logs one `[deprecated]` line per injecting module or MCPL server. + An `EventResponse` can add/edit/remove messages, request inference, signal tool changes, and atomically update module state: ```typescript @@ -125,7 +127,7 @@ Each agent has a `ContextManager` (from `@connectome/context-manager`) that main - **PassthroughStrategy** - No compression, raw message replay up to budget - **AutobiographicalStrategy** - Chunks old messages and summarizes them into diary entries, preserving recent context uncompressed -Before inference, modules and MCPL hooks can inject additional context via `gatherContext()`. +Before inference, modules (`gatherContext()`) and MCPL servers (`context/beforeInference`) can still inject additional context, but **context injection is deprecated** (see the note under Modules and [#171](https://github.com/anima-research/agent-framework/issues/171)). ### Persistence diff --git a/changelog.d/deprecate-context-injection.changed.md b/changelog.d/deprecate-context-injection.changed.md new file mode 100644 index 000000000..051abf7e3 --- /dev/null +++ b/changelog.d/deprecate-context-injection.changed.md @@ -0,0 +1,13 @@ +- **Context injection is deprecated** — module `gatherContext()` and MCPL + `context/beforeInference` `contextInjections` alike. Injected blocks are + per-compile overlays that are never stored and are re-anchored to the latest + user message on every compile, so they break prompt-cache prefixes across + activations (on OpenAI Responses/Codex lanes the first call of every + activation falls back to a head-only cache hit) and can land between a tool + call and its result after a mid-activation recompile (#171). Behavior is + unchanged for now: injections still apply, and the host logs one + `[deprecated]` line per module or MCPL server the first time it injects. + `Module.gatherContext`, `Module.contextTimeoutMs` and + `BeforeInferenceResult.contextInjections` carry `@deprecated`. Put durable + content in the system prompt and deliver changing state as conversation + content (events, push events, tool results) instead. diff --git a/src/context-injection-deprecation.ts b/src/context-injection-deprecation.ts new file mode 100644 index 000000000..2b8bea621 --- /dev/null +++ b/src/context-injection-deprecation.ts @@ -0,0 +1,57 @@ +/** + * Context injection is DEPRECATED — both sources: + * + * - module `gatherContext()` (see `Module.gatherContext`), and + * - MCPL `context/beforeInference` `contextInjections` (spec §10.2). + * + * Injected blocks are per-compile overlays: never stored, and re-anchored to + * the latest user-participant message on every compile. On the next + * activation the old block vanishes from its slot and reappears at the new + * tail, so the request diverges from every earlier one at that point. On + * providers whose prompt cache only hits boundaries an earlier request + * wrote (OpenAI Responses / Codex), the first call of every activation falls + * back to a head-only hit; on Anthropic the loss is bounded but real. A + * mid-activation recompile (e.g. a stream retry) can also anchor the block + * between a tool call and its result. See anima-research/agent-framework#171. + * + * Behavior is unchanged for now — injections are still applied. Hosts log + * one `[deprecated]` line per injecting module / server so remaining uses + * are visible before the mechanism is removed. + * + * Instead: put durable instructions in the system prompt; deliver + * time-varying state as conversation content (push events, tool results) + * so it is stored once at its own position and stays cache-stable. + */ + +export const CONTEXT_INJECTION_DEPRECATION_NOTICE = + 'Context injection is deprecated: injected blocks are per-compile overlays that are never ' + + 'stored and are re-anchored on every activation, which breaks prompt-cache prefixes ' + + '(head-only hits on OpenAI lanes) and can land between a tool call and its result. ' + + 'Put durable content in the system prompt and deliver changing state as conversation ' + + 'content (push events, tool results) instead. See anima-research/agent-framework#171.'; + +const warned = new Set(); + +/** + * Log the deprecation once per (kind, id) for the life of the process. + * Call it only when the source actually produced at least one injection. + */ +export function warnContextInjectionDeprecated( + kind: 'module' | 'mcpl-server', + id: string, + detail?: string, +): void { + const key = `${kind}:${id}`; + if (warned.has(key)) return; + warned.add(key); + const who = kind === 'module' ? `module "${id}"` : `MCPL server "${id}"`; + console.warn( + `[deprecated] ${who} injected context${detail ? ` (${detail})` : ''}. ` + + CONTEXT_INJECTION_DEPRECATION_NOTICE, + ); +} + +/** Test-only: forget which sources have already been warned about. */ +export function resetContextInjectionDeprecationWarnings(): void { + warned.clear(); +} diff --git a/src/mcpl/hook-orchestrator.ts b/src/mcpl/hook-orchestrator.ts index 14c4e85dc..a78db1c3d 100644 --- a/src/mcpl/hook-orchestrator.ts +++ b/src/mcpl/hook-orchestrator.ts @@ -28,6 +28,7 @@ import type { McplServerRegistry } from './server-registry.js'; import type { McplServerConnection } from './server-connection.js'; import type { FeatureSetManager } from './feature-set-manager.js'; import { CapabilityGrant } from './capability-grant.js'; +import { warnContextInjectionDeprecated } from '../context-injection-deprecation.js'; /** Timeout for beforeInference per server (fail-open). */ const BEFORE_INFERENCE_TIMEOUT_MS = 5_000; @@ -275,6 +276,9 @@ export class HookOrchestrator { ); continue; } + // Deprecated mechanism (agent-framework#171): still applied, but + // every injecting server is named once on stderr. + warnContextInjectionDeprecated('mcpl-server', server.id, `position "${mcplInj.position}"`); injections.push(convertMcplInjection(mcplInj)); } } diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index c080af76d..b9481c683 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -826,7 +826,15 @@ export interface BeforeInferenceResult { /** Feature set that provided this response */ featureSet: string; - /** Context injections to apply */ + /** + * Context injections to apply. + * + * @deprecated Context injection is deprecated (agent-framework#171): + * injections are never stored and are re-anchored on every compile, which + * breaks prompt-cache prefixes. Servers should return an empty array and + * deliver state as push events or tool results instead. Hosts still apply + * injections for now and log a `[deprecated]` line per injecting server. + */ contextInjections: McplContextInjection[]; } diff --git a/src/module-registry.ts b/src/module-registry.ts index 7d2bcc87e..4bf7a400f 100644 --- a/src/module-registry.ts +++ b/src/module-registry.ts @@ -24,6 +24,7 @@ import type { TraceEventListener, } from './types/index.js'; import type { Agent } from './agent.js'; +import { warnContextInjectionDeprecated } from './context-injection-deprecation.js'; const MODULE_STATE_PREFIX = 'modules/'; @@ -379,7 +380,12 @@ export class ModuleRegistry { ); let timer: ReturnType | undefined; const promise = Promise.race([ - module.gatherContext(agentName).then(r => injections.push(...r)), + module.gatherContext(agentName).then(r => { + // Deprecated mechanism (agent-framework#171): still applied, but + // every injecting module is named once on stderr. + if (r.length > 0) warnContextInjectionDeprecated('module', module.name); + injections.push(...r); + }), new Promise((_, rej) => { timer = setTimeout( () => rej(new Error(`gatherContext timed out after ${budgetMs}ms`)), diff --git a/src/types/module.ts b/src/types/module.ts index 724abaae0..485d0b3c8 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -42,6 +42,8 @@ export interface Module { * default (15s) when omitted. Declare a larger budget when gatherContext * does real work — e.g. sequential LLM calls with provider backoff — so the * injection isn't silently dropped every turn. + * + * @deprecated Only meaningful for the deprecated `gatherContext` hook. */ readonly contextTimeoutMs?: number; @@ -106,6 +108,15 @@ export interface Module { * that should be injected into the compiled context. * Complementary to MCPL push-based hooks — modules pull via gatherContext. * Adapted from Anarchid/agent-framework@mcpl-module-proto. + * + * @deprecated Context injection is deprecated (agent-framework#171). + * Injections are per-compile overlays — never stored, re-anchored to the + * latest user message on every compile — so they break prompt-cache + * prefixes across activations (head-only hits on OpenAI lanes) and can + * land between a tool call and its result. Do not add new implementations: + * put durable content in the system prompt and deliver changing state as + * conversation content (events, tool results). Still called for now; the + * framework logs one `[deprecated]` line per module that injects. */ gatherContext?(agentName: string): Promise; diff --git a/test/context-injection-deprecation.test.ts b/test/context-injection-deprecation.test.ts new file mode 100644 index 000000000..4a9817936 --- /dev/null +++ b/test/context-injection-deprecation.test.ts @@ -0,0 +1,108 @@ +/** + * Context injection is deprecated (#171). Behavior must be unchanged — + * injections still apply — but every injecting source is named exactly once + * on stderr, and sources that inject nothing stay silent. + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { ModuleRegistry } from '../src/module-registry.js'; +import { HookOrchestrator } from '../src/mcpl/hook-orchestrator.js'; +import { CapabilityGrant } from '../src/mcpl/capability-grant.js'; +import { resetContextInjectionDeprecationWarnings } from '../src/context-injection-deprecation.js'; +import type { Module } from '../src/types/index.js'; + +function makeRegistry(modules: Module[]): ModuleRegistry { + const registry = Object.create(ModuleRegistry.prototype) as ModuleRegistry; + (registry as any).modules = new Map(modules.map((m) => [m.name, m])); + return registry; +} + +function injectingModule(name: string, count: number): Module { + return { + name, + start: async () => {}, + stop: async () => {}, + getTools: () => [], + handleToolCall: async () => ({ success: true }), + onProcess: async () => ({}), + gatherContext: async () => Array.from({ length: count }, (_, i) => ({ + namespace: name, + position: 'afterUser' as const, + content: [{ type: 'text' as const, text: `${name} #${i}` }], + })), + }; +} + +async function captureWarnings(fn: () => Promise): Promise { + const lines: string[] = []; + const orig = console.warn; + console.warn = (...a: unknown[]) => { lines.push(a.join(' ')); }; + try { await fn(); } finally { console.warn = orig; } + return lines.filter((l) => l.includes('[deprecated]')); +} + +beforeEach(() => resetContextInjectionDeprecationWarnings()); + +test('a module that injects is still applied and is named once across turns', async () => { + const registry = makeRegistry([injectingModule('hud', 1)]); + let first: unknown[] = []; + const warnings = await captureWarnings(async () => { + first = await registry.gatherContext('agent'); + await registry.gatherContext('agent'); + await registry.gatherContext('agent'); + }); + assert.equal(first.length, 1, 'injection still applied'); + assert.equal(warnings.length, 1, `one warning for three turns, got ${JSON.stringify(warnings)}`); + assert.match(warnings[0], /module "hud" injected context/); + assert.match(warnings[0], /#171/); +}); + +test('a module whose gatherContext returns nothing is not warned about', async () => { + const registry = makeRegistry([injectingModule('quiet', 0), injectingModule('loud', 2)]); + const warnings = await captureWarnings(async () => { await registry.gatherContext('agent'); }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /"loud"/); +}); + +function orchestratorWith(grantPaths: string[], position: string) { + const server = { + id: 'recap-server', + grant: new CapabilityGrant(new Set(grantPaths), []), + sendBeforeInference: async () => ({ + featureSet: 'recap', + contextInjections: [{ namespace: 'recap', position, content: 'recent messages…' }], + }), + }; + const registry = { getAllServers: () => [server] } as any; + const featureSets = { validateInbound: () => {} } as any; + return new HookOrchestrator(registry, featureSets); +} + +const params = { agentName: 'agent', userMessage: 'hi' } as any; + +test('an MCPL server injection within its grant is still applied and named once', async () => { + const orchestrator = orchestratorWith(['contextHooks.beforeInference.inject.beforeUser'], 'beforeUser'); + let applied: unknown[] = []; + const warnings = await captureWarnings(async () => { + applied = await orchestrator.beforeInference(params); + await orchestrator.beforeInference(params); + }); + assert.equal(applied.length, 1, 'injection still applied'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /MCPL server "recap-server" injected context \(position "beforeUser"\)/); +}); + +test('an injection outside the grant is dropped as before and does not warn', async () => { + const orchestrator = orchestratorWith(['contextHooks.beforeInference.inject.beforeUser'], 'system'); + const errs: string[] = []; + const origErr = console.error; + console.error = (...a: unknown[]) => { errs.push(a.join(' ')); }; + let applied: unknown[] = []; + let warnings: string[] = []; + try { + warnings = await captureWarnings(async () => { applied = await orchestrator.beforeInference(params); }); + } finally { console.error = origErr; } + assert.equal(applied.length, 0); + assert.equal(warnings.length, 0); + assert.ok(errs.some((e) => e.includes('dropped beforeInference injection'))); +});