Paridad de políticas del agente
La política de un agente — qué herramientas puede invocar, cuándo debe pedir aprobación, sus presupuestos de tiempo de ejecución, el alcance de su memoria y sus habilidades — debe aplicarse de forma idéntica sin importar qué ruta de código ensamble y ejecute el turno. ZeroClaw construye un turno a través de varias rutas de construcción distintas, y históricamente cada una aplicaba la política por sí misma. Cuando la misma política se vuelve a derivar en varios lugares, una configuración respetada en una ruta puede omitirse silenciosamente en otra.
#8120 (las herramientas MCP de un agente apareciendo en la sesión de otro agente) fue una de esas divergencias: el ámbito de herramientas por agente que aplicaba la ruta de canal faltaba en otra ruta de construcción. El arnés de paridad de políticas de agente existe para hacer visible esa clase de error antes de que se publique, y el trunk sobre el que se construye (#8156) existe para hacerlo imposible por construcción.
Las rutas de construcción
Las entradas del motor de un turno (el registro de herramientas, el administrador de aprobaciones, los parámetros de ejecución resueltos) se ensamblan en varios sitios distintos:
| Ruta | Dónde construye la entrada del motor |
|---|---|
| Canal | el orquestador de canales |
| RPC | la estructura Agent (from_config / turn) |
| Gateway | el servidor de gateway |
loop_::run | ejecuciones no interactivas: trabajos cron, el heartbeat del daemon, creación de subagentes |
| Delegar | delegación de subagente |
| paso anidado activo de SOP | drive_live_sop_actions: un paso que delega en un agente diferente reconstruye la entrada del motor de ese agente en tiempo de ejecución |
Cada ruta debe proporcionar al motor la misma política para la misma configuración de agente. El arnés de paridad verifica exactamente eso: un ajuste aplicado en una ruta se aplica en todas las rutas. La ruta de pasos anidados en vivo de SOP es un subturno dentro de un turno que ya está en ejecución: cuando un paso especifica un agente diferente, su contrato de ejecución completo se vuelve a ensamblar a través del mismo punto de integración en lugar de heredarse del turno padre – herramientas sujetas a control de acceso, política de seguridad, ámbito de MCP, vinculación y temperatura del proveedor, controles de tiempo de ejecución resueltos y un administrador de aprobaciones que incorpora el perfil de riesgo del agente del paso según el modo de interactividad de la superficie padre. El paso se ejecuta en una transcripción hija explícita (su propio prompt del sistema más el contexto del paso; la conversación padre nunca llega al proveedor del agente del paso), y sus registros identifican al agente del paso como la identidad actuante, con el agente delegante como correlación padre. Una ruta que no puede volver a ensamblarlo hace que el paso entre agentes falle en modo cerrado.
La matriz de paridad
Para cada ajuste de política y cada ruta de construcción, el ajuste se aplica de forma obligatoria, parcialmente obligatoria o no se aplica. La matriz de (ajuste x ruta) es un registro auditado de divergencias: dónde se aplica y dónde no se aplica cada ajuste, verificado contra el origen. Una celda de “gap” es un ajuste que una ruta omite.
Bajo el principio rector del proyecto, una laguna es un defecto, no un valor predeterminado: la omisión no es una concesión. Una ruta de construcción que no aplica una restricción ha ampliado la autoridad del agente por accidente, que es precisamente el fallo del que #8120 fue un caso.
El objetivo de convergencia: una sola costura de resolución
La corrección estructural es dejar de volver a derivar la política por ruta. #8156 introdujo el contenedor ResolvedAgentExecution — una reorganización neutral al comportamiento de las entradas por agente del motor en un solo paquete (agent/turn/execution.rs). Este cambio añade su constructor ResolvedAgentExecution::resolve y enruta cada ruta de turno de producción a través de él (agrupando las entradas en capas ResolvedIo + ResolvedRuntimeKnobs), de modo que el paquete se produce en una sola costura en lugar de ensamblarse en línea en cada sitio. Hoy resolve() distribuye entradas ya resueltas (neutral al comportamiento); más adelante, las PR de superficie trasladan a él la resolución por campo (herramientas mediante un registro acotado, aprobación, los parámetros del runtime) y sellan las entradas. Con esa resolución y sellado en su lugar:
- hay exactamente un lugar en el que se aplica una configuración, así que no hay nada que divergir;
- un newtype con un campo privado (por ejemplo, un registro de herramientas con ámbito que solo el resolver puede crear) convierte en error de compilación pasarle al motor una política sin resolver.
El estado final es que la divergencia no se pueda compilar en lugar de limitarse a probarse. Límite actual/futuro: ResolvedAgentExecution, su constructor resolve(), y las capas de entrada ResolvedIo / ResolvedRuntimeKnobs ya existen en master y cada ruta de producción se construye a través de ellas; la superficie TOOL ahora también tiene su constructor con puerta de acceso (ScopedToolRegistry::assemble, abajo, con el gateway como su primer consumidor); absorber la resolución por campo de las superficies restantes en resolve(), y sellar detrás de él los campos del bundle, es el trabajo que harán más adelante los PR de superficie.
La unión de ensamblaje de herramientas (Epic A, la primera superficie)
El registro de herramientas por agente es la primera superficie con un único constructor con control: ScopedToolRegistry::assemble (crates/zeroclaw-runtime/src/tools/scoped.rs). Históricamente, el registro se ha ensamblado a mano en seis puntos de construcción; por eso hubo que parchear el filtro integrado y el alcance de MCP sitio por sitio (#7064, #6960, #8120). assemble aplica, en este orden: config.peripherals del agente (cuando está conectado; ver el ajuste más abajo), el filtro integrado allowed_tools/ excluded_tools, la franja de memoria de ACP, el alcance del servidor MCP por mcp_bundles más el control de acceso por herramienta (anticipado o diferido; la omisión no concede acceso) con las herramientas de capacidad de MCP y la sección del prompt de recursos anclados, y el registro de habilidades bajo la misma SecurityPolicy (un sitio sin habilidades pasa una slice vacía: la pasarela lo hace, hasta la unificación del cargador Epic F).
La variación por sitio se expresa como datos, nunca como un paso de seguridad omitido. Los controles de ScopedAssembly solo reducen o retienen: ninguno puede ampliar lo que la directiva concede:
caller_allowed- una lista de अनुमति por ejecución (la rutarun()); se intersecta con, y nunca reemplaza, el filtro de políticas y la política de acceso a herramientas MCP.connect_mcp-falseen la ruta de arranque rápido de ACP: los servidores MCP no se resuelven ni se conectan, por lo que no se concede nada.connect_peripherals-falseen superficies solo de listado: cargar periféricos conecta físicamente el hardware (bloqueos seriales exclusivos), lo que un registro contra el que no se ejecuta ningún giro nunca debe hacer.exclude_memory- la tira de la herramienta de memoria de ACP.
Estado de corte (el estrangulamiento, un sitio por PR): el gateway (#8640), loop_::run (#8700) y process_message (#8701) ahora construyen todos mediante assemble. El corte del gateway —ambos constructores de su registro, la semilla del agente del panel y los listados /api/tools por agente— cerró por construcción la brecha de filtrado del gateway: sus listados antes mostraban built-ins sin filtrar que la política del agente deniega (el chat en vivo del gateway se resuelve a través de process_message, que ya filtraba), además de un stub tool_search incluso cuando la política denegaba todas las herramientas MCP diferidas. Una nota de alcance mantiene honesta la afirmación sobre los listados: los periféricos se excluyen de los listados por diseño (connect_peripherals: false - enumerarlos sin conectar hardware es un refinamiento futuro). El corte de process_message cerró una segunda divergencia independiente: antes filtraba los built-ins a través de filter_channel_builtin_tools, una variante que admitía los valores predeterminados canónicos de solo lectura más allá de allowed_tools en autonomía distinta de Full, mientras que todas las demás rutas aplicaban el filtro simple apply_policy_tool_filter. #8701 retiró esa variante, así que ahora todas las rutas aplican el mismo filtro simple (ledger A4, respaldado por una prueba positiva de paridad en el propio archivo en lugar de una caracterización de divergencia).
Los sitios restantes hechos a mano — el orquestador de canales (start_channels), Agent::from_config, y el constructor delegado independiente del destino (independent_agentic_tools_for_target, añadido por #8239 mientras este programa estaba en curso — la recurrencia que el sello existe para समाप्तir) — se migran en PRs de seguimiento. Una vez que todos los sitios se creen a través de assemble, el campo tools del motor se sellará a ScopedToolRegistry (un newtype de campo privado que solo construye assemble), y entregar al motor un registro sin ámbito — o volver a incrustar en silencio un sitio de construcción, como ya hizo una fusión cruzada con la ruta de canales una vez — pasa a ser un error de compilación en lugar de algo que detectar en revisión. Hasta ese sello, la paridad entre sitios para los que aún no se han migrado sigue siendo por convención; lo que la costura garantiza hoy es que cada ruta encaminada a través de ella comparte una sola implementación.
El arnés
El harness de paridad vive en crates/zeroclaw-runtime/src/agent/parity.rs, un hermano #[cfg(test)] del oráculo del motor de turnos #7415 safety_net.rs, reutilizando sus fixtures. Lleva un INDEX de filas de paridad: cada una nombra su épica propietaria, una referencia pública de seguimiento y la prueba (o registro de divergencia rastreada) que la respalda, además de dos capas de pruebas. El índice, deliberadamente, no codifica ninguna cuadrícula de veredicto por ruta: una cuadrícula estática de celdas escritas a mano sería en sí misma datos que se quedarían obsoletos cuando otro PR cambiara una ruta, sin que ninguna prueba lo advirtiera, justo el fallo que este programa existe para acabar. Así que las afirmaciones exigibles viven solo en las pruebas; una meta-prueba hace cumplir la contabilidad del índice (propietario, seguimiento y evidencia presentes), nada más. La cuadrícula legible para humanos (setting x path) vive en esta página. Las dos capas de pruebas:
- Bloqueos del motor L1: cuando una configuración llega a
run_tool_call_loop, el motor la respeta (p. ej., una entrada deexcluded_toolsnunca se ejecuta, aunque el modelo la llame). - L2 path-parity es lo que afirma que una configuración se resuelve igual en cada ruta de construcción. Cuando una superficie ya se resuelve a través de una sola costura, su prueba L2 es una aserción positiva de paridad. Cuando una divergencia confirmada aún no tiene una sola costura, se publica como una prueba de caracterización que se ejecuta siempre y fija la divergencia tal como existe (afirmando que las dos rutas actualmente difieren), de modo que, cuando el epic propietario unifica la semántica, esa aserción falla en la misma PR y debe reescribirse como la aserción positiva de paridad. La divergencia solo puede cambiar de forma ruidosa, nunca silenciosamente. No hay especificaciones
#[ignore]: una prueba ignorada conocida por fallar nunca se ejecuta en CI y no protege nada, así que el objetivo se mantiene como una aserción viva del estado actual.
Crece una superficie a la vez y afirma solo aquello que ningún otro test cubre:
- Una superficie (herramientas, aprobación, presupuestos de ejecución, contexto e historial, memoria, habilidades) se estrangula en
resolveun PR a la vez. - Ese PR añade la prueba de paridad de la superficie: dada una configuración de un agente, cada ruta de construcción entrega al motor el mismo valor resuelto para la configuración.
- Los comportamientos ya cubiertos por las pruebas unitarias propias de un primitivo, o por el oráculo del motor
safety_net, no se vuelven a indicar. El harness solo añade la aserción de paridad entre rutas, que es la propiedad que ninguna prueba por primitivo hace.
Hasta que una superficie tenga una única costura de resolución, no hay nada contra lo que afirmar la paridad, así que su fila permanece en el registro de divergencias como una caracterización de divergencia en ejecución continua, en lugar de como una prueba verde prematura: nunca como una especificación #[ignore]d, en consonancia con la regla de no usar especificaciones ignoradas anterior.
Agregar una superficie (el flujo de trabajo que sigue cada futura PR de superficie)
Con resolve() en su lugar (véase arriba), cada PR de superficie sigue estos pasos:
- Mueve la resolución y el cableado de la superficie desde los sitios de construcción a
ResolvedAgentExecution::resolve; elimina las copias de cada sitio. - Añade una prueba de paridad: construye una configuración de agente distintiva, recorre cada ruta de construcción y afirma que el motor recibe el mismo valor resuelto.
- Voltea la fila de la superficie a enforced-on-every-path.
- Mantén el comportamiento de estrangulación neutral en el resto: el oráculo
safety_nety las pruebas unitarias propias de los primitivos siguen en verde.