agent conversation fix
This commit is contained in:
@ -762,29 +762,16 @@ impl MediatedInbox {
|
||||
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
|
||||
Arc::clone(&self.mailbox)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Composes the line delivered into the target's terminal for a delegated turn.
|
||||
/// Composes the text delivered into a human-facing terminal turn.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
/// Inter-agent conversation no longer uses this path: structured/headless asks send the
|
||||
/// raw task through `AgentSession::send` and capture the model `Final`. Therefore this
|
||||
/// terminal delivery must not inject any inter-agent ticket/reply protocol.
|
||||
fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String {
|
||||
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}"
|
||||
)
|
||||
let _ = (requester, ticket);
|
||||
task.to_owned()
|
||||
}
|
||||
|
||||
fn write_delegation_chunks(
|
||||
@ -866,14 +853,6 @@ impl InputMediator for MediatedInbox {
|
||||
busy: true,
|
||||
});
|
||||
let submit = self.submit().get(&agent).cloned().unwrap_or_default();
|
||||
// Préfixe de délégation : c'est le **signal** qui dit à la cible
|
||||
// « ceci est une tâche IdeA, réponds via `idea_reply` (jamais en
|
||||
// texte) en renvoyant ce `ticket` ». Sans lui la cible traite la
|
||||
// ligne comme une invite humaine et répond dans le terminal, et
|
||||
// l'agent demandeur reste bloqué jusqu'au timeout. La tâche brute
|
||||
// reste dans le `Ticket` (mailbox/historique) ; seul le texte livré
|
||||
// au PTY porte le préfixe. Format aligné sur l'instruction injectée
|
||||
// dans le contexte de l'agent (`[IdeA · tâche de … · ticket …]`).
|
||||
let text = delegation_preamble(&ticket.requester, ticket_id, &ticket.task);
|
||||
// **Fix race cold-launch** : si l'agent est en démarrage à froid (inscrit
|
||||
// par `mark_starting` quand un pont MCP le libérera), ON DIFFÈRE la
|
||||
@ -916,6 +895,33 @@ impl InputMediator for MediatedInbox {
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
}
|
||||
|
||||
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
// Headless/system turns share the same FIFO and busy/liveness accounting as
|
||||
// terminal-delivered turns, but their prompt is sent through AgentSession::send.
|
||||
// Do not publish DelegationReady here: that would leak the task into the
|
||||
// human-facing terminal/write portal while the headless turn is already running.
|
||||
let now_ms = self.clock.now_ms();
|
||||
let started_turn = self.tracker.start_turn(
|
||||
agent,
|
||||
AgentBusyState::Busy {
|
||||
ticket: ticket_id,
|
||||
since_ms: now_ms,
|
||||
},
|
||||
);
|
||||
if started_turn {
|
||||
let stall_after_ms = self.stall().get(&agent).copied().flatten();
|
||||
self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
|
||||
if let Some(events) = &self.tracker.events {
|
||||
events.publish(DomainEvent::AgentBusyChanged {
|
||||
agent_id: agent,
|
||||
busy: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
}
|
||||
|
||||
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
|
||||
eprintln!(
|
||||
"[input-mediator] bind handle agent={agent} handle={}",
|
||||
@ -1162,17 +1168,10 @@ mod tests {
|
||||
assert_eq!(ready.len(), 1, "exactly one DelegationReady on turn start");
|
||||
let tid = TicketId::from_uuid(uuid::Uuid::from_u128(10));
|
||||
assert_eq!(ready[0].0, tid);
|
||||
// The delivered text carries the delegation prefix (the signal to answer via
|
||||
// `idea_reply`) followed by the raw task on the next line.
|
||||
assert_eq!(ready[0].1, delegation_preamble("User", tid, "do the thing"));
|
||||
assert!(
|
||||
ready[0].1.starts_with("[IdeA · tâche de User · ticket "),
|
||||
"delivered text must carry the delegation prefix: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ndo the thing"),
|
||||
"raw task must follow the prefix line: {:?}",
|
||||
ready[0].1 == "do the thing",
|
||||
"delivered text must be the raw task: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
|
||||
@ -1189,11 +1188,25 @@ mod tests {
|
||||
inbox.enqueue(a, ticket(12, "next turn"));
|
||||
let ready = bus.delegation_ready();
|
||||
assert_eq!(ready.len(), 2, "new turn ⇒ a new DelegationReady");
|
||||
assert_eq!(ready[1].1, "next turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_silent_marks_busy_without_delivery() {
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
|
||||
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
|
||||
let a = agent(1);
|
||||
|
||||
inbox.enqueue_silent(a, ticket(10, "headless turn"));
|
||||
|
||||
assert!(inbox.busy_state(a).is_busy());
|
||||
assert_eq!(bus.busy_events(), vec![(a, true)]);
|
||||
assert!(
|
||||
ready[1].1.ends_with("\nnext turn"),
|
||||
"second turn delivers its own prefixed task: {:?}",
|
||||
ready[1].1
|
||||
bus.delegation_ready().is_empty(),
|
||||
"headless bookkeeping must not leak a prompt to the terminal"
|
||||
);
|
||||
assert_eq!(inbox.mailbox.pending(&a), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1548,7 +1561,10 @@ mod tests {
|
||||
.await
|
||||
.expect("must NOT hang until the long timeout")
|
||||
.expect("a turn outcome, not a closed channel");
|
||||
assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
||||
);
|
||||
assert_eq!(
|
||||
inbox.mailbox().pending(&a),
|
||||
0,
|
||||
@ -1589,7 +1605,10 @@ mod tests {
|
||||
let pending = inbox.enqueue(a, ticket(10, "task"));
|
||||
inbox.turn_ended(a);
|
||||
// turn_ended a marqué Idle et armé la grâce ; on répond pendant la grâce.
|
||||
assert!(!inbox.busy_state(a).is_busy(), "turn_ended marks idle (grace armed)");
|
||||
assert!(
|
||||
!inbox.busy_state(a).is_busy(),
|
||||
"turn_ended marks idle (grace armed)"
|
||||
);
|
||||
inbox.mailbox().resolve(a, "in-time".to_owned()).unwrap();
|
||||
|
||||
let res = tokio::time::timeout(Duration::from_secs(2), pending)
|
||||
@ -1616,7 +1635,10 @@ mod tests {
|
||||
.await
|
||||
.expect("no hang")
|
||||
.expect("outcome");
|
||||
assert_eq!(res, domain::mailbox::TurnResolution::ReturnedToPromptNoReply);
|
||||
assert_eq!(
|
||||
res,
|
||||
domain::mailbox::TurnResolution::ReturnedToPromptNoReply
|
||||
);
|
||||
|
||||
// A late idea_reply now has no head to resolve ⇒ typed NoPendingRequest.
|
||||
assert!(
|
||||
@ -1685,11 +1707,7 @@ mod tests {
|
||||
1,
|
||||
"release_cold_start ⇒ exactement une DelegationReady"
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ncold task"),
|
||||
"le texte différé porte la tâche brute: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
assert_eq!(ready[0].1, "cold task");
|
||||
}
|
||||
|
||||
/// **Régression de la race cold-start (ordre INVERSE)** : `release_cold_start`
|
||||
@ -1724,11 +1742,7 @@ mod tests {
|
||||
1,
|
||||
"release AVANT enqueue ⇒ exactement une DelegationReady (jamais zéro, jamais deux)"
|
||||
);
|
||||
assert!(
|
||||
ready[0].1.ends_with("\ncold task"),
|
||||
"le texte livré porte la tâche brute: {:?}",
|
||||
ready[0].1
|
||||
);
|
||||
assert_eq!(ready[0].1, "cold task");
|
||||
assert!(
|
||||
inbox.busy_state(a).is_busy(),
|
||||
"le tour froid livré ⇒ l'agent reste Busy (idle viendra d'idea_reply)"
|
||||
@ -1798,7 +1812,7 @@ mod tests {
|
||||
1,
|
||||
"agent chaud ⇒ DelegationReady immédiate (zéro régression)"
|
||||
);
|
||||
assert!(ready[0].1.ends_with("\nwarm task"));
|
||||
assert_eq!(ready[0].1, "warm task");
|
||||
}
|
||||
|
||||
/// (c) Cas limite : si on ne marque PAS `starting` (l'orchestrateur n'arme pas le
|
||||
@ -1849,7 +1863,7 @@ mod tests {
|
||||
2,
|
||||
"second tour livré immédiatement (plus de gate)"
|
||||
);
|
||||
assert!(ready[1].1.ends_with("\nsecond"));
|
||||
assert_eq!(ready[1].1, "second");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
|
||||
@ -24,9 +24,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use domain::ports::{
|
||||
ConversationDetails, FileSystem, FsError, InspectError, SessionInspector,
|
||||
};
|
||||
use domain::ports::{ConversationDetails, FileSystem, FsError, InspectError, SessionInspector};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
|
||||
@ -203,7 +203,10 @@ mod tests {
|
||||
.ok_or_else(|| FsError::NotFound(path.0.clone()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files.lock().unwrap().insert(path.0.clone(), data.to_vec());
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.0.clone(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
@ -282,7 +285,8 @@ mod tests {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
// Baseline: one pre-existing turn end ⇒ must NOT fire for it.
|
||||
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let watcher =
|
||||
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
@ -307,20 +311,25 @@ mod tests {
|
||||
&transcript_path("engine-1.jsonl"),
|
||||
&format!("{}{}", line("turn_duration"), line("turn_duration")),
|
||||
);
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let watcher =
|
||||
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
|
||||
// No new turn end appended ⇒ no fire, ever (baseline absorbs the existing ones).
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(log.lock().unwrap().is_empty(), "baseline must absorb pre-existing turn ends");
|
||||
assert!(
|
||||
log.lock().unwrap().is_empty(),
|
||||
"baseline must absorb pre-existing turn ends"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_start_missing_folder_then_first_turn_fires() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let watcher =
|
||||
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let _h = watcher.watch(agent(1), None, cwd, cb);
|
||||
@ -337,7 +346,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn dropping_handle_stops_polling() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let watcher = ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let watcher =
|
||||
ClaudeTranscriptTurnWatcher::new(Arc::clone(&fs) as Arc<dyn FileSystem>, "/home/me");
|
||||
let (log, cb) = count_calls();
|
||||
let cwd = ProjectPath::new("/run/a").expect("path");
|
||||
let h = watcher.watch(agent(1), None, cwd, cb);
|
||||
@ -347,6 +357,9 @@ mod tests {
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
fs.set(&transcript_path("engine-1.jsonl"), &line("turn_duration"));
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(log.lock().unwrap().is_empty(), "a dropped handle stops firing");
|
||||
assert!(
|
||||
log.lock().unwrap().is_empty(),
|
||||
"a dropped handle stops firing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,12 +48,11 @@ pub use fs::LocalFileSystem;
|
||||
pub use git::Git2Repository;
|
||||
pub use id::UuidGenerator;
|
||||
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
|
||||
pub use inspector::{transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{
|
||||
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
|
||||
MemoryTransport, StdioTransport,
|
||||
pub use inspector::{
|
||||
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
||||
};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
|
||||
pub use orchestrator::{
|
||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||
REQUESTS_SUBDIR,
|
||||
@ -78,7 +77,6 @@ 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, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||
};
|
||||
|
||||
@ -121,25 +121,6 @@ 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;
|
||||
|
||||
/// **Server-defined** (JSON-RPC reserved range -32000..=-32099): the synchronous
|
||||
/// `idea_ask_agent` rendezvous hit the **absolute ceiling** while the target was
|
||||
/// **still actively working** (its transcript kept growing). **Distinct** from
|
||||
/// [`RENDEZVOUS_NO_REPLY`]: the target is *not* silent -- it simply outran the hard
|
||||
/// ceiling. It is therefore **non-retryable** (a blind re-ask would pile a second
|
||||
/// heavy turn on a target already mid-task); the caller must inspect the target's
|
||||
/// in-progress work/branch before re-soliciting lightly. The accompanying `data`
|
||||
/// carries `{ "code": "RENDEZVOUS_CEILING_ACTIVE", "retryable": false }`.
|
||||
pub const RENDEZVOUS_CEILING_ACTIVE: i32 = -32_002;
|
||||
}
|
||||
|
||||
/// Errors a [`Transport`] may surface.
|
||||
|
||||
@ -36,8 +36,6 @@ pub mod transport;
|
||||
pub use jsonrpc::{
|
||||
JsonRpcError, JsonRpcRequest, JsonRpcResponse, Transport, TransportError, JSONRPC_VERSION,
|
||||
};
|
||||
pub use server::{
|
||||
resolve_ask_rendezvous_ceiling, resolve_ask_rendezvous_timeout, AskActivityProbe, McpServer,
|
||||
};
|
||||
pub use server::McpServer;
|
||||
pub use tools::{catalogue, map_tool_call, tool_returns_reply, ToolDef, ToolMapError};
|
||||
pub use transport::{MemoryTransport, StdioTransport};
|
||||
|
||||
@ -4,8 +4,7 @@
|
||||
//! another entry door onto the *same* [`OrchestratorService::dispatch`]. Where the
|
||||
//! watcher reads a JSON file, this server reads a JSON-RPC `tools/call`; both build
|
||||
//! an [`OrchestratorCommand`] and return its [`OrchestratorOutcome`] unchanged. It
|
||||
//! invents **no semantics** and **never re-routes** the reply — for `idea_ask_agent`
|
||||
//! it returns `outcome.reply` inline, exactly what `dispatch` produced.
|
||||
//! invents **no semantics** and **never re-routes** replies.
|
||||
//!
|
||||
//! It implements the strict MCP minimum over JSON-RPC 2.0:
|
||||
//! - `initialize` → advertise protocol version + tool capability,
|
||||
@ -17,10 +16,8 @@
|
||||
//! at the composition root (M3), exactly like the watcher — no application logic is
|
||||
//! duplicated here.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use application::OrchestratorService;
|
||||
use domain::{DomainEvent, OrchestrationSource, Project};
|
||||
@ -36,72 +33,6 @@ use super::tools::{self, ToolMapError};
|
||||
/// The MCP protocol version this server speaks (advertised on `initialize`).
|
||||
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
|
||||
|
||||
/// **Inactivity window (silence budget)** for the synchronous `idea_ask_agent`
|
||||
/// rendezvous — the size of one *no-progress* probe window, NOT an absolute cap.
|
||||
///
|
||||
/// `idea_ask_agent` is the only tool whose `tools/call` *blocks* awaiting another
|
||||
/// agent's `idea_reply` (a synchronous rendezvous, resolved deep in the application
|
||||
/// layer). Rather than a flat outer timeout (which wrongly fired on a single long
|
||||
/// delegated turn that never returns to its prompt nor emits `turn_duration`), the
|
||||
/// adapter runs an **inactivity watchdog**: it races the dispatch against this window
|
||||
/// and, on each window expiry, probes whether the target made progress (its transcript
|
||||
/// grew). Progress ⇒ the window is re-armed (extension) up to the absolute
|
||||
/// [`ASK_RENDEZVOUS_CEILING`]; genuine silence ⇒ the no-reply error fires. 600 s by
|
||||
/// default, overridable per project via `IDEA_ASK_RENDEZVOUS_TIMEOUT_MS`. Every other
|
||||
/// tool (`idea_reply`, `idea_list_agents`, …) is left untouched — they don't rendezvous.
|
||||
///
|
||||
/// **Fallback (no activity probe).** When no probe is wired (legacy call sites, tests
|
||||
/// that shrink the bound), this degrades to the previous *flat* timeout: the first
|
||||
/// window expiry with no probe is treated as a no-reply expiry — zero regression.
|
||||
const ASK_RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// **Absolute ceiling (hard cap)** of the `idea_ask_agent` rendezvous, so an extending
|
||||
/// inactivity window can never park a `tools/call` forever even against a target that
|
||||
/// stays *busy* indefinitely. Reaching it while the target is still actively working
|
||||
/// yields the distinct, **non-retryable** [`rendezvous_ceiling_active_error`] (never a
|
||||
/// blind-retry suggestion). Generous by default (4 h), overridable per project via
|
||||
/// `IDEA_ASK_RENDEZVOUS_CEILING_MS`.
|
||||
const ASK_RENDEZVOUS_CEILING: Duration = Duration::from_secs(4 * 60 * 60);
|
||||
|
||||
/// A monotonic **activity probe** of a target agent, keyed by its display name.
|
||||
///
|
||||
/// Returns an opaque, monotonically non-decreasing **activity token** (in practice the
|
||||
/// cumulative byte size of the target's transcript `.jsonl` files): a strictly larger
|
||||
/// token between two probes means the target made progress (it is alive and working,
|
||||
/// even within a single long turn that never emits `turn_duration`). `None` ⇒ the
|
||||
/// target/transcript could not be resolved (cold start, unknown name) — treated as "no
|
||||
/// observable progress" so a truly stuck call still expires. Async because resolving the
|
||||
/// token reads the filesystem; built at the composition root (the only layer that knows
|
||||
/// the name→run-dir mapping), keeping this adapter free of `AgentId`.
|
||||
pub type AskActivityProbe =
|
||||
Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = Option<u64>> + Send>> + Send + Sync>;
|
||||
|
||||
/// Resolves the effective `idea_ask_agent` **inactivity window** from an optional
|
||||
/// configured override in **milliseconds**: `Some(ms)` ⇒ that window, `None`/absent ⇒
|
||||
/// the [`ASK_RENDEZVOUS_TIMEOUT`] default (600 s). `Some(0)` is treated as "no override"
|
||||
/// so a misconfigured zero can never collapse the window 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the effective `idea_ask_agent` **absolute ceiling** from an optional
|
||||
/// configured override in **milliseconds**: `Some(ms)` ⇒ that ceiling, `None`/absent ⇒
|
||||
/// the [`ASK_RENDEZVOUS_CEILING`] default (4 h). `Some(0)` is treated as "no override".
|
||||
/// Pure and unit-testable without wiring.
|
||||
#[must_use]
|
||||
pub fn resolve_ask_rendezvous_ceiling(override_ms: Option<u32>) -> Duration {
|
||||
match override_ms {
|
||||
Some(ms) if ms > 0 => Duration::from_millis(u64::from(ms)),
|
||||
_ => ASK_RENDEZVOUS_CEILING,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The IdeA MCP server: an entry adapter over [`OrchestratorService::dispatch`].
|
||||
///
|
||||
/// Cheap to clone the dependencies it holds; one instance serves one project's
|
||||
@ -130,21 +61,6 @@ pub struct McpServer {
|
||||
/// (fix race cold-launch, signal MCP). L'infra ne connaît pas `AgentId` : la
|
||||
/// composition root parse le `requester`. `None` ⇒ no-op.
|
||||
ready_sink: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
/// **Inactivity window (silence budget)** of the `idea_ask_agent` rendezvous (see
|
||||
/// [`ASK_RENDEZVOUS_TIMEOUT`]). One probe window, re-armed on observed progress.
|
||||
/// Carried per instance so tests can shrink it to a few milliseconds without
|
||||
/// polluting the public API; production code uses the resolved default.
|
||||
ask_rendezvous_timeout: Duration,
|
||||
/// **Absolute ceiling** of the `idea_ask_agent` rendezvous (see
|
||||
/// [`ASK_RENDEZVOUS_CEILING`]): the extending inactivity window never parks a call
|
||||
/// past this. Per instance so tests can shrink it; production uses the resolved
|
||||
/// default (4 h).
|
||||
ask_rendezvous_ceiling: Duration,
|
||||
/// Optional monotonic **activity probe** of the target (see [`AskActivityProbe`]),
|
||||
/// injected by the composition root. Drives window extension: progress between two
|
||||
/// probes re-arms the window. `None` ⇒ no observability ⇒ the rendezvous degrades to
|
||||
/// a single flat window (legacy behaviour, zero regression).
|
||||
activity_probe: Option<AskActivityProbe>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@ -159,9 +75,6 @@ impl McpServer {
|
||||
events: None,
|
||||
requester: String::new(),
|
||||
ready_sink: None,
|
||||
ask_rendezvous_timeout: ASK_RENDEZVOUS_TIMEOUT,
|
||||
ask_rendezvous_ceiling: ASK_RENDEZVOUS_CEILING,
|
||||
activity_probe: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,51 +115,9 @@ impl McpServer {
|
||||
events: self.events.clone(),
|
||||
requester: requester.into(),
|
||||
ready_sink: self.ready_sink.clone(),
|
||||
ask_rendezvous_timeout: self.ask_rendezvous_timeout,
|
||||
ask_rendezvous_ceiling: self.ask_rendezvous_ceiling,
|
||||
activity_probe: self.activity_probe.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the `idea_ask_agent` **inactivity window** (silence budget, default
|
||||
/// [`ASK_RENDEZVOUS_TIMEOUT`] = 600 s).
|
||||
///
|
||||
/// 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. This is the size of one *no-progress* window: as long as the activity
|
||||
/// probe sees the target advancing, the window is re-armed up to the absolute
|
||||
/// [`ASK_RENDEZVOUS_CEILING`]. Absent override ⇒ the 600 s default. Tests also use it
|
||||
/// to shrink the window to milliseconds.
|
||||
#[must_use]
|
||||
pub fn with_ask_rendezvous_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.ask_rendezvous_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the `idea_ask_agent` **absolute ceiling** (hard cap, default
|
||||
/// [`ASK_RENDEZVOUS_CEILING`] = 4 h) past which the extending inactivity window never
|
||||
/// parks a call, even against a perpetually-busy target. The composition root resolves
|
||||
/// an optional override with [`resolve_ask_rendezvous_ceiling`]. Tests use it to shrink
|
||||
/// the ceiling to milliseconds. Additive: callers that don't set it keep the default.
|
||||
#[must_use]
|
||||
pub fn with_ask_rendezvous_ceiling(mut self, ceiling: Duration) -> Self {
|
||||
self.ask_rendezvous_ceiling = ceiling;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches the monotonic **activity probe** (see [`AskActivityProbe`]) driving the
|
||||
/// inactivity-window extension: between two window expiries, a strictly larger token
|
||||
/// means the target is alive and working, so the window is re-armed instead of
|
||||
/// expiring. Built by the composition root (the only layer mapping a display name to a
|
||||
/// run-dir transcript). Additive: without a probe the rendezvous degrades to a single
|
||||
/// flat window (legacy behaviour, zero regression).
|
||||
#[must_use]
|
||||
pub fn with_activity_probe(mut self, probe: AskActivityProbe) -> Self {
|
||||
self.activity_probe = Some(probe);
|
||||
self
|
||||
}
|
||||
|
||||
/// Serves JSON-RPC messages from `transport`, tagging every processed
|
||||
/// `tools/call` with `requester` as the delegating agent (cadrage v5 §1.4).
|
||||
///
|
||||
@ -264,16 +135,14 @@ impl McpServer {
|
||||
/// method yields a JSON-RPC error response, **never a panic** and never a
|
||||
/// dropped connection. Notifications (no `id`) are processed without a reply.
|
||||
///
|
||||
/// **Full-duplex / non-blocking (anti-wedge).** A request whose handling blocks —
|
||||
/// notably `idea_ask_agent`, which awaits another agent's `idea_reply` in a
|
||||
/// synchronous rendezvous — must **not** stall the read loop: otherwise a single
|
||||
/// in-flight ask parks the whole connection and every later call (even a
|
||||
/// rendezvous-free `idea_list_agents`) is never even read. So each inbound message
|
||||
/// is handled on its own spawned task that owns a cheap per-call clone of the
|
||||
/// server; finished responses are funnelled back through an `mpsc` channel and
|
||||
/// written by the same loop. Responses carry their JSON-RPC `id`, so out-of-order
|
||||
/// completion is fine (the full-duplex bridge correlates by id). Notifications
|
||||
/// (`handle_raw` ⇒ `None`) emit nothing.
|
||||
/// **Full-duplex / non-blocking.** A slow request must **not** stall the read
|
||||
/// loop: otherwise a single long-running tool call parks the whole connection and
|
||||
/// every later call is never even read. So each inbound message is handled on its
|
||||
/// own spawned task that owns a cheap per-call clone of the server; finished
|
||||
/// responses are funnelled back through an `mpsc` channel and written by the same
|
||||
/// loop. Responses carry their JSON-RPC `id`, so out-of-order completion is fine
|
||||
/// (the full-duplex bridge correlates by id). Notifications (`handle_raw` ⇒
|
||||
/// `None`) emit nothing.
|
||||
pub async fn serve<T: Transport>(&self, transport: &mut T) {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Option<Vec<u8>>>();
|
||||
// Number of spawned handler tasks not yet observed on `rx`. While `> 0`, an
|
||||
@ -433,8 +302,7 @@ impl McpServer {
|
||||
|
||||
/// `tools/call`: map the tool to an [`OrchestratorCommand`], `dispatch` it, and
|
||||
/// fold the [`OrchestratorOutcome`](application::OrchestratorOutcome) into an MCP
|
||||
/// tool result. The `idea_ask_agent` reply is returned **inline** — never
|
||||
/// re-routed.
|
||||
/// tool result.
|
||||
async fn tools_call(&self, params: Value) -> Result<Value, JsonRpcError> {
|
||||
let name = params
|
||||
.get("name")
|
||||
@ -445,8 +313,6 @@ impl McpServer {
|
||||
|
||||
// Diagnostics begin beacon (best-effort, jamais le corps task/result) : trace
|
||||
// l'entrée d'un `tools/call`, son tool, le peer demandeur et la cible/longueurs.
|
||||
// C'est l'amorce de la trace `[mcp]` begin → (armed) → (expired) → end qui
|
||||
// permet de voir un `idea_ask_agent` entrer sans jamais ressortir (blocage).
|
||||
let started = Instant::now();
|
||||
let requester_label = if self.requester.is_empty() {
|
||||
"mcp".to_owned()
|
||||
@ -467,8 +333,8 @@ impl McpServer {
|
||||
task_len={task_len}",
|
||||
);
|
||||
|
||||
// `idea_reply` needs the connected peer's identity as `from`; every other tool
|
||||
// ignores `requester`. The handshake-provided requester is the source of truth.
|
||||
// The handshake-provided requester is still passed to the mapper for tools that
|
||||
// need peer identity.
|
||||
let command = match tools::map_tool_call(&name, &arguments, &self.requester) {
|
||||
Ok(command) => command,
|
||||
Err(e) => {
|
||||
@ -481,39 +347,7 @@ impl McpServer {
|
||||
}
|
||||
};
|
||||
|
||||
// `idea_ask_agent` is the only tool that blocks on a synchronous rendezvous (the
|
||||
// target's `idea_reply`). Bound *only* that path with the **inactivity watchdog**
|
||||
// (server-side safety net, see [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]):
|
||||
// race the dispatch against an inactivity window; on each window expiry, probe
|
||||
// whether the target progressed (transcript grew). Progress ⇒ re-arm the window
|
||||
// (extension) up to the absolute ceiling; genuine silence ⇒ the retryable no-reply
|
||||
// error; ceiling reached while still active ⇒ the distinct non-retryable ceiling
|
||||
// error. Every other tool dispatches unbounded — they never rendezvous.
|
||||
let dispatch = self.service.dispatch(&self.project, command);
|
||||
let result = if name == "idea_ask_agent" {
|
||||
application::diag!(
|
||||
"[mcp] ask armed requester={requester_label} target={arg_target} \
|
||||
task_len={task_len} window_ms={} ceiling_ms={}",
|
||||
self.ask_rendezvous_timeout.as_millis(),
|
||||
self.ask_rendezvous_ceiling.as_millis(),
|
||||
);
|
||||
match self
|
||||
.run_ask_rendezvous(dispatch, &arg_target, &requester_label, started)
|
||||
.await
|
||||
{
|
||||
// RISQUE #1 (Architect) : sur NoReply/CeilingActive, le futur `dispatch` a déjà
|
||||
// été **droppé** dans `run_ask_rendezvous` (il le possède et le pin), ce Drop
|
||||
// libère le `BusyTurnGuard` RAII et purge le `Busy`/ticket de la cible (aucune
|
||||
// fuite `Busy`).
|
||||
AskRendezvousOutcome::Resolved(result) => result,
|
||||
AskRendezvousOutcome::NoReply => return Err(rendezvous_no_reply_error(&arg_target)),
|
||||
AskRendezvousOutcome::CeilingActive => {
|
||||
return Err(rendezvous_ceiling_active_error(&arg_target))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch.await
|
||||
};
|
||||
let result = self.service.dispatch(&self.project, command).await;
|
||||
|
||||
// Surface the processed delegation on the bus, tagged as the MCP door — the
|
||||
// twin of the file watcher's publish. `ok` mirrors the dispatch outcome; the
|
||||
@ -572,77 +406,6 @@ impl McpServer {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the `idea_ask_agent` synchronous rendezvous under the **inactivity
|
||||
/// watchdog** (cf. [`ASK_RENDEZVOUS_TIMEOUT`] / [`ASK_RENDEZVOUS_CEILING`]).
|
||||
///
|
||||
/// Thin adapter over the testable free function [`run_inactivity_watchdog`]: it
|
||||
/// supplies this server's window, ceiling and (optional) activity probe, and maps the
|
||||
/// watchdog verdict to an [`AskRendezvousOutcome`]. With no probe wired the watchdog
|
||||
/// degrades to a single flat window (legacy behaviour, zero regression).
|
||||
async fn run_ask_rendezvous(
|
||||
&self,
|
||||
dispatch: impl Future<Output = Result<application::OrchestratorOutcome, application::AppError>>,
|
||||
target: &str,
|
||||
requester_label: &str,
|
||||
started: Instant,
|
||||
) -> AskRendezvousOutcome {
|
||||
let probe = self.activity_probe.clone();
|
||||
let target_owned = target.to_owned();
|
||||
let probe_fn = move || {
|
||||
let probe = probe.clone();
|
||||
let target = target_owned.clone();
|
||||
async move {
|
||||
match &probe {
|
||||
Some(p) => p(target).await,
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
};
|
||||
// DRY : on réutilise l'**unique** implémentation du watchdog (couche application),
|
||||
// la même que celle qui gouverne réellement le tour délégué dans le service. Ici
|
||||
// c'est le filet de sécurité externe de l'adaptateur MCP.
|
||||
let window_ms = self.ask_rendezvous_timeout.as_millis();
|
||||
let ceiling_ms = self.ask_rendezvous_ceiling.as_millis();
|
||||
let (t_ext, r_ext) = (target.to_owned(), requester_label.to_owned());
|
||||
let (t_exp, r_exp) = (target.to_owned(), requester_label.to_owned());
|
||||
let (t_ceil, r_ceil) = (target.to_owned(), requester_label.to_owned());
|
||||
match application::run_inactivity_watchdog(
|
||||
dispatch,
|
||||
self.ask_rendezvous_timeout,
|
||||
self.ask_rendezvous_ceiling,
|
||||
started,
|
||||
self.activity_probe.is_some(),
|
||||
probe_fn,
|
||||
move |elapsed| {
|
||||
application::diag!(
|
||||
"[mcp] ask window extended (signe de vie) requester={r_ext} target={t_ext} \
|
||||
window_ms={window_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
move |elapsed| {
|
||||
application::diag!(
|
||||
"[mcp] ask EXPIRED (no progress) requester={r_exp} target={t_exp} \
|
||||
window_ms={window_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
move |elapsed| {
|
||||
application::diag!(
|
||||
"[mcp] ask CEILING active requester={r_ceil} target={t_ceil} \
|
||||
ceiling_ms={ceiling_ms} elapsed_ms={}",
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
application::WatchdogOutcome::Resolved(r) => AskRendezvousOutcome::Resolved(r),
|
||||
application::WatchdogOutcome::NoReply => AskRendezvousOutcome::NoReply,
|
||||
application::WatchdogOutcome::CeilingActive => AskRendezvousOutcome::CeilingActive,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an MCP `tools/call` result with a single text content block.
|
||||
@ -653,71 +416,6 @@ 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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the inactivity-watchdog rendezvous ([`McpServer::run_ask_rendezvous`]),
|
||||
/// folded into a `tools/call` result by the caller.
|
||||
enum AskRendezvousOutcome {
|
||||
/// The dispatch completed (success or a typed `AppError`) — returned unchanged.
|
||||
Resolved(Result<application::OrchestratorOutcome, application::AppError>),
|
||||
/// The inactivity window elapsed with no observable progress ⇒ the retryable
|
||||
/// [`rendezvous_no_reply_error`]. Also the flat-window fallback when no probe is wired.
|
||||
NoReply,
|
||||
/// The absolute ceiling was reached while the target was still actively working ⇒ the
|
||||
/// distinct, non-retryable [`rendezvous_ceiling_active_error`].
|
||||
CeilingActive,
|
||||
}
|
||||
|
||||
/// Builds the JSON-RPC error returned when the `idea_ask_agent` rendezvous reaches the
|
||||
/// **absolute ceiling** while the target is **still actively working** (its transcript
|
||||
/// kept growing across probes).
|
||||
///
|
||||
/// **Distinct** from [`rendezvous_no_reply_error`]: the target is not silent, so a blind
|
||||
/// retry would stack a second heavy turn on a target already mid-task. Carried under the
|
||||
/// dedicated [`error_codes::RENDEZVOUS_CEILING_ACTIVE`] code with `data.retryable = false`
|
||||
/// so a caller can branch without parsing the message.
|
||||
fn rendezvous_ceiling_active_error(target: &str) -> JsonRpcError {
|
||||
let message = format!(
|
||||
"target {target} is still actively working but the rendezvous ceiling was reached; \
|
||||
do not retry blindly — check the target's in-progress work/branch (it may still \
|
||||
finish and call idea_reply), then re-solicit lightly if needed"
|
||||
);
|
||||
JsonRpcError {
|
||||
code: error_codes::RENDEZVOUS_CEILING_ACTIVE,
|
||||
message,
|
||||
data: Some(json!({
|
||||
"code": "RENDEZVOUS_CEILING_ACTIVE",
|
||||
"retryable": false,
|
||||
"target": target,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`ToolMapError`] to the right JSON-RPC error code.
|
||||
fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
||||
match err {
|
||||
@ -729,49 +427,3 @@ fn map_err_to_jsonrpc(err: ToolMapError) -> JsonRpcError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
// NOTE: the inactivity-watchdog **algorithm** now lives in (and is unit-tested by)
|
||||
// the `application` crate (`application::run_inactivity_watchdog`), the single source
|
||||
// of truth that also governs the delegated turn. This adapter only maps its verdict to
|
||||
// an `AskRendezvousOutcome`, so we keep here only the adapter-specific concerns: the
|
||||
// error builders and the override resolvers.
|
||||
|
||||
/// The two error builders carry distinct JSON-RPC codes and the documented
|
||||
/// `data.retryable` flags (no-reply ⇒ retryable; ceiling-active ⇒ not).
|
||||
#[test]
|
||||
fn error_builders_carry_distinct_codes_and_retryable_flags() {
|
||||
let no_reply = rendezvous_no_reply_error("architect");
|
||||
assert_eq!(no_reply.code, error_codes::RENDEZVOUS_NO_REPLY);
|
||||
assert_eq!(no_reply.data.as_ref().unwrap()["retryable"], serde_json::json!(true));
|
||||
assert_eq!(
|
||||
no_reply.data.as_ref().unwrap()["code"],
|
||||
serde_json::json!("TARGET_RETURNED_NO_REPLY")
|
||||
);
|
||||
|
||||
let ceiling = rendezvous_ceiling_active_error("architect");
|
||||
assert_eq!(ceiling.code, error_codes::RENDEZVOUS_CEILING_ACTIVE);
|
||||
assert_eq!(ceiling.data.as_ref().unwrap()["retryable"], serde_json::json!(false));
|
||||
assert_eq!(
|
||||
ceiling.data.as_ref().unwrap()["code"],
|
||||
serde_json::json!("RENDEZVOUS_CEILING_ACTIVE")
|
||||
);
|
||||
assert_ne!(no_reply.code, ceiling.code);
|
||||
}
|
||||
|
||||
/// The resolvers honour an override and fall back to their finite defaults; `Some(0)`
|
||||
/// is treated as "no override" (never an instant collapse).
|
||||
#[test]
|
||||
fn resolvers_apply_override_else_finite_default() {
|
||||
assert_eq!(resolve_ask_rendezvous_timeout(Some(1234)), Duration::from_millis(1234));
|
||||
assert_eq!(resolve_ask_rendezvous_timeout(Some(0)), ASK_RENDEZVOUS_TIMEOUT);
|
||||
assert_eq!(resolve_ask_rendezvous_timeout(None), ASK_RENDEZVOUS_TIMEOUT);
|
||||
assert_eq!(resolve_ask_rendezvous_ceiling(Some(9999)), Duration::from_millis(9999));
|
||||
assert_eq!(resolve_ask_rendezvous_ceiling(Some(0)), ASK_RENDEZVOUS_CEILING);
|
||||
assert_eq!(resolve_ask_rendezvous_ceiling(None), ASK_RENDEZVOUS_CEILING);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,12 +8,17 @@
|
||||
//! validation — the very same path the filesystem watcher takes. No tool
|
||||
//! validates anything itself; no tool reaches a use case directly.
|
||||
//!
|
||||
//! ## Agent discovery (`idea_list_agents`)
|
||||
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
|
||||
//!
|
||||
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
|
||||
//! served through the **same** `OrchestratorService::dispatch` as every other tool
|
||||
//! (no use-case is reached directly). Its success carries the agent list inline as
|
||||
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
|
||||
//!
|
||||
//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an
|
||||
//! ergonomic IdeA entrypoint: the application orchestrator drives the target's
|
||||
//! structured/headless session and returns the target turn's `Final` inline. The
|
||||
//! target never receives a model-managed ticket and never has to call `idea_reply`.
|
||||
|
||||
use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
|
||||
use serde_json::{json, Value};
|
||||
@ -44,9 +49,8 @@ pub enum ToolMapError {
|
||||
}
|
||||
|
||||
/// Whether a successful call of `tool` carries an inline reply payload back to the
|
||||
/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
|
||||
/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to
|
||||
/// the awaiting requester, not echoed inline), so it is **not** in this set.
|
||||
/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the
|
||||
/// target agent's captured final answer.
|
||||
#[must_use]
|
||||
pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
matches!(
|
||||
@ -79,35 +83,20 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_ask_agent",
|
||||
description: "Ask another IdeA agent a task and wait for its reply (synchronous \
|
||||
inter-agent rendezvous). Returns the target agent's answer inline.",
|
||||
description: "Ask another IdeA agent to handle a task and wait for its final answer. \
|
||||
IdeA launches or reattaches the target through its structured/headless \
|
||||
profile, captures the target turn's Final, and returns it inline. The \
|
||||
target does not call a reply tool or manage tickets.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Target agent display name." },
|
||||
"task": { "type": "string", "description": "The task/message to send." }
|
||||
"task": { "type": "string", "description": "Task or question to send to the target agent." }
|
||||
},
|
||||
"required": ["target", "task"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_reply",
|
||||
description: "Render the result of the task you are currently processing (the one IdeA \
|
||||
delegated to you as `[IdeA · tâche … · ticket <id>]`). Call this — never \
|
||||
answer in plain text — so IdeA can hand your answer back to the agent that \
|
||||
asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \
|
||||
reply exactly, even when you handle several requests.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": { "type": "string", "description": "The result/answer to deliver to the requester." },
|
||||
"ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
|
||||
},
|
||||
"required": ["result"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_launch_agent",
|
||||
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
||||
@ -275,9 +264,9 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
///
|
||||
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
|
||||
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
|
||||
/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation
|
||||
/// uses the handshake identity, never a model-managed value. Every other tool
|
||||
/// ignores it.
|
||||
/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing
|
||||
/// and cycle detection. Self-keyed tools also use it as their identity; the model
|
||||
/// never supplies another agent's id.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
|
||||
@ -303,27 +292,6 @@ pub fn map_tool_call(
|
||||
request_type: Some("agent.list".to_owned()),
|
||||
..base()
|
||||
},
|
||||
"idea_ask_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.message".to_owned()),
|
||||
// The **requester** is the connected peer's handshake identity (never a
|
||||
// tool argument), injected as `requestedBy` so `validate` can route the
|
||||
// ask on the A↔B conversation and feed the wait-for guard (cadrage C3).
|
||||
requested_by: Some(requester.to_owned()),
|
||||
target_agent: s("target"),
|
||||
task: s("task"),
|
||||
..base()
|
||||
},
|
||||
"idea_reply" => OrchestratorRequest {
|
||||
request_type: Some("agent.reply".to_owned()),
|
||||
// `from` is the handshake identity, not a tool argument: inject the peer's
|
||||
// requester id as `requestedBy` so `validate` builds `Reply { from, .. }`.
|
||||
requested_by: Some(requester.to_owned()),
|
||||
// The agent echoes the `ticket` it received in the `[IdeA · … · ticket
|
||||
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
|
||||
ticket: s("ticket"),
|
||||
result: s("result"),
|
||||
..base()
|
||||
},
|
||||
"idea_launch_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.run".to_owned()),
|
||||
target_agent: s("target"),
|
||||
@ -333,6 +301,13 @@ pub fn map_tool_call(
|
||||
node_id: parse_node_id(args.get("nodeId")),
|
||||
..base()
|
||||
},
|
||||
"idea_ask_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.message".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
target_agent: s("target"),
|
||||
task: s("task"),
|
||||
..base()
|
||||
},
|
||||
"idea_stop_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.stop".to_owned()),
|
||||
target_agent: s("target"),
|
||||
@ -408,33 +383,7 @@ pub fn map_tool_call(
|
||||
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
|
||||
};
|
||||
|
||||
let command = request.validate()?;
|
||||
|
||||
// Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous :
|
||||
// `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement —
|
||||
// jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]`
|
||||
// begin avec la commande effectivement construite (kind/target/requester).
|
||||
match name {
|
||||
"idea_ask_agent" => application::diag!(
|
||||
"[mcp] mapped kind=ask_agent target={} task_len={} requester={}",
|
||||
s("target").as_deref().unwrap_or("-"),
|
||||
s("task").map_or(0, |t| t.len()),
|
||||
if requester.is_empty() { "-" } else { requester },
|
||||
),
|
||||
"idea_reply" => application::diag!(
|
||||
"[mcp] mapped kind=reply result_len={} ticket={} requester={}",
|
||||
s("result").map_or(0, |r| r.len()),
|
||||
if s("ticket").is_some() {
|
||||
"present"
|
||||
} else {
|
||||
"absent"
|
||||
},
|
||||
if requester.is_empty() { "-" } else { requester },
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(command)
|
||||
request.validate().map_err(ToolMapError::Invalid)
|
||||
}
|
||||
|
||||
/// An all-`None` request to spread over; keeps each arm above to just its fields.
|
||||
@ -487,21 +436,39 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agent_maps_to_ask_command() {
|
||||
let cmd = map(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
||||
)
|
||||
.unwrap();
|
||||
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
|
||||
let requester = uuid::Uuid::from_u128(42).to_string();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
||||
&requester,
|
||||
)
|
||||
.unwrap(),
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "Architect".to_owned(),
|
||||
task: "Analyse §17".to_owned(),
|
||||
// `map` passes an empty requester ⇒ no machine requester carried.
|
||||
requester: None,
|
||||
requester: Some(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(&requester).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
||||
"",
|
||||
),
|
||||
Ok(OrchestratorCommand::AskAgent {
|
||||
target: "Architect".to_owned(),
|
||||
task: "Analyse §17".to_owned(),
|
||||
requester: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ),
|
||||
Err(ToolMapError::UnknownTool("idea_reply".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -553,7 +520,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_surfaces_validation_error() {
|
||||
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
|
||||
let err = map("idea_update_context", &json!({ "target": "Architect" }));
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
@ -574,84 +541,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn non_object_arguments_are_rejected() {
|
||||
let err = map("idea_ask_agent", &json!("nope"));
|
||||
let err = map("idea_launch_agent", &json!("nope"));
|
||||
assert_eq!(
|
||||
err,
|
||||
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
|
||||
Err(ToolMapError::BadArguments("idea_launch_agent".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_maps_with_handshake_requester_as_from() {
|
||||
// `from` comes from the handshake requester, NOT a tool argument.
|
||||
let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::Reply {
|
||||
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
|
||||
ticket: None,
|
||||
result: "the answer".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_echoes_ticket_when_provided() {
|
||||
let tkt = "22222222-2222-2222-2222-222222222222";
|
||||
let cmd = map_tool_call(
|
||||
"idea_reply",
|
||||
&json!({ "result": "done", "ticket": tkt }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::Reply {
|
||||
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
|
||||
ticket: Some(domain::mailbox::TicketId::from_uuid(
|
||||
uuid::Uuid::parse_str(tkt).unwrap()
|
||||
)),
|
||||
result: "done".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agent_injects_handshake_requester() {
|
||||
let cmd = map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "B", "task": "go" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "B".to_owned(),
|
||||
task: "go".to_owned(),
|
||||
requester: Some(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_without_result_is_a_validation_error() {
|
||||
let err = map_tool_call("idea_reply", &json!({}), REQ);
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_with_blank_or_missing_requester_is_a_validation_error() {
|
||||
// No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic).
|
||||
let err = map_tool_call("idea_reply", &json!({ "result": "x" }), "");
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
// A non-uuid requester is rejected too.
|
||||
let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid");
|
||||
assert!(matches!(err2, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_read_maps_with_requester_party() {
|
||||
// Global (no target) with a uuid requester ⇒ Agent party.
|
||||
@ -721,8 +617,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_is_not_an_inline_reply_tool() {
|
||||
// ACK only: the result is routed to the awaiting requester, not echoed.
|
||||
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
|
||||
assert!(tool_returns_reply("idea_ask_agent"));
|
||||
assert!(!tool_returns_reply("idea_reply"));
|
||||
}
|
||||
|
||||
|
||||
@ -165,9 +165,9 @@ impl CodexExecSession {
|
||||
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
|
||||
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
|
||||
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
|
||||
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
|
||||
/// <prompt>`.
|
||||
/// - Reprise (id connu) : `codex exec --json
|
||||
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root> resume
|
||||
/// <thread_id> <prompt>`.
|
||||
///
|
||||
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
|
||||
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
|
||||
@ -178,10 +178,7 @@ impl CodexExecSession {
|
||||
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
|
||||
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
|
||||
let mut args = vec!["exec".to_owned()];
|
||||
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
|
||||
args.push("resume".to_owned());
|
||||
args.push(id.clone());
|
||||
}
|
||||
let conversation_id = self.conversation_id.lock().expect("mutex sain").clone();
|
||||
args.push("--json".to_owned());
|
||||
args.push("--skip-git-repo-check".to_owned());
|
||||
args.push("--sandbox".to_owned());
|
||||
@ -190,6 +187,10 @@ impl CodexExecSession {
|
||||
args.push("--add-dir".to_owned());
|
||||
args.push(root.clone());
|
||||
}
|
||||
if let Some(id) = conversation_id {
|
||||
args.push("resume".to_owned());
|
||||
args.push(id);
|
||||
}
|
||||
args.push(prompt.to_owned());
|
||||
SpawnLine {
|
||||
command: self.command.clone(),
|
||||
|
||||
@ -1020,8 +1020,8 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
/// Idem Codex : `codex exec resume <thread_id> --json --skip-git-repo-check <prompt>`
|
||||
/// (format RÉEL : sous-commande `resume`, pas un flag `--resume`).
|
||||
/// Idem Codex : `codex exec --json --skip-git-repo-check resume <thread_id> <prompt>`.
|
||||
/// Les options globales de `exec` doivent précéder la sous-commande `resume`.
|
||||
#[tokio::test]
|
||||
async fn codex_resume_command_carries_resume_subcommand() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
@ -1045,6 +1045,21 @@ mod tests {
|
||||
assert!(args.contains(&"--json"), "vu: {args:?}");
|
||||
assert!(args.contains(&"--skip-git-repo-check"), "vu: {args:?}");
|
||||
assert!(args.contains(&"vas-y"), "vu: {args:?}");
|
||||
let resume_pos = args.iter().position(|a| *a == "resume").expect("resume");
|
||||
let id_pos = args.iter().position(|a| *a == "cx-id").expect("id");
|
||||
let json_pos = args.iter().position(|a| *a == "--json").expect("json");
|
||||
let skip_pos = args
|
||||
.iter()
|
||||
.position(|a| *a == "--skip-git-repo-check")
|
||||
.expect("skip");
|
||||
assert!(
|
||||
json_pos < resume_pos && resume_pos < id_pos,
|
||||
"exec options must precede resume, then the session id, vu: {args:?}"
|
||||
);
|
||||
assert!(
|
||||
skip_pos < resume_pos && resume_pos < id_pos,
|
||||
"exec options must precede resume, then the session id, vu: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
@ -1311,7 +1326,7 @@ mod tests {
|
||||
// d'écriture Codex : la commande générée porte EXACTEMENT
|
||||
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
|
||||
// --add-dir, <project-root>, <prompt>]
|
||||
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
|
||||
// (`resume <id>` après les options `exec` pour une reprise). Le flag `--ask-for-approval never`
|
||||
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
|
||||
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
|
||||
// qu'aucune régression ne réintroduise un flag inconnu de la sous-commande.
|
||||
@ -1358,9 +1373,9 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
}
|
||||
|
||||
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
|
||||
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
|
||||
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
|
||||
/// REPRISE (seed d'id) : argv EXACT `[exec, --json, --skip-git-repo-check,
|
||||
/// --sandbox, workspace-write, --add-dir, <project-root>, resume, <id>, <prompt>]`.
|
||||
/// Toujours pas de `--ask-for-approval`.
|
||||
#[tokio::test]
|
||||
async fn codex_resume_command_carries_exact_args() {
|
||||
let (cmd, argv) = make_recording_fake(&[
|
||||
@ -1383,17 +1398,17 @@ mod tests {
|
||||
args,
|
||||
vec![
|
||||
"exec",
|
||||
"resume",
|
||||
"cx-id",
|
||||
"--json",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--add-dir",
|
||||
"/project/root",
|
||||
"resume",
|
||||
"cx-id",
|
||||
"vas-y",
|
||||
],
|
||||
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
|
||||
"argv reprise doit être exact (options exec avant resume, sans --ask-for-approval), vu: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_file(&cmd);
|
||||
let _ = std::fs::remove_file(&argv);
|
||||
|
||||
@ -286,4 +286,3 @@ impl EmbedderProfileStore for FsEmbedderProfileStore {
|
||||
FsEmbedderProfileStore::delete(self, id).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user