eureka-state: typed TooLarge, typed InvalidInput, and the pins Soul found missing
- The client refuses a request whose encoded size exceeds what one RUDP send
carries (65,507 less the 36-byte header and the 6-byte channel id), before
connecting, as ClientError::TooLarge { bytes, limit }. It was reported as
a retryable Unavailable ("Message too long"). The MCP tools surface it as
isError with {"error":"TooLarge","bytes","limit"}.
- Arguments that fail to deserialize are still a tool error, now with the
same typed body as every other one: {"error":"InvalidInput","detail"}.
- A non-UTF-8 EUREKA_INSTANCE or HUGINN_ENDPOINT is reported as not valid
UTF-8 and not as unset.
- Tests: the schema walk asserts no $id or $schema below any tool's root; the
15 s timeout is measured against a silent daemon; the semantic part of a
query is observed on the wire; Internal's and TooLarge's bodies and the
InvalidInput rewrite are pinned; attribution is refused through the MCP
admit; oversize is TooLarge at and one past the limit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1d3f917d9e
commit
890a00b0de
4 changed files with 348 additions and 27 deletions
|
|
@ -23,7 +23,7 @@ use std::time::{Duration, Instant};
|
|||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use cultnet_rs::{
|
||||
CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage,
|
||||
CultNetRudpSocketTransportConnection, fetch_content,
|
||||
CultNetRudpSocketTransportConnection, CultNetWireContract, encode_cultnet_message_to_vec, fetch_content,
|
||||
};
|
||||
use huginn_mind::envelope::{OperationFailure, decode_response, encode_request};
|
||||
use huginn_mind::epiphany_pipeline::Slug;
|
||||
|
|
@ -33,15 +33,25 @@ use huginn_mind::{HuginnMindRequest, HuginnMindResponse, MAX_DEFERRED_BODY_BYTES
|
|||
const MESSAGE_ID: &str = "eureka-state-call";
|
||||
const POLL: Duration = Duration::from_millis(2);
|
||||
|
||||
/// The largest encoded message one send carries. The client's RUDP session
|
||||
/// does not fragment (`max_fragment_bytes` is unset), so a message is one UDP
|
||||
/// datagram: at most 65,507 bytes over IPv4 (65,535 less the 8-byte UDP and
|
||||
/// 20-byte IP headers), less RUDP's 36-byte fixed header (`RUDP_FIXED_HEADER_BYTES`
|
||||
/// in `cultnet-rs`, private there) and the 6-byte `schema` channel id. A larger
|
||||
/// message makes the operating system refuse the send ("Message too long").
|
||||
pub const MAX_REQUEST_BYTES: usize = 65_507 - 36 - "schema".len();
|
||||
|
||||
/// Why a call produced no answer. `Unavailable` is the transport's failure and
|
||||
/// the only one worth retrying: the daemon could not be reached, went quiet
|
||||
/// past the call's deadline, or answered out of protocol. `Rejected` is the
|
||||
/// daemon refusing the envelope itself, which a retry of the same call cannot
|
||||
/// change.
|
||||
/// daemon refusing the envelope itself, and `TooLarge` is the client refusing
|
||||
/// a request that no single send can carry, before sending it; a retry of the
|
||||
/// same call cannot change either.
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
Unavailable { endpoint: SocketAddr, detail: String },
|
||||
Rejected { endpoint: SocketAddr, code: String, detail: String },
|
||||
TooLarge { bytes: usize, limit: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
|
|
@ -53,6 +63,9 @@ impl fmt::Display for ClientError {
|
|||
Self::Rejected { endpoint, code, detail } => {
|
||||
write!(f, "the Huginn daemon at rudp://{endpoint} rejected the request: {code}: {detail}")
|
||||
}
|
||||
Self::TooLarge { bytes, limit } => {
|
||||
write!(f, "the request encodes to {bytes} bytes and one send carries at most {limit}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,8 +109,15 @@ impl HuginnClient {
|
|||
let deadline = Instant::now() + self.timeout;
|
||||
let unavailable =
|
||||
|error: anyhow::Error| ClientError::Unavailable { endpoint: self.endpoint, detail: format!("{error:#}") };
|
||||
let message = encode_request(MESSAGE_ID, &request, None).map_err(unavailable)?;
|
||||
let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
|
||||
.map_err(unavailable)?
|
||||
.len();
|
||||
if bytes > MAX_REQUEST_BYTES {
|
||||
return Err(ClientError::TooLarge { bytes, limit: MAX_REQUEST_BYTES });
|
||||
}
|
||||
let mut session = self.connect(deadline).map_err(unavailable)?;
|
||||
let outcome = self.exchange(&mut session, &request, deadline);
|
||||
let outcome = self.exchange(&mut session, &request, &message, deadline);
|
||||
// Best effort: the daemon drops a session it never hears from, only later.
|
||||
let _ = session.disconnect(Vec::new());
|
||||
outcome.map_err(|failure| match failure {
|
||||
|
|
@ -114,9 +134,10 @@ impl HuginnClient {
|
|||
&self,
|
||||
session: &mut CultNetRudpSocketTransportConnection,
|
||||
request: &HuginnMindRequest,
|
||||
message: &CultNetMessage,
|
||||
deadline: Instant,
|
||||
) -> Result<HuginnMindResponse, Failure> {
|
||||
let reply = self.ask(session, &encode_request(MESSAGE_ID, request, None)?, deadline)?;
|
||||
let reply = self.ask(session, message, deadline)?;
|
||||
let CultNetMessage::OperationResponse { operation, .. } = &reply else {
|
||||
return Err(anyhow!("the daemon answered with something other than an operation response").into());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
//! startup vanishes from Claude Code without a word. `whoami` reports it and
|
||||
//! every other tool refuses with it. A refusal from the mind is an answer and
|
||||
//! comes back as a normal result; a daemon that cannot be reached or that
|
||||
//! rejects the envelope is a result with `isError: true`, typed, so a caller
|
||||
//! can tell a down daemon from a permanent mismatch.
|
||||
//! rejects the envelope, or a request too large for one send, is a result with
|
||||
//! `isError: true`, typed, so a caller can tell a down daemon from a permanent
|
||||
//! mismatch. Arguments that do not fit a tool's input are typed the same way
|
||||
//! (`InvalidInput`), as a tool error and not a protocol one.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -25,9 +27,11 @@ use huginn_mind::{
|
|||
Faculty, HuginnMindRequest, HuginnMindResponse, PipelineAdmissionBatch, PipelineProvenance, SemanticQuery,
|
||||
};
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::tool::ToolCallContext;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo};
|
||||
use rmcp::{ServerHandler, ServiceExt, tool, tool_handler, tool_router};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ServerCapabilities, ServerInfo};
|
||||
use rmcp::service::RequestContext;
|
||||
use rmcp::{ErrorData, RoleServer, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -101,6 +105,9 @@ impl Trouble {
|
|||
Self::Client(ClientError::Rejected { endpoint, code, detail }) => {
|
||||
json!({ "error": "Rejected", "endpoint": format!("rudp://{endpoint}"), "code": code, "detail": detail })
|
||||
}
|
||||
Self::Client(ClientError::TooLarge { bytes, limit }) => {
|
||||
json!({ "error": "TooLarge", "bytes": bytes, "limit": limit })
|
||||
}
|
||||
Self::Internal(detail) => json!({ "error": "Internal", "detail": detail }),
|
||||
}
|
||||
}
|
||||
|
|
@ -116,18 +123,28 @@ struct Settings {
|
|||
|
||||
impl Settings {
|
||||
fn from_env() -> Self {
|
||||
let instance = std::env::var("EUREKA_INSTANCE").ok();
|
||||
let endpoint = std::env::var("HUGINN_ENDPOINT").ok();
|
||||
let client = client(instance.as_deref(), endpoint.as_deref());
|
||||
Self { instance, endpoint, client }
|
||||
let instance = variable("EUREKA_INSTANCE");
|
||||
let endpoint = variable("HUGINN_ENDPOINT");
|
||||
let shown = |value: &Result<Option<String>, String>| value.as_ref().ok().cloned().flatten();
|
||||
let (shown_instance, shown_endpoint) = (shown(&instance), shown(&endpoint));
|
||||
Self { instance: shown_instance, endpoint: shown_endpoint, client: client(instance, endpoint) }
|
||||
}
|
||||
}
|
||||
|
||||
fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result<HuginnClient, String> {
|
||||
let instance = instance.ok_or("EUREKA_INSTANCE is not set")?;
|
||||
let slug = Slug(instance.to_string());
|
||||
/// An environment variable: its value, `None` when unset, or why it cannot be read.
|
||||
fn variable(name: &str) -> Result<Option<String>, String> {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} is not valid UTF-8")),
|
||||
}
|
||||
}
|
||||
|
||||
fn client(instance: Result<Option<String>, String>, endpoint: Result<Option<String>, String>) -> Result<HuginnClient, String> {
|
||||
let instance = instance?.ok_or("EUREKA_INSTANCE is not set")?;
|
||||
let slug = Slug(instance);
|
||||
slug.validate_slug().map_err(|refusal| format!("EUREKA_INSTANCE is not a valid instance name: {refusal:?}"))?;
|
||||
let endpoint = endpoint.ok_or("HUGINN_ENDPOINT is not set")?;
|
||||
let endpoint = endpoint?.ok_or("HUGINN_ENDPOINT is not set")?;
|
||||
let address = endpoint
|
||||
.strip_prefix("rudp://")
|
||||
.and_then(|address| address.parse::<SocketAddr>().ok())
|
||||
|
|
@ -138,7 +155,6 @@ fn client(instance: Option<&str>, endpoint: Option<&str>) -> Result<HuginnClient
|
|||
#[derive(Clone)]
|
||||
struct EurekaState {
|
||||
settings: Arc<Settings>,
|
||||
#[expect(dead_code, reason = "the tool_handler macro reads this router field")]
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
|
|
@ -244,8 +260,32 @@ impl EurekaState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every tool error is a typed JSON body. The ones this server writes already
|
||||
/// are; the one rmcp writes, for arguments that do not deserialize into the
|
||||
/// tool's input, is plain text, so it is put into the same shape.
|
||||
fn typed_error(result: CallToolResult) -> CallToolResult {
|
||||
if result.is_error != Some(true) {
|
||||
return result;
|
||||
}
|
||||
let Some(text) = result.content.first().and_then(ContentBlock::as_text).map(|text| text.text.as_str()) else {
|
||||
return result;
|
||||
};
|
||||
if serde_json::from_str::<Value>(text).is_ok_and(|body| body.is_object()) {
|
||||
return result;
|
||||
}
|
||||
reply(json!({ "error": "InvalidInput", "detail": text }), true)
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for EurekaState {
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
Ok(typed_error(self.tool_router.call(ToolCallContext::new(self, request, context)).await?))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
|
||||
"Huginn's typed mind for this instance: whoami, admit, view, query. Refusals are answers; isError means the daemon could not be reached or rejected the request.",
|
||||
|
|
@ -259,3 +299,46 @@ async fn main() -> anyhow::Result<()> {
|
|||
server.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn body_of(result: &CallToolResult) -> (bool, String) {
|
||||
(result.is_error == Some(true), result.content[0].as_text().unwrap().text.clone())
|
||||
}
|
||||
|
||||
/// Reaching `Internal` for real takes a panic in a blocking task, so its
|
||||
/// body is pinned where it is built.
|
||||
#[test]
|
||||
fn every_trouble_has_its_typed_body() {
|
||||
assert_eq!(Trouble::Internal("task panicked".into()).body(), json!({ "error": "Internal", "detail": "task panicked" }));
|
||||
assert_eq!(
|
||||
Trouble::Client(ClientError::TooLarge { bytes: 70_000, limit: 65_000 }).body(),
|
||||
json!({ "error": "TooLarge", "bytes": 70_000, "limit": 65_000 })
|
||||
);
|
||||
assert_eq!(Trouble::Misconfigured("bad".into()).body(), json!({ "error": "Misconfigured", "detail": "bad" }));
|
||||
}
|
||||
|
||||
/// Only the text rmcp writes is rewritten: our own typed bodies and every
|
||||
/// success pass through untouched.
|
||||
#[test]
|
||||
fn typed_error_rewrites_only_rmcp_plain_text() {
|
||||
let plain = CallToolResult::error(vec![ContentBlock::text("failed to deserialize parameters: missing field `faculty`")]);
|
||||
let (is_error, text) = body_of(&typed_error(plain));
|
||||
assert!(is_error);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&text).unwrap(),
|
||||
json!({ "error": "InvalidInput", "detail": "failed to deserialize parameters: missing field `faculty`" })
|
||||
);
|
||||
|
||||
let typed = reply(json!({ "error": "Unavailable", "detail": "down" }), true);
|
||||
assert_eq!(body_of(&typed_error(typed.clone())), body_of(&typed));
|
||||
|
||||
let success = CallToolResult::success(vec![ContentBlock::text("not json, and fine")]);
|
||||
assert_eq!(body_of(&typed_error(success)), (false, "not json, and fine".to_string()));
|
||||
|
||||
let quoted = CallToolResult::error(vec![ContentBlock::text("[1, 2]")]);
|
||||
assert_eq!(serde_json::from_str::<Value>(&body_of(&typed_error(quoted)).1).unwrap()["error"], json!("InvalidInput"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ use cultnet_rs::{
|
|||
CultNetMessage, CultNetWireContract, Selection, answer_content_chunk_request, encode_cultnet_message_to_vec,
|
||||
pack_content,
|
||||
};
|
||||
use eureka_state::{ClientError, HuginnClient};
|
||||
use eureka_state::{ClientError, HuginnClient, MAX_REQUEST_BYTES};
|
||||
use huginn_daemon::serve::MAX_RESPONSE_BYTES;
|
||||
use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response};
|
||||
use huginn_mind::envelope::{OperationFailure, encode_failure, encode_request, encode_response};
|
||||
use huginn_mind::epiphany_pipeline::{
|
||||
AuthorityMap, CodeLocation, CutDelete, CutVerification, FileChange, Line, NegativeCheck, OrgRepo,
|
||||
PipelineCutSpec, PipelineDocument, PipelineKind, PipelineRef, Short, StructuralDelta, Title,
|
||||
|
|
@ -128,14 +128,14 @@ fn client(addr: SocketAddr) -> HuginnClient {
|
|||
fn unavailable(error: ClientError) -> (SocketAddr, String) {
|
||||
match error {
|
||||
ClientError::Unavailable { endpoint, detail } => (endpoint, detail),
|
||||
rejected @ ClientError::Rejected { .. } => panic!("expected Unavailable, got {rejected}"),
|
||||
other @ (ClientError::Rejected { .. } | ClientError::TooLarge { .. }) => panic!("expected Unavailable, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(error: ClientError) -> (SocketAddr, String, String) {
|
||||
match error {
|
||||
ClientError::Rejected { endpoint, code, detail } => (endpoint, code, detail),
|
||||
unavailable @ ClientError::Unavailable { .. } => panic!("expected Rejected, got {unavailable}"),
|
||||
other @ (ClientError::Unavailable { .. } | ClientError::TooLarge { .. }) => panic!("expected Rejected, got {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -380,3 +380,83 @@ fn a_body_over_the_deferral_cap_is_refused() {
|
|||
let (_, detail) = unavailable(client(server.addr).call(HuginnMindRequest::Whoami).unwrap_err());
|
||||
assert!(detail.contains("exceeds"), "{detail}");
|
||||
}
|
||||
|
||||
/// An admission whose attribution is `agent_pad` and `session_pad` characters
|
||||
/// long: the two knobs that size a request without changing what it asks.
|
||||
fn padded_admit(agent_pad: usize, session_pad: usize) -> HuginnMindRequest {
|
||||
let mut request = batch(INSTANCE, vec![stewardship()]);
|
||||
request.provenance.agent = Short("a".repeat(agent_pad));
|
||||
request.provenance.session = Short("s".repeat(session_pad));
|
||||
HuginnMindRequest::Admit(request)
|
||||
}
|
||||
|
||||
/// What one send has to carry for `request`, measured the way the client does.
|
||||
fn encoded_bytes(request: &HuginnMindRequest) -> usize {
|
||||
let message = encode_request("eureka-state-call", request, None).unwrap();
|
||||
encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap().len()
|
||||
}
|
||||
|
||||
/// A request that encodes to exactly `target` bytes: the session padding is
|
||||
/// searched for each agent padding until the base64 steps land on it.
|
||||
fn request_of_size(target: usize) -> HuginnMindRequest {
|
||||
for agent_pad in 1..16 {
|
||||
let (mut low, mut high) = (0, target);
|
||||
while low < high {
|
||||
let middle = (low + high) / 2;
|
||||
if encoded_bytes(&padded_admit(agent_pad, middle)) < target {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
let request = padded_admit(agent_pad, low);
|
||||
if encoded_bytes(&request) == target {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
panic!("no padding encodes to {target} bytes");
|
||||
}
|
||||
|
||||
fn admitted_answer() -> CultNetMessage {
|
||||
encode_response(
|
||||
"eureka-state-call",
|
||||
"admit",
|
||||
&HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id: "r".into() }),
|
||||
"scripted",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A request larger than one send carries is refused by the client before it
|
||||
/// connects to anything, as its own error and not as a transport failure the
|
||||
/// caller would retry; one byte under the limit is sent, and answered.
|
||||
#[test]
|
||||
fn a_request_over_what_one_send_carries_is_too_large_and_one_at_the_limit_is_sent() {
|
||||
let closed = UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
|
||||
let over = request_of_size(MAX_REQUEST_BYTES + 1);
|
||||
let started = Instant::now();
|
||||
match client(closed).call(over) {
|
||||
Err(ClientError::TooLarge { bytes, limit }) => assert_eq!((bytes, limit), (MAX_REQUEST_BYTES + 1, MAX_REQUEST_BYTES)),
|
||||
other => panic!("expected TooLarge, got {other:?}"),
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(1), "refused only after trying the transport");
|
||||
|
||||
let server = answering(admitted_answer());
|
||||
for size in [MAX_REQUEST_BYTES - 1, MAX_REQUEST_BYTES] {
|
||||
let answer = client(server.addr).call(request_of_size(size));
|
||||
assert!(
|
||||
matches!(answer, Ok(HuginnMindResponse::Admit(PipelineAdmissionOutcome::AlreadyAdmitted { .. }))),
|
||||
"a request of {size} bytes: {answer:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transport's own refusal of an oversize send ("Message too long") is
|
||||
/// what an unchecked request used to report, as an `Unavailable` daemon a
|
||||
/// caller would retry forever.
|
||||
#[test]
|
||||
fn an_oversize_admission_is_never_reported_as_an_unavailable_daemon() {
|
||||
let server = answering(admitted_answer());
|
||||
let error = client(server.addr).call(padded_admit(6, 70_000)).unwrap_err();
|
||||
assert!(matches!(error, ClientError::TooLarge { .. }), "{error}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::sync::mpsc::{Receiver, channel};
|
|||
use std::time::Duration;
|
||||
|
||||
use huginn_mind::envelope::{OperationFailure, encode_failure, encode_response};
|
||||
use huginn_mind::HuginnMindResponse;
|
||||
use huginn_mind::{HuginnMindRequest, HuginnMindResponse};
|
||||
use huginn_mind::epiphany_pipeline::{PipelineKind, PipelineRef, Short};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -38,11 +38,17 @@ impl Drop for Mcp {
|
|||
impl Mcp {
|
||||
/// The binary with exactly the environment given, initialised.
|
||||
fn start(env: &[(&str, &str)]) -> Self {
|
||||
Self::spawn(|command| {
|
||||
command.envs(env.iter().copied());
|
||||
})
|
||||
}
|
||||
|
||||
/// The binary with no configuration but what `configure` gives its command.
|
||||
fn spawn(configure: impl FnOnce(&mut Command)) -> Self {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_eureka-state"));
|
||||
command.env_remove("EUREKA_INSTANCE").env_remove("HUGINN_ENDPOINT");
|
||||
configure(&mut command);
|
||||
command
|
||||
.env_remove("EUREKA_INSTANCE")
|
||||
.env_remove("HUGINN_ENDPOINT")
|
||||
.envs(env.iter().copied())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
|
|
@ -130,6 +136,18 @@ fn refs(schema: &Value, found: &mut Vec<String>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every key named `key` anywhere below `schema`'s root.
|
||||
fn keys_below_root(schema: &Value, key: &str) -> usize {
|
||||
fn count(value: &Value, key: &str) -> usize {
|
||||
match value {
|
||||
Value::Object(map) => map.iter().map(|(name, inner)| usize::from(name == key) + count(inner, key)).sum(),
|
||||
Value::Array(items) => items.iter().map(|item| count(item, key)).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
schema.as_object().unwrap().values().map(|inner| count(inner, key)).sum()
|
||||
}
|
||||
|
||||
/// Four tools, each input schema a self-contained document: every `$ref` is
|
||||
/// internal and resolves, and the selection's definitions are bundled in.
|
||||
#[test]
|
||||
|
|
@ -147,7 +165,12 @@ fn tools_list_offers_four_tools_with_self_contained_schemas() {
|
|||
let name = reference.strip_prefix("#/$defs/").unwrap_or_else(|| panic!("{}: remote $ref {reference}", tool["name"]));
|
||||
assert!(schema["$defs"].get(name).is_some(), "{}: {reference} does not resolve", tool["name"]);
|
||||
}
|
||||
// The vendored selection schema brings its own `$id` and `$schema`,
|
||||
// which would make its `$ref`s resolve against a remote document.
|
||||
assert!(schema.get("$id").is_none(), "{}: a remote identity", tool["name"]);
|
||||
for key in ["$id", "$schema"] {
|
||||
assert_eq!(keys_below_root(schema, key), 0, "{}: a nested {key}", tool["name"]);
|
||||
}
|
||||
}
|
||||
let query = tools.iter().find(|tool| tool["name"] == "query").unwrap();
|
||||
for bundled in ["fieldPredicate", "citation", "incoming", "recordRef"] {
|
||||
|
|
@ -363,11 +386,125 @@ fn malformed_input_is_refused_before_any_call() {
|
|||
let response = mcp.rpc("tools/call", json!({ "name": "admit", "arguments": { "faculty": "Hands" } }));
|
||||
assert!(response.get("error").is_none(), "{response}");
|
||||
assert_eq!(response["result"]["isError"], json!(true));
|
||||
assert!(response["result"]["content"][0]["text"].as_str().unwrap().contains("missing field"), "{response}");
|
||||
let body: Value = serde_json::from_str(response["result"]["content"][0]["text"].as_str().unwrap()).expect("a JSON body");
|
||||
assert_eq!(body["error"], json!("InvalidInput"), "{body}");
|
||||
assert!(body["detail"].as_str().unwrap().contains("missing field"), "{body}");
|
||||
let (error, _) = mcp.call("whoami", json!({}));
|
||||
assert!(!error);
|
||||
}
|
||||
|
||||
/// A request too large for one send is the client's typed refusal, an error
|
||||
/// result that names the size and the limit and does not call the daemon
|
||||
/// unavailable.
|
||||
#[test]
|
||||
fn an_oversize_admit_is_a_typed_too_large_error() {
|
||||
let mut mcp = Mcp::at(INSTANCE, closed_port());
|
||||
let mut arguments = faculty_args(vec![]);
|
||||
arguments["session"] = json!("s".repeat(90_000));
|
||||
let (error, body) = mcp.call("admit", arguments);
|
||||
assert!(error);
|
||||
assert_eq!(body["error"], json!("TooLarge"), "{body}");
|
||||
assert_eq!(body["limit"], json!(eureka_state::MAX_REQUEST_BYTES));
|
||||
assert!(body["bytes"].as_u64().unwrap() > eureka_state::MAX_REQUEST_BYTES as u64, "{body}");
|
||||
}
|
||||
|
||||
/// Who admitted a batch is validated by the mind, whichever door the batch
|
||||
/// came through: a blank, whitespace, multi-line or oversized session or agent
|
||||
/// is a typed refusal, and nothing is committed.
|
||||
#[test]
|
||||
fn admit_refuses_an_unusable_attribution() {
|
||||
let (root, daemon) = mind(vec![]);
|
||||
let server = serve(root, daemon);
|
||||
let mut mcp = Mcp::at(INSTANCE, server.addr);
|
||||
for (field, value) in [
|
||||
("session", "".to_string()),
|
||||
("session", " ".to_string()),
|
||||
("session", "x".repeat(5_000)),
|
||||
("session", "line\nbreak".to_string()),
|
||||
("agent", "".to_string()),
|
||||
("agent", "two\nlines".to_string()),
|
||||
("agent", "y".repeat(201)),
|
||||
] {
|
||||
let mut arguments = faculty_args(vec![campaign_json()]);
|
||||
arguments[field] = json!(value);
|
||||
let (error, refused) = mcp.call("admit", arguments);
|
||||
assert!(!error, "{field}={value:?}");
|
||||
let refusal = &refused["Refused"]["Document"];
|
||||
let named = json!(format!("provenance.{field}"));
|
||||
assert!(
|
||||
refusal["InvalidFormat"]["field"] == named || refusal["FieldBound"]["field"] == named,
|
||||
"{field}={value:?}: {refused}"
|
||||
);
|
||||
}
|
||||
let (_, whoami) = mcp.call("whoami", json!({}));
|
||||
assert_eq!(whoami["status"]["receipts"], json!(1), "only the seed is committed: {whoami}");
|
||||
let (error, admitted) = mcp.call("admit", faculty_args(vec![campaign_json()]));
|
||||
assert!(!error);
|
||||
assert!(admitted.get("Committed").is_some(), "{admitted}");
|
||||
}
|
||||
|
||||
/// The tool forwards the caller's semantic query, and only that, to the mind.
|
||||
#[test]
|
||||
fn query_sends_the_semantic_request_on_the_wire() {
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let sink = std::sync::Arc::clone(&seen);
|
||||
let server = scripted(move |message| {
|
||||
let (_, request) = huginn_mind::envelope::decode_request(message).expect("a mind request");
|
||||
sink.lock().unwrap().push(request);
|
||||
encode_response("eureka-state-call", "query", &HuginnMindResponse::View(None), "scripted").unwrap()
|
||||
});
|
||||
let mut mcp = Mcp::at(INSTANCE, server.addr);
|
||||
mcp.call("query", json!({ "selection": { "projection": "document" }, "semantic": { "text": "drift", "top_k": 7 } }));
|
||||
mcp.call("query", json!({ "selection": {} }));
|
||||
let seen = seen.lock().unwrap();
|
||||
let [HuginnMindRequest::Query { instance: first, semantic: Some(asked), .. }, HuginnMindRequest::Query { semantic: None, .. }] =
|
||||
&seen[..]
|
||||
else {
|
||||
panic!("two queries expected, the first with its semantic part: {seen:?}");
|
||||
};
|
||||
assert_eq!(first.0, INSTANCE);
|
||||
assert_eq!((asked.text.0.as_str(), asked.top_k), ("drift", 7));
|
||||
}
|
||||
|
||||
/// A daemon that never answers is given up on after the server's own call
|
||||
/// timeout, 15 seconds: not earlier, and not much later.
|
||||
#[test]
|
||||
fn a_silent_daemon_is_given_up_on_after_fifteen_seconds() {
|
||||
let silent = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut mcp = Mcp::at(INSTANCE, silent.local_addr().unwrap());
|
||||
let started = std::time::Instant::now();
|
||||
let (error, body) = mcp.call("view", json!({ "id": absent() }));
|
||||
let elapsed = started.elapsed();
|
||||
assert!(error);
|
||||
assert_eq!(body["error"], json!("Unavailable"), "{body}");
|
||||
assert!(body["detail"].as_str().unwrap().contains("15s"), "{body}");
|
||||
assert!(elapsed >= Duration::from_secs(15), "gave up after {elapsed:?}");
|
||||
assert!(elapsed < Duration::from_secs(20), "gave up after {elapsed:?}");
|
||||
}
|
||||
|
||||
/// An instance name that is not UTF-8 is reported as that, not as unset.
|
||||
#[test]
|
||||
fn a_non_utf8_instance_is_reported_as_such() {
|
||||
let mut mcp = Mcp::spawn(|command| {
|
||||
command.env("EUREKA_INSTANCE", non_utf8()).env("HUGINN_ENDPOINT", "rudp://127.0.0.1:1");
|
||||
});
|
||||
let (error, whoami) = mcp.call("whoami", json!({}));
|
||||
assert!(!error);
|
||||
let detail = whoami["error"]["detail"].as_str().unwrap();
|
||||
assert!(detail.contains("EUREKA_INSTANCE is not valid UTF-8"), "{whoami}");
|
||||
assert_eq!(whoami["instance"], Value::Null);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn non_utf8() -> std::ffi::OsString {
|
||||
std::os::unix::ffi::OsStringExt::from_vec(vec![b'e', 0xff])
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn non_utf8() -> std::ffi::OsString {
|
||||
std::os::windows::ffi::OsStringExt::from_wide(&[0x65, 0xd800])
|
||||
}
|
||||
|
||||
/// A well-formed id of a document no mind here holds.
|
||||
fn absent() -> Value {
|
||||
serde_json::to_value(PipelineRef { kind: PipelineKind::CutSpec, id: Short("eureka-state:cut_spec:cut-99.r1".into()) })
|
||||
|
|
|
|||
Loading…
Reference in a new issue