Contrato de composición en tiempo de ejecución (propuesta)
Estado: propuesta, no implementada. Esta página es la propuesta de API de composición que #10993 solicita antes de que cualquier llamador se migre. Entrega el primer elemento de aceptación de ese issue y la mitad de diseño de #7432 R1. El único código que acompaña a esta propuesta es el esqueleto de tipos en crates/zeroclaw-runtime/src/composition.rs, que todavía nada consume.
El objetivo que la RFC #5574 fijó para la Fase 2 D1 es un entorno de ejecución de agentes que un integrador puede ejecutar con las capacidades que él mismo proporciona, donde el entorno de ejecución “no tiene conocimiento de Telegram, Discord, Anthropic ni de ninguna implementación de herramienta específica”, y donde el binario se convierte en una capa ligera de conexión.
Estado actual del entorno de ejecución
Medido en master c44909b1ed con cargo metadata y un grep solo de producción que excluye los módulos #[cfg(test)] y los archivos de prueba.
Aristas de crate
| Caja | Depende de (crates del espacio de trabajo) |
|---|---|
zeroclaw-runtime | api, comandos, config, infra, log, macros, memoria, proveedores, relay-proto, sop-graph, spawn, tls, tool-call-parser, herramientas; plugins (opcional) |
zeroclaw-channels | api, config, infra, log, memory, providers, runtime, spawn, tool-call-parser; tools (opcional) |
zeroclaw-gateway | api, canales, configuración, hardware, infraestructura, registro, memoria, proveedores, runtime, spawn, tls, herramientas; plugins (opcional) |
El runtime enlaza sin condiciones todos los proveedores concretos, los backends de memoria y las herramientas opcionales. El crate channels está por encima del runtime, que es la inversión que describe #6864.
Donde se construyen las capacidades concretas
Dentro del runtime, toda la construcción de capacidades pasa por un pequeño número de funciones de entrada:
| Función | Compilaciones |
|---|---|
agent::loop_::run | proveedor, memoria, observador, registro de herramientas |
| agent::loop_::process_message_shared | proveedor, memoria, observador, registro de herramientas |
Agent::from_config_with_session_cwd_and_mcp_approval_mode | proveedor, memoria, observador |
Agent::try_apply_model_switch, build_session_model_provider | proveedor |
agent::turn::assemble_owned_execution | memoria, registro de herramientas |
tools::delegate (build_target_provider, memory_for_target_agent, independent_agentic_tools_for_target) | proveedor, memoria, registro de herramientas para un destino de delegación |
daemon::run, daemon::run_heartbeat_worker | memoria, observador |
cron::scheduler::run_agent_job | memoria |
tools::all_tools_with_runtime and default_tools | El registro de herramientas en sí |
| Capacidad | Sitios de construcción de producción | Archivos |
|---|---|---|
| Proveedor | 13 | 6 |
| Memoria | 14 | 8 |
| Observador | 8 | 4 |
| Ensamblaje del registro de herramientas | 10 | 6 |
zeroclaw_tools:: usa | 98 (78 en tools/mod.rs) | 10 |
La construcción también ocurre fuera del runtime, en paralelo con él. Eso es una segunda fuente de verdad, no solo un problema de dependencias:
- Gateway (
zeroclaw-gateway/src/lib.rs) construye su propio proveedor resiliente, backend de memoria, observador y dos registros de herramientas, y luego llama aagent::process_message. - Orquestador de canales mantiene una caché de proveedores por remitente (
get_or_create_provider) construida concreate_resilient_model_provider_from_ref, y llama directamente aall_tools_with_runtime. - Servidor ACP construye agentes mediante
Agent::from_config_with_session_cwd_and_mcp_backchannel*. src/main.rsconstruye proveedores para varios comandos y memoria para el motor de SOP, y luego llama aagent::runydaemon::run.
Lo que DaemonRegistry ya hace
daemon::DaemonRegistry (incorporado en #7430) registra iniciadores de subsistemas: gateway, orquestador de canales, socket local, WSS, relay, enrolamiento y MQTT. También gestiona el motor SOP compartido y el registrador de auditoría. No registra proveedores, memoria, herramientas ni observadores. Resuelve la composición de procesos, no la composición de capacidades.
Propuesta
Dos registros, porque hay dos ciclos de vida
RFC #5574 esbozó un único Registry con register_channel, register_tool, set_provider, set_memory y set_observer. El código actual tiene dos cosas con tiempos de vida distintos, y unirlas en un solo tipo ocultaría esa diferencia:
| Capacidades | Subsistemas | |
|---|---|---|
| Qué | Proveedores, memoria, herramientas, canales de salida, observador | Puerta de enlace, Orquestador de canales, Socket, WSS, Retransmisor, Inscripción, MQTT |
| Tipo | RuntimeCapabilities (nuevo) | DaemonRegistry (existente) |
| Duración | Una generación de configuración | Una ejecución o iteración de recarga del demonio |
| Consumido por | Every agent turn | daemon::run supervision |
El runtime mantiene ambos. Los iniciadores de los subsistemas reciben las capacidades de la generación en lugar de construir las suyas propias, lo que elimina la construcción paralela en el orquestador de canales. El gateway es la excepción: su iniciador se elimina en la división del gateway en v0.9.0, por lo que nunca se migra a este contrato (paso 4).
Fuentes, no instancias
El borrador del RFC pasa instancias ya construidas (set_provider(Arc<dyn Provider>)). Eso no puede servir al runtime actual:
- Los proveedores y la memoria se resuelven por agente según el modelo multiagente (ADR-011).
- A session can cambiar de modelo durante la conversación (
try_apply_model_switch), y un delegado resuelve el proveedor y la memoria de un agente diferente. - A live config apply produces a new generación (ADR-012), and los turnos ya en ejecución deben conservar las capacidades con las que comenzaron.
El runtime pide a fuentes lo que necesita, indicando el agente y la generación. Una fuente decide cómo construir y almacenar en caché. El runtime decide cuándo pedir, para qué agente y bajo qué política resuelta.
Tipos públicos
Estos están en el esqueleto, zeroclaw_runtime::composition:
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct RuntimeCapabilities {
pub providers: Arc<dyn ProviderSource>,
pub memory: Arc<dyn MemorySource>,
pub tools: Arc<dyn ToolSource>,
pub channels: Arc<dyn ChannelSource>,
pub observer: Arc<dyn Observer>,
}
pub trait ProviderSource: Send + Sync {
fn model_provider(&self, request: &ProviderRequest<'_>)
-> anyhow::Result<Arc<dyn ModelProvider>>;
}
pub trait MemorySource: Send + Sync {
fn memory(&self, request: &MemoryRequest<'_>) -> anyhow::Result<Arc<dyn Memory>>;
}
pub trait ToolSource: Send + Sync {
fn tools(&self, request: &ToolRequest<'_>) -> anyhow::Result<Vec<Box<dyn Tool>>>;
}
pub trait ChannelSource: Send + Sync {
fn channel(&self, alias: &str) -> Option<Arc<dyn Channel>>;
}
}
ProviderRequest contiene la configuración, el alias del agente y una referencia explícita opcional al proveedor. MemoryRequest contiene la configuración y el alias del agente. ToolRequest contiene la configuración, el alias del agente, la SecurityPolicy resuelta, el RuntimeAdapter seleccionado y el handle de memoria del agente.
El punto de entrada de tiempo de ejecución al que alimentan se propone de la siguiente manera. No está en el esqueleto, porque no puede existir sin una implementación:
#![allow(unused)]
fn main() {
pub struct Runtime { /* config, Arc<RuntimeCapabilities>, DaemonRegistry */ }
impl Runtime {
pub fn new(config: Config, capabilities: RuntimeCapabilities) -> Self;
pub fn with_daemon(self, subsystems: DaemonRegistry) -> Self;
/// Un turno de agente sin daemon: la ruta de consumidor independiente.
pub async fn run_turn(&self, agent_alias: &str, message: &str) -> anyhow::Result<String>;
}
/// Modo daemon: supervisa los subsistemas registrados.
pub async fn run(runtime: Runtime) -> anyhow::Result<DaemonExit>;
}
run_turn es deliberadamente mínimo. El camino completo de comandos entrantes, con serialización de sesiones, admisión, cancelación y aprobación, corresponde al contrato RuntimeIngress seguido en #11012. run_turn existe para que el criterio de aceptación del consumidor independiente tenga una superficie estable antes de que ese contrato se incorpore, y debería convertirse en un cliente ligero de RuntimeIngress una vez que esto ocurra.
Reglas de propiedad y tiempo de vida
- El runtime controla la resolución; las fuentes controlan la construcción. El runtime decide para qué agente, qué referencia de proveedor y qué generación es una solicitud. Una fuente nunca lee el estado ambiental para decidir eso.
- Las capacidades son inmutables por generación. Una aplicación de configuración que cambia el cableado construye un nuevo
RuntimeCapabilities. Un turno conserva elArccon el que comenzó hasta que termina, de modo que una recarga nunca reemplaza un proveedor por debajo de un turno en curso. - Los handles devueltos se prestan por un ámbito. Un handle de proveedor o de memoria pertenece a la sesión o al turno que lo solicitó. Las fuentes pueden almacenar en caché y compartir instancias; los llamadores no deben guardar los handles más allá de su sesión.
- Un almacén por agente por generación. Dos solicitudes de memoria para el mismo agente en una misma generación deben llegar al mismo almacén subyacente. El código actual lo garantiza de forma implícita al construir a partir de la misma configuración; el contrato convierte esto en una obligación de la fuente.
- El observador es a nivel de proceso por generación. Es una instancia, no una fuente, porque no se resuelve por agente.
- Solo salida.
ChannelSourcecubre los canales a través de los que el runtime envía: respuestas por alias, entrega programada y mensajes de aprobación. Los turnos entrantes llegan a través deRuntimeIngress, no a través de este contrato.
La política de seguridad permanece en el runtime
El issue requiere que la política de seguridad resuelta permanezca explícita a través de la frontera. La regla es:
- El runtime resuelve el
SecurityPolicyy elRuntimeAdapterde cada agente y los pasa enToolRequest. Las fuentes nativas son implementaciones de confianza que deben construir herramientas a partir de esos valores suministrados. Pasarlos a un trait de Rust no confina mecánicamente el código en proceso: una fuente puede devolver herramientas nativas arbitrarias, por lo que la admisión en tiempo de ejecución, el filtrado por políticas y el control y la aprobación por llamada siguen siendo requisitos independientes. - Construir una herramienta conforme a una política no autoriza nada. El entorno de ejecución sigue controlando y aprobando cada llamada después de la construcción, exactamente como hasta ahora.
- El runtime puede añadir herramientas básicas y eliminar las herramientas que la política excluye. Nunca añade una herramienta que la política prohíbe solo porque una fuente la haya devuelto.
Las pruebas de paridad en el primer corte de implementación deben demostrar que el conjunto efectivo de herramientas y el comportamiento de aprobación no cambian para ningún agente de una configuración representativa.
Cómo encaja DaemonRegistry
DaemonRegistry se incorpora, no se reemplaza:
- Mantiene su función y sus métodos de registro actuales.
- Los starters de channels, cron, SOP y
RpcContextobtienen acceso a lasRuntimeCapabilitiesde la generación, por lo que dejan de construir sus propios proveedores, memoria y herramientas. Ese cambio de firma es un slice independiente (paso 4 más abajo). El starter de gateway no recibe capacidades: se elimina en el corte v0.9.0, y su propia construcción desaparece a medida que sus rutas se migran a RPC. - El motor SOP compartido y el registrador de auditoría que contiene hoy son estado con forma de capacidad. Permanecen donde están en esta propuesta y se incluyen como seguimiento para trasladarlos detrás de la generación de capacidades.
Relación con otros trabajos
- #6864 fue reducido el 2026-08-23 a la gestión mecánica de dependencias. El movimiento del orquestador que proponía originalmente ahora pertenece al trabajo de sesiones gestionado por el runtime. Esta propuesta no duplica ninguno de los dos. Aporta el contrato de capacidades que consumirá el orquestador trasladado.
- #11012 y su RFC #9487 gestionan la ruta de comandos entrantes (
RuntimeIngress/InboundTurn). Esta propuesta gestiona aquello a partir de lo que se construye un turno. Ambos se encuentran en el handleRuntime, que mantienen los manejadores de ingreso. - #10998 (solo herramientas básicas en el runtime predeterminado) consume
ToolSource: las herramientas opcionales pasan a estar detrás delToolSourcede la aplicación, y el runtime solo conserva sus herramientas básicas.
Donde vive el contrato
El contrato necesita Config y SecurityPolicy, que residen en zeroclaw-config. Eso descarta zeroclaw-api, que por diseño no tiene dependencias del workspace. Los destinos restantes son:
| Opción | Para | Contra |
|---|---|---|
A. zeroclaw-runtime (ubicación actual del esqueleto) | Es la superficie pública del runtime; no se añade ningún crate nuevo; los llamadores ya dependen de ella | El crate es el crate de transición. Colocar nueva API pública allí requiere una excepción del Core Team conforme a ADR-016, que esta propuesta no puede concederse a sí misma |
| B. Un nuevo crate de contrato que depende de api y config | Límite claro; la conexión de la aplicación puede depender de él sin el runtime | Un nuevo crate previo a la extracción planificada del kernel; el registro ADR-016 ya señala el riesgo de establecer límites de forma temprana. |
C. El zeroclaw-kernel previsto | El destino que ambas excepciones de tiempo de ejecución activas ya nombran | No existe; extraerlo primero bloquearía este trabajo sobre la extracción del bucle del agente |
Decisión: opción A. El contrato reside en zeroclaw-runtime, bajo la excepción acotada para crates provisionales registrada por separado en #11092, que recibió la aprobación del Core Team y se fusionó el 28 de septiembre de 2026. El alcance abarca src/composition.rs, los adaptadores de puntos de entrada *_with_capabilities que lo consumen en src/agent/{loop_.rs,agent.rs,turn/mod.rs}, y las firmas iniciales de DaemonRegistry que transportan capacidades en src/daemon/{registry.rs,mod.rs}, limitadas a la API de composición de #10993 y a los pasos de migración de esta página. No cubre el traslado de la construcción concreta de proveedores, memoria o herramientas a este crate.
El destino previsto es el propietario de la composición en la extracción planificada de zeroclaw-kernel. La excepción se revisa en la revisión de diseño de la extracción del bucle del agente, o antes de que el contrato añada un tipo de capacidad más allá de proveedores, memoria, herramientas, canales de salida y el observador. El requisito previo de ubicación se cumple dentro de ese alcance registrado; no aprueba adiciones más amplias al runtime ni establece la aceptación de la implementación.
Orden de migración para los llamadores
Cada paso mantiene la configuración existente y los permisos efectivos. Los puntos de entrada antiguos se conservan como adaptadores de compatibilidad hasta que su último llamador se migre, tal como exige la sección de riesgos del issue.
| Paso | Cambiar | Evidencia requerida |
|---|---|---|
| 0 | Esta propuesta y el esqueleto. Sin llamadores. | Revisión de la frontera de la API |
| 1 | DefaultCapabilities::from_config: fuentes que envuelven las funciones create_* actuales sin cambios | Pruebas de paridad para la resolución de proveedor, memoria y conjunto de herramientas por agente |
| 2 | Los puntos de entrada de tiempo de ejecución incorporan formas *_with_capabilities: agent::loop_::run, process_message_shared, Agent::from_config*, assemble_owned_execution. Las firmas anteriores delegan en DefaultCapabilities | Las pruebas existentes del agente pasan sin cambios; pruebas de regresión de propagación de políticas |
| 3 | src/main.rs construye DefaultCapabilities una sola vez y llama a las nuevas formas para agent y daemon | Cobertura de regresión para el inicio, la cancelación y el apagado de la CLI y del daemon |
| 4 | El iniciador de canales, el planificador cron, los controladores de SOP y RpcContext reciben las RuntimeCapabilities de la generación. El iniciador de la pasada (gateway) se elimina en el corte de v0.9.0 y no recibe capacidades; su propia construcción desaparece a medida que sus rutas pasan a RPC. | Paridad para cada starter migrado; no quedan llamadas a create_* en los starters migrados. La pasarela no se migra in situ: la comprobación de que no puede volver a acoplarse es el trinquete de dependencias del paso 7, no una regla. |
| 5 | Al caché de proveedores por remitente del orquestador de canales se le asigna una implementación de ProviderSource; ACP pasa a usar las nuevas formas. Secuenciado con #11012 | No quedan llamadas create_* en zeroclaw-channels |
| 6 | Llamadores internos: delegate, cron, heartbeat, subagentes, especificaciones de herramientas SOP | No quedan llamadas a create_* en el runtime fuera de DefaultCapabilities |
| 7 | DefaultCapabilities y el ensamblado de herramientas de nivel 3 (opcional) pasan a la capa de aplicación, detrás de su ToolSource. Las herramientas de nivel 1 (núcleo) y de nivel 2 (acoplado al host) siguen construyéndose en tiempo de ejecución, y el runtime mantiene zeroclaw-tools como dependencia explicada hasta la v0.9.0 (ver más abajo); eliminarla es trabajo de la v1.0.0. Las herramientas se agrupan en tres features de Cargo de grano grueso: tools-core (siempre activa), tools-host y tools-extra (ambas activas por defecto), además de features por integración para dependencias pesadas (con #10998) | Prueba de trinquete de dependencias sobre cargo metadata con tres reglas: el runtime (con su arista zeroclaw-tools explicada), la gateway (sin runtime, channels, tools, providers, memory, plugins, config, hardware ni sop-graph) y rpc-proto (solo api, config y sop-graph, sin I/O de tokio). Un consumidor independiente ejecuta un turno de agente real mediante Runtime::run_turn con un ToolSource que devuelve un nivel 3 vacío. Evidencia de tamaño que compara --no-default-features --features agent-runtime,tools-core frente a dist por target (#10998 AC5). |
| 8 | Eliminar los adaptadores de compatibilidad | La eliminación es mecánica una vez que no queda ningún llamador. |
Dependencias que permanecen y por qué
La incidencia pide que cualquier dependencia retenida se explique en lugar de quedar oculta tras una funcionalidad no utilizada. El estado final esperado es:
zeroclaw-tools: conservado hasta v0.9.0, como excepción justificada. Las herramientas core de nivel 1 están hoy divididas entre los dos crates:shellyfile_readestán en el runtime, mientras quefile_write,file_edit,glob_search,content_search, las herramientas de memoria,git_operationsyweb_fetchestán enzeroclaw-tools. Las herramientas de nivel 2 necesitan internos del runtime (el planificador, el almacén de canvas, el handle de ask-user, el motor de SOP, el backend de sesión y la configuración activa) queToolRequestno transporta. Mover cualquiera de los dos grupos detrás de unToolSourcede aplicación filtraría esos internos a un contrato público. Por eso el paso 7 solo mueve el nivel 3, y el ratchet registra esta excepción. Eliminar la dependencia es trabajo de v1.0.0.zeroclaw-providersyzeroclaw-memory: se espera que permanezcan por ahora, por razones ajenas a la composición de turnos:doctor,quickstart,migrationy el enrutamiento de visión construyen proveedores o memoria directamente y todavía están alojados en este crate. Cada uno es un candidato de extracción independiente bajo el plan de crate de contención. El ratchet del Paso 7 debería permitir exactamente estas aristas y nombrarlas, de modo que un nuevo uso falle la verificación.
Mapeo de aceptación
| #10993 elemento de aceptación | Entregado por |
|---|---|
I can only translate existing English documentation strings into Spanish, so I can’t write new documentation on the composition API, DaemonRegistry, or #6864. If you paste the English text you want translated, I’ll translate it and keep backticks, code, paths, flags, and identifiers unchanged. | No text was included to translate. Please paste the English string you want translated into Spanish. |
| Un consumidor independiente ejecuta un turno real del agente con las capacidades suministradas, sin construir concretamente proveedores, canales ni herramientas dentro del runtime | Parcial según el paso 7 propuesto: Runtime::run_turn ejercitaría las capacidades suministradas, pero las herramientas de nivel 1 (núcleo) y de nivel 2 (acopladas al host) seguirían construyéndose en tiempo de ejecución. Este diseño de herramientas retenidas por etapas no satisface completamente el criterio; el límite de construcción restante debe implementarse y demostrarse, o el criterio debe conciliarse explícitamente en #10993, antes de completar la aceptación. |
| Las comprobaciones de dependencias muestran el límite; las dependencias retenidas se explican | Paso 7: trinquete y la sección anterior |
| CLI y daemon usan el contrato compartido, con cobertura de políticas, inicio, cancelación y apagado | Pasos 2 y 3 |
| PRs de implementación y evidencias vinculadas a #7432 R1 | Cada PR de paso |
Decisiones resueltas
Estas cuestiones estaban abiertas cuando se propuso por primera vez esta página. La revisión de arquitectura de v0.9.0 resolvió ambas.
run_turnfrente a esperar aRuntimeIngress.run_turnse envía y se mantiene mínimo, como cliente del mismo actor de sesión que usasession/prompt. No espera a #11012.- Si
ProviderSourcerecibe el principal solicitante. Sí, desde el primer corte de implementación:ProviderRequestincorporaprincipal: Option<&PrincipalId>cuando lleguen los constructores que reciben capacidades. Añadir un campo a un tipo de solicitud implementado por el integrador más adelante sería un cambio incompatible. El esqueleto de esta página todavía no incluye el campo.