mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Add provider-level tool call handling with Mistral web search support
Implement optional `resolveToolCall` method in IAIClass interface to allow providers to handle specific tools before falling through to AIToolService. Add Mistral web search capability using native agent API, enabling dynamic tool resolution at the provider level.
This commit is contained in:
parent
0ffaa95a57
commit
a32f153087
12 changed files with 226 additions and 25 deletions
|
|
@ -5,6 +5,8 @@ import type { IAIToolDefinition } from "./ToolDefinitions/IAIToolDefinition";
|
|||
import type { AIProvider } from "Enums/ApiProvider";
|
||||
import type { AgentType } from "Enums/AgentType";
|
||||
import type { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
||||
import type { AIToolCall } from "./AIToolCall";
|
||||
import type { AIToolResponse } from "./ToolDefinitions/AIToolResponse";
|
||||
|
||||
export interface IAIClass {
|
||||
get currentProvider(): AIProvider;
|
||||
|
|
@ -16,4 +18,7 @@ export interface IAIClass {
|
|||
|
||||
streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
|
||||
formatBinaryFiles(attachments: Attachment[]): string;
|
||||
|
||||
// Optional provider-level tool call handler. Called before AIToolService.
|
||||
resolveToolCall?(toolCall: AIToolCall): Promise<AIToolResponse | null>;
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ import type { Conversation } from "Conversations/Conversation";
|
|||
import type { Attachment } from "Conversations/Attachment";
|
||||
import { AIProvider, AIProviderURL } from "Enums/ApiProvider";
|
||||
import { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import { fromString as aiToolFromString } from "Enums/AITool";
|
||||
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import { fromString as aiToolFromString, AITool } from "Enums/AITool";
|
||||
import type { IAIToolDefinition } from "AIClasses/ToolDefinitions/IAIToolDefinition";
|
||||
import type { ConversationContent } from "Conversations/ConversationContent";
|
||||
import { Role } from "Enums/Role";
|
||||
|
|
@ -18,6 +19,7 @@ import { AIToolUsageMode } from "Enums/AIToolUsageMode";
|
|||
import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, MistralContentPart } from "./MistralTypes";
|
||||
import type { MistralFileService } from "./MistralFileService";
|
||||
import { Copy, replaceCopy } from "Enums/Copy";
|
||||
import { MistralAgent } from "./MistralAgent";
|
||||
|
||||
export class Mistral extends BaseAIClass {
|
||||
|
||||
|
|
@ -32,6 +34,8 @@ export class Mistral extends BaseAIClass {
|
|||
MimeType.IMAGE_WEBP
|
||||
];
|
||||
|
||||
private readonly agent: MistralAgent = new MistralAgent();
|
||||
|
||||
// Accumulation state for streaming tool calls
|
||||
private accumulatedToolCalls: Map<number, { id: string; name: string; args: string }> = new Map();
|
||||
|
||||
|
|
@ -40,30 +44,30 @@ export class Mistral extends BaseAIClass {
|
|||
}
|
||||
|
||||
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
|
||||
const messages = await this.buildMessages(conversation);
|
||||
|
||||
this.accumulatedToolCalls.clear();
|
||||
|
||||
// Refresh file cache only if conversation has attachments
|
||||
if (conversation.hasAttachments()) {
|
||||
await this.aiFileService.refreshCache();
|
||||
}
|
||||
|
||||
const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`;
|
||||
|
||||
const messages = await this.extractContents(conversation.contents);
|
||||
|
||||
// Add system message at the beginning
|
||||
const allMessages: MistralMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...messages
|
||||
const tools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: AITool.RequestWebSearch,
|
||||
description: `Use this function when you need to search the web for current information, recent events, news, or facts that may have changed.`,
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
query: { type: "string", description: "The search query to look up on the web." }
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
...this.mapFunctionDefinitions(this.aiToolDefinitions)
|
||||
];
|
||||
|
||||
const tools = this.mapFunctionDefinitions(this.aiToolDefinitions);
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: this.model(),
|
||||
max_tokens: 16384,
|
||||
messages: allMessages,
|
||||
messages: messages,
|
||||
stream: true
|
||||
};
|
||||
|
||||
|
|
@ -87,6 +91,32 @@ export class Mistral extends BaseAIClass {
|
|||
);
|
||||
}
|
||||
|
||||
public async resolveToolCall(toolCall: AIToolCall): Promise<AIToolResponse | null> {
|
||||
if (toolCall.name !== AITool.RequestWebSearch) {
|
||||
return null;
|
||||
}
|
||||
const query = (toolCall.arguments as Record<string, string>).query ?? "";
|
||||
const result = await this.agent.search(query);
|
||||
return new AIToolResponse(toolCall.name, { result }, toolCall.toolId);
|
||||
}
|
||||
|
||||
private async buildMessages(conversation: Conversation): Promise<MistralMessage[]> {
|
||||
this.accumulatedToolCalls.clear();
|
||||
|
||||
// Refresh file cache only if conversation has attachments
|
||||
if (conversation.hasAttachments()) {
|
||||
await this.aiFileService.refreshCache();
|
||||
}
|
||||
|
||||
const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`;
|
||||
const messages = await this.extractContents(conversation.contents);
|
||||
|
||||
return [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...messages
|
||||
];
|
||||
}
|
||||
|
||||
protected parseStreamChunk(chunk: string): IStreamChunk {
|
||||
try {
|
||||
// Mistral sends "[DONE]" as the final message
|
||||
|
|
|
|||
114
AIClasses/Mistral/MistralAgent.ts
Normal file
114
AIClasses/Mistral/MistralAgent.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Resolve } from "Services/DependencyService";
|
||||
import { Services } from "Services/Services";
|
||||
import type { SettingsService } from "Services/SettingsService";
|
||||
import { AIProvider, AIProviderModel, MistralAgentEndpoint } from "Enums/ApiProvider";
|
||||
import { Exception } from "Helpers/Exception";
|
||||
import type {
|
||||
MistralAgentCreateRequest,
|
||||
MistralAgentCreateResponse,
|
||||
MistralAgentListResponse,
|
||||
MistralConversationRequest,
|
||||
MistralConversationResponse
|
||||
} from "./MistralTypes";
|
||||
|
||||
/**
|
||||
* Performs a one-shot web search using the Mistral Agents API.
|
||||
* Creates and caches a search agent on first use, then reuses it for subsequent calls.
|
||||
*/
|
||||
export class MistralAgent {
|
||||
|
||||
private readonly apiKey: string;
|
||||
private agentId: string | undefined;
|
||||
|
||||
public constructor() {
|
||||
const settingsService = Resolve<SettingsService>(Services.SettingsService);
|
||||
this.apiKey = settingsService.getApiKeyForProvider(AIProvider.Mistral);
|
||||
}
|
||||
|
||||
public async search(query: string): Promise<string> {
|
||||
if (!this.agentId) {
|
||||
this.agentId = await this.getOrCreateAgent();
|
||||
}
|
||||
|
||||
return await this.complete(this.agentId, query);
|
||||
}
|
||||
|
||||
private async getOrCreateAgent(): Promise<string> {
|
||||
const listResponse = await fetch(MistralAgentEndpoint.Url, {
|
||||
headers: { "Authorization": `Bearer ${this.apiKey}` }
|
||||
});
|
||||
|
||||
if (listResponse.ok) {
|
||||
const list = await listResponse.json() as MistralAgentListResponse;
|
||||
const existing = list.find(agent => agent.name === "VaultKeeper Web Search");
|
||||
if (existing) {
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.createAgent();
|
||||
}
|
||||
|
||||
private async createAgent(): Promise<string> {
|
||||
const requestBody: MistralAgentCreateRequest = {
|
||||
model: AIProviderModel.MistralMedium,
|
||||
name: "VaultKeeper Web Search",
|
||||
instructions: "You are a web search assistant. **Always** search the web to look up current information before responding and never answer from memory alone.",
|
||||
tools: [{ type: "web_search" }]
|
||||
};
|
||||
|
||||
const response = await fetch(MistralAgentEndpoint.Url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
Exception.throw(`Mistral create agent failed: ${response.status} ${response.statusText} - ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as MistralAgentCreateResponse;
|
||||
return data.id;
|
||||
}
|
||||
|
||||
private async complete(agentId: string, query: string): Promise<string> {
|
||||
const requestBody: MistralConversationRequest = {
|
||||
agent_id: agentId,
|
||||
inputs: query,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const response = await fetch(MistralAgentEndpoint.ConversationsUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = `Mistral agent completion failed: ${response.status} ${response.statusText} - ${await response.text()}`;
|
||||
Exception.log(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
const data = await response.json() as MistralConversationResponse;
|
||||
const messageOutput = data.outputs?.find(output => output.type === "message.output");
|
||||
const content = messageOutput?.content;
|
||||
const textOutput = Array.isArray(content)
|
||||
? content.filter(content => content.type === "text").map(content => content.text ?? "").join()
|
||||
: typeof content === "string" ? content : undefined;
|
||||
|
||||
if (!textOutput) {
|
||||
const error = "Mistral web agent returned no content";
|
||||
Exception.log(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
return textOutput;
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,44 @@ export interface MistralContentPart {
|
|||
document_url?: string;
|
||||
}
|
||||
|
||||
// Agents API types
|
||||
export interface MistralAgentCreateRequest {
|
||||
model: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
tools: Array<{ type: "web_search" | "function" }>;
|
||||
}
|
||||
|
||||
export interface MistralAgentCreateResponse {
|
||||
id: string;
|
||||
object: "agent";
|
||||
created_at: string;
|
||||
name: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type MistralAgentListResponse = MistralAgentCreateResponse[];
|
||||
|
||||
export interface MistralConversationRequest {
|
||||
agent_id: string;
|
||||
inputs: string;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface MistralConversationResponse {
|
||||
object: "conversation.response";
|
||||
conversation_id: string;
|
||||
outputs: Array<{
|
||||
type: "message.output" | "tool.execution";
|
||||
role?: string;
|
||||
content?: string | Array<{
|
||||
type: "text" | "tool_reference";
|
||||
text?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// File API types
|
||||
export interface MistralFile {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export enum AITool {
|
|||
ReadMemories = "read_memories",
|
||||
UpdateMemories = "update_memories",
|
||||
|
||||
// only used by gemini
|
||||
// used by gemini and mistral
|
||||
RequestWebSearch = "request_web_search",
|
||||
|
||||
// multi agent calls
|
||||
|
|
|
|||
|
|
@ -96,4 +96,9 @@ export enum AIFileServiceURL {
|
|||
GeminiUpload = "https://generativelanguage.googleapis.com/upload/v1beta",
|
||||
OpenAI = "https://api.openai.com/v1/files",
|
||||
Mistral = "https://api.mistral.ai/v1/files",
|
||||
}
|
||||
|
||||
export enum MistralAgentEndpoint {
|
||||
Url = "https://api.mistral.ai/v1/agents",
|
||||
ConversationsUrl = "https://api.mistral.ai/v1/conversations"
|
||||
}
|
||||
|
|
@ -5,5 +5,5 @@ export enum Path {
|
|||
Attachments = `${Path.Conversations}/Attachments`,
|
||||
UserInstructions = `${Path.VaultkeeperAIDir}/User Instructions`,
|
||||
ExampleUserInstructions = `${Path.UserInstructions}/EXAMPLE_INSTRUCTIONS.md`,
|
||||
Memories = `${Path.VaultkeeperAIDir}/MEMORIES.md`
|
||||
Memories = `${Path.VaultkeeperAIDir}/Memories.md`
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AIToolCall } from "AIClasses/AIToolCall";
|
||||
import type { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
|
||||
import type { IAIClass } from "AIClasses/IAIClass";
|
||||
import type { IPrompt } from "AIPrompts/IPrompt";
|
||||
import type { Conversation } from "Conversations/Conversation";
|
||||
|
|
@ -203,6 +204,14 @@ export abstract class BaseAgent {
|
|||
return { toolCall: capturedToolCall, shouldContinue: capturedShouldContinue };
|
||||
}
|
||||
|
||||
protected async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
|
||||
const providerResult = await this.ai?.resolveToolCall?.(toolCall) ?? null;
|
||||
if (providerResult !== null) {
|
||||
return providerResult;
|
||||
}
|
||||
return this.aiToolService.performAITool(toolCall);
|
||||
}
|
||||
|
||||
private async withToolCallingDisabled<T>(callback: () => Promise<T>): Promise<T> {
|
||||
if (!this.ai) { // this shouldn't ever happen
|
||||
Exception.throw("Error: No AI provider has been set!");
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export class ExecutionAgent extends BaseAgent {
|
|||
|
||||
this.debugService?.log("ExecutionAgent", `Executing function: ${toolCall.name}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.aiToolService.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
this.conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export class MainAgent extends BaseAgent {
|
|||
|
||||
this.debugService?.log("MainAgent", `Executing function: ${toolCall.name}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.aiToolService.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export class OrchestrationAgent extends BaseAgent {
|
|||
isAITool(toolCallName, AITool.ListVaultFiles)) {
|
||||
this.debugService?.log("Orchestration", `Vault tool called for recovery: ${toolCallName}`);
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const toolResponse = await this.aiToolService.performAITool(toolCall);
|
||||
const toolResponse = await this.performAITool(toolCall);
|
||||
planningConversation.addFunctionResponse(toolResponse);
|
||||
return Promise.resolve({ shouldExit: false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export class PlanningAgent extends BaseAgent {
|
|||
}
|
||||
|
||||
this.updateThought(toolCall, callbacks);
|
||||
const functionResponse = await this.aiToolService.performAITool(toolCall);
|
||||
const functionResponse = await this.performAITool(toolCall);
|
||||
conversation.addFunctionResponse(functionResponse);
|
||||
return { shouldExit: false };
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue