Skip to content

Commit 6821b56

Browse files
authored
configurable agent (#101)
1 parent a9013be commit 6821b56

File tree

8 files changed

+111
-15
lines changed

8 files changed

+111
-15
lines changed

src/server/lib/validation/aiAgentConfigSchemas.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ export const aiAgentConfigSchema = {
5252
systemPromptOverride: { type: 'string', maxLength: 50000 },
5353
excludedTools: { type: 'array', items: { type: 'string' } },
5454
excludedFilePatterns: { type: 'array', items: { type: 'string' } },
55+
maxIterations: { type: 'integer', minimum: 1 },
56+
maxToolCalls: { type: 'integer', minimum: 1 },
57+
maxRepeatedCalls: { type: 'integer', minimum: 1 },
58+
compressionThreshold: { type: 'integer', minimum: 1 },
59+
observationMaskingRecencyWindow: { type: 'integer', minimum: 1 },
60+
observationMaskingTokenThreshold: { type: 'integer', minimum: 1 },
61+
toolExecutionTimeout: { type: 'integer', minimum: 1000 },
62+
toolOutputMaxChars: { type: 'integer', minimum: 1000 },
63+
retryBudget: { type: 'integer', minimum: 1 },
5564
},
5665
required: ['enabled', 'providers', 'maxMessagesPerSession', 'sessionTTL'],
5766
additionalProperties: false,

src/server/services/ai/conversation/manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export interface ConversationState {
3535
}
3636

3737
export class ConversationManager {
38-
private readonly COMPRESSION_THRESHOLD = 80000;
38+
private readonly COMPRESSION_THRESHOLD: number;
39+
40+
constructor(compressionThreshold?: number) {
41+
this.COMPRESSION_THRESHOLD = compressionThreshold || 80000;
42+
}
3943

4044
async shouldCompress(messages: ConversationMessage[]): Promise<boolean> {
4145
const tokenCount = await this.estimateTokens(messages);

src/server/services/ai/orchestration/orchestrator.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Tool, ToolCall } from '../types/tool';
2020
import { StreamCallbacks } from '../types/stream';
2121
import { ToolRegistry } from '../tools/registry';
2222
import { ToolSafetyManager } from './safety';
23-
import { LoopDetector } from './loopProtection';
23+
import { LoopDetector, LoopProtection } from './loopProtection';
2424
import { getLogger } from 'server/lib/logger';
2525
import { RetryBudget, createClassifiedError, ErrorCategory } from '../errors';
2626
import type { ClassifiedError } from '../errors';
@@ -43,11 +43,22 @@ export interface OrchestrationResult {
4343
};
4444
}
4545

46+
export interface OrchestratorOptions {
47+
loopProtection?: Partial<LoopProtection>;
48+
retryBudget?: number;
49+
}
50+
4651
export class ToolOrchestrator {
4752
private loopDetector: LoopDetector;
48-
49-
constructor(private toolRegistry: ToolRegistry, private safetyManager: ToolSafetyManager) {
50-
this.loopDetector = new LoopDetector();
53+
private retryBudgetMax: number;
54+
55+
constructor(
56+
private toolRegistry: ToolRegistry,
57+
private safetyManager: ToolSafetyManager,
58+
options?: OrchestratorOptions
59+
) {
60+
this.loopDetector = new LoopDetector(options?.loopProtection);
61+
this.retryBudgetMax = options?.retryBudget || 10;
5162
}
5263

5364
async executeToolLoop(
@@ -69,7 +80,7 @@ export class ToolOrchestrator {
6980

7081
this.loopDetector.reset();
7182

72-
const retryBudget = new RetryBudget(10);
83+
const retryBudget = new RetryBudget(this.retryBudgetMax);
7384
const policy = createProviderPolicy(provider.name, retryBudget);
7485

7586
while (iteration < protection.maxIterations) {

src/server/services/ai/orchestration/safety.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ import { OutputLimiter } from '../tools/outputLimiter';
2323
export class ToolSafetyManager {
2424
private requireConfirmation: boolean;
2525
private validator: JsonSchema.Validator;
26+
private toolExecutionTimeout: number;
27+
private toolOutputMaxChars: number;
2628

27-
constructor(requireConfirmation: boolean = true) {
29+
constructor(requireConfirmation: boolean = true, toolExecutionTimeout?: number, toolOutputMaxChars?: number) {
2830
this.requireConfirmation = requireConfirmation;
2931
this.validator = new JsonSchema.Validator();
32+
this.toolExecutionTimeout = toolExecutionTimeout || 30000;
33+
this.toolOutputMaxChars = toolOutputMaxChars || 30000;
3034
}
3135

3236
async safeExecute(
@@ -85,22 +89,24 @@ export class ToolSafetyManager {
8589
}
8690

8791
try {
88-
const result = await this.withTimeout(tool.execute(args, signal), 30000);
92+
const result = await this.withTimeout(tool.execute(args, signal), this.toolExecutionTimeout);
8993

9094
if (result.success && result.agentContent) {
91-
result.agentContent = OutputLimiter.truncate(result.agentContent);
95+
result.agentContent = OutputLimiter.truncate(result.agentContent, this.toolOutputMaxChars);
9296
}
9397

9498
this.logToolExecution(tool.name, args, result, buildUuid);
9599

96100
return result;
97101
} catch (error: any) {
98102
if (error.message === 'Tool execution timeout') {
99-
getLogger().warn(`AI: tool timeout tool=${tool.name} timeout=30s buildUuid=${buildUuid || 'none'}`);
103+
getLogger().warn(
104+
`AI: tool timeout tool=${tool.name} timeout=${this.toolExecutionTimeout}ms buildUuid=${buildUuid || 'none'}`
105+
);
100106
return {
101107
success: false,
102108
error: {
103-
message: `${tool.name} timed out after 30 seconds`,
109+
message: `${tool.name} timed out after ${this.toolExecutionTimeout / 1000} seconds`,
104110
code: 'TIMEOUT',
105111
recoverable: true,
106112
suggestedAction: 'The operation took too long. Try narrowing your query.',

src/server/services/ai/service.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ export interface AIAgentConfig {
6262
inputCostPerMillion: number;
6363
outputCostPerMillion: number;
6464
};
65+
maxIterations?: number;
66+
maxToolCalls?: number;
67+
maxRepeatedCalls?: number;
68+
compressionThreshold?: number;
69+
observationMaskingRecencyWindow?: number;
70+
observationMaskingTokenThreshold?: number;
71+
toolExecutionTimeout?: number;
72+
toolOutputMaxChars?: number;
73+
retryBudget?: number;
6574
}
6675

6776
export interface ProcessQueryResult {
@@ -88,6 +97,7 @@ export class AIAgentCore {
8897
private excludedTools?: string[];
8998
private excludedFilePatterns?: string[];
9099
private modelPricing?: { inputCostPerMillion: number; outputCostPerMillion: number };
100+
private observationMaskingOptions?: { recencyWindow?: number; tokenThreshold?: number };
91101

92102
private mcpToolsLoaded = false;
93103
private mcpToolInfos: McpToolInfo[] = [];
@@ -111,15 +121,32 @@ export class AIAgentCore {
111121
this.excludedTools = config.excludedTools;
112122
this.excludedFilePatterns = config.excludedFilePatterns;
113123
this.modelPricing = config.modelPricing;
124+
if (config.observationMaskingRecencyWindow || config.observationMaskingTokenThreshold) {
125+
this.observationMaskingOptions = {
126+
recencyWindow: config.observationMaskingRecencyWindow,
127+
tokenThreshold: config.observationMaskingTokenThreshold,
128+
};
129+
}
114130

115131
this.toolRegistry = new ToolRegistry();
116132
this.registerAllTools();
117133

118-
const safetyManager = new ToolSafetyManager(config.requireToolConfirmation ?? true);
119-
this.orchestrator = new ToolOrchestrator(this.toolRegistry, safetyManager);
134+
const safetyManager = new ToolSafetyManager(
135+
config.requireToolConfirmation ?? true,
136+
config.toolExecutionTimeout,
137+
config.toolOutputMaxChars
138+
);
139+
this.orchestrator = new ToolOrchestrator(this.toolRegistry, safetyManager, {
140+
loopProtection: {
141+
maxIterations: config.maxIterations,
142+
maxToolCalls: config.maxToolCalls,
143+
maxRepeatedCalls: config.maxRepeatedCalls,
144+
},
145+
retryBudget: config.retryBudget,
146+
});
120147

121148
this.promptBuilder = new AIAgentPromptBuilder();
122-
this.conversationManager = new ConversationManager();
149+
this.conversationManager = new ConversationManager(config.compressionThreshold);
123150
}
124151

125152
async processQuery(
@@ -179,7 +206,7 @@ export class AIAgentCore {
179206
textMessage(m.role as 'user' | 'assistant', m.content)
180207
);
181208

182-
const maskResult = maskObservations(messages);
209+
const maskResult = maskObservations(messages, this.observationMaskingOptions);
183210
if (maskResult.masked) {
184211
getLogger().info(
185212
`AIAgentCore: observation masking applied maskedParts=${maskResult.stats.maskedParts} savedTokens=${maskResult.stats.savedTokens} buildUuid=${context.buildUuid}`

src/server/services/aiAgent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ export default class AIAgentService extends BaseService {
124124
excludedTools: aiAgentConfig.excludedTools,
125125
excludedFilePatterns: aiAgentConfig.excludedFilePatterns,
126126
modelPricing: this.modelPricing,
127+
maxIterations: aiAgentConfig.maxIterations,
128+
maxToolCalls: aiAgentConfig.maxToolCalls,
129+
maxRepeatedCalls: aiAgentConfig.maxRepeatedCalls,
130+
compressionThreshold: aiAgentConfig.compressionThreshold,
131+
observationMaskingRecencyWindow: aiAgentConfig.observationMaskingRecencyWindow,
132+
observationMaskingTokenThreshold: aiAgentConfig.observationMaskingTokenThreshold,
133+
toolExecutionTimeout: aiAgentConfig.toolExecutionTimeout,
134+
toolOutputMaxChars: aiAgentConfig.toolOutputMaxChars,
135+
retryBudget: aiAgentConfig.retryBudget,
127136
});
128137
}
129138

src/server/services/types/aiAgent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,4 +333,13 @@ export interface AIAgentConfig {
333333
systemPromptOverride?: string;
334334
excludedTools?: string[];
335335
excludedFilePatterns?: string[];
336+
maxIterations?: number;
337+
maxToolCalls?: number;
338+
maxRepeatedCalls?: number;
339+
compressionThreshold?: number;
340+
observationMaskingRecencyWindow?: number;
341+
observationMaskingTokenThreshold?: number;
342+
toolExecutionTimeout?: number;
343+
toolOutputMaxChars?: number;
344+
retryBudget?: number;
336345
}

src/shared/openApiSpec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,27 @@ export const openApiSpecificationForV2Api: OAS3Options = {
745745
systemPromptOverride: { type: 'string', maxLength: 50000 },
746746
excludedTools: { type: 'array', items: { type: 'string' } },
747747
excludedFilePatterns: { type: 'array', items: { type: 'string' } },
748+
maxIterations: { type: 'integer', description: 'Maximum orchestration loop iterations' },
749+
maxToolCalls: { type: 'integer', description: 'Maximum total tool calls per query' },
750+
maxRepeatedCalls: {
751+
type: 'integer',
752+
description: 'Maximum repeated calls with same arguments before loop detection',
753+
},
754+
compressionThreshold: {
755+
type: 'integer',
756+
description: 'Token count threshold before conversation history is compressed',
757+
},
758+
observationMaskingRecencyWindow: {
759+
type: 'integer',
760+
description: 'Number of recent tool results to preserve when masking observations',
761+
},
762+
observationMaskingTokenThreshold: {
763+
type: 'integer',
764+
description: 'Token count threshold before observation masking activates',
765+
},
766+
toolExecutionTimeout: { type: 'integer', description: 'Tool execution timeout in milliseconds' },
767+
toolOutputMaxChars: { type: 'integer', description: 'Maximum characters in tool output before truncation' },
768+
retryBudget: { type: 'integer', description: 'Maximum retry attempts per query on provider errors' },
748769
},
749770
required: ['enabled', 'providers', 'maxMessagesPerSession', 'sessionTTL'],
750771
},

0 commit comments

Comments
 (0)