feat: make llm_input/llm_output modifying hooks for middleware patterns

Convert llm_input and llm_output from void (observational) hooks to
modifying hooks that can transform data flowing to/from LLM providers.

This enables an entire class of plugins that need to intercept and
modify LLM traffic:
- PII redaction (strip personal info before provider, rehydrate after)
- Content filtering and guardrails
- Translation middleware
- Token optimization
- Provider-agnostic safety layers

Changes:
- types.ts: Add PluginHookLlmInputResult and PluginHookLlmOutputResult
- types.ts: Update PluginHookHandlerMap to allow returning results
- hooks.ts: Switch runLlmInput/runLlmOutput from runVoidHook to
  runModifyingHook with merge functions
- attempt.ts: Await hook results and apply prompt/assistantTexts
  modifications when returned
- wired-hooks-llm.test.ts: Add tests for modifying behavior and
  backward compatibility (void returns still work)

Backward compatible: existing plugins that return void from
llm_input/llm_output continue to work unchanged.

Closes #20416
This commit is contained in:
Chandika Jayasundara 2026-02-18 22:16:50 +00:00
parent cb488df572
commit 6bfd447fc9
4 changed files with 162 additions and 23 deletions

View File

@ -779,7 +779,7 @@ export async function runEmbeddedAttempt(
});
const {
assistantTexts,
assistantTexts: rawAssistantTexts,
toolMetas,
unsubscribe,
waitForCompactionRetry,
@ -792,6 +792,7 @@ export async function runEmbeddedAttempt(
getUsageTotals,
getCompactionCount,
} = subscription;
let assistantTexts = rawAssistantTexts;
const queueHandle: EmbeddedPiQueueHandle = {
queueMessage: async (text: string) => {
@ -1014,9 +1015,10 @@ export async function runEmbeddedAttempt(
);
}
// Run llm_input hook — plugins may modify prompt/systemPrompt
if (hookRunner?.hasHooks("llm_input")) {
hookRunner
.runLlmInput(
try {
const llmInputResult = await hookRunner.runLlmInput(
{
runId: params.runId,
sessionId: params.sessionId,
@ -1034,10 +1036,14 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
)
.catch((err) => {
log.warn(`llm_input hook failed: ${String(err)}`);
});
);
// Apply any modifications from the hook
if (llmInputResult?.prompt) {
effectivePrompt = llmInputResult.prompt;
}
} catch (err) {
log.warn(`llm_input hook failed: ${String(err)}`);
}
}
// Only pass images option if there are actually images to pass
@ -1208,9 +1214,10 @@ export async function runEmbeddedAttempt(
)
.map((entry) => ({ toolName: entry.toolName, meta: entry.meta }));
// Run llm_output hook — plugins may modify assistantTexts
if (hookRunner?.hasHooks("llm_output")) {
hookRunner
.runLlmOutput(
try {
const llmOutputResult = await hookRunner.runLlmOutput(
{
runId: params.runId,
sessionId: params.sessionId,
@ -1227,10 +1234,14 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
)
.catch((err) => {
log.warn(`llm_output hook failed: ${String(err)}`);
});
);
// Apply any modifications from the hook
if (llmOutputResult?.assistantTexts) {
assistantTexts = llmOutputResult.assistantTexts;
}
} catch (err) {
log.warn(`llm_output hook failed: ${String(err)}`);
}
}
return {

View File

@ -19,7 +19,9 @@ import type {
PluginHookBeforePromptBuildResult,
PluginHookBeforeCompactionEvent,
PluginHookLlmInputEvent,
PluginHookLlmInputResult,
PluginHookLlmOutputEvent,
PluginHookLlmOutputResult,
PluginHookBeforeResetEvent,
PluginHookBeforeToolCallEvent,
PluginHookBeforeToolCallResult,
@ -54,7 +56,9 @@ export type {
PluginHookBeforePromptBuildEvent,
PluginHookBeforePromptBuildResult,
PluginHookLlmInputEvent,
PluginHookLlmInputResult,
PluginHookLlmOutputEvent,
PluginHookLlmOutputResult,
PluginHookAgentEndEvent,
PluginHookBeforeCompactionEvent,
PluginHookBeforeResetEvent,
@ -278,20 +282,43 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
/**
* Run llm_input hook.
* Allows plugins to observe the exact input payload sent to the LLM.
* Runs in parallel (fire-and-forget).
* Allows plugins to observe or modify the input payload sent to the LLM.
* Plugins can return `{ prompt, systemPrompt }` to transform the input,
* or return void/undefined for observation-only (backward compatible).
*/
async function runLlmInput(event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) {
return runVoidHook("llm_input", event, ctx);
async function runLlmInput(
event: PluginHookLlmInputEvent,
ctx: PluginHookAgentContext,
): Promise<PluginHookLlmInputResult | undefined> {
return runModifyingHook<"llm_input", PluginHookLlmInputResult>(
"llm_input",
event,
ctx,
(acc, next) => ({
prompt: next.prompt ?? acc?.prompt,
systemPrompt: next.systemPrompt ?? acc?.systemPrompt,
}),
);
}
/**
* Run llm_output hook.
* Allows plugins to observe the exact output payload returned by the LLM.
* Runs in parallel (fire-and-forget).
* Allows plugins to observe or modify the output payload returned by the LLM.
* Plugins can return `{ assistantTexts }` to transform the output,
* or return void/undefined for observation-only (backward compatible).
*/
async function runLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext) {
return runVoidHook("llm_output", event, ctx);
async function runLlmOutput(
event: PluginHookLlmOutputEvent,
ctx: PluginHookAgentContext,
): Promise<PluginHookLlmOutputResult | undefined> {
return runModifyingHook<"llm_output", PluginHookLlmOutputResult>(
"llm_output",
event,
ctx,
(acc, next) => ({
assistantTexts: next.assistantTexts ?? acc?.assistantTexts,
}),
);
}
/**

View File

@ -373,6 +373,14 @@ export type PluginHookLlmInputEvent = {
imagesCount: number;
};
// llm_input hook result (when used as a modifying hook)
export type PluginHookLlmInputResult = {
/** Modified prompt text. If set, replaces the original prompt. */
prompt?: string;
/** Modified system prompt. If set, replaces the original system prompt. */
systemPrompt?: string;
};
// llm_output hook
export type PluginHookLlmOutputEvent = {
runId: string;
@ -390,6 +398,12 @@ export type PluginHookLlmOutputEvent = {
};
};
// llm_output hook result (when used as a modifying hook)
export type PluginHookLlmOutputResult = {
/** Modified assistant response texts. If set, replaces the originals. */
assistantTexts?: string[];
};
// agent_end hook
export type PluginHookAgentEndEvent = {
messages: unknown[];
@ -579,11 +593,14 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
llm_input: (
event: PluginHookLlmInputEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookLlmInputResult | void> | PluginHookLlmInputResult | void;
llm_output: (
event: PluginHookLlmOutputEvent,
ctx: PluginHookAgentContext,
) => Promise<void> | void;
) => Promise<PluginHookLlmOutputResult | void> | PluginHookLlmOutputResult | void;
agent_end: (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
before_compaction: (
event: PluginHookBeforeCompactionEvent,

View File

@ -69,4 +69,88 @@ describe("llm hook runner methods", () => {
expect(runner.hasHooks("llm_input")).toBe(true);
expect(runner.hasHooks("llm_output")).toBe(false);
});
it("runLlmInput returns modified prompt from hook", async () => {
const handler = vi.fn().mockResolvedValue({ prompt: "redacted prompt" });
const registry = createMockPluginRegistry([{ hookName: "llm_input", handler }]);
const runner = createHookRunner(registry);
const result = await runner.runLlmInput(
{
runId: "run-1",
sessionId: "session-1",
provider: "openai",
model: "gpt-5",
systemPrompt: "be helpful",
prompt: "original prompt",
historyMessages: [],
imagesCount: 0,
},
{ agentId: "main", sessionId: "session-1" },
);
expect(result).toEqual(expect.objectContaining({ prompt: "redacted prompt" }));
});
it("runLlmInput returns undefined when hook returns void (backward compat)", async () => {
const handler = vi.fn(); // returns undefined
const registry = createMockPluginRegistry([{ hookName: "llm_input", handler }]);
const runner = createHookRunner(registry);
const result = await runner.runLlmInput(
{
runId: "run-1",
sessionId: "session-1",
provider: "openai",
model: "gpt-5",
prompt: "hello",
historyMessages: [],
imagesCount: 0,
},
{ agentId: "main", sessionId: "session-1" },
);
expect(result).toBeUndefined();
});
it("runLlmOutput returns modified assistantTexts from hook", async () => {
const handler = vi.fn().mockResolvedValue({ assistantTexts: ["rehydrated response"] });
const registry = createMockPluginRegistry([{ hookName: "llm_output", handler }]);
const runner = createHookRunner(registry);
const result = await runner.runLlmOutput(
{
runId: "run-1",
sessionId: "session-1",
provider: "openai",
model: "gpt-5",
assistantTexts: ["raw «PERSON_001» response"],
lastAssistant: { role: "assistant", content: "raw" },
usage: { input: 10, output: 20, total: 30 },
},
{ agentId: "main", sessionId: "session-1" },
);
expect(result).toEqual(expect.objectContaining({ assistantTexts: ["rehydrated response"] }));
});
it("runLlmOutput returns undefined when hook returns void (backward compat)", async () => {
const handler = vi.fn(); // returns undefined
const registry = createMockPluginRegistry([{ hookName: "llm_output", handler }]);
const runner = createHookRunner(registry);
const result = await runner.runLlmOutput(
{
runId: "run-1",
sessionId: "session-1",
provider: "openai",
model: "gpt-5",
assistantTexts: ["hi"],
usage: { input: 10, output: 20, total: 30 },
},
{ agentId: "main", sessionId: "session-1" },
);
expect(result).toBeUndefined();
});
});