feat(agents): pont Codex inter-agents + readiness/heartbeat lot 1
Deux chantiers livrés au vert (workspace entier : domain+application+
infrastructure 42 + app-tauri --lib 128, 0 échec).
## Codex inter-agents
- domaine: McpConfigStrategy::TomlConfigHome { target, home_env } +
toml_config_home(...); AgentProfile::materializes_idea_bridge()
(whitelist Claude/ConfigFile + Codex/TomlConfigHome); McpServerWiring
+ encodeur TOML.
- application: lifecycle apply_mcp_config bras TomlConfigHome (écrit
{runDir}/<target>, pousse (home_env, parent) dans spec.env);
guard_mcp_bridge_supported ré-exprimée via materializes_idea_bridge();
catalogue Codex porte toml_config_home(".codex/config.toml","CODEX_HOME").
- app-tauri: is_codex_mcp_profile, migrate_codex_run_dir,
mcp_server_entry_toml.
- tests: matrice domaine TomlConfigHome + round-trip dual Claude/Codex
sur loopback réel (fakes, zéro token).
## Readiness/heartbeat lot 1
- domaine: readiness.rs — ReadinessPolicy::classify (Final => TurnEnded),
variantes ReplyEvent::Heartbeat / ToolActivity.
- application: drain_with_readiness consulte la policy et appelle
mark_idle sur le signal déterministe; branché dans ask_agent.
Corrige la cause racine: une cible qui ne renvoie qu'un Final (sans
idea_reply) débloque désormais sa file Busy.
- infrastructure: adapters de session émettent Heartbeat/ToolActivity.
- tests: drain_with_readiness_lot1 (points QA 5 & 6) verts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
111
crates/domain/src/readiness.rs
Normal file
111
crates/domain/src/readiness.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Politique de **readiness** (« fin-de-tour ») model-agnostique (chantier
|
||||
//! readiness/heartbeat, lot 1).
|
||||
//!
|
||||
//! Objet **pur** (aucune I/O, aucune dépendance externe) qui classe un signal
|
||||
//! observable d'un tour d'agent en un [`ReadinessSignal`] normalisé. Le but : que
|
||||
//! l'application puisse décider de marquer un agent `Idle`
|
||||
//! ([`crate::input::InputMediator::mark_idle`]) sur un **signal déterministe**
|
||||
//! (`Final` du flux structuré) plutôt que de dépendre uniquement d'un `idea_reply`
|
||||
//! explicite ou d'un sniff littéral de prompt PTY.
|
||||
//!
|
||||
//! # Hiérarchie des signaux de fin-de-tour (rappel cadrage)
|
||||
//!
|
||||
//! 1. **Signal n°1 — fin de tour structurée** : [`ReplyEvent::Final`] émis par
|
||||
//! l'adapter (Claude `type:"result"`, Codex `agent_message`/`item.completed`).
|
||||
//! Déterministe, model-agnostique ⇒ classé [`ReadinessSignal::TurnEnded`].
|
||||
//! 2. **Signal n°2 — `idea_reply` explicite** : l'agent appelle l'outil MCP
|
||||
//! [`crate::ports`]/délégation. Premier arrivé gagne avec le n°1.
|
||||
//! 3. **Signal n°3 — repli `prompt_ready_pattern`** : sniff littéral du sigil de
|
||||
//! prompt dans la sortie PTY ([`crate::profile::AgentProfile::prompt_ready_pattern`]).
|
||||
//! **Rétrogradé** au rang de repli depuis ce lot : il ne sert que pour les agents
|
||||
//! TUI/PTY sans adapter structuré (rétro-compat, jamais supprimé).
|
||||
//!
|
||||
//! Les variantes [`ReadinessSignal::Stalled`]/[`ReadinessSignal::TimedOut`] sont la
|
||||
//! place réservée au **lot 2** (détection de stagnation, remplacement des timeouts) :
|
||||
//! elles existent dans le vocabulaire mais ne sont **pas** produites par
|
||||
//! [`ReadinessPolicy::classify`] dans ce lot.
|
||||
|
||||
use crate::ports::ReplyEvent;
|
||||
|
||||
/// Signal de readiness normalisé, model-agnostique, qu'une [`ReadinessPolicy`]
|
||||
/// déduit d'un événement observable du tour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReadinessSignal {
|
||||
/// Le tour est **déterministiquement terminé** : l'agent a rendu son `Final`.
|
||||
/// C'est le signal n°1, model-agnostique — il doit réveiller le `pending` et
|
||||
/// marquer l'agent `Idle`.
|
||||
TurnEnded,
|
||||
/// Un `idea_reply` explicite a été observé (signal n°2). N'est **pas** produit
|
||||
/// par [`ReadinessPolicy::classify`] (qui ne voit que des [`ReplyEvent`]) : il
|
||||
/// est porté par le chemin de délégation, présent ici pour compléter le
|
||||
/// vocabulaire et le rendre explicite.
|
||||
ExplicitReply,
|
||||
/// Le sigil de prompt PTY (repli n°3) est apparu. Idem : non produit par
|
||||
/// `classify`, présent pour nommer le signal de repli legacy.
|
||||
PromptReady,
|
||||
/// L'agent semble **bloqué** (aucune preuve de vivacité depuis un seuil). Place
|
||||
/// réservée au **lot 2** — non produit dans ce lot.
|
||||
Stalled,
|
||||
/// Le garde-fou de durée de tour a expiré. Place réservée au **lot 2** — non
|
||||
/// produit dans ce lot.
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
/// Politique **pure** de classification d'un événement de tour en
|
||||
/// [`ReadinessSignal`]. Sans état, sans I/O : un simple `match` sur le contrat de
|
||||
/// port universel [`ReplyEvent`], pour que la décision « ce tour est-il fini ? »
|
||||
/// vive dans le **domaine** et reste testable sans process ni réseau.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct ReadinessPolicy;
|
||||
|
||||
impl ReadinessPolicy {
|
||||
/// Classe un [`ReplyEvent`] en signal de readiness.
|
||||
///
|
||||
/// - [`ReplyEvent::Final`] ⇒ `Some(`[`ReadinessSignal::TurnEnded`]`)` : seul
|
||||
/// événement terminal, il signe la fin de tour déterministe.
|
||||
/// - [`ReplyEvent::TextDelta`] / [`ReplyEvent::ToolActivity`] /
|
||||
/// [`ReplyEvent::Heartbeat`] ⇒ `None` : tous **non terminaux** (le flux
|
||||
/// continue). Un heartbeat prouve la vivacité mais ne termine pas le tour.
|
||||
#[must_use]
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn final_classifies_as_turn_ended() {
|
||||
let ev = ReplyEvent::Final {
|
||||
content: "fini".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ev),
|
||||
Some(ReadinessSignal::TurnEnded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_activities_and_heartbeats_are_non_terminal() {
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::TextDelta { text: "x".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::Heartbeat),
|
||||
None,
|
||||
"un heartbeat prouve la vivacité mais ne termine JAMAIS le tour"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user