Repository navigation
chore(deprecate): context injection (gatherContext + MCPL beforeInference) #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
antra-tess
wants to merge
1
commit into
main
Choose a base branch
from
chore/deprecate-context-injection
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>(); | ||
|
|
||
| /** | ||
| * 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(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void>): Promise<string[]> { | ||
| 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'))); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
systemwarnings overstate cache lossThis notice also runs for
systeminjections, butbuildSystemPrompt()appends that content to the system prompt. Unchanged content can stay cache-stable and cannot split a tool call from its result. Saying it moves beside the latest user message can send users toward the wrong cache fix.Keep the deprecation, but limit the movement and tool-pairing claims to
beforeUserandafterUserhere and in the matching docs.Prompt To Fix With AI
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!