diff --git a/src/lib/connectors/tools.ts b/src/lib/connectors/tools.ts index 1936cbc..d8be94b 100644 --- a/src/lib/connectors/tools.ts +++ b/src/lib/connectors/tools.ts @@ -233,18 +233,38 @@ export async function buildToolsForUser( }), z.object({ kind: z.literal("list"), - ordered: z.boolean(), - items: z.array(z.string().min(1)).min(1), + ordered: z + .boolean() + .describe("true = liste numérotée (1. 2. 3.), false = puces."), + items: z + .array(z.string().min(1)) + .min(1) + .describe( + "Éléments de la liste, un par entrée ; **gras** / _italique_ inline autorisé." + ), }), z.object({ kind: z.literal("blockquote"), - content: z.string().min(1), + content: z + .string() + .min(1) + .describe( + "Citation en retrait — ex. reproduction d'un article de loi ou d'une clause contractuelle." + ), }), z.object({ kind: z.literal("table"), - headers: z.array(z.string()).min(1), - rows: z.array(z.array(z.string())).min(1), - caption: z.string().optional(), + headers: z.array(z.string()).min(1).describe("En-têtes de colonnes."), + rows: z + .array(z.array(z.string())) + .min(1) + .describe( + "Lignes ; chaque ligne doit comporter autant de cellules que d'en-têtes. Markdown inline autorisé." + ), + caption: z + .string() + .optional() + .describe("Légende optionnelle affichée sous le tableau."), }), z.object({ kind: z.literal("pageBreak"), diff --git a/src/lib/mcp/client.ts b/src/lib/mcp/client.ts index 9143a53..3e2d907 100644 --- a/src/lib/mcp/client.ts +++ b/src/lib/mcp/client.ts @@ -95,10 +95,51 @@ export async function mcpListTools(server: McpServer): Promise }); } +// Circuit-breaker en mémoire (best-effort, par process) : après plusieurs +// échecs/timeouts d'affilée sur un serveur, on coupe court pendant un cooldown +// au lieu de repayer le timeout à chaque appel d'outil du tour. +const CIRCUIT_THRESHOLD = 3; +const CIRCUIT_COOLDOWN_MS = 60_000; +const circuits = new Map(); + +function circuitOpen(serverId: string): boolean { + const c = circuits.get(serverId); + return c != null && Date.now() < c.openUntil; +} +function recordCircuitSuccess(serverId: string): void { + circuits.delete(serverId); +} +function recordCircuitFailure(serverId: string): void { + const c = circuits.get(serverId) ?? { failures: 0, openUntil: 0 }; + c.failures += 1; + if (c.failures >= CIRCUIT_THRESHOLD) c.openUntil = Date.now() + CIRCUIT_COOLDOWN_MS; + circuits.set(serverId, c); +} + export async function mcpCallTool( server: McpServer, toolName: string, args: Record +): Promise { + if (circuitOpen(server.id)) { + throw new Error( + `Serveur MCP « ${server.label} » temporairement indisponible (trop d'échecs récents).` + ); + } + try { + const res = await mcpCallToolInner(server, toolName, args); + recordCircuitSuccess(server.id); + return res; + } catch (err) { + recordCircuitFailure(server.id); + throw err; + } +} + +function mcpCallToolInner( + server: McpServer, + toolName: string, + args: Record ): Promise { return withClient(server, async (client) => { const res = await client.callTool({ name: toolName, arguments: args }); diff --git a/src/lib/orchestrator/context-budget.ts b/src/lib/orchestrator/context-budget.ts index 45601cb..d10138e 100644 --- a/src/lib/orchestrator/context-budget.ts +++ b/src/lib/orchestrator/context-budget.ts @@ -90,7 +90,40 @@ export function trimMessages( return sanitizeToolMessages(kept); } +/** + * Garde-fou : un SEUL message peut à lui seul dépasser le budget — typiquement + * le bloc de référence non-fiable (gros document joint), que trimMessages ne + * rogne JAMAIS car il fait partie des 2 derniers messages conservés. Sur un + * petit modèle souverain, l'appel échouerait. On plafonne donc le contenu + * textuel d'un message surdimensionné, avec un marqueur neutre — le modèle + * complète via search_documents / read_document (cf. règles du prompt système). + */ +const MAX_SINGLE_MESSAGE_FRACTION = 0.6; + +function capOversizedMessages( + messages: ModelMessage[], + budgetTokens: number +): ModelMessage[] { + const maxChars = Math.floor(budgetTokens * 4 * MAX_SINGLE_MESSAGE_FRACTION); + return messages.map((m) => { + // Seuls user/system/assistant portent un contenu string ; un message `tool` + // a un contenu structuré (parts) et n'est pas concerné. + if ( + m.role !== "tool" && + typeof m.content === "string" && + m.content.length > maxChars + ) { + return { + ...m, + content: `${m.content.slice(0, maxChars)}\n\n[…contenu tronqué : bloc trop volumineux pour le contexte. Le reste du document reste accessible via search_documents / read_document.]`, + }; + } + return m; + }); +} + /** Applique le budget résolu depuis l'environnement. */ export function applyContextBudget(messages: ModelMessage[]): ModelMessage[] { - return trimMessages(messages, resolveContextBudgetTokens()); + const budget = resolveContextBudgetTokens(); + return capOversizedMessages(trimMessages(messages, budget), budget); }