mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
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).
229 lines
9.3 KiB
TypeScript
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}`;
|
|
}
|
|
}
|