diff --git a/docs/hooks/reference.md b/docs/hooks/reference.md index f65abeaf84..bcc8a102a6 100644 --- a/docs/hooks/reference.md +++ b/docs/hooks/reference.md @@ -106,13 +106,217 @@ If the hook exits with `0`, the CLI attempts to parse `stdout` as JSON. ### `hookSpecificOutput` Reference -| Field | Supported Events | Description | -| :------------------ | :----------------------------------------- | :-------------------------------------------------------------------------------- | -| `additionalContext` | `SessionStart`, `BeforeAgent`, `AfterTool` | Appends text directly to the agent's context. | -| `llm_request` | `BeforeModel` | A `Partial` to override parameters of the outgoing call. | -| `llm_response` | `BeforeModel` | A **full** `LLMResponse` to bypass the model and provide a synthetic result. | -| `llm_response` | `AfterModel` | A `Partial` to modify the model's response before the agent sees it. | -| `toolConfig` | `BeforeToolSelection` | Object containing `mode` (`AUTO`/`ANY`/`NONE`) and `allowedFunctionNames`. | +### Matchers and tool names + +For `BeforeTool` and `AfterTool` events, the `matcher` field in your settings is +compared against the name of the tool being executed. + +- **Built-in Tools**: You can match any built-in tool (e.g., `read_file`, + `run_shell_command`). See the [Tools Reference](/docs/tools) for a full list + of available tool names. +- **MCP Tools**: Tools from MCP servers follow the naming pattern + `mcp____`. +- **Regex Support**: Matchers support regular expressions (e.g., + `matcher: "read_.*"` matches all file reading tools). + +### `BeforeTool` + +Fires before a tool is invoked. Used for argument validation, security checks, +and parameter rewriting. + +- **Input Fields**: + - `tool_name`: (`string`) The name of the tool being called. + - `tool_input`: (`object`) The raw arguments generated by the model. + - `mcp_context`: (`object`) Optional metadata for MCP-based tools. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` (or `"block"`) to prevent the tool from + executing. + - `reason`: Required if denied. This text is sent **to the agent** as a tool + error, allowing it to respond or retry. + - `hookSpecificOutput.tool_input`: An object that **merges with and + overrides** the model's arguments before execution. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Tool)**: Prevents execution. Uses `stderr` as the + `reason` sent to the agent. **The turn continues.** + +### `AfterTool` + +Fires after a tool executes. Used for result auditing, context injection, or +hiding sensitive output from the agent. + +- **Input Fields**: + - `tool_name`: (`string`) + - `tool_input`: (`object`) The original arguments. + - `tool_response`: (`object`) The result containing `llmContent`, + `returnDisplay`, and optional `error`. + - `mcp_context`: (`object`) +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to hide the real tool output from the agent. + - `reason`: Required if denied. This text **replaces** the tool result sent + back to the model. + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + tool result for the agent. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Exit Code 2 (Block Result)**: Hides the tool result. Uses `stderr` as the + replacement content sent to the agent. **The turn continues.** + +--- + +## Agent hooks + +### `BeforeAgent` + +Fires after a user submits a prompt, but before the agent begins planning. Used +for prompt validation or injecting dynamic context. + +- **Input Fields**: + - `prompt`: (`string`) The original text submitted by the user. +- **Relevant Output Fields**: + - `hookSpecificOutput.additionalContext`: Text that is **appended** to the + prompt for this turn only. + - `decision`: Set to `"deny"` to block the turn and **discard the user's + message** (it will not appear in history). + - `continue`: Set to `false` to block the turn but **save the message to + history**. + - `reason`: Required if denied or stopped. +- **Exit Code 2 (Block Turn)**: Aborts the turn and erases the prompt from + context. Same as `decision: "deny"`. + +### `AfterAgent` + +Fires once per turn after the model generates its final response. Primary use +case is response validation and automatic retries. + +- **Input Fields**: + - `prompt`: (`string`) The user's original request. + - `prompt_response`: (`string`) The final text generated by the agent. + - `stop_hook_active`: (`boolean`) Indicates if this hook is already running as + part of a retry sequence. +- **Relevant Output Fields**: + - `decision`: Set to `"deny"` to **reject the response** and force a retry. + - `reason`: Required if denied. This text is sent **to the agent as a new + prompt** to request a correction. + - `continue`: Set to `false` to **stop the session** without retrying. + - `clearContext`: If `true`, clears conversation history (LLM memory) while + preserving UI display. +- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry + turn using `stderr` as the feedback prompt. + +--- + +## Model hooks + +### `BeforeModel` + +Fires before sending a request to the LLM. Operates on a stable, SDK-agnostic +request format. + +- **Input Fields**: + - `llm_request`: (`object`) Contains `model`, `messages`, and `config` + (generation params). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_request`: An object that **overrides** parts of the + outgoing request (e.g., changing models or temperature). + - `hookSpecificOutput.llm_response`: A **Synthetic Response** object. If + provided, the CLI skips the LLM call entirely and uses this as the response. + - `decision`: Set to `"deny"` to block the request and abort the turn. +- **Exit Code 2 (Block Turn)**: Aborts the turn and skips the LLM call. Uses + `stderr` as the error message. + +### `BeforeToolSelection` + +Fires before the LLM decides which tools to call. Used to filter the available +toolset or force specific tool modes. + +- **Input Fields**: + - `llm_request`: (`object`) Same format as `BeforeModel`. +- **Relevant Output Fields**: + - `hookSpecificOutput.toolConfig.mode`: (`"AUTO" | "ANY" | "NONE"`) + - `"NONE"`: Disables all tools (Wins over other hooks). + - `"ANY"`: Forces at least one tool call. + - `hookSpecificOutput.toolConfig.allowedFunctionNames`: (`string[]`) Whitelist + of tool names. +- **Union Strategy**: Multiple hooks' whitelists are **combined**. +- **Limitations**: Does **not** support `decision`, `continue`, or + `systemMessage`. + +### `AfterModel` + +Fires immediately after an LLM response chunk is received. Used for real-time +redaction or PII filtering. + +- **Input Fields**: + - `llm_request`: (`object`) The original request. + - `llm_response`: (`object`) The model's response (or a single chunk during + streaming). +- **Relevant Output Fields**: + - `hookSpecificOutput.llm_response`: An object that **replaces** the model's + response chunk. + - `decision`: Set to `"deny"` to discard the response chunk and block the + turn. + - `continue`: Set to `false` to **kill the entire agent loop** immediately. +- **Note on Streaming**: Fired for **every chunk** generated by the model. + Modifying the response only affects the current chunk. +- **Exit Code 2 (Block Response)**: Aborts the turn and discards the model's + output. Uses `stderr` as the error message. + +--- + +## Lifecycle & system hooks + +### `SessionStart` + +Fires on application startup, resuming a session, or after a `/clear` command. +Used for loading initial context. + +- **Input fields**: + - `source`: (`"startup" | "resume" | "clear"`) +- **Relevant output fields**: + - `hookSpecificOutput.additionalContext`: (`string`) + - **Interactive**: Injected as the first turn in history. + - **Non-interactive**: Prepended to the user's prompt. + - `systemMessage`: Shown at the start of the session. +- **Advisory only**: `continue` and `decision` fields are **ignored**. Startup + is never blocked. + +### `SessionEnd` + +Fires when the CLI exits or a session is cleared. Used for cleanup or final +telemetry. + +- **Input Fields**: + - `reason`: (`"exit" | "clear" | "logout" | "prompt_input_exit" | "other"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user during shutdown. +- **Best Effort**: The CLI **will not wait** for this hook to complete and + ignores all flow-control fields (`continue`, `decision`). + +### `Notification` + +Fires when the CLI emits a system alert (e.g., Tool Permissions). Used for +external logging or cross-platform alerts. + +- **Input Fields**: + - `notification_type`: (`"ToolPermission"`) + - `message`: Summary of the alert. + - `details`: JSON object with alert-specific metadata (e.g., tool name, file + path). +- **Relevant Output Fields**: + - `systemMessage`: Displayed alongside the system alert. +- **Observability Only**: This hook **cannot** block alerts or grant permissions + automatically. Flow-control fields are ignored. + +### `PreCompress` + +Fires before the CLI summarizes history to save tokens. Used for logging or +state saving. + +- **Input Fields**: + - `trigger`: (`"auto" | "manual"`) +- **Relevant Output Fields**: + - `systemMessage`: Displayed to the user before compression. +- **Advisory Only**: Fired asynchronously. It **cannot** block or modify the + compression process. Flow-control fields are ignored. --- diff --git a/integration-tests/hooks-agent-flow.test.ts b/integration-tests/hooks-agent-flow.test.ts index 462ec155b0..13eb0bcecc 100644 --- a/integration-tests/hooks-agent-flow.test.ts +++ b/integration-tests/hooks-agent-flow.test.ts @@ -155,6 +155,84 @@ describe('Hooks Agent Flow', () => { // The fake response contains "Hello World" expect(afterAgentLog?.hookCall.stdout).toContain('Hello World'); }); + + it('should process clearContext in AfterAgent hook output', async () => { + await rig.setup('should process clearContext in AfterAgent hook output', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.after-agent.responses', + ), + }); + + // BeforeModel hook to track message counts across LLM calls + const messageCountFile = join(rig.testDir!, 'message-counts.json'); + const beforeModelScript = ` + const fs = require('fs'); + const input = JSON.parse(fs.readFileSync(0, 'utf-8')); + const messageCount = input.llm_request?.contents?.length || 0; + let counts = []; + try { counts = JSON.parse(fs.readFileSync('${messageCountFile}', 'utf-8')); } catch (e) {} + counts.push(messageCount); + fs.writeFileSync('${messageCountFile}', JSON.stringify(counts)); + console.log(JSON.stringify({ decision: 'allow' })); + `; + const beforeModelScriptPath = join( + rig.testDir!, + 'before_model_counter.cjs', + ); + writeFileSync(beforeModelScriptPath, beforeModelScript); + + await rig.setup('should process clearContext in AfterAgent hook output', { + settings: { + hooks: { + enabled: true, + BeforeModel: [ + { + hooks: [ + { + type: 'command', + command: `node "${beforeModelScriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + AfterAgent: [ + { + hooks: [ + { + type: 'command', + command: `node -e "console.log(JSON.stringify({decision: 'block', reason: 'Security policy triggered', hookSpecificOutput: {hookEventName: 'AfterAgent', clearContext: true}}))"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + const result = await rig.run({ args: 'Hello test' }); + + const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call'); + expect(hookTelemetryFound).toBeTruthy(); + + const hookLogs = rig.readHookLogs(); + const afterAgentLog = hookLogs.find( + (log) => log.hookCall.hook_event_name === 'AfterAgent', + ); + + expect(afterAgentLog).toBeDefined(); + expect(afterAgentLog?.hookCall.stdout).toContain('clearContext'); + expect(afterAgentLog?.hookCall.stdout).toContain('true'); + expect(result).toContain('Security policy triggered'); + + // Verify context was cleared: second call should not have more messages than first + const countsRaw = rig.readFile('message-counts.json'); + const counts = JSON.parse(countsRaw) as number[]; + expect(counts.length).toBeGreaterThanOrEqual(2); + expect(counts[1]).toBeLessThanOrEqual(counts[0]); + }); }); describe('Multi-step Loops', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c6ddcc73e6..8900898319 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -802,7 +802,12 @@ export const useGeminiStream = ( ); const handleAgentExecutionStoppedEvent = useCallback( - (reason: string, userMessageTimestamp: number, systemMessage?: string) => { + ( + reason: string, + userMessageTimestamp: number, + systemMessage?: string, + contextCleared?: boolean, + ) => { if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); @@ -814,13 +819,27 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); + if (contextCleared) { + addItem( + { + type: MessageType.INFO, + text: 'Conversation context has been cleared.', + }, + userMessageTimestamp, + ); + } setIsResponding(false); }, [addItem, pendingHistoryItemRef, setPendingHistoryItem, setIsResponding], ); const handleAgentExecutionBlockedEvent = useCallback( - (reason: string, userMessageTimestamp: number, systemMessage?: string) => { + ( + reason: string, + userMessageTimestamp: number, + systemMessage?: string, + contextCleared?: boolean, + ) => { if (pendingHistoryItemRef.current) { addItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); @@ -832,6 +851,15 @@ export const useGeminiStream = ( }, userMessageTimestamp, ); + if (contextCleared) { + addItem( + { + type: MessageType.INFO, + text: 'Conversation context has been cleared.', + }, + userMessageTimestamp, + ); + } }, [addItem, pendingHistoryItemRef, setPendingHistoryItem], ); @@ -872,6 +900,7 @@ export const useGeminiStream = ( event.value.reason, userMessageTimestamp, event.value.systemMessage, + event.value.contextCleared, ); break; case ServerGeminiEventType.AgentExecutionBlocked: @@ -879,6 +908,7 @@ export const useGeminiStream = ( event.value.reason, userMessageTimestamp, event.value.systemMessage, + event.value.contextCleared, ); break; case ServerGeminiEventType.ChatCompressed: diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 2bb2836595..62c89e4b78 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -3107,6 +3107,7 @@ ${JSON.stringify( mockHookSystem.fireAfterAgentEvent.mockResolvedValue({ shouldStopExecution: () => true, getEffectiveReason: () => 'Stopped after agent', + shouldClearContext: () => false, systemMessage: undefined, }); @@ -3121,10 +3122,12 @@ ${JSON.stringify( ); const events = await fromAsync(stream); - expect(events).toContainEqual({ - type: GeminiEventType.AgentExecutionStopped, - value: { reason: 'Stopped after agent' }, - }); + expect(events).toContainEqual( + expect.objectContaining({ + type: GeminiEventType.AgentExecutionStopped, + value: expect.objectContaining({ reason: 'Stopped after agent' }), + }), + ); // sendMessageStream should not recurse expect(mockTurnRunFn).toHaveBeenCalledTimes(1); }); @@ -3135,11 +3138,60 @@ ${JSON.stringify( shouldStopExecution: () => false, isBlockingDecision: () => true, getEffectiveReason: () => 'Please explain', + shouldClearContext: () => false, systemMessage: undefined, }) .mockResolvedValueOnce({ shouldStopExecution: () => false, isBlockingDecision: () => false, + shouldClearContext: () => false, + systemMessage: undefined, + }); + + mockTurnRunFn.mockImplementation(async function* () { + yield { type: GeminiEventType.Content, value: 'Response' }; + }); + + const stream = client.sendMessageStream( + { text: 'Hi' }, + new AbortController().signal, + 'test-prompt', + ); + const events = await fromAsync(stream); + + expect(events).toContainEqual( + expect.objectContaining({ + type: GeminiEventType.AgentExecutionBlocked, + value: expect.objectContaining({ reason: 'Please explain' }), + }), + ); + // Should have called turn run twice (original + re-prompt) + expect(mockTurnRunFn).toHaveBeenCalledTimes(2); + expect(mockTurnRunFn).toHaveBeenNthCalledWith( + 2, + expect.anything(), + [{ text: 'Please explain' }], + expect.anything(), + ); + }); + + it('should call resetChat when AfterAgent hook returns shouldClearContext: true', async () => { + const resetChatSpy = vi + .spyOn(client, 'resetChat') + .mockResolvedValue(undefined); + + mockHookSystem.fireAfterAgentEvent + .mockResolvedValueOnce({ + shouldStopExecution: () => false, + isBlockingDecision: () => true, + getEffectiveReason: () => 'Blocked and clearing context', + shouldClearContext: () => true, + systemMessage: undefined, + }) + .mockResolvedValueOnce({ + shouldStopExecution: () => false, + isBlockingDecision: () => false, + shouldClearContext: () => false, systemMessage: undefined, }); @@ -3156,16 +3208,15 @@ ${JSON.stringify( expect(events).toContainEqual({ type: GeminiEventType.AgentExecutionBlocked, - value: { reason: 'Please explain' }, + value: { + reason: 'Blocked and clearing context', + systemMessage: undefined, + contextCleared: true, + }, }); - // Should have called turn run twice (original + re-prompt) - expect(mockTurnRunFn).toHaveBeenCalledTimes(2); - expect(mockTurnRunFn).toHaveBeenNthCalledWith( - 2, - expect.anything(), - [{ text: 'Please explain' }], - expect.anything(), - ); + expect(resetChatSpy).toHaveBeenCalledTimes(1); + + resetChatSpy.mockRestore(); }); }); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index fdf5e22a4d..c048ee42ce 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -40,7 +40,10 @@ import { logContentRetryFailure, logNextSpeakerCheck, } from '../telemetry/loggers.js'; -import type { DefaultHookOutput } from '../hooks/types.js'; +import type { + DefaultHookOutput, + AfterAgentHookOutput, +} from '../hooks/types.js'; import { ContentRetryFailureEvent, NextSpeakerCheckEvent, @@ -812,26 +815,41 @@ export class GeminiClient { turn, ); - if (hookOutput?.shouldStopExecution()) { + // Cast to AfterAgentHookOutput for access to shouldClearContext() + const afterAgentOutput = hookOutput as AfterAgentHookOutput | undefined; + + if (afterAgentOutput?.shouldStopExecution()) { + const contextCleared = afterAgentOutput.shouldClearContext(); yield { type: GeminiEventType.AgentExecutionStopped, value: { - reason: hookOutput.getEffectiveReason(), - systemMessage: hookOutput.systemMessage, + reason: afterAgentOutput.getEffectiveReason(), + systemMessage: afterAgentOutput.systemMessage, + contextCleared, }, }; + // Clear context if requested (honor both stop + clear) + if (contextCleared) { + await this.resetChat(); + } return turn; } - if (hookOutput?.isBlockingDecision()) { - const continueReason = hookOutput.getEffectiveReason(); + if (afterAgentOutput?.isBlockingDecision()) { + const continueReason = afterAgentOutput.getEffectiveReason(); + const contextCleared = afterAgentOutput.shouldClearContext(); yield { type: GeminiEventType.AgentExecutionBlocked, value: { reason: continueReason, - systemMessage: hookOutput.systemMessage, + systemMessage: afterAgentOutput.systemMessage, + contextCleared, }, }; + // Clear context if requested + if (contextCleared) { + await this.resetChat(); + } const continueRequest = [{ text: continueReason }]; yield* this.sendMessageStream( continueRequest, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 099530c90a..8e6974704d 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -79,6 +79,7 @@ export type ServerGeminiAgentExecutionStoppedEvent = { value: { reason: string; systemMessage?: string; + contextCleared?: boolean; }; }; @@ -87,6 +88,7 @@ export type ServerGeminiAgentExecutionBlockedEvent = { value: { reason: string; systemMessage?: string; + contextCleared?: boolean; }; }; diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index 0163f21856..0583c08776 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -16,6 +16,7 @@ import { BeforeModelHookOutput, BeforeToolSelectionHookOutput, AfterModelHookOutput, + AfterAgentHookOutput, } from './types.js'; import { HookEventName } from './types.js'; @@ -158,11 +159,21 @@ export class HookAggregator { merged.suppressOutput = true; } - // Merge hookSpecificOutput - if (output.hookSpecificOutput) { + // Handle clearContext (any true wins) - for AfterAgent hooks + if (output.hookSpecificOutput?.['clearContext'] === true) { merged.hookSpecificOutput = { ...(merged.hookSpecificOutput || {}), - ...output.hookSpecificOutput, + clearContext: true, + }; + } + + // Merge hookSpecificOutput (excluding clearContext which is handled above) + if (output.hookSpecificOutput) { + const { clearContext: _clearContext, ...restSpecificOutput } = + output.hookSpecificOutput; + merged.hookSpecificOutput = { + ...(merged.hookSpecificOutput || {}), + ...restSpecificOutput, }; } @@ -323,6 +334,8 @@ export class HookAggregator { return new BeforeToolSelectionHookOutput(output); case HookEventName.AfterModel: return new AfterModelHookOutput(output); + case HookEventName.AfterAgent: + return new AfterAgentHookOutput(output); default: return new DefaultHookOutput(output); } diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 7457db83ea..16f82e0f9b 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -140,6 +140,8 @@ export function createHookOutput( return new BeforeToolSelectionHookOutput(data); case 'BeforeTool': return new BeforeToolHookOutput(data); + case 'AfterAgent': + return new AfterAgentHookOutput(data); default: return new DefaultHookOutput(data); } @@ -238,6 +240,13 @@ export class DefaultHookOutput implements HookOutput { } return { blocked: false, reason: '' }; } + + /** + * Check if context clearing was requested by hook. + */ + shouldClearContext(): boolean { + return false; + } } /** @@ -362,6 +371,21 @@ export class AfterModelHookOutput extends DefaultHookOutput { } } +/** + * Specific hook output class for AfterAgent events + */ +export class AfterAgentHookOutput extends DefaultHookOutput { + /** + * Check if context clearing was requested by hook + */ + override shouldClearContext(): boolean { + if (this.hookSpecificOutput && 'clearContext' in this.hookSpecificOutput) { + return this.hookSpecificOutput['clearContext'] === true; + } + return false; + } +} + /** * Context for MCP tool executions. * Contains non-sensitive connection information about the MCP server @@ -475,6 +499,16 @@ export interface AfterAgentInput extends HookInput { stop_hook_active: boolean; } +/** + * AfterAgent hook output + */ +export interface AfterAgentOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'AfterAgent'; + clearContext?: boolean; + }; +} + /** * SessionStart source types */