mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
refactor: improve function call/response parsing and filtering
- Move parseFunctionCall/parseFunctionResponse to ResponseHelper - Enhance orphaned call/response filtering with detailed debug logs - Add toolId to conversation content for better tracking - Fix planning workflow execution mechanics and step numbering - Remove unused planning agent appendix and detailedAppendixForPlanningAgent - Add conversation save callbacks throughout AI controller loops - Improve multi-agent function handling to avoid exceptions - Update all tests to include toolId fields for proper filtering
This commit is contained in:
parent
2c7e5b41b6
commit
18d0741ec9
18 changed files with 481 additions and 192 deletions
|
|
@ -10,7 +10,6 @@ import type { Attachment } from "Conversations/Attachment";
|
|||
import type { SettingsService } from "Services/SettingsService";
|
||||
import type { StreamingService } from "Services/StreamingService";
|
||||
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
|
||||
import { StringTools } from "Helpers/StringTools";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import type { AbortService } from "Services/AbortService";
|
||||
|
|
@ -74,7 +73,6 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
protected filterConversationContents(conversationContent: ConversationContent[]): ConversationContent[] {
|
||||
return conversationContent.filter((content, index, array) => {
|
||||
if (!content.content && !content.functionCall && !content.functionResponse && (!content.attachments || content.attachments.length === 0)) {
|
||||
Exception.warn(`Filtered out message that had no content, this was likely deliberate`);
|
||||
return false; // Filter out empty content
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +80,11 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
const previousItem = array[index - 1];
|
||||
const hasValidCall = previousItem && previousItem.functionCall && content.toolId === previousItem.toolId;
|
||||
if (!hasValidCall) {
|
||||
Exception.warn(`Filtered out function response that had no matching function call:\n${content.functionResponse}`);
|
||||
Exception.warn(`[Filter Debug] Filtered orphaned function response at index ${index}/${array.length}:\n` +
|
||||
` ToolId: ${content.toolId}\n` +
|
||||
` Previous item has functionCall: ${previousItem?.functionCall ? 'yes' : 'no'}\n` +
|
||||
` Previous item toolId: ${previousItem?.toolId}\n` +
|
||||
` Response: ${content.functionResponse}`);
|
||||
}
|
||||
return hasValidCall;
|
||||
}
|
||||
|
|
@ -99,38 +101,16 @@ export abstract class BaseAIClass implements IAIClass {
|
|||
const nextItem = array[index + 1];
|
||||
const hasValidResponse = nextItem && nextItem.functionResponse && content.toolId === nextItem.toolId;
|
||||
if (!hasValidResponse) {
|
||||
Exception.warn(`Filtered out function call that had no matching function response:\n${content.functionCall}`);
|
||||
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.functionCall}`);
|
||||
}
|
||||
return hasValidResponse;
|
||||
});
|
||||
}
|
||||
|
||||
protected parseFunctionCall(functionCallJson: string): StoredFunctionCall | null {
|
||||
if (!StringTools.isValidJson(functionCallJson)) {
|
||||
Exception.log(`Invalid JSON in functionCall field:\n${functionCallJson}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(functionCallJson) as StoredFunctionCall;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected parseFunctionResponse(responseJson: string): StoredFunctionResponse | null {
|
||||
if (!StringTools.isValidJson(responseJson)) {
|
||||
Exception.log(`Invalid JSON in function response content:\n${responseJson}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(responseJson) as StoredFunctionResponse;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected throwRetryableError(message: string, code?: string, errorType?: ApiErrorType): never {
|
||||
throw new ApiError({
|
||||
type: errorType || ApiErrorType.SERVER_ERROR,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { MimeType, toMimeType } from "Enums/MimeType";
|
|||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
|
||||
export class Claude extends BaseAIClass {
|
||||
|
||||
|
|
@ -190,7 +191,7 @@ export class Claude extends BaseAIClass {
|
|||
|
||||
// Add function call if present
|
||||
if (content.functionCall) {
|
||||
const parsedContent = this.parseFunctionCall(content.functionCall);
|
||||
const parsedContent = parseFunctionCall(content.functionCall);
|
||||
|
||||
if (parsedContent) {
|
||||
if (parsedContent.functionCall.id && parsedContent.functionCall.id.trim() !== "") {
|
||||
|
|
@ -233,7 +234,7 @@ export class Claude extends BaseAIClass {
|
|||
|
||||
// Add function response if present
|
||||
if (content.functionResponse) {
|
||||
const parsedContent = this.parseFunctionResponse(content.functionResponse);
|
||||
const parsedContent = parseFunctionResponse(content.functionResponse);
|
||||
|
||||
if (parsedContent) {
|
||||
if (parsedContent.id && parsedContent.id.trim() !== "") {
|
||||
|
|
|
|||
|
|
@ -66,8 +66,4 @@ export abstract class AIFunctionDefinitions {
|
|||
return `| ${definition.name} | ${description} |`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
public static detailedAppendixForPlanningAgent(): string {
|
||||
return this.definitionsList.map(definition => JSON.stringify(definition, null, 2)).join('\n\n');
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { isTextFile } from "Enums/FileType";
|
|||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import { ApiError, ApiErrorType } from "Types/ApiError";
|
||||
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
|
||||
export class Gemini extends BaseAIClass {
|
||||
|
||||
|
|
@ -224,7 +225,7 @@ export class Gemini extends BaseAIClass {
|
|||
|
||||
// Add function call if present
|
||||
if (content.functionCall) {
|
||||
const parsedContent = this.parseFunctionCall(content.functionCall);
|
||||
const parsedContent = parseFunctionCall(content.functionCall);
|
||||
|
||||
if (parsedContent) {
|
||||
if (content.thoughtSignature && content.thoughtSignature.trim() !== "") {
|
||||
|
|
@ -268,7 +269,7 @@ export class Gemini extends BaseAIClass {
|
|||
|
||||
// Add function response if present
|
||||
if (content.functionResponse) {
|
||||
const parsedContent = this.parseFunctionResponse(content.functionResponse);
|
||||
const parsedContent = parseFunctionResponse(content.functionResponse);
|
||||
|
||||
if (parsedContent) {
|
||||
if (parsedContent.id && parsedContent.id.trim() !== "") {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ApiError, ApiErrorType } from "Types/ApiError";
|
|||
import { MimeType, toMimeType } from "Enums/MimeType";
|
||||
import { isTextFile } from "Enums/FileType";
|
||||
import { MimeTypeToFileTypes } from "Enums/FileTypeMimeTypeMapping";
|
||||
import { parseFunctionCall, parseFunctionResponse } from "Helpers/ResponseHelper";
|
||||
|
||||
export class OpenAI extends BaseAIClass {
|
||||
|
||||
|
|
@ -198,7 +199,7 @@ export class OpenAI extends BaseAIClass {
|
|||
|
||||
// Case 1: Assistant message with function call
|
||||
if (content.functionCall) {
|
||||
const parsedContent = this.parseFunctionCall(content.functionCall);
|
||||
const parsedContent = parseFunctionCall(content.functionCall);
|
||||
|
||||
if (parsedContent) {
|
||||
// Check if function call has required id field (for OpenAI Responses API)
|
||||
|
|
@ -262,7 +263,7 @@ export class OpenAI extends BaseAIClass {
|
|||
|
||||
// Case 3: Function call response
|
||||
if (content.functionResponse) {
|
||||
const parsedContent = this.parseFunctionResponse(content.functionResponse);
|
||||
const parsedContent = parseFunctionResponse(content.functionResponse);
|
||||
|
||||
if (parsedContent) {
|
||||
// Check if response has required id field (for OpenAI Responses API)
|
||||
|
|
|
|||
|
|
@ -186,12 +186,4 @@ Before returning any plan, verify:
|
|||
- Creates a new summary note with proper wiki-links
|
||||
|
||||
**Remember**: You are the strategic intelligence of the system. The main agent executes; you ensure it executes optimally.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Complete Tool Specifications
|
||||
|
||||
Below are the complete function definitions available to the main agent, specified in JSON format as per Anthropic's documentation standards.
|
||||
|
||||
${AIFunctionDefinitions.detailedAppendixForPlanningAgent()}
|
||||
`;
|
||||
|
|
@ -73,16 +73,23 @@ Ask yourself:
|
|||
#### Planning Workflow
|
||||
|
||||
When strategic guidance is needed:
|
||||
|
||||
1. **Request a plan** with a clear goal description and relevant context
|
||||
2. **Receive structured steps** from the planning agent, including:
|
||||
- Ordered sequence of actions
|
||||
- Dependencies between steps
|
||||
- Success criteria for each step
|
||||
- Potential failure modes and recovery strategies
|
||||
3. **Execute steps sequentially**, gathering ground truth at each stage
|
||||
4. **Monitor progress** against the plan's success criteria
|
||||
5. **Request replanning** if conditions change or obstacles emerge
|
||||
2. **Receive structured steps** from the planning agent
|
||||
3. **Execute the plan** following the execution mechanics below
|
||||
4. **Monitor progress** and request replanning if needed
|
||||
|
||||
#### Execution Mechanics
|
||||
|
||||
When you receive a plan, it includes both an overview of all steps and detailed guidance for the immediate next action.
|
||||
|
||||
**Core loop:**
|
||||
- Each step provides a directive telling you what to do—treat this as your primary instruction
|
||||
- After completing a step, signal completion to receive the next step's details
|
||||
- Continue until all steps are finished
|
||||
|
||||
**Handling issues:**
|
||||
- If circumstances change and the current plan no longer makes sense, request a revised plan
|
||||
- If the overall goal becomes unachievable or irrelevant, cancel the plan entirely
|
||||
|
||||
### 3. ADAPTIVE REPLANNING
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { AIFunctionCall } from "AIClasses/AIFunctionCall";
|
||||
import type { StoredFunctionCall, StoredFunctionResponse } from "AIClasses/Schemas/AIFunctionTypes";
|
||||
import { StringTools } from "./StringTools";
|
||||
import { Exception } from "./Exception";
|
||||
|
||||
// handle the rare event where a function call is also included in content (gemini sometimes does this)
|
||||
export function sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string {
|
||||
|
|
@ -57,4 +60,30 @@ export function sanitizeFunctionCallContent(content: string, functionCall: AIFun
|
|||
sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function parseFunctionCall(functionCallJson: string): StoredFunctionCall | null {
|
||||
if (!StringTools.isValidJson(functionCallJson)) {
|
||||
Exception.log(`Invalid JSON in functionCall field:\n${functionCallJson}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(functionCallJson) as StoredFunctionCall;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFunctionResponse(responseJson: string): StoredFunctionResponse | null {
|
||||
if (!StringTools.isValidJson(responseJson)) {
|
||||
Exception.log(`Invalid JSON in function response content:\n${responseJson}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(responseJson) as StoredFunctionResponse;
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { ConversationContent } from "Conversations/ConversationContent";
|
|||
import { Role } from "Enums/Role";
|
||||
import type { AIFunctionService } from "./AIFunctionService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { sanitizeFunctionCallContent } from "Helpers/ResponseHelper";
|
||||
import { parseFunctionCall, sanitizeFunctionCallContent } from "Helpers/ResponseHelper";
|
||||
import type { IPrompt } from "AIPrompts/IPrompt";
|
||||
import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions";
|
||||
import { AIFunction, isAIFunction } from "Enums/AIFunction";
|
||||
|
|
@ -26,6 +26,7 @@ export class AIControllerService {
|
|||
private readonly aiFunctionService: AIFunctionService;
|
||||
|
||||
private planningConversation: Conversation;
|
||||
private onSaveConversation?: (conversation: Conversation) => Promise<void>;
|
||||
|
||||
public constructor() {
|
||||
this.aiPrompt = Resolve<IPrompt>(Services.IPrompt);
|
||||
|
|
@ -36,6 +37,10 @@ export class AIControllerService {
|
|||
this.ai = Resolve<IAIClass>(Services.IAIClass);
|
||||
}
|
||||
|
||||
public setSaveCallback(callback: (conversation: Conversation) => Promise<void>) {
|
||||
this.onSaveConversation = callback;
|
||||
}
|
||||
|
||||
public async runMainAgent(conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks) {
|
||||
if (!this.ai) { // this shouldn't ever happen
|
||||
Exception.throw("Error: No AI provider has been set!");
|
||||
|
|
@ -53,7 +58,7 @@ export class AIControllerService {
|
|||
return { shouldExit: completedSuccessfully };
|
||||
}
|
||||
|
||||
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
|
||||
this.updateThought(functionCall, callbacks);
|
||||
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
|
|
@ -80,6 +85,7 @@ export class AIControllerService {
|
|||
|
||||
let planningIteration = 0;
|
||||
let continueExecution = true;
|
||||
let planExecutionCancelled = false;
|
||||
|
||||
this.planningConversation = new Conversation();
|
||||
this.planningConversation.contents.push(new ConversationContent({
|
||||
|
|
@ -106,6 +112,7 @@ export class AIControllerService {
|
|||
|
||||
// Stop if cancelled, otherwise continue if replanning was requested OR execution was incomplete
|
||||
if (executionResult.planExecutionCancelled) {
|
||||
planExecutionCancelled = true;
|
||||
continueExecution = false;
|
||||
} else {
|
||||
continueExecution = executionResult.shouldReplan || executionResult.isIncomplete;
|
||||
|
|
@ -134,12 +141,14 @@ export class AIControllerService {
|
|||
}));
|
||||
}
|
||||
|
||||
return true; // Planning and execution completed - terminate main agent
|
||||
// If plan was cancelled, return false to give control back to main agent for summary
|
||||
// Otherwise return true to terminate the main agent loop
|
||||
return !planExecutionCancelled;
|
||||
}
|
||||
|
||||
private async runPlanningAgent(planningConversation: Conversation, callbacks: IChatServiceCallbacks): Promise<ExecutionPlan> {
|
||||
let capturedPlan: ExecutionPlan | null = null;
|
||||
|
||||
|
||||
await this.runAgentLoop(planningConversation, callbacks, async (functionCall) => {
|
||||
const functionCallName = functionCall.name;
|
||||
|
||||
|
|
@ -148,14 +157,20 @@ export class AIControllerService {
|
|||
capturedPlan = parseResult.success
|
||||
? new ExecutionPlan(parseResult.data)
|
||||
: new ExecutionPlan({ steps: [] });
|
||||
// Add response before exiting to avoid orphaned function call
|
||||
planningConversation.addFunctionResponse(new AIFunctionResponse(
|
||||
functionCallName,
|
||||
{ message: "Plan received" },
|
||||
functionCall.toolId
|
||||
));
|
||||
return { shouldExit: true }; // Exit once plan is submitted
|
||||
}
|
||||
|
||||
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
|
||||
this.updateThought(functionCall, callbacks);
|
||||
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
|
||||
planningConversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
}, false);
|
||||
}, true);
|
||||
|
||||
return capturedPlan ?? new ExecutionPlan({ steps: [] });
|
||||
}
|
||||
|
|
@ -211,6 +226,12 @@ export class AIControllerService {
|
|||
}
|
||||
// Capture replan data and exit execution loop to trigger replanning
|
||||
replanData = parseResult.data;
|
||||
// Add response before exiting to avoid orphaned function call
|
||||
conversation.addFunctionResponse(new AIFunctionResponse(
|
||||
functionCallName,
|
||||
{ message: "Replanning initiated" },
|
||||
functionCall.toolId
|
||||
));
|
||||
return { shouldExit: true };
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +254,7 @@ export class AIControllerService {
|
|||
return { shouldExit: false };
|
||||
}
|
||||
|
||||
this.updateThought({ functionCall, shouldContinue: false }, callbacks);
|
||||
this.updateThought(functionCall, callbacks);
|
||||
const functionResponse = await this.aiFunctionService.performAIFunction(functionCall);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
|
|
@ -244,26 +265,37 @@ export class AIControllerService {
|
|||
}
|
||||
|
||||
private async runAgentLoop(conversation: Conversation, callbacks: IChatServiceCallbacks,
|
||||
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, clearThoughtOnContent: boolean = true
|
||||
handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }>, isPlanningConversation: boolean = false
|
||||
): Promise<void> {
|
||||
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, clearThoughtOnContent);
|
||||
let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
|
||||
|
||||
if (!isPlanningConversation) {
|
||||
await this.onSaveConversation?.(conversation);
|
||||
}
|
||||
|
||||
while (response.functionCall || response.shouldContinue) {
|
||||
if (response.functionCall) {
|
||||
const result = await handleFunctionCall(response.functionCall);
|
||||
if (result.shouldExit) {
|
||||
if (!isPlanningConversation) {
|
||||
await this.onSaveConversation?.(conversation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
callbacks.onThoughtUpdate(Copy.AIThoughtMessage);
|
||||
}
|
||||
|
||||
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, clearThoughtOnContent);
|
||||
response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks, isPlanningConversation);
|
||||
|
||||
if (!isPlanningConversation) {
|
||||
await this.onSaveConversation?.(conversation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateThought(response: { functionCall: AIFunctionCall | null, shouldContinue: boolean }, callbacks: IChatServiceCallbacks) {
|
||||
const userMessage = response.functionCall?.arguments.user_message;
|
||||
private updateThought(functionCall: AIFunctionCall | null, callbacks: IChatServiceCallbacks) {
|
||||
const userMessage = functionCall?.arguments.user_message;
|
||||
if (userMessage && typeof userMessage === "string") {
|
||||
callbacks.onThoughtUpdate(userMessage);
|
||||
}
|
||||
|
|
@ -282,7 +314,7 @@ export class AIControllerService {
|
|||
return conversation;
|
||||
}
|
||||
|
||||
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, clearThoughtOnContent: boolean = true
|
||||
private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks, isPlanningConversation: boolean
|
||||
): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> {
|
||||
if (!this.ai) { // this should never happen
|
||||
return { functionCall: null, shouldContinue: false };
|
||||
|
|
@ -315,7 +347,7 @@ export class AIControllerService {
|
|||
accumulatedContent += chunk.content;
|
||||
|
||||
conversationContent.content = accumulatedContent;
|
||||
if (accumulatedContent.trim() !== "" && clearThoughtOnContent) {
|
||||
if (accumulatedContent.trim() !== "" && !isPlanningConversation) {
|
||||
callbacks.onThoughtUpdate(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -329,6 +361,7 @@ export class AIControllerService {
|
|||
conversationContent.content = sanitizedContent;
|
||||
if (capturedFunctionCall) {
|
||||
conversationContent.functionCall = capturedFunctionCall.toConversationString();
|
||||
conversationContent.toolId = capturedFunctionCall.toolId;
|
||||
if (capturedFunctionCall.thoughtSignature) {
|
||||
conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature;
|
||||
}
|
||||
|
|
@ -376,7 +409,15 @@ export class AIControllerService {
|
|||
}
|
||||
|
||||
private createPlanningResponse(conversationContent: ConversationContent, executionPlan: ExecutionPlan): AIFunctionResponse | undefined {
|
||||
const createParseResult = CreatePlanArgsSchema.safeParse(conversationContent.functionCall);
|
||||
if (!conversationContent.functionCall) {
|
||||
return undefined;
|
||||
}
|
||||
const parsedFunctionCall = parseFunctionCall(conversationContent.functionCall);
|
||||
if (!parsedFunctionCall) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const createParseResult = CreatePlanArgsSchema.safeParse(parsedFunctionCall.functionCall.args);
|
||||
if (createParseResult.success) {
|
||||
return new AIFunctionResponse(
|
||||
AIFunction.CreatePlan,
|
||||
|
|
@ -384,7 +425,7 @@ export class AIControllerService {
|
|||
conversationContent.toolId
|
||||
);
|
||||
}
|
||||
const replanParseResult = ReplanArgsSchema.safeParse(conversationContent.functionCall);
|
||||
const replanParseResult = ReplanArgsSchema.safeParse(parsedFunctionCall.functionCall.args);
|
||||
if (replanParseResult.success) {
|
||||
return new AIFunctionResponse(
|
||||
AIFunction.Replan,
|
||||
|
|
@ -392,5 +433,7 @@ export class AIControllerService {
|
|||
conversationContent.toolId
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -120,14 +120,18 @@ export class AIFunctionService {
|
|||
case AIFunction.RequestWebSearch:
|
||||
return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId)
|
||||
|
||||
// multi-agent functions are handled elsewhere - this shouldn't ever get hit
|
||||
// multi-agent functions are handled elsewhere - this shouldn't really ever get hit
|
||||
case AIFunction.CreatePlan:
|
||||
case AIFunction.Replan:
|
||||
case AIFunction.SubmitPlan:
|
||||
case AIFunction.CompleteStep:
|
||||
case AIFunction.CancelPlan: {
|
||||
Exception.throw(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`);
|
||||
break;
|
||||
Exception.log(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`);
|
||||
return new AIFunctionResponse(
|
||||
functionCall.name,
|
||||
{ error: `Failed to execute ${functionCall.name}.` },
|
||||
functionCall.toolId
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ export class ChatService {
|
|||
this.eventService = Resolve<EventService>(Services.EventService);
|
||||
this.abortService = Resolve<AbortService>(Services.AbortService);
|
||||
this.semaphore = new Semaphore(1, false);
|
||||
|
||||
this.aiControllerService.setSaveCallback(async (conversation) => {
|
||||
await this.saveConversation(conversation);
|
||||
});
|
||||
}
|
||||
|
||||
public onNameChanged: ((name: string) => void) | undefined = undefined;
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export class InputService {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Exception.log(error);
|
||||
Exception.warn(error); // Just a warning as some pasted content seem to give nonsense URIs
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export class ExecutionPlan {
|
|||
public constructor(plan: SubmitPlanArgs) {
|
||||
for (const [index, step] of plan.steps.entries()) {
|
||||
this.executionSteps.push(new ExecutionStep(
|
||||
index,
|
||||
index + 1,
|
||||
step.description,
|
||||
step.instruction,
|
||||
step.context
|
||||
|
|
@ -100,7 +100,7 @@ export class ExecutionPlan {
|
|||
return {
|
||||
plan: this.executionSteps.map((step, index) => `${index + 1}. ${step.description}`),
|
||||
firstStep: {
|
||||
step: firstStep.step + 1,
|
||||
step: firstStep.step,
|
||||
description: firstStep.description,
|
||||
instruction: firstStep.instruction,
|
||||
...(firstStep.context && { context: firstStep.context })
|
||||
|
|
|
|||
|
|
@ -376,7 +376,8 @@ describe('BaseAIClass Shared Methods', () => {
|
|||
functionResponse: JSON.stringify({
|
||||
id: 'call_with_response',
|
||||
functionResponse: { name: 'search', response: [] }
|
||||
})
|
||||
}),
|
||||
toolId: 'call_with_response'
|
||||
})
|
||||
];
|
||||
|
||||
|
|
@ -423,7 +424,8 @@ describe('BaseAIClass Shared Methods', () => {
|
|||
functionResponse: JSON.stringify({
|
||||
id: 'complete1',
|
||||
functionResponse: { name: 'read', response: 'data' }
|
||||
})
|
||||
}),
|
||||
toolId: 'complete1'
|
||||
}),
|
||||
|
||||
new ConversationContent({ role: Role.User, content: 'End', displayContent: 'End' })
|
||||
|
|
|
|||
|
|
@ -330,6 +330,20 @@ describe('Claude', () => {
|
|||
});
|
||||
|
||||
it('should convert function response to tool_result format', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_123',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_123'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
id: 'call_123',
|
||||
functionResponse: {
|
||||
|
|
@ -340,14 +354,15 @@ describe('Claude', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_123'
|
||||
});
|
||||
|
||||
const result = await (claude as any).extractContents([functionResponseContent]);
|
||||
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toHaveLength(1);
|
||||
expect(result[0].content[0]).toEqual({
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].content).toHaveLength(1);
|
||||
expect(result[1].content[0]).toEqual({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call_123',
|
||||
content: JSON.stringify(['file1.txt', 'file2.txt'])
|
||||
|
|
@ -380,20 +395,35 @@ describe('Claude', () => {
|
|||
it('should handle invalid JSON in function response gracefully', async () => {
|
||||
const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {});
|
||||
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_invalid',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
|
||||
const invalidContent = new ConversationContent({
|
||||
role: Role.User,
|
||||
content: 'invalid json {',
|
||||
displayContent: 'invalid json {',
|
||||
functionResponse: 'invalid json {'
|
||||
functionResponse: 'invalid json {',
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
|
||||
const result = await (claude as any).extractContents([invalidContent]);
|
||||
const result = await (claude as any).extractContents([functionCallContent, invalidContent]);
|
||||
|
||||
// Should fallback to text
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toHaveLength(1);
|
||||
expect(result[0].content[0].type).toBe('text');
|
||||
expect(result[0].content[0].text).toBe('invalid json {');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].content).toHaveLength(1);
|
||||
expect(result[1].content[0].type).toBe('text');
|
||||
expect(result[1].content[0].text).toBe('invalid json {');
|
||||
expect(exceptionSpy).toHaveBeenCalled();
|
||||
|
||||
exceptionSpy.mockRestore();
|
||||
|
|
@ -502,6 +532,20 @@ describe('Claude', () => {
|
|||
});
|
||||
|
||||
it('should convert function response without ID to legacy text format', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_legacy',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_legacy'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
functionResponse: {
|
||||
name: 'search_vault_files',
|
||||
|
|
@ -513,14 +557,15 @@ describe('Claude', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_legacy'
|
||||
});
|
||||
|
||||
const result = await (claude as any).extractContents([functionResponseContent]);
|
||||
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toHaveLength(1);
|
||||
expect(result[0].content[0].type).toBe('text');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].content).toHaveLength(1);
|
||||
expect(result[1].content[0].type).toBe('text');
|
||||
const expected = `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
|
||||
{
|
||||
"name": "search_vault_files",
|
||||
|
|
@ -529,10 +574,24 @@ describe('Claude', () => {
|
|||
"file2.txt"
|
||||
]
|
||||
}`;
|
||||
expect(result[0].content[0].text).toBe(expected);
|
||||
expect(result[1].content[0].text).toBe(expected);
|
||||
});
|
||||
|
||||
it('should convert function response with empty ID to legacy text format', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_empty',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_empty'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
id: '', // Empty ID
|
||||
functionResponse: {
|
||||
|
|
@ -544,14 +603,15 @@ describe('Claude', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_empty'
|
||||
});
|
||||
|
||||
const result = await (claude as any).extractContents([functionResponseContent]);
|
||||
const result = await (claude as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toHaveLength(1);
|
||||
expect(result[0].content[0].type).toBe('text');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].content).toHaveLength(1);
|
||||
expect(result[1].content[0].type).toBe('text');
|
||||
const expected = `<!-- Historical tool result. This action was ALREADY COMPLETED. -->
|
||||
{
|
||||
"name": "search_vault_files",
|
||||
|
|
@ -560,7 +620,7 @@ describe('Claude', () => {
|
|||
"file2.txt"
|
||||
]
|
||||
}`;
|
||||
expect(result[0].content[0].text).toBe(expected);
|
||||
expect(result[1].content[0].text).toBe(expected);
|
||||
});
|
||||
|
||||
it('should exclude orphaned function calls without responses', async () => {
|
||||
|
|
@ -1131,7 +1191,8 @@ describe('Claude', () => {
|
|||
'x-api-key': 'test-claude-key',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true'
|
||||
})
|
||||
}),
|
||||
expect.any(Function) // extractRetryDelay
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,20 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
|
||||
it('should convert function response without id to legacy text format', async () => {
|
||||
// Function responses from Claude/OpenAI may not have the id field in content
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_cross1',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_cross1'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
functionResponse: {
|
||||
name: 'search_vault_files',
|
||||
|
|
@ -157,16 +171,17 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_cross1'
|
||||
});
|
||||
const result = await (gemini as any).extractContents([claudeResponse]);
|
||||
const result = await (gemini as any).extractContents([functionCallContent, claudeResponse]);
|
||||
|
||||
expect(result[0].parts[0]).toHaveProperty('text');
|
||||
expect(result[0].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
|
||||
expect(result[0].parts[0].text).toContain('"response": [');
|
||||
expect(result[0].parts[0].text).toContain(' "note1.md"');
|
||||
expect(result[0].parts[0].text).toContain(' "note2.md"');
|
||||
expect(result[1].parts[0]).toHaveProperty('text');
|
||||
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[1].parts[0].text).toContain('"name": "search_vault_files"');
|
||||
expect(result[1].parts[0].text).toContain('"response": [');
|
||||
expect(result[1].parts[0].text).toContain(' "note1.md"');
|
||||
expect(result[1].parts[0].text).toContain(' "note2.md"');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -237,7 +252,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_claude_123'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -262,7 +278,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
}),
|
||||
timestamp: new Date(),
|
||||
shouldDisplayContent: true,
|
||||
thoughtSignature: 'geminiSignature123=='
|
||||
thoughtSignature: 'geminiSignature123==',
|
||||
toolId: 'gemini-response-1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -280,7 +297,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'gemini-response-1'
|
||||
});
|
||||
return content;
|
||||
})()
|
||||
|
|
@ -342,7 +360,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_openai_456'
|
||||
});
|
||||
return content;
|
||||
})()
|
||||
|
|
@ -387,7 +406,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call-1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -405,7 +425,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
}),
|
||||
timestamp: new Date(),
|
||||
shouldDisplayContent: true,
|
||||
thoughtSignature: 'sig2=='
|
||||
thoughtSignature: 'sig2==',
|
||||
toolId: 'resp-2'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -419,7 +440,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'resp-2'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -483,7 +505,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_abc123'
|
||||
});
|
||||
return content;
|
||||
})();
|
||||
|
|
@ -537,7 +560,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_xyz789'
|
||||
});
|
||||
return content;
|
||||
})();
|
||||
|
|
@ -597,7 +621,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -638,7 +663,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_2'
|
||||
});
|
||||
return content;
|
||||
})()
|
||||
|
|
@ -789,6 +815,20 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
|
||||
it('should handle OpenAI function response when switching to Gemini', async () => {
|
||||
// OpenAI response with ID should work with Gemini
|
||||
const openaiCall = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_123',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_123'
|
||||
});
|
||||
|
||||
const openaiResponse = (() => {
|
||||
const responseContent = JSON.stringify({
|
||||
id: 'call_123',
|
||||
|
|
@ -801,16 +841,17 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_123'
|
||||
});
|
||||
return content;
|
||||
})();
|
||||
|
||||
const result = await (gemini as any).extractContents([openaiResponse]);
|
||||
const result = await (gemini as any).extractContents([openaiCall, openaiResponse]);
|
||||
|
||||
// Gemini should accept the response with ID
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].parts[0]).toEqual({
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].parts[0]).toEqual({
|
||||
functionResponse: {
|
||||
name: 'search_vault_files',
|
||||
response: ['file1.md', 'file2.md']
|
||||
|
|
@ -834,7 +875,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
}),
|
||||
timestamp: new Date(),
|
||||
shouldDisplayContent: true,
|
||||
thoughtSignature: 'gemini_signature_123=='
|
||||
thoughtSignature: 'gemini_signature_123==',
|
||||
toolId: 'gemini_tool_1'
|
||||
});
|
||||
|
||||
const geminiResponse = (() => {
|
||||
|
|
@ -849,7 +891,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'gemini_tool_1'
|
||||
});
|
||||
return content;
|
||||
})();
|
||||
|
|
@ -911,7 +954,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_xyz'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -976,7 +1020,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1013,7 +1058,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_2'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1062,7 +1108,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
}),
|
||||
timestamp: new Date(),
|
||||
shouldDisplayContent: true,
|
||||
thoughtSignature: 'gemini_signature_1=='
|
||||
thoughtSignature: 'gemini_signature_1==',
|
||||
toolId: 'resp_1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1076,7 +1123,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'resp_1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1113,7 +1161,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_2'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1150,7 +1199,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_3'
|
||||
});
|
||||
return content;
|
||||
})()
|
||||
|
|
@ -1211,7 +1261,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_round'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1279,7 +1330,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_round'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1340,7 +1392,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'toolu_step1'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1376,7 +1429,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_step2'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1397,7 +1451,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
}),
|
||||
timestamp: new Date(),
|
||||
shouldDisplayContent: true,
|
||||
thoughtSignature: 'gemini_step3_signature=='
|
||||
thoughtSignature: 'gemini_step3_signature==',
|
||||
toolId: 'resp_step3'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1411,7 +1466,8 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'resp_step3'
|
||||
});
|
||||
return content;
|
||||
})(),
|
||||
|
|
@ -1613,8 +1669,9 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
expect(openaiResult).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle function response ID mismatch gracefully', async () => {
|
||||
it('should filter out function response with mismatched toolId', async () => {
|
||||
// Function call with one ID, response with different ID
|
||||
// The new filtering logic should filter out the mismatched response
|
||||
const conversation = [
|
||||
(() => {
|
||||
const content = new ConversationContent({
|
||||
|
|
@ -1646,24 +1703,22 @@ describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call-456' // Mismatched toolId
|
||||
});
|
||||
return content;
|
||||
})()
|
||||
];
|
||||
|
||||
// Providers should still parse this (even if IDs don't match)
|
||||
// The mismatched response should be filtered out, leaving only the orphaned function call
|
||||
// But orphaned function calls (without responses) are also filtered unless they're the last item
|
||||
const claude = new Claude();
|
||||
const claudeResult = await (claude as any).extractContents(conversation);
|
||||
expect(claudeResult).toHaveLength(2);
|
||||
expect(claudeResult[0].content[0].id).toBe('call-123');
|
||||
expect(claudeResult[1].content[0].tool_use_id).toBe('call-456');
|
||||
expect(claudeResult).toHaveLength(0);
|
||||
|
||||
const openai = new OpenAI();
|
||||
const openaiResult = await (openai as any).extractContents(conversation);
|
||||
expect(openaiResult).toHaveLength(2);
|
||||
expect(openaiResult[0].call_id).toBe('call-123');
|
||||
expect(openaiResult[1].call_id).toBe('call-456');
|
||||
expect(openaiResult).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -545,6 +545,20 @@ describe('Gemini', () => {
|
|||
});
|
||||
|
||||
it('should convert function response to Gemini format', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call-123',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call-123'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
id: 'call-123',
|
||||
functionResponse: {
|
||||
|
|
@ -556,15 +570,16 @@ describe('Gemini', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent, // displayContent for User role
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call-123'
|
||||
});
|
||||
|
||||
const result = await (gemini as any).extractContents([functionResponseContent]);
|
||||
const result = await (gemini as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].parts).toHaveLength(1);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].parts).toHaveLength(1);
|
||||
// Gemini API requires both 'name' and 'response' fields
|
||||
expect(result[0].parts[0]).toEqual({
|
||||
expect(result[1].parts[0]).toEqual({
|
||||
functionResponse: {
|
||||
name: 'search_vault_files',
|
||||
response: ['file1.txt', 'file2.txt']
|
||||
|
|
@ -573,6 +588,20 @@ describe('Gemini', () => {
|
|||
});
|
||||
|
||||
it('should fall back to legacy text format for function response without id (cross-provider)', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_legacy1',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_legacy1'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
functionResponse: {
|
||||
name: 'search_vault_files',
|
||||
|
|
@ -583,22 +612,37 @@ describe('Gemini', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_legacy1'
|
||||
});
|
||||
|
||||
const result = await (gemini as any).extractContents([functionResponseContent]);
|
||||
const result = await (gemini as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].parts).toHaveLength(1);
|
||||
expect(result[0].parts[0]).toHaveProperty('text');
|
||||
expect(result[0].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
|
||||
expect(result[0].parts[0].text).toContain('"response": [');
|
||||
expect(result[0].parts[0].text).toContain(' "file1.txt"');
|
||||
expect(result[0].parts[0].text).toContain(' "file2.txt"');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].parts).toHaveLength(1);
|
||||
expect(result[1].parts[0]).toHaveProperty('text');
|
||||
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[1].parts[0].text).toContain('"name": "search_vault_files"');
|
||||
expect(result[1].parts[0].text).toContain('"response": [');
|
||||
expect(result[1].parts[0].text).toContain(' "file1.txt"');
|
||||
expect(result[1].parts[0].text).toContain(' "file2.txt"');
|
||||
});
|
||||
|
||||
it('should fall back to legacy text format for function response with empty id', async () => {
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_legacy2',
|
||||
name: 'read_file',
|
||||
args: { path: 'test.md' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_legacy2'
|
||||
});
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
id: '',
|
||||
functionResponse: {
|
||||
|
|
@ -610,17 +654,18 @@ describe('Gemini', () => {
|
|||
role: Role.User,
|
||||
content: responseContent,
|
||||
displayContent: responseContent,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_legacy2'
|
||||
});
|
||||
|
||||
const result = await (gemini as any).extractContents([functionResponseContent]);
|
||||
const result = await (gemini as any).extractContents([functionCallContent, functionResponseContent]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].parts[0]).toHaveProperty('text');
|
||||
expect(result[0].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[0].parts[0].text).toContain('"name": "read_file"');
|
||||
expect(result[0].parts[0].text).toContain('"response": {');
|
||||
expect(result[0].parts[0].text).toContain(' "content": "file contents"');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].parts[0]).toHaveProperty('text');
|
||||
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
|
||||
expect(result[1].parts[0].text).toContain('"name": "read_file"');
|
||||
expect(result[1].parts[0].text).toContain('"response": {');
|
||||
expect(result[1].parts[0].text).toContain(' "content": "file contents"');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in function call gracefully', async () => {
|
||||
|
|
@ -649,19 +694,34 @@ describe('Gemini', () => {
|
|||
it('should handle invalid JSON in function response gracefully', async () => {
|
||||
const exceptionSpy = vi.spyOn(Exception, 'log').mockImplementation(() => {});
|
||||
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_invalid',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
|
||||
const invalidContent = new ConversationContent({
|
||||
role: Role.User,
|
||||
content: 'invalid json {',
|
||||
displayContent: 'invalid json {', // displayContent for User role
|
||||
functionResponse: 'invalid json {'
|
||||
functionResponse: 'invalid json {',
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
|
||||
const result = await (gemini as any).extractContents([invalidContent]);
|
||||
const result = await (gemini as any).extractContents([functionCallContent, invalidContent]);
|
||||
|
||||
// Should fallback to text
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].parts).toHaveLength(1);
|
||||
expect(result[0].parts[0]).toEqual({ text: 'invalid json {' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].parts).toHaveLength(1);
|
||||
expect(result[1].parts[0]).toEqual({ text: 'invalid json {' });
|
||||
expect(exceptionSpy).toHaveBeenCalled();
|
||||
|
||||
exceptionSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -377,6 +377,22 @@ describe('OpenAI', () => {
|
|||
|
||||
it('should convert function response to function_call_output format', async () => {
|
||||
const conversation = new Conversation();
|
||||
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_123',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_123'
|
||||
});
|
||||
conversation.contents.push(functionCallContent);
|
||||
|
||||
const responseContent = JSON.stringify({
|
||||
id: 'call_123',
|
||||
functionResponse: {
|
||||
|
|
@ -386,7 +402,8 @@ describe('OpenAI', () => {
|
|||
});
|
||||
const functionResponseContent = new ConversationContent({
|
||||
role: Role.User,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_123'
|
||||
});
|
||||
conversation.contents.push(functionResponseContent);
|
||||
|
||||
|
|
@ -400,9 +417,9 @@ describe('OpenAI', () => {
|
|||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
|
||||
// Should have 1 function_call_output item
|
||||
expect(requestBody.input).toHaveLength(1);
|
||||
expect(requestBody.input[0]).toEqual({
|
||||
// Should have 2 items: function call and function response
|
||||
expect(requestBody.input).toHaveLength(2);
|
||||
expect(requestBody.input[1]).toEqual({
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_123',
|
||||
output: '["file1.txt","file2.txt"]'
|
||||
|
|
@ -439,9 +456,26 @@ describe('OpenAI', () => {
|
|||
const exceptionSpy = vi.spyOn(Exception, 'log');
|
||||
|
||||
const conversation = new Conversation();
|
||||
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_invalid',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
conversation.contents.push(functionCallContent);
|
||||
|
||||
const invalidContent = new ConversationContent({
|
||||
role: Role.User,
|
||||
functionResponse: 'invalid json {'
|
||||
functionResponse: 'invalid json {',
|
||||
toolId: 'call_invalid'
|
||||
});
|
||||
conversation.contents.push(invalidContent);
|
||||
|
||||
|
|
@ -454,10 +488,11 @@ describe('OpenAI', () => {
|
|||
|
||||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
const message = requestBody.input.find((m: any) => m.role === Role.User);
|
||||
const messages = requestBody.input.filter((m: any) => m.role === Role.User);
|
||||
|
||||
expect(message.content).toBe('invalid json {');
|
||||
expect(message.role).toBe(Role.User); // Falls back to original role
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].content).toBe('invalid json {');
|
||||
expect(messages[0].role).toBe(Role.User); // Falls back to original role
|
||||
expect(exceptionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -730,6 +765,22 @@ describe('OpenAI', () => {
|
|||
|
||||
it('should handle complex function response objects', async () => {
|
||||
const conversation = new Conversation();
|
||||
|
||||
const functionCallContent = new ConversationContent({
|
||||
role: Role.Assistant,
|
||||
content: '',
|
||||
displayContent: '',
|
||||
functionCall: JSON.stringify({
|
||||
functionCall: {
|
||||
id: 'call_123',
|
||||
name: 'search_vault_files',
|
||||
args: { query: 'test' }
|
||||
}
|
||||
}),
|
||||
toolId: 'call_123'
|
||||
});
|
||||
conversation.contents.push(functionCallContent);
|
||||
|
||||
const complexResponse = {
|
||||
files: ['file1.txt', 'file2.md'],
|
||||
count: 2,
|
||||
|
|
@ -744,7 +795,8 @@ describe('OpenAI', () => {
|
|||
});
|
||||
const functionResponseContent = new ConversationContent({
|
||||
role: Role.User,
|
||||
functionResponse: responseContent
|
||||
functionResponse: responseContent,
|
||||
toolId: 'call_123'
|
||||
});
|
||||
conversation.contents.push(functionResponseContent);
|
||||
|
||||
|
|
@ -758,8 +810,8 @@ describe('OpenAI', () => {
|
|||
const callArgs = mockStreamingService.streamRequest.mock.calls[0];
|
||||
const requestBody = callArgs[1];
|
||||
|
||||
expect(requestBody.input).toHaveLength(1);
|
||||
expect(requestBody.input[0]).toEqual({
|
||||
expect(requestBody.input).toHaveLength(2);
|
||||
expect(requestBody.input[1]).toEqual({
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_123',
|
||||
output: JSON.stringify(complexResponse)
|
||||
|
|
@ -918,7 +970,8 @@ describe('OpenAI', () => {
|
|||
expect.objectContaining({
|
||||
'Authorization': 'Bearer test-openai-key',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
}),
|
||||
expect.any(Function) // extractRetryDelay
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue