sdk, agent: review fixes (T18R)

- The serve loop ends the process after EOF or exit_after_response,
  also with background tasks or listeners.
- One schema generator per direction for the whole document, so types
  with the same name get distinct names.
- The agent gives the complete error (code, message, data) to the
  model, writes SKIP only after a successful request, caps tool output
  at 64 KiB, writes each history line in one write, and skips a damaged
  line.
- Endpoints skip blank and non-RPC lines. client_rs does not panic.
- #[must_use] on builders and the lock guard; #[schemars] attributes
  and duplicate hooks are compile errors; raw identifiers; -32000 for
  local SDK errors; x-sausage-params only for methods with params.
This commit is contained in:
2026-10-23 02:38:35 +01:00
parent d7b78ed0fd
commit c728429d04
29 changed files with 829 additions and 104 deletions
+27 -11
View File
@@ -1,5 +1,5 @@
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
use quote::{format_ident, quote};
use quote::quote;
use serde_json::{Value, json};
/// Generate a formatted private client module for a provider's document.
@@ -22,8 +22,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
_ => {}
}
}
let module =
syn::parse_str::<syn::Ident>(&name.replace('-', "_")).map_err(|e| e.to_string())?;
let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{}", name.replace('-', "_"))))
.map_err(|e| e.to_string())?;
let mut space = typify::TypeSpace::default();
let mut defs = doc["components"]["schemas"]
.as_object()
@@ -47,19 +48,32 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
let mut result = vec![];
for method in methods.as_array().into_iter().flatten() {
let wire = method["name"].as_str().ok_or("method name missing")?;
let ident = syn::parse_str::<syn::Ident>(wire).map_err(|e| e.to_string())?;
let mut ps=method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let mut properties=serde_json::Map::new();let mut required=vec![];
for p in method["params"].as_array().into_iter().flatten(){let n=p["name"].as_str().unwrap();properties.insert(n.into(),p["schema"].clone());if p["required"]==true{required.push(n);}}
json!({"title":format!("{}Params",wire.split('_').map(|s|{let mut c=s.chars();c.next().unwrap().to_uppercase().to_string()+c.as_str()}).collect::<String>()),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
let ident = syn::parse_str::<syn::Ident>(wire)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
.map_err(|e| e.to_string())?;
let mut rs = method["result"]["schema"].clone();
local_refs(&mut rs);
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
let p = if empty {
None
} else {
let mut properties = serde_json::Map::new();
let mut required = vec![];
for param in method["params"].as_array().into_iter().flatten() {
let name = param["name"].as_str().ok_or("parameter name missing")?;
properties.insert(name.into(), param["schema"].clone());
if param["required"] == true {
required.push(name);
}
}
let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| {
let mut chars = s.chars();
chars.next().unwrap().to_uppercase().to_string() + chars.as_str()
}).collect::<String>();
json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
Some(
space
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
@@ -102,7 +116,9 @@ pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
let functions = methods(&mut space, name, &doc["methods"], false)?;
let mut endpoints = vec![];
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
let ident = format_ident!("{}", name);
let ident = syn::parse_str::<syn::Ident>(name)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{name}")))
.map_err(|e| e.to_string())?;
let functions = methods(&mut space, "", &description["methods"], true)?;
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
}
+13
View File
@@ -0,0 +1,13 @@
//! A provider may add its first optional named parameter.
use serde_json::json;
#[test]
fn first_optional_param_is_compatible() {
let consumer =
json!({"methods":[{"name":"status","params":[],"result":{"schema":{"type":"string"}}}]});
let mut provider = consumer.clone();
provider["methods"][0]["params"] =
json!([{"name":"detail","required":false,"schema":{"type":"boolean"}}]);
provider["methods"][0]["x-sausage-params"] =
json!({"type":"object","properties":{"detail":{"type":"boolean"}}});
assert!(sausage_interface::compat::check(&consumer, &provider).is_empty());
}
+29
View File
@@ -0,0 +1,29 @@
//! Client generation accepts unusual method names and reports invalid parameter names.
use serde_json::json;
#[test]
fn underscore_segments_and_empty_params_do_not_panic() {
for name in ["_internal", "a__b", "__"] {
for params in [
json!([]),
json!([{"name":"value","schema":{"type":"string"}}]),
] {
let doc = json!({"methods":[{"name":name,"params":params,"result":{"schema":{"type":"null"}}}]});
syn::parse_file(&sausage_interface::client_rs("probe", &doc).unwrap()).unwrap();
}
}
}
#[test]
fn unnamed_param_returns_error() {
let doc = json!({"methods":[{"name":"call","params":[{"schema":{"type":"string"}}],"result":{"schema":{"type":"null"}}}]});
assert!(sausage_interface::client_rs("probe", &doc).is_err());
}
#[test]
fn invalid_endpoint_identifier_returns_error() {
assert!(
sausage_interface::client_rs(
"probe",
&json!({"x-sausage-endpoints":{"bad-name":{"methods":[]}}})
)
.is_err()
);
}