mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -07:00
feat(hooks): Hook Result Aggregation (#9095)
This commit is contained in:
475
packages/core/src/hooks/hookAggregator.test.ts
Normal file
475
packages/core/src/hooks/hookAggregator.test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { HookAggregator } from './hookAggregator.js';
|
||||
import type {
|
||||
HookExecutionResult,
|
||||
BeforeToolSelectionOutput,
|
||||
BeforeModelOutput,
|
||||
HookOutput,
|
||||
} from './types.js';
|
||||
import { HookType, HookEventName } from './types.js';
|
||||
|
||||
// Helper function to create proper HookExecutionResult objects
|
||||
function createHookExecutionResult(
|
||||
output?: HookOutput,
|
||||
success = true,
|
||||
duration = 100,
|
||||
error?: Error,
|
||||
): HookExecutionResult {
|
||||
return {
|
||||
success,
|
||||
output,
|
||||
duration,
|
||||
error,
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
};
|
||||
}
|
||||
|
||||
describe('HookAggregator', () => {
|
||||
let aggregator: HookAggregator;
|
||||
|
||||
beforeEach(() => {
|
||||
aggregator = new HookAggregator();
|
||||
});
|
||||
|
||||
describe('aggregateResults', () => {
|
||||
it('should handle empty results', () => {
|
||||
const results: HookExecutionResult[] = [];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(aggregated.allOutputs).toHaveLength(0);
|
||||
expect(aggregated.errors).toHaveLength(0);
|
||||
expect(aggregated.totalDuration).toBe(0);
|
||||
expect(aggregated.finalOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should aggregate successful results', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
createHookExecutionResult(
|
||||
{ decision: 'allow', reason: 'Hook 1 approved' },
|
||||
true,
|
||||
100,
|
||||
),
|
||||
createHookExecutionResult(
|
||||
{ decision: 'allow', reason: 'Hook 2 approved' },
|
||||
true,
|
||||
150,
|
||||
),
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(aggregated.allOutputs).toHaveLength(2);
|
||||
expect(aggregated.errors).toHaveLength(0);
|
||||
expect(aggregated.totalDuration).toBe(250);
|
||||
expect(aggregated.finalOutput?.decision).toBe('allow');
|
||||
expect(aggregated.finalOutput?.reason).toBe(
|
||||
'Hook 1 approved\nHook 2 approved',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors in results', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: false,
|
||||
error: new Error('Hook failed'),
|
||||
duration: 50,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: true,
|
||||
output: { decision: 'allow' },
|
||||
duration: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(false);
|
||||
expect(aggregated.allOutputs).toHaveLength(1);
|
||||
expect(aggregated.errors).toHaveLength(1);
|
||||
expect(aggregated.errors[0].message).toBe('Hook failed');
|
||||
expect(aggregated.totalDuration).toBe(150);
|
||||
});
|
||||
|
||||
it('should handle blocking decisions with OR logic', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: true,
|
||||
output: { decision: 'allow', reason: 'Hook 1 allowed' },
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: true,
|
||||
output: { decision: 'block', reason: 'Hook 2 blocked' },
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(aggregated.finalOutput?.decision).toBe('block');
|
||||
expect(aggregated.finalOutput?.reason).toBe(
|
||||
'Hook 1 allowed\nHook 2 blocked',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle continue=false with precedence', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: true,
|
||||
output: { decision: 'allow', continue: true },
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeTool,
|
||||
success: true,
|
||||
output: {
|
||||
decision: 'allow',
|
||||
continue: false,
|
||||
stopReason: 'Stop requested',
|
||||
},
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(aggregated.finalOutput?.continue).toBe(false);
|
||||
expect(aggregated.finalOutput?.stopReason).toBe('Stop requested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeToolSelection merge strategy', () => {
|
||||
it('should merge tool configurations with NONE mode precedence', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['tool1', 'tool2'],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'NONE',
|
||||
allowedFunctionNames: [],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeToolSelection,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
const output = aggregated.finalOutput as BeforeToolSelectionOutput;
|
||||
const toolConfig = output.hookSpecificOutput?.toolConfig;
|
||||
expect(toolConfig?.mode).toBe('NONE');
|
||||
expect(toolConfig?.allowedFunctionNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('should merge tool configurations with ANY mode', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'AUTO',
|
||||
allowedFunctionNames: ['tool1'],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['tool2', 'tool3'],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeToolSelection,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
const output = aggregated.finalOutput as BeforeToolSelectionOutput;
|
||||
const toolConfig = output.hookSpecificOutput?.toolConfig;
|
||||
expect(toolConfig?.mode).toBe('ANY');
|
||||
expect(toolConfig?.allowedFunctionNames).toEqual([
|
||||
'tool1',
|
||||
'tool2',
|
||||
'tool3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge tool configurations with AUTO mode when all are AUTO', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'AUTO',
|
||||
allowedFunctionNames: ['tool1'],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeToolSelection,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: 'AUTO',
|
||||
allowedFunctionNames: ['tool2'],
|
||||
},
|
||||
},
|
||||
} as BeforeToolSelectionOutput,
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeToolSelection,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
const output = aggregated.finalOutput as BeforeToolSelectionOutput;
|
||||
const toolConfig = output.hookSpecificOutput?.toolConfig;
|
||||
expect(toolConfig?.mode).toBe('AUTO');
|
||||
expect(toolConfig?.allowedFunctionNames).toEqual(['tool1', 'tool2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeModel/AfterModel merge strategy', () => {
|
||||
it('should use field replacement strategy', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeModel,
|
||||
success: true,
|
||||
output: {
|
||||
decision: 'allow',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeModel',
|
||||
llm_request: { model: 'model1', config: {}, contents: [] },
|
||||
},
|
||||
},
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.BeforeModel,
|
||||
success: true,
|
||||
output: {
|
||||
decision: 'block',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeModel',
|
||||
llm_request: { model: 'model2', config: {}, contents: [] },
|
||||
},
|
||||
},
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.BeforeModel,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(aggregated.finalOutput?.decision).toBe('block'); // Later value wins
|
||||
const output = aggregated.finalOutput as BeforeModelOutput;
|
||||
const llmRequest = output.hookSpecificOutput?.llm_request;
|
||||
expect(llmRequest?.['model']).toBe('model2'); // Later value wins
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAdditionalContext', () => {
|
||||
it('should extract additional context from hook outputs', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.AfterTool,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'AfterTool',
|
||||
additionalContext: 'Context from hook 1',
|
||||
},
|
||||
},
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: {
|
||||
type: HookType.Command,
|
||||
command: 'test-command',
|
||||
timeout: 30000,
|
||||
},
|
||||
eventName: HookEventName.AfterTool,
|
||||
success: true,
|
||||
output: {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'AfterTool',
|
||||
additionalContext: 'Context from hook 2',
|
||||
},
|
||||
},
|
||||
duration: 150,
|
||||
},
|
||||
];
|
||||
|
||||
const aggregated = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.AfterTool,
|
||||
);
|
||||
|
||||
expect(aggregated.success).toBe(true);
|
||||
expect(
|
||||
aggregated.finalOutput?.hookSpecificOutput?.['additionalContext'],
|
||||
).toBe('Context from hook 1\nContext from hook 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
343
packages/core/src/hooks/hookAggregator.ts
Normal file
343
packages/core/src/hooks/hookAggregator.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FunctionCallingConfigMode } from '@google/genai';
|
||||
import type {
|
||||
HookOutput,
|
||||
HookExecutionResult,
|
||||
BeforeToolSelectionOutput,
|
||||
} from './types.js';
|
||||
import {
|
||||
DefaultHookOutput,
|
||||
BeforeToolHookOutput,
|
||||
BeforeModelHookOutput,
|
||||
BeforeToolSelectionHookOutput,
|
||||
AfterModelHookOutput,
|
||||
} from './types.js';
|
||||
import { HookEventName } from './types.js';
|
||||
|
||||
/**
|
||||
* Aggregated hook result
|
||||
*/
|
||||
export interface AggregatedHookResult {
|
||||
success: boolean;
|
||||
finalOutput?: DefaultHookOutput;
|
||||
allOutputs: HookOutput[];
|
||||
errors: Error[];
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook aggregator that merges results from multiple hooks using event-specific strategies
|
||||
*/
|
||||
export class HookAggregator {
|
||||
/**
|
||||
* Aggregate results from multiple hook executions
|
||||
*/
|
||||
aggregateResults(
|
||||
results: HookExecutionResult[],
|
||||
eventName: HookEventName,
|
||||
): AggregatedHookResult {
|
||||
const allOutputs: HookOutput[] = [];
|
||||
const errors: Error[] = [];
|
||||
let totalDuration = 0;
|
||||
|
||||
// Collect all outputs and errors
|
||||
for (const result of results) {
|
||||
totalDuration += result.duration;
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
|
||||
if (result.output) {
|
||||
allOutputs.push(result.output);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge outputs using event-specific strategy
|
||||
const mergedOutput = this.mergeOutputs(allOutputs, eventName);
|
||||
const finalOutput = mergedOutput
|
||||
? this.createSpecificHookOutput(mergedOutput, eventName)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
finalOutput,
|
||||
allOutputs,
|
||||
errors,
|
||||
totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hook outputs using event-specific strategies
|
||||
*
|
||||
* Note: We always use the merge logic even for single hooks to ensure
|
||||
* consistent default behaviors (e.g., default decision='allow' for OR logic)
|
||||
*/
|
||||
private mergeOutputs(
|
||||
outputs: HookOutput[],
|
||||
eventName: HookEventName,
|
||||
): HookOutput | undefined {
|
||||
if (outputs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (eventName) {
|
||||
case HookEventName.BeforeTool:
|
||||
case HookEventName.AfterTool:
|
||||
case HookEventName.BeforeAgent:
|
||||
case HookEventName.AfterAgent:
|
||||
case HookEventName.SessionStart:
|
||||
return this.mergeWithOrDecision(outputs);
|
||||
|
||||
case HookEventName.BeforeModel:
|
||||
case HookEventName.AfterModel:
|
||||
return this.mergeWithFieldReplacement(outputs);
|
||||
|
||||
case HookEventName.BeforeToolSelection:
|
||||
return this.mergeToolSelectionOutputs(
|
||||
outputs as BeforeToolSelectionOutput[],
|
||||
);
|
||||
|
||||
default:
|
||||
// For other events, use simple merge
|
||||
return this.mergeSimple(outputs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge outputs with OR decision logic and message concatenation
|
||||
*/
|
||||
private mergeWithOrDecision(outputs: HookOutput[]): HookOutput {
|
||||
const merged: HookOutput = {
|
||||
continue: true,
|
||||
suppressOutput: false,
|
||||
};
|
||||
|
||||
const messages: string[] = [];
|
||||
const reasons: string[] = [];
|
||||
const systemMessages: string[] = [];
|
||||
const additionalContexts: string[] = [];
|
||||
|
||||
let hasBlockDecision = false;
|
||||
let hasContinueFalse = false;
|
||||
|
||||
for (const output of outputs) {
|
||||
// Handle continue flag
|
||||
if (output.continue === false) {
|
||||
hasContinueFalse = true;
|
||||
merged.continue = false;
|
||||
if (output.stopReason) {
|
||||
messages.push(output.stopReason);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle decision (OR logic for blocking)
|
||||
const tempOutput = new DefaultHookOutput(output);
|
||||
if (tempOutput.isBlockingDecision()) {
|
||||
hasBlockDecision = true;
|
||||
merged.decision = output.decision;
|
||||
}
|
||||
|
||||
// Collect messages
|
||||
if (output.reason) {
|
||||
reasons.push(output.reason);
|
||||
}
|
||||
|
||||
if (output.systemMessage) {
|
||||
systemMessages.push(output.systemMessage);
|
||||
}
|
||||
|
||||
// Handle suppress output (any true wins)
|
||||
if (output.suppressOutput) {
|
||||
merged.suppressOutput = true;
|
||||
}
|
||||
|
||||
// Collect additional context from hook-specific outputs
|
||||
this.extractAdditionalContext(output, additionalContexts);
|
||||
}
|
||||
|
||||
// Set final decision if no blocking decision was found
|
||||
if (!hasBlockDecision && !hasContinueFalse) {
|
||||
merged.decision = 'allow';
|
||||
}
|
||||
|
||||
// Merge messages
|
||||
if (messages.length > 0) {
|
||||
merged.stopReason = messages.join('\n');
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
merged.reason = reasons.join('\n');
|
||||
}
|
||||
|
||||
if (systemMessages.length > 0) {
|
||||
merged.systemMessage = systemMessages.join('\n');
|
||||
}
|
||||
|
||||
// Add merged additional context
|
||||
if (additionalContexts.length > 0) {
|
||||
merged.hookSpecificOutput = {
|
||||
...(merged.hookSpecificOutput || {}),
|
||||
additionalContext: additionalContexts.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge outputs with later fields replacing earlier fields
|
||||
*/
|
||||
private mergeWithFieldReplacement(outputs: HookOutput[]): HookOutput {
|
||||
let merged: HookOutput = {};
|
||||
|
||||
for (const output of outputs) {
|
||||
// Later outputs override earlier ones
|
||||
merged = {
|
||||
...merged,
|
||||
...output,
|
||||
hookSpecificOutput: {
|
||||
...merged.hookSpecificOutput,
|
||||
...output.hookSpecificOutput,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge tool selection outputs with specific logic for tool config
|
||||
*
|
||||
* Tool Selection Strategy:
|
||||
* - The intent is to provide a UNION of tools from all hooks
|
||||
* - If any hook specifies NONE mode, no tools are available (most restrictive wins)
|
||||
* - If any hook specifies ANY mode (and no NONE), ANY mode is used
|
||||
* - Otherwise AUTO mode is used
|
||||
* - Function names are collected from all hooks and sorted for deterministic caching
|
||||
*
|
||||
* This means hooks can only add/enable tools, not filter them out individually.
|
||||
* If one hook restricts and another re-enables, the union takes the re-enabled tool.
|
||||
*/
|
||||
private mergeToolSelectionOutputs(
|
||||
outputs: BeforeToolSelectionOutput[],
|
||||
): BeforeToolSelectionOutput {
|
||||
const merged: BeforeToolSelectionOutput = {};
|
||||
|
||||
const allFunctionNames = new Set<string>();
|
||||
let hasNoneMode = false;
|
||||
let hasAnyMode = false;
|
||||
|
||||
for (const output of outputs) {
|
||||
const toolConfig = output.hookSpecificOutput?.toolConfig;
|
||||
if (!toolConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check mode (using simplified HookToolConfig format)
|
||||
if (toolConfig.mode === 'NONE') {
|
||||
hasNoneMode = true;
|
||||
} else if (toolConfig.mode === 'ANY') {
|
||||
hasAnyMode = true;
|
||||
}
|
||||
|
||||
// Collect function names (union of all hooks)
|
||||
if (toolConfig.allowedFunctionNames) {
|
||||
for (const name of toolConfig.allowedFunctionNames) {
|
||||
allFunctionNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final mode and function names
|
||||
let finalMode: FunctionCallingConfigMode;
|
||||
let finalFunctionNames: string[] = [];
|
||||
|
||||
if (hasNoneMode) {
|
||||
// NONE mode wins - most restrictive
|
||||
finalMode = FunctionCallingConfigMode.NONE;
|
||||
finalFunctionNames = [];
|
||||
} else if (hasAnyMode) {
|
||||
// ANY mode if present (and no NONE)
|
||||
finalMode = FunctionCallingConfigMode.ANY;
|
||||
// Sort for deterministic output to ensure consistent caching
|
||||
finalFunctionNames = Array.from(allFunctionNames).sort();
|
||||
} else {
|
||||
// Default to AUTO mode
|
||||
finalMode = FunctionCallingConfigMode.AUTO;
|
||||
// Sort for deterministic output to ensure consistent caching
|
||||
finalFunctionNames = Array.from(allFunctionNames).sort();
|
||||
}
|
||||
|
||||
merged.hookSpecificOutput = {
|
||||
hookEventName: 'BeforeToolSelection',
|
||||
toolConfig: {
|
||||
mode: finalMode,
|
||||
allowedFunctionNames: finalFunctionNames,
|
||||
},
|
||||
};
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple merge for events without special logic
|
||||
*/
|
||||
private mergeSimple(outputs: HookOutput[]): HookOutput {
|
||||
let merged: HookOutput = {};
|
||||
|
||||
for (const output of outputs) {
|
||||
merged = { ...merged, ...output };
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the appropriate specific hook output class based on event type
|
||||
*/
|
||||
private createSpecificHookOutput(
|
||||
output: HookOutput,
|
||||
eventName: HookEventName,
|
||||
): DefaultHookOutput {
|
||||
switch (eventName) {
|
||||
case HookEventName.BeforeTool:
|
||||
return new BeforeToolHookOutput(output);
|
||||
case HookEventName.BeforeModel:
|
||||
return new BeforeModelHookOutput(output);
|
||||
case HookEventName.BeforeToolSelection:
|
||||
return new BeforeToolSelectionHookOutput(output);
|
||||
case HookEventName.AfterModel:
|
||||
return new AfterModelHookOutput(output);
|
||||
default:
|
||||
return new DefaultHookOutput(output);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract additional context from hook-specific outputs
|
||||
*/
|
||||
private extractAdditionalContext(
|
||||
output: HookOutput,
|
||||
contexts: string[],
|
||||
): void {
|
||||
const specific = output.hookSpecificOutput;
|
||||
if (!specific) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract additionalContext from various hook types
|
||||
if (
|
||||
'additionalContext' in specific &&
|
||||
typeof specific['additionalContext'] === 'string'
|
||||
) {
|
||||
contexts.push(specific['additionalContext']);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user