andy-stack_vaultkeeper-ai/AIClasses/BaseAIClass.ts
Andrew Beal d18f5ef655 refactor: replace Replan with granular orchestration tools
Replace the single Replan tool with three targeted alternatives:
ReviseStep, RevisePlan, and SkipStep. This gives the orchestration
agent more precise control over plan recovery — revising only the
current step, replacing all remaining steps, or skipping a step
entirely — rather than triggering a full replan each time.

Also give the orchestration agent read access to vault files so it
can resolve execution failures without routing back to the planning
agent, and update the execution agent prompt to stop it from
attempting self-recovery (gap resolution is now the orchestrator's
responsibility).
2026-02-19 20:48:59 +00:00

229 lines
9.3 KiB
TypeScript

import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import type { IAIClass } from "AIClasses/IAIClass";
import { type IStreamChunk } from "Services/StreamingService";
import type { Conversation } from "Conversations/Conversation";
import type { AIProvider } from "Enums/ApiProvider";
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
import type { ConversationContent } from "Conversations/ConversationContent";
import type { Attachment } from "Conversations/Attachment";
import type { SettingsService } from "Services/SettingsService";
import type { StreamingService } from "Services/StreamingService";
import type { StoredToolCall, StoredFunctionResponse } from "AIClasses/Schemas/AIToolTypes";
import { Exception } from "Helpers/Exception";
import { ApiError, ApiErrorType } from "Types/ApiError";
import type { AbortService } from "Services/AbortService";
import type { IAIFileService } from "./IAIFileService";
import { AgentType } from "Enums/AgentType";
import { AIToolUsageMode } from "Enums/AIToolUsageMode";
export abstract class BaseAIClass implements IAIClass {
protected readonly provider: AIProvider;
protected readonly apiKey: string;
protected readonly abortService: AbortService;
protected readonly aiFileService: IAIFileService;
protected readonly settingsService: SettingsService;
protected readonly streamingService: StreamingService;
private _systemPrompt: string = "";
private _userInstruction: string = "";
private _agentType: AgentType = AgentType.Main;
private _aiToolDefinitions: IAIToolDefinition[] = [];
private _aiToolUsageMode: AIToolUsageMode = AIToolUsageMode.Auto;
protected constructor(provider: AIProvider) {
this.provider = provider;
this.abortService = Resolve<AbortService>(Services.AbortService);
this.aiFileService = Resolve<IAIFileService>(Services.IAIFileService);
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
this.apiKey = this.settingsService.getApiKeyForProvider(provider);
}
public get currentProvider(): AIProvider {
return this.provider;
}
public set systemPrompt(systemPrompt: string) {
this._systemPrompt = systemPrompt;
}
public get systemPrompt(): string {
return this._systemPrompt;
}
public set userInstruction(userInstruction: string) {
this._userInstruction = userInstruction;
}
public get userInstruction(): string {
return this._userInstruction;
}
public get aiToolDefinitions(): IAIToolDefinition[] {
return this._aiToolDefinitions;
}
public set aiToolDefinitions(aiToolDefinitions: IAIToolDefinition[]) {
this._aiToolDefinitions = aiToolDefinitions;
}
public get agentType() {
return this._agentType;
}
public set agentType(agentType: AgentType) {
this._agentType = agentType;
}
public get aiToolUsageMode(): AIToolUsageMode {
return this._aiToolUsageMode;
}
public set aiToolUsageMode(mode: AIToolUsageMode) {
this._aiToolUsageMode = mode;
}
public abstract streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
public abstract formatBinaryFiles(attachments: Attachment[]): string;
protected abstract parseStreamChunk(chunk: string): IStreamChunk;
protected abstract extractContents(conversationContent: ConversationContent[]): unknown;
protected abstract mapFunctionDefinitions(aiToolDefinitions: IAIToolDefinition[]): object;
protected model(): string {
switch (this._agentType) {
case AgentType.Main:
return this.settingsService.settings.model;
case AgentType.Orchestration:
return this.settingsService.settings.planningModel;
case AgentType.Planning:
return this.settingsService.settings.planningModel;
case AgentType.Execution:
return this.settingsService.settings.model;
}
}
protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] {
return conversationContent.filter((content, index, array) => {
if (!content.content && !content.toolCall && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) {
return false; // Filter out empty content
}
if (content.functionResponse) { // Filter out 'lone' function responses
const previousItem = array[index - 1];
const hasValidCall = previousItem && previousItem.toolCall && content.toolId === previousItem.toolId;
if (!hasValidCall) {
Exception.warn(`[Filter Debug] Filtered orphaned function response at index ${index}/${array.length}:\n` +
` ToolId: ${content.toolId}\n` +
` Previous item has toolCall: ${previousItem?.toolCall ? 'yes' : 'no'}\n` +
` Previous item toolId: ${previousItem?.toolId}\n` +
` Response: ${content.functionResponse}`);
}
return hasValidCall;
}
if (!content.toolCall) {
return true; // Keep non-function-calls
}
if (index === array.length - 1) {
return true; // Keep if it's the last item (most recent)
}
// Keep if next item is a matched function response
const nextItem = array[index + 1];
const hasValidResponse = nextItem && nextItem.functionResponse && content.toolId === nextItem.toolId;
if (!hasValidResponse) {
Exception.warn(`[Filter Debug] Filtered orphaned function call at index ${index}/${array.length}:\n` +
` ToolId: ${content.toolId}\n` +
` Next item has functionResponse: ${nextItem?.functionResponse ? 'yes' : 'no'}\n` +
` Next item toolId: ${nextItem?.toolId}\n` +
` Call: ${content.toolCall}`);
}
return hasValidResponse;
});
}
protected throwRetryableError(message: string, code?: string, errorType?: ApiErrorType): never {
throw new ApiError({
type: errorType || ApiErrorType.SERVER_ERROR,
message: code ? `${message} (${code})` : message,
userMessage: "Service error.",
isRetryable: true
});
}
protected createErrorChunk(error: unknown, errorType?: ApiErrorType, userMessage?: string): IStreamChunk {
// let ApiError propagate
if (error instanceof ApiError) {
throw error;
}
Exception.log(error);
return {
content: "",
isComplete: true,
error: userMessage || `Failed to parse chunk: ${Exception.messageFrom(error)}`,
errorType: errorType || ApiErrorType.UNKNOWN
};
}
protected async processAttachments<T>(
attachments: Attachment[],
formatBinaryFiles: (attachments: Attachment[]) => string
): Promise<{ formattedParts: T[], uploadErrors: Error[] }> {
const uploadErrors: Error[] = [];
for (const attachment of attachments) {
try {
if (attachment.base64.trim() === "") {
Exception.throw(`Failed to upload ${attachment.fileName}: File has no content`);
}
await this.aiFileService.uploadFile(attachment);
if (!attachment.getFileID(this.provider)) {
Exception.throw(`Failed to upload ${attachment.fileName}: File ID undefined after upload attempt`);
}
} catch (error) {
uploadErrors.push(Exception.new(error));
}
}
const formattedContent = formatBinaryFiles(attachments);
const formattedParts = JSON.parse(formattedContent) as T[];
return { formattedParts, uploadErrors };
}
/**
* Converts a function call to legacy text format for cross-provider compatibility.
* Used when a provider doesn't have the required ID field (e.g., Gemini → Claude/OpenAI).
*/
protected convertToolCallToText(parsedContent: StoredToolCall): string {
const formattedJson = JSON.stringify({
name: parsedContent.toolCall.name,
args: parsedContent.toolCall.args
}, null, 2);
return `<!-- Historical tool call. This action was ALREADY COMPLETED.
Use your native function calling for any NEW operations. -->
${formattedJson}`;
}
/**
* Converts a function response to legacy text format for cross-provider compatibility.
* Used when a provider doesn't have the required ID field (e.g., Gemini → Claude/OpenAI).
*/
protected convertFunctionResponseToText(parsedContent: StoredFunctionResponse): string {
const formattedJson = JSON.stringify({
name: parsedContent.functionResponse.name,
response: parsedContent.functionResponse.response
}, null, 2);
return `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
${formattedJson}`;
}
}