Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,12 @@ interface Module {

// Optional
onAgentSpeech?(agentName: string, content: ContentBlock[], context: SpeechContext): Promise<void>;
gatherContext?(agentName: string): Promise<ContextInjection[]>;
gatherContext?(agentName: string): Promise<ContextInjection[]>; // 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
Expand Down Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions changelog.d/deprecate-context-injection.changed.md
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.
57 changes: 57 additions & 0 deletions src/context-injection-deprecation.ts
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. ' +
Comment on lines +27 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 system warnings overstate cache loss

This notice also runs for system injections, but buildSystemPrompt() 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 beforeUser and afterUser here and in the matching docs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/context-injection-deprecation.ts
Line: 27-29

Comment:
**`system` warnings overstate cache loss**

This notice also runs for `system` injections, but `buildSystemPrompt()` 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 `beforeUser` and `afterUser` here and in the matching docs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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!

'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();
}
4 changes: 4 additions & 0 deletions src/mcpl/hook-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/mcpl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down
8 changes: 7 additions & 1 deletion src/module-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';

Expand Down Expand Up @@ -379,7 +380,12 @@ export class ModuleRegistry {
);
let timer: ReturnType<typeof setTimeout> | 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<void>((_, rej) => {
timer = setTimeout(
() => rej(new Error(`gatherContext timed out after ${budgetMs}ms`)),
Expand Down
11 changes: 11 additions & 0 deletions src/types/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<ContextInjection[]>;

Expand Down
108 changes: 108 additions & 0 deletions test/context-injection-deprecation.test.ts
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')));
});
Loading