agent conversation fix
This commit is contained in:
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user