feat(orchestrator): durcir le rendez-vous inter-agents + backstop no-reply (Finding A)
Corrige le wedge survenant lorsqu'un agent délégué ne rappelle pas idea_reply :
le rendez-vous ask_agent ne reste plus bloqué indéfiniment.
Lot 1 — durcissement du préambule de délégation :
- input/mod.rs : delegation_preamble renforcé (obligation explicite idea_reply).
- agent/lifecycle.rs : préambule injecté conditionné à mcp_enabled.
Lot 2 — peuplement du prompt_ready_pattern :
- agent/catalogue.rs : .with_prompt_ready_pattern("? for shortcuts") sur le
profil Claude + tests de seed, pour calibrer la détection de grâce.
Lot 3 — backstop timeout serveur :
- mcp/jsonrpc.rs : code d'erreur typé RENDEZVOUS_NO_REPLY (-32001).
- mcp/server.rs : mapping timeout → erreur typée, with_ask_rendezvous_timeout
promu + resolve_ask_rendezvous_timeout (configurable).
- app-tauri/state.rs : lecture env IDEA_ASK_RENDEZVOUS_TIMEOUT_MS.
- tests/mcp_server.rs mis à jour, fix d'un test input flaky.
Propagation overlay (référence des profils) :
- agent/catalogue.rs : overlay_reference_defaults + reference_index + tests.
- store/profile.rs : ReferenceBackfillProfileStore + tests.
- app-tauri/state.rs : wrap au composition root.
- exports mod.rs / lib.rs (application + infrastructure).
Tests réels verts : application 494, app-tauri 235, infrastructure 248,
mcp_server 22/22, 0 échec. Validation e2e LIVE (rebuild AppImage) en attente
avant merge vers develop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -875,10 +875,21 @@ impl MediatedInbox {
|
||||
/// The `[IdeA · tâche de <requester> · ticket <id>]` header is the protocol signal
|
||||
/// (cadrage B-5, agent context) that marks the message as an IdeA delegation the
|
||||
/// target must answer via `idea_reply` — echoing `ticket` for multi-thread
|
||||
/// correlation — rather than as a free-text human prompt. The raw `task` follows on
|
||||
/// the next line so the agent reads the request unchanged.
|
||||
/// correlation — rather than as a free-text human prompt.
|
||||
///
|
||||
/// A one-line **imperative reminder** follows the header (durcissement
|
||||
/// comportemental, finding A) : the single most common wedge is a target that ends
|
||||
/// its turn with a *prose* answer and never calls `idea_reply`, leaving the asker
|
||||
/// parked. Reminding it inline — on every delegated task, even trivial ones — attacks
|
||||
/// that behavioural cause at the central point where the turn is composed (no per-agent
|
||||
/// duplication). The raw `task` then follows so the agent reads the request unchanged.
|
||||
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
|
||||
format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}")
|
||||
format!(
|
||||
"[IdeA · tâche de {requester} · ticket {ticket}]\n\
|
||||
⚠️ Termine IMPÉRATIVEMENT ce tour par un appel `idea_reply(ticket=\"{ticket}\", \
|
||||
result=…)`, même pour une réponse triviale. Ne réponds JAMAIS uniquement en prose \
|
||||
dans le terminal : sans `idea_reply`, l'agent demandeur reste bloqué.\n{task}"
|
||||
)
|
||||
}
|
||||
|
||||
fn write_delegation_chunks(
|
||||
@ -1425,8 +1436,14 @@ mod tests {
|
||||
|
||||
inbox.enqueue(a, ticket(10, &task));
|
||||
|
||||
// Attendre le SUBMIT final (`\r`, écrit après le délai), pas un simple seuil de
|
||||
// writes : le nombre de chunks de contenu dépend de la taille du payload (préambule
|
||||
// + tâche), donc seul le `\r` terminal est un point d'arrêt stable.
|
||||
assert!(
|
||||
wait_until(|| pty.writes.lock().unwrap().len() >= 4),
|
||||
wait_until(|| {
|
||||
let w = pty.writes.lock().unwrap();
|
||||
w.len() >= 4 && w.last().map(Vec::as_slice) == Some(b"\r".as_slice())
|
||||
}),
|
||||
"long headless delivery writes multiple chunks followed by submit"
|
||||
);
|
||||
let writes = pty.writes.lock().unwrap().clone();
|
||||
|
||||
@ -50,7 +50,9 @@ pub use id::UuidGenerator;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use inspector::ClaudeTranscriptInspector;
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
||||
pub use orchestrator::mcp::{
|
||||
resolve_ask_rendezvous_timeout, McpServer, MemoryTransport, StdioTransport,
|
||||
};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
@ -75,6 +77,7 @@ pub use store::{
|
||||
AdaptiveMemoryRecall, EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore,
|
||||
FsLiveStateStore, FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore,
|
||||
FsSkillStore, FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
OnnxModelInfo, ReferenceBackfillProfileStore, StubEmbedder, VectorMemoryRecall,
|
||||
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -121,6 +121,15 @@ pub mod error_codes {
|
||||
pub const INVALID_PARAMS: i32 = -32_602;
|
||||
/// Internal server error (a dispatched IdeA command failed).
|
||||
pub const INTERNAL_ERROR: i32 = -32_603;
|
||||
|
||||
/// **Server-defined** (JSON-RPC reserved range −32000..=−32099): the synchronous
|
||||
/// `idea_ask_agent` rendezvous expired against the server-side safety net without
|
||||
/// the target ever rendering a reply. **Distinct** from [`INTERNAL_ERROR`] on
|
||||
/// purpose — it is not an internal fault but a *typed, retryable* outcome
|
||||
/// (semantics mirror [`application::AppError::TargetReturnedNoReply`]): the caller
|
||||
/// may re-ask, reminding the target to answer via `idea_reply`. The accompanying
|
||||
/// `data` carries `{ "code": "TARGET_RETURNED_NO_REPLY", "retryable": true }`.
|
||||
pub const RENDEZVOUS_NO_REPLY: i32 = -32_001;
|
||||
}
|
||||
|
||||
/// Errors a [`Transport`] may surface.
|
||||
|
||||
@ -36,6 +36,6 @@ pub mod transport;
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use server::McpServer;
|
||||
pub use server::{resolve_ask_rendezvous_timeout, McpServer};
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
pub use transport::{MemoryTransport, StdioTransport};
|
||||
|
||||
@ -46,6 +46,20 @@ const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
/// untouched — they don't rendezvous.
|
||||
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Resolves the effective `idea_ask_agent` rendezvous bound from an optional
|
||||
/// configured override in **milliseconds** (project/profile level), mirroring the
|
||||
/// application's [`resolve_turn_timeout`](application) shape: `Some(ms)` ⇒ that bound,
|
||||
/// `None`/absent ⇒ the generous [`ASK_RENDEZVOUS_TIMEOUT`] default (statu quo, zero
|
||||
/// regression). `Some(0)` is treated as "no override" so a misconfigured zero can never
|
||||
/// collapse the safety net to an instant expiry. Pure and unit-testable without wiring.
|
||||
#[must_use]
|
||||
pub fn resolve_ask_rendezvous_timeout(override_ms: Option<u32>) -> Duration {
|
||||
match override_ms {
|
||||
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
|
||||
_ => ASK_RENDEZVOUS_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
||||
@ -138,11 +152,15 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Test seam** (doc-hidden): shrinks the `idea_ask_agent` rendezvous bound
|
||||
/// (see [`ASK_RENDEZVOUS_TIMEOUT`]) so a wedged-target test need not wait the
|
||||
/// full production timeout. Doc-hidden so it does not widen the documented public
|
||||
/// surface; production code never calls it.
|
||||
#[doc(hidden)]
|
||||
/// Overrides the `idea_ask_agent` rendezvous safety-net bound (default
|
||||
/// [`ASK_RENDEZVOUS_TIMEOUT`]).
|
||||
///
|
||||
/// A genuine **configuration knob** (project/profile level, modelled on the
|
||||
/// application's `turn_timeout_ms` / `resolve_turn_timeout`): the composition root
|
||||
/// resolves an optional override with [`resolve_ask_rendezvous_timeout`] and applies
|
||||
/// it here. Absent override ⇒ the generous 24 h default is preserved unchanged (zero
|
||||
/// regression on legitimately long delegated turns). Tests also use it to shrink the
|
||||
/// bound to milliseconds.
|
||||
#[must_use]
|
||||
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.ask_rendezvous_timeout = timeout;
|
||||
@ -401,16 +419,20 @@ impl McpServer {
|
||||
match tokio::time::timeout(self.ask_rendezvous_timeout, dispatch).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
// RISQUE #1 (Architect) : on `return` ici ⇒ le futur `dispatch` (déplacé
|
||||
// dans `timeout`) est **droppé**, jamais détaché dans un `spawn`. C'est ce
|
||||
// Drop qui libère le `BusyTurnGuard` RAII de `ask_agent` (cancel_head +
|
||||
// mark_idle) et purge le `Busy`/ticket de la cible — aucune fuite `Busy`.
|
||||
application::diag!(
|
||||
"[mcp] ask EXPIRED requester={requester_label} target={arg_target} \
|
||||
timeout_ms={} elapsed_ms={}",
|
||||
self.ask_rendezvous_timeout.as_millis(),
|
||||
started.elapsed().as_millis(),
|
||||
);
|
||||
return Err(JsonRpcError::new(
|
||||
error_codes::INTERNAL_ERROR,
|
||||
"idea_ask_agent : aucune réponse de la cible avant le timeout du rendezvous",
|
||||
));
|
||||
// Filet serveur : ne plus renvoyer un INTERNAL_ERROR opaque, mais la même
|
||||
// sémantique typée et **retryable** que `AppError::TargetReturnedNoReply`,
|
||||
// sous un code JSON-RPC DISTINCT (RENDEZVOUS_NO_REPLY) + `data` structuré.
|
||||
return Err(rendezvous_no_reply_error(&arg_target));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -483,6 +505,33 @@ fn tool_result_text(text: &str, is_error: bool) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous expires
|
||||
/// against the server-side safety net (the `Elapsed` branch of the outer `timeout`).
|
||||
///
|
||||
/// Mirrors [`application::AppError::TargetReturnedNoReply`] one-for-one — same message
|
||||
/// and same machine-readable `code` (`"TARGET_RETURNED_NO_REPLY"`), flagged `retryable`
|
||||
/// — but as a JSON-RPC protocol error under the **distinct**
|
||||
/// [`error_codes::RENDEZVOUS_NO_REPLY`] code (never the opaque `INTERNAL_ERROR`), since
|
||||
/// at expiry no [`OrchestratorOutcome`](application::OrchestratorOutcome) exists to fold
|
||||
/// into a tool result. The `data` object lets a caller branch without parsing the
|
||||
/// message.
|
||||
fn rendezvous_no_reply_error(target: &str) -> JsonRpcError {
|
||||
let message = format!(
|
||||
"agent {target} returned to its prompt without calling idea_reply (no answer was \
|
||||
rendered) before the rendezvous timeout; retry the request and ensure the agent \
|
||||
replies via idea_reply"
|
||||
);
|
||||
JsonRpcError {
|
||||
code: error_codes::RENDEZVOUS_NO_REPLY,
|
||||
message,
|
||||
data: Some(json!({
|
||||
"code": "TARGET_RETURNED_NO_REPLY",
|
||||
"retryable": true,
|
||||
"target": target,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
||||
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
||||
match err {
|
||||
|
||||
@ -28,7 +28,7 @@ pub use embedder::{
|
||||
pub use live_state::FsLiveStateStore;
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use permission::FsPermissionStore;
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore, ReferenceBackfillProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use skill::FsSkillStore;
|
||||
pub use template::FsTemplateStore;
|
||||
|
||||
@ -148,6 +148,59 @@ impl ProfileStore for FsProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Merge-on-read decorator** over any [`ProfileStore`]: backfills *internal*
|
||||
/// reference defaults that a stored profile is missing, without ever persisting
|
||||
/// anything (finding A — propagate the measured `prompt_ready_pattern` to profiles
|
||||
/// saved before that field existed).
|
||||
///
|
||||
/// Only [`list`](ProfileStore::list) is transformed — each returned profile is run
|
||||
/// through [`application::overlay_reference_defaults`] (pure, allowlisted, never
|
||||
/// clobbers a user value, idempotent). `save`/`delete`/`is_configured`/
|
||||
/// `mark_configured` **delegate verbatim** so persistence and first-run detection are
|
||||
/// byte-for-byte unchanged. Wrap the concrete [`FsProfileStore`] once at the
|
||||
/// composition root, before injecting the store into use cases and the orchestrator,
|
||||
/// so every reader (including direct `.list()` calls) sees the overlaid reads.
|
||||
pub struct ReferenceBackfillProfileStore {
|
||||
inner: Arc<dyn ProfileStore>,
|
||||
}
|
||||
|
||||
impl ReferenceBackfillProfileStore {
|
||||
/// Wraps an inner [`ProfileStore`]; reads are overlaid, writes pass through.
|
||||
#[must_use]
|
||||
pub fn new(inner: Arc<dyn ProfileStore>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for ReferenceBackfillProfileStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| application::overlay_reference_defaults(&p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.inner.save(profile).await
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
self.inner.delete(id).await
|
||||
}
|
||||
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
self.inner.is_configured().await
|
||||
}
|
||||
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
self.inner.mark_configured().await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -286,3 +339,88 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
|
||||
FsEmbedderProfileStore::delete(self, id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod backfill_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory [`ProfileStore`] fake recording mutating/query calls, so we can prove
|
||||
/// the decorator transforms ONLY `list` and delegates the rest verbatim.
|
||||
#[derive(Default)]
|
||||
struct FakeStore {
|
||||
profiles: Mutex<Vec<AgentProfile>>,
|
||||
saved: Mutex<Vec<AgentProfile>>,
|
||||
deleted: Mutex<Vec<ProfileId>>,
|
||||
is_configured_calls: Mutex<u32>,
|
||||
mark_configured_calls: Mutex<u32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProfileStore for FakeStore {
|
||||
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
||||
Ok(self.profiles.lock().unwrap().clone())
|
||||
}
|
||||
async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> {
|
||||
self.saved.lock().unwrap().push(profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, id: ProfileId) -> Result<(), StoreError> {
|
||||
self.deleted.lock().unwrap().push(id);
|
||||
Ok(())
|
||||
}
|
||||
async fn is_configured(&self) -> Result<bool, StoreError> {
|
||||
*self.is_configured_calls.lock().unwrap() += 1;
|
||||
Ok(true)
|
||||
}
|
||||
async fn mark_configured(&self) -> Result<(), StoreError> {
|
||||
*self.mark_configured_calls.lock().unwrap() += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude reference profile with the internal field cleared — the persisted-before
|
||||
/// shape the overlay backfills.
|
||||
fn claude_without_pattern() -> AgentProfile {
|
||||
let id = application::reference_profile_id("claude");
|
||||
let mut p = application::reference_profiles()
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.expect("claude reference exists");
|
||||
p.prompt_ready_pattern = None;
|
||||
p
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_overlays_reference_defaults() {
|
||||
let fake = Arc::new(FakeStore::default());
|
||||
fake.profiles.lock().unwrap().push(claude_without_pattern());
|
||||
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
||||
|
||||
let listed = store.list().await.expect("list ok");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(
|
||||
listed[0].prompt_ready_pattern.as_deref(),
|
||||
Some("? for shortcuts"),
|
||||
"a stored Claude profile WITHOUT the pattern is backfilled on read"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_delete_and_config_delegate_verbatim() {
|
||||
let fake = Arc::new(FakeStore::default());
|
||||
let store = ReferenceBackfillProfileStore::new(Arc::clone(&fake) as Arc<dyn ProfileStore>);
|
||||
|
||||
let p = claude_without_pattern();
|
||||
store.save(&p).await.expect("save ok");
|
||||
store.delete(p.id).await.expect("delete ok");
|
||||
assert!(store.is_configured().await.expect("is_configured ok"));
|
||||
store.mark_configured().await.expect("mark_configured ok");
|
||||
|
||||
// save passes the profile through UNCHANGED (no overlay on the write path).
|
||||
assert_eq!(*fake.saved.lock().unwrap(), vec![p.clone()]);
|
||||
assert_eq!(*fake.deleted.lock().unwrap(), vec![p.id]);
|
||||
assert_eq!(*fake.is_configured_calls.lock().unwrap(), 1);
|
||||
assert_eq!(*fake.mark_configured_calls.lock().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1247,11 +1247,25 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() {
|
||||
.expect("reply owed");
|
||||
|
||||
let error = response.error.expect("a JSON-RPC error on timeout");
|
||||
assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}");
|
||||
assert!(
|
||||
error.message.contains("timeout"),
|
||||
"explicit timeout message, got {error:?}"
|
||||
// Filet serveur : code DISTINCT de l'INTERNAL_ERROR opaque, sémantique typée et
|
||||
// retryable (miroir de `AppError::TargetReturnedNoReply`).
|
||||
assert_eq!(
|
||||
error.code,
|
||||
error_codes::RENDEZVOUS_NO_REPLY,
|
||||
"rendezvous timeout maps to the distinct typed code, got {error:?}"
|
||||
);
|
||||
assert_ne!(
|
||||
error.code,
|
||||
error_codes::INTERNAL_ERROR,
|
||||
"must NOT be the opaque internal error"
|
||||
);
|
||||
assert!(
|
||||
error.message.contains("idea_reply"),
|
||||
"message must steer the caller back to idea_reply, got {error:?}"
|
||||
);
|
||||
let data = error.data.expect("typed data on the rendezvous error");
|
||||
assert_eq!(data["code"], "TARGET_RETURNED_NO_REPLY", "got {data:?}");
|
||||
assert_eq!(data["retryable"], true, "got {data:?}");
|
||||
assert!(response.result.is_none(), "error responses carry no result");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user