Voici les outils de record channel qu'une connexion Codex CLI reçoit d'osFoundry, tels que l'endpoint MCP public les liste. Ils ont été récupérés avec un tools/list non authentifié le 2026-11-09 et réduits aux champs qui comptent ici :
{"name": "list_record_items", "description": "List the schema and a bounded page of item previews from one granted record channel. Use read_record_item for the complete values.", "inputSchema": {"required": ["record_channel_id"], "properties": {"record_channel_id": {"type": "string"}, "before_id": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 50}}}, "annotations": {"readOnlyHint": true}} {"name": "read_record_item", "description": "Read one item by ID from a granted record channel and return its current version.", "inputSchema": {"required": ["record_channel_id", "item_id"]}} {"name": "edit_record_item", "description": "Update validated fields in one record item. Supply the version from read_record_item; concurrent changes are rejected.", "inputSchema": {"required": ["record_channel_id", "item_id", "values", "expected_version", "idempotency_key"], "properties": {"values": {"type": "object"}}}, "annotations": {"readOnlyHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} Regardez values. C'est {"type": "object"}, rien de plus. Le schéma de l'outil ne peut pas dire quelles clés possède un channel, lesquelles sont des champs Select, ni ce que Points peut contenir, parce que tout cela varie d'un channel à l'autre et change dès que quelqu'un modifie l'onglet Schema. Le seul endroit où Codex peut l'apprendre, c'est la première ligne de la description de l'outil de listage : le schéma revient avec la page. La suite de ce post mesure ce que ça apporte, sur une reconstruction, ainsi que deux autres choix dans ces mêmes quatre outils : un curseur before_id au lieu d'un offset, et un journal d'événements auquel revenir.
Qu'est-ce qu'une valeur valide ?
Les types de champ sont fixes. packages/shared/src/types/record.ts dans le frontend d'osFoundry définit RecordChannelFieldType avec 16 types : text, number, date, boolean, email, phone, url, select, multi_select, currency, percentage, rating, relation, file, json et formula. Chaque champ porte is_required, is_unique et une config propre au type : max_length pour text, min/max pour number, max_stars pour rating, options pour les deux types select, expression pour formula.
C'est Select qui piège les agents. Une option est {value, label, color}. C'est la value qui est stockée, et le label est ce que voient les gens. RecordDataGrid.tsx affiche une cellule select avec options.find((o) => o.value === params.value) et se rabat sur String(params.value) dans un chip grisé quand rien ne correspond. Donc si une vérification trop permissive laissait passer "In progress" dans un champ dont la valeur est in_progress, la grille afficherait quelque chose qui a l'air juste et qui est faux : pas de couleur, et invisible pour tout ce qui groupe ou filtre par valeur. Un modèle qui n'a vu que les labels de la grille, ou le message d'une personne, enverra des labels. Il faut bien que quelqu'un dise non.
À quoi doit ressembler un rejet ?
L'API de records d'osFoundry répond à un appel en échec par {ok: false, error: {message}}. Les outils de records propres à l'app desktop pour ses agents intégrés (src-tauri/src/record_tools.rs) transmettent ce message au modèle tel quel, et le commentaire explique pourquoi : le message de l'API "names the field that failed validation, which is usually enough for a self-correcting retry." Son test unitaire prend values.status is not a valid option comme exemple.
Ce "usually enough" se mesure. La reconstruction teste quatre niveaux de détail dans un rejet :
- terse :
{"error": "validation_failed"} - field : quelles clés ont échoué
- values : les clés, la raison, et les valeurs d'option autorisées ou la plage
- labels : la même chose, plus le label de chaque option
Voici le niveau "values", tel quel, sorti de l'exécution :
{"error": "validation_failed", "fields": [ {"key": "status", "message": "values.status must be one of the option values, got \"In progress\"", "allowed": ["new", "triaged", "in_progress", "fixed", "wont_fix"]}, {"key": "owner", "message": "values.owner must be one of the option values, got \"Aiko\"", "allowed": ["aiko", "mika", "ren"]}, {"key": "points", "message": "values.points must be between 0 and 13, got 20", "min": 0, "max": 13}, {"key": "due", "message": "values.due must be a date as YYYY-MM-DD, got \"Friday\""}, {"key": "age_days", "message": "values.age_days is a formula field and is computed, not written"}]} Ces chaînes viennent de la reconstruction. Seul le nommage values.<key> reprend celui d'osFoundry.
La reconstruction
Aucun binaire Codex ne tourne ici. record_store.py implémente list_record_items, read_record_item, edit_record_item et poll_resource_events sur sqlite en mémoire, avec les mêmes noms d'outils, noms d'arguments et limites qu'en production. Les types de champ et les clés de config viennent de record.ts. La longueur de la preview (120 caractères), la forme des résultats et toutes les règles de validation sont propres à la reconstruction. Une modification s'exécute dans BEGIN IMMEDIATE : recherche d'idempotence, comparaison de version, validation, merge, incrément de version, ajout de l'événement, commit. Le moindre problème annule toute la modification.
triage.py est un Codex scripté. Il travaille sur un bug tracker à 11 champs (Exemple : ticket, title, description, status, owner, severity, points, area, due, reporter, age_days), 200 items New par exécution, seeds 1–5. Chaque item reçoit une intention de triage formulée par une personne : "In progress", "Aiko", "S2 Major" ou juste "major", des points parmi {1, 2, 3, 5, 8, 13, 20}, un due "Friday" ou une date ISO, une area. Soit l'agent envoie les mots tels quels, soit il les fait d'abord passer par le schéma, en associant labels et values. Face à un rejet, il corrige ce que le rejet lui permet de corriger, abandonne ce qu'il ne peut pas corriger et signale ce champ à une personne. Il ne borne jamais un nombre. Python 3.9.6, Apple M1.
Le schéma dans la page change-t-il quelque chose ?
1,000 items, 6 champs visés chacun :
| L'agent a lu le schéma | Le rejet indique | Champs enregistrés (sur 6,000) | Laissés à une personne | Appels d'édition par item |
|---|---|---|---|---|
| non | rien | 1,363 (22.7%) | 4,637 | 7.00 |
| non | quel champ | 1,363 (22.7%) | 4,637 | 2.00 |
| non | champ, raison, valeurs autorisées | 5,361 (89.3%) | 639 | 2.00 |
| non | champ, raison, valeurs et labels | 5,857 (97.6%) | 143 | 2.00 |
| oui | rien | 5,857 (97.6%) | 143 | 1.86 |
| oui | quel champ | 5,857 (97.6%) | 143 | 1.14 |
| oui | valeurs, ou valeurs et labels | 5,857 (97.6%) | 143 | 1.14 |
97.6% est le plafond. Les 143 restants sont les items où la personne a dit 20 points alors que le champ s'arrête à 13. Ceux-là doivent être refusés, et ils l'ont été dans chaque ligne.
Un agent qui avait lu le schéma atteignait le plafond quel que soit le contenu du rejet. Sa première modification n'échouait que pour les 143 points hors plage. Sans le schéma, toutes les premières modifications échouaient : status, owner, severity et area étaient des labels, et la moitié des dates due valaient "Friday". À partir de là, la récupération dépend entièrement de l'erreur. "Quel champ" ne sert à rien sans les valeurs autorisées : l'agent sait que status est faux mais pas ce qui est juste, alors il l'abandonne. Les valeurs autorisées sans labels donnent 89.3%. Les 639 ratés, ce sont "Won't fix" face à wont_fix et "major" face à s2. Aucun des deux ne peut être apparié à partir d'une liste de valeurs. Avec les labels dans le rejet, l'agent sans schéma rattrape son retard, au prix d'un appel de plus par item. Une erreur laconique sans schéma a poussé l'agent à découper chaque modification en six modifications d'un seul champ, 7 appels par item, et il a quand même enregistré moins d'un quart.
Le schéma, c'est donc la moitié de la conception de la validation. La vérification côté serveur bloque les mauvaises valeurs. Le schéma qui accompagne chaque page, c'est ce qui permet de viser juste dès le premier essai. Il coûte 1,705 octets par page dans ce channel, environ 10% d'une page de 16.7 KiB avec 50 previews.
Pourquoi des previews, et pourquoi 50 au maximum ?
Une page de 50 previews pesait 16.7 KiB. Lire ces 50 items en entier représentait 124.6 KiB, parce que les descriptions font ici 300–4,000 caractères. Pour le triage, une preview suffit pour choisir quoi ouvrir : le status, le ticket, le début du titre. Codex ouvre ceux qu'il va modifier. Une page bornée évite aussi qu'un gros channel inonde le contexte. La description de l'outil dit "bounded" et le schéma dit maximum: 50.
Pourquoi before_id plutôt qu'un offset ?
Les gens continuent de créer des bugs pendant que Codex lit. La reconstruction pagine 600 items par 50, du plus récent au plus ancien, et modifie le channel après chaque page. Cinq exécutions :
| Changement après chaque page | Pagination | Pages | Lectures d'items | Lus deux fois | Jamais vus |
|---|---|---|---|---|---|
| 2 nouveaux bugs créés | offset | 65 | 3,120 | 120 | 0 |
| 2 nouveaux bugs créés | before_id | 60 | 3,000 | 0 | 0 |
| 2 items déjà lus supprimés | offset | 60 | 2,890 | 0 | 110 |
| 2 items déjà lus supprimés | before_id | 60 | 3,000 | 0 | 0 |
Les nouveaux items en tête décalent tout vers le bas, donc les pages par offset se chevauchent et Codex trie certains items deux fois. Supprimer quelque chose de déjà lu décale tout vers le haut, donc la page offset suivante commence après des items que personne n'a lus. 110 bugs qui ont existé du début à la fin n'ont jamais été vus. Un curseur désigne le dernier item lu, et une insertion ou une suppression ailleurs ne peut pas le déplacer. Les items créés pendant la passe n'apparaissent sur aucune page. C'est à ça que sert le journal d'événements.
Comment Codex retrouve-t-il ce qui a changé depuis la dernière passe ?
Après une passe sur 400 items, des gens ont modifié 30 items, en ont créé 12 et supprimé 3. Deux façons de retrouver ces 45 :
| Stratégie | Appels | Données renvoyées | Trouvés |
|---|---|---|---|
| relister toutes les pages, comparer les versions | 9.0 | 135.8 KiB | 45 sur 45 |
poll_resource_events depuis la séquence stockée | 1.0 | 4.6 KiB | 45 sur 45 |
Le script vérifie par assert que les deux ont trouvé exactement les 45. Le polling coûte ce qui a changé. Relister coûte la taille du channel, à chaque fois. L'état à conserver entre deux exécutions tient en un entier, la dernière séquence.
Et la vérification de version ?
Rapidement, parce qu'elle fonctionne exactement comme pour Notes et les bases de connaissances. Sur 1,000 modifications où une personne a déplacé 1 item sur 8 entre la lecture et l'écriture de Codex, une version issue de la lecture a donné 131 conflits et écrasé 0 des changements de la personne. Relire la version juste avant d'écrire a donné 0 conflit et les a écrasés tous les 131. Après un conflit, le Codex scripté relit, voit que l'item est déjà in progress, et n'ajoute que severity.
Combien coûte la validation ?
2,000 modifications valides de six champs, trois exécutions : médiane de 72.8–75.1 µs par modification avec validation, 41.9–42.4 µs avec validate() remplacé par un stub. La différence, c'est la requête du schéma et les vérifications par champ dans la transaction. Face à un tour de modèle de plusieurs secondes, c'est invisible.
Si vous le câblez vous-même
- Renvoyez le schéma avec le listage, à chaque page, pas via un appel séparé que l'agent risque de sauter. L'écart mesuré, c'est une modification rejetée par item.
- Mettez les valeurs d'option autorisées et leurs labels dans le rejet. Un nom de champ seul ne permet à l'agent de rien corriger.
- Rejetez toute la modification dès qu'un champ échoue, et indiquez quels champs. Un enregistrement partiel qui annonce un succès cache le champ que l'agent a raté.
- Ne convertissez jamais des labels et ne bornez jamais des nombres côté serveur. "major" et "20 points" sont des questions pour une personne, pas pour un matcher.
- Paginez avec un curseur sur un ID stable, et donnez à l'agent une séquence d'événements pour la prochaine exécution.
- Laissez les changements de schéma, les créations et les suppressions hors des outils de l'agent tant que vous n'en avez pas besoin. La validation n'a de sens que si l'agent ne peut pas élargir le champ.
La définition courte du protocole sous-jacent par osFoundry se trouve dans son entrée de glossaire sur MCP. L'écran du record channel d'où vient le schéma est présenté sur la page Records.