Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1851b38c62 | |||
| 660c62c32c | |||
| a1acb781b0 | |||
| 54a6849563 | |||
| 6f54d765c4 | |||
| 5ec2990347 |
@@ -64,7 +64,8 @@ src/
|
||||
│ ├── workflow-service.js Workflows, lazy loading + cache
|
||||
│ ├── ad-service.js Application Dictionary, 20 types, cache par type
|
||||
│ ├── wms-query-service.js Construction d'expressions LINQ
|
||||
│ └── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ ├── log-service.js Lecture et recherche dans les fichiers de logs
|
||||
│ └── response-limit.js Plafond de taille commun aux outils de requête (D24)
|
||||
└── tools/ 23 outils MCP
|
||||
├── wms-query-tools.js query_wms_entities, count_wms_entities,
|
||||
│ get_entity_schema, search_wms_data
|
||||
@@ -105,7 +106,11 @@ serveur au démarrage.
|
||||
[MONITORING.md](MONITORING.md) §2.
|
||||
3. **Un outil ne plante jamais le serveur.** Toute erreur revient en réponse
|
||||
structurée `{ success: false, error, tool }` avec `isError: true` — le
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`.
|
||||
wrapper est dans le handler `tools/call` de `src/index.js`. Les enveloppes
|
||||
construites **localement** dans `src/tools/` portent le champ `tool` elles
|
||||
aussi : le wrapper ne les voit pas, et une erreur sans `tool` sort du
|
||||
contrat. Les champs supplémentaires utiles (`warning` de résolution,
|
||||
`profiles`, `hint`…) viennent après.
|
||||
4. **Messages d'erreur actionnables.** Ils sont lus par Claude, pas par un
|
||||
humain : dire quoi faire ensuite (« appelez `switch_wms_profile` », « profils
|
||||
disponibles : … »).
|
||||
@@ -150,6 +155,11 @@ actif à chaque appel.
|
||||
de `search_logs` — au-delà, des résultats entiers sont écartés et signalés
|
||||
(`truncated`, D24).
|
||||
|
||||
**`MAX_QUERY_RESPONSE_CHARS`** (défaut 25 000) : même plafond pour
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` — au-delà, des
|
||||
lignes entières sont écartées et signalées (D24). `count_wms_entities` n'est
|
||||
pas concerné.
|
||||
|
||||
**Au runtime.** `profile-manager` est un singleton d'état global. Les services
|
||||
s'abonnent via `onSwitch()` pour invalider ce qui dépend du tenant :
|
||||
|
||||
@@ -181,7 +191,10 @@ cache incluent l'application pour éviter toute pollution croisée (D26).
|
||||
| `ad-service` | **un par (application, type)** (20 types) | `AD_ELEMENT_TYPES` : `View` 200, `Workflow` 5000, `Resource` 15000, autres 100000 |
|
||||
|
||||
**Ne préchargez jamais les 9 applications** : seule l'application demandée est
|
||||
chargée (D26).
|
||||
chargée (D26). `search_workflows` et `search_ad_elements` rappellent toujours
|
||||
l'application interrogée et, sur résultat **vide**, ajoutent un `hint` nommant
|
||||
les autres — construit depuis la liste d'applications **déjà en cache**, jamais
|
||||
par un appel réseau (D26).
|
||||
|
||||
Les tailles de page par type viennent de l'observation des timeouts serveur —
|
||||
ne les augmentez pas à l'aveugle.
|
||||
@@ -190,6 +203,38 @@ 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.
|
||||
|
||||
**Les trois outils de requête plafonnent leur volume** via
|
||||
`src/services/response-limit.js` (`MAX_QUERY_RESPONSE_CHARS`) : au-delà, des
|
||||
lignes entières sont écartées, jamais coupées au milieu. Sous le plafond, la
|
||||
réponse est inchangée **octet pour octet** — c'est la contrainte à préserver si
|
||||
vous y touchez.
|
||||
|
||||
| Outil | Unité écartée | Total porté |
|
||||
|---|---|---|
|
||||
| `query_wms_entities` | une ligne | `count` (déjà présent) |
|
||||
| `call_query_api` | une ligne | `totalRows` (ajouté à la coupe) |
|
||||
| `search_wms_data` | un résultat, réparti en tourniquet entre les entités | `totalFound` (déjà présent) |
|
||||
|
||||
Cas limite réel : **une seule ligne Writing dépasse le plafond** (95 288
|
||||
caractères mesurés) — la réponse est alors `returned: 0`, `omitted: 1`,
|
||||
`truncated: true`, avec un hint qui renvoie vers Reading.
|
||||
|
||||
---
|
||||
|
||||
## Écrire une requête WMS
|
||||
|
||||
```js
|
||||
|
||||
+80
-3
@@ -458,15 +458,69 @@ 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,
|
||||
on continue avec l'offset suivant) ; `search_logs` plafonne le volume
|
||||
(`MAX_LOG_SEARCH_CHARS`, défaut 25 000 caractères) en écartant des résultats
|
||||
**entiers** — jamais coupés au milieu de leurs lignes de contexte — et annonce
|
||||
en plus `omitted`, le compte écarté. Pas de helper partagé : le factoriser
|
||||
forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
en plus `omitted`, le compte écarté. Ces deux-là ne partagent pas de helper : le
|
||||
factoriser forcerait une abstraction commune à deux mécanismes qui n'en ont pas.
|
||||
Les **trois outils de requête**, eux, partagent le même mécanisme — ils
|
||||
partagent donc `src/services/response-limit.js` (voir ci-dessous). Le critère
|
||||
est le mécanisme, pas le nombre d'appelants.
|
||||
|
||||
**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).
|
||||
|
||||
`query_wms_entities`, `call_query_api` et `search_wms_data` **plafonnent leur
|
||||
volume** (`MAX_QUERY_RESPONSE_CHARS`, défaut 25 000 — même ordre de grandeur que
|
||||
`MAX_LOG_SEARCH_CHARS`) en écartant des **lignes entières**, via le helper
|
||||
commun `src/services/response-limit.js` (recherche dichotomique : ~8
|
||||
constructions au lieu de 200 retraits ligne à ligne sur des charges utiles de
|
||||
~1 Mo) :
|
||||
|
||||
| Appel | Avant | Après |
|
||||
|---|---:|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` | 957 234 | 24 432 (5 lignes sur 200) |
|
||||
| `search_wms_data("PAL")` | 847 543 | 22 992 (4 résultats sur 150) |
|
||||
| `call_query_api("Products", query_type: 1, limit: 1)` | 95 288 | 738 |
|
||||
|
||||
Trois points de cadrage, tous vérifiés en exécution :
|
||||
|
||||
- **Sous le plafond, rien ne change.** Aucun champ ajouté, réponse identique
|
||||
**octet pour octet** (mesuré sur `query_wms_entities("Container", limit: 1)`,
|
||||
`call_query_api`, `get_entity_schema`, `search_wms_data` sous plafond).
|
||||
`MAX_QUERY_ROWS` et les limites par défaut des outils sont inchangés :
|
||||
le correctif est le bornage signalé, pas une réduction silencieuse.
|
||||
- **Cas limite : une seule ligne dépasse le plafond.** Réel en Writing —
|
||||
`call_query_api("Products", query_type: 1, limit: 1)` répond `returned: 0`,
|
||||
`omitted: 1`, `truncated: true`, avec un hint qui explique le volume Writing
|
||||
et renvoie vers Reading. C'est moins bon qu'un résultat, mais c'est mieux
|
||||
qu'un rejet client opaque.
|
||||
- **`search_wms_data` répartit en tourniquet** les résultats gardés entre les
|
||||
entités, et porte `returned`/`omitted` par entité en plus des totaux. Sans
|
||||
cela, une entité volumineuse placée en tête consommerait tout le budget et
|
||||
les suivantes reviendraient à zéro résultat sans que rien ne le dise —
|
||||
exactement le faux négatif que corrige L5.4.
|
||||
|
||||
`count_wms_entities` n'est pas concerné (`QueryScalarExecute` renvoie un
|
||||
scalaire), et son `query_type` ne porte donc pas l'avertissement de volume
|
||||
ajouté aux deux autres.
|
||||
|
||||
Deux garde-fous de cadrage :
|
||||
|
||||
@@ -568,3 +622,26 @@ Règles associées :
|
||||
ne détaille que les entrées **effectivement en cache** : la sortie reste
|
||||
bornée quel que soit le nombre d'applications interrogées (D24). Il expose
|
||||
aussi les caches de workflows par application.
|
||||
|
||||
**Le paramètre ne suffisait pas : il faut que la réponse le dise** (lot 5,
|
||||
25/08/2026). Cas réel : une session Cowork cherchant des workflows `CST_*` sans
|
||||
passer `application: "CustomApp"` a conclu que l'AD n'en contenait aucun — alors
|
||||
que `CST_PickingTasksSequencing_PR` et `CST_ChooseDestinationFromPS` existent.
|
||||
Le paramètre était disponible et documenté ; ce qui manquait, c'est que **rien
|
||||
dans la réponse ne disait qu'on n'avait regardé qu'une application sur neuf**.
|
||||
Un défaut silencieux se lit comme une exhaustivité.
|
||||
|
||||
`search_workflows` et `search_ad_elements` rappellent donc **toujours**
|
||||
l'application effectivement interrogée (plus seulement quand le paramètre a été
|
||||
passé), et ajoutent un `hint` quand la recherche revient **vide** :
|
||||
|
||||
- Seuil à **0 résultat**, pas « peu ». Toute valeur non nulle produirait un hint
|
||||
parasite sur une recherche légitimement étroite, et le mode d'échec observé
|
||||
est bien le zéro pris pour une absence.
|
||||
- Le hint nomme les autres applications depuis la liste allégée **déjà en
|
||||
cache** ; sans elle, il reste générique et renvoie vers
|
||||
`list_workflow_categories`. **Jamais de fetch pour construire un hint** —
|
||||
ce serait précisément le préchargement que cette décision interdit.
|
||||
- Il nomme `CustomApp` en clair, sauf quand c'est déjà l'application
|
||||
interrogée : c'est une connaissance statique, déjà portée par les
|
||||
descriptions d'outils, pas une donnée à aller chercher.
|
||||
|
||||
+9
-38
@@ -82,43 +82,6 @@ plus riche que `/AD/api/Application/GetAll`), `GET /healthcheck?tenantCode=` et
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Lot 5 — Clore la famille D24 (rejets client sur sorties volumineuses)
|
||||
|
||||
Mesures du 25/08/2026 (protocole, LIMAGRAIN), toutes au-dessus du seuil de
|
||||
rejet client (~70 000 caractères, D24) :
|
||||
|
||||
| Appel | Taille |
|
||||
|---|---:|
|
||||
| `query_wms_entities("Products", limit: 200)` — Reading ordinaire | **957 234** |
|
||||
| `search_wms_data("PAL")` | **847 543** |
|
||||
| `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
|
||||
même ligne Reading en fait ~4 500) ; 200 lignes Reading = ~957 000 ; `search_wms_data`
|
||||
= ~848 000. Garde de taille commune sur `query_wms_entities`, `call_query_api`
|
||||
et `search_wms_data` : lignes entières écartées, `truncated`/`returned`/`omitted`/`hint`
|
||||
(D24). Ne pas réduire `MAX_QUERY_ROWS` ni les limites par défaut.
|
||||
|
||||
### L5.3 — Champ `tool` absent des erreurs construites localement
|
||||
|
||||
Les `catch` locaux d'`api-tools.js` renvoient `{ success: false, error }` sans
|
||||
le champ `tool` du contrat (convention 3) — antérieur au lot 4. Balayer tous
|
||||
les modules d'outils pour le même motif.
|
||||
|
||||
---
|
||||
|
||||
## Écarté
|
||||
|
||||
| Proposition | Raison |
|
||||
@@ -150,7 +113,15 @@ les modules d'outils pour le même motif.
|
||||
pour un usage conversationnel séquentiel, mais Claude peut émettre des appels
|
||||
d'outils **en parallèle** : à traiter si un cas réel de mélange de profils
|
||||
est observé (piste : sérialiser les `tools/call` ou figer le profil résolu au
|
||||
début de chaque appel).
|
||||
début de chaque appel). **Seconde manifestation mesurée (25/08/2026, révision
|
||||
du lot 5)** : sans aucune bascule de profil, une rafale d'appels concurrents
|
||||
pendant le chargement paresseux du cache workflow renvoie des résultats
|
||||
faux en silence — `search_workflows("CST_", application: "CustomApp")` a
|
||||
répondu `count: 0` (contre 44 en séquentiel), et `get_workflow_details` une
|
||||
réponse anormale — les chargements concurrents du même cache ne sont pas
|
||||
synchronisés (pas de déduplication de fetch en vol). Rejoués en séquentiel,
|
||||
les mêmes appels sont corrects. Piste supplémentaire : mémoriser la promesse
|
||||
de fetch en cours par clé de cache et la partager entre appelants.
|
||||
- **`select_expression`** : les projections via le paramètre `Select` provoquent
|
||||
des erreurs de compilation côté serveur (D13). Irritant principal restant.
|
||||
- **Déploiement SSH sur la VM** : l'exécutable est validé, la configuration SSH
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Passation — lot 5 (clore la famille D24)
|
||||
|
||||
Tu travailles sur `mcp-wms-api` : un serveur MCP (Node.js, CommonJS, stdio) qui
|
||||
donne à Claude un accès en lecture à un WMS EasyWMS (Mecalux) via ses API REST.
|
||||
Lis [../CLAUDE.md](../CLAUDE.md) et [../DECISIONS.md](../DECISIONS.md) —
|
||||
en particulier **D24**, le contrat de troncature que ce lot généralise — avant
|
||||
de toucher au code.
|
||||
|
||||
**Mission** : éliminer les derniers cas connus de réponses d'outils dépassant
|
||||
le seuil de rejet des clients MCP (~70 000 caractères, D24) — lot 5 de
|
||||
[../ROADMAP.md](../ROADMAP.md) : pagination du blob `data` de
|
||||
`get_workflow_details` (L5.1), garde de taille sur les trois outils de requête
|
||||
(L5.2), champ `tool` manquant dans les enveloppes d'erreur locales (L5.3).
|
||||
|
||||
**Hors périmètre** : tout le reste de la roadmap (L4.3, L4.4, L4.5,
|
||||
exploration Metrics). **`QueryExecuteStream` est explicitement interdit** —
|
||||
piste long terme consignée en L4.5, pas ce lot. Aucun nouvel outil : le compte
|
||||
reste à 23. Ne pousse rien (`git push` interdit), ne touche pas au `.env`,
|
||||
n'appelle jamais `execute_command` (il écrit dans le WMS).
|
||||
|
||||
---
|
||||
|
||||
## Contexte matériel
|
||||
|
||||
- Profil de travail : `LIMAGRAIN` (par défaut), host `10.255.255.2`, tenant
|
||||
`LIMAGRAI2512`.
|
||||
- **Le profil `AD` est cassé et c'est diagnostiqué — ne le réinvestigue pas**
|
||||
(tenant introuvable côté STS, point ouvert de la ROADMAP). La baseline se
|
||||
mesure avec `npm test` (profil par défaut), attendu **4/4, code de sortie 0**.
|
||||
- Baseline protocolaire à préserver : **23 outils**, **6 resources**, aucune
|
||||
écriture sur stdout hors JSON-RPC.
|
||||
|
||||
Handshake + comptages :
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('tools:',m.result.tools.length);if(m.id===3)console.log('resources:',m.result.resources.length);}});"
|
||||
```
|
||||
|
||||
Mesurer la taille d'une réponse d'outil (la mesure qui fait foi est la
|
||||
longueur de `content[0].text` via le protocole) :
|
||||
|
||||
```bash
|
||||
printf '%s\n%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"NOM","arguments":{}}}' | node src/index.js 2>/dev/null | node -e "let b='';process.stdin.on('data',d=>b+=d).on('end',()=>{for(const l of b.split('\n').filter(Boolean)){const m=JSON.parse(l);if(m.id===2)console.log('chars:',m.result.content[0].text.length);}});"
|
||||
```
|
||||
|
||||
## Contraintes non négociables
|
||||
|
||||
1. `console.error()` uniquement (D6).
|
||||
2. Contrat d'erreur : `{ success: false, error, tool }`, `isError: true` —
|
||||
c'est précisément l'objet de L5.3.
|
||||
3. Messages et hints actionnables.
|
||||
4. Aucun accès base de données (D1).
|
||||
5. **D23** : le wrapper valide les **noms** de paramètres et les requis contre
|
||||
les schémas — tout paramètre ajouté (fenêtre de L5.1…) doit être déclaré
|
||||
dans l'`inputSchema`. Le wrapper ne valide pas les **valeurs** : les gardes
|
||||
de valeur vivent dans le code de l'outil.
|
||||
6. **D24** : signal commun — `truncated: true` **uniquement** quand la réponse
|
||||
est coupée, `hint` actionnable, `returned` vs total avant coupe. `omitted`
|
||||
en plus quand des éléments entiers sont écartés. Réutilise ce vocabulaire
|
||||
exactement ; ne crée pas un second dialecte.
|
||||
7. **Pas de nouveau numéro de décision attendu** : ce lot **étend D24**
|
||||
(complète son texte : les outils de requête et `get_workflow_details`
|
||||
entrent dans son périmètre). Si une décision réellement nouvelle s'impose,
|
||||
vérifie le dernier numéro (D26 à ce jour) et réserve D27 dans le commit qui
|
||||
l'acte.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Confirmer les mesures
|
||||
|
||||
Toutes rejouées le 25/08/2026 (protocole, LIMAGRAIN) avec la commande de
|
||||
mesure ci-dessus. À **confirmer**, pas à réinvestiguer :
|
||||
|
||||
| Appel | Taille constatée |
|
||||
|---|---:|
|
||||
| `query_wms_entities` `{"entity_type":"Products","limit":200}` (Reading) | **957 234** |
|
||||
| `search_wms_data` `{"keyword":"PAL"}` | **847 543** |
|
||||
| `call_query_api` `{"entity_type":"Products","query_type":1,"limit":1}` — une seule ligne Writing | **95 288** |
|
||||
| `get_workflow_details` sur `CST_SendRejectContainersToPK` (`application: "CustomApp"`) | ~101 800 |
|
||||
| `get_workflow_details` `{"workflow_id":"2a320000-0642-47df-aa52-3b89c27c016f"}` (StackerCrane, EasyWMS) | 79 092 (dont blob `data` : 71 512) |
|
||||
|
||||
Attention aux noms de paramètres (D23 les fait respecter) : `search_workflows`
|
||||
prend `query`, `search_wms_data` et `search_logs` prennent `keyword`. Pour
|
||||
trouver l'id du workflow CST :
|
||||
`search_workflows {"query":"CST_SendRejectContainersToPK","application":"CustomApp"}`.
|
||||
|
||||
---
|
||||
|
||||
## L5.1 — Paginer le blob `data` de `get_workflow_details`
|
||||
|
||||
**Problème.** La réponse embarque la définition complète du workflow (blob
|
||||
`data`, XML/JSON EasyBuilder) : 71 512 caractères sur le StackerCrane mesuré,
|
||||
davantage sur les gros `CST_*` — la réponse dépasse le seuil client.
|
||||
|
||||
**À faire.**
|
||||
- Deux paramètres de fenêtre sur le blob `data` (au schéma, D23) : une taille
|
||||
max de tranche (défaut de l'ordre de **20 000** caractères, cohérent avec
|
||||
D24) et un offset (défaut 0). Nommage à ta main (`max_data_chars` /
|
||||
`data_offset` ou équivalent), documenté dans les descriptions.
|
||||
- La réponse porte toujours la taille **totale** du blob ; quand la fenêtre
|
||||
tronque : `truncated: true` + `hint` donnant l'offset suivant.
|
||||
- La tranche est **verbatim** : découpe de chaîne, rien d'autre.
|
||||
- Les métadonnées du workflow (`id`, `name`, `commonInfo`…) restent complètes
|
||||
dans chaque réponse ; seule `data` est fenêtrée.
|
||||
|
||||
**Pente naturelle interdite** : ne résume pas, ne reformule pas, ne « parse »
|
||||
pas le blob pour n'en renvoyer que des morceaux jugés utiles — la définition
|
||||
EasyBuilder doit rester reconstituable à l'octet près en concaténant les
|
||||
tranches.
|
||||
|
||||
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||
- `get_workflow_details` sur le StackerCrane (`2a320000-0642-47df-aa52-3b89c27c016f`)
|
||||
sans paramètre de fenêtre → réponse < ~30 000 caractères, `truncated: true`,
|
||||
taille totale annoncée = 71 512, hint avec l'offset suivant.
|
||||
- En enchaînant les tranches (offset 0, 20 000, 40 000, 60 000) : la somme des
|
||||
longueurs des tranches = 71 512, et la concaténation est identique au blob
|
||||
d'origine (compare au moins les longueurs et les 100 premiers/derniers
|
||||
caractères).
|
||||
- Un workflow à petit blob (< défaut) → réponse strictement inchangée, pas de
|
||||
`truncated`.
|
||||
|
||||
## L5.2 — Garde de taille sur les outils de requête
|
||||
|
||||
**Problème.** Aucune borne de volume sur `query_wms_entities`, `call_query_api`
|
||||
et `search_wms_data` : 957 234 caractères pour 200 lignes Reading, 847 543
|
||||
pour une recherche, et **95 288 pour une seule ligne Writing** (le modèle
|
||||
Writing sérialise l'agrégat complet — navigations, `$id`… — là où la même
|
||||
ligne Reading fait ~4 500).
|
||||
|
||||
**À faire.**
|
||||
- Garde de taille commune aux trois outils : plafond en variable
|
||||
d'environnement avec défaut (style `MAX_LOG_SEARCH_CHARS`, 25 000 — même
|
||||
ordre de grandeur, nom à ta main, documenté dans CLAUDE.md).
|
||||
- Au-delà du plafond : écarter des **lignes entières** (pour `search_wms_data` :
|
||||
des résultats entiers, par entité), et signaler D24 : `truncated: true`,
|
||||
`returned`, `omitted`, `hint` (réduire `limit`, ajouter un `filter` ; et pour
|
||||
`query_type != 0` : rappeler que les lignes Writing sont des agrégats
|
||||
complets et suggérer Reading si l'usage le permet).
|
||||
- **Cas limite à traiter explicitement** : une seule ligne dépasse le plafond
|
||||
(réel en Writing). La réponse est alors `returned: 0`, `omitted: <n>`,
|
||||
`truncated: true`, avec un hint qui explique pourquoi et quoi faire — c'est
|
||||
moins bon qu'un résultat, mais c'est mieux qu'un rejet client opaque.
|
||||
- **Ne change ni `MAX_QUERY_ROWS`, ni les limites par défaut des outils, ni le
|
||||
comportement sous le plafond** : une requête qui tient aujourd'hui doit
|
||||
renvoyer exactement la même réponse.
|
||||
- L'avertissement de volume mérite une phrase dans la description du paramètre
|
||||
`query_type` (D25).
|
||||
|
||||
**Vérification attendue** (protocole, LIMAGRAIN) :
|
||||
- `query_wms_entities` `{"entity_type":"Products","limit":200}` → réponse sous
|
||||
le plafond (+ marge d'enveloppe), `truncated: true`, `returned` < 200,
|
||||
`omitted` cohérent, hint présent.
|
||||
- `search_wms_data` `{"keyword":"PAL"}` → borné et signalé de même.
|
||||
- `call_query_api` `{"entity_type":"Products","query_type":1,"limit":1}` →
|
||||
`returned: 0`, `omitted: 1`, `truncated: true`, hint expliquant le volume
|
||||
Writing.
|
||||
- `query_wms_entities` `{"entity_type":"Container","limit":1}` → réponse
|
||||
**strictement identique** à aujourd'hui (~4 500 caractères, pas de
|
||||
`truncated`).
|
||||
- `count_wms_entities` → non concerné, inchangé.
|
||||
|
||||
## L5.3 — Champ `tool` dans les enveloppes d'erreur locales
|
||||
|
||||
**Problème.** Les `catch` locaux d'`api-tools.js` (deux blocs, vers les lignes
|
||||
145-162 et 191-208) renvoient `{ success: false, error }` **sans** le champ
|
||||
`tool` du contrat. Preuve : `call_query_api` `{"entity_type":"Products","query_type":7}`
|
||||
répond aujourd'hui `{"success": false, "error": "query_type invalide : …"}` —
|
||||
pas de `tool`.
|
||||
|
||||
**À faire.** Balayer **tous** les modules de `src/tools/` à la recherche
|
||||
d'enveloppes `success: false` construites localement : soit y ajouter `tool`,
|
||||
soit laisser l'erreur remonter au wrapper de `src/index.js` (qui l'ajoute) —
|
||||
au choix selon le cas, mais le résultat observable est uniforme. Attention à ne
|
||||
pas perdre les champs additionnels utiles des enveloppes locales (le `warning`
|
||||
de résolution d'`api-tools`, les listes de profils de `profile-tools`…).
|
||||
|
||||
**Vérification attendue** : `call_query_api` avec `query_type: 7` → l'enveloppe
|
||||
contient `"tool": "call_query_api"` ; un `grep` sur `src/tools/` ne montre plus
|
||||
d'enveloppe `success: false` sans `tool` (colle le résultat du grep dans le
|
||||
compte-rendu).
|
||||
|
||||
---
|
||||
|
||||
## Méthode
|
||||
|
||||
1. Phase 0 d'abord (cinq mesures, rejouées telles quelles).
|
||||
2. L5.1, puis L5.2, puis L5.3 — chaque bloc vérifié **en exécution via le
|
||||
protocole** avant de passer au suivant.
|
||||
3. Rappel : le serveur traite les `tools/call` en concurrence (point ouvert de
|
||||
la ROADMAP) — pour les vérifications qui comparent des réponses successives,
|
||||
envoie les requêtes séquentiellement.
|
||||
4. Les schémas changent : reboucler sur les 23 noms de `tools/list` (aucun
|
||||
`Unknown tool` ; `execute_command` vérifié statiquement, non appelé) et
|
||||
vérifier qu'un paramètre inconnu est toujours rejeté (D23).
|
||||
5. Baseline avant/après : handshake (23/6) + `npm test` (4/4, exit 0).
|
||||
6. « Non résolu » est une réponse acceptable pour une investigation
|
||||
time-boxée ; une hypothèse présentée comme solution ne l'est pas.
|
||||
|
||||
## Livraison
|
||||
|
||||
- Un commit par bloc (L5.1, L5.2, L5.3), messages expliquant le pourquoi,
|
||||
**mesures avant/après dans le corps du message** (tailles en caractères).
|
||||
- Documentation dans les mêmes commits : **compléter D24** (périmètre étendu
|
||||
aux outils de requête et à `get_workflow_details`) ; CLAUDE.md — la nouvelle
|
||||
variable d'environnement dans les réglages partagés, la fenêtre de
|
||||
`get_workflow_details` si elle change l'usage documenté ; ROADMAP.md —
|
||||
retirer le lot 5.
|
||||
- Ne pousse pas. `.env` intact. Toute anomalie hors périmètre découverte en
|
||||
route : dans ROADMAP.md, pas dans le code.
|
||||
- Compte-rendu final : pour chaque bloc, la vérification attendue rejouée et
|
||||
son résultat **mesuré** (colle les tailles et les sorties), plus la baseline
|
||||
finale. Laisse `handoff-lot5.md` en place pour la révision.
|
||||
@@ -275,6 +275,7 @@ function getAvailableTypes() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveApplication,
|
||||
getElements,
|
||||
searchElements,
|
||||
getElementDetails,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Response Limit
|
||||
* Garde de taille commune aux trois outils de requête (D24, lot 5).
|
||||
*
|
||||
* Mesures du 25/08/2026 sur `LIMAGRAI2512`, toutes au-dessus du seuil de rejet
|
||||
* du client MCP (~70 000 caractères) : 957 234 caractères pour 200 lignes
|
||||
* Reading, 847 543 pour `search_wms_data("PAL")`, et 95 288 pour **une seule**
|
||||
* ligne Writing — le modèle Writing sérialise l'agrégat complet (navigations,
|
||||
* `$id`…) là où la même ligne Reading fait ~4 500.
|
||||
*
|
||||
* Contrairement aux mécanismes de `get_system_parameters` et `search_logs`
|
||||
* (locaux car différents, D24), les trois outils de requête partagent le même
|
||||
* mécanisme — d'où ce module : on écarte des **lignes entières**, jamais
|
||||
* coupées au milieu.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_RESPONSE_CHARS = 25000;
|
||||
|
||||
/**
|
||||
* Plafond en caractères d'une réponse d'outil de requête.
|
||||
* Même ordre de grandeur que `MAX_LOG_SEARCH_CHARS` (D24).
|
||||
*/
|
||||
function getMaxResponseChars() {
|
||||
return parseInt(process.env.MAX_QUERY_RESPONSE_CHARS) || DEFAULT_MAX_RESPONSE_CHARS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avertissement de volume propre aux contextes non-Reading (D25). Repris tel
|
||||
* quel dans les hints et dans la description du paramètre `query_type`.
|
||||
*/
|
||||
const WRITING_VOLUME_NOTE =
|
||||
'En query_type != 0, une ligne est un agrégat complet sérialisé (navigations, $id…) : ' +
|
||||
'95 288 caractères mesurés pour UNE seule ligne Products en Writing, contre ~4 500 en Reading. ' +
|
||||
'Repassez en query_type: 0 si le modèle Reading suffit.';
|
||||
|
||||
/**
|
||||
* Trouve le plus grand nombre d'éléments dont la réponse tient sous le plafond.
|
||||
*
|
||||
* @param {number} total - nombre d'éléments disponibles
|
||||
* @param {(kept: number) => string} buildText - construit la réponse sérialisée
|
||||
* pour `kept` éléments. Doit être croissante en `kept` et porter
|
||||
* elle-même les champs de troncature quand `kept < total`.
|
||||
* @returns {{ text: string, kept: number, truncated: boolean, cap: number }}
|
||||
*/
|
||||
function fitToCap(total, buildText) {
|
||||
const cap = getMaxResponseChars();
|
||||
|
||||
const full = buildText(total);
|
||||
if (full.length <= cap) {
|
||||
return { text: full, kept: total, truncated: false, cap };
|
||||
}
|
||||
|
||||
// Recherche dichotomique : ~8 constructions pour 200 lignes, là où un retrait
|
||||
// ligne à ligne en ferait 200 sur des charges utiles de ~1 Mo.
|
||||
let lo = 0;
|
||||
let hi = total - 1;
|
||||
let best = -1;
|
||||
let bestText = null;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const text = buildText(mid);
|
||||
if (text.length <= cap) {
|
||||
best = mid;
|
||||
bestText = text;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite réel en Writing : une seule ligne dépasse déjà le plafond. On
|
||||
// renvoie l'enveloppe vide et signalée — moins bon qu'un résultat, mais mieux
|
||||
// qu'un rejet client opaque.
|
||||
if (best < 0) {
|
||||
best = 0;
|
||||
bestText = buildText(0);
|
||||
}
|
||||
|
||||
return { text: bestText, kept: best, truncated: true, cap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs de troncature communs aux outils de requête — vocabulaire D24 exact
|
||||
* (`truncated`, `returned`, `omitted`, `hint`). Le total avant la coupe est
|
||||
* ajouté par l'appelant : `query_wms_entities` et `search_wms_data` le portent
|
||||
* déjà (`count`, `totalFound`), `call_query_api` non.
|
||||
*
|
||||
* @param {number} returned - éléments effectivement renvoyés
|
||||
* @param {number} total - éléments disponibles avant la coupe
|
||||
* @param {number} queryType - QueryContextType de l'appel (D25)
|
||||
* @param {string} unit - nom de l'unité écartée, au singulier ('ligne', 'résultat')
|
||||
* @param {boolean} [feminine] - accord du hint sur `unit` ('ligne' est féminin)
|
||||
* @param {string} [extraHint] - phrase supplémentaire propre à l'outil
|
||||
*/
|
||||
function truncationSignal({ returned, total, queryType = 0, unit, feminine = false, extraHint }) {
|
||||
const cap = getMaxResponseChars();
|
||||
const omitted = total - returned;
|
||||
const plafond = `Plafond de taille de réponse atteint (${cap} caractères, MAX_QUERY_RESPONSE_CHARS)`;
|
||||
const e = feminine ? 'e' : '';
|
||||
const aucun = feminine ? 'Aucune' : 'Aucun';
|
||||
const unSeul = feminine ? 'une seule' : 'un seul';
|
||||
const entiers = feminine ? 'entières' : 'entiers';
|
||||
|
||||
// Cas limite réel en Writing : même une seule ligne dépasse le plafond.
|
||||
let hint = returned === 0
|
||||
? `${plafond} : ${aucun} ${unit} ne tient dans la réponse — ${unSeul} ${unit} dépasse déjà le plafond ` +
|
||||
`à ${feminine ? 'elle' : 'lui'} seul${e}. Restreignez la requête (filter plus étroit, autre entité) : ` +
|
||||
`le contenu n'est pas coupé au milieu, il est écarté en entier.`
|
||||
: `${plafond} : ${returned} ${unit}(s) renvoyé${e}(s) sur ${total}, ${omitted} écarté${e}(s) — des ` +
|
||||
`${unit}s ${entiers}, jamais coupé${e}s au milieu. Réduisez limit ou ajoutez un filter pour cibler.`;
|
||||
|
||||
if (extraHint) hint += ` ${extraHint}`;
|
||||
if (queryType) hint += ` ${WRITING_VOLUME_NOTE}`;
|
||||
|
||||
return { truncated: true, returned, omitted, hint };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMaxResponseChars,
|
||||
fitToCap,
|
||||
truncationSignal,
|
||||
WRITING_VOLUME_NOTE,
|
||||
DEFAULT_MAX_RESPONSE_CHARS,
|
||||
};
|
||||
@@ -133,6 +133,49 @@ async function fetchApplications() {
|
||||
return applicationsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des applications déjà en cache, ou null si le cache est vide/expiré.
|
||||
* Ne déclenche AUCUN appel réseau — c'est ce qui permet d'enrichir une réponse
|
||||
* de recherche sans jamais précharger une application non demandée (D26).
|
||||
* @returns {Array<{name: string, id: string, version: number}>|null}
|
||||
*/
|
||||
function getCachedApplications() {
|
||||
if (applicationsCache && applicationsTimestamp &&
|
||||
Date.now() - applicationsTimestamp < CACHE_TTL) {
|
||||
return applicationsCache;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hint de découvrabilité (L5.4). Une recherche n'interroge qu'UNE application
|
||||
* sur les neuf déclarées, et rien dans la réponse ne le disait : une session
|
||||
* cherchant des workflows `CST_*` sans `application: "CustomApp"` a conclu à
|
||||
* tort qu'il n'y en avait aucun (25/08/2026).
|
||||
*
|
||||
* Les autres applications sont nommées depuis la liste allégée **déjà en
|
||||
* cache** ; sans elle, le hint reste générique et renvoie vers
|
||||
* `list_workflow_categories` — jamais de fetch pour construire un hint.
|
||||
*
|
||||
* @param {string} application - application effectivement interrogée
|
||||
* @param {string} sujet - ce qui a été cherché ('workflow', 'élément Command'…)
|
||||
*/
|
||||
function buildOtherApplicationsHint(application, sujet) {
|
||||
const cached = getCachedApplications();
|
||||
const others = (cached || []).map(a => a.name).filter(n => n !== application);
|
||||
|
||||
const liste = others.length
|
||||
? `Autres applications déclarées sur ce tenant : ${others.join(', ')}.`
|
||||
: `Appelez list_workflow_categories pour lister les autres applications déclarées.`;
|
||||
|
||||
const custom = application.toLowerCase() === 'customapp'
|
||||
? ''
|
||||
: ` Le spécifique client (préfixe CST_) vit dans "CustomApp" : relancez avec application: "CustomApp".`;
|
||||
|
||||
return `Aucun ${sujet} trouvé dans l'application "${application}" — c'est la SEULE interrogée, ` +
|
||||
`les autres ne le sont jamais implicitement.${custom} ${liste}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search workflows by query string.
|
||||
* Real AD keys (lowercase, cf. D5): id, name, version, applicationName,
|
||||
@@ -251,6 +294,9 @@ function getCacheStatus() {
|
||||
module.exports = {
|
||||
fetchAllWorkflows,
|
||||
fetchApplications,
|
||||
getCachedApplications,
|
||||
buildOtherApplicationsHint,
|
||||
resolveApplication,
|
||||
searchWorkflows,
|
||||
getWorkflowDetails,
|
||||
getWorkflowStats,
|
||||
|
||||
+11
-1
@@ -247,15 +247,25 @@ async function searchADElementsTool(args) {
|
||||
code: e.code || e.Code
|
||||
}));
|
||||
|
||||
// L5.4 : même correctif que search_workflows — l'application interrogée est
|
||||
// toujours rappelée, et un résultat vide signale que les huit autres n'ont
|
||||
// pas été regardées. Le hint se construit depuis la liste d'applications
|
||||
// DÉJÀ en cache : aucun appel réseau, aucun préchargement (D26).
|
||||
const effectiveApplication = adService.resolveApplication(application);
|
||||
const hint = mappedResults.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, `élément ${element_type}`)
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
elementType: element_type,
|
||||
...(application ? { application } : {}),
|
||||
application: effectiveApplication,
|
||||
query,
|
||||
count: mappedResults.length,
|
||||
...(hint ? { hint } : {}),
|
||||
elements: mappedResults
|
||||
}, null, 2)
|
||||
}]
|
||||
|
||||
+39
-19
@@ -1,6 +1,7 @@
|
||||
const apiService = require('../services/api-service').getInstance();
|
||||
const entityResolver = require('../services/entity-resolver');
|
||||
const { assertValidQueryType } = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* Tools MCP pour interagir avec les APIs WMS
|
||||
@@ -39,7 +40,7 @@ function listTools() {
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning.',
|
||||
description: 'QueryContextType (défaut: 0 = Reading — statuts en chaînes, à garder sauf raison explicite). Opt-in : 1 = Writing (statuts en ÉNUMÉRATIONS — les comparaisons de chaînes comme == "Release" ÉCHOUENT), 2 = DataWarehouse (souvent non configuré), 3 = Metrics (modèle de données distinct). En query_type != 0, un nom d\'entité inconnu du Metadata Reading est transmis tel quel avec un warning. ATTENTION VOLUME : une ligne Writing est un agrégat complet sérialisé — 95 288 caractères mesurés pour UNE ligne Products, contre ~4 500 en Reading. La réponse est plafonnée (MAX_QUERY_RESPONSE_CHARS) et les lignes en trop sont écartées avec un signal truncated.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
@@ -123,25 +124,42 @@ async function callQueryAPI(args) {
|
||||
queryType,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
entityType: entity_type,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
result,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
const head = {
|
||||
success: true,
|
||||
entityType: entity_type,
|
||||
resolvedTableName: tableName,
|
||||
...(warning ? { warning } : {}),
|
||||
...(queryType !== 0 ? { queryType } : {}),
|
||||
};
|
||||
|
||||
// Garde de taille (D24) : une SEULE ligne Writing faisait 95 288 caractères
|
||||
// — le modèle Writing sérialise l'agrégat complet. On écarte des lignes
|
||||
// entières ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
if (!Array.isArray(result)) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ ...head, result }, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const payload = { ...head };
|
||||
if (kept < result.length) {
|
||||
// Cet outil ne porte pas de champ de total : on l'ajoute (D24).
|
||||
payload.totalRows = result.length;
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: result.length,
|
||||
queryType,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.result = result.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(result.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
@@ -151,6 +169,7 @@ async function callQueryAPI(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'call_query_api',
|
||||
...(resolution?.warning ? { warning: resolution.warning } : {}),
|
||||
},
|
||||
null,
|
||||
@@ -197,6 +216,7 @@ async function executeCommand(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'execute_command',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -113,7 +113,7 @@ async function listLogFiles() {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'list_log_files' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -156,6 +156,7 @@ async function readRecentLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'read_recent_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -225,6 +226,7 @@ async function searchLogs(args) {
|
||||
{
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'search_logs',
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -109,6 +109,7 @@ async function getEntityMetadata(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: `No entity matching "${entity_name}" found`,
|
||||
tool: 'get_entity_metadata',
|
||||
availableCount: Array.isArray(entities) ? entities.length : '?',
|
||||
hint: 'Call get_entity_metadata without entity_name to see all entities',
|
||||
}, null, 2),
|
||||
@@ -150,7 +151,7 @@ async function getEntityMetadata(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'get_entity_metadata' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
@@ -193,7 +194,7 @@ async function genericSearch(args) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ success: false, error: err.message }, null, 2),
|
||||
text: JSON.stringify({ success: false, error: err.message, tool: 'generic_search' }, null, 2),
|
||||
}],
|
||||
isError: true,
|
||||
};
|
||||
|
||||
@@ -110,6 +110,7 @@ function getCurrentProfileTool() {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'get_current_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -127,6 +128,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: 'Missing "profile" argument',
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
@@ -156,6 +158,7 @@ function switchProfileTool(args) {
|
||||
text: JSON.stringify({
|
||||
success: false,
|
||||
error: err.message,
|
||||
tool: 'switch_wms_profile',
|
||||
profiles: profileManager.listProfiles(),
|
||||
}, null, 2),
|
||||
}],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
const wmsQueryService = require('../services/wms-query-service');
|
||||
const { fitToCap, truncationSignal } = require('../services/response-limit');
|
||||
|
||||
/**
|
||||
* List available WMS query tools
|
||||
@@ -46,7 +47,7 @@ Never guess enum string values — they differ between Reading and Writing model
|
||||
},
|
||||
query_type: {
|
||||
type: 'number',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0.',
|
||||
description: 'QueryContextType (default: 0 = Reading — status fields are strings, keep it unless you know why). Opt-in: 1 = Writing (status fields become ENUMS — string comparisons like == "Release" FAIL), 2 = DataWarehouse (often not configured), 3 = Metrics (different data model). Entity names unknown to the Reading metadata are passed through as-is with a warning when query_type != 0. VOLUME WARNING: a Writing row is a full serialised aggregate (navigations, $id…) — 95 288 characters measured for ONE Products row, against ~4 500 in Reading. The response is capped (MAX_QUERY_RESPONSE_CHARS) and excess rows are dropped whole, with a truncated signal.',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
@@ -177,6 +178,10 @@ async function executeTool(name, args) {
|
||||
|
||||
/**
|
||||
* Tool: query_wms_entities
|
||||
*
|
||||
* Garde de taille (D24) : 200 lignes Reading faisaient 957 234 caractères, au
|
||||
* delà du seuil de rejet du client MCP. On écarte des lignes ENTIÈRES depuis la
|
||||
* fin ; sous le plafond, la réponse est strictement celle d'avant.
|
||||
*/
|
||||
async function queryWmsEntities(args) {
|
||||
const { entity_type, select_expression = 'z => z', filter, limit = 100, query_type = 0 } = args;
|
||||
@@ -191,15 +196,34 @@ async function queryWmsEntities(args) {
|
||||
query_type
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...result
|
||||
}, null, 2)
|
||||
}]
|
||||
const { data, ...head } = result;
|
||||
|
||||
// Une réponse non tabulaire (forme inattendue) ne se borne pas par lignes.
|
||||
if (!Array.isArray(data)) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ success: true, ...result }, null, 2) }]
|
||||
};
|
||||
}
|
||||
|
||||
// `count` (dans head) porte déjà le total avant la coupe — c'est le total
|
||||
// exigé par D24, inutile d'en ajouter un second.
|
||||
const buildText = (kept) => {
|
||||
const payload = { success: true, ...head };
|
||||
if (kept < data.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: data.length,
|
||||
queryType: query_type,
|
||||
unit: 'ligne',
|
||||
feminine: true,
|
||||
}));
|
||||
}
|
||||
payload.data = data.slice(0, kept);
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(data.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,17 +296,52 @@ async function searchWmsData(args) {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
keyword,
|
||||
totalFound,
|
||||
results
|
||||
}, null, 2)
|
||||
}]
|
||||
// Garde de taille (D24) : search_wms_data("PAL") faisait 847 543 caractères.
|
||||
// L'unité écartée est un RÉSULTAT entier ; les résultats gardés sont répartis
|
||||
// en tourniquet entre les entités, pour qu'une entité volumineuse placée en
|
||||
// tête n'efface pas silencieusement les suivantes — c'est exactement le
|
||||
// faux négatif que L5.4 corrige par ailleurs.
|
||||
const entityKeys = Object.keys(results).filter(k => Array.isArray(results[k].data));
|
||||
const slots = [];
|
||||
const maxRows = entityKeys.reduce((m, k) => Math.max(m, results[k].data.length), 0);
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
for (const k of entityKeys) {
|
||||
if (i < results[k].data.length) slots.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
const buildText = (kept) => {
|
||||
const keepCount = {};
|
||||
entityKeys.forEach(k => { keepCount[k] = 0; });
|
||||
for (let i = 0; i < kept; i++) keepCount[slots[i]]++;
|
||||
|
||||
const payload = { success: true, keyword, totalFound };
|
||||
if (kept < slots.length) {
|
||||
Object.assign(payload, truncationSignal({
|
||||
returned: kept,
|
||||
total: slots.length,
|
||||
unit: 'résultat',
|
||||
extraHint: 'Les résultats gardés sont répartis entre les entités : voir returned/omitted par entité. ' +
|
||||
'Relancez query_wms_entities entité par entité avec un filter plus précis pour voir le reste.',
|
||||
}));
|
||||
}
|
||||
|
||||
payload.results = {};
|
||||
for (const [k, v] of Object.entries(results)) {
|
||||
if (!Array.isArray(v.data)) {
|
||||
payload.results[k] = v; // entité en erreur : { error, count }, déjà minuscule
|
||||
continue;
|
||||
}
|
||||
const keptRows = v.data.slice(0, keepCount[k]);
|
||||
payload.results[k] = keptRows.length < v.data.length
|
||||
? { count: v.count, returned: keptRows.length, omitted: v.data.length - keptRows.length, data: keptRows }
|
||||
: { count: v.count, data: keptRows };
|
||||
}
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
const { text } = fitToCap(slots.length, buildText);
|
||||
return { content: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
@@ -117,13 +134,23 @@ async function searchWorkflows(args) {
|
||||
|
||||
const results = await workflowService.searchWorkflows(query, category, limit, application);
|
||||
|
||||
// L5.4 : l'application interrogée est TOUJOURS rappelée (pas seulement quand
|
||||
// elle a été passée), et un résultat vide dit qu'une seule application sur
|
||||
// neuf a été regardée — c'est ce silence qui avait fait conclure à tort à
|
||||
// l'absence de workflows CST_.
|
||||
const effectiveApplication = workflowService.resolveApplication(application);
|
||||
const hint = results.length === 0
|
||||
? workflowService.buildOtherApplicationsHint(effectiveApplication, 'workflow')
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
...(application ? { application } : {}),
|
||||
application: effectiveApplication,
|
||||
count: results.length,
|
||||
...(hint ? { hint } : {}),
|
||||
// Clés réelles de l'API AD (minuscules, cf. D5) : id, name, version,
|
||||
// applicationName, commonInfo. Pas de code/category/description.
|
||||
workflows: results.map(w => {
|
||||
@@ -143,23 +170,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)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user