Parité agent-politique
La politique d’un agent - qui définit les outils qu’il peut appeler, les moments où il doit demander une approbation, ses budgets d’exécution, son périmètre de mémoire et ses compétences - doit être appliquée identiquement, quel que soit le chemin de code qui assemble et exécute le tour. ZeroClaw construit un tour via plusieurs chemins de construction distincts, et historiquement, chacun appliquait cette politique de manière autonome. Lorsqu’une même politique est redéfinie à plusieurs endroits, un paramètre respecté sur un chemin peut être silencieusement ignoré sur un autre.
#8120 (outils MCP d’un agent apparaissant dans la session d’un autre agent) était une telle divergence : le périmètre des outils par agent que le chemin channel appliquait manquait sur un autre chemin de construction. Le harness de parité agent-policy existe pour rendre cette classe de bugs visible avant sa livraison, et le trunk sur lequel il s’appuie (#8156) existe pour la rendre impossible par construction.
Les chemins de construction
Les entrées du moteur d’un tour (le registre des outils, le gestionnaire d’approbations, les paramètres d’exécution résolus) sont assemblées à plusieurs sites distincts :
| Chemin | Où il construit l’entrée du moteur |
|---|---|
| Canal | l’orchestrateur de canaux |
| RPC | la structure Agent (from_config / turn) |
| Passerelle | le serveur de passerelle |
loop_::run | exécutions non interactives : tâches cron, le heartbeat du démon, le spawning de sous-agents |
| Délégué | délégation de sous-agent |
| Étape imbriquée SOP en direct | drive_live_sop_actions : une étape déléguant à un agent différent réassemble l’entrée moteur de cet agent à la volée |
Chaque chemin doit transmettre au moteur la même politique pour la même configuration d’agent. Le harnais de parité affirme exactement cela : un paramètre appliqué sur un chemin est appliqué sur tous les chemins. Le chemin d’étape imbriquée en direct de la SOP est un sous-tour à l’intérieur d’un tour déjà en cours : lorsqu’une étape nomme un agent différent, son contrat d’exécution complet est réassemblé à travers la même jointure plutôt qu’hérité du tour parent – outils contrôlés, politique de sécurité, portée MCP, liaison de fournisseur et température, contrôles d’exécution résolus, et un gestionnaire d’approbation portant le profil de risque de l’agent d’étape sous le mode d’interactivité de la surface parente. L’étape s’exécute sur une transcription enfant explicite (son propre prompt système plus le contexte de l’étape ; la conversation parente n’atteint jamais le fournisseur de l’agent d’étape), et ses enregistrements estampillent l’agent d’étape comme identité active avec l’agent délégant comme corrélation parente. Un chemin qui ne peut pas se réassembler échoue l’étape inter-agents en mode fermé.
La matrice de parité
Pour chaque paramètre de stratégie et chaque chemin de construction, le paramètre est soit appliqué, partiellement appliqué ou non appliqué. La matrice (paramètre × chemin) constitue un registre de divergence audité : elle indique où chaque paramètre est appliqué ou non, et est vérifiée par rapport à la source. Une cellule « gap » correspond à un paramètre qu’un chemin omet.
Selon le principe directeur du projet, une lacune est un défaut, pas une valeur par défaut : l’omission n’est pas une autorisation. Un chemin de construction qui ne parvient pas à appliquer une restriction a élargi l’autorité de l’agent par accident, ce qui est précisément l’échec dont #8120 était une instance.
La cible de convergence : un joint de résolution
Le correctif structurel consiste à cesser de redériver la politique par chemin. #8156 a introduit le porteur ResolvedAgentExecution — un regroupement neutre en comportement des entrées par agent du moteur en un seul bundle (agent/turn/execution.rs). Ce changement ajoute son constructeur ResolvedAgentExecution::resolve et fait passer chaque chemin de tour de production par celui-ci (en regroupant les entrées en couches ResolvedIo + ResolvedRuntimeKnobs), de sorte que le bundle est produit en une seule jointure plutôt qu’assemblé en ligne à chaque site. Aujourd’hui resolve() propage des entrées déjà résolues (neutre en comportement) ; des PR de surface ultérieures déplacent la résolution par champ (outils via un registre à portée limitée, approbation, les knobs d’exécution) dans celui-ci et scellent les entrées. Avec cette résolution et ce scellage en place :
- il n’y a exactement qu’un seul endroit où un paramètre est appliqué, donc il n’y a rien à diverger ;
- un newtype avec un champ privé (par exemple un registre d’outils à portée limitée que seul le résolveur peut émettre) fait de la remise d’une politique non résolue au moteur une erreur de compilation.
L’état final est que la divergence est non compilable plutôt que simplement testée. Frontière actuelle/future : ResolvedAgentExecution, son constructeur resolve(), et les couches d’entrée ResolvedIo / ResolvedRuntimeKnobs existent tous sur master et chaque chemin de production construit via eux ; la surface TOOL a maintenant aussi son constructeur contrôlé (ScopedToolRegistry::assemble, ci-dessous, avec la gateway comme premier consommateur) ; absorber la résolution par champ des surfaces restantes dans resolve(), et sceller les champs du bundle derrière celui-ci, constituent le travail que les PRs de surfaces ultérieures accomplissent.
La jointure d’assemblage d’outils (Epic A, la première surface)
Le registre d’outils par agent est la première surface avec un constructeur unique contrôlé : ScopedToolRegistry::assemble (crates/zeroclaw-runtime/src/tools/scoped.rs). Le registre a historiquement été assemblé à la main sur six sites de construction - la raison pour laquelle le filtre intégré et le scoping MCP ont dû être corrigés par site (#7064, #6960, #8120). assemble applique, dans l’ordre : les config.peripherals de l’agent (lorsqu’ils sont connectés - voir le réglage ci-dessous), le filtre intégré allowed_tools/ excluded_tools, le strip mémoire ACP, le scoping serveur MCP selon mcp_bundles plus le gating par outil (immédiat ou différé ; l’omission n’est pas une autorisation) avec les outils de capacité MCP et la section de prompt des pinned-resources, et l’enregistrement des skills sous la même SecurityPolicy (un site sans skills passe une slice vide - la gateway le fait, jusqu’à l’unification du chargeur Epic F).
La variation par site est exprimée sous forme de données, jamais comme une étape de sécurité omise. Les réglages ScopedAssembly ne font que restreindre ou retenir - aucun ne peut élargir ce que la politique accorde :
caller_allowed- une liste d’autorisation par exécution (le cheminrun()) ; s’intersecte avec, ne remplace jamais, le filtre de politique et la politique d’accès aux outils MCP.connect_mcp-falsesur le chemin de démarrage rapide ACP : les serveurs MCP ne sont ni résolus ni connectés, donc rien n’est accordé.connect_peripherals-falsesur les surfaces de listage uniquement : le chargement des périphériques connecte physiquement le matériel (verrous série exclusifs), ce qu’un registre contre lequel aucun tour ne s’exécute ne doit jamais faire.exclude_memory- le retrait de l’outil mémoire ACP.
Statut de basculement (strangler, un site par PR) : le gateway (#8640), loop_::run (#8700) et process_message (#8701) se construisent aujourd’hui via assemble. Le basculement du gateway — tant ses générateurs de registre, l’amorçage de l’agent du tableau de bord que les listes /api/tools par agent — a combler l’écart de filtrage du gateway par conception : ses listes affichaient auparavant des fonctions intégrées non filtrées que la politique de l’agent refuse (la communication live sur le gateway est résolue via process_message, qui les filtre déjà), ainsi qu’un stub tool_search même lorsque la politique refusait chaque outil MCP différé. Une note de périmètre maintient l’exactitude de l’affirmation sur les listes : les périphériques sont exclus des listes par conception (connect_peripherals: false — les énumérer sans connecter de matériel est une amélioration future). Le basculement de process_message a fermé une seconde divergence indépendante : il filtrait auparavant les fonctions intégrées via filter_channel_builtin_tools, une variante qui admettait les valeurs par défaut canoniques en lecture seule au-delà de allowed_tools pour les niveaux d’autonomie autres que Full, tandis que tous les autres chemins appliquaient le filtre standard apply_policy_tool_filter. La PR #8701 a retiré cette variante, de sorte que chaque chemin applique désormais le même filtre standard (ledger A4, étayé par un test de parité positif dans le fichier plutôt que par une caractérisation de divergence).
Les sites restants codés à la main - l’orchestrateur de canaux (start_channels), Agent::from_config, et le constructeur de cible indépendante de délégation (independent_agentic_tools_for_target, ajouté par #8239 pendant que ce programme était en cours - la récurrence que le sceau existe pour faire cesser) - migrent dans des PRs de suivi. Une fois que tous les sites créent via assemble, le champ tools du moteur se scelle en ScopedToolRegistry (un newtype à champ privé que seul assemble construit), et fournir au moteur un registre non scopé - ou ré-inliner discrètement un site de construction, comme un merge croisé l’a déjà fait une fois au chemin des canaux - devient une erreur de compilation au lieu d’une détection en revue. Jusqu’à ce sceau, la parité inter-sites pour les sites pas encore migrés reste par convention ; ce que la jointure garantit aujourd’hui est que chaque chemin routé via elle partage une seule implémentation.
Le harnais
Le harness de parité se trouve dans crates/zeroclaw-runtime/src/agent/parity.rs, un frère #[cfg(test)] de l’oracle du turn-engine #7415 safety_net.rs, réutilisant ses fixtures. Il porte un INDEX de lignes de parité - chacune nommant son epic propriétaire, une référence de suivi publique, et le test (ou l’enregistrement de divergence suivie) qui le soutient - plus deux couches de tests. L’index encode délibérément aucune grille de verdict par chemin : une grille statique de cellules écrites à la main serait elle-même des données qui deviennent obsolètes lorsqu’une autre PR modifie un chemin, sans qu’aucun test ne le remarque - l’échec même que ce programme existe pour éliminer. Ainsi, les assertions applicables n’existent que dans les tests ; un méta-test impose la tenue des registres de l’index (propriétaire, suivi et preuves présentes), rien de plus. La grille lisible par l’humain (paramètre × chemin) se trouve dans cette page. Les deux couches de tests :
- Verrouillages du moteur L1 : lorsqu’un paramètre atteint
run_tool_call_loop, le moteur le respecte (par ex., une entréeexcluded_toolsne s’exécute jamais, même si le modèle l’appelle). - L2 path-parity garantit qu’une configuration se résout de manière identique sur chaque chemin de construction. Lorsqu’une surface se résout déjà via une seule jonction, son test L2 constitue une assertion de parité positive. Lorsqu’une divergence confirmée n’a pas encore de jonction unique, elle est déployée en tant que test de caractérisation systématiquement exécuté, qui fige la divergence dans son état actuel (affirmant que les deux chemins diffèrent actuellement) ; de sorte que lorsque l’épic propriétaire unifie la sémantique, cette assertion échoue dans la même PR et doit être réécrite en une assertion de parité positive. La divergence ne peut évoluer que bruyamment, jamais silencieusement. Il n’y a pas de specs
#[ignore]: un test ignoré et connu comme échouant ne s’exécute jamais dans CI et ne protège rien, de sorte que l’objectif est maintenu sous la forme d’une assertion active de l’état actuel.
Il développe une surface à la fois et vérifie uniquement ce qu’aucun autre test ne couvre :
- Une surface (outils, approbation, budgets d’exécution, contexte et historique, mémoire, compétences) est progressivement migrée vers
resolve, une PR à la fois. - Cette PR ajoute le test de parité de la surface : pour une configuration d’agent donnée, chaque chemin de construction fournit au moteur la même valeur résolue pour le paramètre.
- Les comportements déjà couverts par les tests unitaires propres à un primitif, ou par l’oracle
safety_netdu moteur, ne sont pas reproduits. La batterie ajoute uniquement l’assertion de parité entre les chemins, qui est la propriété qu’aucun test par primitif ne vérifie.
Tant qu’une surface n’a pas un unique joint de résolution, il n’y a rien contre quoi affirmer la parité, donc sa ligne reste dans l’enregistrement de divergence en tant que caractérisation de divergence toujours en cours d’exécution plutôt qu’en tant que test vert prématuré - jamais en tant que spécification #[ignore]d, conformément à la règle no-ignored-specs ci-dessus.
Ajout d’une surface (le workflow que suit chaque PR pour une future surface)
Avec resolve() en place (voir ci-dessus), chaque surface PR suit ces étapes :
- Déplacez la résolution et le câblage de la surface des sites de construction vers
ResolvedAgentExecution::resolve; supprimez les copies par site. - Ajouter un test de parité : créer une configuration d’agent distinctive, parcourir chaque chemin de construction, et vérifier que le moteur reçoit la valeur résolue identique.
- Basculer la ligne de la surface sur enforced-on-every-path.
- Conservez un comportement neutre pour le strangle ailleurs : l’oracle
safety_netet les tests unitaires des primitives restent au vert.