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:
parent
cb488df572
commit
6bfd447fc9
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user