fix(worker): preserve typed native WIP contracts
This commit is contained in:
Generated
+1
@@ -6409,6 +6409,7 @@ dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"client",
|
||||
|
||||
@@ -49,6 +49,7 @@ tungstenite = { version = "0.28.0", default-features = false, features = ["hands
|
||||
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
|
||||
futures-util = { version = "0.3", features = ["sink"] }
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
base64 = { workspace = true }
|
||||
wip-client = { workspace = true }
|
||||
wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
|
||||
+243
-72
@@ -16,6 +16,8 @@ use agen::llm_client::client::LlmClient;
|
||||
use agen::state::Mutable;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use http::{Request, Response};
|
||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde::Deserialize;
|
||||
@@ -27,9 +29,9 @@ use wip_client::{
|
||||
RequestId, SecurityContext, SessionId,
|
||||
};
|
||||
use wip_http::{
|
||||
Endpoint, Limits, Route, decode_call_operation_metadata, decode_fetch_interface_request,
|
||||
decode_observe_request, encode_call_operation_response, encode_fetch_interface_response,
|
||||
encode_observe_response, encode_protocol_error_response,
|
||||
Endpoint, Limits, Route, decode_call_operation_metadata, decode_call_operation_request,
|
||||
decode_fetch_interface_request, decode_observe_request, encode_call_operation_response,
|
||||
encode_fetch_interface_response, encode_observe_response, encode_protocol_error_response,
|
||||
};
|
||||
use wip_protocol::{
|
||||
CallOperationRequest, CallOperationResponse, Documentation, FetchInterfaceResponse,
|
||||
@@ -872,32 +874,22 @@ impl WipRuntime {
|
||||
arguments: Json,
|
||||
execution: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let arguments = match self
|
||||
.host
|
||||
.projection(&path)
|
||||
.map(|projection| projection.kind)
|
||||
{
|
||||
Some(WipProjectionKind::Compatibility) => BTreeMap::from([(
|
||||
"input".to_string(),
|
||||
json_to_wip(&arguments).map_err(ToolError::InvalidArgument)?,
|
||||
)]),
|
||||
Some(WipProjectionKind::Native) => {
|
||||
let Json::Object(arguments) = &arguments else {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"native WIP operation arguments must be a JSON object keyed by descriptor parameter name"
|
||||
.into(),
|
||||
));
|
||||
};
|
||||
arguments
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
Ok((
|
||||
name.clone(),
|
||||
json_to_wip(value).map_err(ToolError::InvalidArgument)?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, ToolError>>()?
|
||||
let arguments = match self.host.projection(&path) {
|
||||
Some(projection) if projection.kind == WipProjectionKind::Compatibility => {
|
||||
BTreeMap::from([(
|
||||
"input".to_string(),
|
||||
json_to_wip(&arguments).map_err(ToolError::InvalidArgument)?,
|
||||
)])
|
||||
}
|
||||
Some(projection) => decode_native_arguments(
|
||||
&path,
|
||||
&interface,
|
||||
&operation,
|
||||
&arguments,
|
||||
&projection.descriptor,
|
||||
self.wire_limits,
|
||||
)
|
||||
.map_err(ToolError::InvalidArgument)?,
|
||||
None => {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"WIP target `{path}` is not published"
|
||||
@@ -1488,6 +1480,11 @@ fn descriptor_json(descriptor: &InterfaceDescriptor) -> Json {
|
||||
json!({
|
||||
"format": descriptor.format,
|
||||
"documentation": descriptor.documentation.as_ref().map(documentation_json),
|
||||
"types": descriptor.types.iter().map(|declaration| json!({
|
||||
"name": declaration.name,
|
||||
"documentation": declaration.documentation.as_ref().map(documentation_json),
|
||||
"definition": type_json(&declaration.definition),
|
||||
})).collect::<Vec<_>>(),
|
||||
"operations": descriptor.operations.iter().map(|operation| json!({
|
||||
"name": operation.name,
|
||||
"documentation": operation.documentation.as_ref().map(documentation_json),
|
||||
@@ -1523,10 +1520,34 @@ fn type_json(value: &TypeExpr) -> Json {
|
||||
TypeExpr::Json => json!("json"),
|
||||
TypeExpr::Entry => json!("entry"),
|
||||
TypeExpr::Named { name } => json!({"named": name}),
|
||||
TypeExpr::Record { .. } => json!("record"),
|
||||
TypeExpr::List { .. } => json!("list"),
|
||||
TypeExpr::Enum { .. } => json!("enum"),
|
||||
TypeExpr::Union { .. } => json!("union"),
|
||||
TypeExpr::Record { fields } => json!({
|
||||
"record": {
|
||||
"fields": fields.iter().map(|field| json!({
|
||||
"name": field.name,
|
||||
"required": field.required,
|
||||
"documentation": field.documentation.as_ref().map(documentation_json),
|
||||
"type": type_json(&field.r#type),
|
||||
})).collect::<Vec<_>>()
|
||||
}
|
||||
}),
|
||||
TypeExpr::List { items } => json!({"list": {"items": type_json(items)}}),
|
||||
TypeExpr::Enum { cases } => json!({
|
||||
"enum": {
|
||||
"cases": cases.iter().map(|case| json!({
|
||||
"name": case.name,
|
||||
"documentation": case.documentation.as_ref().map(documentation_json),
|
||||
})).collect::<Vec<_>>()
|
||||
}
|
||||
}),
|
||||
TypeExpr::Union { cases } => json!({
|
||||
"union": {
|
||||
"cases": cases.iter().map(|case| json!({
|
||||
"name": case.name,
|
||||
"documentation": case.documentation.as_ref().map(documentation_json),
|
||||
"payload": case.payload.as_ref().map(type_json),
|
||||
})).collect::<Vec<_>>()
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1591,6 +1612,26 @@ fn json_output(summary: String, value: Json) -> ToolOutput {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_native_arguments(
|
||||
path: &str,
|
||||
interface: &str,
|
||||
operation: &str,
|
||||
arguments: &Json,
|
||||
descriptor: &InterfaceDescriptor,
|
||||
limits: Limits,
|
||||
) -> Result<BTreeMap<String, Value>, String> {
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"target": {"path": path},
|
||||
"interface": {"reference": interface},
|
||||
"operation": operation,
|
||||
"arguments": arguments,
|
||||
}))
|
||||
.map_err(|error| format!("failed to encode native WIP arguments: {error}"))?;
|
||||
decode_call_operation_request(&body, descriptor, limits)
|
||||
.map(|request| request.arguments)
|
||||
.map_err(|error| format!("native WIP arguments do not match the descriptor: {error}"))
|
||||
}
|
||||
|
||||
fn json_to_wip(value: &Json) -> Result<Value, String> {
|
||||
match value {
|
||||
Json::Null => Ok(Value::Unit),
|
||||
@@ -1637,7 +1678,7 @@ fn wip_to_json(value: &Value) -> Result<Json, String> {
|
||||
Value::Number(value) if value.is_finite() => Ok(json!(value)),
|
||||
Value::Number(_) => Err("WIP number is not finite".into()),
|
||||
Value::String(value) => Ok(Json::String(value.clone())),
|
||||
Value::Bytes(_) => Err("WIP bytes cannot be projected into JSON tool arguments".into()),
|
||||
Value::Bytes(value) => Ok(Json::String(BASE64_STANDARD.encode(value))),
|
||||
Value::Record(values) => values
|
||||
.iter()
|
||||
.map(|(name, value)| Ok((name.clone(), wip_to_json(value)?)))
|
||||
@@ -1662,6 +1703,7 @@ mod tests {
|
||||
use agen::llm_client::{ClientError, Request as LlmRequest, ResponseStream};
|
||||
use futures::stream;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use wip_protocol::{EnumCase, FieldDeclaration, TypeDeclaration, UnionCase};
|
||||
|
||||
struct EchoTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
@@ -1812,96 +1854,225 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
struct NativeConcat;
|
||||
struct NativeTyped;
|
||||
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for NativeConcat {
|
||||
impl WipOperationHandler for NativeTyped {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
assert_eq!(operation, "concat");
|
||||
let (Some(Value::String(left)), Some(Value::String(right))) =
|
||||
(arguments.get("left"), arguments.get("right"))
|
||||
else {
|
||||
panic!("descriptor validation must run before native handler")
|
||||
};
|
||||
Ok(WipOperationOutput::native(Value::String(format!(
|
||||
"{left}{right}"
|
||||
))))
|
||||
assert_eq!(operation, "accept");
|
||||
assert_eq!(arguments.get("number"), Some(&Value::Number(1.0)));
|
||||
assert_eq!(
|
||||
arguments.get("bytes"),
|
||||
Some(&Value::Bytes(vec![0, 1, 2, 255]))
|
||||
);
|
||||
assert_eq!(
|
||||
arguments.get("payload"),
|
||||
Some(&Value::Record(BTreeMap::from([
|
||||
("label".into(), Value::String("item".into())),
|
||||
(
|
||||
"tags".into(),
|
||||
Value::List(vec![
|
||||
Value::String("red".into()),
|
||||
Value::String("blue".into()),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"choice".into(),
|
||||
Value::Record(BTreeMap::from([
|
||||
("$case".into(), Value::String("found".into())),
|
||||
("value".into(), Value::String("/items/1".into())),
|
||||
])),
|
||||
),
|
||||
])))
|
||||
);
|
||||
Ok(WipOperationOutput::native(Value::Bytes(vec![255, 0, 1])))
|
||||
}
|
||||
}
|
||||
|
||||
fn native_concat_projection() -> WipProjection {
|
||||
fn documentation(summary: &str) -> Option<Documentation> {
|
||||
Some(Documentation {
|
||||
summary: summary.into(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn native_typed_projection() -> WipProjection {
|
||||
WipProjection {
|
||||
route: "/native/concat".into(),
|
||||
capability: "native:concat".into(),
|
||||
route: "/native/typed".into(),
|
||||
capability: "native:typed".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "concat".into(),
|
||||
description: Some("native concat sample".into()),
|
||||
interfaces: vec!["yoi.native/concat/v1".into()],
|
||||
r#ref: Some("native:concat".into()),
|
||||
name: "typed".into(),
|
||||
description: Some("native typed sample".into()),
|
||||
interfaces: vec!["yoi.native/typed/v1".into()],
|
||||
r#ref: Some("native:typed".into()),
|
||||
validator: Some(vec![1]),
|
||||
},
|
||||
interface: "yoi.native/concat/v1".into(),
|
||||
interface: "yoi.native/typed/v1".into(),
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: Vec::new(),
|
||||
documentation: documentation("typed interface"),
|
||||
types: vec![TypeDeclaration {
|
||||
name: "Payload".into(),
|
||||
documentation: documentation("named payload"),
|
||||
definition: TypeExpr::Record {
|
||||
fields: vec![
|
||||
FieldDeclaration {
|
||||
name: "label".into(),
|
||||
required: true,
|
||||
documentation: documentation("payload label"),
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
FieldDeclaration {
|
||||
name: "tags".into(),
|
||||
required: false,
|
||||
documentation: documentation("payload tags"),
|
||||
r#type: TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Enum {
|
||||
cases: vec![
|
||||
EnumCase {
|
||||
name: "red".into(),
|
||||
documentation: documentation("red tag"),
|
||||
},
|
||||
EnumCase {
|
||||
name: "blue".into(),
|
||||
documentation: documentation("blue tag"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
FieldDeclaration {
|
||||
name: "choice".into(),
|
||||
required: true,
|
||||
documentation: documentation("payload choice"),
|
||||
r#type: TypeExpr::Union {
|
||||
cases: vec![
|
||||
UnionCase {
|
||||
name: "empty".into(),
|
||||
documentation: documentation("empty choice"),
|
||||
payload: None,
|
||||
},
|
||||
UnionCase {
|
||||
name: "found".into(),
|
||||
documentation: documentation("found entry"),
|
||||
payload: Some(TypeExpr::Entry),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "concat".into(),
|
||||
documentation: None,
|
||||
name: "accept".into(),
|
||||
documentation: documentation("accept typed values"),
|
||||
parameters: vec![
|
||||
ParameterDeclaration {
|
||||
name: "left".into(),
|
||||
name: "payload".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
documentation: documentation("named payload argument"),
|
||||
r#type: TypeExpr::Named {
|
||||
name: "Payload".into(),
|
||||
},
|
||||
},
|
||||
ParameterDeclaration {
|
||||
name: "right".into(),
|
||||
name: "number".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
r#type: TypeExpr::Number,
|
||||
},
|
||||
ParameterDeclaration {
|
||||
name: "bytes".into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Bytes,
|
||||
},
|
||||
],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
r#type: TypeExpr::Bytes,
|
||||
},
|
||||
}],
|
||||
},
|
||||
interface_validator: Some(vec![1]),
|
||||
handler: Arc::new(NativeConcat),
|
||||
handler: Arc::new(NativeTyped),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_projection_accepts_descriptor_named_arguments_end_to_end() {
|
||||
async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.mount(native_concat_projection()).unwrap();
|
||||
registry.mount(native_typed_projection()).unwrap();
|
||||
let runtime =
|
||||
WipRuntime::new(WipHost::new(registry), SecurityContext::new("worker-a"), 0).unwrap();
|
||||
runtime.discover("/".into(), 2, false).await.unwrap();
|
||||
runtime
|
||||
.inspect("yoi.native/concat/v1".into(), false)
|
||||
let inspected = runtime
|
||||
.inspect("yoi.native/typed/v1".into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let inspected: Json = serde_json::from_str(inspected.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
inspected.pointer(
|
||||
"/descriptor/types/0/definition/record/fields/1/type/list/items/enum/cases/0/documentation/summary"
|
||||
),
|
||||
Some(&json!("red tag"))
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.pointer("/descriptor/types/0/definition/record/fields/1/required"),
|
||||
Some(&json!(false))
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.pointer(
|
||||
"/descriptor/types/0/definition/record/fields/2/type/union/cases/1/payload"
|
||||
),
|
||||
Some(&json!("entry"))
|
||||
);
|
||||
|
||||
let output = runtime
|
||||
.call(
|
||||
"/native/concat".into(),
|
||||
"yoi.native/concat/v1".into(),
|
||||
"concat".into(),
|
||||
json!({"left": "W", "right": "IP"}),
|
||||
"/native/typed".into(),
|
||||
"yoi.native/typed/v1".into(),
|
||||
"accept".into(),
|
||||
json!({
|
||||
"payload": {
|
||||
"label": "item",
|
||||
"tags": ["red", "blue"],
|
||||
"choice": {"$case": "found", "value": "/items/1"}
|
||||
},
|
||||
"number": 1,
|
||||
"bytes": "AAEC/w=="
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.summary, "WIP operation completed");
|
||||
assert_eq!(output.content.as_deref(), Some("\"WIP\""));
|
||||
assert_eq!(output.content.as_deref(), Some("\"/wAB\""));
|
||||
|
||||
let invalid = runtime
|
||||
.call(
|
||||
"/native/typed".into(),
|
||||
"yoi.native/typed/v1".into(),
|
||||
"accept".into(),
|
||||
json!({
|
||||
"payload": {
|
||||
"label": "item",
|
||||
"choice": {"$case": "empty"}
|
||||
},
|
||||
"number": 1,
|
||||
"bytes": "AAEC_w=="
|
||||
}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(invalid.to_string().contains("canonical base64"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -46,7 +46,7 @@ Each object has one opaque interface reference:
|
||||
yoi.tool/<exact-tool-registration-name>/v1
|
||||
```
|
||||
|
||||
Its single operation is `call`. For this compatibility route, `WipCall.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
|
||||
Its single operation is `call`. For this compatibility route, `WipCall.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
|
||||
|
||||
The interface descriptor uses WIP `Json` for compatibility input and includes the exact original JSON Schema in descriptor documentation. Before execution, the Host validates the JSON value with that original schema and then delegates to the original async `Tool`. Constraints are therefore neither approximated nor silently dropped. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection.
|
||||
|
||||
@@ -61,7 +61,7 @@ The interface descriptor uses WIP `Json` for compatibility input and includes th
|
||||
- A native projection replaces a compatibility projection only when both declare the same semantic capability (`tool:<registration-name>` for compatibility tools).
|
||||
- When native is already selected, the compatibility tool is hidden rather than exposed through a second entry.
|
||||
|
||||
The unit samples `mount_collision_and_native_replacement_are_explicit` and `native_projection_accepts_descriptor_named_arguments_end_to_end` demonstrate independent native registration, deterministic replacement, collision rejection, and a complete native invocation.
|
||||
`WipInspect` renders the complete descriptor, including descriptor-local named declarations and recursive record, list, enum, and union shapes with required flags and declaration documentation. The unit samples `mount_collision_and_native_replacement_are_explicit` and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate independent native registration, deterministic replacement, collision rejection, complete descriptor inspection, and a descriptor-typed native invocation.
|
||||
|
||||
## Authority and execution
|
||||
|
||||
|
||||
Reference in New Issue
Block a user