diff --git a/CLAUDE.md b/CLAUDE.md index dd767ff..032245e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,22 @@ ne les augmentez pas à l'aveugle. --- +## Sorties bornées (D24) + +Une réponse d'outil de plus de ~70 000 caractères est **rejetée par le client +MCP**. Les outils qui peuvent dépasser ce seuil bornent et **signalent** : +`truncated: true` (jamais `false`), `hint` actionnable, `returned`, et le total +avant la coupe. Réutilisez ce vocabulaire, n'en inventez pas un second. + +**`get_workflow_details` fenêtre le blob `data`** (la définition EasyBuilder : +71 512 caractères sur un StackerCrane, 92 362 sur un gros `CST_*`) : +`max_data_chars` (défaut 20 000) et `data_offset` (défaut 0). Les métadonnées +restent complètes, `dataTotalChars` est porté par toute réponse, et la tranche +est **verbatim** — concaténer les tranches dans l'ordre des offsets reconstitue +la définition à l'octet près. Ne la résumez pas, ne la « parsez » pas. + +--- + ## Écrire une requête WMS ```js diff --git a/DECISIONS.md b/DECISIONS.md index 992893c..4e62164 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -458,7 +458,7 @@ sa réponse et **signale** la coupe. Le signal est commun : | `truncated: true` | présent **uniquement** quand la réponse a été coupée — jamais `truncated: false` | | `hint` | présent ssi `truncated` ; actionnable : dit comment continuer (`offset` suivant) ou réduire (filtres, `context_lines`…) | | `returned` | nombre d'éléments effectivement renvoyés | -| total (`totalParameters`, `totalResults`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même | +| total (`totalParameters`, `totalResults`, `dataTotalChars`) | total **avant** la coupe — `truncated` se vérifie donc depuis la réponse elle-même | Les mécanismes restent **volontairement locaux**, car ils diffèrent : `get_system_parameters` pagine (`limit`/`offset` au schéma — rien n'est perdu, @@ -468,6 +468,22 @@ on continue avec l'offset suivant) ; `search_logs` plafonne le volume en plus `omitted`, le compte écarté. Pas de helper partagé : le factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas. +**Périmètre étendu (lot 5, 25/08/2026).** Trois familles d'outils dépassaient +encore le seuil, toutes mesurées sur `LIMAGRAI2512` : + +`get_workflow_details` **fenêtre le blob `data`** (`max_data_chars`, défaut +20 000 ; `data_offset`, défaut 0) — 79 092 caractères pour un StackerCrane +(dont 71 512 de blob), 101 816 pour `CST_SendRejectContainersToPK` (92 362 de +blob), ramenés à ~23 000. La tranche est **verbatim** : découpe de chaîne, rien +d'autre. Ne jamais résumer, reformuler ni « parser » cette définition +EasyBuilder — la concaténation des tranches dans l'ordre des offsets doit la +reconstituer à l'octet près (vérifié : 20 000 + 20 000 + 20 000 + 11 512 = +71 512, concaténation identique au blob d'origine). Les métadonnées du workflow +restent complètes dans chaque tranche ; seul `data` est fenêtré, et +`dataTotalChars` est porté par **toute** réponse — y compris non tronquée, où +la seule différence avec l'ancienne réponse est ces trois champs de fenêtre +(+65 caractères mesurés). + Deux garde-fous de cadrage : - **Ne pas réduire les défauts existants** (`max_results` 50, `context_lines` 2) diff --git a/ROADMAP.md b/ROADMAP.md index f0e5630..30d3840 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -96,13 +96,6 @@ rejet client (~70 000 caractères, D24) : | `get_workflow_details(CST_SendRejectContainersToPK)` | ~101 800 | | `call_query_api("Products", query_type: 1, limit: 1)` — **une seule ligne** Writing | **95 288** | -### L5.1 — Paginer le blob `data` de `get_workflow_details` - -La définition complète d'un workflow dépasse le seuil (~101 800 pour -`CST_SendRejectContainersToPK`, 79 092 pour un `StackerCrane_…` EasyWMS). -Découper le blob `data` en tranches verbatim (paramètres de fenêtre au schéma), -signalées D24 — sans jamais résumer ni reformuler le contenu. - ### L5.2 — Garde de taille sur les outils de requête Une ligne Writing = un agrégat complet sérialisé (95 288 caractères là où la diff --git a/src/tools/workflow-tools.js b/src/tools/workflow-tools.js index 278558a..7ad3e66 100644 --- a/src/tools/workflow-tools.js +++ b/src/tools/workflow-tools.js @@ -5,6 +5,12 @@ const workflowService = require('../services/workflow-service'); +// Taille par défaut d'une tranche du blob `data` de get_workflow_details. +// Ordre de grandeur cible de D24 (~20-25 000 caractères par réponse) : avec +// l'échappement JSON et les métadonnées, 20 000 caractères de blob tiennent +// sous ~23 000 caractères de réponse. +const DEFAULT_MAX_DATA_CHARS = 20000; + /** * List available workflow tools */ @@ -39,7 +45,8 @@ function listTools() { }, { name: 'get_workflow_details', - description: 'Get full details of a specific workflow by ID or name', + description: `Get full details of a specific workflow by ID or name. +The EasyBuilder definition (the \`data\` blob) is large — 71 512 characters for a StackerCrane workflow, 92 362 for CST_SendRejectContainersToPK — so it is returned as a VERBATIM WINDOW (max_data_chars / data_offset). Workflow metadata is always complete; only \`data\` is windowed. dataTotalChars always carries the full blob size, and concatenating the slices in offset order reproduces the definition byte for byte.`, inputSchema: { type: 'object', additionalProperties: false, @@ -52,6 +59,16 @@ function listTools() { type: 'string', description: 'AD application the workflow belongs to (default: the active profile\'s application, usually EasyWMS). Client-specific workflows (CST_*) live in "CustomApp".', }, + max_data_chars: { + type: 'number', + description: `Maximum number of characters of the \`data\` blob returned by this call (default: ${DEFAULT_MAX_DATA_CHARS}). The slice is verbatim — never summarised, reformatted or parsed. Pass 0 for metadata only.`, + default: DEFAULT_MAX_DATA_CHARS, + }, + data_offset: { + type: 'number', + description: 'Character offset in the `data` blob where the returned slice starts (default: 0). When the response carries truncated: true, its hint gives the next offset to pass here.', + default: 0, + }, }, required: ['workflow_id'], }, @@ -143,23 +160,74 @@ async function searchWorkflows(args) { }; } +/** + * Garde de valeur des paramètres de fenêtre. Le wrapper D23 valide les noms de + * paramètres, pas les valeurs — la garde vit donc ici, avant tout appel réseau. + */ +function assertWindowValue(value, fallback, paramName) { + if (value == null) return fallback; + if (!Number.isInteger(value) || value < 0) { + const attendu = paramName === 'max_data_chars' + ? `taille max de la tranche du blob data, défaut ${DEFAULT_MAX_DATA_CHARS}, 0 = métadonnées seules` + : 'offset de départ dans le blob data, défaut 0'; + throw new Error( + `${paramName} invalide : ${JSON.stringify(value)}. Attendu : un entier >= 0 (${attendu}).` + ); + } + return value; +} + /** * Tool: get_workflow_details + * + * La définition EasyBuilder (blob `data`) fait à elle seule 71 512 caractères + * sur un StackerCrane et 92 362 sur CST_SendRejectContainersToPK : la réponse + * complète dépassait le seuil de rejet du client MCP (D24). On renvoie une + * TRANCHE VERBATIM du blob (découpe de chaîne, rien d'autre) : les métadonnées + * restent complètes, et concaténer les tranches dans l'ordre des offsets + * reconstitue la définition à l'octet près. Ne jamais résumer ni « parser » ce + * blob pour n'en renvoyer que des morceaux jugés utiles. */ async function getWorkflowDetails(args) { - const { workflow_id, application } = args; + const { workflow_id, application, max_data_chars, data_offset } = args; - console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'})`); + const maxDataChars = assertWindowValue(max_data_chars, DEFAULT_MAX_DATA_CHARS, 'max_data_chars'); + const dataOffset = assertWindowValue(data_offset, 0, 'data_offset'); + + console.error(`[WorkflowTools] Getting workflow details: ${workflow_id} (application: ${application || '(profil)'}, max_data_chars=${maxDataChars}, data_offset=${dataOffset})`); const workflow = await workflowService.getWorkflowDetails(workflow_id, application); + const payload = { success: true, workflow }; + + // Seul un blob `data` textuel se fenêtre ; un workflow sans définition (ou + // d'une forme inattendue) sort inchangé. + if (typeof workflow?.data === 'string') { + const total = workflow.data.length; + const slice = workflow.data.slice(dataOffset, dataOffset + maxDataChars); + const nextOffset = dataOffset + slice.length; + + payload.workflow = { ...workflow, data: slice }; + // La taille totale est portée par TOUTE réponse : truncated se vérifie + // depuis la réponse elle-même (D24). + payload.dataTotalChars = total; + payload.dataOffset = dataOffset; + payload.returned = slice.length; + + if (nextOffset < total) { + payload.truncated = true; + payload.hint = + `Blob \`data\` tronqué : ${slice.length} caractère(s) sur ${total} renvoyé(s) depuis l'offset ${dataOffset}. ` + + `Rappelez get_workflow_details avec les mêmes workflow_id/application et data_offset: ${nextOffset} pour la tranche ` + + `suivante (max_data_chars change la taille des tranches). Les tranches sont verbatim : les concaténer dans l'ordre ` + + `des offsets reconstitue la définition EasyBuilder à l'octet près.`; + } + } + return { content: [{ type: 'text', - text: JSON.stringify({ - success: true, - workflow - }, null, 2) + text: JSON.stringify(payload, null, 2) }] }; }