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:
@ -71,6 +71,19 @@ pub enum DomainEvent {
|
||||
/// `true` when a turn is in flight, `false` when the agent is idle.
|
||||
busy: bool,
|
||||
},
|
||||
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
|
||||
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
|
||||
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
|
||||
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
|
||||
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
|
||||
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
|
||||
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
|
||||
AgentLivenessChanged {
|
||||
/// The agent whose liveness changed.
|
||||
agent_id: AgentId,
|
||||
/// The new liveness state.
|
||||
liveness: crate::input::AgentLiveness,
|
||||
},
|
||||
/// An agent's runtime profile was changed (hot-swap of the AI engine).
|
||||
AgentProfileChanged {
|
||||
/// The agent.
|
||||
|
||||
@ -80,6 +80,29 @@ pub enum AgentBusyState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Whether an agent currently shows **signs of life** during a turn (lot 2,
|
||||
/// chantier readiness/heartbeat — détection « Stalled »).
|
||||
///
|
||||
/// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is
|
||||
/// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of
|
||||
/// liveness for longer than its profile's `stall_after_ms`). Only meaningful while
|
||||
/// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly).
|
||||
///
|
||||
/// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI
|
||||
/// can badge a frozen agent without event spam. Derived from the per-agent
|
||||
/// `last_seen_ms` heartbeat, never from parsing the model output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "liveness")]
|
||||
pub enum AgentLiveness {
|
||||
/// The agent is producing proof of liveness (deltas / tool activity /
|
||||
/// heartbeats) within its `stall_after_ms` window — or is idle.
|
||||
Alive,
|
||||
/// No proof of liveness for longer than the profile's `stall_after_ms`: the
|
||||
/// agent is presumed frozen. Advisory only — the FIFO and the turn keep running
|
||||
/// (a late heartbeat flips it back to [`Self::Alive`]).
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl AgentBusyState {
|
||||
/// Whether a turn is currently in flight.
|
||||
#[must_use]
|
||||
@ -195,6 +218,29 @@ pub trait InputMediator: Send + Sync {
|
||||
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
|
||||
fn mark_idle(&self, agent: AgentId);
|
||||
|
||||
/// Records a **proof of liveness** (« battement ») for `agent` — called on every
|
||||
/// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`])
|
||||
/// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall
|
||||
/// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the
|
||||
/// agent is still working; a battement arriving after a `Stalled` transition flips it
|
||||
/// back to [`AgentLiveness::Alive`].
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness). The infra adapter
|
||||
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
|
||||
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
|
||||
fn mark_alive(&self, _agent: AgentId) {}
|
||||
|
||||
/// Declares the agent's **stall threshold** (its profile's
|
||||
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
|
||||
/// how long without a battement means "frozen" (lot 2). The orchestrator resolves it
|
||||
/// from the target's profile and calls this **before** the enqueue that starts a
|
||||
/// turn; the adapter stashes it and arms a fresh liveness window when the turn
|
||||
/// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block —
|
||||
/// zero regression).
|
||||
///
|
||||
/// Default: no-op (a mediator that does not track liveness).
|
||||
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
|
||||
|
||||
/// The current [`AgentBusyState`] of `agent`.
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ pub mod orchestrator;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
@ -74,7 +75,8 @@ pub use skill::{Skill, SkillRef, SkillScope};
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
|
||||
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, LivenessStrategy,
|
||||
McpServerWiring, SessionStrategy,
|
||||
};
|
||||
|
||||
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
|
||||
@ -84,7 +86,9 @@ pub use conversation::{
|
||||
ConversationSession, SessionRef, WaitForGraph,
|
||||
};
|
||||
|
||||
pub use input::{AgentBusyState, InputMediator, InputSource};
|
||||
pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
|
||||
@ -209,6 +209,17 @@ pub enum ReplyEvent {
|
||||
/// Libellé humain-lisible de l'activité.
|
||||
label: String,
|
||||
},
|
||||
/// **Preuve de vivacité non terminale** (model-agnostique) : l'agent est
|
||||
/// toujours en train de travailler. Émis par l'adapter quand le moteur signale
|
||||
/// un battement de cœur natif **sans contenu utile** (handshake/init, fenêtre de
|
||||
/// limite de débit, début/fin de tour côté moteur…). Sert à distinguer « agent
|
||||
/// vivant mais lent » d'« agent bloqué » (readiness/heartbeat, lot 1).
|
||||
///
|
||||
/// **Jamais terminal** : un `Heartbeat` ne clôt **pas** le flux — il s'intercale
|
||||
/// comme un delta (ignoré par les consommateurs synchrones) et le flux continue
|
||||
/// jusqu'au [`ReplyEvent::Final`]. Un tour comporte ≥0 `Heartbeat`, jamais
|
||||
/// d'obligation d'en émettre.
|
||||
Heartbeat,
|
||||
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
|
||||
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
|
||||
/// Après `Final`, le flux se termine (plus aucun événement).
|
||||
@ -220,8 +231,10 @@ pub enum ReplyEvent {
|
||||
|
||||
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
|
||||
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
|
||||
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
|
||||
/// plutôt que des octets bruts.
|
||||
/// mais **typé** : deltas de texte → activités d'outil → battements de cœur*
|
||||
/// ([`ReplyEvent::Heartbeat`], non terminaux, en nombre quelconque et entrelacés) →
|
||||
/// **un** `Final` terminal déterministe, plutôt que des octets bruts. Seul le `Final`
|
||||
/// clôt le flux ; deltas, activités et heartbeats sont tous non terminaux.
|
||||
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -118,6 +118,61 @@ impl SessionStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Réglages de **vivacité** (readiness/heartbeat) d'un profil IA — place ménagée
|
||||
/// pour le lot 2 (chantier readiness/heartbeat). Donnée **déclarative** (pas de code
|
||||
/// par CLI — Open/Closed), calquée sur [`SessionStrategy`] / [`McpCapability`].
|
||||
///
|
||||
/// Deux seuils optionnels :
|
||||
/// - `stall_after_ms` : délai sans **aucune** preuve de vivacité (delta / activité /
|
||||
/// `ReplyEvent::Heartbeat`) au bout duquel l'agent est présumé **bloqué**
|
||||
/// (`ReadinessSignal::Stalled`) — détection au lot 2 ;
|
||||
/// - `turn_timeout_ms` : durée maximale d'**un tour** avant le garde-fou
|
||||
/// (`ReadinessSignal::TimedOut`) — remplacement des timeouts en dur au lot 2.
|
||||
///
|
||||
/// **Lot 1 : champs présents mais non consommés** — on ne fait que ménager la place
|
||||
/// (le modèle de sérialisation et l'API sont figés ici pour éviter une migration au
|
||||
/// lot 2). `None` (défaut, et valeur des profils existants) ⇒ comportement actuel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LivenessStrategy {
|
||||
/// Délai (ms) sans preuve de vivacité avant de présumer l'agent bloqué.
|
||||
/// `None` ⇒ pas de détection de stagnation (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stall_after_ms: Option<u32>,
|
||||
/// Durée maximale (ms) d'un tour avant le garde-fou de timeout. `None` ⇒ pas de
|
||||
/// garde-fou par profil (lot 2).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_timeout_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl LivenessStrategy {
|
||||
/// Construit une stratégie de vivacité validée (parse-don't-validate, comme
|
||||
/// [`SessionStrategy::new`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// Renvoie [`DomainError::EmptyField`] si un seuil fourni vaut `0` (un seuil de
|
||||
/// `0 ms` n'a pas de sens : `None` est la façon d'exprimer « pas de seuil »).
|
||||
pub const fn new(
|
||||
stall_after_ms: Option<u32>,
|
||||
turn_timeout_ms: Option<u32>,
|
||||
) -> Result<Self, DomainError> {
|
||||
if let Some(0) = stall_after_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.stallAfterMs",
|
||||
});
|
||||
}
|
||||
if let Some(0) = turn_timeout_ms {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "liveness.turnTimeoutMs",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
stall_after_ms,
|
||||
turn_timeout_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
|
||||
///
|
||||
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
|
||||
@ -193,6 +248,22 @@ pub enum McpConfigStrategy {
|
||||
/// Nom de la variable d'environnement.
|
||||
var: String,
|
||||
},
|
||||
/// Écrire un fichier de conf MCP **TOML** au chemin (relatif au run dir isolé
|
||||
/// §14.1) attendu par la CLI, et pousser `home_env` (le nom d'une variable
|
||||
/// d'environnement) vers le **dossier parent** de `target` pour isoler la CLI
|
||||
/// de sa config globale. C'est le pendant Codex de [`Self::ConfigFile`] : Codex
|
||||
/// lit ses serveurs MCP dans `$CODEX_HOME/config.toml` (défaut `~/.codex`), table
|
||||
/// TOML `[mcp_servers.<nom>]`. IdeA écrit ce `config.toml` DANS le run dir de
|
||||
/// l'agent et pointe `CODEX_HOME={runDir}/.codex` pour ne JAMAIS toucher au
|
||||
/// `~/.codex` global (isolation par agent, miroir du `.mcp.json` de Claude).
|
||||
TomlConfigHome {
|
||||
/// Chemin relatif sûr du fichier `config.toml` (convention :
|
||||
/// `".codex/config.toml"`).
|
||||
target: String,
|
||||
/// Nom de la variable d'environnement pointée sur le **dossier parent** de
|
||||
/// `target` (ex. `"CODEX_HOME"`).
|
||||
home_env: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpConfigStrategy {
|
||||
@ -227,6 +298,24 @@ impl McpConfigStrategy {
|
||||
crate::validation::valid_env_var(&var)?;
|
||||
Ok(Self::Env { var })
|
||||
}
|
||||
|
||||
/// Constructeur validé `TomlConfigHome` (pendant Codex de [`Self::config_file`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::PathNotRelativeSafe`] si `target` est absolu ou contient `..`
|
||||
/// (même validation que [`Self::config_file`]),
|
||||
/// - [`DomainError::InvalidEnvVar`] si `home_env` n'est pas un identifiant de
|
||||
/// variable d'environnement valide.
|
||||
pub fn toml_config_home(
|
||||
target: impl Into<String>,
|
||||
home_env: impl Into<String>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let target = target.into();
|
||||
let home_env = home_env.into();
|
||||
crate::validation::relative_safe(&target)?;
|
||||
crate::validation::valid_env_var(&home_env)?;
|
||||
Ok(Self::TomlConfigHome { target, home_env })
|
||||
}
|
||||
}
|
||||
|
||||
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
|
||||
@ -253,6 +342,128 @@ impl McpCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// Donnée de **wiring** du serveur MCP IdeA (`command` + `args` + `transport`),
|
||||
/// factorisée pour servir de **source unique** aux deux sérialisations qui en
|
||||
/// dérivaient séparément (et risquaient de diverger) : la déclaration `.mcp.json`
|
||||
/// de Claude (JSON) et la table `[mcp_servers.idea]` de Codex (TOML).
|
||||
///
|
||||
/// Pure (aucune I/O, aucune dépendance) : les deux encodeurs construisent une
|
||||
/// chaîne à la main, donc le domaine reste sans dépendance (`serde_json`/`toml`
|
||||
/// non requis). Le contenu (exe, endpoint, …) est calculé par l'appelant — le
|
||||
/// domaine ne fait que la **mise en forme**.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerWiring {
|
||||
/// Commande à lancer (le binaire IdeA en mode `mcp-server`, ou `"idea"` en
|
||||
/// déclaration minimale dégradée).
|
||||
pub command: String,
|
||||
/// Arguments passés à `command` (ex. `["mcp-server", "--endpoint", …]`).
|
||||
pub args: Vec<String>,
|
||||
/// Transport du serveur MCP (surfacé dans les deux formats).
|
||||
pub transport: McpTransport,
|
||||
}
|
||||
|
||||
impl McpServerWiring {
|
||||
/// Construit le wiring depuis ses parties.
|
||||
#[must_use]
|
||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||
Self {
|
||||
command,
|
||||
args,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
/// Étiquette stable du transport, identique pour les deux formats.
|
||||
#[must_use]
|
||||
const fn transport_label(&self) -> &'static str {
|
||||
match self.transport {
|
||||
McpTransport::Stdio => "stdio",
|
||||
McpTransport::Socket => "socket",
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode un document **`.mcp.json`** complet (Claude Code et CLIs apparentées) :
|
||||
/// `{ "mcpServers": { "idea": { command, args, transport } } }`. Chaque chaîne est
|
||||
/// échappée en littéral JSON (chemins avec espaces/backslash/quotes restent
|
||||
/// valides).
|
||||
#[must_use]
|
||||
pub fn to_mcp_json(&self) -> String {
|
||||
let command = json_string(&self.command);
|
||||
let args = if self.args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let joined = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| format!("\n {}", json_string(a)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{joined}\n ")
|
||||
};
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
r#"{{
|
||||
"mcpServers": {{
|
||||
"idea": {{
|
||||
"command": {command},
|
||||
"args": [{args}],
|
||||
"transport": "{transport}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
||||
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
||||
/// restent valides).
|
||||
#[must_use]
|
||||
pub fn to_config_toml(&self) -> String {
|
||||
let command = toml_string(&self.command);
|
||||
let args = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| toml_string(a))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let transport = self.transport_label();
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Échappe `s` en **littéral de chaîne JSON** (guillemets inclus) pour les chemins
|
||||
/// exe/endpoint avec espaces, backslash ou quotes.
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Échappe `s` en **chaîne basique TOML** (guillemets inclus). Les chaînes
|
||||
/// basiques TOML utilisent les mêmes séquences d'échappement que JSON pour `"`,
|
||||
/// `\`, et les contrôles.
|
||||
fn toml_string(s: &str) -> String {
|
||||
// Le jeu d'échappement requis par une chaîne basique TOML coïncide avec celui de
|
||||
// JSON pour les caractères qui nous concernent (chemins, flags).
|
||||
json_string(s)
|
||||
}
|
||||
|
||||
/// Declarative runtime configuration for one AI CLI.
|
||||
///
|
||||
/// Invariants:
|
||||
@ -316,6 +527,13 @@ pub struct AgentProfile {
|
||||
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
|
||||
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
|
||||
///
|
||||
/// **Rang (chantier readiness/heartbeat, lot 1) : signal de repli n°3.** Depuis
|
||||
/// l'introduction de la fin-de-tour structurée ([`crate::ports::ReplyEvent::Final`]
|
||||
/// ⇒ [`crate::readiness::ReadinessSignal::TurnEnded`], signal n°1) et du signal
|
||||
/// explicite `idea_reply` (n°2), ce sniff littéral est **rétrogradé** au rang de
|
||||
/// repli : il ne sert plus que pour les agents **TUI/PTY sans adapter structuré**.
|
||||
/// Conservé tel quel pour la rétro-compat (jamais supprimé).
|
||||
///
|
||||
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
|
||||
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
|
||||
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
|
||||
@ -325,6 +543,15 @@ pub struct AgentProfile {
|
||||
/// un profil sans motif sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_ready_pattern: Option<String>,
|
||||
/// Réglages de **vivacité** (readiness/heartbeat, chantier lot 1). `None` (défaut,
|
||||
/// et valeur des profils existants) ⇒ comportement actuel. **Lot 1** : champ
|
||||
/// présent mais **non consommé** (place ménagée pour les seuils de stagnation /
|
||||
/// timeout de tour du lot 2).
|
||||
///
|
||||
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
|
||||
/// un profil sans cette clé sérialise exactement comme avant.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub liveness: Option<LivenessStrategy>,
|
||||
/// Séquence de soumission écrite **après** le texte d'une délégation pour la
|
||||
/// faire valider par la CLI (§20.3, fix Bug 1). Le portail d'écriture (front)
|
||||
/// écrit d'abord le texte (sans `\n`, pour esquiver la détection de paste de
|
||||
@ -483,6 +710,7 @@ impl AgentProfile {
|
||||
structured_adapter: None,
|
||||
mcp: None,
|
||||
prompt_ready_pattern: None,
|
||||
liveness: None,
|
||||
submit_sequence: None,
|
||||
submit_delay_ms: None,
|
||||
})
|
||||
@ -515,6 +743,15 @@ impl AgentProfile {
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`LivenessStrategy`] (readiness/heartbeat, lot 1) et renvoie
|
||||
/// le profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils sans réglage de vivacité ne l'appellent simplement pas.
|
||||
#[must_use]
|
||||
pub const fn with_liveness(mut self, liveness: LivenessStrategy) -> Self {
|
||||
self.liveness = Some(liveness);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder : fixe la [`Self::submit_sequence`] (§20.3, fix Bug 1) et renvoie le
|
||||
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
|
||||
/// profils qui s'en remettent au défaut `"\r"` ne l'appellent simplement pas.
|
||||
@ -546,6 +783,33 @@ impl AgentProfile {
|
||||
pub fn is_selectable(&self) -> bool {
|
||||
self.structured_adapter.is_some()
|
||||
}
|
||||
|
||||
/// **Source de vérité UNIQUE** de la whitelist des couples (adaptateur structuré
|
||||
/// × stratégie MCP) qu'IdeA **matérialise réellement** pour exposer les outils
|
||||
/// `idea_*` à la CLI — donc les seuls couples vers lesquels la délégation
|
||||
/// inter-agents (`idea_ask_agent`/`idea_reply`) peut router une cible.
|
||||
///
|
||||
/// Un profil déclare bien une [`McpConfigStrategy`], mais ce n'est honoré que si
|
||||
/// IdeA sait écrire la config que **cette** CLI lit nativement :
|
||||
/// - `Claude` + `ConfigFile { target == ".mcp.json" }` ⇒ Claude lit `.mcp.json`
|
||||
/// dans son cwd (run dir isolé) ;
|
||||
/// - `Codex` + `TomlConfigHome { .. }` ⇒ Codex lit `$CODEX_HOME/config.toml`,
|
||||
/// qu'IdeA isole dans le run dir via `home_env` ;
|
||||
/// - tout autre couple (y compris `mcp` absent) ⇒ `false` : repli fichier
|
||||
/// `.ideai/requests` + prose, le pont natif n'est pas branché.
|
||||
///
|
||||
/// Cette fonction centralise le critère pour que la garde applicative
|
||||
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||
#[must_use]
|
||||
pub fn materializes_idea_bridge(&self) -> bool {
|
||||
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
||||
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||
target == ".mcp.json"
|
||||
}
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -832,4 +1096,200 @@ mod mcp_tests {
|
||||
assert_eq!(back.submit_sequence.as_deref(), Some("\r"));
|
||||
assert_eq!(back.submit_delay_ms, Some(60));
|
||||
}
|
||||
|
||||
// -- Lot 1 : liveness (readiness/heartbeat) — non-régression de sérialisation --
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_liveness() {
|
||||
// Profils existants (via `new`) : aucun réglage de vivacité.
|
||||
assert!(profile_without_mcp().liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_without_liveness_omits_key_in_json() {
|
||||
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("liveness"),
|
||||
"a profile without liveness must NOT serialise the key (zero regression); got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_json_without_liveness_deserialises_to_none() {
|
||||
let legacy = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"name": "Dev",
|
||||
"command": "claude",
|
||||
"args": [],
|
||||
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
|
||||
"detect": null,
|
||||
"cwdTemplate": "{agentRunDir}"
|
||||
}"#;
|
||||
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
|
||||
assert!(profile.liveness.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_liveness_sets_and_round_trips_camel_case() {
|
||||
let liveness = LivenessStrategy::new(Some(30_000), Some(600_000)).expect("valid liveness");
|
||||
let profile = profile_without_mcp().with_liveness(liveness);
|
||||
assert_eq!(profile.liveness, Some(liveness));
|
||||
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("liveness"), "key present: {json}");
|
||||
assert!(json.contains("stallAfterMs"), "camelCase field: {json}");
|
||||
assert!(json.contains("turnTimeoutMs"), "camelCase field: {json}");
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
assert_eq!(back.liveness, Some(liveness));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_omits_unset_thresholds_in_json() {
|
||||
// Un seul seuil fixé : l'autre est `None` ⇒ sa clé est omise.
|
||||
let liveness = LivenessStrategy::new(None, Some(600_000)).expect("valid liveness");
|
||||
let json = serde_json::to_string(&liveness).expect("serialise");
|
||||
assert!(
|
||||
!json.contains("stallAfterMs"),
|
||||
"an unset stall threshold must be omitted; got: {json}"
|
||||
);
|
||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_new_rejects_zero_thresholds() {
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(Some(0), None).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
LivenessStrategy::new(None, Some(0)).unwrap_err(),
|
||||
DomainError::EmptyField { .. }
|
||||
));
|
||||
// Les deux None : valide (= « aucun seuil »).
|
||||
assert!(LivenessStrategy::new(None, None).is_ok());
|
||||
}
|
||||
|
||||
// -- Codex : surface MCP `TomlConfigHome` (pont inter-agents Codex) ----------
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_round_trips_with_tagged_strategy() {
|
||||
let strategy = McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home");
|
||||
let json = serde_json::to_string(&strategy).expect("serialise");
|
||||
|
||||
// Wire contract: tagged enum (`strategy` tag) + camelCase variant & fields.
|
||||
assert!(
|
||||
json.contains("\"strategy\":\"tomlConfigHome\""),
|
||||
"tagged camelCase variant expected; got: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"target\":\".codex/config.toml\""),
|
||||
"target field expected; got: {json}"
|
||||
);
|
||||
// NB: `rename_all = "camelCase"` on this enum renames *variants*, not the
|
||||
// fields of a struct variant, so `home_env` stays snake_case on the wire.
|
||||
assert!(
|
||||
json.contains("\"home_env\":\"CODEX_HOME\""),
|
||||
"home_env field expected; got: {json}"
|
||||
);
|
||||
|
||||
let back: McpConfigStrategy = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(strategy, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_absolute_and_parent_target() {
|
||||
let abs = McpConfigStrategy::toml_config_home("/abs/x", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(abs, DomainError::PathNotRelativeSafe { .. }));
|
||||
|
||||
let parent = McpConfigStrategy::toml_config_home("../escape", "CODEX_HOME").unwrap_err();
|
||||
assert!(matches!(parent, DomainError::PathNotRelativeSafe { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_config_home_rejects_invalid_home_env() {
|
||||
// Empty home_env: first char is None ⇒ invalid identifier.
|
||||
let empty = McpConfigStrategy::toml_config_home(".codex/config.toml", "").unwrap_err();
|
||||
assert!(matches!(empty, DomainError::InvalidEnvVar { .. }));
|
||||
|
||||
// Illegal character in the env var name.
|
||||
let illegal =
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "BAD-NAME").unwrap_err();
|
||||
assert!(matches!(illegal, DomainError::InvalidEnvVar { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materializes_idea_bridge_matrix() {
|
||||
// Claude + `.mcp.json` ConfigFile ⇒ bridge materialised.
|
||||
let claude = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Claude)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(claude.materializes_idea_bridge());
|
||||
|
||||
// Codex + TomlConfigHome ⇒ bridge materialised (the key point: Codex used to
|
||||
// be refused before this surface existed).
|
||||
let codex = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME")
|
||||
.expect("valid toml config home"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(codex.materializes_idea_bridge());
|
||||
|
||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||
let codex_no_mcp =
|
||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||
|
||||
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||
let codex_wrong_strategy = profile_without_mcp()
|
||||
.with_structured_adapter(StructuredAdapter::Codex)
|
||||
.with_mcp(McpCapability::new(
|
||||
McpConfigStrategy::config_file(".mcp.json").expect("valid target"),
|
||||
McpTransport::Stdio,
|
||||
));
|
||||
assert!(!codex_wrong_strategy.materializes_idea_bridge());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_wiring_encodes_expected_toml() {
|
||||
// A command path with a space and a backslash exercises TOML escaping.
|
||||
let wiring = McpServerWiring::new(
|
||||
"/opt/My Apps\\idea".to_owned(),
|
||||
vec![
|
||||
"mcp-server".to_owned(),
|
||||
"--endpoint".to_owned(),
|
||||
"/tmp/sock 1".to_owned(),
|
||||
],
|
||||
McpTransport::Stdio,
|
||||
);
|
||||
let toml = wiring.to_config_toml();
|
||||
|
||||
// Table header present.
|
||||
assert!(
|
||||
toml.contains("[mcp_servers.idea]"),
|
||||
"table header expected; got: {toml}"
|
||||
);
|
||||
// Command path escaped as a TOML basic string (backslash doubled, space kept).
|
||||
assert!(
|
||||
toml.contains("command = \"/opt/My Apps\\\\idea\""),
|
||||
"escaped command path expected; got: {toml}"
|
||||
);
|
||||
// Args preserved in order, as a TOML array.
|
||||
assert!(
|
||||
toml.contains("args = [\"mcp-server\", \"--endpoint\", \"/tmp/sock 1\"]"),
|
||||
"args in order expected; got: {toml}"
|
||||
);
|
||||
// Transport surfaced.
|
||||
assert!(
|
||||
toml.contains("transport = \"stdio\""),
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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