diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e42a48cd3c..3c2b8d9c37 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -138,6 +138,8 @@ jobs: run: cargo make --profile ci check-openapi - name: Check how-to-guides docs are in sync with golem-skills/ run: cargo make --profile ci check-docs-skills + - name: Check reflection conformance corpus embeds + run: cargo make --profile ci check-reflection-conformance - name: Check configs are up to date run: cargo make --profile ci check-configs - name: Check WIT deps are synced diff --git a/Makefile.toml b/Makefile.toml index 7ed6fa6865..a4f837c030 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -1137,6 +1137,20 @@ script = ''' fi ''' +[tasks.generate-reflection-conformance] +description = "Embeds the reflection conformance corpus in Scala and MoonBit test sources" +script_runner = "@duckscript" +script = ''' +exec --fail-on-error node scripts/sync-reflection-conformance.mjs +''' + +[tasks.check-reflection-conformance] +description = "Fails if Scala or MoonBit reflection conformance embeds are stale" +script_runner = "@duckscript" +script = ''' +exec --fail-on-error node scripts/sync-reflection-conformance.mjs --check +''' + ## ** PUBLISH ** [tasks.publish] description = "Publishes packages to crates.io" diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt index c1bd82cc45..7c0e78b3b3 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/json_codec.mbt @@ -184,7 +184,7 @@ pub fn schema_value_to_json(value : SchemaValue) -> Json { } let fields : Array[(String, Json)] = [("bytes", Json::array(arr))] match mime_type { - Some(m) => fields.push(("mime_type", Json::string(m))) + Some(m) => fields.push(("mimeType", Json::string(m))) None => () } node("binary", obj(fields)) @@ -417,7 +417,7 @@ pub fn schema_value_from_json(j : Json) -> SchemaValue raise { for b in arr { buf.write_byte(parse_int_range(b, 0, 255, "byte").to_byte()) } - let mime = match field_opt(m, "mime_type") { + let mime = match field_opt(m, "mimeType") { Some(x) => Some(expect_string(x)) None => None } diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt index d9c24e72ac..b9cd20332c 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/protocol.mbt @@ -73,9 +73,12 @@ pub(all) struct ResolvedAgent { } ///| -fn encode_config_entry(entry : AgentConfigEntry) -> Json { +fn encode_config_entry(entry : AgentConfigEntry) -> Json raise { let path : Array[Json] = entry.path.map(Json::string) - obj([("path", Json::array(path)), ("value", schema_value_to_json(entry.value))]) + obj([ + ("path", Json::array(path)), + ("value", entry.codec.encode_canonical(entry.value)), + ]) } ///| @@ -86,7 +89,7 @@ fn encode_create_agent_request( parameters : SchemaValue, phantom_id : String?, config : Array[AgentConfigEntry], -) -> Json { +) -> Json raise { let fields : Array[(String, Json)] = [ ("appName", Json::string(app_name)), ("envName", Json::string(env_name)), @@ -114,7 +117,7 @@ fn encode_agent_invocation_request( mode : String, schedule_at : String?, idempotency_key : String?, -) -> Json { +) -> Json raise { let fields : Array[(String, Json)] = [ ("appName", Json::string(app_name)), ("envName", Json::string(env_name)), diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt index 531233ac45..a0ae414cc3 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/public_value_codec.mbt @@ -49,7 +49,28 @@ pub fn PublicValueCodec::encode( value : SchemaValue, ) -> Json raise { let charge = Ref(0) - self.encode_at(pvc_field(pvc_object(self.graph), "root"), value, 0, charge) + self.encode_at( + pvc_field(pvc_object(self.graph), "root"), + value, + 0, + charge, + false, + ) +} + +///| +fn PublicValueCodec::encode_canonical( + self : PublicValueCodec, + value : SchemaValue, +) -> Json raise { + let charge = Ref(0) + self.encode_at( + pvc_field(pvc_object(self.graph), "root"), + value, + 0, + charge, + true, + ) } ///| @@ -673,9 +694,9 @@ fn pvc_b64_value(c : Char) -> Int { c.to_int() - 'a'.to_int() + 26 } else if c >= '0' && c <= '9' { c.to_int() - '0'.to_int() + 52 - } else if c == '+' { + } else if c == '-' { 62 - } else if c == '/' { + } else if c == '_' { 63 } else { -1 @@ -685,25 +706,25 @@ fn pvc_b64_value(c : Char) -> Int { ///| fn pvc_base64_decode(value : String) -> Bytes raise { let chars = value.to_array() - if chars.length() % 4 != 0 { - pvc_fail("base64 padding is missing") + if chars.length() % 4 == 1 { + pvc_fail("binary bytes are not canonical unpadded base64url") } let bytes : Array[Byte] = [] for i = 0; i < chars.length(); i = i + 4 { let a = pvc_b64_value(chars[i]) let b = pvc_b64_value(chars[i + 1]) - let last = i + 4 == chars.length() - let c = if chars[i + 2] == '=' { -2 } else { pvc_b64_value(chars[i + 2]) } - let d = if chars[i + 3] == '=' { -2 } else { pvc_b64_value(chars[i + 3]) } - if a < 0 || - b < 0 || - c == -1 || - d == -1 || - (!last && (c < 0 || d < 0)) || - (c == -2 && d != -2) || - (c == -2 && (b & 15) != 0) || - (d == -2 && c >= 0 && (c & 3) != 0) { - pvc_fail("binary bytes are not canonical padded base64") + let c = if i + 2 < chars.length() { + pvc_b64_value(chars[i + 2]) + } else { + -2 + } + let d = if i + 3 < chars.length() { + pvc_b64_value(chars[i + 3]) + } else { + -2 + } + if a < 0 || b < 0 || c == -1 || d == -1 { + pvc_fail("binary bytes are not canonical unpadded base64url") } bytes.push(((a << 2) | (b >> 4)).to_byte()) if c >= 0 { @@ -713,12 +734,16 @@ fn pvc_base64_decode(value : String) -> Bytes raise { } } } - Bytes::from_array(bytes) + let result = Bytes::from_array(bytes) + if pvc_base64_encode(result) != value { + pvc_fail("binary bytes are not canonical unpadded base64url") + } + result } ///| fn pvc_base64_encode(bytes : Bytes) -> String { - let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" let out = StringBuilder() for i = 0; i < bytes.length(); i = i + 3 { let a = bytes[i].to_int() @@ -728,20 +753,12 @@ fn pvc_base64_encode(bytes : Bytes) -> String { let c = if have_c { bytes[i + 2].to_int() } else { 0 } out.write_char(alphabet.get_char(a >> 2).unwrap()) out.write_char(alphabet.get_char(((a & 3) << 4) | (b >> 4)).unwrap()) - out.write_char( - if have_b { - alphabet.get_char(((b & 15) << 2) | (c >> 6)).unwrap() - } else { - '=' - }, - ) - out.write_char( - if have_c { - alphabet.get_char(c & 63).unwrap() - } else { - '=' - }, - ) + if have_b { + out.write_char(alphabet.get_char(((b & 15) << 2) | (c >> 6)).unwrap()) + } + if have_c { + out.write_char(alphabet.get_char(c & 63).unwrap()) + } } out.to_string() } @@ -1492,6 +1509,7 @@ fn PublicValueCodec::encode_at( value : SchemaValue, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { if depth >= 64 { pvc_fail("nesting exceeds 64 levels") @@ -1574,11 +1592,17 @@ fn PublicValueCodec::encode_at( Json::string(v.to_string()) } ("f32", F32Value(v)) => { + if canonical && (v.is_nan() || v.is_inf()) { + pvc_fail("canonical configuration floats must be finite") + } pvc_numeric_restrictions(schema, "float", None, None, Some(v.to_double())) pvc_charge(charge, 4) pvc_encode_float(v.to_double()) } ("f64", F64Value(v)) => { + if canonical && (v.is_nan() || v.is_inf()) { + pvc_fail("canonical configuration floats must be finite") + } pvc_numeric_restrictions(schema, "float", None, None, Some(v)) pvc_charge(charge, 8) pvc_encode_float(v) @@ -1592,9 +1616,10 @@ fn PublicValueCodec::encode_at( pvc_charge(charge, @utf8.encode(v).length()) Json::string(v) } - ("record", RecordValue(v)) => self.encode_record(schema, v, depth, charge) + ("record", RecordValue(v)) => + self.encode_record(schema, v, depth, charge, canonical) ("variant", VariantValue(i, v)) => - self.encode_variant(schema, i, v, depth, charge) + self.encode_variant(schema, i, v, depth, charge, canonical) ("enum", EnumValue(i)) => { let cases = pvc_array(pvc_field(schema, "cases")) if i < 0 || i >= cases.length() { @@ -1626,26 +1651,46 @@ fn PublicValueCodec::encode_at( v, depth, charge, + canonical, ) ("list", ListValue(v)) => - self.encode_homogeneous(pvc_field(schema, "element"), v, depth, charge) + self.encode_homogeneous( + pvc_field(schema, "element"), + v, + depth, + charge, + canonical, + ) ("fixed-list", FixedListValue(v)) => { if v.length() != pvc_u32(pvc_field(schema, "length")).reinterpret_as_int() { pvc_fail("fixed-list length mismatch") } - self.encode_homogeneous(pvc_field(schema, "element"), v, depth, charge) + self.encode_homogeneous( + pvc_field(schema, "element"), + v, + depth, + charge, + canonical, + ) } ("map", MapValue(v)) => { pvc_collection(charge, v.length()) Json::array( v.map(e => { Json::array([ - self.encode_at(pvc_field(schema, "key"), e.key, depth + 1, charge), + self.encode_at( + pvc_field(schema, "key"), + e.key, + depth + 1, + charge, + canonical, + ), self.encode_at( pvc_field(schema, "value"), e.value, depth + 1, charge, + canonical, ), ]) }), @@ -1653,22 +1698,38 @@ fn PublicValueCodec::encode_at( } ("option", OptionValue(v)) => match v { + None if canonical => Json::null() None => { pvc_charge(charge, 4) pvc_obj([("$option", Json::string("none"))]) } + Some(x) if canonical => + self.encode_at( + pvc_field(schema, "inner"), + x, + depth + 1, + charge, + canonical, + ) Some(x) => { pvc_charge(charge, 4) pvc_obj([ ("$option", Json::string("some")), ( "value", - self.encode_at(pvc_field(schema, "inner"), x, depth + 1, charge), + self.encode_at( + pvc_field(schema, "inner"), + x, + depth + 1, + charge, + canonical, + ), ), ]) } } - ("result", ResultValue(v)) => self.encode_result(schema, v, depth, charge) + ("result", ResultValue(v)) => + self.encode_result(schema, v, depth, charge, canonical) ("text", TextValue(text, language)) => { match language { Some(x) => @@ -1761,13 +1822,18 @@ fn PublicValueCodec::encode_at( pvc_fail("unknown union branch") } pvc_discriminator(self, ty, body, branches, tag) - pvc_charge(charge, pvc_utf8_len(tag)) - pvc_obj([ - ("$union", Json::string(tag)), - ("value", self.encode_at(ty, body, depth + 1, charge)), - ]) + let encoded = self.encode_at(ty, body, depth + 1, charge, canonical) + if canonical { + encoded + } else { + pvc_charge(charge, pvc_utf8_len(tag)) + pvc_obj([("$union", Json::string(tag)), ("value", encoded)]) + } } - ("stream", StreamReferenceValue(provisional, token)) => + ("stream", StreamReferenceValue(provisional, token)) => { + if canonical { + pvc_fail("streams are unsupported in canonical configuration values") + } match (provisional, token) { (Some(v), None) => { if !pvc_uuid_v4(v) { @@ -1794,6 +1860,7 @@ fn PublicValueCodec::encode_at( Json::null() } } + } ("secret" | "quota-token" | "permission-card" | "future", _) => { pvc_fail("unsupported value kind '\{kind}'") Json::null() @@ -1812,6 +1879,7 @@ fn PublicValueCodec::encode_record( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let fields = pvc_array(pvc_field(schema, "fields")) if fields.length() != values.length() { @@ -1828,6 +1896,7 @@ fn PublicValueCodec::encode_record( values[i], depth + 1, charge, + canonical, ) } Json::object(out) @@ -1841,6 +1910,7 @@ fn PublicValueCodec::encode_variant( payload : SchemaValue?, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let cases = pvc_array(pvc_field(schema, "cases")) if index < 0 || index >= cases.length() { @@ -1853,13 +1923,22 @@ fn PublicValueCodec::encode_variant( if (ty is Some(_)) != (payload is Some(_)) { pvc_fail("variant payload mismatch") } - let fields = [("$case", Json::string(name))] match (ty, payload) { - (Some(t), Some(v)) => - fields.push(("value", self.encode_at(t, v, depth + 1, charge))) - _ => () + (Some(t), Some(v)) => { + let encoded = self.encode_at(t, v, depth + 1, charge, canonical) + if canonical { + pvc_obj([(name, encoded)]) + } else { + pvc_obj([("$case", Json::string(name)), ("value", encoded)]) + } + } + (None, None) if canonical => Json::string(name) + (None, None) => pvc_obj([("$case", Json::string(name))]) + _ => { + pvc_fail("variant payload mismatch") + Json::null() + } } - pvc_obj(fields) } ///| @@ -1869,13 +1948,16 @@ fn PublicValueCodec::encode_sequence( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { if types.length() != values.length() { pvc_fail("tuple arity mismatch") } pvc_collection(charge, values.length()) Json::array( - values.mapi((i, v) => self.encode_at(types[i], v, depth + 1, charge)), + values.mapi((i, v) => + self.encode_at(types[i], v, depth + 1, charge, canonical) + ), ) } @@ -1886,9 +1968,12 @@ fn PublicValueCodec::encode_homogeneous( values : Array[SchemaValue], depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { pvc_collection(charge, values.length()) - Json::array(values.map(v => self.encode_at(ty, v, depth + 1, charge))) + Json::array( + values.map(v => self.encode_at(ty, v, depth + 1, charge, canonical)), + ) } ///| @@ -1898,6 +1983,7 @@ fn PublicValueCodec::encode_result( result : SchemaResult, depth : Int, charge : Ref[Int], + canonical : Bool, ) -> Json raise { let spec = pvc_object(pvc_field(schema, "spec")) let (tag, value) = match result { @@ -1908,12 +1994,21 @@ fn PublicValueCodec::encode_result( if (ty is Some(_)) != (value is Some(_)) { pvc_fail("result payload mismatch") } - let fields = [("$result", Json::string(tag))] pvc_charge(charge, pvc_utf8_len(tag)) match (ty, value) { - (Some(t), Some(v)) => - fields.push(("value", self.encode_at(t, v, depth + 1, charge))) - _ => () + (Some(t), Some(v)) => { + let encoded = self.encode_at(t, v, depth + 1, charge, canonical) + if canonical { + pvc_obj([(tag, encoded)]) + } else { + pvc_obj([("$result", Json::string(tag)), ("value", encoded)]) + } + } + (None, None) if canonical => pvc_obj([(tag, Json::null())]) + (None, None) => pvc_obj([("$result", Json::string(tag))]) + _ => { + pvc_fail("result payload mismatch") + Json::null() + } } - pvc_obj(fields) } diff --git a/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt b/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt index 4d97542383..7852ce6f9b 100644 --- a/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt +++ b/cli/golem-cli/src/bridge_gen/moonbit/runtime/stream_wbtest.mbt @@ -86,7 +86,7 @@ test "public codec preserves record s64 binary and structural wire shapes" { inspect( json.stringify(), content=( - #|{"count":"-9","data":{"bytes":"AP8=","mimeType":"application/octet-stream"},"choice":{"$option":"some","value":"yes"}} + #|{"count":"-9","data":{"bytes":"AP8","mimeType":"application/octet-stream"},"choice":{"$option":"some","value":"yes"}} ), ) assert_true(codec.decode(json) == value) diff --git a/cli/golem-cli/src/bridge_gen/rust/mod.rs b/cli/golem-cli/src/bridge_gen/rust/mod.rs index b6a6bec0cb..7c6839fa97 100644 --- a/cli/golem-cli/src/bridge_gen/rust/mod.rs +++ b/cli/golem-cli/src/bridge_gen/rust/mod.rs @@ -443,17 +443,22 @@ impl RustBridgeGenerator { let __config_graph: crate::__golem_bridge_runtime::schema::SchemaGraph = serde_json::from_str(#config_graph_json) .map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to load config schema: {__e}") })?; + let __public_config_json = golem_client::invocation_session::encode_generated_streamless_value( + &__config_graph, + &__config_value, + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode public config value: {__e}") })?; + let __canonical_config_json = golem_common::schema::render::to_json_value( + &__config_graph, + &__config_graph.root, + &__config_value, + ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode canonical config value: {__e}") })?; session_config.push(golem_common::model::invocation_session_public::PublicConfigEntry { path: vec![#(#path_segments),*], - value: golem_client::invocation_session::encode_generated_streamless_value( - &__config_graph, - &__config_value, - ).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to encode public config value: {__e}") })?, + value: __public_config_json, }); - let __config_json = serde_json::to_value(&__config_value).map_err(|__e| crate::__golem_bridge_runtime::ClientError::InvocationFailed { message: format!("Failed to serialize config value: {__e}") })?; agent_config.push(golem_client::model::AgentConfigEntryDto { path: vec![#(#path_segments),*], - value: __config_json.into(), + value: __canonical_config_json.into(), }); } }); diff --git a/cli/golem-cli/src/bridge_gen/scala/mod.rs b/cli/golem-cli/src/bridge_gen/scala/mod.rs index 0735c154f6..0740e35ef9 100644 --- a/cli/golem-cli/src/bridge_gen/scala/mod.rs +++ b/cli/golem-cli/src/bridge_gen/scala/mod.rs @@ -1558,9 +1558,8 @@ impl ScalaBridgeGenerator { || "_root_.scala.None".to_string(), |codec| format!("_root_.scala.Some({codec})"), ); - let config_codecs = self.public_config_codecs()?; writer.line(format!( - "{BRIDGE}.invokeStreamingAgent(resolved, {method_name_lit}, () => methodParameters({invoke_args}), {constructor_codec}, {input_codec}, {output_codec}, {config_codecs}).map {{ __result =>" + "{BRIDGE}.invokeStreamingAgent(resolved, {method_name_lit}, () => methodParameters({invoke_args}), {constructor_codec}, {input_codec}, {output_codec}).map {{ __result =>" )); } else { writer.line(format!( @@ -1919,7 +1918,7 @@ impl ScalaBridgeGenerator { /// Emits `val agentConfig = List(, …).flatten`, where each `` /// is the matching config parameter mapped (when present) to an - /// `AgentConfigEntry(path, encodedValue)`. + /// `AgentConfigEntry(path, encodedValue, publicCodec)`. fn write_config_list( &self, writer: &mut ScalaWriter, @@ -1937,6 +1936,7 @@ impl ScalaBridgeGenerator { .collect::>() .join(", "); let enc = self.encode_expr("value", &config.value_type, 0)?; + let codec = self.public_codec(&config.value_type)?; let comma = if idx + 1 < local_configs.len() { "," } else { @@ -1946,7 +1946,7 @@ impl ScalaBridgeGenerator { writer.indent(); writer.line(format!("val configValue = {enc}")); writer.line(format!( - "{AGENT_CONFIG_ENTRY}({LIST}({path_lit}), configValue)" + "{AGENT_CONFIG_ENTRY}({LIST}({path_lit}), configValue, {codec})" )); writer.dedent(); writer.line(format!("}}{comma}")); @@ -2036,26 +2036,6 @@ impl ScalaBridgeGenerator { )) } - fn public_config_codecs(&self) -> anyhow::Result { - let entries = self - .local_configs() - .into_iter() - .map(|config| { - let path = config - .path - .iter() - .map(|part| scala_string_literal(part)) - .collect::>() - .join(", "); - Ok(format!( - "{LIST}({path}) -> {}", - self.public_codec(&config.value_type)? - )) - }) - .collect::>>()?; - Ok(format!("{LIST}({})", entries.join(", "))) - } - /// The `(name, type)` parameter declarations for a constructor or method's /// user-supplied input fields, in declaration order. A multimodal input is /// surfaced as a single `List[Multimodal]` parameter named after the diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala index f28dd13eb4..ba73032845 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/Bridge.scala @@ -22,6 +22,7 @@ import java.net.URI import java.net.http.{HttpClient, HttpRequest, HttpResponse} import java.util.concurrent.CompletableFuture import scala.concurrent.{Future, Promise} +import scala.util.Try /** * The REST transport shared by all generated bridge clients in this project. @@ -52,9 +53,10 @@ object Bridge { phantomId = phantomId, config = config ) - val body = BridgeProtocol.encodeCreateAgentRequest(request).render - send(configuration, "create-agent", body, None).flatMap { response => - complete("create-agent", response, BridgeProtocol.decodeCreateAgentResponse) + Future.fromTry(Try(BridgeProtocol.encodeCreateAgentRequest(request).render)).flatMap { body => + send(configuration, "create-agent", body, None).flatMap { response => + complete("create-agent", response, BridgeProtocol.decodeCreateAgentResponse) + } } } @@ -81,9 +83,10 @@ object Bridge { scheduleAt = scheduleAt, idempotencyKey = None ) - val body = BridgeProtocol.encodeAgentInvocationRequest(request).render - send(configuration, "invoke-agent", body, request.idempotencyKey).flatMap { response => - complete("invoke-agent", response, BridgeProtocol.decodeAgentInvocationResult) + Future.fromTry(Try(BridgeProtocol.encodeAgentInvocationRequest(request).render)).flatMap { body => + send(configuration, "invoke-agent", body, request.idempotencyKey).flatMap { response => + complete("invoke-agent", response, BridgeProtocol.decodeAgentInvocationResult) + } } } @@ -144,8 +147,9 @@ object Bridge { methodParameters: () => SchemaValue, constructorCodec: PublicValueCodec.Codec, inputCodec: PublicValueCodec.Codec, - outputCodec: Option[PublicValueCodec.Codec], - configCodecs: List[(List[String], PublicValueCodec.Codec)] + outputCodec: Option[PublicValueCodec.Codec] ): Future[AgentInvocationResult] = - StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec, configCodecs) + Try( + StreamSession.invoke(resolved, methodName, methodParameters, constructorCodec, inputCodec, outputCodec) + ).fold(Future.failed, future => future) } diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala index 081a24ca55..68ddd5f57a 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/BridgeProtocol.scala @@ -19,7 +19,7 @@ package golem.bridge.runtime import golem.bridge.runtime.json.Json /** A single agent configuration override entry of a create-agent request. */ -final case class AgentConfigEntry(path: List[String], value: SchemaValue) +final case class AgentConfigEntry(path: List[String], value: SchemaValue, codec: PublicValueCodec.Codec) /** Body of a `POST /v1/agents/create-agent` request. */ final case class CreateAgentRequest( @@ -118,7 +118,7 @@ object BridgeProtocol { private def encodeConfigEntry(entry: AgentConfigEntry): Json = Json.obj( "path" -> Json.arr(entry.path.map(Json.string).toVector), - "value" -> SchemaValueCodec.toJson(entry.value) + "value" -> entry.codec.encodeCanonical(entry.value) ) def decodeCreateAgentResponse(json: Json): Either[String, CreateAgentResponse] = diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala index c5201180aa..a39b4422df 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/PublicValueCodec.scala @@ -35,14 +35,17 @@ object PublicValueCodec { private val defs: Map[String, Schema] ) { def encode(value: SchemaValue): json.Json = - encodeAt(root, value, 0, new Budget) + encodeAt(root, value, 0, new Budget, canonical = false) + + private[runtime] def encodeCanonical(value: SchemaValue): json.Json = + encodeAt(root, value, 0, new Budget, canonical = true) def decode(value: json.Json): SchemaValue = { rejectDuplicates(value, "$value") decodeAt(root, value, 0, new Budget) } - private def encodeAt(schema0: Schema, value: SchemaValue, depth: Int, budget: Budget): json.Json = { + private def encodeAt(schema0: Schema, value: SchemaValue, depth: Int, budget: Budget, canonical: Boolean): json.Json = { checkDepth(depth) budget.add(1) val schema = resolve(schema0) @@ -56,8 +59,8 @@ object PublicValueCodec { case ("u16", U16Value(v)) => integer(v.toLong, 0, 65535, 2, schema, budget) case ("u32", U32Value(v)) => integer(v, 0, 4294967295L, 4, schema, budget) case ("u64", U64Value(v)) => checkedDecimal(BigInt(v) & MaxU64, signed = false, schema, budget) - case ("f32", F32Value(v)) => encodeFloat(v.toDouble, true, 4, schema, budget) - case ("f64", F64Value(v)) => encodeFloat(v, false, 8, schema, budget) + case ("f32", F32Value(v)) => encodeFloat(v.toDouble, true, 4, schema, budget, canonical) + case ("f64", F64Value(v)) => encodeFloat(v, false, 8, schema, budget, canonical) case ("char", CharValue(v)) => if (!Character.isValidCodePoint(v) || v >= 0xd800 && v <= 0xdfff) fail("invalid char value") val s = new String(Character.toChars(v)); budget.string(s); json.Json.string(s) @@ -71,7 +74,7 @@ object PublicValueCodec { collection(values.length, budget) json.Json.obj(fields.zip(values).map { case ((name, ty), v) => budget.string(name) - name -> encodeAt(ty, v, depth + 1, budget) + name -> encodeAt(ty, v, depth + 1, budget, canonical) }) case ("variant", VariantValue(index, payload)) => val cases = schemaArray(schema, "cases") @@ -79,11 +82,17 @@ object PublicValueCodec { val obj = objectFields(cases(index), "schema variant case") val name = stringField(obj, "name") val payloadSchema = optionalSchemaField(obj, "payload") - val base = Vector[(String, json.Json)]("$case" -> json.Json.string(name)) budget.string(name) (payloadSchema, payload) match { - case (None, None) => json.Json.obj(base) - case (Some(ty), Some(v)) => json.Json.obj(base :+ ("value" -> encodeAt(ty, v, depth + 1, budget))) + case (None, None) if canonical => json.Json.string(name) + case (None, None) => json.Json.obj("$case" -> json.Json.string(name)) + case (Some(ty), Some(v)) if canonical => + json.Json.obj(name -> encodeAt(ty, v, depth + 1, budget, canonical)) + case (Some(ty), Some(v)) => + json.Json.obj( + "$case" -> json.Json.string(name), + "value" -> encodeAt(ty, v, depth + 1, budget, canonical) + ) case _ => fail("variant payload presence does not match schema") } case ("enum", EnumValue(index)) => @@ -96,29 +105,31 @@ object PublicValueCodec { val selected = flags.zip(bits).collect { case (name, true) => name } collection(selected.length, budget) json.Json.arr(selected.map { name => budget.string(name); json.Json.string(name) }.toVector) - case ("tuple", TupleValue(values)) => encodeSequence(schemaArray(schema, "elements").map(parseSchema), values, depth, budget, "tuple") - case ("list", ListValue(values)) => encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, None) + case ("tuple", TupleValue(values)) => encodeSequence(schemaArray(schema, "elements").map(parseSchema), values, depth, budget, "tuple", canonical) + case ("list", ListValue(values)) => encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, None, canonical) case ("fixed-list", FixedListValue(values)) => val length = schemaU32(schema, "length") - encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, Some(length)) + encodeRepeated(schemaField(schemaValue(schema), "element"), values, depth, budget, Some(length), canonical) case ("map", MapValue(entries)) => collection(entries.length, budget) val obj = schemaValue(schema) val key = schemaField(obj, "key"); val valueType = schemaField(obj, "value") - json.Json.arr(entries.map(e => json.Json.arr(Vector(encodeAt(key, e.key, depth + 1, budget), encodeAt(valueType, e.value, depth + 1, budget)))).toVector) + json.Json.arr(entries.map(e => json.Json.arr(Vector(encodeAt(key, e.key, depth + 1, budget, canonical), encodeAt(valueType, e.value, depth + 1, budget, canonical)))).toVector) case ("option", OptionValue(inner)) => val ty = schemaField(schemaValue(schema), "inner") inner match { - case None => budget.add(4); json.Json.obj("$option" -> json.Json.string("none")) - case Some(v) => budget.add(4); json.Json.obj("$option" -> json.Json.string("some"), "value" -> encodeAt(ty, v, depth + 1, budget)) + case None if canonical => json.Json.`null` + case None => budget.add(4); json.Json.obj("$option" -> json.Json.string("none")) + case Some(v) if canonical => encodeAt(ty, v, depth + 1, budget, canonical) + case Some(v) => budget.add(4); json.Json.obj("$option" -> json.Json.string("some"), "value" -> encodeAt(ty, v, depth + 1, budget, canonical)) } - case ("result", ResultValue(result)) => encodeResult(schema, result, depth, budget) + case ("result", ResultValue(result)) => encodeResult(schema, result, depth, budget, canonical) case ("text", TextValue(text, language)) => validateText(schema, text, language); budget.string(text); language.foreach(budget.string) json.Json.obj(Vector("text" -> json.Json.string(text)) ++ language.map(v => "language" -> json.Json.string(v))) case ("binary", BinaryValue(bytes, mimeType)) => validateBinary(schema, bytes, mimeType); budget.add(bytes.length); mimeType.foreach(budget.string) - json.Json.obj(Vector("bytes" -> json.Json.string(Base64.getEncoder.encodeToString(bytes.toArray))) ++ mimeType.map(v => "mimeType" -> json.Json.string(v))) + json.Json.obj(Vector("bytes" -> json.Json.string(Base64.getUrlEncoder.withoutPadding().encodeToString(bytes.toArray))) ++ mimeType.map(v => "mimeType" -> json.Json.string(v))) case ("path", PathValue(v)) => validatePath(schema, v); budget.string(v); json.Json.string(v) case ("url", UrlValue(v)) => validateUrl(schema, v); budget.string(v); json.Json.string(v) case ("datetime", DatetimeValue(v)) => validateDatetime(v); budget.string(v); json.Json.string(v) @@ -133,11 +144,12 @@ object PublicValueCodec { ) case ("union", UnionValue(tag, body)) => val branch = unionBranch(schema, tag) - val encoded = encodeAt(schemaField(branch, "body"), body, depth + 1, budget) + val encoded = encodeAt(schemaField(branch, "body"), body, depth + 1, budget, canonical) if (!matchesDiscriminator(branch, encoded)) fail("union body does not satisfy discriminator") - budget.string(tag) - json.Json.obj("$union" -> json.Json.string(tag), "value" -> encoded) + if (canonical) encoded + else { budget.string(tag); json.Json.obj("$union" -> json.Json.string(tag), "value" -> encoded) } case ("stream", StreamReferenceValue(provisional, token, _)) => + if (canonical) fail("streams are unsupported in canonical configuration values") val field = (provisional, token) match { case (Some(v), None) => validateUuidV4(v); budget.add(16); "provisionalRef" -> json.Json.string(v) case (None, Some(v)) => if (v.isEmpty || utf8Length(v) > MaxStreamToken) fail("invalid stream token length"); budget.string(v); "streamToken" -> json.Json.string(v) @@ -266,9 +278,9 @@ object PublicValueCodec { loop(schema, Set.empty) } - private def encodeSequence(types: Vector[Schema], values: List[SchemaValue], depth: Int, budget: Budget, what: String): json.Json = { + private def encodeSequence(types: Vector[Schema], values: List[SchemaValue], depth: Int, budget: Budget, what: String, canonical: Boolean): json.Json = { if (types.length != values.length) fail(s"$what arity does not match schema") - collection(values.length, budget); json.Json.arr(types.zip(values).map { case (t, v) => encodeAt(t, v, depth + 1, budget) }) + collection(values.length, budget); json.Json.arr(types.zip(values).map { case (t, v) => encodeAt(t, v, depth + 1, budget, canonical) }) } private def decodeSequence(types: Vector[Schema], input: json.Json, depth: Int, budget: Budget, what: String): Vector[SchemaValue] = { @@ -276,9 +288,9 @@ object PublicValueCodec { collection(values.length, budget); types.zip(values).map { case (t, v) => decodeAt(t, v, depth + 1, budget) } } - private def encodeRepeated(ty: Schema, values: List[SchemaValue], depth: Int, budget: Budget, fixed: Option[Int]): json.Json = { + private def encodeRepeated(ty: Schema, values: List[SchemaValue], depth: Int, budget: Budget, fixed: Option[Int], canonical: Boolean): json.Json = { fixed.foreach(n => if (values.length != n) fail("fixed-list length does not match schema")) - collection(values.length, budget); json.Json.arr(values.map(v => encodeAt(ty, v, depth + 1, budget)).toVector) + collection(values.length, budget); json.Json.arr(values.map(v => encodeAt(ty, v, depth + 1, budget, canonical)).toVector) } private def decodeRepeated(ty: Schema, input: json.Json, depth: Int, budget: Budget, fixed: Option[Int]): Vector[SchemaValue] = { @@ -286,17 +298,23 @@ object PublicValueCodec { collection(values.length, budget); values.map(v => decodeAt(ty, v, depth + 1, budget)) } - private def encodeResult(schema: Schema, result: SchemaResult, depth: Int, budget: Budget): json.Json = { + private def encodeResult(schema: Schema, result: SchemaResult, depth: Int, budget: Budget, canonical: Boolean): json.Json = { val spec = objectField(schemaValue(schema), "spec") val (tag, payload, ty) = result match { case SchemaResult.Ok(v) => ("ok", v, optionalSchemaField(spec, "ok")) case SchemaResult.Err(v) => ("err", v, optionalSchemaField(spec, "err")) } budget.string(tag) - val base = Vector[(String, json.Json)]("$result" -> json.Json.string(tag)) (payload, ty) match { - case (None, None) => json.Json.obj(base) - case (Some(v), Some(t)) => json.Json.obj(base :+ ("value" -> encodeAt(t, v, depth + 1, budget))) + case (None, None) if canonical => json.Json.obj(tag -> json.Json.`null`) + case (None, None) => json.Json.obj("$result" -> json.Json.string(tag)) + case (Some(v), Some(t)) if canonical => + json.Json.obj(tag -> encodeAt(t, v, depth + 1, budget, canonical)) + case (Some(v), Some(t)) => + json.Json.obj( + "$result" -> json.Json.string(tag), + "value" -> encodeAt(t, v, depth + 1, budget, canonical) + ) case _ => fail("result payload presence does not match schema") } } @@ -347,8 +365,9 @@ object PublicValueCodec { n } - private def encodeFloat(value: Double, isF32: Boolean, width: Int, schema: Schema, budget: Budget): json.Json = { + private def encodeFloat(value: Double, isF32: Boolean, width: Int, schema: Schema, budget: Budget, canonical: Boolean): json.Json = { budget.add(width) + if (canonical && !value.isFinite) fail("canonical configuration floats must be finite") if (value.isFinite) validateNumeric(schema, BigDecimal(value)) else if (hasNumericBounds(schema)) fail("exceptional float does not satisfy numeric restrictions") if (value.isNaN) floatTag("nan") @@ -470,9 +489,9 @@ object PublicValueCodec { } private def decodeBase64(value: String): Vector[Byte] = { - if (value.length % 4 != 0 || !Base64Syntax.matcher(value).matches()) fail("binary bytes are not canonical padded base64") - val bytes = try Base64.getDecoder.decode(value) catch { case _: IllegalArgumentException => fail("invalid base64") } - if (Base64.getEncoder.encodeToString(bytes) != value) fail("binary bytes are not canonical padded base64") + if (value.length % 4 == 1 || !Base64UrlSyntax.matcher(value).matches()) fail("binary bytes are not canonical unpadded base64url") + val bytes = try Base64.getUrlDecoder.decode(value) catch { case _: IllegalArgumentException => fail("invalid base64url") } + if (Base64.getUrlEncoder.withoutPadding().encodeToString(bytes) != value) fail("binary bytes are not canonical unpadded base64url") bytes.toVector } @@ -605,7 +624,7 @@ object PublicValueCodec { private val UnsignedDecimal = Pattern.compile("0|[1-9][0-9]*") private val Mime = Pattern.compile("[A-Za-z0-9!#$&^_.+\\-]+/[A-Za-z0-9!#$&^_.+\\-]+") private val Language = Pattern.compile("[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*") - private val Base64Syntax = Pattern.compile("(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?") + private val Base64UrlSyntax = Pattern.compile("[A-Za-z0-9_-]*") private val UuidV4 = Pattern.compile("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") private val Datetime = Pattern.compile("[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]{1,9})?Z") } diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala index 9d941bf04d..fab5934be9 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/SchemaValueCodec.scala @@ -108,7 +108,7 @@ object SchemaValueCodec { "bytes" -> Json.arr(bytes.map(b => Json.fromInt(b & 0xff))) ) val withMime = mimeType match { - case Some(m) => base :+ ("mime_type" -> Json.string(m)) + case Some(m) => base :+ ("mimeType" -> Json.string(m)) case None => base } node("binary", Json.obj(withMime)) @@ -451,7 +451,7 @@ object SchemaValueCodec { case "binary" => for { bytes <- field(value, "bytes").flatMap(Json.asArray).flatMap(decodeBytes) - mimeType <- optionalStringField(value, "mime_type") + mimeType <- optionalStringField(value, "mimeType") } yield BinaryValue(bytes, mimeType) case "path" => field(value, "path").flatMap(Json.asString).map(PathValue(_)) case "url" => field(value, "url").flatMap(Json.asString).map(UrlValue(_)) diff --git a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala index 7de32e5268..117a5e56b3 100644 --- a/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala +++ b/cli/golem-cli/src/bridge_gen/scala/runtime/golem/bridge/runtime/StreamSession.scala @@ -204,8 +204,7 @@ object StreamSession { parameters: () => SchemaValue, constructorCodec: PublicValueCodec.Codec, inputCodec: PublicValueCodec.Codec, - outputCodec: Option[PublicValueCodec.Codec], - configCodecs: List[(List[String], PublicValueCodec.Codec)] + outputCodec: Option[PublicValueCodec.Codec] ): Future[AgentInvocationResult] = { implicit val ec: ExecutionContext = resolved.configuration.executionContext val session = new Session(resolved) @@ -730,10 +729,8 @@ object StreamSession { } val base = resolved.configuration.server.url.stripSuffix("/").replaceFirst("^http", "ws") val selector = Json.obj("agentType" -> Json.string(resolved.agentTypeName), "application" -> Json.string(resolved.configuration.appName), "constructorParameters" -> constructorCodec.encode(resolved.parameters), "environment" -> Json.string(resolved.configuration.envName), "method" -> Json.string(method)) - val codecsByPath = configCodecs.toMap val config = Json.arr(resolved.config.map { e => - val codec = codecsByPath.getOrElse(e.path, throw BridgeException(s"missing public config codec for ${e.path.mkString(".")}")) - Json.obj("path" -> Json.arr(e.path.map(Json.string).toVector), "value" -> codec.encode(e.value)) + Json.obj("path" -> Json.arr(e.path.map(Json.string).toVector), "value" -> e.codec.encode(e.value)) }.toVector) pendingAttempt = UUID.randomUUID.toString pendingDescriptor = StreamSessionProtocol.message("invocationStart", Vector("attemptId" -> Json.string(pendingAttempt), "config" -> config, "idempotencyKey" -> Json.string(idempotencyKey), "methodParameters" -> inputCodec.encode(encodedParameters), "selector" -> selector)) diff --git a/cli/golem-cli/src/bridge_gen/typescript/mod.rs b/cli/golem-cli/src/bridge_gen/typescript/mod.rs index c2f0b5c4a8..0a2e13b245 100644 --- a/cli/golem-cli/src/bridge_gen/typescript/mod.rs +++ b/cli/golem-cli/src/bridge_gen/typescript/mod.rs @@ -1115,6 +1115,12 @@ impl TypeScriptBridgeGenerator { /// Generates a type definition and an encode/decode function pair for custom types used /// by the agent. pub(crate) fn generate_ts_type_definitions(&self, writer: &mut TsWriter) -> anyhow::Result<()> { + let needs_config_encoders = self.mode == TypeScriptBridgeMode::ExternalRest + && self + .agent_type + .config + .iter() + .any(|config| config.source == AgentConfigSource::Local); if self.has_external_streams() { for (typ, name) in self.type_naming.types() { writer.write_line(format!( @@ -1132,12 +1138,31 @@ impl TypeScriptBridgeGenerator { self.generate_ts_schema_type_encode(writer, name, typ)?; self.generate_ts_schema_type_decode(writer, name, typ)?; } - if self.has_external_streams() { + if self.has_external_streams() || needs_config_encoders { + let public_type = if self.has_external_streams() { + self.streaming_type_name(name).to_string() + } else { + name.to_string() + }; writer.write_line(format!( "function encodePublic{name}(value: {}, stream: any): any {{ return {}; }}", - self.streaming_type_name(name), + public_type, self.encode_public_value_body("value", typ, "stream")? )); + } + if needs_config_encoders { + let canonical_type = if self.has_external_streams() { + self.streaming_type_name(name).to_string() + } else { + name.to_string() + }; + writer.write_line(format!( + "function encodeCanonical{name}(value: {}): any {{ return {}; }}", + canonical_type, + self.encode_canonical_value_body("value", typ)? + )); + } + if self.has_external_streams() { writer.write_line(format!( "function decodePublic{name}(value: any, stream: any): {} {{ return {}; }}", self.streaming_type_name(name), @@ -1644,21 +1669,24 @@ impl TypeScriptBridgeGenerator { } for (config, param_name) in local_configs.iter().zip(&names.config_parameters) { let path = serde_json::to_string(&config.path)?; - let encoded_value = self.encode_schema_value(param_name, &config.value_type)?; + let public_value = self.encode_public_value_with_stream( + param_name, + &config.value_type, + "((_value: any) => { throw new Error('configuration streams are unsupported'); })", + )?; + let public_value = + self.validate_public_value(&public_value, &config.value_type, "none"); + let canonical_value = self.encode_canonical_value(param_name, &config.value_type)?; writer.write_line(format!("if ({param_name} !== undefined) {{")); writer.indent(); + if !self.has_external_streams() { + writer.write_line(format!("void ({public_value});")); + } writer.write_line(format!( - "{}.push({{ path: {path}, value: {encoded_value} }});", + "{}.push({{ path: {path}, value: {canonical_value} }});", names.agent_config )); if self.has_external_streams() { - let public_value = self.encode_public_value_with_stream( - param_name, - &config.value_type, - "((_value: any) => { throw new Error('configuration streams are unsupported'); })", - )?; - let public_value = - self.validate_public_value(&public_value, &config.value_type, "none"); writer.write_line(format!( "{}.push({{ path: {path}, value: {public_value} }});", names.public_config @@ -1937,6 +1965,16 @@ impl TypeScriptBridgeGenerator { typ: &SchemaType, stream: &str, ) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ $case: 'inline', value: {{ text: v.val, ...(v.languageCode !== undefined ? {{ language: v.languageCode }} : {{}}) }} }}; if(v.tag === 'url') return {{ $case: 'url', value: v.val }}; throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ $case: 'inline', value: {{ bytes: Buffer.from(v.val).toString('base64url'), ...(v.mimeType !== undefined ? {{ mimeType: v.mimeType }} : {{}}) }} }}; if(v.tag === 'url') return {{ $case: 'url', value: v.val }}; throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } Ok(match typ { SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("({value}).toString()"), SchemaType::Bool { .. } @@ -2013,7 +2051,7 @@ impl TypeScriptBridgeGenerator { ) } SchemaType::Binary { .. } => format!( - "({{ bytes: Buffer.from(({value}).bytes).toString('base64'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" + "({{ bytes: Buffer.from(({value}).bytes).toString('base64url'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" ), SchemaType::Text { .. } => value.to_string(), SchemaType::Map { @@ -2101,6 +2139,167 @@ impl TypeScriptBridgeGenerator { }) } + fn encode_canonical_value(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if let Some(name) = self.type_naming.type_name_for_type(typ) { + return Ok(format!("encodeCanonical{name}({value})")); + } + self.encode_canonical_value_body(value, typ) + } + + fn encode_canonical_value_body(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ inline: {{ text: v.val, ...(v.languageCode !== undefined ? {{ language: v.languageCode }} : {{}}) }} }}; if(v.tag === 'url') return {{ url: v.val }}; throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.tag === 'inline') return {{ inline: {{ bytes: Buffer.from(v.val).toString('base64url'), ...(v.mimeType !== undefined ? {{ mimeType: v.mimeType }} : {{}}) }} }}; if(v.tag === 'url') return {{ url: v.val }}; throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } + Ok(match typ { + SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("({value}).toString()"), + SchemaType::Bool { .. } + | SchemaType::S8 { .. } + | SchemaType::S16 { .. } + | SchemaType::S32 { .. } + | SchemaType::U8 { .. } + | SchemaType::U16 { .. } + | SchemaType::U32 { .. } + | SchemaType::Char { .. } + | SchemaType::String { .. } + | SchemaType::Path { .. } + | SchemaType::Url { .. } + | SchemaType::Datetime { .. } => value.to_string(), + SchemaType::F32 { .. } | SchemaType::F64 { .. } => format!( + "((v: number) => {{ if (!Number.isFinite(v)) throw new Error('configuration floats must be finite'); return v; }})({value})" + ), + SchemaType::Duration { .. } => { + format!("({{ nanoseconds: ({value}).toString() }})") + } + SchemaType::Quantity { .. } => format!( + "({{ mantissa: ({value}).mantissa.toString(), scale: ({value}).scale, unit: ({value}).unit }})" + ), + SchemaType::List { element, .. } | SchemaType::FixedList { element, .. } => format!( + "Array.from({value} as Iterable).map((item: any) => {})", + self.encode_canonical_value("item", element)? + ), + SchemaType::Tuple { elements, .. } => format!( + "[{}]", + elements + .iter() + .enumerate() + .map(|(index, element)| self + .encode_canonical_value(&format!("{value}[{index}]"), element)) + .collect::>>()? + .join(", ") + ), + SchemaType::Record { fields, .. } => format!( + "({{{}}})", + fields + .iter() + .zip(self.member_names(fields.iter().map(|field| field.name.as_str()))) + .map(|(field, member)| Ok(format!( + "{}: {}", + serde_json::to_string(&field.name)?, + self.encode_canonical_value(&format!("{value}.{member}"), &field.body)? + ))) + .collect::>>()? + .join(", ") + ), + SchemaType::Option { inner, .. } => format!( + "({value} === undefined ? null : {})", + self.encode_canonical_value(value, inner)? + ), + SchemaType::Binary { .. } => format!( + "({{ bytes: Buffer.from(({value}).bytes).toString('base64url'), ...(({value}).mimeType !== undefined ? {{ mimeType: ({value}).mimeType }} : {{}}) }})" + ), + SchemaType::Text { .. } => value.to_string(), + SchemaType::Map { + key, value: item, .. + } => format!( + "Array.from(({value} as Map).entries()).map(([key,val]) => [{}, {}])", + self.encode_canonical_value("key", key)?, + self.encode_canonical_value("val", item)? + ), + SchemaType::Enum { .. } => value.to_string(), + SchemaType::Flags { flags, .. } => { + let names = self.member_names(flags.iter().map(String::as_str)); + format!( + "[{}].filter((entry: [string, string]) => ({value} as any)[entry[1]]).map((entry: [string, string]) => entry[0])", + flags + .iter() + .zip(names) + .map(|(wire, member)| Ok(format!( + "[{}, {}]", + serde_json::to_string(wire)?, + serde_json::to_string(&member)? + ))) + .collect::>>()? + .join(",") + ) + } + SchemaType::Variant { cases, .. } => format!( + "((v:any) => {{ {} throw new Error('unknown variant'); }})({value})", + cases + .iter() + .map(|case| Ok(match &case.payload { + Some(payload) => format!( + "if(v.tag === {:?}) return {{ {:?}: {} }};", + case.name, + case.name, + self.encode_canonical_value("v.val", payload)? + ), + None => format!("if(v.tag === {:?}) return {:?};", case.name, case.name), + })) + .collect::>>()? + .join(" ") + ), + SchemaType::Result { spec, .. } => { + let ok = spec + .ok + .as_deref() + .map(|payload| self.encode_canonical_value("v.ok", payload)) + .transpose()? + .unwrap_or_else(|| "null".to_string()); + let err = spec + .err + .as_deref() + .map(|payload| self.encode_canonical_value("v.err", payload)) + .transpose()? + .unwrap_or_else(|| "null".to_string()); + format!("((v:any) => 'ok' in v ? {{ ok: {ok} }} : {{ err: {err} }})({value})") + } + SchemaType::Union { spec, .. } => format!( + "((v:any) => {{ {} throw new Error('unknown union'); }})({value})", + spec.branches + .iter() + .map(|branch| Ok(format!( + "if(v.tag === {:?}) return {};", + branch.tag, + self.encode_canonical_value("v.val", &branch.body)? + ))) + .collect::>>()? + .join(" ") + ), + SchemaType::Ref { id, .. } => { + let definition = self + .type_naming + .graph() + .lookup(id) + .ok_or_else(|| anyhow!("missing canonical schema ref {id}"))?; + self.encode_canonical_value(value, &definition.body)? + } + SchemaType::Secret { .. } + | SchemaType::QuotaToken { .. } + | SchemaType::PermissionCard { .. } + | SchemaType::Future { .. } + | SchemaType::Stream { .. } => format!( + "((_: any) => {{ throw new Error('unsupported configuration value'); }})({value})" + ), + }) + } + fn decode_public_value(&self, value: &str, typ: &SchemaType) -> anyhow::Result { if let Some(name) = self.type_naming.type_name_for_type(typ) { return Ok(format!("decodePublic{name}({value}, stream)")); @@ -2109,6 +2308,16 @@ impl TypeScriptBridgeGenerator { } fn decode_public_value_body(&self, value: &str, typ: &SchemaType) -> anyhow::Result { + if unstructured_text_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.$case === 'inline') return base.UnstructuredText.fromInline(v.value.text, v.value.language); if(v.$case === 'url') return base.UnstructuredText.fromUrl(v.value); throw new Error('unknown unstructured text variant'); }})({value})" + )); + } + if unstructured_binary_restrictions(self.type_naming.graph(), typ)?.is_some() { + return Ok(format!( + "((v:any) => {{ if(v.$case === 'inline') return base.UnstructuredBinary.fromInline(Uint8Array.from(Buffer.from(v.value.bytes, 'base64url')), v.value.mimeType); if(v.$case === 'url') return base.UnstructuredBinary.fromUrl(v.value); throw new Error('unknown unstructured binary variant'); }})({value})" + )); + } Ok(match typ { SchemaType::S64 { .. } | SchemaType::U64 { .. } => format!("BigInt({value})"), SchemaType::Stream { @@ -2163,7 +2372,7 @@ impl TypeScriptBridgeGenerator { self.decode_public_value(&format!("({value} as any).value"), inner)? ), SchemaType::Binary { .. } => format!( - "({{ bytes: Uint8Array.from(Buffer.from(({value} as any).bytes, 'base64')), mimeType: ({value} as any).mimeType }})" + "({{ bytes: Uint8Array.from(Buffer.from(({value} as any).bytes, 'base64url')), mimeType: ({value} as any).mimeType }})" ), SchemaType::Duration { .. } => format!("BigInt(({value} as any).nanoseconds)"), SchemaType::Quantity { .. } => format!( @@ -3907,6 +4116,13 @@ impl TypeScriptBridgeGenerator { } fn streaming_type_definition(&self, typ: &SchemaType) -> anyhow::Result { + if let Some(restrictions) = unstructured_text_restrictions(self.type_naming.graph(), typ)? { + return Ok(self.unstructured_text_type(restrictions)); + } + if let Some(restrictions) = unstructured_binary_restrictions(self.type_naming.graph(), typ)? + { + return Ok(self.unstructured_binary_type(restrictions)); + } Ok(match self.resolve_ref(typ) { SchemaType::Variant { cases, .. } => cases .iter() @@ -4193,6 +4409,13 @@ impl TypeScriptBridgeGenerator { } fn type_definition(&self, typ: &SchemaType) -> anyhow::Result { + if let Some(restrictions) = unstructured_text_restrictions(self.type_naming.graph(), typ)? { + return Ok(self.unstructured_text_type(restrictions)); + } + if let Some(restrictions) = unstructured_binary_restrictions(self.type_naming.graph(), typ)? + { + return Ok(self.unstructured_binary_type(restrictions)); + } // Resolve through `Ref` so the body shape drives the type definition. let resolved = self.resolve_ref(typ); match resolved { diff --git a/cli/golem-cli/tests/app/mod.rs b/cli/golem-cli/tests/app/mod.rs index ce3d986195..b2cfdd4a12 100644 --- a/cli/golem-cli/tests/app/mod.rs +++ b/cli/golem-cli/tests/app/mod.rs @@ -27,6 +27,7 @@ mod mcp_import; mod mcp_oauth; mod moonbit_guest_streams; mod moonbit_http_router; +mod moonbit_reflection; mod moonbit_tool_middleware; mod plugins; mod remote_releases; @@ -60,6 +61,7 @@ tag_suite!(mcp_import, agents_guest_bridge); tag_suite!(mcp_oauth, agents_guest_bridge); tag_suite!(moonbit_guest_streams, agents_guest_bridge); tag_suite!(moonbit_http_router, deploy); +tag_suite!(moonbit_reflection, deploy); tag_suite!(moonbit_tool_middleware, deploy); tag_suite!(plugins, deploy); tag_suite!(rust_streams, agents_guest_bridge); diff --git a/cli/golem-cli/tests/app/moonbit_reflection.rs b/cli/golem-cli/tests/app/moonbit_reflection.rs new file mode 100644 index 0000000000..6c8cfeb572 --- /dev/null +++ b/cli/golem-cli/tests/app/moonbit_reflection.rs @@ -0,0 +1,162 @@ +use crate::Tracing; +use crate::app::{TestContext, cmd, flag}; +use golem_cli::{fs, versions}; +use indoc::{formatdoc, indoc}; +use test_r::{inherit_test_dep, test, timeout}; +use uuid::Uuid; + +inherit_test_dep!(Tracing); + +async fn deployed_moonbit_reflection_context() -> TestContext { + let mut ctx = TestContext::new(); + ctx.start_server().await; + fs::create_dir_all(ctx.cwd_path_join("moonbit-reflection")).unwrap(); + ctx.cd("moonbit-reflection"); + let created = ctx + .cli([flag::YES, cmd::NEW, ".", flag::TEMPLATE, "moonbit"]) + .await; + assert!(created.success_or_dump()); + + fs::write_str( + ctx.cwd_path_join("golem.yaml"), + formatdoc! {r#" + manifestVersion: {version} + app: moonbit-reflection + environments: + local: + server: local + componentPresets: debug + components: + moonbit-reflection:main: + dir: . + templates: moonbit + tools: + reflection-conformance: {{}} + agents: + ReflectionCaller: + tools: + reflection-conformance: {{}} + "#, version = versions::sdk::MANIFEST}, + ) + .unwrap(); + + let package_path = ctx.cwd_path_join("moon.pkg"); + let package = fs::read_to_string(&package_path).unwrap().replace( + "import {", + "import {\n \"golemcloud/golem_sdk/reflection\",", + ); + fs::write_str(package_path, package).unwrap(); + fs::write_str( + ctx.cwd_path_join("reflection.mbt"), + indoc! {r#" + #derive.tool("reflection-conformance") + struct ReflectionConformanceTool {} + + #warnings("-struct_never_constructed") + struct Meters {} + + impl @schema.QuantityUnit for Meters with fn type_id() { + "reflection-conformance.Meters" + } + + impl @schema.QuantityUnit for Meters with fn base_unit() { "m" } + + impl @schema.QuantityUnit for Meters with fn allowed_suffixes() { [] } + + #derive.arg("maybe", scope="option") + pub fn ReflectionConformanceTool::canonical( + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[Meters], + maybe : String?, + ) -> String { + let quantity = quantity.value() + if signed == -9223372036854775807L - 1L && + unsigned == 18446744073709551615UL && + duration == @schema.Duration::new(9223372036854775807L) && + quantity.mantissa == -9223372036854775807L - 1L && + quantity.scale == -9 && + quantity.unit == "m" && + maybe is None { + "moonbit-canonical-ok" + } else { + "moonbit-canonical-mismatch" + } + } + + #derive.agent + struct ReflectionCaller { name : String } + + fn ReflectionCaller::new(name : String) -> ReflectionCaller { { name, } } + + fn describe_tool_error( + error : @tool.ToolError[@reflection.ReflectedToolCustomError], + ) -> String { + match error { + Rpc(error) => "rpc:\{Repr(error)}" + RemoteTool(error) => "remote:\{Repr(error)}" + Tool(custom) => { + @model.drop_owned_capabilities(custom.payload.value) + "tool" + } + UnknownToolError(_, payload) => { + @model.drop_owned_capabilities(payload.value) + "unknown" + } + InvalidInput(message) => "input:\{message}" + MalformedRemoteOutput(message) => "output:\{message}" + } + } + + pub async fn ReflectionCaller::run(self : Self) -> String { + ignore(self) + let tool = @reflection.get_tool_type("reflection-conformance") catch { + error => return "discovery:\{Repr(error)}" + } + let command = tool.command(["canonical"]) catch { + error => return "command:\{Repr(error)}" + } + let canonical = command.invoke_json(Json::object({ + "signed": Json::string("-9223372036854775808"), + "unsigned": Json::string("18446744073709551615"), + "duration": Json::object({ + "nanoseconds": Json::string("9223372036854775807"), + }), + "quantity": Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(-9.0), + "unit": Json::string("m"), + }), + })) + match canonical { + Ok(Some(Json::String(value))) => value + Ok(_) => "canonical-error:unexpected" + Err(error) => "canonical-error:\{describe_tool_error(error)}" + } + } + "#}, + ) + .unwrap(); + + assert!(ctx.cli([cmd::BUILD]).await.success_or_dump()); + assert!(ctx.cli([cmd::DEPLOY, flag::YES]).await.success_or_dump()); + ctx +} + +#[test] +#[timeout("20 minutes")] +async fn test_moonbit_reflected_invoke_json_preserves_canonical_wide_values() { + let ctx = deployed_moonbit_reflection_context().await; + let output = ctx + .cli([ + flag::YES, + cmd::AGENT, + cmd::INVOKE, + &format!("ReflectionCaller(\"{}\")", Uuid::new_v4()), + "run", + ]) + .await; + assert!(output.success_or_dump()); + assert!(output.stdout_contains("moonbit-canonical-ok")); +} diff --git a/cli/golem-cli/tests/app/scala_guest_streams.rs b/cli/golem-cli/tests/app/scala_guest_streams.rs index bba48b14b2..4e713c30f7 100644 --- a/cli/golem-cli/tests/app/scala_guest_streams.rs +++ b/cli/golem-cli/tests/app/scala_guest_streams.rs @@ -119,12 +119,19 @@ async fn deployed_scala_streams_context() -> TestContext { fs::write_str(scala_dir.join("consumer/StreamConsumer.scala"), indoc! {r#" package consumer import golem.BaseAgent - import golem.runtime.annotations.{agentDefinition, agentImplementation} + import golem.reflection.{GolemReflectError, ToolClientDefinition} + import golem.runtime.annotations.{agentDefinition, agentImplementation, toolDefinition} import golem.schema.AgentStream + import golem.tool.ToolRpcFailure import golem.bridge.client.stream_provider.{StreamProviderClient, Item, Bundle} import scala.concurrent.Future import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + @toolDefinition(name = "scala-construction-probe") + trait ConstructionProbeTool { + def ping(): String + } + @agentDefinition() trait StreamConsumer extends BaseAgent { class Id(val name: String) @@ -133,6 +140,7 @@ async fn deployed_scala_streams_context() -> TestContext { def cancel(): Future[String] def recoverable(): Future[String] def fatal(): Future[String] + def invalidToolTarget(): String } @agentImplementation() final class StreamConsumerImpl(private val name: String) extends StreamConsumer { @@ -214,6 +222,17 @@ async fn deployed_scala_streams_context() -> TestContext { val provider = StreamProviderClient.get(name + "-fatal") provider.malformed().flatMap(collect).map(_ => "unexpected-clean-eof") } + def invalidToolTarget(): String = { + val definition = ToolClientDefinition.named("NOT-valid")( + (target: String) => ConstructionProbeToolClient(target) + ) + definition.client match { + case Left(GolemReflectError.ToolRpc(ToolRpcFailure.ProtocolError(message))) => + "scala-invalid-tool-recovered:" + message + case Left(error) => "unexpected-error:" + error.toString + case Right(_) => "unexpected-success" + } + } } "#}).unwrap(); // Guest bridge sources belong to the consumer, not the provider's discovery input. @@ -243,6 +262,17 @@ async fn test_scala_agent_guest_streams_e2e() { .await; assert!(output.success_or_dump()); assert!(output.stdout_contains("scala-streams-ok")); + let invalid_target = ctx + .cli([ + flag::YES, + cmd::AGENT, + cmd::INVOKE, + "StreamConsumer(\"test\")", + "invalidToolTarget", + ]) + .await; + assert!(invalid_target.success_or_dump()); + assert!(invalid_target.stdout_contains("scala-invalid-tool-recovered:")); for (method, expected) in [("cancel", "scala-cancel-ok"), ("nested", "scala-nested-ok")] { let output = tokio::time::timeout( std::time::Duration::from_secs(120), diff --git a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs index 7b86a5dacd..8b31bf7f31 100644 --- a/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs +++ b/cli/golem-cli/tests/bridge_gen/effect_bridge_external.rs @@ -9,6 +9,10 @@ use golem_cli::bridge_gen::BridgeGenerator; use golem_cli::bridge_gen::effect::effect_external::EffectExternalBridgeGenerator; use golem_common::model::agent::AgentMode; use golem_common::schema::SchemaType; +use golem_common::schema::schema_type::{BinaryRestrictions, TextRestrictions}; +use golem_common::schema::unstructured::{ + unstructured_binary_schema_type, unstructured_text_schema_type, +}; use tempfile::TempDir; use test_r::test; @@ -20,11 +24,29 @@ fn effect_external_all_schema_streaming_consumer_compiles() { "MappedInput", SchemaType::record(vec![named_field("item_stream", SchemaType::string())]), )); + schema.schema.defs.push(def( + "Document", + unstructured_text_schema_type(TextRestrictions::default()), + )); + schema.schema.defs.push(def( + "Attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), + )); schema.methods.push(method( "rename", vec![field("payload", ref_to("MappedInput"))], None, )); + schema.methods.push(method( + "document", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(ref_to("Document")), + )); + schema.methods.push(method( + "attachment", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(ref_to("Attachment")), + )); schema .methods .iter_mut() @@ -62,6 +84,15 @@ fn effect_external_all_schema_streaming_consumer_compiles() { !source.contains("Effect.Effect>() + .join(", "); + let config_expected = case("config/canonical-entry")["expected"].to_string(); + let request_expected = format!( + "{{\"appName\":\"app\",\"envName\":\"env\",\"agentTypeName\":\"ConfigAgent\",\"parameters\":{{\"kind\":\"tuple\",\"value\":{{\"elements\":[]}}}},\"config\":[{config_expected},{{\"path\":[\"optional\"],\"value\":\"west\"}}]}}" + ); + + let mut agent_type = agent( + "ConfigAgent", + "moonbit", + vec![], + vec![method("ping", vec![], None)], + vec![], + AgentMode::Durable, + ); + agent_type.config = vec![local_config(vec!["limits", "maximum"], SchemaType::s64())]; + let pkg = GeneratedPackage::new(agent_type); + let source = r#"test "reflection corpus bridge wire cases" { + let mime_type = "application/octet-stream" + let binary_value = BinaryValue(b"\xfb\xff", Some(mime_type)) + assert_eq( + schema_value_to_json(binary_value).stringify(), + "{\"kind\":\"binary\",\"value\":{\"bytes\":[251,255],\"mimeType\":\"application/octet-stream\"}}", + ) + let binary_codec = public_value_codec( + "{\"root\":{\"kind\":\"binary\",\"value\":{\"restrictions\":{}}}}", + ) + assert_eq(binary_codec.encode(binary_value).stringify(), __BINARY_EXPECTED__) + for input in [__INVALID_BINARY__] { + try binary_codec.decode(@json.parse(input)) catch { + BridgeError(_) => () + _ => fail("expected noncanonical binary JSON to be rejected") + } noraise { + _ => fail("expected noncanonical binary JSON to be rejected") + } + } + let s64_codec = public_value_codec( + "{\"root\":{\"kind\":\"s64\",\"value\":{}}}", + ) + let entry = AgentConfigEntry::{ + path: ["limits", "maximum"], + value: S64Value(9223372036854775807L), + codec: s64_codec, + } + let option_codec = public_value_codec( + "{\"root\":{\"kind\":\"option\",\"value\":{\"inner\":{\"kind\":\"string\",\"value\":{}}}}}", + ) + let option_value = OptionValue(Some(StringValue("west"))) + assert_eq( + option_codec.encode(option_value).stringify(), + "{\"$option\":\"some\",\"value\":\"west\"}", + ) + assert_eq(option_codec.encode_canonical(option_value).stringify(), "\"west\"") + let variant_codec = public_value_codec( + "{\"root\":{\"kind\":\"variant\",\"value\":{\"cases\":[{\"name\":\"payload\",\"payload\":{\"kind\":\"string\",\"value\":{}}}]}}}", + ) + assert_eq( + variant_codec + .encode_canonical(VariantValue(0, Some(StringValue("value")))) + .stringify(), + "{\"payload\":\"value\"}", + ) + let result_codec = public_value_codec( + "{\"root\":{\"kind\":\"result\",\"value\":{\"spec\":{\"ok\":{\"kind\":\"string\",\"value\":{}}}}}}", + ) + assert_eq( + result_codec + .encode_canonical(ResultValue(ResultOk(Some(StringValue("ready"))))) + .stringify(), + "{\"ok\":\"ready\"}", + ) + let union_codec = public_value_codec( + "{\"root\":{\"kind\":\"union\",\"value\":{\"spec\":{\"branches\":[{\"tag\":\"command\",\"body\":{\"kind\":\"string\",\"value\":{}},\"discriminator\":{\"rule\":\"prefix\",\"value\":{\"prefix\":\"cmd:\"}}}]}}}}", + ) + assert_eq( + union_codec + .encode_canonical(UnionValue("command", StringValue("cmd:run"))) + .stringify(), + "\"cmd:run\"", + ) + let option_entry = AgentConfigEntry::{ + path: ["optional"], + value: option_value, + codec: option_codec, + } + let request = encode_create_agent_request( + "app", + "env", + "ConfigAgent", + TupleValue([]), + None, + [entry, option_entry], + ) + assert_eq(request.stringify(), __REQUEST_EXPECTED__) +} +"# + .replace( + "__BINARY_EXPECTED__", + &serde_json::to_string(&binary_expected).unwrap(), + ) + .replace("__INVALID_BINARY__", &invalid_binary) + .replace( + "__REQUEST_EXPECTED__", + &serde_json::to_string(&request_expected).unwrap(), + ); + std::fs::write( + pkg.module_dir().join("runtime/reflection_wire_wbtest.mbt"), + source, + ) + .unwrap(); + pkg.test_native(); +} + /// An ephemeral agent must not get a `get_with_config` (no parameter-addressable /// `get`), but still gets the phantom config variants. #[test] @@ -2522,7 +2660,11 @@ fn external_recursive_stream_client_compiles() { std::fs::write(&mod_path, serde_json::to_string_pretty(&module).unwrap()).unwrap(); let manifest_path = pkg.module_dir().join("runtime/moon.pkg"); let mut manifest = std::fs::read_to_string(&manifest_path).unwrap(); - manifest = manifest.replacen("import {", "import {\n \"moonbitlang/x/fs\" @fs,", 1); + manifest = manifest.replacen( + "import {", + "import {\n \"moonbitlang/core/encoding/base64\",\n \"moonbitlang/x/fs\" @fs,", + 1, + ); std::fs::write(&manifest_path, manifest).unwrap(); std::fs::write( pkg.module_dir().join("runtime/frozen_fixture_wbtest.mbt"), @@ -2539,7 +2681,7 @@ test "binary codec matches every frozen public v1 frame" { let pair = payload_hex.substring(start=i * 2, end=i * 2 + 2) @string.parse_int(pair[:], base=16).to_byte() }) - let actual = pvc_base64_encode(encode_binary_envelope(metadata, payload)) + let actual = @base64.encode(encode_binary_envelope(metadata, payload), padding=true) assert_true(actual == expect_string(get_field(object, "frameBase64"))) } } diff --git a/cli/golem-cli/tests/bridge_gen/rust.rs b/cli/golem-cli/tests/bridge_gen/rust.rs index 4abfb5b16e..8639a5cabf 100644 --- a/cli/golem-cli/tests/bridge_gen/rust.rs +++ b/cli/golem-cli/tests/bridge_gen/rust.rs @@ -30,7 +30,9 @@ use golem_common::schema::agent::{ }; use golem_common::schema::graph::{SchemaGraph, SchemaTypeDef}; use golem_common::schema::metadata::TypeId; -use golem_common::schema::schema_type::{BinaryRestrictions, TextRestrictions}; +use golem_common::schema::schema_type::{ + BinaryRestrictions, NumericBound, NumericRestrictions, TextRestrictions, +}; use golem_common::schema::tool::{ BoolFlagShape, CommandBody, CommandIndex, CommandNode, CommandTree, Doc, ErrorCase, ErrorKind, FlagShape, FlagSpec, Globals, OptionShape, OptionSpec, Positional, Positionals, @@ -350,6 +352,51 @@ fn bridge_rust_ephemeral_agent_skips_non_phantom_constructors() { )); } +#[test] +fn bridge_rust_external_rest_config_uses_canonical_json_and_session_public_json() { + let dir = TempDir::new().unwrap(); + let target_dir = Utf8Path::from_path(dir.path()).unwrap(); + let mut agent_type = agent( + "ConfigAgent", + "rust", + vec![], + vec![], + vec![], + AgentMode::Durable, + ); + agent_type.config = vec![local_config( + vec!["limits", "maximum"], + SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + )]; + let package_dir = target_dir.join(bridge_client_directory_name( + &agent_type.type_name, + BridgeMode::External, + )); + RustBridgeGenerator::new(agent_type, &package_dir, true) + .unwrap() + .generate() + .unwrap(); + + let source = std::fs::read_to_string(package_dir.join("src/lib.rs")).unwrap(); + assert!(source.contains( + "let __public_config_json = golem_client::invocation_session::encode_generated_streamless_value(" + )); + assert!( + source + .contains("let __canonical_config_json = golem_common::schema::render::to_json_value(") + ); + assert!(source.contains("value: __public_config_json")); + assert!(source.contains("value: __canonical_config_json.into()")); + assert!(!source.contains("serde_json::to_value(&__config_value)")); +} + #[test] fn bridge_rust_ephemeral_metadata_wrapper_does_not_collide_with_schema_type() { let dir = TempDir::new().unwrap(); diff --git a/cli/golem-cli/tests/bridge_gen/scala.rs b/cli/golem-cli/tests/bridge_gen/scala.rs index 11650a339f..a12bc0cbd8 100644 --- a/cli/golem-cli/tests/bridge_gen/scala.rs +++ b/cli/golem-cli/tests/bridge_gen/scala.rs @@ -419,6 +419,13 @@ fn stream_runtime_state_semantics_execute() { ) .unwrap(); } + std::fs::copy( + workspace_root() + .unwrap() + .join("test-data/reflection-conformance/v1.json"), + pkg.package_dir().join("reflection-conformance.json"), + ) + .unwrap(); run_sbt_test( pkg.package_dir().as_path(), r#"package golem.bridge.runtime @@ -431,6 +438,15 @@ import golem.bridge.runtime.StreamSessionState.* import golem.bridge.runtime.json.Json class StreamRuntimeTest extends munit.FunSuite { + private val reflectionCorpus = Json.parse( + java.nio.file.Files.readString(java.nio.file.Path.of("reflection-conformance.json")) + ).toOption.get + + private def corpusCase(id: String): Json = + Json.asArray(Json.requireField(reflectionCorpus, "cases").toOption.get).toOption.get + .find(value => Json.field(value, "id").flatMap(Json.asString(_).toOption).contains(id)) + .getOrElse(fail(s"missing reflection conformance case $id")) + test("text codec directly consumes frozen canonical and malformed fixtures") { val messages = Json.parse(java.nio.file.Files.readString(java.nio.file.Path.of("json-messages.json"))).toOption.get Json.asArray(Json.requireField(messages, "vectors").toOption.get).toOption.get.foreach { vector => @@ -505,11 +521,125 @@ class StreamRuntimeTest extends munit.FunSuite { SchemaValue.BinaryValue(Vector[Byte](0, -1), Some("application/octet-stream")) )) val encoded = codec.encode(value) - assertEquals(encoded.render, """{"count":"-9223372036854775808","data":{"bytes":"AP8=","mimeType":"application/octet-stream"}}""") + assertEquals(encoded.render, """{"count":"-9223372036854775808","data":{"bytes":"AP8","mimeType":"application/octet-stream"}}""") assertEquals(codec.decode(encoded), value) intercept[BridgeException](codec.decode(Json.parse("""{"count":"1","data":{"bytes":"AA==","mimeType":"application/octet-stream"}}""").toOption.get)) } + test("reflection corpus drives tagged binary public binary and config request wires") { + val binaryCase = corpusCase("canonical/binary-mime") + val binaryExpected = Json.requireField(binaryCase, "expected").toOption.get + val mimeType = Json.asString(Json.requireField(binaryExpected, "mimeType").toOption.get).toOption.get + val binaryValue = SchemaValue.BinaryValue(Vector[Byte](-5, -1), Some(mimeType)) + assertEquals( + SchemaValueCodec.toJson(binaryValue).render, + """{"kind":"binary","value":{"bytes":[251,255],"mimeType":"application/octet-stream"}}""" + ) + + val binaryCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"binary","value":{"restrictions":{}}}}""" + ) + assertEquals(binaryCodec.encode(binaryValue), binaryExpected) + val rejected = Json.asArray( + Json.requireField(corpusCase("errors/binary-noncanonical-base64"), "inputs").toOption.get + ).toOption.get + rejected.foreach(input => intercept[BridgeException](binaryCodec.decode(input))) + + val configCase = corpusCase("config/canonical-entry") + val configExpected = Json.requireField(configCase, "expected").toOption.get + val path = Json.asArray(Json.requireField(configExpected, "path").toOption.get).toOption.get + .map(value => Json.asString(value).toOption.get).toList + val s64Codec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"s64","value":{}}}""" + ) + val optionCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"option","value":{"inner":{"kind":"string","value":{}}}}}""" + ) + val optionValue = SchemaValue.OptionValue(Some(SchemaValue.StringValue("west"))) + assertEquals(optionCodec.encode(optionValue).render, """{"$option":"some","value":"west"}""") + assertEquals(optionCodec.encodeCanonical(optionValue).render, """"west"""") + val variantCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"variant","value":{"cases":[{"name":"payload","payload":{"kind":"string","value":{}}}]}}}""" + ) + assertEquals( + variantCodec.encodeCanonical(SchemaValue.VariantValue(0, Some(SchemaValue.StringValue("value")))).render, + """{"payload":"value"}""" + ) + val resultCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"result","value":{"spec":{"ok":{"kind":"string","value":{}}}}}}""" + ) + assertEquals( + resultCodec.encodeCanonical(SchemaValue.ResultValue(SchemaResult.Ok(Some(SchemaValue.StringValue("ready"))))).render, + """{"ok":"ready"}""" + ) + val unionCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"union","value":{"spec":{"branches":[{"tag":"command","body":{"kind":"string","value":{}},"discriminator":{"rule":"prefix","value":{"prefix":"cmd:"}}}]}}}}""" + ) + assertEquals( + unionCodec.encodeCanonical(SchemaValue.UnionValue("command", SchemaValue.StringValue("cmd:run"))).render, + """"cmd:run"""" + ) + val request = CreateAgentRequest( + "app", + "env", + "ConfigAgent", + SchemaValue.TupleValue(List.empty), + None, + List( + AgentConfigEntry(path, SchemaValue.S64Value(Long.MaxValue), s64Codec), + AgentConfigEntry(List("optional"), optionValue, optionCodec) + ) + ) + val envelope = BridgeProtocol.encodeCreateAgentRequest(request) + val config = Json.asArray(Json.requireField(envelope, "config").toOption.get).toOption.get + assertEquals( + config, + Vector(configExpected, Json.parse("""{"path":["optional"],"value":"west"}""").toOption.get) + ) + assertEquals(Json.requireField(config.head, "value").toOption.get, Json.string(Long.MaxValue.toString)) + + val floatCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"f64","value":{}}}""" + ) + val call = scala.util.Try( + Bridge.createAgent( + Configuration(GolemServer.Custom("http://127.0.0.1:1", "token"), "app", "env", global), + "ConfigAgent", + SchemaValue.TupleValue(List.empty), + None, + List(AgentConfigEntry(List("float"), SchemaValue.F64Value(Double.NaN), floatCodec)) + ) + ) + assert(call.isSuccess) + intercept[BridgeException](Await.result(call.get, 1.second)) + + val tupleCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"tuple","value":{"elements":[]}}}""" + ) + val boundedCodec = PublicValueCodec.fromSchemaGraphJson( + """{"root":{"kind":"u32","value":{"restrictions":{"min":{"kind":"unsigned","value":10}}}}}""" + ) + val streamingCall = scala.util.Try( + Bridge.invokeStreamingAgent( + ResolvedAgent( + Configuration(GolemServer.Custom("http://127.0.0.1:1", "token"), "app", "env", global), + "ConfigAgent", + SchemaValue.U32Value(1), + None, + List.empty, + None + ), + "stream", + () => SchemaValue.TupleValue(List.empty), + boundedCodec, + tupleCodec, + None + ) + ) + assert(streamingCall.isSuccess) + intercept[BridgeException](Await.result(streamingCall.get, 1.second)) + } + test("public codec enforces quantity bounds restrictions unions and integer boundaries") { val quantity = PublicValueCodec.fromSchemaGraphJson("""{"root":{"kind":"quantity","value":{"spec":{"baseUnit":"m","allowedSuffixes":["cm"],"min":{"mantissa":1,"scale":0,"unit":"m"},"max":{"mantissa":200,"scale":0,"unit":"m"}}}}}""") val q = SchemaValue.QuantityValue(150, 0, "cm") @@ -621,7 +751,6 @@ class StreamRuntimeTest extends munit.FunSuite { codec, codec, None, - List.empty, ) intercept[Throwable](Await.result(stream.consume(), 1.second)) @@ -1779,10 +1908,10 @@ fn local_config_overrides_compile() { // Supplied overrides build `AgentConfigEntry` values keyed by the path. assert!(client.contains( - "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"db\", \"host\"), configValue)" + "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"db\", \"host\"), configValue, _root_.golem.bridge.runtime.PublicValueCodec.fromSchemaGraphJson(" )); assert!(client.contains( - "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"max-retries\"), configValue)" + "_root_.golem.bridge.runtime.AgentConfigEntry(_root_.scala.collection.immutable.List(\"max-retries\"), configValue, _root_.golem.bridge.runtime.PublicValueCodec.fromSchemaGraphJson(" )); // The plain constructors pass an empty config list. diff --git a/cli/golem-cli/tests/bridge_gen/typescript.rs b/cli/golem-cli/tests/bridge_gen/typescript.rs index 4711962f28..20333beb71 100644 --- a/cli/golem-cli/tests/bridge_gen/typescript.rs +++ b/cli/golem-cli/tests/bridge_gen/typescript.rs @@ -304,6 +304,100 @@ fn static_and_instance_agent_methods_can_share_names() { } } +#[test] +fn external_rest_config_uses_canonical_json_for_named_non_streaming_values() { + let dir = TempDir::new().unwrap(); + let target = Utf8Path::from_path(dir.path()).unwrap(); + let mut agent_type = agent( + "ConfigAgent", + "typescript", + vec![], + vec![], + vec![def( + "config-shape", + SchemaType::record(vec![ + named_field("maybe-region", SchemaType::option(SchemaType::string())), + named_field( + "outcome", + SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: None, + }), + ), + named_field( + "mode", + SchemaType::variant(vec![ + variant_case("off", None), + variant_case("on", Some(SchemaType::string())), + ]), + ), + named_field( + "target", + SchemaType::union(UnionSpec { + branches: vec![UnionBranch { + tag: "command".to_string(), + body: SchemaType::string(), + discriminator: DiscriminatorRule::Prefix { + prefix: "cmd:".to_string(), + }, + metadata: MetadataEnvelope::default(), + }], + }), + ), + named_field( + "document", + unstructured_text_schema_type(TextRestrictions::default()), + ), + named_field( + "attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), + ), + ]), + )], + AgentMode::Durable, + ); + agent_type.config = vec![local_config( + vec!["limits", "maximum"], + ref_to("config-shape"), + )]; + let package_dir = target.join("config-agent-client"); + TypeScriptBridgeGenerator::new_with_mode( + agent_type, + &package_dir, + true, + TypeScriptBridgeMode::ExternalRest, + ) + .unwrap() + .generate() + .unwrap(); + + let source = std::fs::read_to_string(package_dir.join("config-agent-client.ts")).unwrap(); + assert!(source.contains("function encodeCanonicalConfigShape(value: ConfigShape): any")); + assert!(source.contains("value: encodeCanonicalConfigShape(configLimitsMaximum)")); + assert!( + source.contains("function encodePublicConfigShape(value: ConfigShape, stream: any): any") + ); + let canonical = source + .lines() + .find(|line| line.starts_with("function encodeCanonicalConfigShape")) + .expect("missing canonical config encoder"); + assert!(canonical.contains("'ok' in v ? { ok:")); + assert!(canonical.contains("unknown variant")); + assert!(canonical.contains("if(v.tag === \"command\") return")); + assert!(canonical.contains("if(v.tag === 'inline') return { inline: { text: v.val")); + assert!(canonical.contains( + "if(v.tag === 'inline') return { inline: { bytes: Buffer.from(v.val).toString('base64url')" + )); + assert!( + source.contains("if(v.tag === 'inline') return { $case: 'inline', value: { text: v.val") + ); + assert!(source.contains( + "if(v.tag === 'inline') return { $case: 'inline', value: { bytes: Buffer.from(v.val).toString('base64url')" + )); + assert!(source.contains("void (")); + install_and_build(&package_dir); +} + #[test] fn guest_agent_runtime_import_alias_does_not_collide_with_agent_class() { let dir = TempDir::new().unwrap(); @@ -1030,8 +1124,27 @@ fn external_streaming_generation_compiles_recursive_streams() { )))), ), method("status", vec![], Some(SchemaType::string())), + method( + "document", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(ref_to("Document")), + ), + method( + "attachment", + vec![field("input", SchemaType::stream(Some(SchemaType::u8())))], + Some(ref_to("Attachment")), + ), + ], + vec![ + def( + "Document", + unstructured_text_schema_type(TextRestrictions::default()), + ), + def( + "Attachment", + unstructured_binary_schema_type(BinaryRestrictions::default()), + ), ], - vec![], AgentMode::Durable, ); agent_type.config = vec![local_config( @@ -1052,6 +1165,8 @@ fn external_streaming_generation_compiles_recursive_streams() { .unwrap(); assert!(source.contains("createStreamingRemoteMethod")); assert!(source.contains("AgentStream")); + assert!(source.contains("toString('base64url')")); + assert!(source.contains("'base64url'")); assert!(source.contains("AgentStream")); assert!(source.contains(": bigint;")); assert!(source.contains("amount: base.QuantityValue")); @@ -1062,6 +1177,25 @@ fn external_streaming_generation_compiles_recursive_streams() { assert!(source.contains("\"stable\"")); assert!(source.contains("config: this.publicConfig")); assert!(source.contains("path: [\"limits\",\"maximum\"]")); + assert!(source.contains( + "if(v.$case === 'inline') return base.UnstructuredText.fromInline(v.value.text, v.value.language)" + )); + assert!(source.contains( + "if(v.$case === 'inline') return base.UnstructuredBinary.fromInline(Uint8Array.from(Buffer.from(v.value.bytes, 'base64url')), v.value.mimeType)" + )); + for line in source + .lines() + .filter(|line| line.contains(".push({ path: [\"limits\",\"maximum\"]")) + { + assert!( + line.contains(".toString()"), + "config is not public JSON: {line}" + ); + assert!( + !line.contains("kind:"), + "config is tagged schema JSON: {line}" + ); + } assert!(source.contains("val: 9007199254740993n")); assert!(source.contains("val: 18446744073709551615n")); assert!(!source.contains("triggerExchange")); diff --git a/docs/src/content/next/concepts/_meta.js b/docs/src/content/next/concepts/_meta.js index 5da3a5c391..e44fc6463b 100644 --- a/docs/src/content/next/concepts/_meta.js +++ b/docs/src/content/next/concepts/_meta.js @@ -5,6 +5,7 @@ export default { "worker-gateway": "API Gateway", "http-routers": "HTTP Routers and Files", "agent-to-agent-communication": "Agent to Agent Communication", + "agent-reflection": "Agent Reflection", "api-definitions": "API Definitions", permissions: "Permissions and Cards", plugins: "Plugins", diff --git a/docs/src/content/next/concepts/agent-reflection.mdx b/docs/src/content/next/concepts/agent-reflection.mdx new file mode 100644 index 0000000000..716e86da8d --- /dev/null +++ b/docs/src/content/next/concepts/agent-reflection.mdx @@ -0,0 +1,95 @@ +# Agent reflection + +Golem offers Normal RPC plus three reflection approaches for calling agents and +tools. Choose the simplest approach whose source of schemas matches your +application. + +| Client approach | Use it when | What validates the call | +| --- | --- | --- | +| **Normal RPC** | Producer and caller share the same source definition | Generated static codecs validate typed inputs and decode outputs | +| **Caller-defined static client** | The caller knows a stable interface but does not share the producer's source definition | Caller-owned schemas and codecs validate the fields they describe | +| **Discovered client** | The caller selects an agent, method, tool, or command from the current environment | An immutable discovery snapshot automatically validates inputs and declared outputs | +| **Fully dynamic client** | Infrastructure already transports packed schema-native values | The caller explicitly owns packing and validation; the host still validates authorization and the deployed target | + +Normal RPC is not reflection. Prefer it for ordinary application code with a +shared definition: it provides the strongest compile-time experience with the +least setup. + +Caller-defined static clients are useful for integrations that deliberately +own a typed view of another component. A full agent definition can create and +identify agents and validate known configuration declarations. A method-only +definition can bind an existing durable identity, but cannot validate an +identity or configuration declarations it does not describe. Caller-defined +tool definitions similarly provide typed access to a selected tool surface. + +Discovered clients are the default choice when the deployed environment is the +source of truth. Discovery reads visible metadata and returns a client backed +by that snapshot. The snapshot does not change under the caller: rediscover +explicitly when a newer deployment should be used. + +Fully dynamic clients are the lowest-level option. They invoke an existing +durable agent identity or an explicit tool command using schema-native values. +Use them for gateways, adapters, and other infrastructure that intentionally +keeps schema selection, packing, and result validation separate from +transport. + +## Validation boundaries + +Static and discovered clients validate what they know before opening RPC. +Discovered schemas include numeric and text restrictions, nested structures, +defaults, and tool command constraints. Reflected JSON helpers provide +canonical JSON conversion, and JSON Schema projections make the same shape +available to JSON-based integrations. An omitted optional record field and an +explicit `null` both decode as absent; re-encoding may include the field with +`null`, and optional fields are omitted from JSON Schema `required`. + +Canonical JSON uses numbers for integers through 32 bits. Signed and unsigned +64-bit integers are canonical decimal strings. Duration is +`{ "nanoseconds": "..." }`, and a quantity's `mantissa` is also a signed decimal +string. A leading `+`, leading zeroes, `-0`, the wrong JSON type, and values +outside the declared integer range are rejected. Reflection JSON Schema uses +matching string patterns and exact string-valued bounds. + +Streams, futures, and opaque capabilities cannot be represented as reflected +JSON and must use schema-native value APIs. Their reflection-specific JSON +Schema projection is unsatisfiable; other host JSON Schema projections retain +their existing policies. + +The host remains authoritative for visibility, authorization, effective +configuration, durable identity resolution, and the deployed target schema. +Awaited calls also verify whether an output is required and whether the +returned value matches the declared schema. Remote agent failures, tool +failures, and custom error payloads remain structured so callers can handle +them without parsing messages. + +Configuration overrides apply when an agent is created. Rebinding an existing +durable identity does not reconfigure that agent; it keeps its persisted +initial configuration. + +## Composing approaches + +The approaches can meet at schemas and identities: + +- Discovery can provide schemas to an explicit JSON or JSON Schema adapter. +- A discovered schema can pack input for a fully dynamic call and validate its + result, but moving the value into a dynamic client does not carry the + discovered client's automatic validation policy with it. +- Normal RPC, full caller-defined clients, and discovered factories can + produce a durable identity. That identity can later be rebound with a + method-only, discovered, or fully dynamic client. +- Invocation metadata can be stored, parsed, explicitly rediscovered, and + rebound when the identity is durable and reusable. + +Ephemeral agents form a hard boundary. A known phantom can be invoked once and +returns its final identity in invocation metadata, but that final ephemeral +identity cannot be rebound for another call. Started dynamic or reflected +operations also retain their normal resource rules: consume returned streams, +and cancel or close work that the caller abandons. + +For concrete APIs and language recipes, see the [common reflection +guide](/next/how-to-guides/common/golem-agent-reflection) and the SDK guides for +[Rust](/next/how-to-guides/rust/golem-agent-reflection-rust), +[TypeScript](/next/how-to-guides/ts/golem-agent-reflection-ts), +[Effect](/next/how-to-guides/effect/golem-agent-reflection-effect), +[Scala](/next/how-to-guides/scala/golem-agent-reflection-scala), and +[MoonBit](/next/how-to-guides/moonbit/golem-agent-reflection-moonbit). diff --git a/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx b/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx index d04887fc2c..1015397cf0 100644 --- a/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx +++ b/docs/src/content/next/how-to-guides/common/golem-agent-reflection.mdx @@ -33,11 +33,11 @@ Creation-time configuration overrides are optional because component defaults ma ## Optional values and canonical JSON -Normal RPC and caller-defined static clients use the language's normal optional-field syntax. Reflected JSON is schema-shaped instead: records contain every declared field and an absent `option` is `null`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. +Normal RPC and caller-defined static clients use the language's normal optional-field syntax. In reflected JSON, an omitted `option` record field and an explicit `null` both decode as absent; re-encoding may include the field with `null`. Reflection JSON Schema therefore omits optional fields from `required`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. Canonical invocation JSON uses JSON numbers for integers through 32 bits. Signed and unsigned 64-bit integers are canonical base-10 strings, as are duration nanoseconds and quantity mantissas. A duration is `{ "nanoseconds": "..." }`; a quantity is `{ "mantissa": "...", "scale": number, "unit": string }`. Leading zeroes, `+`, and `-0` are invalid. JSON Schema projections use the same string patterns and carry the exact range as metadata. -Streams and opaque capabilities have no canonical JSON representation. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. +Streams, futures, and opaque capabilities have no canonical JSON representation. Their reflection JSON Schema projection is unsatisfiable. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. ## Mixing client approaches diff --git a/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx b/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx index 1f7b4af634..7f3e3edff4 100644 --- a/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx +++ b/docs/src/content/next/how-to-guides/effect/golem-agent-reflection-effect.mdx @@ -81,7 +81,7 @@ Effect errors preserve the boundary that failed: - The host remains authoritative for visibility, authorization, environment-scoped identity resolution, effective configuration, and the deployed input schema. - Awaited calls verify unit/non-unit cardinality and declared output shape. `RemoteCallError`, `ToolRuntimeError`, custom agent errors, and custom tool payloads remain tagged values in the Effect error channel. -In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Canonical reflected JSON records contain every field, so represent an absent `option` with `null`: +In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Reflected JSON accepts either an omitted `option` record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema does not list that field in `required`: ```ts import { Effect } from "effect" @@ -92,14 +92,16 @@ const optionalCall = Effect.scoped(Effect.gen(function* () { if (!type || type.mode !== "durable") return yield* Effect.fail("SearchAgent unavailable") const client = yield* type.client.get({ tenant: "docs" }) const search = yield* client.method("search") - const checked = search.definition.input.validateJson({ query: "golem", cursor: null }) + const checked = search.definition.input.validateJson({ query: "golem" }) if (!checked.success) return yield* Effect.fail(checked.issues) - return yield* search.invoke({ query: "golem", cursor: null }) + return yield* search.invoke({ query: "golem" }) })) ``` Canonical JSON represents `s64` and `u64` as base-10 strings. Duration is `{ nanoseconds: "..." }`; quantity uses a decimal-string `mantissa`. Smaller integers remain JSON numbers. JSON Schema projections expose the same canonical patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, streams, and cleanup Agent calls are Effects: fiber interruption cancels result observation, while remote side effects may already have happened. A scheduled call returns a scoped cancel Effect. Keep the scope open until the cancellation token is no longer needed: @@ -137,8 +139,6 @@ const dynamicSearch = Effect.scoped(Effect.gen(function* () { return yield* Effect.fail("SearchAgent.search is unavailable") const input = method.input.packJson({ query: "golem", cursor: null }) - const inputCheck = method.input.validateValue(input) - if (!inputCheck.success) return yield* Effect.fail(inputCheck.issues) const identity = yield* type.agentId({ tenant: "docs" }) const dynamic = yield* identity.dynamicClient() diff --git a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx index 91d358a29b..fc1235dde9 100644 --- a/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx +++ b/docs/src/content/next/how-to-guides/moonbit/golem-agent-reflection-moonbit.mdx @@ -31,7 +31,13 @@ Reflection clients do not pin that component ID. `SchemaRef::pack_json` converts canonical JSON into a schema-native value; `unpack_json` performs the awaited conversion back. `to_json_schema()` projects the canonical JSON carrier for introspection, including named definitions and representable restrictions; -capabilities and streams have no JSON value representation. Discovery returns +capabilities, futures, and streams have no JSON value representation and project +to an unsatisfiable reflection JSON Schema. An omitted option record field and +an explicit `null` both decode as absent; re-encoding may emit `null`, and the +field is omitted from JSON Schema `required`. Canonical JSON encodes `s64` and +`u64` as decimal strings, duration as `{ "nanoseconds": "..." }`, and quantity +mantissas as decimal strings. These strings reject `+`, leading zeroes, `-0`, +and overflow. Discovery returns an immutable snapshot; explicitly discover again when a newer deployment must be observed. @@ -49,7 +55,7 @@ let result = counter.invoke_json( ) ``` -For explicit reflected packing, call `add.input.pack_json`, invoke through +For explicit reflected packing, call `add.input().pack_json`, invoke through `invoke_value`, await the result, and call the output `SchemaRef::unpack_json`. Reflected schema-native constructor and method inputs are checked locally against the selected schema graph before opening RPC. Declared native outputs @@ -116,14 +122,14 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + let typed_input = command.input_schema().typed_value(packed) catch { error => return "input:\{Repr(error)}" } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) let raw = match dynamic.invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed }, + command.path(), + typed_input, ) { Ok(raw) => raw Err(error) => { @@ -131,7 +137,7 @@ async fn invoke_discovered_tool_dynamically() -> String { } } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { diff --git a/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx b/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx index eee256b98a..8cfddc7275 100644 --- a/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx +++ b/docs/src/content/next/how-to-guides/rust/golem-agent-reflection-rust.mdx @@ -28,14 +28,15 @@ Validation happens at three different boundaries: | Completion | Result cardinality, resolved graph equivalence, declared custom-error payloads, and value decoding | Execution failures and undeclared remote errors | Normal RPC and caller-defined Rust values use `Option`. Reflected canonical JSON -omits an optional record field or supplies its ordinary JSON value; when using +treats an omitted optional record field or an explicit `null` as absent; re-encoding +may include that field with `null`, and reflection JSON Schema omits it from +`required`. When using `SchemaValue` directly, use `SchemaValue::Option { inner: None }` or `SchemaValue::Option { inner: Some(Box::new(value)) }`. Optional tool scalar positionals and options use that option carrier. Repeatable/tail inputs use an empty list for absence, flags use their effective boolean/count value, and an explicit default is represented by the default value. Do not replace an absent -optional carrier with a zero, empty string, or `null` unless its declared schema -actually permits that value. +optional carrier with a zero or empty string. Canonical JSON represents `s64`, `u64`, duration nanoseconds, and quantity mantissas as canonical base-10 strings. Smaller integers remain JSON numbers. @@ -43,6 +44,10 @@ Packing rejects leading `+`, non-canonical leading zeroes, and out-of-range values before transport; projected JSON Schema uses the same string patterns and exposes the exact range as metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected +JSON. Their reflection JSON Schema projection is unsatisfiable; use +schema-native value APIs for those leaves. + ## Discover Agent Types Discover the agent types registered in the current environment: @@ -293,7 +298,6 @@ async fn search_dynamically() -> Result { let input = method .input() .pack_json(&json!({ "query": "golem", "cursor": null }))?; - method.input().validate_value(&input)?; let agent_id = agent_type.agent_id_json(&json!({ "tenant": "docs" }), None)?; let result = match agent_id diff --git a/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx b/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx index d9d459e2a8..ed2165024b 100644 --- a/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx +++ b/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx @@ -29,6 +29,13 @@ Agent type names are unique in an environment. An `AgentType` exposes its current component ID, lifecycle mode, constructor `SchemaRef`, and method `SchemaRef`s. `SchemaRef` validates `SchemaValue`, packs and unpacks canonical `zio.blocks.schema.json.Json`, and renders JSON Schema. +An omitted option record field and an explicit `null` both decode as `None`; +re-encoding may emit `null`, and the field is omitted from JSON Schema +`required`. Canonical JSON encodes `s64` and `u64` as decimal strings, duration +as `{ "nanoseconds": "..." }`, and quantity mantissas as decimal strings. These +strings reject `+`, leading zeroes, `-0`, and overflow. Capabilities, futures, +and streams cannot be packed or unpacked as reflected JSON and project to an +unsatisfiable reflection JSON Schema. `getAgentType` returns `Right(None)` for a missing type and reserves `Left` for discovery or decoding failures. The returned schemas are an immutable snapshot; call discovery again when a newer deployment must be observed. @@ -86,10 +93,6 @@ def invokeDiscoveredToolDynamically( tool <- Reflection.getToolType(toolName).left.map(_.toString) command <- tool.command(path).left.map(_.toString) input <- command.packJson(json).left.map(_.toString) - _ <- command.inputSchema - .validateValue(input) - .left - .map(_.map(_.message).mkString("; ")) } yield (tool, command, TypedSchemaValue(command.inputSchema.graph, input)) prepared match { diff --git a/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx b/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx index 2e5a193119..4d83ab2155 100644 --- a/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx +++ b/docs/src/content/next/how-to-guides/ts/golem-agent-reflection-ts.mdx @@ -230,7 +230,7 @@ Validation happens at several boundaries: - The host authorizes the caller, resolves the environment-scoped identity, validates effective configuration, and checks the deployed input schema. - Awaited typed and reflected calls verify unit/non-unit cardinality and decode the declared result. Catch `RemoteCallError` with `isRemoteCallError(error)` and handle `error.cause` as a tagged value; do not parse messages. -In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Canonical reflected JSON records contain every field, so pass `null` for an absent option: +In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Reflected JSON accepts either an omitted option record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema omits that field from `required`: ```typescript import { getReflectedAgentType } from '@golemcloud/golem-ts-sdk'; @@ -239,17 +239,18 @@ const type = getReflectedAgentType('SearchAgent'); const search = type?.method('search'); if (!type || !search || type.mode !== 'durable') throw new Error('SearchAgent.search unavailable'); -const checked = search.input.validateJson({ query: 'golem', cursor: null }); +const checked = search.input.validateJson({ query: 'golem' }); if (!checked.success) throw new Error(JSON.stringify(checked.issues)); const result = await type.client.get({ tenant: 'docs' }).method('search').invoke({ query: 'golem', - cursor: null, }); console.log(result.value); ``` Canonical JSON represents `s64` and `u64` as decimal strings. Duration is `{ nanoseconds: "..." }`, and quantity uses a decimal-string `mantissa`. Smaller integers remain numbers. The generated JSON Schema uses matching patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, Streams, and Cleanup Cancel an awaited agent call with `AbortSignal`; cancel a scheduled durable call with its token. A triggered call has no result observer: diff --git a/docs/src/content/next/invoke/stream-session-public-protocol-v1.mdx b/docs/src/content/next/invoke/stream-session-public-protocol-v1.mdx index d45a68afde..0f972bea06 100644 --- a/docs/src/content/next/invoke/stream-session-public-protocol-v1.mdx +++ b/docs/src/content/next/invoke/stream-session-public-protocol-v1.mdx @@ -99,17 +99,17 @@ uses the representation of its resolved body. | `char` | JSON string containing exactly one Unicode scalar value | | `string` | JSON string | | `text` | `{"text":"..."}` with optional non-empty `"language":""` | -| `binary` | `{"bytes":""}` with optional `"mimeType":"type/subtype"` | +| `binary` | `{"bytes":""}` with optional `"mimeType":"type/subtype"` | | `path` | non-empty JSON string | | `url` | non-empty JSON string | | `datetime` | RFC 3339 UTC string with four-digit year and nanosecond precision | | `duration` | `{"nanoseconds":""}` | | `quantity` | `{"mantissa":"","scale":,"unit":"..."}` | -Binary `bytes` uses canonical padded RFC 4648 base64 with the standard `+/` -alphabet. Padding is REQUIRED exactly when RFC 4648 requires it. Whitespace, -URL-safe alphabet characters, omitted padding, and non-zero unused trailing -bits are invalid. MIME types are ASCII and match +Binary `bytes` uses canonical unpadded RFC 4648 base64url with the URL-safe +`-_` alphabet; for example, `-_8` encodes the bytes `fb ff`. Padding is +FORBIDDEN. Whitespace, the standard-alphabet `+` and `/` characters, and +non-zero unused trailing bits are invalid. MIME types are ASCII and match `^[A-Za-z0-9!#$&^_.+\-]+/[A-Za-z0-9!#$&^_.+\-]+$`. The decoder applies all restrictions from the expected schema after decoding, diff --git a/golem-client/tests/fixtures/stream-session-v1/malformed.json b/golem-client/tests/fixtures/stream-session-v1/malformed.json index 688073330d..a84628e9ff 100644 --- a/golem-client/tests/fixtures/stream-session-v1/malformed.json +++ b/golem-client/tests/fixtures/stream-session-v1/malformed.json @@ -19,10 +19,12 @@ {"expectedCode":"malformed-message","input":"{\"attemptId\":\"not-a-uuid\",\"operation\":\"resume\",\"outputCursors\":[],\"sessionToken\":\"token\",\"type\":\"resumeAttach\",\"version\":1}","lane":"text","name":"invalid-attempt-uuid"}, {"expectedCode":"validation-error","input":"\"9007199254740993\"","lane":"schema-value","name":"u64-above-javascript-safe-boundary-as-string-control","schema":{"kind":"u32"}}, {"expectedCode":"validation-error","input":"9007199254740993","lane":"schema-value","name":"u64-json-number","schema":{"kind":"u64"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-url-safe-alphabet","schema":{"kind":"binary"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/8\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-missing-padding","schema":{"kind":"binary"}}, - {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/9=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-unused-bits","schema":{"kind":"binary"}}, - {"expectedCode":"validation-error","input":"{\"bytes\":\"AA==\",\"mimeType\":\"invalid\"}","lane":"schema-value","name":"binary-invalid-mime","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"+/8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-standard-alphabet","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_8=\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-padding","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"-_9\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-trailing-bits","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"A\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-impossible-length","schema":{"kind":"binary"}}, + {"expectedCode":"malformed-message","input":"{\"bytes\":\"__9\",\"mimeType\":\"application/octet-stream\"}","lane":"schema-value","name":"binary-nonzero-unused-bits","schema":{"kind":"binary"}}, + {"expectedCode":"validation-error","input":"{\"bytes\":\"AA\",\"mimeType\":\"invalid\"}","lane":"schema-value","name":"binary-invalid-mime","schema":{"kind":"binary"}}, {"expectedCode":"malformed-message","input":"{\"$float\":\"infinity\"}","lane":"schema-value","name":"unknown-exceptional-float","schema":{"kind":"f64"}}, {"expectedCode":"validation-error","input":"{\"expected\":true,\"unknown\":false}","lane":"schema-value","name":"unknown-record-field","schema":{"fields":[{"name":"expected","type":{"kind":"bool"}}],"kind":"record"}}, {"expectedCode":"validation-error","input":"{\"$option\":\"none\",\"value\":1}","lane":"schema-value","name":"illegal-none-value","schema":{"inner":{"kind":"u8"},"kind":"option"}}, diff --git a/golem-client/tests/fixtures/stream-session-v1/schema-values.json b/golem-client/tests/fixtures/stream-session-v1/schema-values.json index d60b402900..538c2a356b 100644 --- a/golem-client/tests/fixtures/stream-session-v1/schema-values.json +++ b/golem-client/tests/fixtures/stream-session-v1/schema-values.json @@ -15,7 +15,7 @@ {"canonical":"\"λ\"","name":"char","schema":{"kind":"char"}}, {"canonical":"\"Golem\"","name":"string","schema":{"kind":"string"}}, {"canonical":"{\"language\":\"en-GB\",\"text\":\"hello\"}","name":"text","schema":{"kind":"text"}}, - {"canonical":"{\"bytes\":\"+/8=\",\"mimeType\":\"application/octet-stream\"}","name":"binary-padded-standard-base64","schema":{"kind":"binary","maxBytes":16,"mimeTypes":["application/octet-stream"]}}, + {"canonical":"{\"bytes\":\"-_8\",\"mimeType\":\"application/octet-stream\"}","name":"binary-unpadded-base64url","schema":{"kind":"binary","maxBytes":16,"mimeTypes":["application/octet-stream"]}}, {"canonical":"\"/tmp/input.wav\"","name":"path","schema":{"kind":"path"}}, {"canonical":"\"https://example.com/a\"","name":"url","schema":{"kind":"url"}}, {"canonical":"\"2026-08-28T12:34:56.123456789Z\"","name":"datetime","schema":{"kind":"datetime"}}, diff --git a/golem-common/src/schema/public_json.rs b/golem-common/src/schema/public_json.rs index 03259dee96..273e0076ab 100644 --- a/golem-common/src/schema/public_json.rs +++ b/golem-common/src/schema/public_json.rs @@ -22,7 +22,7 @@ use crate::schema::{ SchemaValue, UnionValuePayload, VariantValuePayload, }; use base64::Engine; -use base64::engine::general_purpose::STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use golem_schema::schema::render::{from_json_value, to_json_value}; use serde_json::{Map, Number, Value}; use std::collections::HashSet; @@ -446,12 +446,12 @@ where )); } let encoded = required_string(object, "bytes")?; - let bytes = STANDARD.decode(encoded).map_err(|_| { - PublicSchemaValueError::malformed("binary bytes are not canonical padded base64") + let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| { + PublicSchemaValueError::malformed("binary bytes are not canonical unpadded base64url") })?; - if STANDARD.encode(&bytes) != encoded { + if URL_SAFE_NO_PAD.encode(&bytes) != encoded { return Err(PublicSchemaValueError::malformed( - "binary bytes are not canonical padded base64", + "binary bytes are not canonical unpadded base64url", )); } let mime_type = object @@ -605,7 +605,7 @@ where let mut object = Map::new(); object.insert( "bytes".to_string(), - Value::String(STANDARD.encode(&value.bytes)), + Value::String(URL_SAFE_NO_PAD.encode(&value.bytes)), ); if let Some(mime_type) = &value.mime_type { object.insert("mimeType".to_string(), Value::String(mime_type.clone())); @@ -1357,7 +1357,7 @@ mod tests { } #[test] - fn safe_integers_and_standard_base64_round_trip() { + fn safe_integers_and_base64url_round_trip() { assert_eq!( decode( &SchemaType::u64(), @@ -1378,7 +1378,7 @@ mod tests { assert_eq!( decode( &SchemaType::binary(BinaryRestrictions::default()), - json!({"bytes":"+/8=","mimeType":"application/octet-stream"}), + json!({"bytes":"-_8","mimeType":"application/octet-stream"}), PublicStreamReferencePolicy::None, ) .unwrap(), @@ -1539,6 +1539,11 @@ mod tests { json!(9007199254740993u64), PublicErrorCode::ValidationError, ), + ( + SchemaType::binary(BinaryRestrictions::default()), + json!({"bytes":"+/8=","mimeType":"application/octet-stream"}), + PublicErrorCode::MalformedMessage, + ), ( SchemaType::binary(BinaryRestrictions::default()), json!({"bytes":"-_8=","mimeType":"application/octet-stream"}), @@ -1546,7 +1551,7 @@ mod tests { ), ( SchemaType::binary(BinaryRestrictions::default()), - json!({"bytes":"+/8","mimeType":"application/octet-stream"}), + json!({"bytes":"-_9","mimeType":"application/octet-stream"}), PublicErrorCode::MalformedMessage, ), ( diff --git a/golem-common/src/schema/render/mod.rs b/golem-common/src/schema/render/mod.rs index 2d97cd81ea..f4ae60672f 100644 --- a/golem-common/src/schema/render/mod.rs +++ b/golem-common/src/schema/render/mod.rs @@ -31,4 +31,5 @@ pub use cli_text::{ value_to_cli_text_with_secret_metadata, }; pub use docs::graph_to_markdown; +pub use golem_schema::schema::render::to_json_value; pub use json_schema::{input_schema_to_json_schema, output_schema_to_json_schema}; diff --git a/golem-common/src/schema/render/tests/json_schema_tests.rs b/golem-common/src/schema/render/tests/json_schema_tests.rs index b61a2fe3f6..a4ad75ca86 100644 --- a/golem-common/src/schema/render/tests/json_schema_tests.rs +++ b/golem-common/src/schema/render/tests/json_schema_tests.rs @@ -373,7 +373,7 @@ fn binary_emits_canonical_object_shape() { schema["properties"]["mimeType"]["pattern"] .as_str() .unwrap(), - "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$" + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$" ); assert_eq!(schema["additionalProperties"], json!(false)); } @@ -1060,8 +1060,9 @@ mod agent_entry_points { // Canonical Text shape: `{ text, language? }`. assert_eq!(props["text"]["type"], json!("string")); assert!(props.contains_key("language")); + assert_eq!(props["language"]["enum"], json!(["en", "fr"])); assert_eq!(doc["required"], json!(["text"])); - assert_eq!(doc["description"], json!("Allowed languages: en, fr")); + assert!(doc.get("description").is_none()); } #[test] diff --git a/golem-schema/src/schema/canonical/quantity.rs b/golem-schema/src/schema/canonical/quantity.rs index be02695c53..3e762bd535 100644 --- a/golem-schema/src/schema/canonical/quantity.rs +++ b/golem-schema/src/schema/canonical/quantity.rs @@ -22,10 +22,9 @@ //! `from_text` additionally accepts a single ASCII space between the //! decimal and the unit (e.g. `1 kg`); the output form is always //! no-space. -//! - Text form is restricted to `|scale| <= 18` and rejects -//! `mantissa == i64::MIN`; both would either overflow representation or -//! produce an unbounded output string. JSON encoding is unrestricted -//! on both fronts. A negative-scale rendering whose absolute decimal +//! - Text form is restricted to `|scale| <= 18`; larger scales would produce +//! an unbounded output string. JSON encoding is unrestricted on this front. +//! A negative-scale rendering whose absolute decimal //! string would exceed 40 characters is rejected as //! `ParseError::OutOfRange("quantity scale")`. //! - JSON form: `{ "mantissa": "…", "scale": …, "unit": "..." }`, with @@ -51,9 +50,6 @@ fn unit_regex() -> &'static Regex { } pub fn to_text(payload: &QuantityValue) -> Result { - if payload.mantissa == i64::MIN { - return Err(ParseError::OutOfRange("quantity mantissa")); - } if payload.scale.unsigned_abs() > MAX_ABS_SCALE_TEXT as u32 { return Err(ParseError::OutOfRange("quantity scale")); } @@ -263,12 +259,12 @@ fn parse_decimal(s: &str) -> Result<(i64, i32), ParseError> { let combined: String = format!("{whole}{frac}"); let stripped = combined.trim_start_matches('0'); let digits = if stripped.is_empty() { "0" } else { stripped }; - let magnitude: i64 = digits + let magnitude: i128 = digits .parse() .map_err(|_| ParseError::OutOfRange("mantissa"))?; - let mut mantissa = sign - .checked_mul(magnitude) - .ok_or(ParseError::OutOfRange("mantissa"))?; + let mut mantissa: i64 = (i128::from(sign) * magnitude) + .try_into() + .map_err(|_| ParseError::OutOfRange("mantissa"))?; let mut scale: i32 = frac.len() as i32; while scale > 0 && mantissa % 10 == 0 && mantissa != 0 { mantissa /= 10; @@ -285,8 +281,7 @@ fn format_decimal(mantissa: i64, scale: i32) -> String { return "0".to_string(); } let negative = mantissa < 0; - // `to_text` rejects `i64::MIN` before reaching here, so `.abs()` is safe. - let abs_str = mantissa.abs().to_string(); + let abs_str = mantissa.unsigned_abs().to_string(); let body = if scale <= 0 { let mut s = abs_str; for _ in 0..(-scale) { @@ -547,15 +542,14 @@ mod tests { } #[test] - fn i64_min_mantissa_text_rejected() { + fn i64_min_mantissa_text_roundtrips() { let p = QuantityValue { mantissa: i64::MIN, scale: 0, unit: "x".into(), }; - assert_eq!( - to_text(&p), - Err(ParseError::OutOfRange("quantity mantissa")) - ); + let text = to_text(&p).unwrap(); + assert_eq!(text, "-9223372036854775808x"); + assert_eq!(from_text(&text), Ok(p)); } } diff --git a/golem-schema/src/schema/render/json_schema.rs b/golem-schema/src/schema/render/json_schema.rs index ab265e0821..7ce169a199 100644 --- a/golem-schema/src/schema/render/json_schema.rs +++ b/golem-schema/src/schema/render/json_schema.rs @@ -18,15 +18,17 @@ use crate::schema::graph::SchemaGraph; use crate::schema::metadata::{MetadataEnvelope, TypeId}; use crate::schema::schema_type::{ - BinaryRestrictions, DiscriminatorRule, PathSpec, PermissionCardSpec, QuantitySpec, - QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType, SecretSpec, TextRestrictions, - UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType, + BinaryRestrictions, DiscriminatorRule, NumericBound, NumericRestrictions, PathSpec, + PermissionCardSpec, QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaType, + SecretSpec, TextRestrictions, UnionBranch, UnionSpec, UrlRestrictions, VariantCaseType, }; use serde_json::{Map, Number, Value}; use std::collections::{HashMap, HashSet}; const JSON_SCHEMA_DRAFT: &str = "https://json-schema.org/draft/2020-12/schema"; -const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$"; +const MIME_TYPE_PATTERN: &str = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$"; +const BASE64URL_PATTERN: &str = + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$"; /// Configuration for the JSON Schema renderer. /// @@ -37,6 +39,7 @@ pub struct JsonSchemaConfig { /// Emit the `$schema` JSON Schema draft marker at the document root. pub include_draft_marker: bool, host_managed: HostManagedSchemaPolicy, + unsupported: UnsupportedLeafSchemaPolicy, } #[derive(Clone, Copy, Debug)] @@ -46,12 +49,19 @@ enum HostManagedSchemaPolicy { Redact, } +#[derive(Clone, Copy, Debug)] +enum UnsupportedLeafSchemaPolicy { + Placeholder, + Reject, +} + impl JsonSchemaConfig { /// Canonical standalone JSON Schema document (includes the `$schema` /// draft marker). pub const CANONICAL: Self = Self { include_draft_marker: true, host_managed: HostManagedSchemaPolicy::TrustedSnapshot, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; /// Canonical JSON Schema document without the `$schema` draft marker, for @@ -59,16 +69,27 @@ impl JsonSchemaConfig { pub const WITHOUT_DRAFT_MARKER: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::TrustedSnapshot, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; pub(crate) const EXTERNAL_INPUT: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::Reject, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, }; pub(crate) const EXTERNAL_OUTPUT: Self = Self { include_draft_marker: false, host_managed: HostManagedSchemaPolicy::Redact, + unsupported: UnsupportedLeafSchemaPolicy::Placeholder, + }; + + /// Reflection JSON adapters cannot pack or unpack capabilities, futures, + /// or streams, so their projected schemas must reject every JSON value. + pub const REFLECTION: Self = Self { + include_draft_marker: true, + host_managed: HostManagedSchemaPolicy::Reject, + unsupported: UnsupportedLeafSchemaPolicy::Reject, }; } @@ -158,6 +179,24 @@ pub fn to_external_output_json_schema( ) } +/// Render the JSON domain accepted by reflection `pack_json`/`unpack_json`. +/// Capability, future, and stream leaves are unsatisfiable because those +/// adapters have no JSON representation for them. +pub fn to_reflection_json_schema( + graph: &SchemaGraph, + ty: &SchemaType, + include_draft_marker: bool, +) -> Value { + to_json_schema_with_config( + graph, + ty, + JsonSchemaConfig { + include_draft_marker, + ..JsonSchemaConfig::REFLECTION + }, + ) +} + /// Whether `ty`, after following any `Ref` chain against `graph`, is an /// `option<…>`. Used to decide whether an input parameter is required. fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool { @@ -629,16 +668,31 @@ pub(super) fn render_type( SchemaType::Ref { id, .. } => obj([("$ref", Value::String(ref_pointer(id, root)))]), SchemaType::Bool { .. } => obj([("type", Value::String("boolean".to_string()))]), - SchemaType::S8 { .. } => integer_schema(i8::MIN as i64, i8::MAX as i64), - SchemaType::S16 { .. } => integer_schema(i16::MIN as i64, i16::MAX as i64), - SchemaType::S32 { .. } => integer_schema(i32::MIN as i64, i32::MAX as i64), - SchemaType::S64 { .. } => signed_64_schema(), - SchemaType::U8 { .. } => integer_schema(0, u8::MAX as i64), - SchemaType::U16 { .. } => integer_schema(0, u16::MAX as i64), - SchemaType::U32 { .. } => integer_schema(0, u32::MAX as i64), - SchemaType::U64 { .. } => unsigned_64_schema(), - SchemaType::F32 { .. } | SchemaType::F64 { .. } => { - obj([("type", Value::String("number".to_string()))]) + SchemaType::S8 { restrictions, .. } => { + signed_integer_schema(i8::MIN as i64, i8::MAX as i64, restrictions.as_ref()) + } + SchemaType::S16 { restrictions, .. } => { + signed_integer_schema(i16::MIN as i64, i16::MAX as i64, restrictions.as_ref()) + } + SchemaType::S32 { restrictions, .. } => { + signed_integer_schema(i32::MIN as i64, i32::MAX as i64, restrictions.as_ref()) + } + SchemaType::S64 { restrictions, .. } => signed_64_schema(restrictions.as_ref()), + SchemaType::U8 { restrictions, .. } => { + unsigned_integer_schema(0, u8::MAX as u64, restrictions.as_ref()) + } + SchemaType::U16 { restrictions, .. } => { + unsigned_integer_schema(0, u16::MAX as u64, restrictions.as_ref()) + } + SchemaType::U32 { restrictions, .. } => { + unsigned_integer_schema(0, u32::MAX as u64, restrictions.as_ref()) + } + SchemaType::U64 { restrictions, .. } => unsigned_64_schema(restrictions.as_ref()), + SchemaType::F32 { restrictions, .. } => { + float_schema(-(f32::MAX as f64), f32::MAX as f64, restrictions.as_ref()) + } + SchemaType::F64 { restrictions, .. } => { + float_schema(-f64::MAX, f64::MAX, restrictions.as_ref()) } SchemaType::Char { .. } => obj([ ("type", Value::String("string".to_string())), @@ -778,7 +832,7 @@ pub(super) fn render_type( ]), SchemaType::Duration { .. } => obj([ ("type", Value::String("object".to_string())), - ("properties", obj([("nanoseconds", signed_64_schema())])), + ("properties", obj([("nanoseconds", signed_64_schema(None))])), ( "required", Value::Array(vec![Value::String("nanoseconds".to_string())]), @@ -809,13 +863,16 @@ pub(super) fn render_type( }) } - SchemaType::Future { .. } | SchemaType::Stream { .. } => obj([ - ("type", Value::String("null".to_string())), - ( - "description", - Value::String("WASI P3 placeholder".to_string()), - ), - ]), + SchemaType::Future { .. } | SchemaType::Stream { .. } => match config.unsupported { + UnsupportedLeafSchemaPolicy::Placeholder => obj([ + ("type", Value::String("null".to_string())), + ( + "description", + Value::String("WASI P3 placeholder".to_string()), + ), + ]), + UnsupportedLeafSchemaPolicy::Reject => obj([("not", Value::Object(Map::new()))]), + }, }; // Per-node metadata: attach docs / examples / deprecated for every @@ -867,25 +924,113 @@ pub(super) fn ref_to_def_key(key: &str) -> String { format!("#/$defs/{}", escape_pointer_token(key)) } -fn integer_schema(min: i64, max: i64) -> Value { +fn signed_integer_schema(min: i64, max: i64, restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(min) + .max(min); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(max) + .min(max); obj([ ("type", Value::String("integer".to_string())), - ("minimum", Value::Number(Number::from(min))), - ("maximum", Value::Number(Number::from(max))), + ("minimum", Value::Number(Number::from(minimum))), + ("maximum", Value::Number(Number::from(maximum))), ]) } -fn unsigned_64_schema() -> Value { +fn unsigned_integer_schema( + min: u64, + max: u64, + restrictions: Option<&NumericRestrictions>, +) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(min) + .max(min); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(max) + .min(max); + obj([ + ("type", Value::String("integer".to_string())), + ("minimum", Value::Number(Number::from(minimum))), + ("maximum", Value::Number(Number::from(maximum))), + ]) +} + +fn float_schema(min: f64, max: f64, restrictions: Option<&NumericRestrictions>) -> Value { + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("number".to_string())); + if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.min) + && let Some(value) = Number::from_f64(f64::from_bits(bits).max(min)) + { + schema.insert("minimum".to_string(), Value::Number(value)); + } + if let Some(NumericBound::FloatBits(bits)) = restrictions.and_then(|value| value.max) + && let Some(value) = Number::from_f64(f64::from_bits(bits).min(max)) + { + schema.insert("maximum".to_string(), Value::Number(value)); + } + Value::Object(schema) +} + +fn unsigned_64_schema(restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(0); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Unsigned(value) => Some(value), + _ => None, + }) + .unwrap_or(u64::MAX); obj([ ("type", Value::String("string".to_string())), ("format", Value::String("uint64".to_string())), ("pattern", Value::String("^(?:0|[1-9][0-9]*)$".to_string())), - ("x-golem-minimum", Value::String("0".to_string())), - ("x-golem-maximum", Value::String(u64::MAX.to_string())), + ("x-golem-minimum", Value::String(minimum.to_string())), + ("x-golem-maximum", Value::String(maximum.to_string())), ]) } -fn signed_64_schema() -> Value { +fn signed_64_schema(restrictions: Option<&NumericRestrictions>) -> Value { + let minimum = restrictions + .and_then(|value| value.min) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(i64::MIN); + let maximum = restrictions + .and_then(|value| value.max) + .and_then(|bound| match bound { + NumericBound::Signed(value) => Some(value), + _ => None, + }) + .unwrap_or(i64::MAX); obj([ ("type", Value::String("string".to_string())), ("format", Value::String("int64".to_string())), @@ -893,8 +1038,8 @@ fn signed_64_schema() -> Value { "pattern", Value::String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".to_string()), ), - ("x-golem-minimum", Value::String(i64::MIN.to_string())), - ("x-golem-maximum", Value::String(i64::MAX.to_string())), + ("x-golem-minimum", Value::String(minimum.to_string())), + ("x-golem-maximum", Value::String(maximum.to_string())), ]) } @@ -996,10 +1141,15 @@ fn text_schema(restrictions: &TextRestrictions) -> Map { } let mut properties = Map::new(); properties.insert("text".to_string(), Value::Object(text_field)); - properties.insert( - "language".to_string(), - obj([("type", Value::String("string".to_string()))]), - ); + let mut language_field = Map::new(); + language_field.insert("type".to_string(), Value::String("string".to_string())); + if let Some(langs) = &restrictions.languages { + language_field.insert( + "enum".to_string(), + Value::Array(langs.iter().cloned().map(Value::String).collect()), + ); + } + properties.insert("language".to_string(), Value::Object(language_field)); let mut m = Map::new(); m.insert("type".to_string(), Value::String("object".to_string())); m.insert("properties".to_string(), Value::Object(properties)); @@ -1008,17 +1158,11 @@ fn text_schema(restrictions: &TextRestrictions) -> Map { Value::Array(vec![Value::String("text".to_string())]), ); m.insert("additionalProperties".to_string(), Value::Bool(false)); - if let Some(langs) = &restrictions.languages { - m.insert( - "description".to_string(), - Value::String(format!("Allowed languages: {}", langs.join(", "))), - ); - } m } fn binary_schema(restrictions: &BinaryRestrictions) -> Map { - // Canonical Binary JSON shape: `{ bytes: base64url-string, mime_type?: string }`. + // Canonical Binary JSON shape: `{ bytes: base64url-string, mimeType?: string }`. // `min_bytes` / `max_bytes` count *raw* bytes; the JSON field is // base64url-no-pad-encoded, so the on-wire string length is // `base64url_no_pad_len(n) = 4*(n/3) + match n%3 { 0=>0, 1=>2, 2=>3 }`. @@ -1028,6 +1172,10 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { "contentEncoding".to_string(), Value::String("base64url".to_string()), ); + bytes_field.insert( + "pattern".to_string(), + Value::String(BASE64URL_PATTERN.to_string()), + ); if let Some(min) = restrictions.min_bytes { bytes_field.insert( "minLength".to_string(), @@ -1046,6 +1194,12 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { "pattern".to_string(), Value::String(MIME_TYPE_PATTERN.to_string()), ); + if let Some(mimes) = &restrictions.mime_types { + mime_field.insert( + "enum".to_string(), + Value::Array(mimes.iter().cloned().map(Value::String).collect()), + ); + } let mut properties = Map::new(); properties.insert("bytes".to_string(), Value::Object(bytes_field)); properties.insert("mimeType".to_string(), Value::Object(mime_field)); @@ -1057,12 +1211,6 @@ fn binary_schema(restrictions: &BinaryRestrictions) -> Map { Value::Array(vec![Value::String("bytes".to_string())]), ); m.insert("additionalProperties".to_string(), Value::Bool(false)); - if let Some(mimes) = &restrictions.mime_types { - m.insert( - "description".to_string(), - Value::String(format!("Allowed MIME types: {}", mimes.join(", "))), - ); - } m } @@ -1123,7 +1271,7 @@ fn url_schema(restrictions: &UrlRestrictions) -> Map { fn quantity_schema(spec: &QuantitySpec) -> Map { let mut props = Map::new(); - props.insert("mantissa".to_string(), signed_64_schema()); + props.insert("mantissa".to_string(), signed_64_schema(None)); props.insert( "scale".to_string(), obj([("type", Value::String("integer".to_string()))]), diff --git a/golem-schema/src/schema/render/json_value.rs b/golem-schema/src/schema/render/json_value.rs index a91fa8af22..27a35a1615 100644 --- a/golem-schema/src/schema/render/json_value.rs +++ b/golem-schema/src/schema/render/json_value.rs @@ -674,13 +674,24 @@ fn from_json_body( } let mut out = Vec::with_capacity(fields.len()); for field in fields.iter() { - let value = obj.get(&field.name).ok_or_else(|| { - mismatch(path, format!("missing record field `{}`", field.name)) - })?; - path.push(PathSegment::Field(field.name.clone())); - let v = from_json_inner(graph, &field.body, value, path, &mut visited, policy)?; - path.pop(); - out.push(v); + match obj.get(&field.name) { + Some(value) => { + path.push(PathSegment::Field(field.name.clone())); + let v = + from_json_inner(graph, &field.body, value, path, &mut visited, policy)?; + path.pop(); + out.push(v); + } + None if resolves_to_option(graph, &field.body) => { + out.push(SchemaValue::Option { inner: None }); + } + None => { + return Err(mismatch( + path, + format!("missing record field `{}`", field.name), + )); + } + } } Ok(SchemaValue::Record { fields: out }) } @@ -878,6 +889,21 @@ fn from_json_body( } } +fn resolves_to_option(graph: &SchemaGraph, ty: &SchemaType) -> bool { + let mut current = ty; + let mut visited = HashSet::new(); + loop { + match current { + SchemaType::Option { .. } => return true, + SchemaType::Ref { id, .. } if visited.insert(id.clone()) => match graph.lookup(id) { + Some(definition) => current = &definition.body, + None => return false, + }, + _ => return false, + } + } +} + fn decode_result( graph: &SchemaGraph, spec: &ResultSpec, diff --git a/golem-schema/src/schema/render/mod.rs b/golem-schema/src/schema/render/mod.rs index ffaf622cd1..6768a50879 100644 --- a/golem-schema/src/schema/render/mod.rs +++ b/golem-schema/src/schema/render/mod.rs @@ -23,7 +23,7 @@ pub mod walker; pub use error::RenderError; pub use json_schema::{ JsonSchemaConfig, to_external_input_json_schema, to_external_output_json_schema, - to_json_schema, to_json_schema_with_config, + to_json_schema, to_json_schema_with_config, to_reflection_json_schema, }; pub use json_value::{ from_json_value, from_untrusted_json_value, to_json_value, to_json_value_redacted, diff --git a/golem-schema/src/schema/render/tests.rs b/golem-schema/src/schema/render/tests.rs index 4cf1df7e03..0e1dce46a2 100644 --- a/golem-schema/src/schema/render/tests.rs +++ b/golem-schema/src/schema/render/tests.rs @@ -12,12 +12,20 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::{RenderError, from_json_value, to_json_schema, to_json_value}; +use super::{ + RenderError, from_json_value, to_json_schema, to_json_value, to_reflection_json_schema, +}; +use crate::schema::schema_type::{NumericBound, NumericRestrictions}; +use crate::schema::validation::{is_equivalent_cross_graph, validate_graph, validate_value}; use crate::schema::{ - MetadataEnvelope, NamedFieldType, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, - TextRestrictions, TextValuePayload, TypeId, VariantCaseType, VariantValuePayload, + BinaryRestrictions, DurationValuePayload, MetadataEnvelope, NamedFieldType, PermissionCardSpec, + QuantitySpec, QuantityValue, QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, + SchemaTypeDef, SchemaValue, TextRestrictions, TextValuePayload, TypeId, VariantCaseType, + VariantValuePayload, }; +use proptest::prelude::*; use serde_json::{Value, json}; +use std::collections::HashSet; use test_r::test; #[test] @@ -154,6 +162,82 @@ fn wide_integer_json_rejects_noncanonical_or_out_of_range_strings() { } } +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn wide_and_rich_numeric_json_round_trips_without_precision_loss( + signed in any::(), + unsigned in any::(), + scale in any::(), + ) { + let signed_type = SchemaType::s64(); + let signed_graph = SchemaGraph::anonymous(signed_type.clone()); + let signed_json = json!(signed.to_string()); + prop_assert_eq!( + from_json_value(&signed_graph, &signed_type, &signed_json), + Ok(SchemaValue::S64(signed)) + ); + + let unsigned_type = SchemaType::u64(); + let unsigned_graph = SchemaGraph::anonymous(unsigned_type.clone()); + let unsigned_json = json!(unsigned.to_string()); + prop_assert_eq!( + from_json_value(&unsigned_graph, &unsigned_type, &unsigned_json), + Ok(SchemaValue::U64(unsigned)) + ); + + let duration_type = SchemaType::duration(); + let duration_graph = SchemaGraph::anonymous(duration_type.clone()); + let duration_json = json!({ "nanoseconds": signed.to_string() }); + prop_assert_eq!( + from_json_value(&duration_graph, &duration_type, &duration_json), + Ok(SchemaValue::Duration(DurationValuePayload { nanoseconds: signed })) + ); + + let quantity_type = SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: Vec::new(), + min: None, + max: None, + }); + let quantity_graph = SchemaGraph::anonymous(quantity_type.clone()); + let quantity_json = json!({ + "mantissa": signed.to_string(), + "scale": scale, + "unit": "m", + }); + prop_assert_eq!( + from_json_value(&quantity_graph, &quantity_type, &quantity_json), + Ok(SchemaValue::Quantity(QuantityValue { + mantissa: signed, + scale, + unit: "m".to_string(), + })) + ); + } + + #[test] + fn decimal_string_mutations_are_rejected(value in any::()) { + let signed_type = SchemaType::s64(); + let signed_graph = SchemaGraph::anonymous(signed_type.clone()); + let unsigned_type = SchemaType::u64(); + let unsigned_graph = SchemaGraph::anonymous(unsigned_type.clone()); + let digits = value.to_string(); + + for mutated in [format!("+{digits}"), format!("0{digits}"), "-0".to_string()] { + prop_assert!( + from_json_value(&signed_graph, &signed_type, &json!(mutated)).is_err() + ); + } + for mutated in [format!("+{digits}"), format!("0{digits}"), format!("-{digits}")] { + prop_assert!( + from_json_value(&unsigned_graph, &unsigned_type, &json!(mutated)).is_err() + ); + } + } +} + #[test] fn refs_variants_and_options_share_one_graph() { let payload_id = TypeId::new("example.payload"); @@ -210,6 +294,254 @@ fn refs_variants_and_options_share_one_graph() { assert!(!required.contains(&Value::String("value".to_string()))); } +#[test] +fn omitted_option_record_fields_decode_as_none() { + let option_id = TypeId::new("example.optional-string"); + let ty = SchemaType::record(vec![ + NamedFieldType { + name: "direct".to_string(), + body: SchemaType::option(SchemaType::string()), + metadata: MetadataEnvelope::default(), + }, + NamedFieldType { + name: "referenced".to_string(), + body: SchemaType::ref_to(option_id.clone()), + metadata: MetadataEnvelope::default(), + }, + ]); + let graph = SchemaGraph { + defs: vec![SchemaTypeDef { + id: option_id, + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: ty.clone(), + }; + let expected = SchemaValue::Record { + fields: vec![ + SchemaValue::Option { inner: None }, + SchemaValue::Option { inner: None }, + ], + }; + + assert_eq!(from_json_value(&graph, &ty, &json!({})).unwrap(), expected); + assert_eq!( + from_json_value(&graph, &ty, &json!({ "direct": null, "referenced": null })).unwrap(), + expected + ); +} + +#[test] +fn reflection_json_schema_rejects_unrepresentable_leaves() { + for ty in [ + SchemaType::secret(crate::schema::SecretSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ] { + let graph = SchemaGraph::anonymous(ty.clone()); + assert_eq!( + to_reflection_json_schema(&graph, &ty, false)["not"], + json!({}) + ); + } +} + +#[test] +fn numeric_json_schemas_apply_every_narrow_and_float_bound() { + let signed = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Signed(min)), + max: Some(NumericBound::Signed(max)), + unit: None, + }) + }; + let unsigned = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(min)), + max: Some(NumericBound::Unsigned(max)), + unit: None, + }) + }; + let cases = [ + SchemaType::S8 { + restrictions: signed(-12, 12), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S16 { + restrictions: signed(-1_200, 1_200), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S32 { + restrictions: signed(-120_000, 120_000), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U8 { + restrictions: unsigned(12, 120), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U16 { + restrictions: unsigned(1_200, 12_000), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U32 { + restrictions: unsigned(120_000, 1_200_000), + metadata: MetadataEnvelope::default(), + }, + ]; + let expected = [ + (-12, 12), + (-1_200, 1_200), + (-120_000, 120_000), + (12, 120), + (1_200, 12_000), + (120_000, 1_200_000), + ]; + for (ty, (min, max)) in cases.into_iter().zip(expected) { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } + + for (ty, min, max) in [ + ( + SchemaType::F32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + -1.5, + 2.5, + ), + ( + SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-3.5).unwrap()), + max: Some(NumericBound::float(4.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + -3.5, + 4.5, + ), + ] { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } +} + +#[test] +fn numeric_json_schemas_clamp_out_of_domain_bounds_to_primitive_ranges() { + let signed = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Signed(min)), + max: Some(NumericBound::Signed(max)), + unit: None, + }) + }; + let unsigned = |min, max| { + Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(min)), + max: Some(NumericBound::Unsigned(max)), + unit: None, + }) + }; + let cases = [ + SchemaType::S8 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S16 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::S32 { + restrictions: signed(i64::MIN, i64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U8 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U16 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + SchemaType::U32 { + restrictions: unsigned(0, u64::MAX), + metadata: MetadataEnvelope::default(), + }, + ]; + let expected = [ + (i8::MIN as i64, i8::MAX as i64), + (i16::MIN as i64, i16::MAX as i64), + (i32::MIN as i64, i32::MAX as i64), + (u8::MIN as i64, u8::MAX as i64), + (u16::MIN as i64, u16::MAX as i64), + (u32::MIN as i64, u32::MAX as i64), + ]; + for (ty, (min, max)) in cases.into_iter().zip(expected) { + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], min); + assert_eq!(schema["maximum"], max); + } + + let ty = SchemaType::F32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-f64::MAX).unwrap()), + max: Some(NumericBound::float(f64::MAX).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }; + let schema = to_json_schema(&SchemaGraph::anonymous(ty.clone()), &ty); + assert_eq!(schema["minimum"], -(f32::MAX as f64)); + assert_eq!(schema["maximum"], f32::MAX as f64); +} + +#[test] +fn rich_json_schemas_enforce_allowlists_and_canonical_base64url() { + let text = SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + ..Default::default() + }); + let text_schema = to_json_schema(&SchemaGraph::anonymous(text.clone()), &text); + assert_eq!( + text_schema["properties"]["language"]["enum"], + json!(["en", "de"]) + ); + assert!(text_schema.get("description").is_none()); + + let binary = SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec!["image/png".to_string()]), + ..Default::default() + }); + let binary_schema = to_json_schema(&SchemaGraph::anonymous(binary.clone()), &binary); + assert_eq!( + binary_schema["properties"]["mimeType"]["enum"], + json!(["image/png"]) + ); + assert!(binary_schema.get("description").is_none()); + let pattern = binary_schema["properties"]["bytes"]["pattern"] + .as_str() + .expect("binary bytes pattern"); + assert_eq!( + pattern, + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$" + ); + let regex = regex::Regex::new(pattern).expect("canonical base64url regex"); + for valid in ["", "AQ", "AQI", "AQID", "-_8"] { + assert!(regex.is_match(valid), "{valid} must match"); + } + for invalid in ["+/8", "AQ==", "-_9", "A"] { + assert!(!regex.is_match(invalid), "{invalid} must not match"); + } +} + #[test] fn malformed_json_and_schema_values_are_typed_errors() { let ty = SchemaType::record(vec![NamedFieldType { @@ -227,3 +559,369 @@ fn malformed_json_and_schema_values_are_typed_errors() { .expect_err("wrong value shape must fail"); assert!(matches!(mismatch, RenderError::ValueMismatch { .. })); } + +fn conformance_fixture(name: &str, id: &str) -> SchemaGraph { + let root = match name { + "s64" => SchemaType::s64(), + "constrained-s64" => SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "u64" => SchemaType::u64(), + "binary" => SchemaType::binary(BinaryRestrictions::default()), + "duration" => SchemaType::duration(), + "quantity" => SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: vec![], + min: None, + max: None, + }), + "tool-input" => SchemaType::record(vec![ + field("pattern", SchemaType::string()), + field("paths", SchemaType::list(SchemaType::string())), + field("ignoreCase", SchemaType::option(SchemaType::bool())), + ]), + "config-entry" => SchemaType::record(vec![ + field("path", SchemaType::list(SchemaType::string())), + field("value", SchemaType::s64()), + ]), + "constrained-u32" => SchemaType::U32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(2)), + max: Some(NumericBound::Unsigned(10)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-f64" => SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-text" => SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + min_length: Some(2), + max_length: Some(8), + regex: Some("^[a-z]+$".to_string()), + }), + "constrained-binary" => SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec![ + "image/png".to_string(), + "application/octet-stream".to_string(), + ]), + min_bytes: Some(2), + max_bytes: Some(4), + }), + "result" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::u32())), + }), + "custom-error" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::record(vec![ + field("code", SchemaType::string()), + field("retryable", SchemaType::bool()), + ]))), + }), + "optional-record" => { + let id = TypeId::new("conformance.optional"); + return SchemaGraph { + defs: vec![SchemaTypeDef { + id: id.clone(), + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: SchemaType::record(vec![ + field("direct", SchemaType::option(SchemaType::string())), + field("referenced", SchemaType::ref_to(id)), + ]), + }; + } + other => panic!("{id}: unknown conformance fixture {other}"), + }; + SchemaGraph::anonymous(root) +} + +fn field(name: &str, body: SchemaType) -> NamedFieldType { + NamedFieldType { + name: name.to_string(), + body, + metadata: MetadataEnvelope::default(), + } +} + +fn assert_json_subset(actual: &Value, expected: &Value, id: &str) { + match expected { + Value::Object(expected) => { + let actual = actual + .as_object() + .unwrap_or_else(|| panic!("{id}: expected object {expected:?}, got {actual}")); + for (key, expected) in expected { + let actual = actual + .get(key) + .unwrap_or_else(|| panic!("{id}: missing key {key} in {actual:?}")); + assert_json_subset(actual, expected, id); + } + } + _ => assert_eq!(actual, expected, "{id}"), + } +} + +fn json_pointer<'a>(value: &'a Value, pointer: &str, id: &str) -> &'a Value { + if pointer.is_empty() { + value + } else { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("{id}: missing JSON pointer {pointer} in {value}")) + } +} + +fn assert_semantic_conformance(fixture: &str, expected: &Value, id: &str) { + match fixture { + "unsupported-leaves" => { + let types = [ + SchemaType::secret(Default::default()), + SchemaType::quota_token(QuotaTokenSpec::default()), + SchemaType::permission_card(PermissionCardSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ]; + assert_eq!( + types.len(), + expected["count"] + .as_u64() + .unwrap_or_else(|| panic!("{id}: missing unsupported-leaves count")) + as usize, + "{id}" + ); + for ty in types { + let graph = SchemaGraph::anonymous(ty.clone()); + assert_json_subset( + &to_reflection_json_schema(&graph, &ty, false), + &expected["schema"], + id, + ); + } + } + "all-kinds" => { + const KINDS: &[&str] = &[ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", + ]; + assert_eq!( + serde_json::to_value(KINDS).unwrap(), + expected["names"], + "{id}" + ); + } + "all-restrictions" => { + const RESTRICTIONS: &[&str] = &[ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", + ]; + assert_eq!( + serde_json::to_value(RESTRICTIONS).unwrap(), + expected["names"], + "{id}" + ); + } + "graph" => { + let referenced = conformance_fixture("optional-record", id); + let inline = SchemaGraph::anonymous(SchemaType::record(vec![ + field("direct", SchemaType::option(SchemaType::string())), + field("referenced", SchemaType::option(SchemaType::string())), + ])); + assert!(validate_graph(&referenced).is_ok(), "{id}"); + assert!( + is_equivalent_cross_graph(&referenced, &referenced.root, &inline, &inline.root,), + "{id}" + ); + } + other => panic!("{id}: unknown semantic conformance fixture {other}"), + } +} + +fn conformance_corpus() -> Value { + serde_json::from_str(include_str!( + "../../../../test-data/reflection-conformance/v1.json" + )) + .expect("valid reflection conformance corpus") +} + +fn run_conformance_cases(operation: &str) { + let corpus = conformance_corpus(); + let cases = corpus["cases"].as_array().expect("cases array"); + for case in cases { + let id = case["id"].as_str().expect("case id"); + let case_operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + if case_operation != operation { + continue; + } + let fixture = case["fixture"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing fixture")); + let expected = &case["expected"]; + match operation { + "roundtrip" => { + let graph = conformance_fixture(fixture, id); + let packed = from_json_value(&graph, &graph.root, &case["input"]) + .unwrap_or_else(|error| panic!("{id}: {error}")); + let rendered = to_json_value(&graph, &graph.root, &packed) + .unwrap_or_else(|error| panic!("{id}: {error}")); + assert_eq!(&rendered, expected, "{id}"); + } + "reject" => { + let graph = conformance_fixture(fixture, id); + let inputs = case["inputs"] + .as_array() + .cloned() + .unwrap_or_else(|| vec![case["input"].clone()]); + for input in inputs { + let actual = match from_json_value(&graph, &graph.root, &input) { + Err(_) => "invalid-json", + Ok(value) if validate_value(&graph, &graph.root, &value).is_err() => { + "constraint-violation" + } + Ok(value) => panic!("{id} accepted {input} as {value:?}"), + }; + assert_eq!( + actual, + expected["kind"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing reject kind")), + "{id}" + ); + } + } + "json-schema" => { + let graph = conformance_fixture(fixture, id); + let rendered = to_reflection_json_schema(&graph, &graph.root, false); + let path = case["path"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing path")); + let selected = json_pointer(&rendered, path, id); + assert_json_subset(selected, expected, id); + } + "semantic" => assert_semantic_conformance(fixture, expected, id), + operation => panic!("unknown requested conformance operation {operation}"), + } + } +} + +#[test] +fn reflection_conformance_corpus_integrity() { + let corpus = conformance_corpus(); + assert_eq!(corpus["version"], "1.0.0"); + let cases = corpus["cases"].as_array().expect("cases array"); + let recognized = HashSet::from(["roundtrip", "reject", "json-schema", "semantic"]); + let mut case_ids = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(case_ids.insert(id), "duplicate case ID {id}"); + let operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + assert!( + recognized.contains(operation), + "{id}: unknown operation {operation}" + ); + } + let declared_ids = corpus["caseIds"].as_array().expect("declared case IDs"); + let declared: HashSet<_> = declared_ids + .iter() + .map(|id| id.as_str().expect("declared case ID")) + .collect(); + assert_eq!( + declared.len(), + declared_ids.len(), + "duplicate declared case ID" + ); + assert_eq!(case_ids, declared, "missing or unknown conformance cases"); +} + +#[test] +fn reflection_conformance_roundtrip_cases() { + run_conformance_cases("roundtrip"); +} + +#[test] +fn reflection_conformance_reject_cases() { + run_conformance_cases("reject"); +} + +#[test] +fn reflection_conformance_json_schema_cases() { + run_conformance_cases("json-schema"); +} + +#[test] +fn reflection_conformance_semantic_cases() { + run_conformance_cases("semantic"); +} diff --git a/golem-skills/skills/common/golem-agent-reflection/SKILL.md b/golem-skills/skills/common/golem-agent-reflection/SKILL.md index 4175e9fbb4..14f755d1c0 100644 --- a/golem-skills/skills/common/golem-agent-reflection/SKILL.md +++ b/golem-skills/skills/common/golem-agent-reflection/SKILL.md @@ -38,11 +38,11 @@ Creation-time configuration overrides are optional because component defaults ma ## Optional values and canonical JSON -Normal RPC and caller-defined static clients use the language's normal optional-field syntax. Reflected JSON is schema-shaped instead: records contain every declared field and an absent `option` is `null`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. +Normal RPC and caller-defined static clients use the language's normal optional-field syntax. In reflected JSON, an omitted `option` record field and an explicit `null` both decode as absent; re-encoding may include the field with `null`. Reflection JSON Schema therefore omits optional fields from `required`. Tool defaults do not turn `Present` into a key-existence check; constraints evaluate the effective optional/default carrier, and nested `ValueIs` comparisons use the declared nested schema. Canonical invocation JSON uses JSON numbers for integers through 32 bits. Signed and unsigned 64-bit integers are canonical base-10 strings, as are duration nanoseconds and quantity mantissas. A duration is `{ "nanoseconds": "..." }`; a quantity is `{ "mantissa": "...", "scale": number, "unit": string }`. Leading zeroes, `+`, and `-0` are invalid. JSON Schema projections use the same string patterns and carry the exact range as metadata. -Streams and opaque capabilities have no canonical JSON representation. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. +Streams, futures, and opaque capabilities have no canonical JSON representation. Their reflection JSON Schema projection is unsatisfiable. Use the schema-native `*Value` APIs, transfer each owned handle once, consume returned streams, and cancel or close started operations according to the language-specific API. ## Mixing client approaches diff --git a/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md b/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md index c4f8da48a6..d5f21ff363 100644 --- a/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md +++ b/golem-skills/skills/effect/golem-agent-reflection-effect/SKILL.md @@ -86,7 +86,7 @@ Effect errors preserve the boundary that failed: - The host remains authoritative for visibility, authorization, environment-scoped identity resolution, effective configuration, and the deployed input schema. - Awaited calls verify unit/non-unit cardinality and declared output shape. `RemoteCallError`, `ToolRuntimeError`, custom agent errors, and custom tool payloads remain tagged values in the Effect error channel. -In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Canonical reflected JSON records contain every field, so represent an absent `option` with `null`: +In Normal RPC and caller-defined static inputs, use `Schema.optional(...)` in a struct and omit the property. Reflected JSON accepts either an omitted `option` record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema does not list that field in `required`: ```ts import { Effect } from "effect" @@ -97,14 +97,16 @@ const optionalCall = Effect.scoped(Effect.gen(function* () { if (!type || type.mode !== "durable") return yield* Effect.fail("SearchAgent unavailable") const client = yield* type.client.get({ tenant: "docs" }) const search = yield* client.method("search") - const checked = search.definition.input.validateJson({ query: "golem", cursor: null }) + const checked = search.definition.input.validateJson({ query: "golem" }) if (!checked.success) return yield* Effect.fail(checked.issues) - return yield* search.invoke({ query: "golem", cursor: null }) + return yield* search.invoke({ query: "golem" }) })) ``` Canonical JSON represents `s64` and `u64` as base-10 strings. Duration is `{ nanoseconds: "..." }`; quantity uses a decimal-string `mantissa`. Smaller integers remain JSON numbers. JSON Schema projections expose the same canonical patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, streams, and cleanup Agent calls are Effects: fiber interruption cancels result observation, while remote side effects may already have happened. A scheduled call returns a scoped cancel Effect. Keep the scope open until the cancellation token is no longer needed: @@ -142,8 +144,6 @@ const dynamicSearch = Effect.scoped(Effect.gen(function* () { return yield* Effect.fail("SearchAgent.search is unavailable") const input = method.input.packJson({ query: "golem", cursor: null }) - const inputCheck = method.input.validateValue(input) - if (!inputCheck.success) return yield* Effect.fail(inputCheck.issues) const identity = yield* type.agentId({ tenant: "docs" }) const dynamic = yield* identity.dynamicClient() diff --git a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md index aba3677731..dc8af820d5 100644 --- a/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md +++ b/golem-skills/skills/moonbit/golem-agent-reflection-moonbit/SKILL.md @@ -36,7 +36,13 @@ Reflection clients do not pin that component ID. `SchemaRef::pack_json` converts canonical JSON into a schema-native value; `unpack_json` performs the awaited conversion back. `to_json_schema()` projects the canonical JSON carrier for introspection, including named definitions and representable restrictions; -capabilities and streams have no JSON value representation. Discovery returns +capabilities, futures, and streams have no JSON value representation and project +to an unsatisfiable reflection JSON Schema. An omitted option record field and +an explicit `null` both decode as absent; re-encoding may emit `null`, and the +field is omitted from JSON Schema `required`. Canonical JSON encodes `s64` and +`u64` as decimal strings, duration as `{ "nanoseconds": "..." }`, and quantity +mantissas as decimal strings. These strings reject `+`, leading zeroes, `-0`, +and overflow. Discovery returns an immutable snapshot; explicitly discover again when a newer deployment must be observed. @@ -54,7 +60,7 @@ let result = counter.invoke_json( ) ``` -For explicit reflected packing, call `add.input.pack_json`, invoke through +For explicit reflected packing, call `add.input().pack_json`, invoke through `invoke_value`, await the result, and call the output `SchemaRef::unpack_json`. Reflected schema-native constructor and method inputs are checked locally against the selected schema graph before opening RPC. Declared native outputs @@ -121,14 +127,14 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + let typed_input = command.input_schema().typed_value(packed) catch { error => return "input:\{Repr(error)}" } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) let raw = match dynamic.invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed }, + command.path(), + typed_input, ) { Ok(raw) => raw Err(error) => { @@ -136,7 +142,7 @@ async fn invoke_discovered_tool_dynamically() -> String { } } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { diff --git a/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md b/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md index 4fa981986c..683397d01a 100644 --- a/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md +++ b/golem-skills/skills/rust/golem-agent-reflection-rust/SKILL.md @@ -33,14 +33,15 @@ Validation happens at three different boundaries: | Completion | Result cardinality, resolved graph equivalence, declared custom-error payloads, and value decoding | Execution failures and undeclared remote errors | Normal RPC and caller-defined Rust values use `Option`. Reflected canonical JSON -omits an optional record field or supplies its ordinary JSON value; when using +treats an omitted optional record field or an explicit `null` as absent; re-encoding +may include that field with `null`, and reflection JSON Schema omits it from +`required`. When using `SchemaValue` directly, use `SchemaValue::Option { inner: None }` or `SchemaValue::Option { inner: Some(Box::new(value)) }`. Optional tool scalar positionals and options use that option carrier. Repeatable/tail inputs use an empty list for absence, flags use their effective boolean/count value, and an explicit default is represented by the default value. Do not replace an absent -optional carrier with a zero, empty string, or `null` unless its declared schema -actually permits that value. +optional carrier with a zero or empty string. Canonical JSON represents `s64`, `u64`, duration nanoseconds, and quantity mantissas as canonical base-10 strings. Smaller integers remain JSON numbers. @@ -48,6 +49,10 @@ Packing rejects leading `+`, non-canonical leading zeroes, and out-of-range values before transport; projected JSON Schema uses the same string patterns and exposes the exact range as metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected +JSON. Their reflection JSON Schema projection is unsatisfiable; use +schema-native value APIs for those leaves. + ## Discover Agent Types Discover the agent types registered in the current environment: @@ -298,7 +303,6 @@ async fn search_dynamically() -> Result { let input = method .input() .pack_json(&json!({ "query": "golem", "cursor": null }))?; - method.input().validate_value(&input)?; let agent_id = agent_type.agent_id_json(&json!({ "tenant": "docs" }), None)?; let result = match agent_id diff --git a/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md index c20bd4656a..d4e203cb86 100644 --- a/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md +++ b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md @@ -34,6 +34,13 @@ Agent type names are unique in an environment. An `AgentType` exposes its current component ID, lifecycle mode, constructor `SchemaRef`, and method `SchemaRef`s. `SchemaRef` validates `SchemaValue`, packs and unpacks canonical `zio.blocks.schema.json.Json`, and renders JSON Schema. +An omitted option record field and an explicit `null` both decode as `None`; +re-encoding may emit `null`, and the field is omitted from JSON Schema +`required`. Canonical JSON encodes `s64` and `u64` as decimal strings, duration +as `{ "nanoseconds": "..." }`, and quantity mantissas as decimal strings. These +strings reject `+`, leading zeroes, `-0`, and overflow. Capabilities, futures, +and streams cannot be packed or unpacked as reflected JSON and project to an +unsatisfiable reflection JSON Schema. `getAgentType` returns `Right(None)` for a missing type and reserves `Left` for discovery or decoding failures. The returned schemas are an immutable snapshot; call discovery again when a newer deployment must be observed. @@ -91,10 +98,6 @@ def invokeDiscoveredToolDynamically( tool <- Reflection.getToolType(toolName).left.map(_.toString) command <- tool.command(path).left.map(_.toString) input <- command.packJson(json).left.map(_.toString) - _ <- command.inputSchema - .validateValue(input) - .left - .map(_.map(_.message).mkString("; ")) } yield (tool, command, TypedSchemaValue(command.inputSchema.graph, input)) prepared match { diff --git a/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md b/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md index 52e0c84375..9fab6949b6 100644 --- a/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md +++ b/golem-skills/skills/ts/golem-agent-reflection-ts/SKILL.md @@ -235,7 +235,7 @@ Validation happens at several boundaries: - The host authorizes the caller, resolves the environment-scoped identity, validates effective configuration, and checks the deployed input schema. - Awaited typed and reflected calls verify unit/non-unit cardinality and decode the declared result. Catch `RemoteCallError` with `isRemoteCallError(error)` and handle `error.cause` as a tagged value; do not parse messages. -In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Canonical reflected JSON records contain every field, so pass `null` for an absent option: +In Normal RPC and caller-defined static inputs, declare optional fields with the schema library, for example `z.string().optional()`, and omit them normally. Reflected JSON accepts either an omitted option record field or an explicit `null` as absent; re-encoding may include the field with `null`. Reflection JSON Schema omits that field from `required`: ```typescript import { getReflectedAgentType } from '@golemcloud/golem-ts-sdk'; @@ -244,17 +244,18 @@ const type = getReflectedAgentType('SearchAgent'); const search = type?.method('search'); if (!type || !search || type.mode !== 'durable') throw new Error('SearchAgent.search unavailable'); -const checked = search.input.validateJson({ query: 'golem', cursor: null }); +const checked = search.input.validateJson({ query: 'golem' }); if (!checked.success) throw new Error(JSON.stringify(checked.issues)); const result = await type.client.get({ tenant: 'docs' }).method('search').invoke({ query: 'golem', - cursor: null, }); console.log(result.value); ``` Canonical JSON represents `s64` and `u64` as decimal strings. Duration is `{ nanoseconds: "..." }`, and quantity uses a decimal-string `mantissa`. Smaller integers remain numbers. The generated JSON Schema uses matching patterns and exact range metadata. +Capabilities, futures, and streams cannot be packed or unpacked as reflected JSON. Their reflection JSON Schema projection is unsatisfiable; use schema-native value APIs for those leaves. + ## Cancellation, Streams, and Cleanup Cancel an awaited agent call with `AbortSignal`; cancel a scheduled durable call with its token. A triggered call has no result observer: diff --git a/golem-worker-service/src/mcp/invoke/mod.rs b/golem-worker-service/src/mcp/invoke/mod.rs index 7ae6b988d6..13c1d1a8b5 100644 --- a/golem-worker-service/src/mcp/invoke/mod.rs +++ b/golem-worker-service/src/mcp/invoke/mod.rs @@ -194,19 +194,24 @@ mod codec_tests { } #[test] - fn option_record_field_is_required() { + fn option_record_field_may_be_omitted() { let ty = SchemaType::record(vec![field( "inner", SchemaType::option(SchemaType::string()), )]); - // The advertised schema marks the field required; an explicit null is - // accepted and round-trips. + // The advertised schema omits the field from `required`; omission and + // explicit null both decode as `None`. round_trip(ty.clone(), json!({"inner": null})); round_trip(ty.clone(), json!({"inner": "x"})); - // Omitting the field is rejected (schema and runtime now agree). - assert!( - parse_fails(ty, json!({})), - "omitted option record field must be rejected" + let graph = SchemaGraph::anonymous(ty); + let value = from_json_value(&graph, &graph.root, &json!({})).expect("omitted option field"); + assert_eq!( + value, + golem_common::schema::schema_value::SchemaValue::Record { + fields: vec![golem_common::schema::schema_value::SchemaValue::Option { + inner: None, + }], + } ); } } diff --git a/golem-worker-service/src/service/worker/service.rs b/golem-worker-service/src/service/worker/service.rs index a6022ba6af..a0d74b0826 100644 --- a/golem-worker-service/src/service/worker/service.rs +++ b/golem-worker-service/src/service/worker/service.rs @@ -44,8 +44,9 @@ use golem_common::base_model::json::NormalizedJsonValue; use golem_common::model::AgentInvocationOutput; use golem_common::model::account::AccountId; use golem_common::model::agent::{ - AgentMode, AgentTypeName, GolemUserPrincipal, InvocationFreshnessDisposition, OwnerKind, - ParsedAgentId, Principal, ephemeral_invocation_phantom_id, + AgentConfigSource, AgentMode, AgentTypeName, GolemUserPrincipal, + InvocationFreshnessDisposition, OwnerKind, ParsedAgentId, Principal, + ephemeral_invocation_phantom_id, }; use golem_common::model::application::ApplicationName; use golem_common::model::card::owner::{AgentOwnerLeafPattern, AgentOwnerPattern}; @@ -64,7 +65,8 @@ use golem_common::model::filesystem::{ FileByteSelection, FileReadError, FileReadHead, validate_file_read_path, }; use golem_common::model::invocation_session_public::{ - InvocationSelector, PublicConfigEntry, PublicNativeToolTarget, PublicTypedValue, + InvocationSelector, PublicConfigEntry, PublicErrorCode, PublicNativeToolTarget, + PublicTypedValue, }; use golem_common::model::oplog::OplogCursor; use golem_common::model::oplog::OplogIndex; @@ -73,6 +75,7 @@ use golem_common::model::worker::AgentConfigEntryDto; use golem_common::model::worker::AgentUpdateMode; use golem_common::model::worker::{AgentMetadataDto, ResolvedRevert, RevertWorkerTarget}; use golem_common::model::{AgentFilter, AgentFingerprint, AgentId, IdempotencyKey, ScanCursor}; +use golem_common::schema::agent::AgentConfigDeclarationSchema; use golem_common::schema::json_input_schema_value_to_typed_schema_value; use golem_common::schema::public_json::{ PublicSchemaValueError, PublicStreamReference, PublicStreamReferencePolicy, @@ -146,6 +149,54 @@ fn public_input_graph(graph: &SchemaGraph, input_schema: &InputSchema) -> Schema } } +fn decode_public_agent_config( + graph: &SchemaGraph, + declarations: &[AgentConfigDeclarationSchema], + entries: Vec, +) -> Result, PublicSchemaValueError> { + entries + .into_iter() + .map(|entry| { + let declaration = declarations + .iter() + .find(|declaration| { + declaration.source == AgentConfigSource::Local && declaration.path == entry.path + }) + .ok_or_else(|| { + PublicSchemaValueError::new( + PublicErrorCode::ValidationError, + format!( + "agent type does not declare local config {}", + entry.path.join(".") + ), + ) + })?; + let value = decode_public_json_schema_value( + graph, + &declaration.value_type, + &entry.value, + PublicStreamReferencePolicy::None, + |_, _| unreachable!("configuration stream references are disabled by policy"), + )?; + let value = + golem_schema::schema::render::to_json_value(graph, &declaration.value_type, &value) + .map_err(|error| { + PublicSchemaValueError::new( + PublicErrorCode::ValidationError, + format!( + "config value for path {} cannot be encoded canonically: {error}", + entry.path.join(".") + ), + ) + })?; + Ok(AgentConfigEntryDto { + path: entry.path, + value: NormalizedJsonValue::new(value), + }) + }) + .collect() +} + fn public_invocation_graph( graph: &SchemaGraph, input_schema: &InputSchema, @@ -2011,15 +2062,6 @@ impl WorkerService { let agent_type_name = AgentTypeName(start.selector.agent_type); let method_name = start.selector.method; let idempotency_key = IdempotencyKey::new(start.idempotency_key); - let config = start - .config - .into_iter() - .map(|entry| AgentConfigEntryDto { - path: entry.path, - value: NormalizedJsonValue::new(entry.value), - }) - .collect::>(); - let resolved = self .agent_resolution_cache .resolve(&app_name, &env_name, &agent_type_name, None, &auth) @@ -2029,6 +2071,8 @@ impl WorkerService { let environment_id = resolved.environment_id; let component_id = registered_agent_type.implemented_by.component_id; let agent_type = ®istered_agent_type.agent_type; + let config = + decode_public_agent_config(&agent_type.schema, &agent_type.config, start.config)?; let constructor_graph = public_input_graph(&agent_type.schema, &agent_type.constructor.input_schema); let constructor_parameters = decode_public_json_schema_value( @@ -3525,7 +3569,8 @@ mod tests { use super::{ PublicAgentSessionStart, PublicAgentSessionStartError, WorkerService, agent_verb_for_invocation_mode, build_public_agent_id, build_public_invocation_agent_id, - decode_public_schema_value, normalize_agent_invocation_identity, + decode_public_agent_config, decode_public_schema_value, + normalize_agent_invocation_identity, }; use crate::api::agents::{ AgentInvocationMode, AgentInvocationRequest, CreateAgentRequest, NativeToolDescribeRequest, @@ -3550,7 +3595,7 @@ mod tests { use golem_common::model::Empty; use golem_common::model::account::{AccountEmail, AccountId}; use golem_common::model::agent::{ - AgentMode, AgentTypeName, GolemUserPrincipal, HttpEndpointDetails, + AgentConfigSource, AgentMode, AgentTypeName, GolemUserPrincipal, HttpEndpointDetails, InvocationFreshnessDisposition, OwnerKind, ParsedAgentId, Principal, RegisteredAgentType, RegisteredAgentTypeImplementer, ResolvedAgentType, Snapshotting, ephemeral_invocation_phantom_id, @@ -3573,7 +3618,7 @@ mod tests { use golem_common::model::diff::Hash; use golem_common::model::environment::{EnvironmentId, EnvironmentName}; use golem_common::model::filesystem::{FileByteSelection, FileReadHead}; - use golem_common::model::invocation_session_public::InvocationSelector; + use golem_common::model::invocation_session_public::{InvocationSelector, PublicConfigEntry}; use golem_common::model::json::NormalizedJsonValue; use golem_common::model::oplog::{OplogCursor, OplogIndex}; use golem_common::model::tool::{ToolBindingOwner, ToolName}; @@ -3584,6 +3629,7 @@ mod tests { use golem_common::model::{ AgentFilter, AgentFingerprint, AgentId, AgentStatus, IdempotencyKey, ScanCursor, Timestamp, }; + use golem_common::schema::agent::AgentConfigDeclarationSchema; use golem_common::schema::public_json::PublicStreamReference; use golem_common::schema::stream::SchemaValueStream; use golem_common::schema::{ @@ -3609,6 +3655,77 @@ mod tests { ) } + #[test] + fn public_agent_config_is_transcoded_to_canonical_json() { + let graph = SchemaGraph::anonymous(SchemaType::string()); + let declarations = vec![ + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["optional".to_string()], + value_type: SchemaType::option(SchemaType::string()), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["result".to_string()], + value_type: SchemaType::result(golem_common::schema::ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: None, + }), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["variant".to_string()], + value_type: SchemaType::variant(vec![golem_common::schema::VariantCaseType { + name: "payload".to_string(), + payload: Some(SchemaType::string()), + metadata: Default::default(), + }]), + }, + AgentConfigDeclarationSchema { + source: AgentConfigSource::Local, + path: vec!["union".to_string()], + value_type: SchemaType::union(golem_common::schema::UnionSpec { + branches: vec![golem_common::schema::UnionBranch { + tag: "command".to_string(), + body: SchemaType::string(), + discriminator: golem_common::schema::DiscriminatorRule::Prefix { + prefix: "cmd:".to_string(), + }, + metadata: Default::default(), + }], + }), + }, + ]; + let config = decode_public_agent_config( + &graph, + &declarations, + vec![ + PublicConfigEntry { + path: vec!["optional".to_string()], + value: serde_json::json!({"$option": "some", "value": "west"}), + }, + PublicConfigEntry { + path: vec!["result".to_string()], + value: serde_json::json!({"$result": "ok", "value": "ready"}), + }, + PublicConfigEntry { + path: vec!["variant".to_string()], + value: serde_json::json!({"$case": "payload", "value": "value"}), + }, + PublicConfigEntry { + path: vec!["union".to_string()], + value: serde_json::json!({"$union": "command", "value": "cmd:run"}), + }, + ], + ) + .unwrap(); + + assert_eq!(config[0].value.0, serde_json::json!("west")); + assert_eq!(config[1].value.0, serde_json::json!({"ok": "ready"})); + assert_eq!(config[2].value.0, serde_json::json!({"payload": "value"})); + assert_eq!(config[3].value.0, serde_json::json!("cmd:run")); + } + struct TestAgentTypeResolver(AgentMode); impl golem_common::model::agent::AgentTypeSchemaResolver for TestAgentTypeResolver { diff --git a/scripts/sync-reflection-conformance.mjs b/scripts/sync-reflection-conformance.mjs new file mode 100644 index 0000000000..e0a82c6ab4 --- /dev/null +++ b/scripts/sync-reflection-conformance.mjs @@ -0,0 +1,95 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const corpusPath = path.join(root, 'test-data/reflection-conformance/v1.json'); +const scalaPath = path.join( + root, + 'sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala', +); +const moonBitPath = path.join( + root, + 'sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt', +); + +const source = await readFile(corpusPath, 'utf8'); +const corpus = JSON.parse(source); +const normalized = `${JSON.stringify(corpus, null, 2)}\n`; + +if (corpus.version !== '1.0.0') throw new Error(`unexpected corpus version ${corpus.version}`); +if (corpus.schemaKinds.length !== 37) throw new Error('the corpus must enumerate all 37 schema kinds'); +if (corpus.restrictionKinds.length !== 24) + throw new Error('the corpus must enumerate all 24 restriction kinds'); + +const ids = corpus.cases.map((testCase) => testCase.id); +if (new Set(ids).size !== ids.length) throw new Error('conformance case IDs must be unique'); +if (new Set(corpus.caseIds).size !== corpus.caseIds.length) + throw new Error('declared conformance case IDs must be unique'); +if (JSON.stringify([...ids].sort()) !== JSON.stringify([...corpus.caseIds].sort())) + throw new Error('cases must exactly match the declared conformance case-ID set'); +for (const testCase of corpus.cases) { + for (const field of ['id', 'operation', 'fixture', 'expected']) { + if (!(field in testCase)) throw new Error(`${testCase.id ?? ''} is missing ${field}`); + } + if (testCase.operation === 'reject') { + const kind = testCase.expected?.kind; + if (kind !== 'invalid-json' && kind !== 'constraint-violation') { + throw new Error( + `${testCase.id} has missing or unknown reject kind ${JSON.stringify(kind)}`, + ); + } + } +} + +const scala = `/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. +private[reflection] object ReflectionConformanceCorpus { + val json: String = """${normalized}""" +} +`; + +const moonBit = `// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. + +///| +let reflection_conformance_corpus_json : String = ${JSON.stringify(normalized)} +`; + +const generated = [ + [scalaPath, scala], + [moonBitPath, moonBit], +]; + +if (process.argv.includes('--check')) { + const stale = []; + for (const [file, expected] of generated) { + const actual = await readFile(file, 'utf8').catch(() => undefined); + if (actual !== expected) stale.push(path.relative(root, file)); + } + if (stale.length !== 0) { + throw new Error( + `reflection conformance embeds are stale: ${stale.join(', ')}; run node scripts/sync-reflection-conformance.mjs`, + ); + } +} else { + await Promise.all(generated.map(([file, contents]) => writeFile(file, contents))); +} diff --git a/sdks/effect/src/SchemaRef.ts b/sdks/effect/src/SchemaRef.ts index 750c6377f7..29386f083b 100644 --- a/sdks/effect/src/SchemaRef.ts +++ b/sdks/effect/src/SchemaRef.ts @@ -7,6 +7,7 @@ import { schemaValueToWit, } from "./internal/schema-model/wit.js" import { + assertCanonicalJsonEligible, jsonSchema, packJson, SchemaRenderError, @@ -43,21 +44,24 @@ export class SchemaRef { }) return Object.freeze(ref) } - /** Pack canonical JSON into the native schema-value carrier. @since 1.6.0 @category conversions */ + /** Pack canonical JSON and validate it against the complete schema. @since 1.6.0 @category conversions */ packJson(value: JsonValue): CoreTypes.SchemaValueTree { - return schemaValueToWit(packJson(this.graph, this.root, value)) + const model = packJson(this.graph, this.root, value) + if (!schemaValueConforms(this.graph, this.root, model)) + throw new SchemaRenderError([], "schema value does not conform to the expected schema") + return schemaValueToWit(model) } /** Unpack a native schema value into canonical JSON. @since 1.6.0 @category conversions */ unpackJson(value: CoreTypes.SchemaValueTree): JsonValue { - return unpackJson(this.graph, this.root, schemaValueFromWit(value)) + const model = schemaValueFromWit(value) + if (!schemaValueConforms(this.graph, this.root, model)) + throw new SchemaRenderError([], "schema value does not conform to the expected schema") + return unpackJson(this.graph, this.root, model) } /** Explicitly validate canonical JSON. @since 1.6.0 @category validation */ validateJson(value: JsonValue): ValidationResult { try { - const model = packJson(this.graph, this.root, value) - if (!schemaValueConforms(this.graph, this.root, model)) - return invalid("schema value does not conform to the expected schema") - return { success: true, value: schemaValueToWit(model) } + return { success: true, value: this.packJson(value) } } catch (error) { return invalid(error) } @@ -76,7 +80,7 @@ export class SchemaRef { /** Check whether this root has an unambiguous canonical JSON representation. @since 1.6.0 @category validation */ jsonEligibility(): ValidationResult { try { - jsonSchema(this.graph, this.root, false) + assertCanonicalJsonEligible(this.graph, this.root) return { success: true, value: undefined } } catch (error) { return invalid(error) diff --git a/sdks/effect/src/internal/reflection/schemaRender.ts b/sdks/effect/src/internal/reflection/schemaRender.ts index b92da78926..df6546ed39 100644 --- a/sdks/effect/src/internal/reflection/schemaRender.ts +++ b/sdks/effect/src/internal/reflection/schemaRender.ts @@ -154,7 +154,10 @@ export function fromCanonicalJson( return { tag: "record", fields: body.fields.map((field) => { - if (!(field.name in object)) fail([...path, field.name], "missing field") + if (!Object.prototype.hasOwnProperty.call(object, field.name)) { + if (resolve(graph, field.body).body.tag === "option") return { tag: "option" } + fail([...path, field.name], "missing field") + } return fromCanonicalJson(graph, field.body, object[field.name], [...path, field.name]) }), } @@ -312,11 +315,14 @@ export function toCanonicalJson( text: value.text, ...(value.language === undefined ? {} : { language: value.language }), } - case "binary": + case "binary": { + if (value.mimeType !== undefined && !MIME_TYPE_PATTERN.test(value.mimeType)) + fail([...path, "mimeType"], "invalid MIME type") return { bytes: bytesToBase64(value.bytes), ...(value.mimeType === undefined ? {} : { mimeType: value.mimeType }), } + } case "datetime": return datetimeToISOString(value.value) case "duration": @@ -453,7 +459,7 @@ export function toCanonicalJsonSchema( type: SchemaType, includeDraftMarker: boolean, ): JsonValue { - const referencedDefinitions = assertCanonicalJsonEligible(graph, type) + const referencedDefinitions = assertCanonicalJsonEligible(graph, type, true) const root = renderSchema(graph, type) const defs = Object.fromEntries( [...referencedDefinitions].map((id) => { @@ -474,7 +480,11 @@ export function toCanonicalJsonSchema( } } -function assertCanonicalJsonEligible(graph: SchemaGraph, root: SchemaType): ReadonlySet { +export function assertCanonicalJsonEligible( + graph: SchemaGraph, + root: SchemaType, + allowUnsupportedLeaves = false, +): ReadonlySet { const referencedDefinitions = new Set() const visit = (type: SchemaType, path: Path, aliasChain = new Set()): void => { const body = type.body @@ -523,9 +533,11 @@ function assertCanonicalJsonEligible(graph: SchemaGraph, root: SchemaType): Read case "secret": case "quota-token": case "permission-card": + if (allowUnsupportedLeaves) return return fail(path, `${body.tag} values cannot cross a canonical JSON boundary`) case "future": case "stream": + if (allowUnsupportedLeaves) return return fail(path, `${body.tag} values have no canonical JSON representation`) default: return @@ -613,12 +625,17 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record field.name), + required: body.fields + .filter((field) => resolve(graph, field.body).body.tag !== "option") + .map((field) => field.name), additionalProperties: false, } break @@ -804,10 +827,10 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record> 2)) if (value[index + 3] !== undefined) bytes.push(((c & 3) << 6) | d) } - return Uint8Array.from(bytes) + const result = Uint8Array.from(bytes) + if (bytesToBase64(result) !== value) fail(path, "invalid base64url without padding") + return result } function rejectUnknownFields( diff --git a/sdks/effect/test/reflection-conformance.test.ts b/sdks/effect/test/reflection-conformance.test.ts new file mode 100644 index 0000000000..cbc52163e2 --- /dev/null +++ b/sdks/effect/test/reflection-conformance.test.ts @@ -0,0 +1,309 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "@effect/vitest" +import { SchemaRef, type JsonValue } from "../src/SchemaRef.js" +import { field, t, type SchemaGraph, type SchemaType } from "../src/internal/schema-model/model.js" +import { schemaGraphToWit } from "../src/internal/schema-model/wit.js" +import { fromCanonicalJson, SchemaRenderError } from "../src/internal/reflection/schemaRender.js" +import { schemaValueConforms } from "../src/internal/reflection/schemaValidation.js" + +interface ConformanceCase { + id: string + operation: "roundtrip" | "reject" | "json-schema" | "semantic" + fixture: string + input?: JsonValue + inputs?: JsonValue[] + path?: string + expected: JsonValue +} + +interface ConformanceCorpus { + version: string + schemaKinds: string[] + restrictionKinds: string[] + caseIds: string[] + cases: ConformanceCase[] +} + +const corpus = JSON.parse( + readFileSync( + new URL("../../../test-data/reflection-conformance/v1.json", import.meta.url), + "utf8", + ), +) as ConformanceCorpus + +const supportedSchemaKinds = [ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", +] as const + +const supportedRestrictionKinds = [ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", +] as const + +const ref = (root: SchemaType) => new SchemaRef(schemaGraphToWit({ defs: new Map(), root })) + +function fixture(name: string): SchemaRef { + switch (name) { + case "s64": + return ref(t.s64()) + case "constrained-s64": + return ref( + t.s64({ + min: { tag: "signed", val: -9_007_199_254_740_993n }, + max: { tag: "signed", val: 9_007_199_254_740_993n }, + }), + ) + case "u64": + return ref(t.u64()) + case "binary": + return ref(t.binary()) + case "duration": + return ref(t.duration()) + case "quantity": + return ref(t.quantity({ baseUnit: "m", allowedSuffixes: [] })) + case "optional-record": { + const graph: SchemaGraph = { + defs: new Map([["conformance.optional", { body: t.option(t.string()) }]]), + root: t.record([ + field("direct", t.option(t.string())), + field("referenced", t.ref("conformance.optional")), + ]), + } + return new SchemaRef(schemaGraphToWit(graph)) + } + case "tool-input": + return ref( + t.record([ + field("pattern", t.string()), + field("paths", t.list(t.string())), + field("ignoreCase", t.option(t.bool())), + ]), + ) + case "config-entry": + return ref(t.record([field("path", t.list(t.string())), field("value", t.s64())])) + case "constrained-u32": + return ref( + t.u32({ + min: { tag: "unsigned", val: 2n }, + max: { tag: "unsigned", val: 10n }, + }), + ) + case "constrained-f64": + return ref( + t.f64({ + min: { tag: "float-bits", val: 0xbff8000000000000n }, + max: { tag: "float-bits", val: 0x4004000000000000n }, + }), + ) + case "constrained-text": + return ref( + t.text({ + languages: ["en", "de"], + minLength: 2, + maxLength: 8, + regex: "^[a-z]+$", + }), + ) + case "constrained-binary": + return ref( + t.binary({ + mimeTypes: ["image/png", "application/octet-stream"], + minBytes: 2, + maxBytes: 4, + }), + ) + case "result": + return ref(t.result(t.string(), t.u32())) + case "custom-error": + return ref( + t.result(t.string(), t.record([field("code", t.string()), field("retryable", t.bool())])), + ) + default: + throw new Error(`unknown conformance fixture ${name}`) + } +} + +function atPointer(value: JsonValue, pointer: string): JsonValue { + if (pointer === "") return value + return pointer + .slice(1) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + .reduce((current, part) => { + if (current === null || typeof current !== "object" || Array.isArray(current)) { + throw new Error(`cannot resolve ${pointer}`) + } + const next = current[part] + if (next === undefined) throw new Error(`missing ${pointer}`) + return next + }, value) +} + +function expectSubset(actual: JsonValue, expected: JsonValue): void { + if (expected !== null && typeof expected === "object" && !Array.isArray(expected)) { + expect(actual).not.toBeNull() + expect(typeof actual).toBe("object") + expect(Array.isArray(actual)).toBe(false) + for (const [key, value] of Object.entries(expected)) { + expect(Object.prototype.hasOwnProperty.call(actual, key)).toBe(true) + expectSubset((actual as Record)[key], value) + } + } else { + expect(actual).toEqual(expected) + } +} + +function assertSemantic(testCase: ConformanceCase): void { + const expected = testCase.expected as Record + switch (testCase.fixture) { + case "unsupported-leaves": { + const unsupported = [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + t.future(t.string()), + t.stream(t.string()), + ] + expect(unsupported).toHaveLength(expected.count as number) + for (const type of unsupported) expectSubset(ref(type).toJsonSchema(), expected.schema) + break + } + case "all-kinds": + expect(supportedSchemaKinds).toEqual(expected.names) + expect(corpus.schemaKinds).toEqual(expected.names) + break + case "all-restrictions": + expect(supportedRestrictionKinds).toEqual(expected.names) + expect(corpus.restrictionKinds).toEqual(expected.names) + break + case "graph": { + const referenced = fixture("optional-record") + const inline = ref( + t.record([ + field("direct", t.option(t.string())), + field("referenced", t.option(t.string())), + ]), + ) + expect(referenced.packJson({})).toEqual(inline.packJson({})) + expect(referenced.validateJson({}).success).toBe(true) + break + } + default: + throw new Error(`unknown semantic conformance fixture ${testCase.fixture}`) + } +} + +function executeCase(testCase: ConformanceCase): void { + switch (testCase.operation) { + case "roundtrip": { + const schema = fixture(testCase.fixture) + expect(schema.unpackJson(schema.packJson(testCase.input!))).toEqual(testCase.expected) + break + } + case "reject": { + for (const input of testCase.inputs ?? [testCase.input!]) { + const schema = fixture(testCase.fixture) + expect( + () => schema.packJson(input), + `${testCase.id}: direct packing accepted the input`, + ).toThrow(SchemaRenderError) + let packed: ReturnType + try { + packed = fromCanonicalJson(schema.graph, schema.root, input) + } catch { + expect("invalid-json").toBe((testCase.expected as { readonly kind: string }).kind) + continue + } + if (schemaValueConforms(schema.graph, schema.root, packed)) + throw new Error(`accepted ${JSON.stringify(input)}`) + expect("constraint-violation").toBe((testCase.expected as { readonly kind: string }).kind) + } + break + } + case "json-schema": + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ""), + testCase.expected, + ) + break + case "semantic": + assertSemantic(testCase) + break + default: + throw new Error(`unknown conformance operation ${String(testCase.operation)}`) + } +} + +describe("reflection conformance corpus", () => { + it("has a valid version, unique declared case IDs, and recognized operations", () => { + expect(corpus.version).toBe("1.0.0") + const ids = corpus.cases.map((testCase) => testCase.id) + expect(new Set(ids).size, "duplicate corpus case ID").toBe(ids.length) + expect(new Set(corpus.caseIds).size, "duplicate declared case ID").toBe(corpus.caseIds.length) + expect([...ids].sort()).toEqual([...corpus.caseIds].sort()) + const operations = new Set(["roundtrip", "reject", "json-schema", "semantic"]) + for (const testCase of corpus.cases) { + expect(operations.has(testCase.operation), testCase.id).toBe(true) + } + }) + + for (const testCase of corpus.cases) it(testCase.id, () => executeCase(testCase)) +}) diff --git a/sdks/effect/test/reflection.test.ts b/sdks/effect/test/reflection.test.ts index 5785ec081d..80d87942ff 100644 --- a/sdks/effect/test/reflection.test.ts +++ b/sdks/effect/test/reflection.test.ts @@ -88,7 +88,11 @@ describe("reflection", () => { const ref = new SchemaRef(schemaGraphToWit(recursive)) const json = { label: "first", next: { label: "last", next: null } } expect(ref.unpackJson(ref.packJson(json))).toEqual(json) - expect(ref.validateJson({ label: "missing-next" })).toMatchObject({ success: false }) + expect(ref.validateJson({ label: "missing-next" })).toMatchObject({ success: true }) + expect(ref.unpackJson(ref.packJson({ label: "missing-next" }))).toEqual({ + label: "missing-next", + next: null, + }) expect(ref.validateValue(ref.packJson(json))).toMatchObject({ success: true }) expect(ref.toJsonSchema()).toMatchObject({ $schema: expect.any(String) }) expect(Object.isFrozen(ref.graph)).toBe(true) diff --git a/sdks/effect/test/schema-ref.test.ts b/sdks/effect/test/schema-ref.test.ts index e870f55c22..39e29b8899 100644 --- a/sdks/effect/test/schema-ref.test.ts +++ b/sdks/effect/test/schema-ref.test.ts @@ -35,7 +35,7 @@ describe("SchemaRef", () => { }) }) - it("renders numeric restrictions and required nullable fields", () => { + it("renders numeric restrictions and optional nullable fields", () => { const schema = ref( t.record([ field( @@ -46,11 +46,14 @@ describe("SchemaRef", () => { ]), ) expect(schema.toJsonSchema()).toMatchObject({ - required: ["bounded", "nullable"], + required: ["bounded"], properties: { bounded: { type: "integer", minimum: 10, maximum: 20 } }, }) - expect(schema.validateJson({ bounded: 15 }).success).toBe(false) + expect(schema.validateJson({ bounded: 15 }).success).toBe(true) expect(schema.validateJson({ bounded: 15, nullable: null }).success).toBe(true) + expect( + ref(t.record([field("constructor", t.option(t.string()))])).validateJson({}).success, + ).toBe(true) for (const numeric of [t.f32, t.f64]) { expect( ref( @@ -63,6 +66,52 @@ describe("SchemaRef", () => { } }) + it("rejects native values outside declared restrictions before unpacking", () => { + const schema = ref(t.u32({ min: { tag: "unsigned", val: 10n } })) + expect(() => schema.packJson(1)).toThrow(/does not conform/) + expect(schema.packJson(10)).toEqual({ + root: 0, + valueNodes: [{ tag: "u32-value", val: 10 }], + }) + expect(schema.validateJson(1).success).toBe(false) + expect(() => + schema.unpackJson({ root: 0, valueNodes: [{ tag: "u32-value", val: 1 }] }), + ).toThrow(/does not conform/) + }) + + it("renders enforceable rich-value allowlists and canonical base64url bytes", () => { + const rendered = ref( + t.record([ + field("text", t.text({ languages: ["en", "de"] })), + field("binary", t.binary({ mimeTypes: ["image/png"] })), + ]), + ).toJsonSchema() + expect(rendered).toMatchObject({ + properties: { + text: { properties: { language: { enum: ["en", "de"] } } }, + binary: { properties: { mimeType: { enum: ["image/png"] } } }, + }, + }) + const properties = ( + rendered as { + properties: { text: Record; binary: Record } + } + ).properties + expect(properties.text).not.toHaveProperty("description") + expect(properties.binary).not.toHaveProperty("description") + const pattern = ( + rendered as { + properties: { binary: { properties: { bytes: { pattern: string } } } } + } + ).properties.binary.properties.bytes.pattern + expect(pattern).toBe( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ) + const regex = new RegExp(pattern, "u") + for (const valid of ["", "AQ", "AQI", "AQID", "-_8"]) expect(regex.test(valid)).toBe(true) + for (const invalid of ["+/8", "AQ==", "-_9", "A"]) expect(regex.test(invalid)).toBe(false) + }) + it("matches canonical rich JSON forms, restrictions, and lossless integer ranges", () => { const schema = ref( t.record([ @@ -94,6 +143,17 @@ describe("SchemaRef", () => { ).toBe(false) expect(schema.validateJson({ ...valid, duration: { nanoseconds: "-0" } }).success).toBe(false) expect(schema.unpackJson(schema.packJson(valid))).toEqual(valid) + expect(() => + ref(t.binary()).unpackJson({ + root: 0, + valueNodes: [ + { + tag: "binary-value", + val: { bytes: new Uint8Array([1]), mimeType: "not a mime" }, + }, + ], + }), + ).toThrow(/invalid MIME type/) }) it("validates nested native capabilities without consuming or rewriting them", () => { @@ -159,7 +219,9 @@ describe("SchemaRef", () => { const eligibility = schema.jsonEligibility() expect(eligibility.success).toBe(false) if (!eligibility.success) expect(eligibility.issues[0]?.path).toEqual(["unsupported"]) - expect(() => schema.toJsonSchema()).toThrow(/canonical JSON/) + expect(schema.toJsonSchema()).toMatchObject({ + properties: { unsupported: { not: {} } }, + }) } const nestedOption = ref(t.option(t.option(t.string()))) @@ -278,6 +340,18 @@ describe("SchemaRef", () => { }) }) + it("makes reflection-only unsupported leaves unsatisfiable", () => { + for (const type of [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + t.future(t.string()), + t.stream(t.string()), + ]) { + expect(ref(type).toJsonSchema()).toMatchObject({ not: {} }) + } + }) + it("renders declared restrictions for canonical wide integers", () => { const signed = ref( t.s64({ min: { tag: "signed", val: -10n }, max: { tag: "signed", val: 20n } }), diff --git a/sdks/moonbit/golem_sdk/README.mbt.md b/sdks/moonbit/golem_sdk/README.mbt.md index 1b7587c1c9..755e6fb6fb 100644 --- a/sdks/moonbit/golem_sdk/README.mbt.md +++ b/sdks/moonbit/golem_sdk/README.mbt.md @@ -231,10 +231,14 @@ canonical `grep`/`git` examples for the complete annotation surface. `@reflection.get_tool_type(name)` returns the tool descriptor visible to the caller. Select a command with `tool.command(path)`; path segments may use command aliases, and the returned -`ToolCommand.path` contains their canonical names. Its `arguments`, `input_schema`, and `result` +`ToolCommand.path()` contains their canonical names. Its `arguments()`, `input_schema()`, and `result()` describe the selected command. Supply every canonical input field in declaration order. An absent optional argument is an option value of `None`, while a defaulted argument carries its default. +Discovery returns opaque immutable snapshots. Array and graph accessors return defensive copies, +and a snapshot changes only after explicit rediscovery. Use `SchemaRef::typed_value` to validate a +schema-native value and create the self-contained graph required by a fully dynamic invocation. + Use `command.pack_json(input)` and `command.invoke_json(input)` for canonical JSON, or `command.invoke_value(input)` for schema-native values. The command validates inputs locally before opening RPC and validates declared results after invocation. `start_value` returns an invocation diff --git a/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt index b22ec0b7fc..8f6e296dfc 100644 --- a/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/config_wbtest.mbt @@ -91,3 +91,17 @@ test "native config accepts large integers without a JSON round trip" { _ => fail("expected JSON unsafe integer to be rejected") } } + +///| +test "agent snapshot accessors return defensive nested copies" { + let agent_type = configured_reflected_type() + let declarations = agent_type.config() + let path = declarations[0].path() + path[0] = "mutated" + declarations[0] = declarations[1] + let graph = agent_type.constructor_input().graph() + graph.root.metadata.aliases.push("mutated") + + assert_eq(agent_type.config()[0].path(), ["greeting"]) + assert_eq(agent_type.constructor_input().root().metadata.aliases, []) +} diff --git a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt index e2b975adda..15a9eb752a 100644 --- a/sdks/moonbit/golem_sdk/reflection/json_schema.mbt +++ b/sdks/moonbit/golem_sdk/reflection/json_schema.mbt @@ -5,7 +5,7 @@ pub fn SchemaRef::to_json_schema( self : SchemaRef, include_draft_marker? : Bool = true, ) -> Json { - let root = schema_json(self.root) + let root = schema_json(self.graph, self.root) let fields : Map[String, Json] = match root { Object(value) => value _ => {} @@ -18,7 +18,7 @@ pub fn SchemaRef::to_json_schema( if !self.graph.defs.is_empty() { let defs : Map[String, Json] = Map([]) for definition in self.graph.defs { - defs[definition.id] = schema_json(definition.body) + defs[definition.id] = schema_json(self.graph, definition.body) } fields["$defs"] = Json::object(defs) } @@ -127,10 +127,15 @@ fn schema_wide_integer( restrictions : @types.NumericRestrictions?, ) -> Json { let fields : Map[String, Json] = { - "type": Json::string("integer"), - "minimum": Json::number(if signed { -9007199254740991.0 } else { 0.0 }), - "maximum": Json::number(9007199254740991.0), - "x-golem-numeric-width": Json::string(if signed { "s64" } else { "u64" }), + "type": Json::string("string"), + "format": Json::string(if signed { "int64" } else { "uint64" }), + "pattern": Json::string( + if signed { + "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$" + } else { + "^(?:0|[1-9][0-9]*)$" + }, + ), "x-golem-minimum": Json::string( if signed { "-9223372036854775808" @@ -149,23 +154,13 @@ fn schema_wide_integer( match restrictions { Some(value) => { match value.min { - Some(bound) => { - fields["minimum"] = Json::number( - (if signed { -9007199254740991.0 } else { 0.0 }).max( - schema_bound_number(bound), - ), - ) + Some(bound) => fields["x-golem-minimum"] = Json::string(schema_bound_text(bound)) - } None => () } match value.max { - Some(bound) => { - fields["maximum"] = Json::number( - 9007199254740991.0.min(schema_bound_number(bound)), - ) + Some(bound) => fields["x-golem-maximum"] = Json::string(schema_bound_text(bound)) - } None => () } } @@ -175,15 +170,43 @@ fn schema_wide_integer( } ///| -fn schema_record(fields : Array[@model.NamedFieldType]) -> Json { +fn schema_resolves_to_option( + graph : @model.SchemaGraph, + schema : @model.SchemaType, + visited : Map[String, Unit], +) -> Bool { + match schema.body { + Option(_) => true + Ref(id) => + if visited.contains(id) { + false + } else { + visited[id] = () + match graph.defs.iter().find_first(definition => definition.id == id) { + Some(definition) => + schema_resolves_to_option(graph, definition.body, visited) + None => false + } + } + _ => false + } +} + +///| +fn schema_record( + graph : @model.SchemaGraph, + fields : Array[@model.NamedFieldType], +) -> Json { let properties : Map[String, Json] = Map([]) let required : Array[Json] = [] for field in fields { properties[field.name] = schema_with_metadata( - schema_json(field.body), + schema_json(graph, field.body), field.metadata, ) - required.push(Json::string(field.name)) + if !schema_resolves_to_option(graph, field.body, Map([])) { + required.push(Json::string(field.name)) + } } Json::object({ "type": Json::string("object"), @@ -288,14 +311,17 @@ fn schema_quantity_bound(value : @types.QuantityValue) -> Json { } ///| -fn schema_variant(cases : Array[@model.VariantCaseType]) -> Json { +fn schema_variant( + graph : @model.SchemaGraph, + cases : Array[@model.VariantCaseType], +) -> Json { let choices : Array[Json] = [] for entry in cases { match entry.payload { None => choices.push(Json::object({ "const": Json::string(entry.name) })) Some(payload) => { let properties : Map[String, Json] = Map([]) - properties[entry.name] = schema_json(payload) + properties[entry.name] = schema_json(graph, payload) choices.push( Json::object({ "type": Json::string("object"), @@ -322,10 +348,14 @@ fn schema_pair(first : Json, second : Json) -> Json { } ///| -fn schema_result_side(name : String, payload : @model.SchemaType?) -> Json { +fn schema_result_side( + graph : @model.SchemaGraph, + name : String, + payload : @model.SchemaType?, +) -> Json { let properties : Map[String, Json] = Map([]) properties[name] = match payload { - Some(value) => schema_json(value) + Some(value) => schema_json(graph, value) None => schema_type("null") } Json::object({ @@ -337,7 +367,7 @@ fn schema_result_side(name : String, payload : @model.SchemaType?) -> Json { } ///| -fn schema_json(schema : @model.SchemaType) -> Json { +fn schema_json(graph : @model.SchemaGraph, schema : @model.SchemaType) -> Json { let rendered = match schema.body { Ref(id) => Json::object({ @@ -363,8 +393,8 @@ fn schema_json(schema : @model.SchemaType) -> Json { "maxLength": schema_number(1), }) String => schema_type("string") - Record(fields) => schema_record(fields) - Variant(cases) => schema_variant(cases) + Record(fields) => schema_record(graph, fields) + Variant(cases) => schema_variant(graph, cases) Enum(cases) => Json::object({ "type": Json::string("string"), @@ -382,7 +412,7 @@ fn schema_json(schema : @model.SchemaType) -> Json { Tuple(elements) => { let items : Array[Json] = [] for element in elements { - items.push(schema_json(element)) + items.push(schema_json(graph, element)) } Json::object({ "type": Json::string("array"), @@ -395,29 +425,29 @@ fn schema_json(schema : @model.SchemaType) -> Json { List(element) => Json::object({ "type": Json::string("array"), - "items": schema_json(element), + "items": schema_json(graph, element), }) FixedList(element, length) => Json::object({ "type": Json::string("array"), - "items": schema_json(element), + "items": schema_json(graph, element), "minItems": Json::number(length.to_double()), "maxItems": Json::number(length.to_double()), }) Map(key, value) => Json::object({ "type": Json::string("array"), - "items": schema_pair(schema_json(key), schema_json(value)), + "items": schema_pair(schema_json(graph, key), schema_json(graph, value)), }) Option(element) => Json::object({ - "oneOf": schema_array([schema_type("null"), schema_json(element)]), + "oneOf": schema_array([schema_type("null"), schema_json(graph, element)]), }) Result(ok, err) => Json::object({ "oneOf": schema_array([ - schema_result_side("ok", ok), - schema_result_side("err", err), + schema_result_side(graph, "ok", ok), + schema_result_side(graph, "err", err), ]), }) Text(restrictions) => { @@ -453,6 +483,9 @@ fn schema_json(schema : @model.SchemaType) -> Json { let bytes : Map[String, Json] = { "type": Json::string("string"), "contentEncoding": Json::string("base64url"), + "pattern": Json::string( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ), } match restrictions.min_bytes { Some(value) => @@ -468,7 +501,12 @@ fn schema_json(schema : @model.SchemaType) -> Json { ) None => () } - let mime : Map[String, Json] = { "type": Json::string("string") } + let mime : Map[String, Json] = { + "type": Json::string("string"), + "pattern": Json::string( + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + ), + } match restrictions.mime_types { Some(values) => mime["enum"] = schema_strings(values) None => () @@ -543,14 +581,19 @@ fn schema_json(schema : @model.SchemaType) -> Json { }) Duration => Json::object({ - "type": Json::string("string"), - "format": Json::string("duration"), + "type": Json::string("object"), + "properties": Json::object({ + "nanoseconds": schema_wide_integer(true, None), + }), + "required": schema_strings(["nanoseconds"]), + "additionalProperties": Json::boolean(false), + "title": Json::string("Duration in nanoseconds"), }) Quantity(spec) => { let fields : Map[String, Json] = { "type": Json::string("object"), "properties": Json::object({ - "mantissa": schema_type("integer"), + "mantissa": schema_wide_integer(true, None), "scale": schema_type("integer"), "unit": schema_type("string"), }), @@ -581,7 +624,10 @@ fn schema_json(schema : @model.SchemaType) -> Json { choices.push( Json::object({ "allOf": schema_array([ - schema_with_metadata(schema_json(branch.body), branch.metadata), + schema_with_metadata( + schema_json(graph, branch.body), + branch.metadata, + ), schema_discriminator(branch.discriminator), ]), }), @@ -589,13 +635,8 @@ fn schema_json(schema : @model.SchemaType) -> Json { } Json::object({ "oneOf": Json::array(choices) }) } - Secret(_) => Json::object({ "x-golem-capability": Json::string("secret") }) - QuotaToken(_) => - Json::object({ "x-golem-capability": Json::string("quota-token") }) - PermissionCard(_) => - Json::object({ "x-golem-capability": Json::string("permission-card") }) - Future(_) => Json::object({ "x-golem-unsupported": Json::string("future") }) - Stream(_) => Json::object({ "x-golem-unsupported": Json::string("stream") }) + Secret(_) | QuotaToken(_) | PermissionCard(_) | Future(_) | Stream(_) => + Json::object({ "not": Json::object({}) }) } schema_with_metadata(rendered, schema.metadata) } diff --git a/sdks/moonbit/golem_sdk/reflection/moon.pkg b/sdks/moonbit/golem_sdk/reflection/moon.pkg index 3fd31f3640..1e89bf5fb0 100644 --- a/sdks/moonbit/golem_sdk/reflection/moon.pkg +++ b/sdks/moonbit/golem_sdk/reflection/moon.pkg @@ -16,6 +16,7 @@ import { "moonbitlang/core/string", "moonbitlang/core/math", "moonbitlang/core/encoding/base64", + "moonbitlang/core/json", } supported_targets = "+wasm" diff --git a/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti b/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti index c9c3c4f8c3..859ebd4698 100644 --- a/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk/reflection/pkg.generated.mbti @@ -34,13 +34,18 @@ pub(all) suberror ReflectError { } derive(Eq, @debug.Debug) // Types and methods -pub(all) struct AgentMethod { +pub struct AgentMethod { name : String description : String prompt_hint : String? input : SchemaRef output : SchemaRef? } derive(Eq, @debug.Debug) +pub fn AgentMethod::description(Self) -> String +pub fn AgentMethod::input(Self) -> SchemaRef +pub fn AgentMethod::name(Self) -> String +pub fn AgentMethod::output(Self) -> SchemaRef? +pub fn AgentMethod::prompt_hint(Self) -> String? pub(all) enum AgentMode { Durable @@ -59,7 +64,7 @@ pub(all) enum AgentRpcError { Custom(@schema_model.TypedSchemaValue) } derive(Eq, @debug.Debug) -pub(all) struct AgentType { +pub struct AgentType { name : String description : String source_language : String @@ -71,6 +76,9 @@ pub(all) struct AgentType { } derive(Eq, @debug.Debug) pub fn AgentType::agent_id_json(Self, Json, phantom_id? : @types.Uuid?) -> ParsedAgentId raise ReflectError pub fn AgentType::agent_id_value(Self, @schema_model.SchemaValue, phantom_id? : @types.Uuid?) -> ParsedAgentId raise ReflectError +pub fn AgentType::config(Self) -> Array[ReflectedConfigDeclaration] +pub fn AgentType::constructor_input(Self) -> SchemaRef +pub fn AgentType::description(Self) -> String pub fn AgentType::ephemeral_json(Self, Json) -> ReflectedAgentClient raise ReflectError pub fn AgentType::ephemeral_json_with_config(Self, Json, Array[ReflectedConfigJsonValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::ephemeral_phantom_json(Self, Json, @types.Uuid) -> ReflectedAgentClient raise ReflectError @@ -86,6 +94,10 @@ pub fn AgentType::get_phantom_json_with_config(Self, Json, @types.Uuid, Array[Re pub fn AgentType::get_phantom_value_with_config(Self, @schema_model.SchemaValue, @types.Uuid, Array[ReflectedConfigValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::get_value(Self, @schema_model.SchemaValue) -> ReflectedAgentClient raise ReflectError pub fn AgentType::get_value_with_config(Self, @schema_model.SchemaValue, Array[ReflectedConfigValue]) -> ReflectedAgentClient raise ReflectError +pub fn AgentType::implemented_by(Self) -> @types.ComponentId +pub fn AgentType::methods(Self) -> Array[AgentMethod] +pub fn AgentType::mode(Self) -> AgentMode +pub fn AgentType::name(Self) -> String pub fn AgentType::new_phantom_json(Self, Json) -> ReflectedAgentClient raise ReflectError pub fn AgentType::new_phantom_json_with_config(Self, Json, Array[ReflectedConfigJsonValue]) -> ReflectedAgentClient raise ReflectError pub fn AgentType::new_phantom_value(Self, @schema_model.SchemaValue) -> ReflectedAgentClient raise ReflectError @@ -93,6 +105,7 @@ pub fn AgentType::new_phantom_value_with_config(Self, @schema_model.SchemaValue, pub fn AgentType::pack_config_json(Self, Array[ReflectedConfigJsonValue]) -> Array[@golemcloud/golem_sdk/interface/golem/agent/common.TypedAgentConfigValue] raise ReflectError pub fn AgentType::resume_phantom_json(Self, Json, @types.Uuid) -> ReflectedAgentClient raise ReflectError pub fn AgentType::resume_phantom_value(Self, @schema_model.SchemaValue, @types.Uuid) -> ReflectedAgentClient raise ReflectError +pub fn AgentType::source_language(Self) -> String pub fn AgentType::validate_config_values(Self, Array[ReflectedConfigValue]) -> Array[@golemcloud/golem_sdk/interface/golem/agent/common.TypedAgentConfigValue] raise ReflectError pub(all) struct CreatedPhantomClient { @@ -192,11 +205,14 @@ pub fn ReflectedAgentClient::schedule_value(Self, @system-clock.Instant, String, pub fn ReflectedAgentClient::trigger_json(Self, String, Json) -> InvocationMetadata raise ReflectError pub fn ReflectedAgentClient::trigger_value(Self, String, @schema_model.SchemaValue) -> InvocationMetadata raise ReflectError -pub(all) struct ReflectedConfigDeclaration { +pub struct ReflectedConfigDeclaration { path : Array[String] source : @golemcloud/golem_sdk/interface/golem/agent/common.AgentConfigSource schema : SchemaRef } derive(Eq, @debug.Debug) +pub fn ReflectedConfigDeclaration::path(Self) -> Array[String] +pub fn ReflectedConfigDeclaration::schema(Self) -> SchemaRef +pub fn ReflectedConfigDeclaration::source(Self) -> @golemcloud/golem_sdk/interface/golem/agent/common.AgentConfigSource pub(all) struct ReflectedConfigJsonValue { path : Array[String] @@ -208,10 +224,11 @@ pub(all) struct ReflectedConfigValue { value : @schema_model.SchemaValue } derive(Eq, @debug.Debug) -pub(all) struct ReflectedToolClient { +pub struct ReflectedToolClient { tool : ToolType } derive(Eq, @debug.Debug) pub fn ReflectedToolClient::command(Self, Array[String]) -> ToolCommand raise ReflectError +pub fn ReflectedToolClient::tool(Self) -> ToolType pub(all) struct ReflectedToolCustomError { name : String @@ -234,18 +251,20 @@ pub(all) struct ScheduledInvocation { pub fn ScheduledInvocation::cancel(Self) -> Unit pub fn ScheduledInvocation::drop(Self) -> Unit -pub(all) struct SchemaRef { +pub struct SchemaRef { graph : @schema_model.SchemaGraph root : @schema_model.SchemaType } derive(Eq, @debug.Debug) pub fn SchemaRef::contains_stream(Self) -> Bool -pub fn SchemaRef::new(@schema_model.SchemaGraph) -> Self +pub fn SchemaRef::graph(Self) -> @schema_model.SchemaGraph pub fn SchemaRef::pack_json(Self, Json) -> @schema_model.SchemaValue raise ReflectError +pub fn SchemaRef::root(Self) -> @schema_model.SchemaType pub fn SchemaRef::to_json_schema(Self, include_draft_marker? : Bool) -> Json +pub fn SchemaRef::typed_value(Self, @schema_model.SchemaValue) -> @schema_model.TypedSchemaValue raise ReflectError pub fn SchemaRef::unpack_json(Self, @schema_model.SchemaValue) -> Json raise ReflectError pub fn SchemaRef::validate_value(Self, @schema_model.SchemaValue) -> Unit raise ReflectError -pub(all) struct ToolArgument { +pub struct ToolArgument { kind : String name : String aliases : Array[String] @@ -255,6 +274,13 @@ pub(all) struct ToolArgument { schema : SchemaRef optional_carrier : Bool } derive(Eq, @debug.Debug) +pub fn ToolArgument::aliases(Self) -> Array[String] +pub fn ToolArgument::default(Self) -> @schema_model.SchemaValue? raise ReflectError +pub fn ToolArgument::kind(Self) -> String +pub fn ToolArgument::name(Self) -> String +pub fn ToolArgument::required(Self) -> Bool +pub fn ToolArgument::schema(Self) -> SchemaRef +pub fn ToolArgument::short(Self) -> Char? pub(all) struct ToolClientDefinition[Client] { name : String? @@ -265,7 +291,7 @@ pub fn[Client] ToolClientDefinition::client_for(Self[Client], String) -> Client pub fn[Client] ToolClientDefinition::named(String, (String) -> Client) -> Self[Client] pub fn[Client] ToolClientDefinition::unnamed((String) -> Client) -> Self[Client] -pub(all) struct ToolCommand { +pub struct ToolCommand { tool : ToolType path : Array[String] body : @golemcloud/golem_sdk/interface/golem/tool/common.CommandBody? @@ -275,14 +301,21 @@ pub(all) struct ToolCommand { result : SchemaRef? // private fields } derive(Eq, @debug.Debug) +pub fn ToolCommand::arguments(Self) -> Array[ToolArgument] +pub fn ToolCommand::input_schema(Self) -> SchemaRef pub async fn ToolCommand::invoke_json(Self, Json, stdin? : @async-core.Stream[Byte]?) -> Result[Json?, @tool.ToolError[ReflectedToolCustomError]] pub async fn ToolCommand::invoke_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[@schema_model.SchemaValue?, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::is_callable(Self) -> Bool pub fn ToolCommand::pack_json(Self, Json) -> Result[@schema_model.SchemaValue, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::path(Self) -> Array[String] +pub fn ToolCommand::result(Self) -> SchemaRef? pub fn ToolCommand::start_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[ReflectedToolInvocation, @tool.ToolError[ReflectedToolCustomError]] +pub fn ToolCommand::subcommands(Self) -> Array[String] +pub fn ToolCommand::tool(Self) -> ToolType pub fn ToolCommand::trigger_json(Self, Json, stdin? : @async-core.Stream[Byte]?) -> Result[Unit, @tool.ToolError[ReflectedToolCustomError]] pub fn ToolCommand::trigger_value(Self, @schema_model.SchemaValue, stdin? : @async-core.Stream[Byte]?) -> Result[Unit, @tool.ToolError[ReflectedToolCustomError]] -pub(all) struct ToolType { +pub struct ToolType { lookup_name : String definition : @golemcloud/golem_sdk/interface/golem/tool/common.Tool implemented_by : @types.ComponentId @@ -291,6 +324,9 @@ pub(all) struct ToolType { pub fn ToolType::client(Self) -> ReflectedToolClient pub fn ToolType::command(Self, Array[String]) -> ToolCommand raise ReflectError pub fn ToolType::from_registered(@golemcloud/golem_sdk/interface/golem/tool/host.RegisteredTool) -> Self raise ReflectError +pub fn ToolType::implemented_by(Self) -> @types.ComponentId +pub fn ToolType::lookup_name(Self) -> String +pub fn ToolType::version(Self) -> String // Type aliases diff --git a/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt b/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt index 3458c00cb1..e6eb36ef05 100644 --- a/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt +++ b/sdks/moonbit/golem_sdk/reflection/reflected_client.mbt @@ -1,6 +1,6 @@ ///| /// A method discovered from the runtime-owned agent schema. -pub(all) struct AgentMethod { +pub struct AgentMethod { name : String description : String prompt_hint : String? @@ -9,7 +9,7 @@ pub(all) struct AgentMethod { } derive(Debug, Eq) ///| -pub(all) struct ReflectedConfigDeclaration { +pub struct ReflectedConfigDeclaration { path : Array[String] source : @common.AgentConfigSource schema : SchemaRef @@ -29,7 +29,7 @@ pub(all) struct ReflectedConfigValue { ///| /// An agent type discovered from the runtime registry. -pub(all) struct AgentType { +pub struct AgentType { name : String description : String source_language : String @@ -40,6 +40,92 @@ pub(all) struct AgentType { config : Array[ReflectedConfigDeclaration] } derive(Debug, Eq) +///| +pub fn AgentMethod::name(self : AgentMethod) -> String { + self.name +} + +///| +pub fn AgentMethod::description(self : AgentMethod) -> String { + self.description +} + +///| +pub fn AgentMethod::prompt_hint(self : AgentMethod) -> String? { + self.prompt_hint +} + +///| +pub fn AgentMethod::input(self : AgentMethod) -> SchemaRef { + self.input +} + +///| +pub fn AgentMethod::output(self : AgentMethod) -> SchemaRef? { + self.output +} + +///| +pub fn ReflectedConfigDeclaration::path( + self : ReflectedConfigDeclaration, +) -> Array[String] { + self.path.copy() +} + +///| +pub fn ReflectedConfigDeclaration::source( + self : ReflectedConfigDeclaration, +) -> @common.AgentConfigSource { + self.source +} + +///| +pub fn ReflectedConfigDeclaration::schema( + self : ReflectedConfigDeclaration, +) -> SchemaRef { + self.schema +} + +///| +pub fn AgentType::name(self : AgentType) -> String { + self.name +} + +///| +pub fn AgentType::description(self : AgentType) -> String { + self.description +} + +///| +pub fn AgentType::source_language(self : AgentType) -> String { + self.source_language +} + +///| +pub fn AgentType::mode(self : AgentType) -> AgentMode { + self.mode +} + +///| +pub fn AgentType::implemented_by(self : AgentType) -> @types.ComponentId { + self.implemented_by +} + +///| +pub fn AgentType::constructor_input(self : AgentType) -> SchemaRef { + self.constructor_input +} + +///| +pub fn AgentType::methods(self : AgentType) -> Array[AgentMethod] { + self.methods.copy() +} + +///| +pub fn AgentType::config(self : AgentType) -> Array[ReflectedConfigDeclaration] { + self.config.copy() +} + ///| /// A reflected client and any reusable identity created before invocation. /// Ephemeral clients have no reusable identity until invocation metadata is diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt new file mode 100644 index 0000000000..b363230524 --- /dev/null +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_corpus_wbtest.mbt @@ -0,0 +1,12 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. + +///| +let reflection_conformance_corpus_json : String = "{\n \"version\": \"1.0.0\",\n \"schemaKinds\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ],\n \"restrictionKinds\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ],\n \"caseIds\": [\n \"canonical/s64-min\",\n \"canonical/s64-max\",\n \"canonical/u64-max\",\n \"canonical/duration-min\",\n \"canonical/duration-max\",\n \"canonical/quantity-wide-mantissa\",\n \"canonical/omitted-options\",\n \"canonical/binary-mime\",\n \"canonical/constrained-text-without-language\",\n \"canonical/constrained-binary-without-mime\",\n \"errors/s64-plus\",\n \"errors/s64-leading-zero\",\n \"errors/s64-negative-zero\",\n \"errors/s64-overflow\",\n \"errors/u64-negative\",\n \"errors/u64-overflow\",\n \"errors/wide-wrong-json-type\",\n \"errors/duration-legacy-string\",\n \"errors/quantity-negative-zero\",\n \"errors/quantity-extra-field\",\n \"errors/binary-noncanonical-base64\",\n \"errors/binary-invalid-mime\",\n \"errors/text-extra-field\",\n \"json-schema/s64\",\n \"json-schema/s64-restrictions\",\n \"json-schema/u32-restrictions\",\n \"json-schema/f64-restrictions\",\n \"json-schema/text-restrictions\",\n \"json-schema/binary-restrictions\",\n \"json-schema/u64\",\n \"json-schema/duration-nanoseconds\",\n \"json-schema/quantity-mantissa\",\n \"json-schema/optional-required\",\n \"json-schema/unsupported-leaves\",\n \"schema/all-kinds\",\n \"schema/all-restrictions\",\n \"graph/references-validation-equivalence\",\n \"tool/canonical-input\",\n \"config/canonical-entry\",\n \"constraints/rejected-value\",\n \"compatibility/result\",\n \"compatibility/custom-error\"\n ],\n \"cases\": [\n {\n \"id\": \"canonical/s64-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"-9223372036854775808\",\n \"expected\": \"-9223372036854775808\"\n },\n {\n \"id\": \"canonical/s64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775807\",\n \"expected\": \"9223372036854775807\"\n },\n {\n \"id\": \"canonical/u64-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551615\",\n \"expected\": \"18446744073709551615\"\n },\n {\n \"id\": \"canonical/duration-min\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"-9223372036854775808\"\n },\n \"expected\": {\n \"nanoseconds\": \"-9223372036854775808\"\n }\n },\n {\n \"id\": \"canonical/duration-max\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"duration\",\n \"input\": {\n \"nanoseconds\": \"9223372036854775807\"\n },\n \"expected\": {\n \"nanoseconds\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"canonical/quantity-wide-mantissa\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"mantissa\": \"-9223372036854775808\",\n \"scale\": -9,\n \"unit\": \"m\"\n }\n },\n {\n \"id\": \"canonical/omitted-options\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"optional-record\",\n \"input\": {},\n \"expected\": {\n \"direct\": null,\n \"referenced\": null\n }\n },\n {\n \"id\": \"canonical/binary-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n },\n \"expected\": {\n \"bytes\": \"-_8\",\n \"mimeType\": \"application/octet-stream\"\n }\n },\n {\n \"id\": \"canonical/constrained-text-without-language\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"constrained-text\",\n \"input\": {\n \"text\": \"hello\"\n },\n \"expected\": {\n \"text\": \"hello\"\n }\n },\n {\n \"id\": \"canonical/constrained-binary-without-mime\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"constrained-binary\",\n \"input\": {\n \"bytes\": \"AQI\"\n },\n \"expected\": {\n \"bytes\": \"AQI\"\n }\n },\n {\n \"id\": \"errors/s64-plus\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"+1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-leading-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"01\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"-0\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/s64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": \"9223372036854775808\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-negative\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"-1\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/u64-overflow\",\n \"operation\": \"reject\",\n \"fixture\": \"u64\",\n \"input\": \"18446744073709551616\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/wide-wrong-json-type\",\n \"operation\": \"reject\",\n \"fixture\": \"s64\",\n \"input\": 1,\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/duration-legacy-string\",\n \"operation\": \"reject\",\n \"fixture\": \"duration\",\n \"input\": \"PT1S\",\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-negative-zero\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"-0\",\n \"scale\": 0,\n \"unit\": \"m\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/quantity-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"quantity\",\n \"input\": {\n \"mantissa\": \"1\",\n \"scale\": 0,\n \"unit\": \"m\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-noncanonical-base64\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"inputs\": [\n {\n \"bytes\": \"+/8\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8=\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_9\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"A\",\n \"mimeType\": \"application/octet-stream\"\n },\n {\n \"bytes\": \"-_8\",\n \"mime_type\": \"application/octet-stream\"\n }\n ],\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/binary-invalid-mime\",\n \"operation\": \"reject\",\n \"fixture\": \"binary\",\n \"input\": {\n \"bytes\": \"AQ\",\n \"mimeType\": \"not a mime\"\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"errors/text-extra-field\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-text\",\n \"input\": {\n \"text\": \"hello\",\n \"extra\": true\n },\n \"expected\": {\n \"kind\": \"invalid-json\"\n }\n },\n {\n \"id\": \"json-schema/s64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/s64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-s64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9007199254740993\",\n \"x-golem-maximum\": \"9007199254740993\"\n }\n },\n {\n \"id\": \"json-schema/u64\",\n \"operation\": \"json-schema\",\n \"fixture\": \"u64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"uint64\",\n \"pattern\": \"^(?:0|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"0\",\n \"x-golem-maximum\": \"18446744073709551615\"\n }\n },\n {\n \"id\": \"json-schema/u32-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-u32\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"integer\",\n \"minimum\": 2,\n \"maximum\": 10\n }\n },\n {\n \"id\": \"json-schema/f64-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-f64\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"number\",\n \"minimum\": -1.5,\n \"maximum\": 2.5\n }\n },\n {\n \"id\": \"json-schema/text-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-text\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"text\": {\n \"type\": \"string\",\n \"minLength\": 2,\n \"maxLength\": 8,\n \"pattern\": \"^[a-z]+$\"\n },\n \"language\": {\n \"type\": \"string\",\n \"enum\": [\n \"en\",\n \"de\"\n ]\n }\n },\n \"required\": [\n \"text\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/binary-restrictions\",\n \"operation\": \"json-schema\",\n \"fixture\": \"constrained-binary\",\n \"path\": \"\",\n \"expected\": {\n \"type\": \"object\",\n \"properties\": {\n \"bytes\": {\n \"type\": \"string\",\n \"contentEncoding\": \"base64url\",\n \"pattern\": \"^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$\",\n \"minLength\": 3,\n \"maxLength\": 6\n },\n \"mimeType\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z0-9!#$&^_.+\\\\-]+\\\\/[A-Za-z0-9!#$&^_.+\\\\-]+$\",\n \"enum\": [\n \"image/png\",\n \"application/octet-stream\"\n ]\n }\n },\n \"required\": [\n \"bytes\"\n ],\n \"additionalProperties\": false\n }\n },\n {\n \"id\": \"json-schema/duration-nanoseconds\",\n \"operation\": \"json-schema\",\n \"fixture\": \"duration\",\n \"path\": \"/properties/nanoseconds\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/quantity-mantissa\",\n \"operation\": \"json-schema\",\n \"fixture\": \"quantity\",\n \"path\": \"/properties/mantissa\",\n \"expected\": {\n \"type\": \"string\",\n \"format\": \"int64\",\n \"pattern\": \"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$\",\n \"x-golem-minimum\": \"-9223372036854775808\",\n \"x-golem-maximum\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"json-schema/optional-required\",\n \"operation\": \"json-schema\",\n \"fixture\": \"optional-record\",\n \"path\": \"/required\",\n \"expected\": []\n },\n {\n \"id\": \"json-schema/unsupported-leaves\",\n \"operation\": \"semantic\",\n \"fixture\": \"unsupported-leaves\",\n \"expected\": {\n \"count\": 5,\n \"schema\": {\n \"not\": {}\n }\n }\n },\n {\n \"id\": \"schema/all-kinds\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-kinds\",\n \"expected\": {\n \"names\": [\n \"ref\",\n \"bool\",\n \"s8\",\n \"s16\",\n \"s32\",\n \"s64\",\n \"u8\",\n \"u16\",\n \"u32\",\n \"u64\",\n \"f32\",\n \"f64\",\n \"char\",\n \"string\",\n \"record\",\n \"variant\",\n \"enum\",\n \"flags\",\n \"tuple\",\n \"list\",\n \"fixed-list\",\n \"map\",\n \"option\",\n \"result\",\n \"text\",\n \"binary\",\n \"path\",\n \"url\",\n \"datetime\",\n \"duration\",\n \"quantity\",\n \"union\",\n \"secret\",\n \"quota-token\",\n \"permission-card\",\n \"future\",\n \"stream\"\n ]\n }\n },\n {\n \"id\": \"schema/all-restrictions\",\n \"operation\": \"semantic\",\n \"fixture\": \"all-restrictions\",\n \"expected\": {\n \"names\": [\n \"numeric-minimum\",\n \"numeric-maximum\",\n \"numeric-unit\",\n \"text-languages\",\n \"text-min-length\",\n \"text-max-length\",\n \"text-regex\",\n \"binary-mime-types\",\n \"binary-min-bytes\",\n \"binary-max-bytes\",\n \"path-direction\",\n \"path-kind\",\n \"path-mime-types\",\n \"path-extensions\",\n \"url-schemes\",\n \"url-hosts\",\n \"quantity-base-unit\",\n \"quantity-suffixes\",\n \"quantity-minimum\",\n \"quantity-maximum\",\n \"union-prefix\",\n \"union-suffix\",\n \"union-regex\",\n \"union-field\"\n ]\n }\n },\n {\n \"id\": \"graph/references-validation-equivalence\",\n \"operation\": \"semantic\",\n \"fixture\": \"graph\",\n \"expected\": {\n \"valid\": true,\n \"equivalent\": true\n }\n },\n {\n \"id\": \"tool/canonical-input\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"tool-input\",\n \"input\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ]\n },\n \"expected\": {\n \"pattern\": \"*.rs\",\n \"paths\": [\n \"src\",\n \"tests\"\n ],\n \"ignoreCase\": null\n }\n },\n {\n \"id\": \"config/canonical-entry\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"config-entry\",\n \"input\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n },\n \"expected\": {\n \"path\": [\n \"limits\",\n \"maximum\"\n ],\n \"value\": \"9223372036854775807\"\n }\n },\n {\n \"id\": \"constraints/rejected-value\",\n \"operation\": \"reject\",\n \"fixture\": \"constrained-u32\",\n \"input\": 11,\n \"expected\": {\n \"kind\": \"constraint-violation\"\n }\n },\n {\n \"id\": \"compatibility/result\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"result\",\n \"input\": {\n \"ok\": \"ready\"\n },\n \"expected\": {\n \"ok\": \"ready\"\n }\n },\n {\n \"id\": \"compatibility/custom-error\",\n \"operation\": \"roundtrip\",\n \"fixture\": \"custom-error\",\n \"input\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n },\n \"expected\": {\n \"err\": {\n \"code\": \"denied\",\n \"retryable\": false\n }\n }\n }\n ]\n}\n" diff --git a/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt new file mode 100644 index 0000000000..714bd1e8cd --- /dev/null +++ b/sdks/moonbit/golem_sdk/reflection/reflection_conformance_wbtest.mbt @@ -0,0 +1,597 @@ +///| +fn corpus_object(value : Json) -> Map[String, Json] raise { + match value { + Object(fields) => fields + _ => fail("expected conformance JSON object") + } +} + +///| +fn corpus_array(value : Json) -> Array[Json] raise { + match value { + Array(values) => values + _ => fail("expected conformance JSON array") + } +} + +///| +fn corpus_string(value : Json) -> String raise { + match value { + String(value) => value + _ => fail("expected conformance JSON string") + } +} + +///| +fn conformance_object( + value : Json, + id : String, + context : String, +) -> Map[String, Json] raise { + match value { + Object(fields) => fields + _ => fail(id + ": expected " + context + " object") + } +} + +///| +fn conformance_array( + value : Json, + id : String, + context : String, +) -> Array[Json] raise { + match value { + Array(values) => values + _ => fail(id + ": expected " + context + " array") + } +} + +///| +fn conformance_string( + value : Json, + id : String, + context : String, +) -> String raise { + match value { + String(value) => value + _ => fail(id + ": expected " + context + " string") + } +} + +///| +fn conformance_count(value : Json, id : String, context : String) -> Int raise { + match value { + Number(value, ..) => value.to_int() + _ => fail(id + ": expected " + context + " number") + } +} + +///| +fn conformance_bool(value : Json, id : String, context : String) -> Bool raise { + match value { + True => true + False => false + _ => fail(id + ": expected " + context + " boolean") + } +} + +///| +fn conformance_field( + fields : Map[String, Json], + name : String, + id : String, +) -> Json raise { + match fields.get(name) { + Some(value) => value + None => fail(id + ": missing conformance JSON field " + name) + } +} + +///| +fn corpus_field( + name : String, + body : @model.SchemaType, +) -> @model.NamedFieldType { + { name, body, metadata: @model.empty_metadata(), } +} + +///| +fn conformance_fixture(name : String, id : String) -> SchemaRef raise { + let root = match name { + "s64" => @model.schema_type(S64(None)) + "constrained-s64" => + @model.schema_type( + S64( + Some({ + min: Some(@types.Signed(-9007199254740993L)), + max: Some(@types.Signed(9007199254740993L)), + unit: None, + }), + ), + ) + "u64" => @model.schema_type(U64(None)) + "binary" => + @model.schema_type( + Binary({ mime_types: None, min_bytes: None, max_bytes: None, }), + ) + "duration" => @model.schema_type(Duration) + "quantity" => + @model.schema_type( + Quantity({ base_unit: "m", allowed_suffixes: [], min: None, max: None, }), + ) + "tool-input" => + @model.schema_type( + Record([ + corpus_field("pattern", @model.schema_type(String)), + corpus_field( + "paths", + @model.schema_type(List(@model.schema_type(String))), + ), + corpus_field( + "ignoreCase", + @model.schema_type(Option(@model.schema_type(Bool))), + ), + ]), + ) + "config-entry" => + @model.schema_type( + Record([ + corpus_field( + "path", + @model.schema_type(List(@model.schema_type(String))), + ), + corpus_field("value", @model.schema_type(S64(None))), + ]), + ) + "constrained-u32" => + @model.schema_type( + U32( + Some({ + min: Some(@types.Unsigned(2UL)), + max: Some(@types.Unsigned(10UL)), + unit: None, + }), + ), + ) + "constrained-f64" => + @model.schema_type( + F64( + Some({ + min: Some(@types.FloatBits((-1.5).reinterpret_as_uint64())), + max: Some(@types.FloatBits(2.5.reinterpret_as_uint64())), + unit: None, + }), + ), + ) + "constrained-text" => + @model.schema_type( + Text({ + languages: Some(["en", "de"]), + min_length: Some(2U), + max_length: Some(8U), + regex: Some("^[a-z]+$"), + }), + ) + "constrained-binary" => + @model.schema_type( + Binary({ + mime_types: Some(["image/png", "application/octet-stream"]), + min_bytes: Some(2U), + max_bytes: Some(4U), + }), + ) + "result" => + @model.schema_type( + Result( + Some(@model.schema_type(String)), + Some(@model.schema_type(U32(None))), + ), + ) + "custom-error" => + @model.schema_type( + Result( + Some(@model.schema_type(String)), + Some( + @model.schema_type( + Record([ + corpus_field("code", @model.schema_type(String)), + corpus_field("retryable", @model.schema_type(Bool)), + ]), + ), + ), + ), + ) + "optional-record" => { + let optional = @model.schema_type(Option(@model.schema_type(String))) + return SchemaRef::new({ + defs: [{ id: "conformance.optional", name: None, body: optional, }], + root: @model.schema_type( + Record([ + corpus_field("direct", optional), + corpus_field( + "referenced", + @model.schema_type(Ref("conformance.optional")), + ), + ]), + ), + }) + } + other => fail(id + ": unknown conformance fixture " + other) + } + SchemaRef::new({ defs: [], root, }) +} + +///| +fn conformance_at_pointer( + value : Json, + pointer : String, + id : String, +) -> Json raise { + match pointer { + "" => value + "/required" => + conformance_field( + conformance_object(value, id, "rendered schema"), + "required", + id, + ) + "/properties/nanoseconds" => + conformance_field( + conformance_object( + conformance_field( + conformance_object(value, id, "rendered schema"), + "properties", + id, + ), + id, + "rendered schema properties", + ), + "nanoseconds", + id, + ) + "/properties/mantissa" => + conformance_field( + conformance_object( + conformance_field( + conformance_object(value, id, "rendered schema"), + "properties", + id, + ), + id, + "rendered schema properties", + ), + "mantissa", + id, + ) + other => fail(id + ": unsupported conformance JSON pointer " + other) + } +} + +///| +fn assert_conformance_subset( + actual : Json, + expected : Json, + id : String, +) -> Unit raise { + match expected { + Object(expected_fields) => { + let actual_fields = conformance_object(actual, id, "rendered schema") + for entry in expected_fields { + let (name, expected_value) = entry + let actual_value = match actual_fields.get(name) { + Some(value) => value + None => fail(id + ": missing conformance JSON field " + name) + } + assert_conformance_subset(actual_value, expected_value, id) + } + } + _ => { + guard actual == expected else { fail(id + ": JSON values differ") } + } + } +} + +///| +fn assert_conformance_semantic( + name : String, + expected : Json, + corpus : Json, + id : String, +) -> Unit raise { + match name { + "unsupported-leaves" => { + let types = [ + @model.SchemaTypeBody::Secret({ + inner: @model.schema_type(String), + category: None, + }), + @model.SchemaTypeBody::QuotaToken({ resource_name: None, }), + @model.SchemaTypeBody::PermissionCard({ polymorphic: false, }), + @model.SchemaTypeBody::Future(None), + @model.SchemaTypeBody::Stream(None), + ] + let expected_fields = conformance_object( + expected, id, "expected semantic result", + ) + guard types.length() == + conformance_count( + conformance_field(expected_fields, "count", id), + id, + "unsupported-leaf count", + ) else { + fail(id + ": unsupported-leaf count differs") + } + for body in types { + assert_conformance_subset( + test_schema(body).to_json_schema(include_draft_marker=false), + conformance_field(expected_fields, "schema", id), + id, + ) + } + } + "all-kinds" => { + let corpus_fields = conformance_object(corpus, id, "conformance corpus") + let supported = [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", "f32", + "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", "list", + "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", "permission-card", + "future", "stream", + ] + let expected_names = conformance_array( + conformance_field( + conformance_object(expected, id, "expected semantic result"), + "names", + id, + ), + id, + "expected schema names", + ).map(value => conformance_string(value, id, "schema name")) + guard supported == expected_names else { + fail(id + ": supported schema kinds differ") + } + guard conformance_array( + conformance_field(corpus_fields, "schemaKinds", id), + id, + "declared schema kinds", + ).map(value => conformance_string(value, id, "schema kind")) == + expected_names else { + fail(id + ": declared schema kinds differ") + } + } + "all-restrictions" => { + let corpus_fields = conformance_object(corpus, id, "conformance corpus") + let supported = [ + "numeric-minimum", "numeric-maximum", "numeric-unit", "text-languages", "text-min-length", + "text-max-length", "text-regex", "binary-mime-types", "binary-min-bytes", + "binary-max-bytes", "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", "quantity-minimum", + "quantity-maximum", "union-prefix", "union-suffix", "union-regex", "union-field", + ] + let expected_names = conformance_array( + conformance_field( + conformance_object(expected, id, "expected semantic result"), + "names", + id, + ), + id, + "expected restriction names", + ).map(value => conformance_string(value, id, "restriction name")) + guard supported == expected_names else { + fail(id + ": supported restriction kinds differ") + } + guard conformance_array( + conformance_field(corpus_fields, "restrictionKinds", id), + id, + "declared restriction kinds", + ).map(value => conformance_string(value, id, "restriction kind")) == + expected_names else { + fail(id + ": declared restriction kinds differ") + } + } + "graph" => { + let expected_fields = conformance_object( + expected, id, "expected semantic result", + ) + let referenced = conformance_fixture("optional-record", id) + let inline = test_schema( + Record([ + corpus_field( + "direct", + @model.schema_type(Option(@model.schema_type(String))), + ), + corpus_field( + "referenced", + @model.schema_type(Option(@model.schema_type(String))), + ), + ]), + ) + let valid = @model.validate_graph(referenced.graph()).length() == 0 + let equivalent = referenced.pack_json(Json::object({})) == + inline.pack_json(Json::object({})) + guard valid == + conformance_bool( + conformance_field(expected_fields, "valid", id), + id, + "graph validity", + ) else { + fail(id + ": graph validity differs") + } + guard equivalent == + conformance_bool( + conformance_field(expected_fields, "equivalent", id), + id, + "graph equivalence", + ) else { + fail(id + ": graph equivalence differs") + } + } + other => fail(id + ": unknown semantic conformance fixture " + other) + } +} + +///| +fn conformance_reject_kind( + schema : SchemaRef, + input : Json, + id : String, +) -> String raise { + try schema.pack_json(input) catch { + _ => () + } noraise { + _ => fail(id + ": direct packing accepted the input") + } + let packed = try schema.pack(schema.resolve(schema.root), input) catch { + _ => None + } noraise { + value => Some(value) + } + match packed { + None => "invalid-json" + Some(value) => + try schema.validate_value(value) catch { + _ => "constraint-violation" + } noraise { + _ => fail(id + ": conformance case was accepted") + } + } +} + +///| +fn run_conformance_operation(operation : String) -> Unit raise { + let corpus = @json.parse(reflection_conformance_corpus_json) + let root = corpus_object(corpus) + for test_case_json in corpus_array(root.get("cases").unwrap()) { + let test_case = corpus_object(test_case_json) + let id = corpus_string(test_case.get("id").unwrap()) + if conformance_string( + conformance_field(test_case, "operation", id), + id, + "operation", + ) == + operation { + let name = conformance_string( + conformance_field(test_case, "fixture", id), + id, + "fixture", + ) + match operation { + "roundtrip" => { + let schema = conformance_fixture(name, id) + let actual = schema.unpack_json( + schema.pack_json(conformance_field(test_case, "input", id)), + ) catch { + error => fail(id + ": " + repr(error)) + } + guard actual == conformance_field(test_case, "expected", id) else { + fail(id + ": roundtrip output differs") + } + } + "reject" => { + let schema = conformance_fixture(name, id) + let inputs = match test_case.get("inputs") { + Some(values) => conformance_array(values, id, "rejection inputs") + None => [conformance_field(test_case, "input", id)] + } + for input in inputs { + let actual = conformance_reject_kind(schema, input, id) + let expected_fields = conformance_object( + conformance_field(test_case, "expected", id), + id, + "expected rejection", + ) + let expected = conformance_string( + conformance_field(expected_fields, "kind", id), + id, + "rejection kind", + ) + guard actual == expected else { + fail(id + ": rejection category differs") + } + } + } + "json-schema" => + assert_conformance_subset( + conformance_at_pointer( + conformance_fixture(name, id).to_json_schema( + include_draft_marker=false, + ), + conformance_string( + conformance_field(test_case, "path", id), + id, + "JSON pointer", + ), + id, + ), + conformance_field(test_case, "expected", id), + id, + ) + "semantic" => + assert_conformance_semantic( + name, + conformance_field(test_case, "expected", id), + corpus, + id, + ) + other => fail(id + ": unknown requested conformance operation " + other) + } + } + } +} + +///| +test "reflection conformance corpus integrity" { + let corpus = @json.parse(reflection_conformance_corpus_json) + let root = corpus_object(corpus) + guard corpus_string(root.get("version").unwrap()) == "1.0.0" else { + fail("unexpected reflection conformance corpus version") + } + let executed : Array[String] = [] + let recognized = ["roundtrip", "reject", "json-schema", "semantic"] + for test_case_json in corpus_array(root.get("cases").unwrap()) { + let test_case = corpus_object(test_case_json) + let id = corpus_string(test_case.get("id").unwrap()) + guard !executed.any(value => value == id) else { + fail("duplicate conformance case ID " + id) + } + executed.push(id) + let operation = corpus_string(test_case.get("operation").unwrap()) + guard recognized.any(value => value == operation) else { + fail(id + ": unknown conformance operation " + operation) + } + } + let declared = corpus_array(root.get("caseIds").unwrap()).map(corpus_string) + guard executed.length() == declared.length() else { + fail("declared conformance case count differs") + } + let declared_seen : Array[String] = [] + for id in declared { + guard !declared_seen.any(value => value == id) else { + fail("duplicate declared conformance case ID " + id) + } + declared_seen.push(id) + guard executed.any(value => value == id) else { + fail("declared conformance case ID has no case: " + id) + } + } +} + +///| +test "reflection conformance roundtrip cases" { + run_conformance_operation("roundtrip") +} + +///| +test "reflection conformance rejection cases" { + run_conformance_operation("reject") +} + +///| +test "reflection conformance JSON Schema cases" { + run_conformance_operation("json-schema") +} + +///| +test "reflection conformance semantic cases" { + run_conformance_operation("semantic") +} diff --git a/sdks/moonbit/golem_sdk/reflection/rich_json.mbt b/sdks/moonbit/golem_sdk/reflection/rich_json.mbt index f8c356e96a..24c5ec6abe 100644 --- a/sdks/moonbit/golem_sdk/reflection/rich_json.mbt +++ b/sdks/moonbit/golem_sdk/reflection/rich_json.mbt @@ -1,181 +1,3 @@ -///| -fn json_uint64(value : StringView) -> UInt64 raise ReflectError { - @string.parse_uint64(value, base=10) catch { - _ => raise Json("invalid duration integer") - } -} - -///| -fn duration_to_json(value : Int64) -> String { - if value == 0L { - return "PT0S" - } - let negative = value < 0L - let mut remaining = if negative { - (-(value + 1L)).reinterpret_as_uint64() + 1UL - } else { - value.reinterpret_as_uint64() - } - let days = remaining / 86400000000000UL - remaining = remaining % 86400000000000UL - let hours = remaining / 3600000000000UL - remaining = remaining % 3600000000000UL - let minutes = remaining / 60000000000UL - remaining = remaining % 60000000000UL - let seconds = remaining / 1000000000UL - let nanos = remaining % 1000000000UL - let mut result = if negative { "-P" } else { "P" } - if days != 0UL { - result += "\{days}D" - } - if hours != 0UL || minutes != 0UL || seconds != 0UL || nanos != 0UL { - result += "T" - if hours != 0UL { - result += "\{hours}H" - } - if minutes != 0UL { - result += "\{minutes}M" - } - if seconds != 0UL || nanos != 0UL { - result += "\{seconds}" - if nanos != 0UL { - let mut fraction = "\{nanos}".pad_start(9, '0') - while fraction.has_suffix("0") { - fraction = fraction.view()[0:fraction.length() - 1].to_owned() - } - result += "." + fraction - } - result += "S" - } - } - result -} - -///| -fn duration_part( - part : StringView?, - factor : UInt64, - maximum : UInt64, -) -> UInt64 raise ReflectError { - match part { - None => 0UL - Some(value) => { - let number = json_uint64(value) - guard number <= maximum / factor else { - raise Json("duration nanoseconds out of i64 range") - } - number * factor - } - } -} - -///| -fn duration_add( - total : UInt64, - part : UInt64, - maximum : UInt64, -) -> UInt64 raise ReflectError { - guard part <= maximum - total else { - raise Json("duration nanoseconds out of i64 range") - } - total + part -} - -///| -fn duration_from_json(json : Json) -> Int64 raise ReflectError { - match json { - Object(fields) => { - guard fields.length() == 1 else { - raise Json("unexpected duration JSON field") - } - json_integer( - required_field(fields, "nanoseconds"), - -9007199254740991.0, - 9007199254740991.0, - ).to_int64() - } - String(text) => { - let shorthand = re"^(-?[0-9]+)(ns|us|ms|s)$".execute(text) - match shorthand { - Some(m) => { - let number = @string.parse_int64(m.group(1).unwrap(), base=10) catch { - _ => raise Json("duration nanoseconds out of i64 range") - } - let factor = match m.group(2).unwrap().to_owned() { - "ns" => 1L - "us" => 1000L - "ms" => 1000000L - _ => 1000000000L - } - if factor == 1L { - return number - } - guard number >= -9223372036854775807L / factor && - number <= 9223372036854775807L / factor else { - raise Json("duration nanoseconds out of i64 range") - } - number * factor - } - None => { - let iso = re"^(-)?P(?:([0-9]+)D)?(?:T(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)(?:\.([0-9]{1,9}))?S)?)?$".execute( - text, - ) - guard iso is Some(m) else { - raise Json("expected an ISO 8601 duration") - } - guard m.group(2) is Some(_) || - m.group(3) is Some(_) || - m.group(4) is Some(_) || - m.group(5) is Some(_) else { - raise Json("expected an ISO 8601 duration") - } - let negative = m.group(1) is Some(_) - let maximum = if negative { - 9223372036854775808UL - } else { - 9223372036854775807UL - } - let mut total = duration_part(m.group(2), 86400000000000UL, maximum) - total = duration_add( - total, - duration_part(m.group(3), 3600000000000UL, maximum), - maximum, - ) - total = duration_add( - total, - duration_part(m.group(4), 60000000000UL, maximum), - maximum, - ) - total = duration_add( - total, - duration_part(m.group(5), 1000000000UL, maximum), - maximum, - ) - match m.group(6) { - Some(fraction) => { - let nanos = json_uint64( - fraction.to_owned().pad_end(9, '0').view(), - ) - total = duration_add(total, nanos, maximum) - } - None => () - } - if negative { - if total == 9223372036854775808UL { - -9223372036854775807L - 1L - } else { - -total.reinterpret_as_int64() - } - } else { - total.reinterpret_as_int64() - } - } - } - } - _ => raise Json("expected an ISO 8601 duration") - } -} - ///| fn days_from_civil(year : Int, month : Int, day : Int) -> Int { let y = if month <= 2 { year - 1 } else { year } diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt index 46b8a4239f..f057a46c11 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref.mbt @@ -1,15 +1,153 @@ ///| /// A graph-backed view of one reflected schema root. -pub(all) struct SchemaRef { +pub struct SchemaRef { graph : @model.SchemaGraph root : @model.SchemaType } derive(Debug, Eq) ///| -pub fn SchemaRef::new(graph : @model.SchemaGraph) -> SchemaRef { +fn SchemaRef::new(graph : @model.SchemaGraph) -> SchemaRef { { root: graph.root, graph, } } +///| +fn clone_metadata( + metadata : @types.MetadataEnvelope, +) -> @types.MetadataEnvelope { + { + ..metadata, + aliases: metadata.aliases.copy(), + examples: metadata.examples.copy(), + } +} + +///| +fn clone_schema_type(schema : @model.SchemaType) -> @model.SchemaType { + let body = match schema.body { + Record(fields) => + @model.SchemaTypeBody::Record( + fields.map(field => { + ..field, + body: clone_schema_type(field.body), + metadata: clone_metadata(field.metadata), + }), + ) + Variant(cases) => + @model.SchemaTypeBody::Variant( + cases.map(case => { + ..case, + payload: case.payload.map(clone_schema_type), + metadata: clone_metadata(case.metadata), + }), + ) + Enum(cases) => @model.SchemaTypeBody::Enum(cases.copy()) + Flags(names) => @model.SchemaTypeBody::Flags(names.copy()) + Tuple(elements) => + @model.SchemaTypeBody::Tuple(elements.map(clone_schema_type)) + List(element) => @model.SchemaTypeBody::List(clone_schema_type(element)) + FixedList(element, length) => + @model.SchemaTypeBody::FixedList(clone_schema_type(element), length) + Map(key, value) => + @model.SchemaTypeBody::Map( + clone_schema_type(key), + clone_schema_type(value), + ) + Option(element) => @model.SchemaTypeBody::Option(clone_schema_type(element)) + Result(ok, err) => + @model.SchemaTypeBody::Result( + ok.map(clone_schema_type), + err.map(clone_schema_type), + ) + Text(restrictions) => + @model.SchemaTypeBody::Text({ + ..restrictions, + languages: restrictions.languages.map(values => values.copy()), + }) + Binary(restrictions) => + @model.SchemaTypeBody::Binary({ + ..restrictions, + mime_types: restrictions.mime_types.map(values => values.copy()), + }) + Path(spec) => + @model.SchemaTypeBody::Path({ + ..spec, + allowed_mime_types: spec.allowed_mime_types.map(values => values.copy()), + allowed_extensions: spec.allowed_extensions.map(values => values.copy()), + }) + Url(restrictions) => + @model.SchemaTypeBody::Url({ + allowed_schemes: restrictions.allowed_schemes.map(values => { + values.copy() + }), + allowed_hosts: restrictions.allowed_hosts.map(values => values.copy()), + }) + Quantity(spec) => + @model.SchemaTypeBody::Quantity({ + ..spec, + allowed_suffixes: spec.allowed_suffixes.copy(), + }) + Union(branches) => + @model.SchemaTypeBody::Union( + branches.map(branch => { + ..branch, + body: clone_schema_type(branch.body), + metadata: clone_metadata(branch.metadata), + }), + ) + Secret(spec) => + @model.SchemaTypeBody::Secret({ + ..spec, + inner: clone_schema_type(spec.inner), + }) + Future(element) => + @model.SchemaTypeBody::Future(element.map(clone_schema_type)) + Stream(element) => + @model.SchemaTypeBody::Stream(element.map(clone_schema_type)) + other => other + } + { body, metadata: clone_metadata(schema.metadata), } +} + +///| +fn clone_schema_graph(graph : @model.SchemaGraph) -> @model.SchemaGraph { + { + defs: graph.defs.map(def => { ..def, body: clone_schema_type(def.body), }), + root: clone_schema_type(graph.root), + } +} + +///| +fn SchemaRef::owned_graph(self : SchemaRef) -> @model.SchemaGraph { + let closed = @model.schema_graph_closure({ + defs: self.graph.defs, + root: self.root, + }) + clone_schema_graph(closed) +} + +///| +/// Return a self-contained defensive copy of this schema graph. +pub fn SchemaRef::graph(self : SchemaRef) -> @model.SchemaGraph { + self.owned_graph() +} + +///| +/// Return a defensive copy of this schema root. +pub fn SchemaRef::root(self : SchemaRef) -> @model.SchemaType { + self.owned_graph().root +} + +///| +/// Validate a value and pair it with a self-contained defensive graph copy for +/// fully dynamic invocation. +pub fn SchemaRef::typed_value( + self : SchemaRef, + value : @model.SchemaValue, +) -> @model.TypedSchemaValue raise ReflectError { + self.validate_value(value) + { graph: self.owned_graph(), value, } +} + ///| pub fn SchemaRef::validate_value( self : SchemaRef, @@ -219,11 +357,12 @@ fn SchemaRef::validate_refinements( } match r.languages { Some(allowed) => - if !(match language { - Some(lang) => allowed.contains(lang) - None => false - }) { - raise Schema("text language is not allowed") + match language { + Some(lang) => + if !allowed.contains(lang) { + raise Schema("text language is not allowed") + } + None => () } None => () } @@ -256,11 +395,19 @@ fn SchemaRef::validate_refinements( } match r.mime_types { Some(allowed) => - if !(match mime_type { - Some(mime) => allowed.contains(mime) - None => false - }) { - raise Schema("binary MIME type is not allowed") + match mime_type { + Some(mime) => + if !allowed.contains(mime) { + raise Schema("binary MIME type is not allowed") + } + None => () + } + None => () + } + match mime_type { + Some(mime) => + if !is_valid_mime_type(mime) { + raise Schema("invalid MIME type") } None => () } @@ -418,13 +565,27 @@ fn SchemaRef::type_contains_stream( fn SchemaRef::resolve( self : SchemaRef, schema : @model.SchemaType, +) -> @model.SchemaType raise ReflectError { + self.resolve_with(schema, {}) +} + +///| +fn SchemaRef::resolve_with( + self : SchemaRef, + schema : @model.SchemaType, + visited : Map[String, Unit], ) -> @model.SchemaType raise ReflectError { match schema.body { - Ref(id) => + Ref(id) => { + guard !visited.contains(id) else { + raise Schema("cyclic schema reference '" + id + "'") + } + visited[id] = () match self.graph.defs.iter().find_first(def => def.id == id) { - Some(def) => self.resolve(def.body) + Some(def) => self.resolve_with(def.body, visited) None => raise Schema("dangling schema reference '" + id + "'") } + } _ => schema } } @@ -453,6 +614,11 @@ fn json_string(value : Json) -> String raise ReflectError { } } +///| +fn is_valid_mime_type(value : String) -> Bool { + re"^[A-Za-z0-9!#$&^_.+\-]+/[A-Za-z0-9!#$&^_.+\-]+$".execute(value) is Some(_) +} + ///| fn json_number(value : Json) -> Double raise ReflectError { match value { @@ -477,6 +643,28 @@ fn json_integer( value } +///| +fn json_int64_string(value : Json) -> Int64 raise ReflectError { + let text = json_string(value) + guard re"^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".execute(text) is Some(_) else { + raise Json("expected a canonical signed integer string") + } + @string.parse_int64(text.view(), base=10) catch { + _ => raise Json("signed integer is outside the s64 range") + } +} + +///| +fn json_uint64_string(value : Json) -> UInt64 raise ReflectError { + let text = json_string(value) + guard re"^(?:0|[1-9][0-9]*)$".execute(text) is Some(_) else { + raise Json("expected a canonical unsigned integer string") + } + @string.parse_uint64(text.view(), base=10) catch { + _ => raise Json("unsigned integer is outside the u64 range") + } +} + ///| fn required_field( fields : Map[String, Json], @@ -622,24 +810,16 @@ fn SchemaRef::unpack( | (@model.SchemaTypeBody::S16(_), @model.SchemaValue::S16(value)) | (@model.SchemaTypeBody::S32(_), @model.SchemaValue::S32(value)) => Json::number(value.to_double()) - (@model.SchemaTypeBody::S64(_), @model.SchemaValue::S64(value)) => { - guard value >= -9007199254740991L && value <= 9007199254740991L else { - raise Json("64-bit integer is outside the safe JSON range") - } - Json::number(value.to_double()) - } + (@model.SchemaTypeBody::S64(_), @model.SchemaValue::S64(value)) => + Json::string(value.to_string()) (@model.SchemaTypeBody::U8(_), @model.SchemaValue::U8(value)) => Json::number(value.to_int().to_double()) (@model.SchemaTypeBody::U16(_), @model.SchemaValue::U16(value)) => Json::number(value.reinterpret_as_int().to_double()) (@model.SchemaTypeBody::U32(_), @model.SchemaValue::U32(value)) => Json::number(value.to_double()) - (@model.SchemaTypeBody::U64(_), @model.SchemaValue::U64(value)) => { - guard value <= 9007199254740991UL else { - raise Json("64-bit integer is outside the safe JSON range") - } - Json::number(value.to_double()) - } + (@model.SchemaTypeBody::U64(_), @model.SchemaValue::U64(value)) => + Json::string(value.to_string()) (@model.SchemaTypeBody::F32(_), @model.SchemaValue::F32(value)) => { guard !value.is_nan() && !value.is_inf() else { raise Json("expected a finite JSON number") @@ -795,26 +975,26 @@ fn SchemaRef::unpack( "bytes": Json::string(binary_to_json(bytes)), } match mime { - Some(mime) => fields["mimeType"] = Json::string(mime) + Some(mime) => { + if !is_valid_mime_type(mime) { + raise Json("invalid MIME type") + } + fields["mimeType"] = Json::string(mime) + } None => () } Json::object(fields) } - (@model.SchemaTypeBody::Quantity(_), @model.SchemaValue::Quantity(value)) => { - guard value.mantissa >= -9007199254740991L && - value.mantissa <= 9007199254740991L else { - raise Json("quantity mantissa is outside the safe JSON range") - } + (@model.SchemaTypeBody::Quantity(_), @model.SchemaValue::Quantity(value)) => Json::object({ - "mantissa": Json::number(value.mantissa.to_double()), + "mantissa": Json::string(value.mantissa.to_string()), "scale": Json::number(value.scale.to_double()), "unit": Json::string(value.unit), }) - } (@model.SchemaTypeBody::Datetime, @model.SchemaValue::Datetime(value)) => Json::string(datetime_to_json(value)) (@model.SchemaTypeBody::Duration, @model.SchemaValue::Duration(value)) => - Json::string(duration_to_json(value)) + Json::object({ "nanoseconds": Json::string(value.to_string()) }) (@model.SchemaTypeBody::Secret(_), @model.SchemaValue::Secret(_)) | (@model.SchemaTypeBody::QuotaToken(_), @model.SchemaValue::QuotaToken(_)) | ( @@ -846,14 +1026,11 @@ fn SchemaRef::pack( S8(_) => S8(json_integer(json, -128.0, 127.0).to_int()) S16(_) => S16(json_integer(json, -32768.0, 32767.0).to_int()) S32(_) => S32(json_integer(json, -2147483648.0, 2147483647.0).to_int()) - S64(_) => - S64( - json_integer(json, -9007199254740991.0, 9007199254740991.0).to_int64(), - ) + S64(_) => S64(json_int64_string(json)) U8(_) => U8(json_integer(json, 0.0, 255.0).to_uint().to_byte()) U16(_) => U16(json_integer(json, 0.0, 65535.0).to_uint()) U32(_) => U32(json_integer(json, 0.0, 4294967295.0).to_uint()) - U64(_) => U64(json_integer(json, 0.0, 9007199254740991.0).to_uint64()) + U64(_) => U64(json_uint64_string(json)) F32(_) => { let value = json_number(json) guard !value.is_nan() && !value.is_inf() else { @@ -889,7 +1066,15 @@ fn SchemaRef::pack( } } let values = expected.map(field => { - self.pack(self.resolve(field.body), required_field(fields, field.name)) + let resolved = self.resolve(field.body) + match fields.get(field.name) { + Some(value) => self.pack(resolved, value) + None => + match resolved.body { + Option(_) => @model.SchemaValue::Option(None) + _ => raise Json("missing field '" + field.name + "'") + } + } }) Record(values) } @@ -1023,6 +1208,10 @@ fn SchemaRef::pack( } Text(_) => { let fields = json_object(json) + guard fields.length() <= 2 && + (fields.length() == 1 || fields.contains("language")) else { + raise Json("unexpected text JSON field") + } Text( json_string(required_field(fields, "text")), fields.get("language").map(json_string), @@ -1048,9 +1237,17 @@ fn SchemaRef::pack( (fields.length() == 1 || fields.contains("mimeType")) else { raise Json("unexpected binary JSON field") } + let mime_type = fields.get("mimeType").map(json_string) + match mime_type { + Some(mime) => + if !is_valid_mime_type(mime) { + raise Json("invalid MIME type") + } + None => () + } Binary( binary_from_json(json_string(required_field(fields, "bytes"))), - fields.get("mimeType").map(json_string), + mime_type, ) } Quantity(_) => { @@ -1059,11 +1256,7 @@ fn SchemaRef::pack( raise Json("quantity JSON requires mantissa, scale, and unit") } Quantity({ - mantissa: json_integer( - required_field(fields, "mantissa"), - -9007199254740991.0, - 9007199254740991.0, - ).to_int64(), + mantissa: json_int64_string(required_field(fields, "mantissa")), scale: json_integer( required_field(fields, "scale"), -2147483648.0, @@ -1073,7 +1266,13 @@ fn SchemaRef::pack( }) } Datetime => Datetime(datetime_from_json(json_string(json))) - Duration => Duration(duration_from_json(json)) + Duration => { + let fields = json_object(json) + guard fields.length() == 1 else { + raise Json("duration JSON requires only nanoseconds") + } + Duration(json_int64_string(required_field(fields, "nanoseconds"))) + } Secret(_) | QuotaToken(_) | PermissionCard(_) => raise Json("capability values cannot be constructed from JSON") Future(_) | Stream(_) => diff --git a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt index d9f7becf48..4b7fd84f5c 100644 --- a/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/schema_ref_wbtest.mbt @@ -42,7 +42,7 @@ test "JSON Schema projects records and recursive definitions" { ]), }), }), - "required": Json::array([Json::string("next")]), + "required": Json::array([]), "additionalProperties": Json::boolean(false), }), }), @@ -50,6 +50,23 @@ test "JSON Schema projects records and recursive definitions" { ) } +///| +test "canonical JSON rejects cyclic alias references" { + let schema = SchemaRef::new({ + defs: [ + { id: "alias-a", name: None, body: @model.schema_type(Ref("alias-b")), }, + { id: "alias-b", name: None, body: @model.schema_type(Ref("alias-a")), }, + ], + root: @model.schema_type(Ref("alias-a")), + }) + try schema.pack_json(Json::null()) catch { + Schema(message) => assert_eq(message, "cyclic schema reference 'alias-a'") + error => fail("unexpected error: \{repr(error)}") + } noraise { + _ => fail("expected cyclic alias rejection") + } +} + ///| test "JSON Schema retains text and binary constraints" { let text = test_schema( @@ -96,11 +113,17 @@ test "JSON Schema retains text and binary constraints" { "bytes": Json::object({ "type": Json::string("string"), "contentEncoding": Json::string("base64url"), + "pattern": Json::string( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + ), "minLength": Json::number(4.0), "maxLength": Json::number(8.0), }), "mimeType": Json::object({ "type": Json::string("string"), + "pattern": Json::string( + "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + ), "enum": Json::array([Json::string("image/png")]), }), }), @@ -108,6 +131,29 @@ test "JSON Schema retains text and binary constraints" { "additionalProperties": Json::boolean(false), }), ) + let pattern = match rendered { + Object(fields) => + match fields.get("properties") { + Some(Object(properties)) => + match properties.get("bytes") { + Some(Object(bytes)) => + match bytes.get("pattern") { + Some(String(value)) => value + _ => fail("expected binary pattern string") + } + _ => fail("expected binary bytes schema") + } + _ => fail("expected binary properties") + } + _ => fail("expected binary JSON Schema object") + } + let regex = @string.Regex::Regex(pattern.replace_all(old="_-]", new="_\\-]")) + for valid in ["", "AQ", "AQI", "AQID", "-_8"] { + assert_true(regex.execute(valid) is Some(_)) + } + for invalid in ["+/8", "AQ==", "-_9", "A"] { + assert_true(regex.execute(invalid) is None) + } } ///| @@ -126,7 +172,9 @@ test "JSON Schema keeps wide numeric bounds exact in metadata" { Object(fields) => fields _ => fail("expected JSON Schema object") } - assert_eq(fields.get("x-golem-numeric-width"), Some(Json::string("u64"))) + assert_eq(fields.get("type"), Some(Json::string("string"))) + assert_eq(fields.get("format"), Some(Json::string("uint64"))) + assert_eq(fields.get("pattern"), Some(Json::string("^(?:0|[1-9][0-9]*)$"))) assert_eq( fields.get("x-golem-minimum"), Some(Json::string("9007199254740993")), @@ -269,18 +317,36 @@ test "unsigned 32-bit JSON preserves values above the signed range" { } ///| -test "64-bit JSON rejects values outside the safe integer range" { +test "64-bit JSON uses full-range canonical decimal strings" { let signed = test_schema(S64(None)) let unsigned = test_schema(U64(None)) - try signed.unpack_json(@model.SchemaValue::S64(9007199254740992L)) catch { - _ => () - } noraise { - _ => fail("expected unsafe signed integer to be rejected") + assert_eq( + signed.unpack_json(@model.SchemaValue::S64(-9223372036854775807L - 1L)), + Json::string("-9223372036854775808"), + ) + assert_eq( + signed.pack_json(Json::string("9223372036854775807")), + @model.SchemaValue::S64(9223372036854775807L), + ) + assert_eq( + unsigned.unpack_json(@model.SchemaValue::U64(18446744073709551615UL)), + Json::string("18446744073709551615"), + ) + assert_eq( + unsigned.pack_json(Json::string("18446744073709551615")), + @model.SchemaValue::U64(18446744073709551615UL), + ) + for invalid in ["+1", "01", "-0", "9223372036854775808"] { + try signed.pack_json(Json::string(invalid)) catch { + _ => () + } noraise { + _ => fail("expected malformed or overflowing s64 to be rejected") + } } - try unsigned.unpack_json(@model.SchemaValue::U64(9007199254740992UL)) catch { + try unsigned.pack_json(Json::number(1.0)) catch { _ => () } noraise { - _ => fail("expected unsafe unsigned integer to be rejected") + _ => fail("expected numeric u64 JSON to be rejected") } } @@ -400,31 +466,96 @@ test "binary JSON round-trips unpadded base64url and MIME type" { } noraise { _ => fail("expected padded base64url to be rejected") } + try + schema.pack_json( + Json::object({ + "bytes": Json::string("AQ"), + "mimeType": Json::string("not a mime"), + }), + ) + catch { + _ => () + } noraise { + _ => fail("expected invalid MIME type to be rejected") + } + try + schema.unpack_json( + @model.SchemaValue::Binary(([1] : FixedArray[Byte]), Some("not a mime")), + ) + catch { + _ => () + } noraise { + _ => fail("expected invalid MIME type to be rejected") + } +} + +///| +test "constrained text and binary metadata remain optional" { + let text = test_schema( + Text({ + languages: Some(["en"]), + min_length: None, + max_length: None, + regex: None, + }), + ) + text.validate_value(@model.SchemaValue::Text("hello", None)) + try + text.pack_json( + Json::object({ + "text": Json::string("hello"), + "extra": Json::boolean(true), + }), + ) + catch { + _ => () + } noraise { + _ => fail("expected unknown text field to be rejected") + } + let binary = test_schema( + Binary({ + min_bytes: None, + max_bytes: None, + mime_types: Some(["image/png"]), + }), + ) + binary.validate_value( + @model.SchemaValue::Binary(([1] : FixedArray[Byte]), None), + ) } ///| -test "quantity JSON round-trips within the safe integer range" { +test "quantity JSON uses a full-range canonical mantissa string" { let schema = test_schema( Quantity({ base_unit: "kg", allowed_suffixes: [], min: None, max: None, }), ) let value = @model.SchemaValue::Quantity({ - mantissa: 1234L, + mantissa: -9223372036854775807L - 1L, scale: 2, unit: "kg", }) - assert_eq(schema.pack_json(schema.unpack_json(value)), value) + let json = schema.unpack_json(value) + assert_eq( + json, + Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(2.0), + "unit": Json::string("kg"), + }), + ) + assert_eq(schema.pack_json(json), value) try - schema.unpack_json( - @model.SchemaValue::Quantity({ - mantissa: 9007199254740992L, - scale: 0, - unit: "kg", + schema.pack_json( + Json::object({ + "mantissa": Json::string("-0"), + "scale": Json::number(0.0), + "unit": Json::string("kg"), }), ) catch { _ => () } noraise { - _ => fail("expected unsafe quantity mantissa to be rejected") + _ => fail("expected non-canonical quantity mantissa to be rejected") } } @@ -457,28 +588,76 @@ test "datetime JSON round-trips UTC instants and rejects invalid dates" { } ///| -test "duration JSON round-trips ISO strings and accepts shorthand" { +test "duration JSON uses a signed decimal nanoseconds object" { let schema = test_schema(Duration) - let value = @model.SchemaValue::Duration(-90061000000001L) - let json = Json::string("-P1DT1H1M1.000000001S") + let value = @model.SchemaValue::Duration(-9223372036854775807L - 1L) + let json = Json::object({ + "nanoseconds": Json::string("-9223372036854775808"), + }) assert_eq(schema.unpack_json(value), json) assert_eq(schema.pack_json(json), value) - assert_eq( - schema.pack_json(Json::string("1500ms")), - @model.SchemaValue::Duration(1500000000L), - ) - assert_eq( - schema.pack_json(Json::string("-9223372036854775808ns")), - @model.SchemaValue::Duration(-9223372036854775807L - 1L), - ) - assert_eq( - schema.pack_json(Json::object({ "nanoseconds": Json::number(42.0) })), - @model.SchemaValue::Duration(42L), - ) - try schema.pack_json(Json::string("PT9223372037S")) catch { + try schema.pack_json(Json::string("PT1S")) catch { + _ => () + } noraise { + _ => fail("expected non-canonical duration JSON to be rejected") + } + try + schema.pack_json(Json::object({ "nanoseconds": Json::string("-0") })) + catch { _ => () } noraise { - _ => fail("expected duration overflow to be rejected") + _ => fail("expected non-canonical duration integer to be rejected") + } +} + +///| +test "omitted option record fields decode as none" { + let metadata = @model.empty_metadata() + let schema = SchemaRef::new({ + defs: [ + { + id: "optional-string", + name: None, + body: @model.schema_type(Option(@model.schema_type(String))), + }, + ], + root: @model.schema_type( + Record([ + { + name: "direct", + body: @model.schema_type(Option(@model.schema_type(String))), + metadata, + }, + { + name: "referenced", + body: @model.schema_type(Ref("optional-string")), + metadata, + }, + ]), + ), + }) + let absent = @model.SchemaValue::Record([ + @model.SchemaValue::Option(None), + @model.SchemaValue::Option(None), + ]) + assert_eq(schema.pack_json(Json::object({})), absent) + let rendered = schema.to_json_schema(include_draft_marker=false) + guard rendered is Object(fields) else { fail("expected schema object") } + assert_eq(fields.get("required"), Some(Json::array([]))) +} + +///| +test "reflection JSON Schema rejects unrepresentable leaves" { + for + body in [ + @model.SchemaTypeBody::Future(None), + @model.SchemaTypeBody::Stream(None), + @model.SchemaTypeBody::QuotaToken({ resource_name: None, }), + ] { + assert_eq( + test_schema(body).to_json_schema(include_draft_marker=false), + Json::object({ "not": Json::object({}) }), + ) } } diff --git a/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt b/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt index 7c993ecd0d..f692a011c5 100644 --- a/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt +++ b/sdks/moonbit/golem_sdk/reflection/tool_reflection.mbt @@ -1,6 +1,6 @@ ///| /// A selected argument in a discovered tool command's canonical input. -pub(all) struct ToolArgument { +pub struct ToolArgument { kind : String name : String aliases : Array[String] @@ -13,7 +13,7 @@ pub(all) struct ToolArgument { ///| /// A snapshot of one tool registration visible to the calling component. -pub(all) struct ToolType { +pub struct ToolType { lookup_name : String definition : @toolCommon.Tool implemented_by : @types.ComponentId @@ -21,12 +21,12 @@ pub(all) struct ToolType { } derive(Debug, Eq) ///| -pub(all) struct ReflectedToolClient { +pub struct ReflectedToolClient { tool : ToolType } derive(Debug, Eq) ///| -pub(all) struct ToolCommand { +pub struct ToolCommand { tool : ToolType path : Array[String] body : @toolCommon.CommandBody? @@ -37,6 +37,149 @@ pub(all) struct ToolCommand { result : SchemaRef? } derive(Debug, Eq) +///| +pub fn ToolArgument::kind(self : ToolArgument) -> String { + self.kind +} + +///| +pub fn ToolArgument::name(self : ToolArgument) -> String { + self.name +} + +///| +pub fn ToolArgument::aliases(self : ToolArgument) -> Array[String] { + self.aliases.copy() +} + +///| +pub fn ToolArgument::short(self : ToolArgument) -> Char? { + self.short +} + +///| +pub fn ToolArgument::required(self : ToolArgument) -> Bool { + self.required +} + +///| +fn schema_value_contains_capability(value : @model.SchemaValue) -> Bool { + match value { + Record(values) | Tuple(values) | List(values) | FixedList(values) => { + for value in values { + if schema_value_contains_capability(value) { + return true + } + } + false + } + Variant(_, payload) + | Option(payload) + | ResultOk(payload) + | ResultErr(payload) => + match payload { + Some(value) => schema_value_contains_capability(value) + None => false + } + Map(entries) => { + for entry in entries { + if schema_value_contains_capability(entry.key) || + schema_value_contains_capability(entry.value) { + return true + } + } + false + } + Union(_, body) => schema_value_contains_capability(body) + Secret(_) | QuotaToken(_) | Stream(_) | PermissionCard(_) => true + _ => false + } +} + +///| +pub fn ToolArgument::default( + self : ToolArgument, +) -> @model.SchemaValue? raise ReflectError { + match self.default { + Some(value) => { + if schema_value_contains_capability(value) { + raise Schema("tool argument defaults cannot contain capabilities") + } + let encoded = @model.schema_value_to_wit(value) catch { + error => + raise Schema("failed to copy tool argument default: \{repr(error)}") + } + Some( + @model_host.schema_value_from_wit(encoded) catch { + error => + raise Schema("failed to copy tool argument default: \{repr(error)}") + }, + ) + } + None => None + } +} + +///| +pub fn ToolArgument::schema(self : ToolArgument) -> SchemaRef { + self.schema +} + +///| +pub fn ToolType::lookup_name(self : ToolType) -> String { + self.lookup_name +} + +///| +pub fn ToolType::version(self : ToolType) -> String { + self.definition.version +} + +///| +pub fn ToolType::implemented_by(self : ToolType) -> @types.ComponentId { + self.implemented_by +} + +///| +pub fn ReflectedToolClient::tool(self : ReflectedToolClient) -> ToolType { + self.tool +} + +///| +pub fn ToolCommand::tool(self : ToolCommand) -> ToolType { + self.tool +} + +///| +pub fn ToolCommand::path(self : ToolCommand) -> Array[String] { + self.path.copy() +} + +///| +pub fn ToolCommand::subcommands(self : ToolCommand) -> Array[String] { + self.subcommands.copy() +} + +///| +pub fn ToolCommand::arguments(self : ToolCommand) -> Array[ToolArgument] { + self.arguments.copy() +} + +///| +pub fn ToolCommand::input_schema(self : ToolCommand) -> SchemaRef { + self.input_schema +} + +///| +pub fn ToolCommand::result(self : ToolCommand) -> SchemaRef? { + self.result +} + +///| +pub fn ToolCommand::is_callable(self : ToolCommand) -> Bool { + self.body is Some(_) +} + ///| pub(all) struct ReflectedToolCustomError { name : String diff --git a/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt b/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt index 988afa2a61..bfe51fd95e 100644 --- a/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt +++ b/sdks/moonbit/golem_sdk/reflection/tool_reflection_wbtest.mbt @@ -284,6 +284,44 @@ test "reflected optional tool inputs share the canonical wire schema" { } } +///| +test "tool snapshot accessors cannot mutate later command lookup" { + let tool = sample_tool() + let command = tool.command(["r"]) + let path = command.path() + path[0] = "mutated" + let arguments = command.arguments() + let aliases = arguments[0].aliases() + aliases.push("mutated") + arguments[0] = arguments[1] + let graph = command.input_schema().graph() + guard graph.root.body is Record(fields) else { + fail("expected reflected record input") + } + fields[0] = { ..fields[0], name: "mutated", } + fields[1].metadata.aliases.push("mutated") + + let fresh = tool.command(["r"]) + assert_eq(fresh.path(), ["run"]) + assert_eq(fresh.arguments()[0].name(), "message") + assert_eq(fresh.arguments()[0].aliases(), []) + let fresh_graph = fresh.input_schema().graph() + guard fresh_graph.root.body is Record(fresh_fields) else { + fail("expected reflected record input") + } + assert_eq(fresh_fields[0].name, "message") + assert_eq(fresh_fields[1].metadata.aliases, []) + fresh + .input_schema() + .validate_value( + @model.SchemaValue::Record([ + @model.SchemaValue::String("message"), + @model.SchemaValue::Option(None), + @model.SchemaValue::Option(None), + ]), + ) +} + ///| extern "wasm" fn set_reflection_tool_host_mode(mode : Int) = "golem:test" "set-tool-host-mode" diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt index 6d872ccb49..fe4e64e290 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tool_clients.mbt @@ -37,25 +37,29 @@ pub fn MoonbitStreamingClient::stream( let __golem_def = __golem_tool_def_MoonBitStreaming() let __golem_index = match __golem_def.command_index_by_path(["stream"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("mode", @schema.to_value_as(mode))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.start(["stream"], __golem_input, Some(stdin), true, fn(_, _) { - None - }) { + match self + .client + .start(["stream"], __golem_input, Some(stdin), true, fn(_, _) { None }) { Err(__golem_error) => Err(__golem_error) Ok(__golem_invocation) => @tool.typed_invocation(__golem_invocation, @tool.decode_result_value) @@ -104,14 +108,17 @@ pub fn CanonicalGrepClient::canonical_grep( let __golem_def = __golem_tool_def_CanonicalGrep() let __golem_index = match __golem_def.command_index_by_path([]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -121,16 +128,18 @@ pub fn CanonicalGrepClient::canonical_grep( __golem_values.push(("extra-patterns", @schema.to_value_as(extra_patterns))) __golem_values.push(("max-count", @schema.to_value_as(max_count))) __golem_values.push(("files", @schema.to_value_as(files))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.start([], __golem_input, Some(stdin), true, fn( - __golem_name, - __golem_value, + match self + .client + .start( + [], __golem_input, Some(stdin), true, fn(__golem_name, __golem_value) { + @tool.decode_named_tool_error(__golem_name, __golem_value) + }, ) { - @tool.decode_named_tool_error(__golem_name, __golem_value) - }) { Err(__golem_error) => Err(__golem_error) Ok(__golem_invocation) => @tool.typed_invocation(__golem_invocation, @tool.decode_result_value) @@ -150,14 +159,17 @@ pub async fn CanonicalGrepClient::replace( let __golem_def = __golem_tool_def_CanonicalGrep() let __golem_index = match __golem_def.command_index_by_path(["replace"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -166,11 +178,14 @@ pub async fn CanonicalGrepClient::replace( __golem_values.push(("pattern", @schema.to_value_as(pattern))) __golem_values.push(("replacement", @schema.to_value_as(replacement))) __golem_values.push(("files", @schema.to_value_as(files))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["replace"], __golem_input, None) { + match self + .client + .invoke_and_await_tool_error(["replace"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -218,14 +233,17 @@ pub async fn CanonicalGitClient::commit( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["commit"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -239,11 +257,12 @@ pub async fn CanonicalGitClient::commit( __golem_values.push(("signoff", @schema.to_value_as(signoff))) __golem_values.push(("reset-author", @schema.to_value_as(reset_author))) __golem_values.push(("output", @schema.to_value_as(output))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["commit"], __golem_input, None) { + match self.client.invoke_and_await_tool_error(["commit"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -276,7 +295,7 @@ pub fn CanonicalGitClient::remote( __golem_prefix.push(("git-dir", @schema.to_value_as(git_dir))) __golem_prefix.push(("paginate", @schema.to_value_as(paginate))) __golem_prefix.push(("config", @schema.to_value_as(config))) - { client: self.client, prefix: __golem_prefix, } + { client: self.client, prefix: __golem_prefix } } ///| @@ -291,17 +310,19 @@ pub async fn CanonicalGitRemoteClient::add( fetch : Bool, ) -> Result[Unit, @tool.ToolError[CanonicalRemoteError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "add"]) { + let __golem_index = match __golem_def.command_index_by_path(["remote", "add"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() @@ -311,15 +332,14 @@ pub async fn CanonicalGitRemoteClient::add( __golem_values.push(("master", @schema.to_value_as(master))) __golem_values.push(("tags", @schema.to_value_as(tags))) __golem_values.push(("fetch", @schema.to_value_as(fetch))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "add"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "add"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -332,30 +352,32 @@ pub async fn CanonicalGitRemoteClient::remove( name : String, ) -> Result[Unit, @tool.ToolError[CanonicalRemoteError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "remove"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["remote", "remove"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "remove"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "remove"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -373,17 +395,20 @@ pub async fn CanonicalGitRemoteClient::set_url( delete : Bool, ) -> Result[Unit, @tool.ToolError[CanonicalSetUrlError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["remote", "set-url"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["remote", "set-url"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() @@ -393,15 +418,14 @@ pub async fn CanonicalGitRemoteClient::set_url( __golem_values.push(("push", @schema.to_value_as(push))) __golem_values.push(("add", @schema.to_value_as(add))) __golem_values.push(("delete", @schema.to_value_as(delete))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["remote", "set-url"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["remote", "set-url"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -430,7 +454,7 @@ pub fn CanonicalGitClient::stash( let __golem_prefix = [] __golem_prefix.push(("verbose", @schema.to_value_as(verbose))) __golem_prefix.push(("git-dir", @schema.to_value_as(git_dir))) - { client: self.client, prefix: __golem_prefix, } + { client: self.client, prefix: __golem_prefix } } ///| @@ -443,24 +467,28 @@ pub async fn CanonicalGitStashClient::stash( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["stash"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("message", @schema.to_value_as(message))) __golem_values.push(("keep-index", @schema.to_value_as(keep_index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error(["stash"], __golem_input, None) { + match self.client.invoke_and_await_tool_error(["stash"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -474,31 +502,32 @@ pub async fn CanonicalGitStashClient::pop( index : UInt?, ) -> Result[Unit, @tool.ToolError[CanonicalStashError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["stash", "pop"]) { + let __golem_index = match __golem_def.command_index_by_path(["stash", "pop"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) __golem_values.push(("index", @schema.to_value_as(index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["stash", "pop"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["stash", "pop"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -512,31 +541,33 @@ pub async fn CanonicalGitStashClient::apply( index : UInt?, ) -> Result[Unit, @tool.ToolError[CanonicalStashError]] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(["stash", "apply"]) { - Some(__golem_index) => __golem_index - None => - return Err( - @tool.tool_protocol_error( - "generated command path is missing from descriptor", - ), - ) - } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_index = + match __golem_def.command_index_by_path(["stash", "apply"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = self.prefix.copy() __golem_values.push(("name", @schema.to_value_as(name))) __golem_values.push(("index", @schema.to_value_as(index))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_tool_error( - ["stash", "apply"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_tool_error(["stash", "apply"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_empty(__golem_result) } @@ -560,14 +591,17 @@ pub async fn CanonicalGitClient::log( let __golem_def = __golem_tool_def_CanonicalGit() let __golem_index = match __golem_def.command_index_by_path(["log"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] @@ -581,7 +615,9 @@ pub async fn CanonicalGitClient::log( __golem_values.push(("oneline", @schema.to_value_as(oneline))) __golem_values.push(("graph", @schema.to_value_as(graph))) __golem_values.push(("paths", @schema.to_value_as(paths))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } match self.client.invoke_and_await_tool_error(["log"], __golem_input, None) { @@ -604,9 +640,7 @@ pub fn CanonicalBigBoundClient::new() -> CanonicalBigBoundClient { ///| /// -pub fn CanonicalBigBoundClient::new_for( - name : String, -) -> CanonicalBigBoundClient { +pub fn CanonicalBigBoundClient::new_for(name : String) -> CanonicalBigBoundClient { { client: @tool.ToolClient::new(name), } } @@ -625,19 +659,24 @@ pub async fn CanonicalBigBoundClient::canonical_big_bound( let __golem_def = __golem_tool_def_CanonicalBigBound() let __golem_index = match __golem_def.command_index_by_path([]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("count", @schema.to_value_as(count))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } match self.client.invoke_and_await_tool_error([], __golem_input, None) { @@ -660,17 +699,13 @@ pub fn MoonbitReflectionTestClient::new() -> MoonbitReflectionTestClient { ///| /// -pub fn MoonbitReflectionTestClient::new_for( - name : String, -) -> MoonbitReflectionTestClient { +pub fn MoonbitReflectionTestClient::new_for(name : String) -> MoonbitReflectionTestClient { { client: @tool.ToolClient::new(name), } } ///| /// -pub fn MoonbitReflectionTestClient::drop( - self : MoonbitReflectionTestClient, -) -> Unit { +pub fn MoonbitReflectionTestClient::drop(self : MoonbitReflectionTestClient) -> Unit { self.client.drop() } @@ -683,23 +718,29 @@ pub async fn MoonbitReflectionTestClient::echo( let __golem_def = __golem_tool_def_MoonBitReflectionTest() let __golem_index = match __golem_def.command_index_by_path(["echo"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("label", @schema.to_value_as(label))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_no_tool_error(["echo"], __golem_input, None) { + match self + .client + .invoke_and_await_no_tool_error(["echo"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } @@ -714,27 +755,75 @@ pub async fn MoonbitReflectionTestClient::optional( let __golem_def = __golem_tool_def_MoonBitReflectionTest() let __golem_index = match __golem_def.command_index_by_path(["optional"]) { Some(__golem_index) => __golem_index - None => + None => { return Err( @tool.tool_protocol_error( "generated command path is missing from descriptor", ), ) + } } - let __golem_model = __golem_def.canonical_input_model(__golem_index) catch { + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } let __golem_values = [] __golem_values.push(("maybe", @schema.to_value_as(maybe))) - let __golem_input = @tool.build_canonical_input(__golem_model, __golem_values) catch { + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) } - match - self.client.invoke_and_await_no_tool_error( - ["optional"], - __golem_input, - None, - ) { + match self + .client + .invoke_and_await_no_tool_error(["optional"], __golem_input, None) { + Err(__golem_error) => Err(__golem_error) + Ok(__golem_result) => @tool.decode_result_value(__golem_result) + } +} + +///| +/// +pub async fn MoonbitReflectionTestClient::canonical_values( + self : MoonbitReflectionTestClient, + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[ReflectionMeters], + maybe : String?, +) -> Result[String, @tool.ToolError[@tool.NoToolError]] { + let __golem_def = __golem_tool_def_MoonBitReflectionTest() + let __golem_index = + match __golem_def.command_index_by_path(["canonical-values"]) { + Some(__golem_index) => __golem_index + None => { + return Err( + @tool.tool_protocol_error( + "generated command path is missing from descriptor", + ), + ) + } + } + let __golem_model = try { + __golem_def.canonical_input_model(__golem_index) + } catch{ + __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) + } + let __golem_values = [] + __golem_values.push(("signed", @schema.to_value_as(signed))) + __golem_values.push(("unsigned", @schema.to_value_as(unsigned))) + __golem_values.push(("duration", @schema.to_value_as(duration))) + __golem_values.push(("quantity", @schema.to_value_as(quantity))) + __golem_values.push(("maybe", @schema.to_value_as(maybe))) + let __golem_input = try { + @tool.build_canonical_input(__golem_model, __golem_values) + } catch{ + __golem_error => return Err(@tool.tool_protocol_error(repr(__golem_error))) + } + match self + .client + .invoke_and_await_no_tool_error(["canonical-values"], __golem_input, None) { Err(__golem_error) => Err(__golem_error) Ok(__golem_result) => @tool.decode_result_value(__golem_result) } diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt index fd12e22e47..fe617cbdd4 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/golem_tools.mbt @@ -3,31 +3,35 @@ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalGrepError with fn error_cases() { - Ok([ - { - name: "invalid-pattern", - doc: { - summary: "The supplied pattern is not a valid regular expression.", - description: "", - examples: [], - }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 2, - payload: Some( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - }, - { - name: "no-match", - doc: { summary: "No line matched.", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::RUNTIME_ERROR, - exit_code: 1, - payload: None, - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalGrepError with error_cases() { + try { + Ok( + [ + { + name: "invalid-pattern", + doc: { + summary: "The supplied pattern is not a valid regular expression.", + description: "", + examples: [], + }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 2, + payload: Some( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + }, + { + name: "no-match", + doc: { summary: "No line matched.", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::RUNTIME_ERROR, + exit_code: 1, + payload: None, + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -35,32 +39,38 @@ pub impl @tool.ToolErrorSchema for CanonicalGrepError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalGrepError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalGrepError with to_error_payload_value( self, ) { match self { - InvalidPattern(reason~) => - Ok(("invalid-pattern", @schema.try_into_typed_schema_value(reason))) catch { + InvalidPattern(reason~) => { + try { + Ok(("invalid-pattern", @schema.try_into_typed_schema_value(reason))) + } catch{ __golem_error => Err(__golem_error.to_string()) } - NoMatch => - Ok(("no-match", @schema.try_into_typed_schema_value(()))) catch { + } + NoMatch => { + try { + Ok(("no-match", @schema.try_into_typed_schema_value(()))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalGrepError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalGrepError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "invalid-pattern" => { - let __golem_decoded : String? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :String? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -69,9 +79,9 @@ pub impl @tool.ToolErrorSchema for CanonicalGrepError with fn from_error_payload Err("remote tool error payload is malformed for case `invalid-pattern`") } "no-match" => { - let __golem_decoded : Unit? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :Unit? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -93,34 +103,38 @@ extend CanonicalGrepError with @tool.ToolErrorSchema::{ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn error_cases() { - Ok([ - { - name: "nothing-staged", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::RUNTIME_ERROR, - exit_code: 1, - payload: None, - }, - { - name: "dirty-merge", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::RUNTIME_ERROR, - exit_code: 128, - payload: None, - }, - { - name: "bad-author-format", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 129, - payload: Some( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalCommitError with error_cases() { + try { + Ok( + [ + { + name: "nothing-staged", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::RUNTIME_ERROR, + exit_code: 1, + payload: None, + }, + { + name: "dirty-merge", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::RUNTIME_ERROR, + exit_code: 128, + payload: None, + }, + { + name: "bad-author-format", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 129, + payload: Some( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -128,36 +142,45 @@ pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalCommitError with to_error_payload_value( self, ) { match self { - NothingStaged => - Ok(("nothing-staged", @schema.try_into_typed_schema_value(()))) catch { + NothingStaged => { + try { + Ok(("nothing-staged", @schema.try_into_typed_schema_value(()))) + } catch{ __golem_error => Err(__golem_error.to_string()) } - DirtyMerge => - Ok(("dirty-merge", @schema.try_into_typed_schema_value(()))) catch { + } + DirtyMerge => { + try { + Ok(("dirty-merge", @schema.try_into_typed_schema_value(()))) + } catch{ __golem_error => Err(__golem_error.to_string()) } - BadAuthorFormat(author~) => - Ok(("bad-author-format", @schema.try_into_typed_schema_value(author))) catch { + } + BadAuthorFormat(author~) => { + try { + Ok(("bad-author-format", @schema.try_into_typed_schema_value(author))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalCommitError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "nothing-staged" => { - let __golem_decoded : Unit? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :Unit? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -166,9 +189,9 @@ pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn from_error_paylo Err("remote tool error payload is malformed for case `nothing-staged`") } "dirty-merge" => { - let __golem_decoded : Unit? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :Unit? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -177,9 +200,9 @@ pub impl @tool.ToolErrorSchema for CanonicalCommitError with fn from_error_paylo Err("remote tool error payload is malformed for case `dirty-merge`") } "bad-author-format" => { - let __golem_decoded : String? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :String? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -201,23 +224,27 @@ extend CanonicalCommitError with @tool.ToolErrorSchema::{ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalLogError with fn error_cases() { - Ok([ - { - name: "bad-revision", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 128, - payload: None, - }, - { - name: "not-a-repository", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 129, - payload: None, - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalLogError with error_cases() { + try { + Ok( + [ + { + name: "bad-revision", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 128, + payload: None, + }, + { + name: "not-a-repository", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 129, + payload: None, + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -225,32 +252,38 @@ pub impl @tool.ToolErrorSchema for CanonicalLogError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalLogError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalLogError with to_error_payload_value( self, ) { match self { - BadRevision => - Ok(("bad-revision", @schema.try_into_typed_schema_value(()))) catch { + BadRevision => { + try { + Ok(("bad-revision", @schema.try_into_typed_schema_value(()))) + } catch{ __golem_error => Err(__golem_error.to_string()) } - NotARepository => - Ok(("not-a-repository", @schema.try_into_typed_schema_value(()))) catch { + } + NotARepository => { + try { + Ok(("not-a-repository", @schema.try_into_typed_schema_value(()))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalLogError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalLogError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "bad-revision" => { - let __golem_decoded : Unit? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :Unit? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -259,9 +292,9 @@ pub impl @tool.ToolErrorSchema for CanonicalLogError with fn from_error_payload_ Err("remote tool error payload is malformed for case `bad-revision`") } "not-a-repository" => { - let __golem_decoded : Unit? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :Unit? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -283,20 +316,24 @@ extend CanonicalLogError with @tool.ToolErrorSchema::{ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalRemoteError with fn error_cases() { - Ok([ - { - name: "no-such-remote", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 128, - payload: Some( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalRemoteError with error_cases() { + try { + Ok( + [ + { + name: "no-such-remote", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 128, + payload: Some( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -304,28 +341,31 @@ pub impl @tool.ToolErrorSchema for CanonicalRemoteError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalRemoteError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalRemoteError with to_error_payload_value( self, ) { match self { - NoSuchRemote(name~) => - Ok(("no-such-remote", @schema.try_into_typed_schema_value(name))) catch { + NoSuchRemote(name~) => { + try { + Ok(("no-such-remote", @schema.try_into_typed_schema_value(name))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalRemoteError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalRemoteError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "no-such-remote" => { - let __golem_decoded : String? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :String? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -347,20 +387,24 @@ extend CanonicalRemoteError with @tool.ToolErrorSchema::{ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with fn error_cases() { - Ok([ - { - name: "failed", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::RUNTIME_ERROR, - exit_code: 1, - payload: Some( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with error_cases() { + try { + Ok( + [ + { + name: "failed", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::RUNTIME_ERROR, + exit_code: 1, + payload: Some( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -368,28 +412,31 @@ pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with to_error_payload_value( self, ) { match self { - Failed(__golem_payload) => - Ok(("failed", @schema.try_into_typed_schema_value(__golem_payload))) catch { + Failed(__golem_payload) => { + try { + Ok(("failed", @schema.try_into_typed_schema_value(__golem_payload))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalSetUrlError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "failed" => { - let __golem_decoded : String? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :String? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -411,20 +458,24 @@ extend CanonicalSetUrlError with @tool.ToolErrorSchema::{ ///| /// #warnings("-unused_try") -pub impl @tool.ToolErrorSchema for CanonicalStashError with fn error_cases() { - Ok([ - { - name: "no-such-stash", - doc: { summary: "", description: "", examples: [], }, - kind: @toolCommon.ErrorKind::USAGE_ERROR, - exit_code: 128, - payload: Some( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - }, - ]) catch { +pub impl @tool.ToolErrorSchema for CanonicalStashError with error_cases() { + try { + Ok( + [ + { + name: "no-such-stash", + doc: { summary: "", description: "", examples: [] }, + kind: @toolCommon.ErrorKind::USAGE_ERROR, + exit_code: 128, + payload: Some( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + }, + ], + ) + } catch{ __golem_error => Err(@tool.ToolBuildError::schema_model(__golem_error.to_string())) } @@ -432,28 +483,31 @@ pub impl @tool.ToolErrorSchema for CanonicalStashError with fn error_cases() { ///| /// -pub impl @tool.ToolErrorSchema for CanonicalStashError with fn to_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalStashError with to_error_payload_value( self, ) { match self { - NoSuchStash(name~) => - Ok(("no-such-stash", @schema.try_into_typed_schema_value(name))) catch { + NoSuchStash(name~) => { + try { + Ok(("no-such-stash", @schema.try_into_typed_schema_value(name))) + } catch{ __golem_error => Err(__golem_error.to_string()) } + } } } ///| /// -pub impl @tool.ToolErrorSchema for CanonicalStashError with fn from_error_payload_value( +pub impl @tool.ToolErrorSchema for CanonicalStashError with from_error_payload_value( __golem_name, __golem_value, ) { match __golem_name { "no-such-stash" => { - let __golem_decoded : String? = Some( - @schema.from_value_as(__golem_value.value), - ) catch { + let __golem_decoded :String? = try { + Some(@schema.from_value_as(__golem_value.value)) + } catch{ _ => None } if __golem_decoded is Some(__golem_payload) { @@ -496,10 +550,7 @@ fn __golem_tool_concat_globals( ///| /// -fn __golem_tool_rebase_node( - node : @tool.CommandNodeDef, - offset : Int, -) -> @tool.CommandNodeDef { +fn __golem_tool_rebase_node(node : @tool.CommandNodeDef, offset : Int) -> @tool.CommandNodeDef { { name: node.name, aliases: node.aliases, @@ -521,14 +572,11 @@ fn __golem_tool_extract_prepared(def : @tool.ToolDef) -> @tool.ToolDef { ///| /// -fn __golem_tool_append_prepared( - def : @tool.ToolDef, - child : @tool.ToolDef, -) -> @tool.ToolDef { +fn __golem_tool_append_prepared(def : @tool.ToolDef, child : @tool.ToolDef) -> @tool.ToolDef { let offset = def.commands.length() let commands = def.commands.copy() let root = commands[0] - commands[0] = { + commands[0] = { name: root.name, aliases: root.aliases, doc: root.doc, @@ -539,86 +587,96 @@ fn __golem_tool_append_prepared( for node in child.commands { commands.push(__golem_tool_rebase_node(node, offset)) } - { version: def.version, commands, } + { version: def.version, commands } } ///| /// fn __golem_tool_def_raw_MoonBitStreaming() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "1.0.0", commands: [ { name: "moonbit-streaming", aliases: [], - doc: { summary: "", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [1], body: None, }, { name: "stream", aliases: [], - doc: { summary: "", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "mode", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [], + flags: [], + constraints: [], + stdin: Some( + { + doc: { summary: "", description: "", examples: [] }, + mime: [], + required: true, + }, + ), + stdout: Some( + { + doc: { summary: "", description: "", examples: [] }, + mime: [], + required: true, + }, + ), + result: Some( { - name: "mode", - doc: { summary: "", description: "", examples: [], }, - value_name: None, type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), ), - default: None, - required: true, - accepts_stdio: false, + doc: { summary: "", description: "", examples: [] }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", }, - ], - tail: None, - }, - options: [], - flags: [], - constraints: [], - stdin: Some({ - doc: { summary: "", description: "", examples: [], }, - mime: [], - required: true, - }), - stdout: Some({ - doc: { summary: "", description: "", examples: [], }, - mime: [], - required: true, - }), - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), ), - doc: { summary: "", description: "", examples: [], }, - formatters: [ + errors: [], + annotations: Some( { - name: "default", - doc: { summary: "", description: "", examples: [], }, + read_only: false, + destructive: true, + idempotent: false, + open_world: true, }, - ], - default_formatter: "default", - }), - errors: [], - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), + }, + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -633,13 +691,13 @@ fn __golem_tool_def_prepare_MoonBitStreaming( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "1.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -658,7 +716,7 @@ fn __golem_tool_def_prepare_MoonBitStreaming( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -666,13 +724,15 @@ fn __golem_tool_def_prepare_MoonBitStreaming( ///| /// fn __golem_tool_def_MoonBitStreaming() -> @tool.ToolDef { - __golem_tool_def_prepare_MoonBitStreaming( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_MoonBitStreaming( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -688,66 +748,86 @@ async fn __golem_tool_invoke_MoonBitStreaming( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_MoonBitStreaming() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool moonbit-streaming invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool moonbit-streaming invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 1 => { - let mode : String = @tool.decode_canonical_field( - __golem_decoded, "mode", "tool moonbit-streaming command stream", - ) catch { - __golem_error => + let mode :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "mode", + "tool moonbit-streaming command stream", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } let stdin = match __golem_stdin { Some(__golem_stream) => __golem_stream - None => + None => { return @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidInput("required stdin stream is missing"), ) + } } let stdout = match __golem_stdout { Some(__golem_output) => __golem_output - None => + None => { return @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidInput("required stdout stream is missing"), ) + } } let __golem_value = MoonBitStreaming::stream(mode, stdin, stdout) Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -755,7 +835,7 @@ async fn __golem_tool_invoke_MoonBitStreaming( /// fn __golem_tool_def_raw_CanonicalGrep() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "2.0.0", commands: [ { @@ -781,16 +861,18 @@ fn __golem_tool_def_raw_CanonicalGrep() -> @tool.ToolDef { shape: @tool.OptionShapeDef::Scalar( @schema.into_schema_graph( ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGrepColorMode]), + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGrepColorMode] + ), ), ), default: Some( @tool.literal_to_schema_value( @schema.into_schema_graph( ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGrepColorMode]), + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGrepColorMode] + ), ), @tool.ToolLiteral::Scalar("Auto"), ), @@ -809,140 +891,156 @@ fn __golem_tool_def_raw_CanonicalGrep() -> @tool.ToolDef { description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), env_var: None, }, ], }, subcommands: [1], - body: Some({ - positionals: { - fixed: [ - { - name: "pattern", - doc: { - summary: "regular expression", - description: "", - examples: [], + body: Some( + { + positionals: { + fixed: [ + { + name: "pattern", + doc: { + summary: "regular expression", + description: "", + examples: [], + }, + value_name: None, + type_: @tool.refine_text( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + Some("^.+$"), + None, + None, + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: Some( + { + name: "files", + doc: { + summary: "files to search", + description: "", + examples: [], + }, + value_name: None, + item_type: @tool.refine_path( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + ), + Some(@types.PathDirection::INPUT), + Some(@types.PathKind::FILE), + None, + ), + min: 0U, + max: None, + separator: None, + verbatim: false, + accepts_stdio: true, }, + ), + }, + options: [ + { + long: "extra-patterns", + short: Some('\u{65}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, value_name: None, - type_: @tool.refine_text( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + shape: @tool.OptionShapeDef::RepeatableList( + { + repetition: @toolCommon.Repetition::Either('\u{2c}'), + item_type: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + }, + ), + default: None, + required: false, + env_var: None, + }, + { + long: "max-count", + short: Some('\u{6e}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @tool.refine_numeric( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), + ), + Some("1"), + None, + None, ), - Some("^.+$"), - None, - None, ), default: None, - required: true, - accepts_stdio: false, + required: false, + env_var: None, }, ], - tail: Some({ - name: "files", - doc: { - summary: "files to search", - description: "", - examples: [], + flags: [], + constraints: [], + stdin: Some( + { + doc: { summary: "", description: "", examples: [] }, + mime: [], + required: true, }, - value_name: None, - item_type: @tool.refine_path( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), - ), - Some(@types.PathDirection::INPUT), - Some(@types.PathKind::FILE), - None, - ), - min: 0U, - max: None, - separator: None, - verbatim: false, - accepts_stdio: true, - }), - }, - options: [ - { - long: "extra-patterns", - short: Some('\u{65}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::RepeatableList({ - repetition: @toolCommon.Repetition::Either('\u{2c}'), - item_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - }), - default: None, - required: false, - env_var: None, - }, - { - long: "max-count", - short: Some('\u{6e}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @tool.refine_numeric( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), + ), + stdout: Some( + { + doc: { summary: "", description: "", examples: [] }, + mime: [], + required: true, + }, + ), + result: Some( + { + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[Array[CanonicalGrepHit]] ), - Some("1"), - None, - None, ), - ), - default: None, - required: false, - env_var: None, - }, - ], - flags: [], - constraints: [], - stdin: Some({ - doc: { summary: "", description: "", examples: [], }, - mime: [], - required: true, - }), - stdout: Some({ - doc: { summary: "", description: "", examples: [], }, - mime: [], - required: true, - }), - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[Array[CanonicalGrepHit]]), + doc: { + summary: "Search files for a regular expression. Bare `canonical-grep` runs this body.", + description: "", + examples: [], + }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", + }, ), - doc: { - summary: "Search files for a regular expression. Bare `canonical-grep` runs this body.", - description: "", - examples: [], + errors: match CanonicalGrepError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - formatters: [ + annotations: Some( { - name: "default", - doc: { summary: "", description: "", examples: [], }, + read_only: false, + destructive: true, + idempotent: false, + open_world: true, }, - ], - default_formatter: "default", - }), - errors: match CanonicalGrepError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, { name: "replace", @@ -958,21 +1056,23 @@ fn __golem_tool_def_raw_CanonicalGrep() -> @tool.ToolDef { long: "color", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, shape: @tool.OptionShapeDef::Scalar( @schema.into_schema_graph( ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGrepColorMode]), + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGrepColorMode] + ), ), ), default: Some( @tool.literal_to_schema_value( @schema.into_schema_graph( ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGrepColorMode]), + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGrepColorMode] + ), ), @tool.ToolLiteral::Scalar("Auto"), ), @@ -986,99 +1086,106 @@ fn __golem_tool_def_raw_CanonicalGrep() -> @tool.ToolDef { long: "case-sensitive", short: Some('\u{69}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), env_var: None, }, ], }, subcommands: [], - body: Some({ - positionals: { - fixed: [ - { - name: "pattern", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - default: None, - required: true, - accepts_stdio: false, - }, + body: Some( + { + positionals: { + fixed: [ + { + name: "pattern", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "replacement", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: Some( + { + name: "files", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + item_type: @tool.refine_path( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + ), + Some(@types.PathDirection::IN_OUT), + Some(@types.PathKind::FILE), + None, + ), + min: 0U, + max: None, + separator: None, + verbatim: false, + accepts_stdio: false, + }, + ), + }, + options: [], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: Some( { - name: "replacement", - doc: { summary: "", description: "", examples: [], }, - value_name: None, type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), ), - default: None, - required: true, - accepts_stdio: false, + doc: { + summary: "Replace matching text in place.", + description: "", + examples: [], + }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", }, - ], - tail: Some({ - name: "files", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - item_type: @tool.refine_path( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), - ), - Some(@types.PathDirection::IN_OUT), - Some(@types.PathKind::FILE), - None, - ), - min: 0U, - max: None, - separator: None, - verbatim: false, - accepts_stdio: false, - }), - }, - options: [], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), ), - doc: { - summary: "Replace matching text in place.", - description: "", - examples: [], + errors: match CanonicalGrepError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - formatters: [ + annotations: Some( { - name: "default", - doc: { summary: "", description: "", examples: [], }, + read_only: false, + destructive: true, + idempotent: false, + open_world: true, }, - ], - default_formatter: "default", - }), - errors: match CanonicalGrepError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -1093,13 +1200,13 @@ fn __golem_tool_def_prepare_CanonicalGrep( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "2.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -1118,7 +1225,7 @@ fn __golem_tool_def_prepare_CanonicalGrep( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -1126,13 +1233,15 @@ fn __golem_tool_def_prepare_CanonicalGrep( ///| /// fn __golem_tool_def_CanonicalGrep() -> @tool.ToolDef { - __golem_tool_def_prepare_CanonicalGrep( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_CanonicalGrep( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -1148,123 +1257,187 @@ async fn __golem_tool_invoke_CanonicalGrep( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_CanonicalGrep() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool canonical-grep invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool canonical-grep invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 0 => { - let case_sensitive : Bool = @tool.decode_canonical_field( - __golem_decoded, "case-sensitive", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let case_sensitive :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "case-sensitive", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, - ) + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } } - let color : CanonicalGrepColorMode = @tool.decode_canonical_field( - __golem_decoded, "color", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let color :CanonicalGrepColorMode = try { + @tool.decode_canonical_field( + __golem_decoded, + "color", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let pattern : String = @tool.decode_canonical_field( - __golem_decoded, "pattern", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let pattern :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "pattern", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let extra_patterns : Array[String] = @tool.decode_canonical_field( - __golem_decoded, "extra-patterns", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let extra_patterns :Array[String] = try { + @tool.decode_canonical_field( + __golem_decoded, + "extra-patterns", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let max_count : UInt? = @tool.decode_canonical_field( - __golem_decoded, "max-count", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let max_count :UInt? = try { + @tool.decode_canonical_field( + __golem_decoded, + "max-count", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let files : Array[@schema.Path] = @tool.decode_canonical_field( - __golem_decoded, "files", "tool canonical-grep command canonical-grep", - ) catch { - __golem_error => + let files :Array[@schema.Path] = try { + @tool.decode_canonical_field( + __golem_decoded, + "files", + "tool canonical-grep command canonical-grep", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } let stdin = match __golem_stdin { Some(__golem_stream) => __golem_stream - None => + None => { return @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidInput("required stdin stream is missing"), ) + } } let stdout = match __golem_stdout { Some(__golem_output) => __golem_output - None => + None => { return @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidInput("required stdout stream is missing"), ) + } } - match - CanonicalGrep::canonical_grep( - case_sensitive, color, pattern, extra_patterns, max_count, files, stdin, - stdout, - ) { - Ok(__golem_value) => + match CanonicalGrep::canonical_grep( + case_sensitive, + color, + pattern, + extra_patterns, + max_count, + files, + stdin, + stdout, + ) { + Ok(__golem_value) => { Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) + } Err(error) => { - let (__golem_error_name, payload) = match - CanonicalGrepError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalGrepError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -1288,88 +1461,135 @@ async fn __golem_tool_invoke_CanonicalGrep( ), ) } - let case_sensitive : Bool = @tool.decode_canonical_field( - __golem_decoded, "case-sensitive", "tool canonical-grep command replace", - ) catch { - __golem_error => + let case_sensitive :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "case-sensitive", + "tool canonical-grep command replace", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let color : CanonicalGrepColorMode = @tool.decode_canonical_field( - __golem_decoded, "color", "tool canonical-grep command replace", - ) catch { - __golem_error => + let color :CanonicalGrepColorMode = try { + @tool.decode_canonical_field( + __golem_decoded, + "color", + "tool canonical-grep command replace", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let pattern : String = @tool.decode_canonical_field( - __golem_decoded, "pattern", "tool canonical-grep command replace", - ) catch { - __golem_error => + let pattern :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "pattern", + "tool canonical-grep command replace", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let replacement : String = @tool.decode_canonical_field( - __golem_decoded, "replacement", "tool canonical-grep command replace", - ) catch { - __golem_error => + let replacement :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "replacement", + "tool canonical-grep command replace", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let files : Array[@schema.Path] = @tool.decode_canonical_field( - __golem_decoded, "files", "tool canonical-grep command replace", - ) catch { - __golem_error => + let files :Array[@schema.Path] = try { + @tool.decode_canonical_field( + __golem_decoded, + "files", + "tool canonical-grep command replace", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - match - CanonicalGrep::replace( - case_sensitive, color, pattern, replacement, files, - ) { - Ok(__golem_value) => + match CanonicalGrep::replace( + case_sensitive, + color, + pattern, + replacement, + files, + ) { + Ok(__golem_value) => { Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) + } Err(error) => { - let (__golem_error_name, payload) = match - CanonicalGrepError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalGrepError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -1377,7 +1597,7 @@ async fn __golem_tool_invoke_CanonicalGrep( /// fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "0.0.0", commands: [ { @@ -1388,7 +1608,7 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [1, 2], body: None, }, @@ -1406,12 +1626,12 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { long: "git-dir", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, shape: @tool.OptionShapeDef::Scalar( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -1422,7 +1642,7 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { @tool.literal_to_schema_value( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -1438,15 +1658,20 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { long: "config", short: Some('\u{63}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, - shape: @tool.OptionShapeDef::RepeatableMap({ - repetition: @toolCommon.Repetition::Repeated, - map_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[Map[String, String]]), - ), - duplicate_key_policy: @toolCommon.DuplicateKeyPolicy::REJECT, - }), + shape: @tool.OptionShapeDef::RepeatableMap( + { + repetition: @toolCommon.Repetition::Repeated, + map_type: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[Map[String, String]] + ), + ), + duplicate_key_policy: @toolCommon.DuplicateKeyPolicy::REJECT, + }, + ), default: None, required: false, env_var: None, @@ -1457,7 +1682,7 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { long: "verbose", short: Some('\u{76}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, shape: @toolCommon.FlagShape::CountFlag(Some(3U)), env_var: None, }, @@ -1465,384 +1690,408 @@ fn __golem_tool_def_raw_CanonicalGit() -> @tool.ToolDef { long: "paginate", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: true, - negatable: true, - }), + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: true, negatable: true } + ), env_var: None, }, ], }, subcommands: [], - body: Some({ - positionals: { fixed: [], tail: None, }, - options: [ - { - long: "message", - short: Some('\u{6d}'), - aliases: ["msg"], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - ), - default: None, - required: true, - env_var: None, - }, - { - long: "author", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @tool.refine_text( + body: Some( + { + positionals: { fixed: [], tail: None }, + options: [ + { + long: "message", + short: Some('\u{6d}'), + aliases: ["msg"], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + (@schema.TypeTag::{ } : @schema.TypeTag[String]), ), - Some("^.+ <.+@.+>$"), - None, - None, ), - ), - default: None, - required: false, - env_var: Some("GIT_AUTHOR_NAME"), - }, - { - long: "output", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGitOutputMode]), + default: None, + required: true, + env_var: None, + }, + { + long: "author", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @tool.refine_text( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + Some("^.+ <.+@.+>$"), + None, + None, + ), ), - ), - default: Some( - @tool.literal_to_schema_value( + default: None, + required: false, + env_var: Some("GIT_AUTHOR_NAME"), + }, + { + long: "output", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( @schema.into_schema_graph( ( - @schema.TypeTag::{ } : - @schema.TypeTag[CanonicalGitOutputMode]), + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGitOutputMode] + ), ), - @tool.ToolLiteral::Scalar("Human"), ), - ), - required: false, - env_var: None, - }, - ], - flags: [ - { - long: "amend", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: true, - }), - env_var: None, - }, - { - long: "signoff", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: true, - }), - env_var: None, - }, - { - long: "reset-author", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - ], - constraints: [ - @tool.ConstraintDef::Implies({ - lhs_quant: @toolCommon.Quantifier::ALL, - lhs: [@tool.RefDef::Present("reset-author")], - rhs_quant: @toolCommon.Quantifier::ALL, - rhs: [@tool.RefDef::Present("amend")], - }), - @tool.ConstraintDef::RequiresAll([ - @tool.RefDef::ValueIs({ - name: "output", - value: @tool.ValueIsLiteralDef::Deferred( - @tool.ToolLiteral::Scalar("Json"), + default: Some( + @tool.literal_to_schema_value( + @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalGitOutputMode] + ), + ), + @tool.ToolLiteral::Scalar("Human"), + ), ), - }), - ]), - ], - stdin: None, - stdout: None, - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[CanonicalCommitResult]), - ), - doc: { - summary: "Record changes to the repository.", - description: "", - examples: [], - }, - formatters: [ + required: false, + env_var: None, + }, + ], + flags: [ { - name: "human", - doc: { summary: "", description: "", examples: [], }, + long: "amend", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: true } + ), + env_var: None, }, { - name: "porcelain", - doc: { summary: "", description: "", examples: [], }, + long: "signoff", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: true } + ), + env_var: None, }, { - name: "json", - doc: { summary: "", description: "", examples: [], }, + long: "reset-author", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, }, ], - default_formatter: "human", - }), - errors: match CanonicalCommitError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + constraints: [ + @tool.ConstraintDef::Implies( + { + lhs_quant: @toolCommon.Quantifier::ALL, + lhs: [@tool.RefDef::Present("reset-author")], + rhs_quant: @toolCommon.Quantifier::ALL, + rhs: [@tool.RefDef::Present("amend")], + }, + ), + @tool.ConstraintDef::RequiresAll( + [ + @tool.RefDef::ValueIs( + { + name: "output", + value: @tool.ValueIsLiteralDef::Deferred( + @tool.ToolLiteral::Scalar("Json"), + ), + }, + ), + ], + ), + ], + stdin: None, + stdout: None, + result: Some( + { + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[CanonicalCommitResult] + ), + ), + doc: { + summary: "Record changes to the repository.", + description: "", + examples: [], + }, + formatters: [ + { + name: "human", + doc: { summary: "", description: "", examples: [] }, + }, + { + name: "porcelain", + doc: { summary: "", description: "", examples: [] }, + }, + { + name: "json", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "human", + }, + ), + errors: match CanonicalCommitError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error + }, + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, { name: "log", aliases: [], - doc: { summary: "Show commit logs.", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "Show commit logs.", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [], - tail: Some({ - name: "paths", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - item_type: @tool.refine_path( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), - ), - Some(@types.PathDirection::INPUT), - Some(@types.PathKind::FILE), - None, - ), - min: 0U, - max: None, - separator: Some("--"), - verbatim: false, - accepts_stdio: false, - }), - }, - options: [ - { - long: "max-count", - short: Some('\u{6e}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @tool.refine_numeric( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[Int64]), + body: Some( + { + positionals: { + fixed: [], + tail: Some( + { + name: "paths", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + item_type: @tool.refine_path( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + ), + Some(@types.PathDirection::INPUT), + Some(@types.PathKind::FILE), + None, ), - Some("0"), - Some("9223372036854775807"), - None, - ), - ), - default: None, - required: false, - env_var: None, - }, - { - long: "since", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@types.Datetime]), - ), - ), - default: None, - required: false, - env_var: None, - }, - { - long: "until", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@types.Datetime]), - ), + min: 0U, + max: None, + separator: Some("--"), + verbatim: false, + accepts_stdio: false, + }, ), - default: None, - required: false, - env_var: None, }, - { - long: "author", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::RepeatableList({ - repetition: @toolCommon.Repetition::Delimited('\u{2c}'), - item_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + options: [ + { + long: "max-count", + short: Some('\u{6e}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @tool.refine_numeric( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[Int64]), + ), + Some("0"), + Some("9223372036854775807"), + None, + ), ), - }), - default: None, - required: false, - env_var: None, - }, - { - long: "grep", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::RepeatableList({ - repetition: @toolCommon.Repetition::Either('\u{2c}'), - item_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + default: None, + required: false, + env_var: None, + }, + { + long: "since", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@types.Datetime]), + ), ), - }), - default: None, - required: false, - env_var: None, - }, - ], - flags: [ - { - long: "all-match", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - { - long: "invert-grep", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - { - long: "oneline", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - { - long: "graph", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - ], - constraints: [ - @tool.ConstraintDef::AllOrNone([ - @tool.RefDef::Present("all-match"), - @tool.RefDef::Present("grep"), - ]), - ], - stdin: None, - stdout: None, - result: Some({ - type_: @schema.into_schema_graph( - ( - @schema.TypeTag::{ } : - @schema.TypeTag[Array[CanonicalLogEntry]]), - ), - doc: { - summary: "Show commit logs.", - description: "", - examples: [], - }, - formatters: [ + default: None, + required: false, + env_var: None, + }, { - name: "oneline", - doc: { summary: "", description: "", examples: [], }, + long: "until", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@types.Datetime]), + ), + ), + default: None, + required: false, + env_var: None, + }, + { + long: "author", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::RepeatableList( + { + repetition: @toolCommon.Repetition::Delimited('\u{2c}'), + item_type: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + }, + ), + default: None, + required: false, + env_var: None, + }, + { + long: "grep", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::RepeatableList( + { + repetition: @toolCommon.Repetition::Either('\u{2c}'), + item_type: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + }, + ), + default: None, + required: false, + env_var: None, + }, + ], + flags: [ + { + long: "all-match", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, }, { - name: "short", - doc: { summary: "", description: "", examples: [], }, + long: "invert-grep", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, }, { - name: "medium", - doc: { summary: "", description: "", examples: [], }, + long: "oneline", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, }, { - name: "full", - doc: { summary: "", description: "", examples: [], }, + long: "graph", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, }, ], - default_formatter: "medium", - }), - errors: match CanonicalLogError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + constraints: [ + @tool.ConstraintDef::AllOrNone( + [ + @tool.RefDef::Present("all-match"), + @tool.RefDef::Present("grep"), + ], + ), + ], + stdin: None, + stdout: None, + result: Some( + { + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[Array[CanonicalLogEntry]] + ), + ), + doc: { + summary: "Show commit logs.", + description: "", + examples: [], + }, + formatters: [ + { + name: "oneline", + doc: { summary: "", description: "", examples: [] }, + }, + { + name: "short", + doc: { summary: "", description: "", examples: [] }, + }, + { + name: "medium", + doc: { summary: "", description: "", examples: [] }, + }, + { + name: "full", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "medium", + }, + ), + errors: match CanonicalLogError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error + }, + annotations: Some( + { + read_only: true, + destructive: true, + idempotent: true, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: true, - destructive: true, - idempotent: true, - open_world: true, - }), - }), + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -1857,13 +2106,13 @@ fn __golem_tool_def_prepare_CanonicalGit( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -1893,12 +2142,12 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "git-dir", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, shape: @tool.OptionShapeDef::Scalar( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -1909,7 +2158,7 @@ fn __golem_tool_def_prepare_CanonicalGit( @tool.literal_to_schema_value( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -1925,15 +2174,17 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "config", short: Some('\u{63}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, - shape: @tool.OptionShapeDef::RepeatableMap({ - repetition: @toolCommon.Repetition::Repeated, - map_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[Map[String, String]]), - ), - duplicate_key_policy: @toolCommon.DuplicateKeyPolicy::REJECT, - }), + shape: @tool.OptionShapeDef::RepeatableMap( + { + repetition: @toolCommon.Repetition::Repeated, + map_type: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[Map[String, String]]), + ), + duplicate_key_policy: @toolCommon.DuplicateKeyPolicy::REJECT, + }, + ), default: None, required: false, env_var: None, @@ -1944,7 +2195,7 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "verbose", short: Some('\u{76}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, shape: @toolCommon.FlagShape::CountFlag(Some(3U)), env_var: None, }, @@ -1952,21 +2203,22 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "paginate", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: true, - negatable: true, - }), + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: true, negatable: true } + ), env_var: None, }, ], }, None, - Some({ - summary: "Manage the set of tracked repositories.", - description: "", - examples: [], - }), + Some( + { + summary: "Manage the set of tracked repositories.", + description: "", + examples: [], + }, + ), Some(["rmt"]), ) let def = __golem_tool_append_prepared(def, child) @@ -1978,12 +2230,12 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "git-dir", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, value_name: None, shape: @tool.OptionShapeDef::Scalar( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -1994,7 +2246,7 @@ fn __golem_tool_def_prepare_CanonicalGit( @tool.literal_to_schema_value( @tool.refine_path( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Path]), ), None, Some(@types.PathKind::DIRECTORY), @@ -2012,23 +2264,25 @@ fn __golem_tool_def_prepare_CanonicalGit( long: "verbose", short: Some('\u{76}'), aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, shape: @toolCommon.FlagShape::CountFlag(Some(3U)), env_var: None, }, ], }, None, - Some({ - summary: "Stash changes in a dirty working directory.", - description: "", - examples: [], - }), + Some( + { + summary: "Stash changes in a dirty working directory.", + description: "", + examples: [], + }, + ), None, ) let def = __golem_tool_append_prepared(def, child) def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -2036,13 +2290,15 @@ fn __golem_tool_def_prepare_CanonicalGit( ///| /// fn __golem_tool_def_CanonicalGit() -> @tool.ToolDef { - __golem_tool_def_prepare_CanonicalGit( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_CanonicalGit( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -2058,24 +2314,32 @@ async fn __golem_tool_invoke_CanonicalGit( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_CanonicalGit() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool canonical-git invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool canonical-git invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } - if __golem_command_path.length() > 0 && - ["remote", "rmt"].contains(__golem_command_path[0]) { + if __golem_command_path.length() > 0 && ["remote", "rmt"].contains( + __golem_command_path[0], + ) { return @tool.invoke_registered_subtool( "remote", __golem_command_path[1:].to_owned(), @@ -2085,8 +2349,9 @@ async fn __golem_tool_invoke_CanonicalGit( __golem_principal, ) } - if __golem_command_path.length() > 0 && - ["stash"].contains(__golem_command_path[0]) { + if __golem_command_path.length() > 0 && ["stash"].contains( + __golem_command_path[0], + ) { return @tool.invoke_registered_subtool( "stash", __golem_command_path[1:].to_owned(), @@ -2116,119 +2381,204 @@ async fn __golem_tool_invoke_CanonicalGit( ), ) } - let verbose : UInt = @tool.decode_canonical_field( - __golem_decoded, "verbose", "tool canonical-git command commit", - ) catch { - __golem_error => + let verbose :UInt = try { + @tool.decode_canonical_field( + __golem_decoded, + "verbose", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let git_dir : @schema.Path = @tool.decode_canonical_field( - __golem_decoded, "git-dir", "tool canonical-git command commit", - ) catch { - __golem_error => + let git_dir :@schema.Path = try { + @tool.decode_canonical_field( + __golem_decoded, + "git-dir", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let paginate : Bool = @tool.decode_canonical_field( - __golem_decoded, "paginate", "tool canonical-git command commit", - ) catch { - __golem_error => + let paginate :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "paginate", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let config : Map[String, String] = @tool.decode_canonical_field( - __golem_decoded, "config", "tool canonical-git command commit", - ) catch { - __golem_error => + let config :Map[String, String] = try { + @tool.decode_canonical_field( + __golem_decoded, + "config", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let message : String = @tool.decode_canonical_field( - __golem_decoded, "message", "tool canonical-git command commit", - ) catch { - __golem_error => + let message :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "message", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let author : String? = @tool.decode_canonical_field( - __golem_decoded, "author", "tool canonical-git command commit", - ) catch { - __golem_error => + let author :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "author", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let amend : Bool = @tool.decode_canonical_field( - __golem_decoded, "amend", "tool canonical-git command commit", - ) catch { - __golem_error => + let amend :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "amend", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let signoff : Bool = @tool.decode_canonical_field( - __golem_decoded, "signoff", "tool canonical-git command commit", - ) catch { - __golem_error => + let signoff :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "signoff", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let reset_author : Bool = @tool.decode_canonical_field( - __golem_decoded, "reset-author", "tool canonical-git command commit", - ) catch { - __golem_error => + let reset_author :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "reset-author", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let output : CanonicalGitOutputMode = @tool.decode_canonical_field( - __golem_decoded, "output", "tool canonical-git command commit", - ) catch { - __golem_error => + let output :CanonicalGitOutputMode = try { + @tool.decode_canonical_field( + __golem_decoded, + "output", + "tool canonical-git command commit", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - match - CanonicalGit::commit( - verbose, git_dir, paginate, config, message, author, amend, signoff, reset_author, - output, - ) { - Ok(__golem_value) => + match CanonicalGit::commit( + verbose, + git_dir, + paginate, + config, + message, + author, + amend, + signoff, + reset_author, + output, + ) { + Ok(__golem_value) => { Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) + } Err(error) => { - let (__golem_error_name, payload) = match - CanonicalCommitError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalCommitError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -2252,129 +2602,215 @@ async fn __golem_tool_invoke_CanonicalGit( ), ) } - let max_count : Int64? = @tool.decode_canonical_field( - __golem_decoded, "max-count", "tool canonical-git command log", - ) catch { - __golem_error => + let max_count :Int64? = try { + @tool.decode_canonical_field( + __golem_decoded, + "max-count", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let since : @types.Datetime? = @tool.decode_canonical_field( - __golem_decoded, "since", "tool canonical-git command log", - ) catch { - __golem_error => + let since :@types.Datetime? = try { + @tool.decode_canonical_field( + __golem_decoded, + "since", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let until : @types.Datetime? = @tool.decode_canonical_field( - __golem_decoded, "until", "tool canonical-git command log", - ) catch { - __golem_error => + let until :@types.Datetime? = try { + @tool.decode_canonical_field( + __golem_decoded, + "until", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let author : Array[String] = @tool.decode_canonical_field( - __golem_decoded, "author", "tool canonical-git command log", - ) catch { - __golem_error => + let author :Array[String] = try { + @tool.decode_canonical_field( + __golem_decoded, + "author", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let grep : Array[String] = @tool.decode_canonical_field( - __golem_decoded, "grep", "tool canonical-git command log", - ) catch { - __golem_error => + let grep :Array[String] = try { + @tool.decode_canonical_field( + __golem_decoded, + "grep", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let all_match : Bool = @tool.decode_canonical_field( - __golem_decoded, "all-match", "tool canonical-git command log", - ) catch { - __golem_error => + let all_match :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "all-match", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let invert_grep : Bool = @tool.decode_canonical_field( - __golem_decoded, "invert-grep", "tool canonical-git command log", - ) catch { - __golem_error => + let invert_grep :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "invert-grep", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let oneline : Bool = @tool.decode_canonical_field( - __golem_decoded, "oneline", "tool canonical-git command log", - ) catch { - __golem_error => + let oneline :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "oneline", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let graph : Bool = @tool.decode_canonical_field( - __golem_decoded, "graph", "tool canonical-git command log", - ) catch { - __golem_error => + let graph :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "graph", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let paths : Array[@schema.Path] = @tool.decode_canonical_field( - __golem_decoded, "paths", "tool canonical-git command log", - ) catch { - __golem_error => + let paths :Array[@schema.Path] = try { + @tool.decode_canonical_field( + __golem_decoded, + "paths", + "tool canonical-git command log", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - match - CanonicalGit::log( - max_count, since, until, author, grep, all_match, invert_grep, oneline, - graph, paths, - ) { - Ok(__golem_value) => + match CanonicalGit::log( + max_count, + since, + until, + author, + grep, + all_match, + invert_grep, + oneline, + graph, + paths, + ) { + Ok(__golem_value) => { Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) + } Err(error) => { - let (__golem_error_name, payload) = match - CanonicalLogError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalLogError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -2382,7 +2818,7 @@ async fn __golem_tool_invoke_CanonicalGit( /// fn __golem_tool_def_raw_CanonicalRemote() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "0.0.0", commands: [ { @@ -2393,14 +2829,14 @@ fn __golem_tool_def_raw_CanonicalRemote() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [1, 2, 3], body: None, }, { name: "add", aliases: [], - doc: { summary: "Add a remote.", description: "", examples: [], }, + doc: { summary: "Add a remote.", description: "", examples: [] }, globals: { options: [], flags: [ @@ -2408,167 +2844,175 @@ fn __golem_tool_def_raw_CanonicalRemote() -> @tool.ToolDef { long: "verbose", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, shape: @toolCommon.FlagShape::CountFlag(Some(3U)), env_var: None, }, ], }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "name", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @tool.refine_text( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + Some("^[a-zA-Z][a-zA-Z0-9_-]*$"), + None, + None, + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "url", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @tool.refine_url( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), + ), + Some(["https"]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [ { - name: "name", - doc: { summary: "", description: "", examples: [], }, + long: "track", + short: Some('\u{74}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, value_name: None, - type_: @tool.refine_text( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), - ), - Some("^[a-zA-Z][a-zA-Z0-9_-]*$"), - None, - None, + shape: @tool.OptionShapeDef::RepeatableList( + { + repetition: @toolCommon.Repetition::Repeated, + item_type: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + }, ), default: None, - required: true, - accepts_stdio: false, + required: false, + env_var: None, }, { - name: "url", - doc: { summary: "", description: "", examples: [], }, + long: "master", + short: Some('\u{6d}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, value_name: None, - type_: @tool.refine_url( + shape: @tool.OptionShapeDef::Scalar( @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), + (@schema.TypeTag::{ } : @schema.TypeTag[String]), ), - Some(["https"]), ), default: None, - required: true, - accepts_stdio: false, + required: false, + env_var: None, }, ], - tail: None, - }, - options: [ - { - long: "track", - short: Some('\u{74}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::RepeatableList({ - repetition: @toolCommon.Repetition::Repeated, - item_type: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + flags: [ + { + long: "tags", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: true, negatable: true } ), - }), - default: None, - required: false, - env_var: None, - }, - { - long: "master", - short: Some('\u{6d}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + env_var: None, + }, + { + long: "fetch", + short: Some('\u{66}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } ), - ), - default: None, - required: false, - env_var: None, - }, - ], - flags: [ - { - long: "tags", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: true, - negatable: true, - }), - env_var: None, - }, - { - long: "fetch", - short: Some('\u{66}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, + env_var: None, + }, + ], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalRemoteError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalRemoteError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: false, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: false, - idempotent: false, - open_world: true, - }), - }), + ), }, { name: "remove", aliases: ["rm"], - doc: { summary: "Remove a remote.", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "Remove a remote.", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ - { - name: "name", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - type_: @tool.refine_text( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + body: Some( + { + positionals: { + fixed: [ + { + name: "name", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @tool.refine_text( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + Some("^[a-zA-Z][a-zA-Z0-9_-]*$"), + None, + None, ), - Some("^[a-zA-Z][a-zA-Z0-9_-]*$"), - None, - None, - ), - default: None, - required: true, - accepts_stdio: false, + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalRemoteError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error + }, + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: true, + open_world: true, }, - ], - tail: None, - }, - options: [], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalRemoteError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: true, - open_world: true, - }), - }), + ), }, { name: "set-url", @@ -2578,114 +3022,117 @@ fn __golem_tool_def_raw_CanonicalRemote() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "name", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "newurl", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @tool.refine_url( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), + ), + Some(["https"]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "oldurl", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @tool.refine_url( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), + ), + Some(["https"]), + ), + default: None, + required: false, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [], + flags: [ { - name: "name", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + long: "push", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } ), - default: None, - required: true, - accepts_stdio: false, + env_var: None, }, { - name: "newurl", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - type_: @tool.refine_url( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), - ), - Some(["https"]), + long: "add", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } ), - default: None, - required: true, - accepts_stdio: false, + env_var: None, }, { - name: "oldurl", - doc: { summary: "", description: "", examples: [], }, - value_name: None, - type_: @tool.refine_url( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[@schema.Url]), - ), - Some(["https"]), + long: "delete", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } ), - default: None, - required: false, - accepts_stdio: false, + env_var: None, }, ], - tail: None, - }, - options: [], - flags: [ - { - long: "push", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - { - long: "add", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, - }, - { - long: "delete", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, + constraints: [ + @tool.ConstraintDef::MutexGroups( + [ + { refs: [@tool.RefDef::Present("add")], }, + { refs: [@tool.RefDef::Present("delete")], }, + ], + ), + ], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalSetUrlError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - constraints: [ - @tool.ConstraintDef::MutexGroups([ - { refs: [@tool.RefDef::Present("add")], }, - { refs: [@tool.RefDef::Present("delete")], }, - ]), - ], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalSetUrlError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -2700,13 +3147,13 @@ fn __golem_tool_def_prepare_CanonicalRemote( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -2725,7 +3172,7 @@ fn __golem_tool_def_prepare_CanonicalRemote( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -2733,13 +3180,15 @@ fn __golem_tool_def_prepare_CanonicalRemote( ///| /// fn __golem_tool_def_CanonicalRemote() -> @tool.ToolDef { - __golem_tool_def_prepare_CanonicalRemote( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_CanonicalRemote( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -2755,21 +3204,28 @@ async fn __golem_tool_invoke_CanonicalRemote( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_CanonicalRemote() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool remote invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool remote invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 1 => { @@ -2791,84 +3247,136 @@ async fn __golem_tool_invoke_CanonicalRemote( ), ) } - let verbose : UInt = @tool.decode_canonical_field( - __golem_decoded, "verbose", "tool remote command add", - ) catch { - __golem_error => + let verbose :UInt = try { + @tool.decode_canonical_field( + __golem_decoded, + "verbose", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let name : String = @tool.decode_canonical_field( - __golem_decoded, "name", "tool remote command add", - ) catch { - __golem_error => + let name :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "name", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let url : @schema.Url = @tool.decode_canonical_field( - __golem_decoded, "url", "tool remote command add", - ) catch { - __golem_error => + let url :@schema.Url = try { + @tool.decode_canonical_field( + __golem_decoded, + "url", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let track : Array[String] = @tool.decode_canonical_field( - __golem_decoded, "track", "tool remote command add", - ) catch { - __golem_error => + let track :Array[String] = try { + @tool.decode_canonical_field( + __golem_decoded, + "track", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let master : String? = @tool.decode_canonical_field( - __golem_decoded, "master", "tool remote command add", - ) catch { - __golem_error => + let master :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "master", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let tags : Bool = @tool.decode_canonical_field( - __golem_decoded, "tags", "tool remote command add", - ) catch { - __golem_error => + let tags :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "tags", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let fetch : Bool = @tool.decode_canonical_field( - __golem_decoded, "fetch", "tool remote command add", - ) catch { - __golem_error => + let fetch :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "fetch", + "tool remote command add", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - match - CanonicalRemote::add(verbose, name, url, track, master, tags, fetch) { + match CanonicalRemote::add(verbose, name, url, track, master, tags, fetch) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalRemoteError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalRemoteError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -2892,35 +3400,46 @@ async fn __golem_tool_invoke_CanonicalRemote( ), ) } - let name : String = @tool.decode_canonical_field( - __golem_decoded, "name", "tool remote command remove", - ) catch { - __golem_error => + let name :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "name", + "tool remote command remove", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalRemote::remove(name) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalRemoteError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalRemoteError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -2944,85 +3463,132 @@ async fn __golem_tool_invoke_CanonicalRemote( ), ) } - let name : String = @tool.decode_canonical_field( - __golem_decoded, "name", "tool remote command set-url", - ) catch { - __golem_error => + let name :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "name", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let newurl : @schema.Url = @tool.decode_canonical_field( - __golem_decoded, "newurl", "tool remote command set-url", - ) catch { - __golem_error => + let newurl :@schema.Url = try { + @tool.decode_canonical_field( + __golem_decoded, + "newurl", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let oldurl : @schema.Url? = @tool.decode_canonical_field( - __golem_decoded, "oldurl", "tool remote command set-url", - ) catch { - __golem_error => + let oldurl :@schema.Url? = try { + @tool.decode_canonical_field( + __golem_decoded, + "oldurl", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let push : Bool = @tool.decode_canonical_field( - __golem_decoded, "push", "tool remote command set-url", - ) catch { - __golem_error => + let push :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "push", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let add : Bool = @tool.decode_canonical_field( - __golem_decoded, "add", "tool remote command set-url", - ) catch { - __golem_error => + let add :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "add", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let delete : Bool = @tool.decode_canonical_field( - __golem_decoded, "delete", "tool remote command set-url", - ) catch { - __golem_error => + let delete :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "delete", + "tool remote command set-url", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalRemote::set_url(name, newurl, oldurl, push, add, delete) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalSetUrlError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalSetUrlError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -3030,7 +3596,7 @@ async fn __golem_tool_invoke_CanonicalRemote( /// fn __golem_tool_def_raw_CanonicalStash() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "0.0.0", commands: [ { @@ -3048,60 +3614,63 @@ fn __golem_tool_def_raw_CanonicalStash() -> @tool.ToolDef { long: "verbose", short: None, aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, shape: @toolCommon.FlagShape::CountFlag(Some(3U)), env_var: None, }, ], }, subcommands: [1, 2], - body: Some({ - positionals: { fixed: [], tail: None, }, - options: [ - { - long: "message", - short: Some('\u{6d}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + body: Some( + { + positionals: { fixed: [], tail: None }, + options: [ + { + long: "message", + short: Some('\u{6d}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), ), - ), - default: None, - required: true, - env_var: None, - }, - ], - flags: [ - { - long: "keep-index", - short: Some('\u{6b}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - shape: @toolCommon.FlagShape::BoolFlag({ - default: false, - negatable: false, - }), - env_var: None, + default: None, + required: true, + env_var: None, + }, + ], + flags: [ + { + long: "keep-index", + short: Some('\u{6b}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, + shape: @toolCommon.FlagShape::BoolFlag( + { default: false, negatable: false } + ), + env_var: None, + }, + ], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalStashError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalStashError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, { name: "pop", @@ -3111,58 +3680,62 @@ fn __golem_tool_def_raw_CanonicalStash() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "name", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: false, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [ { - name: "name", - doc: { summary: "", description: "", examples: [], }, + long: "index", + short: Some('\u{69}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, value_name: None, - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), + ), ), default: None, required: false, - accepts_stdio: false, + env_var: None, }, ], - tail: None, - }, - options: [ - { - long: "index", - short: Some('\u{69}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), - ), - ), - default: None, - required: false, - env_var: None, + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalStashError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalStashError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, { name: "apply", @@ -3172,63 +3745,67 @@ fn __golem_tool_def_raw_CanonicalStash() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "name", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: false, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [ { - name: "name", - doc: { summary: "", description: "", examples: [], }, + long: "index", + short: Some('\u{69}'), + aliases: [], + doc: { summary: "", description: "", examples: [] }, value_name: None, - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), + ), ), default: None, required: false, - accepts_stdio: false, + env_var: None, }, ], - tail: None, - }, - options: [ - { - long: "index", - short: Some('\u{69}'), - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt]), - ), - ), - default: None, - required: false, - env_var: None, + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalStashError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalStashError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -3243,13 +3820,13 @@ fn __golem_tool_def_prepare_CanonicalStash( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -3268,7 +3845,7 @@ fn __golem_tool_def_prepare_CanonicalStash( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -3276,13 +3853,15 @@ fn __golem_tool_def_prepare_CanonicalStash( ///| /// fn __golem_tool_def_CanonicalStash() -> @tool.ToolDef { - __golem_tool_def_prepare_CanonicalStash( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_CanonicalStash( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -3298,21 +3877,28 @@ async fn __golem_tool_invoke_CanonicalStash( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_CanonicalStash() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool stash invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool stash invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 0 => { @@ -3334,51 +3920,76 @@ async fn __golem_tool_invoke_CanonicalStash( ), ) } - let verbose : UInt = @tool.decode_canonical_field( - __golem_decoded, "verbose", "tool stash command stash", - ) catch { - __golem_error => + let verbose :UInt = try { + @tool.decode_canonical_field( + __golem_decoded, + "verbose", + "tool stash command stash", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let message : String = @tool.decode_canonical_field( - __golem_decoded, "message", "tool stash command stash", - ) catch { - __golem_error => + let message :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "message", + "tool stash command stash", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let keep_index : Bool = @tool.decode_canonical_field( - __golem_decoded, "keep-index", "tool stash command stash", - ) catch { - __golem_error => + let keep_index :Bool = try { + @tool.decode_canonical_field( + __golem_decoded, + "keep-index", + "tool stash command stash", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalStash::stash(verbose, message, keep_index) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalStashError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalStashError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -3402,43 +4013,61 @@ async fn __golem_tool_invoke_CanonicalStash( ), ) } - let name : String? = @tool.decode_canonical_field( - __golem_decoded, "name", "tool stash command pop", - ) catch { - __golem_error => + let name :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "name", + "tool stash command pop", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let index : UInt? = @tool.decode_canonical_field( - __golem_decoded, "index", "tool stash command pop", - ) catch { - __golem_error => + let index :UInt? = try { + @tool.decode_canonical_field( + __golem_decoded, + "index", + "tool stash command pop", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalStash::pop(name, index) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalStashError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalStashError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } @@ -3462,53 +4091,72 @@ async fn __golem_tool_invoke_CanonicalStash( ), ) } - let name : String? = @tool.decode_canonical_field( - __golem_decoded, "name", "tool stash command apply", - ) catch { - __golem_error => + let name :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "name", + "tool stash command apply", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } - let index : UInt? = @tool.decode_canonical_field( - __golem_decoded, "index", "tool stash command apply", - ) catch { - __golem_error => + let index :UInt? = try { + @tool.decode_canonical_field( + __golem_decoded, + "index", + "tool stash command apply", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalStash::apply(name, index) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalStashError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalStashError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -3516,7 +4164,7 @@ async fn __golem_tool_invoke_CanonicalStash( /// fn __golem_tool_def_raw_CanonicalBigBound() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "0.0.0", commands: [ { @@ -3527,53 +4175,57 @@ fn __golem_tool_def_raw_CanonicalBigBound() -> @tool.ToolDef { description: "", examples: [], }, - globals: { options: [], flags: [], }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { fixed: [], tail: None, }, - options: [ - { - long: "count", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @tool.refine_numeric( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), + body: Some( + { + positionals: { fixed: [], tail: None }, + options: [ + { + long: "count", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @tool.refine_numeric( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), + ), + Some("0"), + Some("18446744073709551615"), + None, ), - Some("0"), - Some("18446744073709551615"), - None, ), - ), - default: None, - required: false, - env_var: None, + default: None, + required: false, + env_var: None, + }, + ], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: None, + errors: match CanonicalGrepError::error_cases() { + Ok(__golem_errors) => __golem_errors + Err(__golem_error) => raise __golem_error }, - ], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: None, - errors: match CanonicalGrepError::error_cases() { - Ok(__golem_errors) => __golem_errors - Err(__golem_error) => raise __golem_error + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), }, - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -3588,13 +4240,13 @@ fn __golem_tool_def_prepare_CanonicalBigBound( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -3613,7 +4265,7 @@ fn __golem_tool_def_prepare_CanonicalBigBound( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -3621,13 +4273,15 @@ fn __golem_tool_def_prepare_CanonicalBigBound( ///| /// fn __golem_tool_def_CanonicalBigBound() -> @tool.ToolDef { - __golem_tool_def_prepare_CanonicalBigBound( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_CanonicalBigBound( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -3643,21 +4297,28 @@ async fn __golem_tool_invoke_CanonicalBigBound( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_CanonicalBigBound() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool canonical-big-bound invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool canonical-big-bound invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 0 => { @@ -3679,45 +4340,57 @@ async fn __golem_tool_invoke_CanonicalBigBound( ), ) } - let count : UInt64? = @tool.decode_canonical_field( - __golem_decoded, "count", "tool canonical-big-bound command canonical-big-bound", - ) catch { - __golem_error => + let count :UInt64? = try { + @tool.decode_canonical_field( + __golem_decoded, + "count", + "tool canonical-big-bound command canonical-big-bound", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } match CanonicalBigBound::canonical_big_bound(count) { Ok(__golem_value) => Ok(@tool.invocation_result_empty()) Err(error) => { - let (__golem_error_name, payload) = match - CanonicalGrepError::to_error_payload_value(error) { - Ok(payload) => payload - Err(encode_error) => - return Err( - @types.ToolError::InvalidResult( - "failed serializing custom tool error: " + encode_error, - ), - ) - } - let payload = @tool.encode_error_payload(payload) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + let (__golem_error_name, payload) = + match CanonicalGrepError::to_error_payload_value(error) { + Ok(payload) => payload + Err(encode_error) => { + return Err( + @types.ToolError::InvalidResult( + "failed serializing custom tool error: " + encode_error, + ), + ) + } + } + let payload = try { + @tool.encode_error_payload(payload) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error } Err( - @types.ToolError::CustomError({ name: __golem_error_name, payload, }), + @types.ToolError::CustomError({ name: __golem_error_name, payload }), ) } } } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -3725,122 +4398,245 @@ async fn __golem_tool_invoke_CanonicalBigBound( /// fn __golem_tool_def_raw_MoonBitReflectionTest() -> @tool.ToolDef { try { - let def : @tool.ToolDef = { + let def :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "moonbit-reflection-test", aliases: [], - doc: { summary: "", description: "", examples: [], }, - globals: { options: [], flags: [], }, - subcommands: [1, 2], + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, + subcommands: [1, 2, 3], body: None, }, { name: "echo", aliases: [], - doc: { summary: "", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { - fixed: [ + body: Some( + { + positionals: { + fixed: [ + { + name: "label", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: Some( { - name: "label", - doc: { summary: "", description: "", examples: [], }, - value_name: None, type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + (@schema.TypeTag::{ } : @schema.TypeTag[String]), ), - default: None, - required: true, - accepts_stdio: false, + doc: { summary: "", description: "", examples: [] }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", }, - ], - tail: None, - }, - options: [], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), ), - doc: { summary: "", description: "", examples: [], }, - formatters: [ + errors: [], + annotations: Some( { - name: "default", - doc: { summary: "", description: "", examples: [], }, + read_only: false, + destructive: true, + idempotent: false, + open_world: true, }, - ], - default_formatter: "default", - }), - errors: [], - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + ), + }, + ), }, { name: "optional", aliases: [], - doc: { summary: "", description: "", examples: [], }, - globals: { options: [], flags: [], }, + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, subcommands: [], - body: Some({ - positionals: { fixed: [], tail: None, }, - options: [ - { - long: "maybe", - short: None, - aliases: [], - doc: { summary: "", description: "", examples: [], }, - value_name: None, - shape: @tool.OptionShapeDef::Scalar( - @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + body: Some( + { + positionals: { fixed: [], tail: None }, + options: [ + { + long: "maybe", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), ), - ), - default: None, - required: false, - env_var: None, - }, - ], - flags: [], - constraints: [], - stdin: None, - stdout: None, - result: Some({ - type_: @schema.into_schema_graph( - (@schema.TypeTag::{ } : @schema.TypeTag[String]), + default: None, + required: false, + env_var: None, + }, + ], + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: Some( + { + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + doc: { summary: "", description: "", examples: [] }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", + }, ), - doc: { summary: "", description: "", examples: [], }, - formatters: [ + errors: [], + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), + }, + ), + }, + { + name: "canonical-values", + aliases: [], + doc: { summary: "", description: "", examples: [] }, + globals: { options: [], flags: [] }, + subcommands: [], + body: Some( + { + positionals: { + fixed: [ + { + name: "signed", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[Int64]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "unsigned", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[UInt64]), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "duration", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[@schema.Duration] + ), + ), + default: None, + required: true, + accepts_stdio: false, + }, + { + name: "quantity", + doc: { summary: "", description: "", examples: [] }, + value_name: None, + type_: @schema.into_schema_graph( + ( + @schema.TypeTag::{ } + : @schema.TypeTag[@schema.Quantity[ReflectionMeters]] + ), + ), + default: None, + required: true, + accepts_stdio: false, + }, + ], + tail: None, + }, + options: [ { - name: "default", - doc: { summary: "", description: "", examples: [], }, + long: "maybe", + short: None, + aliases: [], + doc: { summary: "", description: "", examples: [] }, + value_name: None, + shape: @tool.OptionShapeDef::Scalar( + @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + ), + default: None, + required: false, + env_var: None, }, ], - default_formatter: "default", - }), - errors: [], - annotations: Some({ - read_only: false, - destructive: true, - idempotent: false, - open_world: true, - }), - }), + flags: [], + constraints: [], + stdin: None, + stdout: None, + result: Some( + { + type_: @schema.into_schema_graph( + (@schema.TypeTag::{ } : @schema.TypeTag[String]), + ), + doc: { summary: "", description: "", examples: [] }, + formatters: [ + { + name: "default", + doc: { summary: "", description: "", examples: [] }, + }, + ], + default_formatter: "default", + }, + ), + errors: [], + annotations: Some( + { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + ), + }, + ), }, ], } def - } catch { + } catch{ error => abort("invalid generated raw tool definition: " + repr(error)) } } @@ -3855,13 +4651,13 @@ fn __golem_tool_def_prepare_MoonBitReflectionTest( override_aliases : Array[String]?, ) -> @tool.ToolDef { try { - let carrier : @tool.ToolDef = { + let carrier :@tool.ToolDef = { version: "0.0.0", commands: [ { name: "__golem_ancestry", aliases: [], - doc: { summary: "", description: "", examples: [], }, + doc: { summary: "", description: "", examples: [] }, globals: strict_ancestors, subcommands: [], body: None, @@ -3880,7 +4676,7 @@ fn __golem_tool_def_prepare_MoonBitReflectionTest( ) let def = prepared def - } catch { + } catch{ error => abort("invalid generated prepared tool definition: " + repr(error)) } } @@ -3888,13 +4684,15 @@ fn __golem_tool_def_prepare_MoonBitReflectionTest( ///| /// fn __golem_tool_def_MoonBitReflectionTest() -> @tool.ToolDef { - __golem_tool_def_prepare_MoonBitReflectionTest( - { options: [], flags: [], }, - { options: [], flags: [], }, - None, - None, - None, - ).normalize_inherited_globals() catch { + try { + __golem_tool_def_prepare_MoonBitReflectionTest( + { options: [], flags: [] }, + { options: [], flags: [] }, + None, + None, + None, + ).normalize_inherited_globals() + } catch{ error => abort("invalid generated tool definition: " + repr(error)) } } @@ -3910,21 +4708,28 @@ async fn __golem_tool_invoke_MoonBitReflectionTest( __golem_principal : @tool.Principal, ) -> Result[@toolCommon.InvocationResult, @types.ToolError] { let __golem_def = __golem_tool_def_MoonBitReflectionTest() - let __golem_index = match - __golem_def.command_index_by_path(__golem_command_path) { - Some(__golem_index) => __golem_index - None => - return @tool.reject_wire_invocation( - __golem_input, - __golem_stdin, - @types.ToolError::InvalidCommandPath(__golem_command_path), - ) - } - let __golem_decoded = @tool.decode_canonical_input( - __golem_def, __golem_index, __golem_input, "tool moonbit-reflection-test invocation", - ) catch { - __golem_error => + let __golem_index = + match __golem_def.command_index_by_path(__golem_command_path) { + Some(__golem_index) => __golem_index + None => { + return @tool.reject_wire_invocation( + __golem_input, + __golem_stdin, + @types.ToolError::InvalidCommandPath(__golem_command_path), + ) + } + } + let __golem_decoded = try { + @tool.decode_canonical_input( + __golem_def, + __golem_index, + __golem_input, + "tool moonbit-reflection-test invocation", + ) + } catch{ + __golem_error => { return @tool.reject_undecoded_invocation(__golem_stdin, __golem_error) + } } match __golem_index { 1 => { @@ -3946,21 +4751,31 @@ async fn __golem_tool_invoke_MoonBitReflectionTest( ), ) } - let label : String = @tool.decode_canonical_field( - __golem_decoded, "label", "tool moonbit-reflection-test command echo", - ) catch { - __golem_error => + let label :String = try { + @tool.decode_canonical_field( + __golem_decoded, + "label", + "tool moonbit-reflection-test command echo", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } let __golem_value = MoonBitReflectionTest::echo(label) Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) } @@ -3983,30 +4798,154 @@ async fn __golem_tool_invoke_MoonBitReflectionTest( ), ) } - let maybe : String? = @tool.decode_canonical_field( - __golem_decoded, "maybe", "tool moonbit-reflection-test command optional", - ) catch { - __golem_error => + let maybe :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "maybe", + "tool moonbit-reflection-test command optional", + ) + } catch{ + __golem_error => { return @tool.reject_decoded_invocation( - __golem_decoded, __golem_stdin, __golem_error, + __golem_decoded, + __golem_stdin, + __golem_error, ) + } } let __golem_value = MoonBitReflectionTest::optional(maybe) Ok( - @tool.invocation_result_value(__golem_value) catch { - @asyncCore.Cancelled::Cancelled as error => raise error - @types.ToolError::InvalidResult(message) => + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { + return Err(@types.ToolError::InvalidResult(message)) + } + error => raise error + }, + ) + } + 3 => { + if __golem_stdin is Some(_) { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + @types.ToolError::InvalidInput( + "command does not accept a stdin stream", + ), + ) + } + if __golem_stdout is Some(_) { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + @types.ToolError::InvalidInput( + "command does not accept a stdout stream", + ), + ) + } + let signed :Int64 = try { + @tool.decode_canonical_field( + __golem_decoded, + "signed", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let unsigned :UInt64 = try { + @tool.decode_canonical_field( + __golem_decoded, + "unsigned", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let duration :@schema.Duration = try { + @tool.decode_canonical_field( + __golem_decoded, + "duration", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let quantity :@schema.Quantity[ReflectionMeters] = try { + @tool.decode_canonical_field( + __golem_decoded, + "quantity", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let maybe :String? = try { + @tool.decode_canonical_field( + __golem_decoded, + "maybe", + "tool moonbit-reflection-test command canonical-values", + ) + } catch{ + __golem_error => { + return @tool.reject_decoded_invocation( + __golem_decoded, + __golem_stdin, + __golem_error, + ) + } + } + let __golem_value = MoonBitReflectionTest::canonical_values( + signed, + unsigned, + duration, + quantity, + maybe, + ) + Ok( + try { + @tool.invocation_result_value(__golem_value) + } catch{ + @asyncCore.Cancelled::Cancelled as error => raise error + @types.ToolError::InvalidResult(message) => { return Err(@types.ToolError::InvalidResult(message)) - error => raise error + } + error => raise error }, ) } - _ => + _ => { @tool.reject_decoded_invocation( __golem_decoded, __golem_stdin, @types.ToolError::InvalidCommandPath(__golem_command_path), ) + } } } @@ -4042,7 +4981,7 @@ fn init { __golem_tool_def_MoonBitReflectionTest(), __golem_tool_invoke_MoonBitReflectionTest, ) - } catch { + } catch{ error => abort("failed to register generated tools: " + repr(error)) } } diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti index 2fb2718b1c..bad0f394bb 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/pkg.generated.mbti @@ -356,6 +356,7 @@ pub fn FileOwnerClient::trigger_update(Self, String) -> Unit raise @common.Agent pub async fn FileOwnerClient::update(Self, String) -> Unit type MoonBitReflectionTest +pub fn MoonBitReflectionTest::canonical_values(Int64, UInt64, @schema.Duration, @schema.Quantity[ReflectionMeters], String?) -> String pub fn MoonBitReflectionTest::echo(String) -> String pub fn MoonBitReflectionTest::optional(String?) -> String @@ -364,6 +365,7 @@ pub async fn MoonBitStreaming::stream(String, @async-core.Stream[Byte], @tool.Pr type MoonBitToolReflectionCaller pub async fn MoonBitToolReflectionCaller::agent_round_trip(Self) -> String +pub async fn MoonBitToolReflectionCaller::canonical_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::optional_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::principal_round_trip(Self) -> String pub async fn MoonBitToolReflectionCaller::round_trip(Self) -> String @@ -372,6 +374,7 @@ pub(all) struct MoonBitToolReflectionCallerClient { client : @rpc.AgentClient } pub async fn MoonBitToolReflectionCallerClient::agent_round_trip(Self) -> String +pub async fn MoonBitToolReflectionCallerClient::canonical_round_trip(Self) -> String pub fn MoonBitToolReflectionCallerClient::drop(Self) -> Unit pub fn MoonBitToolReflectionCallerClient::get(String) -> Self raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::get_agent_id(Self) -> String raise @common.AgentError @@ -383,9 +386,11 @@ pub async fn MoonBitToolReflectionCallerClient::principal_round_trip(Self) -> St pub async fn MoonBitToolReflectionCallerClient::round_trip(Self) -> String pub fn MoonBitToolReflectionCallerClient::schedule_agent_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_agent_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_canonical_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_optional_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_principal_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_cancelable_round_trip(Self, @system-clock.Instant) -> @host.CancellationToken raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::schedule_canonical_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_optional_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_principal_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::schedule_round_trip(Self, @system-clock.Instant) -> Unit raise @common.AgentError @@ -393,6 +398,7 @@ pub async fn[T] MoonBitToolReflectionCallerClient::scoped(String, async (Self) - pub async fn[T] MoonBitToolReflectionCallerClient::scoped_get_phantom(String, @types.Uuid, async (Self) -> T) -> T pub async fn[T] MoonBitToolReflectionCallerClient::scoped_new_phantom(String, async (Self) -> T) -> T pub fn MoonBitToolReflectionCallerClient::trigger_agent_round_trip(Self) -> Unit raise @common.AgentError +pub fn MoonBitToolReflectionCallerClient::trigger_canonical_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_optional_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_principal_round_trip(Self) -> Unit raise @common.AgentError pub fn MoonBitToolReflectionCallerClient::trigger_round_trip(Self) -> Unit raise @common.AgentError @@ -420,6 +426,7 @@ pub fn MoonBitToolStreamingCallerClient::trigger_marker_before_eof(Self, Bytes) pub(all) struct MoonbitReflectionTestClient { client : @tool.ToolClient } +pub async fn MoonbitReflectionTestClient::canonical_values(Self, Int64, UInt64, @schema.Duration, @schema.Quantity[ReflectionMeters], String?) -> Result[String, @tool.ToolError[@tool-core.NoToolError]] pub fn MoonbitReflectionTestClient::drop(Self) -> Unit pub async fn MoonbitReflectionTestClient::echo(Self, String) -> Result[String, @tool.ToolError[@tool-core.NoToolError]] pub fn MoonbitReflectionTestClient::new() -> Self @@ -442,6 +449,8 @@ pub(all) enum Priority { pub impl @schema.FromSchema for Priority pub impl @schema.IntoSchema for Priority +type ReflectionMeters + type RpcExampleAgent pub async fn RpcExampleAgent::counter_rpc_example(Self) -> UInt64 pub fn RpcExampleAgent::get_last_value(Self) -> UInt64 diff --git a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt index 250da70dbc..92cdca03df 100644 --- a/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt +++ b/sdks/moonbit/golem_sdk_example1/golem_moonbit_examples/tool_reflection.mbt @@ -2,6 +2,25 @@ #derive.tool("moonbit-reflection-test") struct MoonBitReflectionTest {} +///| +#warnings("-struct_never_constructed") +struct ReflectionMeters {} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn type_id() { + "golem.examples.ReflectionMeters" +} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn base_unit() { + "m" +} + +///| +impl @schema.QuantityUnit for ReflectionMeters with fn allowed_suffixes() { + [] +} + ///| #derive.golem_schema pub(all) struct AuditLogConstructor { @@ -37,6 +56,29 @@ pub fn MoonBitReflectionTest::optional(maybe : String?) -> String { maybe.unwrap_or("omitted") } +///| +#derive.arg("maybe", scope="option") +pub fn MoonBitReflectionTest::canonical_values( + signed : Int64, + unsigned : UInt64, + duration : @schema.Duration, + quantity : @schema.Quantity[ReflectionMeters], + maybe : String?, +) -> String { + let quantity = quantity.value() + if signed == -9223372036854775807L - 1L && + unsigned == 18446744073709551615UL && + duration == @schema.Duration::new(9223372036854775807L) && + quantity.mantissa == -9223372036854775807L - 1L && + quantity.scale == -9 && + quantity.unit == "m" && + maybe is None { + "moonbit-canonical-ok" + } else { + "moonbit-canonical-mismatch" + } +} + ///| fn reflected_tool_error( error : @tool.ToolError[@reflection.ReflectedToolCustomError], @@ -88,21 +130,20 @@ async fn invoke_discovered_tool_dynamically() -> String { Ok(value) => value Err(error) => return "input:\{describe_dynamic_tool_error(error)}" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { error => return "input:\{Repr(error)}" } + let typed_input = command.input_schema().typed_value(packed) catch { + error => return "input:\{Repr(error)}" + } - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name) - let raw = match - dynamic.invoke_value(command.path, { - graph: command.input_schema.graph, - value: packed, - }) { + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()) + let raw = match dynamic.invoke_value(command.path(), typed_input) { Ok(raw) => raw Err(error) => return "invoke:\{describe_dynamic_tool_error(error)}" } - match (command.result, raw.result) { + match (command.result(), raw.result) { (None, None) => "ok" (Some(schema), Some(output)) => { schema.validate_value(output.value) catch { @@ -173,20 +214,23 @@ pub async fn MoonBitToolReflectionCaller::round_trip(self : Self) -> String { Ok(value) => value Err(_) => return "input-error" } - command.input_schema.validate_value(packed) catch { + command.input_schema().validate_value(packed) catch { error => return "input-validation:\{Repr(error)}" } + let typed_input = command.input_schema().typed_value(packed) catch { + error => return "input-validation:\{Repr(error)}" + } let invalid = command.pack_json(Json::object({ "label": Json::number(42.0) })) is Err(_) let native = command.invoke_value(packed) let encoded = command.invoke_json(input) - let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name).invoke_value( - command.path, - { graph: command.input_schema.graph, value: packed, }, + let dynamic = @reflection.DynamicToolClient::new(tool.lookup_name()).invoke_value( + command.path(), + typed_input, ) let typed = @reflection.ToolClientDefinition::unnamed(fn(name) { MoonbitReflectionTestClient::new_for(name) - }).client_for(tool.lookup_name) catch { + }).client_for(tool.lookup_name()) catch { error => return "typed-definition:\{Repr(error)}" } defer typed.drop() @@ -209,7 +253,7 @@ pub async fn MoonBitToolReflectionCaller::round_trip(self : Self) -> String { } let dynamic_text = match dynamic { Ok(result) => - match (command.result, result.result) { + match (command.result(), result.result) { (Some(schema), Some(value)) => { schema.validate_value(value.value) catch { _ => { @@ -252,9 +296,8 @@ pub async fn MoonBitToolReflectionCaller::optional_round_trip( let command = tool.command(["optional"]) catch { error => return "command:\{Repr(error)}" } - let omitted_json = command.invoke_json( - Json::object({ "maybe": Json::null() }), - ) + let omitted_json = command.invoke_json(Json::object({})) + let null_json = command.invoke_json(Json::object({ "maybe": Json::null() })) let supplied_json = command.invoke_json( Json::object({ "maybe": Json::string("supplied") }), ) @@ -297,7 +340,39 @@ pub async fn MoonBitToolReflectionCaller::optional_round_trip( Ok(value) => value Err(_) => return "generated-supplied-error" } - "\{text_json(omitted_json)}|\{text_json(supplied_json)}|\{text_native(omitted_native)}|\{text_native(supplied_native)}|\{generated_omitted}|\{generated_supplied}" + "\{text_json(omitted_json)}|\{text_json(null_json)}|\{text_json(supplied_json)}|\{text_native(omitted_native)}|\{text_native(supplied_native)}|\{generated_omitted}|\{generated_supplied}" +} + +///| +pub async fn MoonBitToolReflectionCaller::canonical_round_trip( + self : Self, +) -> String { + ignore(self) + let tool = @reflection.get_tool_type("moonbit-reflection-test") catch { + error => return "discovery:\{Repr(error)}" + } + let command = tool.command(["canonical-values"]) catch { + error => return "command:\{Repr(error)}" + } + let result = command.invoke_json( + Json::object({ + "signed": Json::string("-9223372036854775808"), + "unsigned": Json::string("18446744073709551615"), + "duration": Json::object({ + "nanoseconds": Json::string("9223372036854775807"), + }), + "quantity": Json::object({ + "mantissa": Json::string("-9223372036854775808"), + "scale": Json::number(-9.0), + "unit": Json::string("m"), + }), + }), + ) + match result { + Ok(Some(Json::String(text))) => text + Ok(_) => "canonical-error:unexpected" + Err(error) => "canonical-error:\{reflected_tool_error(error)}" + } } ///| diff --git a/sdks/rust/golem-rust/src/agentic/reflection.rs b/sdks/rust/golem-rust/src/agentic/reflection.rs index ad4eba2cec..2db5f20139 100644 --- a/sdks/rust/golem-rust/src/agentic/reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/reflection.rs @@ -23,7 +23,7 @@ pub use super::tool_reflection::{ use crate::bindings::golem::agent::{common as wire_common, host}; use crate::schema::render::{ - RenderError, from_json_value, to_json_schema_with_config, to_json_value, + RenderError, from_json_value, to_json_value, to_reflection_json_schema, }; use crate::schema::validation::validate_value; use crate::schema::{ @@ -59,6 +59,11 @@ impl SchemaRef { Self { graph, root } } + #[cfg(test)] + pub(crate) fn shares_definition_pool_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.graph, &other.graph) + } + pub fn graph(&self) -> &SchemaGraph { &self.graph } @@ -102,12 +107,7 @@ impl SchemaRef { #[cfg(feature = "json")] pub fn to_json_schema(&self, include_draft_marker: bool) -> serde_json::Value { - let config = if include_draft_marker { - crate::schema::render::JsonSchemaConfig::CANONICAL - } else { - crate::schema::render::JsonSchemaConfig::WITHOUT_DRAFT_MARKER - }; - to_json_schema_with_config(&self.graph, &self.root, config) + to_reflection_json_schema(&self.graph, &self.root, include_draft_marker) } } @@ -515,7 +515,7 @@ impl AgentType { )?; Ok(ReflectedAgentClient { agent_type: self.clone(), - transport: Rc::new(transport), + transport: ReflectedTransport::Host(Rc::new(transport)), reusable_identity: Some(agent_id.clone()), }) } @@ -855,7 +855,7 @@ impl ReflectedAgentClientFactory { )?; Ok(ReflectedAgentClient { agent_type: self.agent_type.clone(), - transport: Rc::new(transport), + transport: ReflectedTransport::Host(Rc::new(transport)), reusable_identity: identity, }) } @@ -864,7 +864,7 @@ impl ReflectedAgentClientFactory { #[derive(Clone)] pub struct ReflectedAgentClient { agent_type: AgentType, - transport: Rc, + transport: ReflectedTransport, reusable_identity: Option, } @@ -893,7 +893,101 @@ impl ReflectedAgentClient { #[derive(Clone)] pub struct ReflectedAgentMethod { definition: AgentMethod, - transport: Rc, + transport: ReflectedTransport, +} + +#[derive(Clone)] +enum ReflectedTransport { + Host(Rc), + #[cfg(test)] + Test(Rc), +} + +#[cfg(test)] +struct TestReflectedTransport { + value: Option, +} + +#[cfg(test)] +impl TestReflectedTransport { + fn completion(&self) -> Invocation> { + Invocation { + metadata: InvocationMetadata { + agent_id: ParsedAgentId::new("test-agent"), + idempotency_key: "test-key".to_string(), + }, + value: self.value.clone(), + } + } + + async fn invoke_and_await(&self, _method: &str, _input: SchemaValue) -> PendingResult { + Ok(self.completion()) + } + + fn pending(&self, _method: &str, _input: SchemaValue) -> ReflectedTransportPending { + let completion = self.completion(); + ReflectedTransportPending { + metadata: completion.metadata.clone(), + cancel: Rc::new(|| {}), + future: Box::pin(std::future::ready(Ok(completion))), + } + } +} + +impl ReflectedTransport { + async fn invoke_and_await(&self, method: &str, input: SchemaValue) -> PendingResult { + match self { + Self::Host(transport) => transport.invoke_and_await(method, input).await, + #[cfg(test)] + Self::Test(transport) => transport.invoke_and_await(method, input).await, + } + } + + fn pending( + &self, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => { + let pending = transport.pending(method, input)?; + let metadata = pending.metadata.clone(); + let cancellation = Rc::clone(&pending.raw); + Ok(ReflectedTransportPending { + metadata, + cancel: Rc::new(move || cancellation.cancel()), + future: Box::pin(pending), + }) + } + #[cfg(test)] + Self::Test(transport) => Ok(transport.pending(method, input)), + } + } + + fn trigger( + &self, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => transport.trigger(method, input), + #[cfg(test)] + Self::Test(_) => panic!("test reflected transport does not support trigger"), + } + } + + fn schedule( + &self, + at: ScheduledTime, + method: &str, + input: SchemaValue, + ) -> Result { + match self { + Self::Host(transport) => transport.schedule(at, method, input), + #[cfg(test)] + Self::Test(_) => panic!("test reflected transport does not support scheduling"), + } + } } fn validate_declared_output( @@ -1122,6 +1216,21 @@ pub struct ScheduledInvocation { } type PendingResult = Result>, GolemReflectError>; +type PendingResultFuture = Pin>>; + +struct ReflectedTransportPending { + metadata: InvocationMetadata, + cancel: Rc, + future: PendingResultFuture, +} + +impl Future for ReflectedTransportPending { + type Output = PendingResult; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.get_mut().future.as_mut().poll(cx) + } +} pub struct PendingInvocation { pub metadata: InvocationMetadata, @@ -1131,7 +1240,7 @@ pub struct PendingInvocation { /// A reflected pending invocation that applies the selected method's output policy on completion. pub struct ReflectedPendingInvocation { - checked: CheckedReflectedOutput, + checked: CheckedReflectedOutput, } struct CheckedReflectedOutput { @@ -1146,7 +1255,7 @@ impl ReflectedPendingInvocation { } pub fn cancel(&self) { - self.checked.inner.cancel(); + (self.checked.inner.cancel)(); } pub async fn get(self) -> PendingResult { @@ -1565,17 +1674,21 @@ fn decode_custom_error(value: crate::schema::wit::wire::TypedSchemaValue) -> Rem #[cfg(test)] mod tests { use super::{ - CheckedReflectedOutput, GolemReflectError, Invocation, InvocationMetadata, - MethodOnlyAgentClientDefinition, ParsedAgentId, SchemaRef, validate_declared_output, + AgentMethod, GolemReflectError, MethodOnlyAgentClientDefinition, ReflectedAgentMethod, + ReflectedTransport, SchemaRef, TestReflectedTransport, validate_declared_output, }; + use crate::bindings::golem::agent::common as wire_common; + use crate::schema::render::from_json_value; + use crate::schema::schema_type::{NumericBound, NumericRestrictions}; + use crate::schema::validation::{is_equivalent_cross_graph, validate_graph, validate_value}; use crate::schema::{ - MetadataEnvelope, NamedFieldType, SchemaGraph, SchemaType, SchemaValue, VariantCaseType, - VariantValuePayload, + BinaryRestrictions, MetadataEnvelope, NamedFieldType, PermissionCardSpec, QuantitySpec, + QuotaTokenSpec, ResultSpec, SchemaGraph, SchemaType, SchemaTypeDef, SchemaValue, + TextRestrictions, TypeId, VariantCaseType, VariantValuePayload, }; - use serde_json::json; - use std::future::{Future, ready}; - use std::pin::Pin; - use std::task::{Context, Poll, Waker}; + use serde_json::{Value, json}; + use std::collections::HashSet; + use std::rc::Rc; use test_r::test; #[test] @@ -1612,6 +1725,414 @@ mod tests { json!({ "name": "demo", "enabled": true }) ); assert_eq!(schema.to_json_schema(true)["type"], json!("object")); + assert_eq!( + schema + .pack_json(&json!({ "name": "demo" })) + .expect("decode omitted option"), + SchemaValue::Record { + fields: vec![ + SchemaValue::String("demo".to_string()), + SchemaValue::Option { inner: None }, + ] + } + ); + assert_eq!(schema.to_json_schema(false)["required"], json!(["name"])); + } + + #[test] + fn reflection_json_schema_rejects_unrepresentable_leaves() { + for ty in [ + SchemaType::secret(Default::default()), + SchemaType::future(None), + SchemaType::stream(None), + ] { + let schema = SchemaRef::new(SchemaGraph::anonymous(ty)); + assert_eq!(schema.to_json_schema(false)["not"], json!({})); + } + } + + fn conformance_field(name: &str, body: SchemaType) -> NamedFieldType { + NamedFieldType { + name: name.to_string(), + body, + metadata: MetadataEnvelope::default(), + } + } + + fn conformance_schema(name: &str, id: &str) -> SchemaRef { + let root = match name { + "s64" => SchemaType::s64(), + "constrained-s64" => SchemaType::S64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Signed(-9_007_199_254_740_993)), + max: Some(NumericBound::Signed(9_007_199_254_740_993)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "u64" => SchemaType::u64(), + "binary" => SchemaType::binary(BinaryRestrictions::default()), + "duration" => SchemaType::duration(), + "quantity" => SchemaType::quantity(QuantitySpec { + base_unit: "m".to_string(), + allowed_suffixes: vec![], + min: None, + max: None, + }), + "tool-input" => SchemaType::record(vec![ + conformance_field("pattern", SchemaType::string()), + conformance_field("paths", SchemaType::list(SchemaType::string())), + conformance_field("ignoreCase", SchemaType::option(SchemaType::bool())), + ]), + "config-entry" => SchemaType::record(vec![ + conformance_field("path", SchemaType::list(SchemaType::string())), + conformance_field("value", SchemaType::s64()), + ]), + "constrained-u32" => SchemaType::U32 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::Unsigned(2)), + max: Some(NumericBound::Unsigned(10)), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-f64" => SchemaType::F64 { + restrictions: Some(NumericRestrictions { + min: Some(NumericBound::float(-1.5).unwrap()), + max: Some(NumericBound::float(2.5).unwrap()), + unit: None, + }), + metadata: MetadataEnvelope::default(), + }, + "constrained-text" => SchemaType::text(TextRestrictions { + languages: Some(vec!["en".to_string(), "de".to_string()]), + min_length: Some(2), + max_length: Some(8), + regex: Some("^[a-z]+$".to_string()), + }), + "constrained-binary" => SchemaType::binary(BinaryRestrictions { + mime_types: Some(vec![ + "image/png".to_string(), + "application/octet-stream".to_string(), + ]), + min_bytes: Some(2), + max_bytes: Some(4), + }), + "result" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::u32())), + }), + "custom-error" => SchemaType::result(ResultSpec { + ok: Some(Box::new(SchemaType::string())), + err: Some(Box::new(SchemaType::record(vec![ + conformance_field("code", SchemaType::string()), + conformance_field("retryable", SchemaType::bool()), + ]))), + }), + "optional-record" => { + let id = TypeId::new("conformance.optional"); + return SchemaRef::new(SchemaGraph { + defs: vec![SchemaTypeDef { + id: id.clone(), + name: None, + body: SchemaType::option(SchemaType::string()), + }], + root: SchemaType::record(vec![ + conformance_field("direct", SchemaType::option(SchemaType::string())), + conformance_field("referenced", SchemaType::ref_to(id)), + ]), + }); + } + other => panic!("{id}: unknown conformance fixture {other}"), + }; + SchemaRef::new(SchemaGraph::anonymous(root)) + } + + fn conformance_json_pointer<'a>(value: &'a Value, pointer: &str, id: &str) -> &'a Value { + if pointer.is_empty() { + value + } else { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("{id}: missing JSON pointer {pointer} in {value}")) + } + } + + fn assert_conformance_subset(actual: &Value, expected: &Value, id: &str) { + match expected { + Value::Object(expected) => { + let actual = actual + .as_object() + .unwrap_or_else(|| panic!("{id}: expected JSON object, got {actual}")); + for (key, expected) in expected { + assert_conformance_subset( + actual + .get(key) + .unwrap_or_else(|| panic!("{id}: missing key {key} in {actual:?}")), + expected, + id, + ); + } + } + _ => assert_eq!(actual, expected, "{id}"), + } + } + + fn assert_conformance_semantic(fixture: &str, expected: &Value, id: &str) { + match fixture { + "unsupported-leaves" => { + let types = [ + SchemaType::secret(Default::default()), + SchemaType::quota_token(QuotaTokenSpec::default()), + SchemaType::permission_card(PermissionCardSpec::default()), + SchemaType::future(None), + SchemaType::stream(None), + ]; + assert_eq!( + types.len(), + expected["count"] + .as_u64() + .unwrap_or_else(|| panic!("{id}: missing unsupported-leaves count")) + as usize, + "{id}" + ); + for ty in types { + assert_conformance_subset( + &SchemaRef::new(SchemaGraph::anonymous(ty)).to_json_schema(false), + &expected["schema"], + id, + ); + } + } + "all-kinds" => assert_eq!( + serde_json::to_value(vec![ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream", + ]) + .unwrap(), + expected["names"], + "{id}" + ), + "all-restrictions" => assert_eq!( + serde_json::to_value(vec![ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field", + ]) + .unwrap(), + expected["names"], + "{id}" + ), + "graph" => { + let referenced = conformance_schema("optional-record", id); + let inline = SchemaRef::new(SchemaGraph::anonymous(SchemaType::record(vec![ + conformance_field("direct", SchemaType::option(SchemaType::string())), + conformance_field("referenced", SchemaType::option(SchemaType::string())), + ]))); + assert!(validate_graph(referenced.graph()).is_ok(), "{id}"); + assert!( + is_equivalent_cross_graph( + referenced.graph(), + referenced.root(), + inline.graph(), + inline.root(), + ), + "{id}" + ); + } + other => panic!("{id}: unknown semantic conformance fixture {other}"), + } + } + + fn conformance_corpus() -> Value { + serde_json::from_str(include_str!( + "../../../../../test-data/reflection-conformance/v1.json" + )) + .expect("valid reflection conformance corpus") + } + + fn run_conformance_cases(operation: &str) { + let corpus = conformance_corpus(); + let cases = corpus["cases"].as_array().expect("cases array"); + for case in cases { + let id = case["id"].as_str().expect("case id"); + let case_operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + if case_operation != operation { + continue; + } + let fixture = case["fixture"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing fixture")); + let expected = &case["expected"]; + match operation { + "roundtrip" => { + let schema = conformance_schema(fixture, id); + let packed = schema + .pack_json(&case["input"]) + .unwrap_or_else(|error| panic!("{id}: {error}")); + assert_eq!( + schema + .unpack_json(&packed) + .unwrap_or_else(|error| panic!("{id}: {error}")), + *expected, + "{id}", + ); + } + "reject" => { + let schema = conformance_schema(fixture, id); + let inputs = case["inputs"] + .as_array() + .cloned() + .unwrap_or_else(|| vec![case["input"].clone()]); + for input in inputs { + assert!( + schema.pack_json(&input).is_err(), + "{id}: direct packing accepted {input}" + ); + let actual = match from_json_value(schema.graph(), schema.root(), &input) { + Err(_) => "invalid-json", + Ok(value) + if validate_value(schema.graph(), schema.root(), &value) + .is_err() => + { + "constraint-violation" + } + Ok(value) => panic!("{id} accepted {input} as {value:?}"), + }; + assert_eq!( + actual, + expected["kind"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing reject kind")), + "{id}" + ); + } + } + "json-schema" => { + let schema = conformance_schema(fixture, id); + let rendered = schema.to_json_schema(false); + let path = case["path"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing path")); + assert_conformance_subset( + conformance_json_pointer(&rendered, path, id), + expected, + id, + ); + } + "semantic" => assert_conformance_semantic(fixture, expected, id), + operation => panic!("unknown requested conformance operation {operation}"), + } + } + } + + #[test] + fn reflection_conformance_corpus_integrity() { + let corpus = conformance_corpus(); + assert_eq!(corpus["version"], "1.0.0"); + let cases = corpus["cases"].as_array().expect("cases array"); + let recognized = HashSet::from(["roundtrip", "reject", "json-schema", "semantic"]); + let mut case_ids = HashSet::new(); + for case in cases { + let id = case["id"].as_str().expect("case id"); + assert!(case_ids.insert(id), "duplicate case ID {id}"); + let operation = case["operation"] + .as_str() + .unwrap_or_else(|| panic!("{id}: missing operation")); + assert!( + recognized.contains(operation), + "{id}: unknown operation {operation}" + ); + } + let declared_ids = corpus["caseIds"].as_array().expect("declared case IDs"); + let declared: HashSet<_> = declared_ids + .iter() + .map(|id| id.as_str().expect("declared case ID")) + .collect(); + assert_eq!( + declared.len(), + declared_ids.len(), + "duplicate declared case ID" + ); + assert_eq!(case_ids, declared, "missing or unknown conformance cases"); + } + + #[test] + fn reflection_conformance_roundtrip_cases() { + run_conformance_cases("roundtrip"); + } + + #[test] + fn reflection_conformance_reject_cases() { + run_conformance_cases("reject"); + } + + #[test] + fn reflection_conformance_json_schema_cases() { + run_conformance_cases("json-schema"); + } + + #[test] + fn reflection_conformance_semantic_cases() { + run_conformance_cases("semantic"); } #[test] @@ -1661,39 +2182,61 @@ mod tests { } #[test] - fn reflected_pending_output_matches_awaited_policy() { + async fn reflected_public_completion_paths_apply_the_same_output_policy() { let output = SchemaRef::new(SchemaGraph::anonymous(SchemaType::string())); - for (declared, value) in [ - (Some(output.clone()), None), - (None, Some(SchemaValue::Bool(true))), - (Some(output.clone()), Some(SchemaValue::U32(7))), + let input = SchemaRef::new(SchemaGraph::anonymous(SchemaType::record(vec![]))); + for (name, declared, value) in [ + ("missing_declared", Some(output.clone()), None), + ("unexpected_unit", None, Some(SchemaValue::Bool(true))), ( + "incompatible", Some(output.clone()), - Some(SchemaValue::String("ok".to_string())), + Some(SchemaValue::U32(7)), ), ] { - let awaited = validate_declared_output(declared.as_ref(), value.as_ref(), "read"); - let invocation = Invocation { - metadata: InvocationMetadata { - agent_id: ParsedAgentId::new("test"), - idempotency_key: "test".to_string(), + let method = ReflectedAgentMethod { + definition: AgentMethod { + agent_type_name: "InvalidOutput".to_string(), + raw: wire_common::AgentMethod { + name: name.to_string(), + description: String::new(), + http_endpoint: vec![], + prompt_hint: None, + input_schema: wire_common::InputSchema::Parameters(vec![]), + output_schema: if declared.is_some() { + wire_common::OutputSchema::Single(0) + } else { + wire_common::OutputSchema::Unit + }, + read_only: None, + }, + input: input.clone(), + output: declared, }, - value, - }; - let mut pending = CheckedReflectedOutput { - inner: ready(Ok(invocation)), - output: declared, - method: "read".to_string(), - }; - let mut context = Context::from_waker(Waker::noop()); - let completed = Pin::new(&mut pending).poll(&mut context); - let Poll::Ready(result) = completed else { - panic!("ready reflected invocation must complete"); + transport: ReflectedTransport::Test(Rc::new(TestReflectedTransport { value })), }; - assert_eq!(result.is_ok(), awaited.is_ok()); - if let Err(error) = result { - assert!(matches!(error, GolemReflectError::MalformedRemoteOutput(_))); - } + let empty = SchemaValue::Record { fields: vec![] }; + + let awaited = method + .invoke_value(empty.clone()) + .await + .expect_err("awaited completion must reject malformed output"); + let pending = method + .pending_value(empty) + .expect("pending invocation starts") + .get() + .await + .expect_err("pending completion must reject malformed output"); + + assert!(matches!( + awaited, + GolemReflectError::MalformedRemoteOutput(_) + )); + assert!(matches!( + pending, + GolemReflectError::MalformedRemoteOutput(_) + )); + assert_eq!(awaited.to_string(), pending.to_string()); } } diff --git a/sdks/rust/golem-rust/src/agentic/tool_reflection.rs b/sdks/rust/golem-rust/src/agentic/tool_reflection.rs index 4fc87f2e99..dc722a66d4 100644 --- a/sdks/rust/golem-rust/src/agentic/tool_reflection.rs +++ b/sdks/rust/golem-rust/src/agentic/tool_reflection.rs @@ -224,7 +224,6 @@ impl ToolType { .body .as_ref() .expect("resolved body"); - let input_graph = Arc::new(input.record_schema.clone()); let mut arguments = Vec::with_capacity(input.fields.len()); let surfaces = self.definition.canonical_input_surfaces(index); input @@ -272,7 +271,7 @@ impl ToolType { name: field.name.clone(), aliases: field.aliases.clone(), short: field.short, - schema: SchemaRef::with_root(input_graph.clone(), field.type_.clone()), + schema: SchemaRef::with_root(self.schema.clone(), field.type_.clone()), required, default, }); @@ -297,7 +296,7 @@ impl ToolType { tool: self.clone(), index, path: canonical_path, - input: SchemaRef::with_root(input_graph, input.record_schema.root.clone()), + input: SchemaRef::with_root(self.schema.clone(), input.record_schema.root.clone()), wire_input: input.record_schema, arguments, }) @@ -1321,7 +1320,7 @@ mod tests { env_var: None, }); let command = tool.command(&["run"]).unwrap(); - assert_eq!(command.input_schema().graph(), &command.wire_input); + assert_eq!(command.input_schema().root(), &command.wire_input.root); for fields in [ vec![ SchemaValue::Option { inner: None }, @@ -1341,6 +1340,34 @@ mod tests { } } + #[test] + fn repeated_command_lookup_shares_the_discovered_definition_pool() { + let tool = sample(); + let first = tool.command(&["run"]).unwrap(); + let second = tool.command(&["r"]).unwrap(); + + assert!( + first + .input_schema() + .shares_definition_pool_with(second.input_schema()) + ); + assert!( + first + .input_schema() + .shares_definition_pool_with(&first.arguments()[0].schema) + ); + assert!( + first + .input_schema() + .shares_definition_pool_with(&first.output_schema().unwrap()) + ); + assert!( + !first + .input_schema() + .shares_definition_pool_with(&SchemaRef::new(first.wire_input.clone())) + ); + } + #[test] fn malformed_remote_output_is_distinct() { let command = sample().command(&["run"]).expect("command"); diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/Reflection.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/Reflection.scala index 29e7ca22ea..8567637e7c 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/Reflection.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/Reflection.scala @@ -206,10 +206,9 @@ final class AgentType private[reflection] ( ReflectionInternals.sequence(entries.map { entry => configDeclaration(entry.path).flatMap { declaration => declaration.schema - .packJson(entry.value) + .validateJson(entry.value) .left - .map(error => GolemReflectError.Validation(error.message)) - .flatMap(value => validate(declaration.schema, value).map(_ => value)) + .map(errors => GolemReflectError.Validation(errors.map(_.message).mkString("; "))) .map(value => ConfigOverride(entry.path, TypedSchemaValue(declaration.schema.graph, value))) } }) diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala index 2c51fcbc29..2ee8d20390 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -32,16 +32,19 @@ final class SchemaRef private (val graph: SchemaGraph, val root: SchemaType) { .map(_ => value) def validateJson(value: Json): Either[List[SchemaIssue], SchemaValue] = - packJson(value).left.map(error => List(error)).flatMap(validateValue) + packAndValidateJson(value) def packJson(value: Json): Either[SchemaIssue, SchemaValue] = - CanonicalJson.pack(graph, root, value) + packAndValidateJson(value).left.map(_.head) def unpackJson(value: SchemaValue): Either[SchemaIssue, Json] = validateValue(value).left.map(_.head).flatMap(_ => CanonicalJson.unpack(graph, root, value)) def toJsonSchema(includeDraftMarker: Boolean = true): Json = CanonicalJson.jsonSchema(graph, root, includeDraftMarker) + + private def packAndValidateJson(value: Json): Either[List[SchemaIssue], SchemaValue] = + CanonicalJson.pack(graph, root, value).left.map(error => List(error)).flatMap(validateValue) } object SchemaRef { @@ -51,8 +54,11 @@ object SchemaRef { new SchemaRef(SchemaGraph(graph.defs, root), root) } -private object CanonicalJson { - private val MaxSafeInteger = BigInt("9007199254740991") +private[reflection] object CanonicalJson { + private val SignedIntegerPattern = "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$".r + private val UnsignedIntegerPattern = "^(?:0|[1-9][0-9]*)$".r + private val MimeTypePattern = "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$".r + private val U64Max = (BigInt(1) << 64) - 1 def pack(graph: SchemaGraph, schema: SchemaType, json: Json): Either[SchemaIssue, SchemaValue] = attempt(packUnsafe(graph, resolve(graph, schema), json)) @@ -97,26 +103,63 @@ private object CanonicalJson { } typed("number", limits: _*) } + def restrictedWideBound( + bound: Option[NumericBound], + fallback: BigInt, + minimum: Boolean + ): BigInt = { + val value = bound.flatMap { + case NumericBound.Signed(value) => Some(BigInt(value)) + case NumericBound.Unsigned(value) => Some(BigInt(java.lang.Long.toUnsignedString(value))) + case NumericBound.FloatBits(_) => None + }.getOrElse(fallback) + if (minimum) value.max(fallback) else value.min(fallback) + } + def wideInteger(unsigned: Boolean, restrictions: Option[NumericRestrictions]): Json = { + val baseMinimum = if (unsigned) BigInt(0) else BigInt(Long.MinValue) + val baseMaximum = if (unsigned) U64Max else BigInt(Long.MaxValue) + typed( + "string", + "format" -> Json.String(if (unsigned) "uint64" else "int64"), + "pattern" -> Json.String( + if (unsigned) "^(?:0|[1-9][0-9]*)$" else "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$" + ), + "x-golem-minimum" -> Json.String( + restrictedWideBound(restrictions.flatMap(_.min), baseMinimum, minimum = true).toString + ), + "x-golem-maximum" -> Json.String( + restrictedWideBound(restrictions.flatMap(_.max), baseMaximum, minimum = false).toString + ) + ) + } + def isOption(schema: SchemaType, seen: Set[String] = Set.empty): Boolean = schema.body match { + case OptionType(_) => true + case RefType(id) if seen(id) => false + case RefType(id) => graph.defs.get(id).exists(definition => isOption(definition.body, seen + id)) + case _ => false + } schema.body match { case RefType(id) => Json.Object("$ref" -> Json.String(s"#/$$defs/${id.replace("~", "~0").replace("/", "~1")}")) case BoolType => typed("boolean") case S8Type(r) => integer(-128, 127, r) case S16Type(r) => integer(-32768, 32767, r) case S32Type(r) => integer(Int.MinValue, Int.MaxValue, r) - case S64Type(r) => integer(BigInt(Long.MinValue), BigInt(Long.MaxValue), r) + case S64Type(r) => wideInteger(unsigned = false, restrictions = r) case U8Type(r) => integer(0, 255, r) case U16Type(r) => integer(0, 65535, r) case U32Type(r) => integer(0, BigInt("4294967295"), r) - case U64Type(r) => integer(0, (BigInt(1) << 64) - 1, r) + case U64Type(r) => wideInteger(unsigned = true, restrictions = r) case F32Type(r) => decimal(r) case F64Type(r) => decimal(r) case CharType => typed("string", "minLength" -> number(1), "maxLength" -> number(1)) case StringType => typed("string") case RecordType(recordFields) => Json.Object( - "type" -> Json.String("object"), - "properties" -> Json.Object(recordFields.map(field => field.name -> schemaJson(graph, field.body)): _*), - "required" -> Json.Array(recordFields.map(field => Json.String(field.name)): _*), + "type" -> Json.String("object"), + "properties" -> Json.Object(recordFields.map(field => field.name -> schemaJson(graph, field.body)): _*), + "required" -> Json.Array( + recordFields.filterNot(field => isOption(field.body)).map(field => Json.String(field.name)): _* + ), "additionalProperties" -> Json.Boolean(false) ) case VariantType(cases) => @@ -195,27 +238,48 @@ private object CanonicalJson { restrictions.minBytes.map(value => "minLength" -> number(base64UrlLength(value))).toList ++ restrictions.maxBytes.map(value => "maxLength" -> number(base64UrlLength(value))).toList val mimeType = restrictions.mimeTypes match { - case Some(values) => typed("string", "enum" -> Json.Array(values.map(Json.String): _*)) - case None => typed("string") + case Some(values) => + typed( + "string", + "pattern" -> Json.String(MimeTypePattern.regex), + "enum" -> Json.Array(values.map(Json.String): _*) + ) + case None => typed("string", "pattern" -> Json.String(MimeTypePattern.regex)) } typed( "object", "properties" -> Json.Object( - "bytes" -> typed("string", ("contentEncoding" -> Json.String("base64url")) +: byteRestrictions: _*), + "bytes" -> typed( + "string", + ("contentEncoding" -> Json.String("base64url")) +: + ("pattern" -> Json.String( + "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$" + )) +: byteRestrictions: _* + ), "mimeType" -> mimeType ), "required" -> Json.Array(Json.String("bytes")), "additionalProperties" -> Json.Boolean(false) ) - case PathType(_) => typed("string", "format" -> Json.String("file-path")) - case UrlType(_) => typed("string", "format" -> Json.String("uri")) - case DatetimeType => typed("string", "format" -> Json.String("date-time")) - case DurationType => typed("string", "format" -> Json.String("duration")) + case PathType(_) => typed("string", "format" -> Json.String("file-path")) + case UrlType(_) => typed("string", "format" -> Json.String("uri")) + case DatetimeType => typed("string", "format" -> Json.String("date-time")) + case DurationType => + typed( + "object", + "properties" -> Json.Object("nanoseconds" -> wideInteger(unsigned = false, restrictions = None)), + "required" -> Json.Array(Json.String("nanoseconds")), + "additionalProperties" -> Json.Boolean(false), + "title" -> Json.String("Duration in nanoseconds") + ) case QuantityType(_) => typed( "object", - "properties" -> Json - .Object("mantissa" -> typed("integer"), "scale" -> typed("integer"), "unit" -> typed("string")), + "properties" -> Json.Object( + "mantissa" -> wideInteger(unsigned = false, restrictions = None), + "scale" -> typed("integer"), + "unit" -> typed("string") + ), "required" -> Json.Array(Json.String("mantissa"), Json.String("scale"), Json.String("unit")), "additionalProperties" -> Json.Boolean(false) ) @@ -223,11 +287,8 @@ private object CanonicalJson { Json.Object("oneOf" -> Json.Array(branches.map { branch => Json.Object("allOf" -> Json.Array(schemaJson(graph, branch.body), discriminatorJson(branch.discriminator))) }: _*)) - case SecretType(_) => Json.Object("x-golem-capability" -> Json.String("secret")) - case QuotaTokenType(_) => Json.Object("x-golem-capability" -> Json.String("quota-token")) - case PermissionCardType(_) => Json.Object("x-golem-capability" -> Json.String("permission-card")) - case FutureType(_) => Json.Object("x-golem-unsupported" -> Json.String("future")) - case StreamType(_) => Json.Object("x-golem-unsupported" -> Json.String("stream")) + case SecretType(_) | QuotaTokenType(_) | PermissionCardType(_) | FutureType(_) | StreamType(_) => + Json.Object("not" -> Json.Object()) } } @@ -294,9 +355,14 @@ private object CanonicalJson { result } - private def safeLong(json: Json, unsigned: Boolean): Long = { - val min = if (unsigned) BigInt(0) else -MaxSafeInteger - val value = integral(json, min, MaxSafeInteger) + private def canonicalLong(json: Json, unsigned: Boolean): Long = { + val text = string(json) + val pattern = if (unsigned) UnsignedIntegerPattern else SignedIntegerPattern + if (!pattern.pattern.matcher(text).matches()) fail("expected a canonical decimal integer string") + val value = BigInt(text) + val min = if (unsigned) BigInt(0) else BigInt(Long.MinValue) + val max = if (unsigned) U64Max else BigInt(Long.MaxValue) + if (value < min || value > max) fail(s"integer is outside [$min, $max]") value.toLong } @@ -306,11 +372,11 @@ private object CanonicalJson { case S8Type(_) => S8Value(integral(json, -128, 127).toByte) case S16Type(_) => S16Value(integral(json, -32768, 32767).toShort) case S32Type(_) => S32Value(integral(json, Int.MinValue, Int.MaxValue).toInt) - case S64Type(_) => S64Value(safeLong(json, unsigned = false)) + case S64Type(_) => S64Value(canonicalLong(json, unsigned = false)) case U8Type(_) => U8Value(integral(json, 0, 255).toInt) case U16Type(_) => U16Value(integral(json, 0, 65535).toInt) case U32Type(_) => U32Value(integral(json, 0, BigInt("4294967295")).toLong) - case U64Type(_) => U64Value(safeLong(json, unsigned = true)) + case U64Type(_) => U64Value(canonicalLong(json, unsigned = true)) case F32Type(_) => val value = decimal(json).toFloat if (!java.lang.Float.isFinite(value)) fail("number is outside the f32 range") @@ -328,13 +394,17 @@ private object CanonicalJson { val jsonFields = fields(json) jsonFields.keys.find(name => !expected.exists(_.name == name)).foreach(name => fail(s"unknown field '$name'")) RecordValue( - expected.map(field => - packUnsafe( - graph, - resolve(graph, field.body), - jsonFields.getOrElse(field.name, fail(s"missing field '${field.name}'")) - ) - ) + expected.map { field => + val resolved = resolve(graph, field.body) + jsonFields.get(field.name) match { + case Some(value) => packUnsafe(graph, resolved, value) + case None => + resolved.body match { + case OptionType(_) => OptionValue(None) + case _ => fail(s"missing field '${field.name}'") + } + } + } ) case VariantType(cases) => json match { @@ -397,27 +467,36 @@ private object CanonicalJson { ResultValue(if (side == "ok") SchemaResult.Ok(packed) else SchemaResult.Err(packed)) case TextType(_) => val jsonFields = fields(json) + if (!jsonFields.keySet.subsetOf(Set("text", "language"))) fail("text JSON contains unknown fields") TextValue( string(jsonFields.getOrElse("text", fail("missing field 'text'"))), jsonFields.get("language").map(string) ) case BinaryType(_) => val jsonFields = fields(json) + if (!jsonFields.keySet.subsetOf(Set("bytes", "mimeType"))) fail("binary JSON contains unknown fields") + val mimeType = jsonFields.get("mimeType").map(string) + mimeType.foreach(value => if (!MimeTypePattern.pattern.matcher(value).matches()) fail("invalid MIME type")) BinaryValue( decodeBase64Url(string(jsonFields.getOrElse("bytes", fail("missing field 'bytes'")))), - jsonFields.get("mimeType").map(string) + mimeType ) case PathType(_) => PathValue(string(json)) case UrlType(_) => UrlValue(string(json)) case DatetimeType => val instant = java.time.Instant.parse(string(json)) DatetimeValue(Datetime(instant.getEpochSecond, instant.getNano)) - case DurationType => DurationValue(decodeDuration(string(json))) + case DurationType => + val jsonFields = fields(json) + if (jsonFields.keySet != Set("nanoseconds")) fail("duration JSON requires only 'nanoseconds'") + DurationValue(canonicalLong(jsonFields("nanoseconds"), unsigned = false)) case QuantityType(_) => val jsonFields = fields(json) + if (jsonFields.keySet != Set("mantissa", "scale", "unit")) + fail("quantity JSON requires exactly 'mantissa', 'scale', and 'unit'") QuantityValueNode( QuantityValue( - safeLong(jsonFields("mantissa"), unsigned = false), + canonicalLong(jsonFields("mantissa"), unsigned = false), integral(jsonFields("scale"), Int.MinValue, Int.MaxValue).toInt, string(jsonFields("unit")) ) @@ -436,15 +515,16 @@ private object CanonicalJson { private def unpackUnsafe(graph: SchemaGraph, schema: SchemaType, value: SchemaValue): Json = (schema.body, value) match { - case (BoolType, BoolValue(x)) => Json.Boolean(x) - case (S8Type(_), S8Value(x)) => number(BigDecimal(x)) - case (S16Type(_), S16Value(x)) => number(BigDecimal(x)) - case (S32Type(_), S32Value(x)) => number(BigDecimal(x)) - case (S64Type(_), S64Value(x)) if BigInt(x).abs <= MaxSafeInteger => number(BigDecimal(x)) - case (U8Type(_), U8Value(x)) => number(BigDecimal(x)) - case (U16Type(_), U16Value(x)) => number(BigDecimal(x)) - case (U32Type(_), U32Value(x)) => number(BigDecimal(x)) - case (U64Type(_), U64Value(x)) if x >= 0 && BigInt(x) <= MaxSafeInteger => number(BigDecimal(x)) + case (BoolType, BoolValue(x)) => Json.Boolean(x) + case (S8Type(_), S8Value(x)) => number(BigDecimal(x)) + case (S16Type(_), S16Value(x)) => number(BigDecimal(x)) + case (S32Type(_), S32Value(x)) => number(BigDecimal(x)) + case (S64Type(_), S64Value(x)) => Json.String(x.toString) + case (U8Type(_), U8Value(x)) => number(BigDecimal(x)) + case (U16Type(_), U16Value(x)) => number(BigDecimal(x)) + case (U32Type(_), U32Value(x)) => number(BigDecimal(x)) + case (U64Type(_), U64Value(x)) => + Json.String(java.lang.Long.toUnsignedString(x)) case (F32Type(_), F32Value(x)) => number(BigDecimal.decimal(x)) case (F64Type(_), F64Value(x)) => number(BigDecimal(x)) case (CharType, CharValue(x)) => Json.String(new String(Character.toChars(x))) @@ -503,6 +583,7 @@ private object CanonicalJson { case (TextType(_), TextValue(text, language)) => Json.Object((List("text" -> Json.String(text)) ++ language.map(value => "language" -> Json.String(value))): _*) case (BinaryType(_), BinaryValue(bytes, mimeType)) => + mimeType.foreach(value => if (!MimeTypePattern.pattern.matcher(value).matches()) fail("invalid MIME type")) Json.Object( (List("bytes" -> Json.String(encodeBase64Url(bytes))) ++ mimeType.map(value => "mimeType" -> Json.String(value) @@ -512,10 +593,11 @@ private object CanonicalJson { case (UrlType(_), UrlValue(x)) => Json.String(x) case (DatetimeType, DatetimeValue(x)) => Json.String(java.time.Instant.ofEpochSecond(x.seconds, x.nanoseconds.toLong).toString) - case (DurationType, DurationValue(x)) => Json.String(encodeDuration(x)) + case (DurationType, DurationValue(x)) => + Json.Object("nanoseconds" -> Json.String(x.toString)) case (QuantityType(_), QuantityValueNode(x)) => Json.Object( - "mantissa" -> number(BigDecimal(x.mantissa)), + "mantissa" -> Json.String(x.mantissa.toString), "scale" -> number(BigDecimal(x.scale)), "unit" -> Json.String(x.unit) ) @@ -597,50 +679,9 @@ private object CanonicalJson { if (index + 3 < value.length) result += (((c & 3) << 6) | d).toByte index += 4 } - result.result() - } - - private val DurationPattern = - "^(-)?P(?:(\\d+)D)?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)(?:\\.(\\d{1,9}))?S)?)?$".r - - private def decodeDuration(value: String): Long = value match { - case DurationPattern(sign, days, hours, minutes, seconds, fraction) => - if (List(days, hours, minutes, seconds).forall(_ == null)) fail("expected an ISO 8601 duration") - def amount(raw: String): BigInt = if (raw == null) BigInt(0) else BigInt(raw) - val fractional = Option(fraction).fold(BigInt(0))(raw => BigInt(raw.padTo(9, '0').mkString)) - val nanos = amount(days) * 86400000000000L + amount(hours) * 3600000000000L + - amount(minutes) * 60000000000L + amount(seconds) * 1000000000L + fractional - val signed = if (sign == null) nanos else -nanos - if (!signed.isValidLong) fail("duration nanoseconds out of i64 range") - signed.toLong - case _ => fail("expected an ISO 8601 duration") + val decoded = result.result() + if (encodeBase64Url(decoded) != value) fail("invalid base64url without padding") + decoded } - private def encodeDuration(nanoseconds: Long): String = - if (nanoseconds == 0) "PT0S" - else { - val negative = nanoseconds < 0 - var remaining = BigInt(nanoseconds).abs - val days = remaining / 86400000000000L - remaining %= 86400000000000L - val hours = remaining / 3600000000000L - remaining %= 3600000000000L - val minutes = remaining / 60000000000L - remaining %= 60000000000L - val seconds = remaining / 1000000000L - val nanos = remaining % 1000000000L - val result = new StringBuilder(if (negative) "-P" else "P") - if (days != 0) result.append(days).append('D') - if (hours != 0 || minutes != 0 || seconds != 0 || nanos != 0) { - result.append('T') - if (hours != 0) result.append(hours).append('H') - if (minutes != 0) result.append(minutes).append('M') - if (seconds != 0 || nanos != 0) { - result.append(seconds) - if (nanos != 0) result.append('.').append(f"${nanos.toLong}%09d".reverse.dropWhile(_ == '0').reverse) - result.append('S') - } - } - result.result() - } } diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/ToolReflection.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/ToolReflection.scala index f66a82e882..cdc12e3e81 100644 --- a/sdks/scala/core/js/src/main/scala/golem/reflection/ToolReflection.scala +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/ToolReflection.scala @@ -292,15 +292,9 @@ final class ToolCommand private[reflection] ( def packJson(input: Json): Either[ToolError[Nothing], SchemaValue] = try inputSchema - .packJson(input) + .validateJson(input) .left - .map(issue => ToolError.InvalidInput(issue.message)) - .flatMap(value => - inputSchema - .validateValue(value) - .left - .map(issues => ToolError.InvalidInput(issues.map(_.message).mkString("; "))) - ) + .map(issues => ToolError.InvalidInput(issues.map(_.message).mkString("; "))) .flatMap(validateConstraints) catch { case NonFatal(error) => Left(ToolError.InvalidInput(Option(error.getMessage).getOrElse(error.toString))) } diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala new file mode 100644 index 0000000000..31ea573971 --- /dev/null +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceCorpus.scala @@ -0,0 +1,713 @@ +/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +// Generated by scripts/sync-reflection-conformance.mjs. Do not edit. +private[reflection] object ReflectionConformanceCorpus { + val json: String = """{ + "version": "1.0.0", + "schemaKinds": [ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ], + "restrictionKinds": [ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ], + "caseIds": [ + "canonical/s64-min", + "canonical/s64-max", + "canonical/u64-max", + "canonical/duration-min", + "canonical/duration-max", + "canonical/quantity-wide-mantissa", + "canonical/omitted-options", + "canonical/binary-mime", + "canonical/constrained-text-without-language", + "canonical/constrained-binary-without-mime", + "errors/s64-plus", + "errors/s64-leading-zero", + "errors/s64-negative-zero", + "errors/s64-overflow", + "errors/u64-negative", + "errors/u64-overflow", + "errors/wide-wrong-json-type", + "errors/duration-legacy-string", + "errors/quantity-negative-zero", + "errors/quantity-extra-field", + "errors/binary-noncanonical-base64", + "errors/binary-invalid-mime", + "errors/text-extra-field", + "json-schema/s64", + "json-schema/s64-restrictions", + "json-schema/u32-restrictions", + "json-schema/f64-restrictions", + "json-schema/text-restrictions", + "json-schema/binary-restrictions", + "json-schema/u64", + "json-schema/duration-nanoseconds", + "json-schema/quantity-mantissa", + "json-schema/optional-required", + "json-schema/unsupported-leaves", + "schema/all-kinds", + "schema/all-restrictions", + "graph/references-validation-equivalence", + "tool/canonical-input", + "config/canonical-entry", + "constraints/rejected-value", + "compatibility/result", + "compatibility/custom-error" + ], + "cases": [ + { + "id": "canonical/s64-min", + "operation": "roundtrip", + "fixture": "s64", + "input": "-9223372036854775808", + "expected": "-9223372036854775808" + }, + { + "id": "canonical/s64-max", + "operation": "roundtrip", + "fixture": "s64", + "input": "9223372036854775807", + "expected": "9223372036854775807" + }, + { + "id": "canonical/u64-max", + "operation": "roundtrip", + "fixture": "u64", + "input": "18446744073709551615", + "expected": "18446744073709551615" + }, + { + "id": "canonical/duration-min", + "operation": "roundtrip", + "fixture": "duration", + "input": { + "nanoseconds": "-9223372036854775808" + }, + "expected": { + "nanoseconds": "-9223372036854775808" + } + }, + { + "id": "canonical/duration-max", + "operation": "roundtrip", + "fixture": "duration", + "input": { + "nanoseconds": "9223372036854775807" + }, + "expected": { + "nanoseconds": "9223372036854775807" + } + }, + { + "id": "canonical/quantity-wide-mantissa", + "operation": "roundtrip", + "fixture": "quantity", + "input": { + "mantissa": "-9223372036854775808", + "scale": -9, + "unit": "m" + }, + "expected": { + "mantissa": "-9223372036854775808", + "scale": -9, + "unit": "m" + } + }, + { + "id": "canonical/omitted-options", + "operation": "roundtrip", + "fixture": "optional-record", + "input": {}, + "expected": { + "direct": null, + "referenced": null + } + }, + { + "id": "canonical/binary-mime", + "operation": "roundtrip", + "fixture": "binary", + "input": { + "bytes": "-_8", + "mimeType": "application/octet-stream" + }, + "expected": { + "bytes": "-_8", + "mimeType": "application/octet-stream" + } + }, + { + "id": "canonical/constrained-text-without-language", + "operation": "roundtrip", + "fixture": "constrained-text", + "input": { + "text": "hello" + }, + "expected": { + "text": "hello" + } + }, + { + "id": "canonical/constrained-binary-without-mime", + "operation": "roundtrip", + "fixture": "constrained-binary", + "input": { + "bytes": "AQI" + }, + "expected": { + "bytes": "AQI" + } + }, + { + "id": "errors/s64-plus", + "operation": "reject", + "fixture": "s64", + "input": "+1", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-leading-zero", + "operation": "reject", + "fixture": "s64", + "input": "01", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-negative-zero", + "operation": "reject", + "fixture": "s64", + "input": "-0", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/s64-overflow", + "operation": "reject", + "fixture": "s64", + "input": "9223372036854775808", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/u64-negative", + "operation": "reject", + "fixture": "u64", + "input": "-1", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/u64-overflow", + "operation": "reject", + "fixture": "u64", + "input": "18446744073709551616", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/wide-wrong-json-type", + "operation": "reject", + "fixture": "s64", + "input": 1, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/duration-legacy-string", + "operation": "reject", + "fixture": "duration", + "input": "PT1S", + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/quantity-negative-zero", + "operation": "reject", + "fixture": "quantity", + "input": { + "mantissa": "-0", + "scale": 0, + "unit": "m" + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/quantity-extra-field", + "operation": "reject", + "fixture": "quantity", + "input": { + "mantissa": "1", + "scale": 0, + "unit": "m", + "extra": true + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/binary-noncanonical-base64", + "operation": "reject", + "fixture": "binary", + "inputs": [ + { + "bytes": "+/8", + "mimeType": "application/octet-stream" + }, + { + "bytes": "-_8=", + "mimeType": "application/octet-stream" + }, + { + "bytes": "-_9", + "mimeType": "application/octet-stream" + }, + { + "bytes": "A", + "mimeType": "application/octet-stream" + }, + { + "bytes": "-_8", + "mime_type": "application/octet-stream" + } + ], + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/binary-invalid-mime", + "operation": "reject", + "fixture": "binary", + "input": { + "bytes": "AQ", + "mimeType": "not a mime" + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "errors/text-extra-field", + "operation": "reject", + "fixture": "constrained-text", + "input": { + "text": "hello", + "extra": true + }, + "expected": { + "kind": "invalid-json" + } + }, + { + "id": "json-schema/s64", + "operation": "json-schema", + "fixture": "s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/s64-restrictions", + "operation": "json-schema", + "fixture": "constrained-s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9007199254740993", + "x-golem-maximum": "9007199254740993" + } + }, + { + "id": "json-schema/u64", + "operation": "json-schema", + "fixture": "u64", + "path": "", + "expected": { + "type": "string", + "format": "uint64", + "pattern": "^(?:0|[1-9][0-9]*)$", + "x-golem-minimum": "0", + "x-golem-maximum": "18446744073709551615" + } + }, + { + "id": "json-schema/u32-restrictions", + "operation": "json-schema", + "fixture": "constrained-u32", + "path": "", + "expected": { + "type": "integer", + "minimum": 2, + "maximum": 10 + } + }, + { + "id": "json-schema/f64-restrictions", + "operation": "json-schema", + "fixture": "constrained-f64", + "path": "", + "expected": { + "type": "number", + "minimum": -1.5, + "maximum": 2.5 + } + }, + { + "id": "json-schema/text-restrictions", + "operation": "json-schema", + "fixture": "constrained-text", + "path": "", + "expected": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "pattern": "^[a-z]+$" + }, + "language": { + "type": "string", + "enum": [ + "en", + "de" + ] + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + }, + { + "id": "json-schema/binary-restrictions", + "operation": "json-schema", + "fixture": "constrained-binary", + "path": "", + "expected": { + "type": "object", + "properties": { + "bytes": { + "type": "string", + "contentEncoding": "base64url", + "pattern": "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + "minLength": 3, + "maxLength": 6 + }, + "mimeType": { + "type": "string", + "pattern": "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + "enum": [ + "image/png", + "application/octet-stream" + ] + } + }, + "required": [ + "bytes" + ], + "additionalProperties": false + } + }, + { + "id": "json-schema/duration-nanoseconds", + "operation": "json-schema", + "fixture": "duration", + "path": "/properties/nanoseconds", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/quantity-mantissa", + "operation": "json-schema", + "fixture": "quantity", + "path": "/properties/mantissa", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/optional-required", + "operation": "json-schema", + "fixture": "optional-record", + "path": "/required", + "expected": [] + }, + { + "id": "json-schema/unsupported-leaves", + "operation": "semantic", + "fixture": "unsupported-leaves", + "expected": { + "count": 5, + "schema": { + "not": {} + } + } + }, + { + "id": "schema/all-kinds", + "operation": "semantic", + "fixture": "all-kinds", + "expected": { + "names": [ + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ] + } + }, + { + "id": "schema/all-restrictions", + "operation": "semantic", + "fixture": "all-restrictions", + "expected": { + "names": [ + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ] + } + }, + { + "id": "graph/references-validation-equivalence", + "operation": "semantic", + "fixture": "graph", + "expected": { + "valid": true, + "equivalent": true + } + }, + { + "id": "tool/canonical-input", + "operation": "roundtrip", + "fixture": "tool-input", + "input": { + "pattern": "*.rs", + "paths": [ + "src", + "tests" + ] + }, + "expected": { + "pattern": "*.rs", + "paths": [ + "src", + "tests" + ], + "ignoreCase": null + } + }, + { + "id": "config/canonical-entry", + "operation": "roundtrip", + "fixture": "config-entry", + "input": { + "path": [ + "limits", + "maximum" + ], + "value": "9223372036854775807" + }, + "expected": { + "path": [ + "limits", + "maximum" + ], + "value": "9223372036854775807" + } + }, + { + "id": "constraints/rejected-value", + "operation": "reject", + "fixture": "constrained-u32", + "input": 11, + "expected": { + "kind": "constraint-violation" + } + }, + { + "id": "compatibility/result", + "operation": "roundtrip", + "fixture": "result", + "input": { + "ok": "ready" + }, + "expected": { + "ok": "ready" + } + }, + { + "id": "compatibility/custom-error", + "operation": "roundtrip", + "fixture": "custom-error", + "input": { + "err": { + "code": "denied", + "retryable": false + } + }, + "expected": { + "err": { + "code": "denied", + "retryable": false + } + } + } + ] +} +""" +} diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala new file mode 100644 index 0000000000..7338448683 --- /dev/null +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/ReflectionConformanceSpec.scala @@ -0,0 +1,357 @@ +/* + * Copyright 2024-2026 Golem Cloud + * + * Licensed under the Golem Source License v1.1 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://license.golem.cloud/LICENSE + */ + +package golem.reflection + +import golem.schema.* +import golem.schema.SchemaTypeBody.* +import golem.schema.validation.WellFormedness +import zio.blocks.schema.json.Json +import zio.test.* + +import scala.collection.immutable.ListMap + +object ReflectionConformanceSpec extends ZIOSpecDefault { + private def fields(value: Json): ListMap[String, Json] = value match { + case Json.Object(values) => ListMap(values.toList*) + case other => throw new AssertionError(s"expected JSON object, got $other") + } + + private def elements(value: Json): List[Json] = value match { + case Json.Array(values) => values.toList + case other => throw new AssertionError(s"expected JSON array, got $other") + } + + private def string(value: Json): String = value match { + case Json.String(value) => value + case other => throw new AssertionError(s"expected JSON string, got $other") + } + + private def count(value: Json): Int = value match { + case Json.Number(value) => value.toInt + case other => throw new AssertionError(s"expected JSON number, got $other") + } + + private def boolean(value: Json): Boolean = value match { + case Json.Boolean(value) => value + case other => throw new AssertionError(s"expected JSON boolean, got $other") + } + + private def field(name: String, body: SchemaType): NamedFieldType = NamedFieldType(name, body) + + private def fixture(name: String): SchemaRef = { + val root = name match { + case "s64" => SchemaType(S64Type()) + case "constrained-s64" => + SchemaType( + S64Type( + Some( + NumericRestrictions( + min = Some(NumericBound.Signed(-9007199254740993L)), + max = Some(NumericBound.Signed(9007199254740993L)) + ) + ) + ) + ) + case "u64" => SchemaType(U64Type()) + case "binary" => SchemaType(BinaryType(BinaryRestrictions.empty)) + case "duration" => SchemaType(DurationType) + case "quantity" => SchemaType(QuantityType(QuantitySpec("m", Nil, None, None))) + case "tool-input" => + SchemaType( + RecordType( + List( + field("pattern", SchemaType(StringType)), + field("paths", SchemaType(ListType(SchemaType(StringType)))), + field("ignoreCase", SchemaType(OptionType(SchemaType(BoolType)))) + ) + ) + ) + case "config-entry" => + SchemaType( + RecordType( + List( + field("path", SchemaType(ListType(SchemaType(StringType)))), + field("value", SchemaType(S64Type())) + ) + ) + ) + case "constrained-u32" => + SchemaType( + U32Type( + Some( + NumericRestrictions( + min = Some(NumericBound.Unsigned(2)), + max = Some(NumericBound.Unsigned(10)) + ) + ) + ) + ) + case "constrained-f64" => + SchemaType( + F64Type( + Some( + NumericRestrictions( + min = Some(NumericBound.FloatBits(java.lang.Double.doubleToRawLongBits(-1.5))), + max = Some(NumericBound.FloatBits(java.lang.Double.doubleToRawLongBits(2.5))) + ) + ) + ) + ) + case "constrained-text" => + SchemaType( + TextType( + TextRestrictions( + languages = Some(List("en", "de")), + minLength = Some(2), + maxLength = Some(8), + regex = Some("^[a-z]+$") + ) + ) + ) + case "constrained-binary" => + SchemaType( + BinaryType( + BinaryRestrictions( + mimeTypes = Some(List("image/png", "application/octet-stream")), + minBytes = Some(2), + maxBytes = Some(4) + ) + ) + ) + case "result" => + SchemaType(ResultType(Some(SchemaType(StringType)), Some(SchemaType(U32Type())))) + case "custom-error" => + SchemaType( + ResultType( + Some(SchemaType(StringType)), + Some( + SchemaType( + RecordType( + List( + field("code", SchemaType(StringType)), + field("retryable", SchemaType(BoolType)) + ) + ) + ) + ) + ) + ) + case "optional-record" => + val optional = SchemaType(OptionType(SchemaType(StringType))) + return SchemaRef( + SchemaGraph( + ListMap("conformance.optional" -> SchemaTypeDef(optional)), + SchemaType( + RecordType( + List( + field("direct", optional), + field("referenced", SchemaType(RefType("conformance.optional"))) + ) + ) + ) + ) + ) + case other => throw new AssertionError(s"unknown conformance fixture $other") + } + SchemaRef(SchemaGraph(ListMap.empty, root)) + } + + private def atPointer(value: Json, pointer: String): Json = + if (pointer.isEmpty) value + else + pointer + .stripPrefix("/") + .split('/') + .foldLeft(value) { (current, part) => + fields(current).getOrElse( + part.replace("~1", "/").replace("~0", "~"), + throw new AssertionError(s"missing JSON pointer $pointer in $value") + ) + } + + private def assertSubset(actual: Json, expected: Json): Unit = expected match { + case Json.Object(expectedFields) => + val actualFields = fields(actual) + expectedFields.toList.foreach { case (name, value) => + assertSubset( + actualFields.getOrElse(name, throw new AssertionError(s"missing $name in $actual")), + value + ) + } + case _ => Predef.assert(actual == expected, s"expected $expected, got $actual") + } + + private def assertSemantic(name: String, expected: Json, corpus: Json): Unit = name match { + case "unsupported-leaves" => + val unsupported = List[SchemaTypeBody]( + SecretType(SecretSpec(SchemaType(StringType), None)), + QuotaTokenType(QuotaTokenSpec(None)), + PermissionCardType(PermissionCardSpec(polymorphic = false)), + FutureType(None), + StreamType(None) + ) + Predef.assert(unsupported.size == count(fields(expected)("count"))) + unsupported.foreach { body => + assertSubset( + SchemaRef(SchemaGraph(ListMap.empty, SchemaType(body))).toJsonSchema(false), + fields(expected)("schema") + ) + } + case "all-kinds" => + val supported = List( + "ref", + "bool", + "s8", + "s16", + "s32", + "s64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "char", + "string", + "record", + "variant", + "enum", + "flags", + "tuple", + "list", + "fixed-list", + "map", + "option", + "result", + "text", + "binary", + "path", + "url", + "datetime", + "duration", + "quantity", + "union", + "secret", + "quota-token", + "permission-card", + "future", + "stream" + ) + val expectedNames = elements(fields(expected)("names")).map(string) + Predef.assert(supported == expectedNames) + Predef.assert(elements(fields(corpus)("schemaKinds")).map(string) == expectedNames) + case "all-restrictions" => + val supported = List( + "numeric-minimum", + "numeric-maximum", + "numeric-unit", + "text-languages", + "text-min-length", + "text-max-length", + "text-regex", + "binary-mime-types", + "binary-min-bytes", + "binary-max-bytes", + "path-direction", + "path-kind", + "path-mime-types", + "path-extensions", + "url-schemes", + "url-hosts", + "quantity-base-unit", + "quantity-suffixes", + "quantity-minimum", + "quantity-maximum", + "union-prefix", + "union-suffix", + "union-regex", + "union-field" + ) + val expectedNames = elements(fields(expected)("names")).map(string) + Predef.assert(supported == expectedNames) + Predef.assert(elements(fields(corpus)("restrictionKinds")).map(string) == expectedNames) + case "graph" => + val expectedFields = fields(expected) + val referenced = fixture("optional-record") + val inline = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType( + RecordType( + List( + field("direct", SchemaType(OptionType(SchemaType(StringType)))), + field("referenced", SchemaType(OptionType(SchemaType(StringType)))) + ) + ) + ) + ) + ) + val valid = WellFormedness.validateGraph(referenced.graph).isRight + val equivalent = referenced.packJson(Json.Object()) == inline.packJson(Json.Object()) + Predef.assert(valid == boolean(expectedFields("valid"))) + Predef.assert(equivalent == boolean(expectedFields("equivalent"))) + case other => throw new AssertionError(s"unknown semantic conformance fixture $other") + } + + private val corpus = Json.parse(ReflectionConformanceCorpus.json).fold(throw _, identity) + private val corpusRoot = fields(corpus) + private val corpusCases = elements(corpusRoot("cases")) + + private def conformanceTest(testCaseJson: Json) = { + val testCase = fields(testCaseJson) + val id = string(testCase("id")) + test(id) { + val name = string(testCase("fixture")) + string(testCase("operation")) match { + case "roundtrip" => + val schema = fixture(name) + val packed = + schema.packJson(testCase("input")).fold(error => throw new AssertionError(s"$id: $error"), identity) + Predef.assert(schema.unpackJson(packed) == Right(testCase("expected")), id) + case "reject" => + val inputs = testCase.get("inputs").map(elements).getOrElse(List(testCase("input"))) + inputs.foreach { input => + val schema = fixture(name) + Predef.assert(schema.packJson(input).isLeft, s"$id: direct packing accepted $input") + val actual = CanonicalJson.pack(schema.graph, schema.root, input) match { + case Left(_) => "invalid-json" + case Right(value) if schema.validateValue(value).isLeft => "constraint-violation" + case Right(value) => throw new AssertionError(s"$id accepted $input as $value") + } + Predef.assert(actual == string(fields(testCase("expected"))("kind")), id) + } + case "json-schema" => + assertSubset( + atPointer(fixture(name).toJsonSchema(false), string(testCase("path"))), + testCase("expected") + ) + case "semantic" => assertSemantic(name, testCase("expected"), corpus) + case operation => throw new AssertionError(s"unknown conformance operation $operation for $id") + } + assertTrue(true) + } + } + + override def spec = suite("reflection conformance corpus")( + (test("has a valid version, unique declared case IDs, and recognized operations") { + val ids = corpusCases.map(testCase => string(fields(testCase)("id"))) + val declared = elements(corpusRoot("caseIds")).map(string) + val operations = Set("roundtrip", "reject", "json-schema", "semantic") + assertTrue( + string(corpusRoot("version")) == "1.0.0", + ids.distinct.size == ids.size, + declared.distinct.size == declared.size, + ids.toSet == declared.toSet, + corpusCases.forall(testCase => operations.contains(string(fields(testCase)("operation")))) + ) + } :: corpusCases.map(conformanceTest))* + ) +} diff --git a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala index 4c717c28aa..0e77a223c4 100644 --- a/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -113,28 +113,78 @@ object SchemaRefSpec extends ZIOSpecDefault { ), NamedFieldType( "message", - SchemaType(TextType(TextRestrictions(minLength = Some(12), regex = Some("^https://")))) + SchemaType( + TextType( + TextRestrictions( + languages = Some(List("en", "de")), + minLength = Some(12), + regex = Some("^https://") + ) + ) + ) ), NamedFieldType( "content", - SchemaType(BinaryType(BinaryRestrictions(minBytes = Some(3), maxBytes = Some(6)))) + SchemaType( + BinaryType( + BinaryRestrictions( + mimeTypes = Some(List("image/png")), + minBytes = Some(3), + maxBytes = Some(6) + ) + ) + ) ) ) ) ) ) ) - val rendered = restricted.toJsonSchema() - val properties = rendered.get("properties").one.toOption.get - val count = properties.get("count").one.toOption.get - val text = properties.get("message").one.toOption.get.get("properties").one.toOption.get.get("text").one - val bytes = properties.get("content").one.toOption.get.get("properties").one.toOption.get.get("bytes").one + val rendered = restricted.toJsonSchema() + val validJson = Json.Object( + "count" -> Json.Number(BigDecimal(3)), + "message" -> Json.Object("text" -> Json.String("https://golem"), "language" -> Json.String("en")), + "content" -> Json.Object("bytes" -> Json.String("AQID"), "mimeType" -> Json.String("image/png")) + ) + val invalidJson = Json.Object( + "count" -> Json.Number(BigDecimal(4)), + "message" -> Json.Object("text" -> Json.String("https://golem"), "language" -> Json.String("en")), + "content" -> Json.Object("bytes" -> Json.String("AQID"), "mimeType" -> Json.String("image/png")) + ) + val properties = rendered.get("properties").one.toOption.get + val count = properties.get("count").one.toOption.get + val textSchema = properties.get("message").one.toOption.get + val textProperties = textSchema.get("properties").one.toOption.get + val text = textProperties.get("text").one + val binarySchema = properties.get("content").one.toOption.get + val binaryProperties = binarySchema.get("properties").one.toOption.get + val bytes = binaryProperties.get("bytes").one + val mimeType = binaryProperties.get("mimeType").one + val pattern = bytes.flatMap(_.get("pattern").one) match { + case Right(Json.String(value)) => value + case other => throw new AssertionError(s"expected binary pattern, got $other") + } + val canonical = List("", "AQ", "AQI", "AQID", "-_8").forall(value => pattern.r.pattern.matcher(value).matches()) + val nonCanonical = List("+/8", "AQ==", "-_9", "A").forall(value => !pattern.r.pattern.matcher(value).matches()) assertTrue( + restricted.packJson(validJson).isRight, + restricted.packJson(invalidJson).isLeft, + restricted.validateJson(invalidJson).isLeft, count.get("maximum").one == Right(Json.Number(BigDecimal(3))), text.flatMap(_.get("minLength").one) == Right(Json.Number(BigDecimal(12))), text.flatMap(_.get("pattern").one) == Right(Json.String("^https://")), + textProperties.get("language").one.flatMap(_.get("enum").one) == + Right(Json.Array(Json.String("en"), Json.String("de"))), + textSchema.get("description").one.isLeft, bytes.flatMap(_.get("minLength").one) == Right(Json.Number(BigDecimal(4))), - bytes.flatMap(_.get("maxLength").one) == Right(Json.Number(BigDecimal(8))) + bytes.flatMap(_.get("maxLength").one) == Right(Json.Number(BigDecimal(8))), + pattern == "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + canonical, + nonCanonical, + mimeType.flatMap(_.get("pattern").one) == + Right(Json.String("^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$")), + mimeType.flatMap(_.get("enum").one) == Right(Json.Array(Json.String("image/png"))), + binarySchema.get("description").one.isLeft ) }, test("union export keeps discriminator and branch body while packing enforces the rule") { @@ -171,19 +221,140 @@ object SchemaRefSpec extends ZIOSpecDefault { union.packJson(Json.String("http://host")).isLeft ) }, - test("requires explicit null for an absent option and renders it as required") { + test("decodes omitted and explicit-null option fields as absent") { val optional = SchemaRef( + SchemaGraph( + ListMap("maybe-ref" -> SchemaTypeDef(SchemaType(OptionType(SchemaType(StringType))))), + SchemaType( + RecordType( + List( + NamedFieldType("maybe", SchemaType(OptionType(SchemaType(StringType)))), + NamedFieldType("referenced", SchemaType(RefType("maybe-ref"))) + ) + ) + ) + ) + ) + val absent = RecordValue(List(OptionValue(None), OptionValue(None))) + val malformedRefs = SchemaRef( + SchemaGraph( + ListMap( + "cycle-a" -> SchemaTypeDef(SchemaType(RefType("cycle-b"))), + "cycle-b" -> SchemaTypeDef(SchemaType(RefType("cycle-a"))) + ), + SchemaType( + RecordType( + List( + NamedFieldType("dangling", SchemaType(RefType("missing"))), + NamedFieldType("cycle", SchemaType(RefType("cycle-a"))) + ) + ) + ) + ) + ) + assertTrue( + optional.packJson(Json.Object()) == Right(absent), + optional.packJson(Json.Object("maybe" -> Json.Null, "referenced" -> Json.Null)) == Right(absent), + optional.toJsonSchema().get("required").one == Right(Json.Array()), + malformedRefs.toJsonSchema().get("required").one == + Right(Json.Array(Json.String("dangling"), Json.String("cycle"))) + ) + }, + test("uses lossless canonical JSON for wide integers, durations, and quantities") { + val wide = SchemaRef( SchemaGraph( ListMap.empty, - SchemaType(RecordType(List(NamedFieldType("maybe", SchemaType(OptionType(SchemaType(StringType))))))) + SchemaType( + RecordType( + List( + NamedFieldType("signed", SchemaType(S64Type())), + NamedFieldType("unsigned", SchemaType(U64Type())), + NamedFieldType("duration", SchemaType(DurationType)), + NamedFieldType( + "quantity", + SchemaType(QuantityType(QuantitySpec("m", Nil, None, None))) + ) + ) + ) + ) + ) + ) + val json = Json.Object( + "signed" -> Json.String(Long.MinValue.toString), + "unsigned" -> Json.String("18446744073709551615"), + "duration" -> Json.Object("nanoseconds" -> Json.String(Long.MaxValue.toString)), + "quantity" -> Json.Object( + "mantissa" -> Json.String(Long.MinValue.toString), + "scale" -> Json.Number(BigDecimal(-2)), + "unit" -> Json.String("m") + ) + ) + val value = RecordValue( + List( + S64Value(Long.MinValue), + U64Value(-1L), + DurationValue(Long.MaxValue), + QuantityValueNode(QuantityValue(Long.MinValue, -2, "m")) ) ) + val rendered = wide.toJsonSchema(includeDraftMarker = false) + val props = rendered.get("properties").one.toOption.get assertTrue( - optional.packJson(Json.Object()).isLeft, - optional.packJson(Json.Object("maybe" -> Json.Null)) == Right(RecordValue(List(OptionValue(None)))), - optional.toJsonSchema().get("required").one == Right(Json.Array(Json.String("maybe"))) + wide.packJson(json) == Right(value), + wide.unpackJson(value) == Right(json), + props.get("signed").one.flatMap(_.get("pattern").one) == + Right(Json.String("^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$")), + props.get("unsigned").one.flatMap(_.get("x-golem-maximum").one) == + Right(Json.String("18446744073709551615")), + props.get("duration").one.flatMap(_.get("type").one) == Right(Json.String("object")), + props + .get("quantity") + .one + .flatMap(_.get("properties").one) + .flatMap(_.get("mantissa").one) + .flatMap(_.get("type").one) == Right(Json.String("string")) ) }, + test("rejects non-canonical or overflowing wide decimal strings") { + val signed = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(S64Type()))) + val unsigned = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(U64Type()))) + val duration = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(DurationType))) + val quantity = SchemaRef( + SchemaGraph(ListMap.empty, SchemaType(QuantityType(QuantitySpec("m", Nil, None, None)))) + ) + assertTrue( + List(Json.String("+1"), Json.String("01"), Json.String("-0"), Json.String("9223372036854775808")) + .forall(signed.packJson(_).isLeft), + List(Json.String("-1"), Json.String("+1"), Json.String("01"), Json.String("18446744073709551616")) + .forall(unsigned.packJson(_).isLeft), + duration.packJson(Json.String("PT1S")).isLeft, + duration.packJson(Json.Object("nanoseconds" -> Json.Number(BigDecimal(1)))).isLeft, + quantity + .packJson( + Json.Object( + "mantissa" -> Json.String("-0"), + "scale" -> Json.Number(BigDecimal(0)), + "unit" -> Json.String("m") + ) + ) + .isLeft + ) + }, + test("reflection JSON Schema rejects leaves with no JSON representation") { + val leaves = List[SchemaTypeBody]( + SecretType(SecretSpec(SchemaType(StringType), None)), + QuotaTokenType(QuotaTokenSpec(None)), + PermissionCardType(PermissionCardSpec(polymorphic = false)), + FutureType(None), + StreamType(None) + ) + assertTrue(leaves.forall { body => + SchemaRef(SchemaGraph(ListMap.empty, SchemaType(body))) + .toJsonSchema(includeDraftMarker = false) + .get("not") + .one == Right(Json.Object()) + }) + }, test("rejects numbers that overflow after float narrowing") { val f32 = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(F32Type()))) val f64 = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(F64Type()))) @@ -320,6 +491,21 @@ object SchemaRefSpec extends ZIOSpecDefault { val expected = Json.Number(BigDecimal((Int.MaxValue.toLong * 4 + 2) / 3)) assertTrue(bytes.get("minLength").one == Right(expected), bytes.get("maxLength").one == Right(expected)) }, + test("binary MIME syntax is canonical while MIME metadata stays optional") { + val ref = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType(BinaryType(BinaryRestrictions(mimeTypes = Some(List("image/png"))))) + ) + ) + val withoutMime = Json.Object("bytes" -> Json.String("AQ")) + val invalidMime = Json.Object("bytes" -> Json.String("AQ"), "mimeType" -> Json.String("not a mime")) + assertTrue( + ref.validateJson(withoutMime).isRight, + ref.packJson(invalidMime).isLeft, + ref.unpackJson(BinaryValue(Vector[Byte](1), Some("not a mime"))).isLeft + ) + }, test("throwing config codecs return schema encode failures") { val definition = AgentClientDefinition.full[String, String]( name = "ConfiguredCounterAgent", diff --git a/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala b/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala index 0466352fde..9f78fb42e8 100644 --- a/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala +++ b/sdks/scala/integration-tests/src/test/scala/golem/integration/GolemExamplesIntegrationSpec.scala @@ -309,7 +309,7 @@ object GolemServer { file } process <- ZIO.acquireRelease( - Cmd("golem", "-vvv", "server", "run", "--clean", "--disable-app-manifest-discovery") + Cmd("golem", "--yes", "-vvv", "server", "run", "--clean", "--disable-app-manifest-discovery") .workingDirectory(examplesDir) .env(buildEnv) .redirectErrorStream(true) @@ -624,6 +624,11 @@ object GolemExamplesIntegrationSpec extends ZIOSpec[GolemServer] { "samples/principal/principal.ts", Contains("was created by:") ), + Sample( + "reflection-canonical-json", + "samples/reflection/repl-reflection-canonical.ts", + Contains("scala-canonical-ok", "omitted", "supplied") + ), // --- Database (requires external DB) --- Sample( diff --git a/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala b/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala index 94faa7c015..8b510ed984 100644 --- a/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala +++ b/sdks/scala/model/src/main/scala/golem/schema/Derivation.scala @@ -79,20 +79,41 @@ private[golem] object Derivation { /** Convert a Scala value into its structural [[SchemaValue]]. */ def toValue[A](schema: Schema[A], value: A): SchemaValue = - try dynamicToSchemaValue(schema.reflect, schema.toDynamicValue(value)) - catch { + try { + val reflect = schema.reflect + if (isInstant(reflect)) { + val instant = value.asInstanceOf[Instant] + SchemaValue.DatetimeValue(Datetime(instant.getEpochSecond, instant.getNano)) + } else if (isDuration(reflect)) { + SchemaValue.DurationValue(value.asInstanceOf[JDuration].toNanos) + } else dynamicToSchemaValue(reflect, schema.toDynamicValue(value)) + } catch { case e: SchemaEncodeError => throw e case NonFatal(e) => throw SchemaEncodeError(Option(e.getMessage).getOrElse(e.toString)) } /** Reconstruct a Scala value from a structural [[SchemaValue]]. */ def fromValue[A](schema: Schema[A], value: SchemaValue): Either[FromSchemaError, A] = - try - schema - .fromDynamicValue(schemaValueToDynamic(schema.reflect, value)) - .left - .map(err => FromSchemaError(err.toString)) - catch { + try { + val reflect = schema.reflect + if (isInstant(reflect)) + value match { + case SchemaValue.DatetimeValue(v) => + validateNanoseconds(v.nanoseconds) + Right(Instant.ofEpochSecond(v.seconds, v.nanoseconds.toLong).asInstanceOf[A]) + case other => Left(FromSchemaError(s"expected datetime value for Instant, got $other")) + } + else if (isDuration(reflect)) + value match { + case SchemaValue.DurationValue(v) => Right(JDuration.ofNanos(v).asInstanceOf[A]) + case other => Left(FromSchemaError(s"expected duration value for Duration, got $other")) + } + else + schema + .fromDynamicValue(schemaValueToDynamic(reflect, value)) + .left + .map(err => FromSchemaError(err.toString)) + } catch { case e: FromSchemaError => Left(e) case NonFatal(e) => Left(FromSchemaError(Option(e.getMessage).getOrElse(e.toString))) } diff --git a/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala b/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala index 54bee3d89b..9da152d0e0 100644 --- a/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala +++ b/sdks/scala/model/src/test/scala/golem/schema/RichTypesSpec.scala @@ -87,6 +87,20 @@ object RichTypesSpec extends ZIOSpecDefault { FromSchema[Quantity[Bytes]].fromValue(IntoSchema[Quantity[Bytes]].toValue(quantity)) == Right(quantity) ) }, + test("native Java time schemas use rich structural values") { + val duration = JDuration.ofNanos(Long.MaxValue) + val instant = Instant.ofEpochSecond(1234L, 567) + val durationInto = IntoSchema.derived[JDuration](using Schema.duration) + val durationFrom = FromSchema.derived[JDuration](using Schema.duration) + val instantInto = IntoSchema.derived[Instant](using Schema.instant) + val instantFrom = FromSchema.derived[Instant](using Schema.instant) + assertTrue( + durationInto.toValue(duration) == SchemaValue.DurationValue(Long.MaxValue), + durationFrom.fromValue(SchemaValue.DurationValue(Long.MaxValue)) == Right(duration), + instantInto.toValue(instant) == SchemaValue.DatetimeValue(Datetime(1234L, 567)), + instantFrom.fromValue(SchemaValue.DatetimeValue(Datetime(1234L, 567))) == Right(instant) + ) + }, test("derived record fields use rich nodes and roundtrip") { val record = RichRecord( GolemPath("/workspace/data"), diff --git a/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts b/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts new file mode 100644 index 0000000000..f5d72639f8 --- /dev/null +++ b/sdks/scala/test-agents/samples/reflection/repl-reflection-canonical.ts @@ -0,0 +1,3 @@ +const caller = await ScalaToolReflectionCaller.get("canonical-json"); +console.log(await caller.canonicalRoundTrip()); +console.log(await caller.optionalRoundTrip()); diff --git a/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala b/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala index 461ddd584e..082ac1812b 100644 --- a/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala +++ b/sdks/scala/test-agents/src/main/scala-3/example/integrationtests/ToolReflectionCaller.scala @@ -10,26 +10,61 @@ package example.integrationtests -import golem.{BaseAgent, Principal} +import golem.{BaseAgent, Principal, ULong} import golem.reflection.{AgentClientDefinition, DynamicAgentClient, DynamicToolClient, GolemReflectError, Reflection} import golem.runtime.annotations.* import golem.runtime.{InputRecordCodec, OutputCodec} -import golem.schema.{SchemaValue, TypedSchemaValue} +import golem.schema.{Quantity, QuantityUnit, SchemaValue, TypedSchemaValue} import zio.blocks.schema.json.Json +import zio.blocks.typeid.TypeId +import java.time.{Duration => JDuration} import scala.concurrent.{ExecutionContext, Future} +sealed trait ReflectionMeters + +object ReflectionMeters { + implicit val unit: QuantityUnit[ReflectionMeters] = new QuantityUnit[ReflectionMeters] { + override val baseUnit: String = "m" + override val allowedSuffixes: List[String] = Nil + override val typeId: TypeId[ReflectionMeters] = TypeId.of[ReflectionMeters] + } +} + @toolDefinition(name = "scala-reflection-test") trait ScalaReflectionTestTool { def echo(label: String): String @arg("maybe", scope = "option") def optional(maybe: Option[String]): String + @arg("maybe", scope = "option") + def canonicalValues( + signed: Long, + unsigned: ULong, + duration: JDuration, + quantity: Quantity[ReflectionMeters], + maybe: Option[String] + ): String } @toolImplementation() final class ScalaReflectionTestToolImpl extends ScalaReflectionTestTool { override def echo(label: String): String = s"scala-tool:$label" override def optional(maybe: Option[String]): String = maybe.getOrElse("omitted") + override def canonicalValues( + signed: Long, + unsigned: ULong, + duration: JDuration, + quantity: Quantity[ReflectionMeters], + maybe: Option[String] + ): String = + if ( + signed == Long.MinValue && + unsigned.value == ((BigInt(1) << 64) - 1) && + duration.toNanos == Long.MaxValue && + quantity == Quantity[ReflectionMeters](Long.MinValue, -9, "m") && + maybe.isEmpty + ) "scala-canonical-ok" + else "scala-canonical-mismatch" } @agentDefinition() @@ -48,6 +83,7 @@ trait ScalaToolReflectionCaller extends BaseAgent { class Id(val name: String) def roundTrip(): Future[String] def optionalRoundTrip(): Future[String] + def canonicalRoundTrip(): Future[String] def principalRoundTrip(): Future[String] def agentRoundTrip(): Future[String] } @@ -66,14 +102,41 @@ final class ScalaToolReflectionCallerImpl(name: String) extends ScalaToolReflect case Left(error) => Future.successful(s"error:$error") case Right(command) => for { - omittedJson <- command.invokeJson(Json.Object("maybe" -> Json.Null)) + omittedJson <- command.invokeJson(Json.Object()) + nullJson <- command.invokeJson(Json.Object("maybe" -> Json.Null)) suppliedJson <- command.invokeJson(Json.Object("maybe" -> Json.String("supplied"))) omittedNative <- command.invokeValue(SchemaValue.RecordValue(List(SchemaValue.OptionValue(None)))) suppliedNative <- command.invokeValue( SchemaValue.RecordValue(List(SchemaValue.OptionValue(Some(SchemaValue.StringValue("supplied"))))) ) - } yield s"$omittedJson|$suppliedJson|$omittedNative|$suppliedNative" + } yield s"$omittedJson|$nullJson|$suppliedJson|$omittedNative|$suppliedNative" + } + } + + override def canonicalRoundTrip(): Future[String] = { + val prepared = for { + tool <- Reflection.getToolType("scala-reflection-test").left.map(_.toString) + command <- tool.command(List("canonical-values")).left.map(_.toString) + } yield command + + prepared match { + case Left(error) => Future.successful(s"error:$error") + case Right(command) => + command + .invokeJson( + Json.Object( + "signed" -> Json.String(Long.MinValue.toString), + "unsigned" -> Json.String("18446744073709551615"), + "duration" -> Json.Object("nanoseconds" -> Json.String(Long.MaxValue.toString)), + "quantity" -> Json.Object( + "mantissa" -> Json.String(Long.MinValue.toString), + "scale" -> Json.Number(BigDecimal(-9)), + "unit" -> Json.String("m") + ) + ) + ) + .map(_.toString) } } diff --git a/sdks/ts/packages/golem-ts-bridge/src/index.ts b/sdks/ts/packages/golem-ts-bridge/src/index.ts index 7a9204c1a0..a7bbc48e0d 100644 --- a/sdks/ts/packages/golem-ts-bridge/src/index.ts +++ b/sdks/ts/packages/golem-ts-bridge/src/index.ts @@ -250,8 +250,9 @@ export type StreamingRemoteMethod = { // // These mirror the Rust `SchemaValue` / `TypedSchemaValue` serde shapes // (`#[serde(tag = "kind", content = "value", rename_all = "kebab-case")]`). -// Request `parameters` / `methodParameters` and agent `config` values travel -// as a bare `SchemaValue`; invocation results come back as a `TypedSchemaValue`. +// Request `parameters` / `methodParameters` travel as bare `SchemaValue`s; +// config values use their declared public schema codec, and invocation results +// come back as a `TypedSchemaValue`. // =========================================================================== export interface TextValuePayload { @@ -353,7 +354,7 @@ export interface InvocationReceipt { export interface AgentConfigEntry { path: string[]; - value: SchemaValue; + value: PublicValue; } export interface CreateAgentRequest { @@ -1720,14 +1721,11 @@ class PublicValueValidator { const input = publicObject(value, 'binary'); publicExactOptionalMembers(input, new Set(['bytes']), new Set(['mimeType']), 'binary'); const encoded = publicString(input.bytes, 'binary bytes'); - if ( - encoded.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded) - ) - this.fail('malformed-message', 'binary bytes are not canonical padded base64'); - const bytes = Buffer.from(encoded, 'base64'); - if (bytes.toString('base64') !== encoded) - this.fail('malformed-message', 'binary bytes are not canonical padded base64'); + if (!/^[A-Za-z0-9_-]*$/u.test(encoded) || encoded.length % 4 === 1) + this.fail('malformed-message', 'binary bytes are not canonical unpadded base64url'); + const bytes = Buffer.from(encoded, 'base64url'); + if (bytes.toString('base64url') !== encoded) + this.fail('malformed-message', 'binary bytes are not canonical unpadded base64url'); const mime = input.mimeType === undefined ? undefined : publicString(input.mimeType, 'MIME type'); if (mime !== undefined && !/^[A-Za-z0-9!#$&^_.+\-]+\/[A-Za-z0-9!#$&^_.+\-]+$/u.test(mime)) @@ -3003,7 +3001,7 @@ class StreamingSession { throw new StreamingProtocolError('malformed-message', 'invalid binary item metadata'); const deliveredSequence = output.stream.accept(sequence, 1n, 'binary'); output.stream.push( - { bytes: payload.toString('base64'), mimeType: metadata.mimeType }, + { bytes: payload.toString('base64url'), mimeType: metadata.mimeType }, { token: metadata.cursorToken, sequence }, payload.length + (metadata.mimeType ? Buffer.byteLength(metadata.mimeType) : 0), deliveredSequence, @@ -3259,19 +3257,16 @@ class StreamingSession { throw new StreamingProtocolError('schema-mismatch', 'invalid binary stream item'); let payload: Buffer; if (typeof encoded.bytes === 'string') { - if ( - encoded.bytes.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(encoded.bytes) - ) + if (!/^[A-Za-z0-9_-]*$/u.test(encoded.bytes) || encoded.bytes.length % 4 === 1) throw new StreamingProtocolError( 'schema-mismatch', - 'binary bytes are not canonical base64', + 'binary bytes are not canonical unpadded base64url', ); - payload = Buffer.from(encoded.bytes, 'base64'); - if (payload.toString('base64') !== encoded.bytes) + payload = Buffer.from(encoded.bytes, 'base64url'); + if (payload.toString('base64url') !== encoded.bytes) throw new StreamingProtocolError( 'schema-mismatch', - 'binary bytes are not canonical base64', + 'binary bytes are not canonical unpadded base64url', ); } else if (encoded.bytes instanceof Uint8Array) payload = Buffer.from(encoded.bytes); else throw new StreamingProtocolError('schema-mismatch', 'invalid binary stream bytes'); diff --git a/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs b/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs index 975bfca101..a8ced0482a 100644 --- a/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs +++ b/sdks/ts/packages/golem-ts-bridge/test/runtime.test.mjs @@ -110,10 +110,14 @@ test('REST request JSON preserves exact bigint values', async () => { envName: 'env', agentTypeName: 'agent', parameters: { kind: 'u64', value: 18_446_744_073_709_551_615n }, + config: [{ path: ['limits', 'maximum'], value: '18446744073709551615' }], }, ); assert.match(body, /"value":18446744073709551615(?:[,}])/u); - assert.doesNotMatch(body, /"18446744073709551615"/u); + assert.match( + body, + /"config":\[\{"path":\["limits","maximum"\],"value":"18446744073709551615"\}\]/u, + ); } finally { await new Promise((resolve) => http.close(resolve)); } @@ -951,14 +955,18 @@ test('public value codec enforces canonical values, restrictions, and strict sha }, }); assert.deepEqual( - binary.validate({ bytes: '+/8=', mimeType: 'application/octet-stream' }, 'none'), - { bytes: '+/8=', mimeType: 'application/octet-stream' }, + binary.validate({ bytes: '-_8', mimeType: 'application/octet-stream' }, 'none'), + { bytes: '-_8', mimeType: 'application/octet-stream' }, + ); + assert.throws( + () => binary.validate({ bytes: '+/8=', mimeType: 'application/octet-stream' }, 'none'), + { code: 'malformed-message' }, ); assert.throws( - () => binary.validate({ bytes: '-_8=', mimeType: 'application/octet-stream' }, 'none'), + () => binary.validate({ bytes: '-_9', mimeType: 'application/octet-stream' }, 'none'), { code: 'malformed-message' }, ); - assert.throws(() => binary.validate({ bytes: '+/8=', mimeType: 'image/png' }, 'none'), { + assert.throws(() => binary.validate({ bytes: '-_8', mimeType: 'image/png' }, 'none'), { code: 'validation-error', }); diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts index 32695cdfa5..fe9cc31d53 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/ref.ts @@ -63,8 +63,7 @@ export class SchemaRef { validateJson(value: JsonValue): SchemaValidationResult { try { - const packed = this.packJson(value); - return this.validateValue(packed); + return { success: true, value: this.packJson(value) }; } catch (error) { return { success: false, issues: [schemaIssue(error)] }; } @@ -79,11 +78,19 @@ export class SchemaRef { }; } + /** Pack canonical JSON and validate it against the complete schema. */ packJson(value: JsonValue): SchemaValue { - return fromCanonicalJson(this.graph, this.root, value); + const packed = fromCanonicalJson(this.graph, this.root, value); + if (!schemaValueConforms(this.graph, this.root, packed)) { + throw new SchemaRenderError('schema value does not conform to the expected schema'); + } + return packed; } unpackJson(value: SchemaValue): JsonValue { + if (!schemaValueConforms(this.graph, this.root, value)) { + throw new SchemaRenderError('schema value does not conform to the expected schema'); + } return toCanonicalJson(this.graph, this.root, value); } diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts index 6bf7e90aab..692d2979cb 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/render.ts @@ -12,11 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { - SchemaGraph, - SchemaType, - SchemaTypeBody, - SchemaValue, +import { + floatFromBits, + type NumericRestrictions, + type SchemaGraph, + type SchemaType, + type SchemaTypeBody, + type SchemaValue, } from '../internal/schema-model'; import { datetimeFromISOString, datetimeToISOString } from '../bridge/schema'; import { SchemaRenderError, type JsonValue } from './ref'; @@ -99,7 +101,10 @@ export function fromCanonicalJson( return { tag: 'record', fields: body.fields.map((field) => { - if (!(field.name in object)) fail([...path, field.name], 'missing field'); + if (!Object.prototype.hasOwnProperty.call(object, field.name)) { + if (resolve(graph, field.body).body.tag === 'option') return { tag: 'option' }; + fail([...path, field.name], 'missing field'); + } return fromCanonicalJson(graph, field.body, object[field.name], [...path, field.name]); }), }; @@ -233,6 +238,7 @@ export function toCanonicalJson( fail(path, `expected ${body.tag} schema value, found ${value.tag}`); switch (value.tag) { case 'bool': + return value.value; case 's8': case 's16': case 's32': @@ -257,11 +263,14 @@ export function toCanonicalJson( text: value.text, ...(value.language === undefined ? {} : { language: value.language }), }; - case 'binary': + case 'binary': { + if (value.mimeType !== undefined && !MIME_TYPE_PATTERN.test(value.mimeType)) + fail([...path, 'mimeType'], 'invalid MIME type'); return { bytes: bytesToBase64(value.bytes), ...(value.mimeType === undefined ? {} : { mimeType: value.mimeType }), }; + } case 'datetime': return datetimeToISOString(value.value); case 'duration': @@ -436,7 +445,12 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record field.name), + required: body.fields + .filter((field) => resolve(graph, field.body).body.tag !== 'option') + .map((field) => field.name), additionalProperties: false, }; break; @@ -658,13 +688,24 @@ function renderSchema(graph: SchemaGraph, type: SchemaType): Record> 2)); if (value[index + 3] !== undefined) bytes.push(((c & 3) << 6) | d); } - return Uint8Array.from(bytes); + const result = Uint8Array.from(bytes); + if (bytesToBase64(result) !== value) fail(path, 'invalid base64url without padding'); + return result; } function rejectUnknownFields( @@ -955,3 +1001,19 @@ function integerStringSchema( 'x-golem-maximum': max.toString(), }; } + +function restrictedIntegerBound( + bound: NumericRestrictions['min'] | undefined, + fallback: bigint, + side: 'min' | 'max', +): bigint { + if (bound === undefined || bound.tag === 'float-bits') return fallback; + const value = bound.val; + return side === 'min' + ? value > fallback + ? value + : fallback + : value < fallback + ? value + : fallback; +} diff --git a/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts b/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts index c82cf3b354..1c37a2b4e8 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/schema/union.ts @@ -130,7 +130,10 @@ export function matchesSchemaType( // distinct required keys. return ( isPlainObject(value) && - body.fields.every((f) => f.body.body.tag === 'option' || f.name in value) + body.fields.every( + (f) => + f.body.body.tag === 'option' || Object.prototype.hasOwnProperty.call(value, f.name), + ) ); case 'secret': case 'quota-token': diff --git a/sdks/ts/packages/golem-ts-sdk/src/toolReflection.ts b/sdks/ts/packages/golem-ts-sdk/src/toolReflection.ts index a26b51a2aa..39d63052c4 100644 --- a/sdks/ts/packages/golem-ts-sdk/src/toolReflection.ts +++ b/sdks/ts/packages/golem-ts-sdk/src/toolReflection.ts @@ -37,6 +37,7 @@ import { v, } from './internal/schema-model'; import { SchemaRef, type JsonValue } from './schema/ref'; +import { fromCanonicalJson } from './schema/render'; import { ComponentId } from './ids'; import { createToolClientRuntime, @@ -189,7 +190,7 @@ export class ToolCommand { /** Validate and pack canonical JSON input before opening a tool RPC. */ packJson(input: JsonValue): SchemaValue { if (!this.inputSchema) throw new TypeError(`Command '${this.path.join(' ')}' has no body`); - const value = this.inputSchema.packJson(input); + const value = fromCanonicalJson(this.inputSchema.graph, this.inputSchema.root, input); if (!this.inputSchema.validateValue(value).success) throw new TypeError('Invalid tool input'); this.validateConstraints(value); return value; diff --git a/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts new file mode 100644 index 0000000000..10b573c70b --- /dev/null +++ b/sdks/ts/packages/golem-ts-sdk/tests/reflection-conformance.test.ts @@ -0,0 +1,330 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + field, + schemaType, + t, + type SchemaGraph, + type SchemaType, +} from '../src/internal/schema-model'; +import { SchemaRef, SchemaRenderError, type JsonValue } from '../src/schema/ref'; +import { fromCanonicalJson } from '../src/schema/render'; + +interface ConformanceCase { + id: string; + operation: 'roundtrip' | 'reject' | 'json-schema' | 'semantic'; + fixture: string; + input?: JsonValue; + inputs?: JsonValue[]; + path?: string; + expected: JsonValue; +} + +interface ConformanceCorpus { + version: string; + schemaKinds: string[]; + restrictionKinds: string[]; + caseIds: string[]; + cases: ConformanceCase[]; +} + +const corpus = JSON.parse( + readFileSync( + new URL('../../../../../test-data/reflection-conformance/v1.json', import.meta.url), + 'utf8', + ), +) as ConformanceCorpus; + +const supportedSchemaKinds = [ + 'ref', + 'bool', + 's8', + 's16', + 's32', + 's64', + 'u8', + 'u16', + 'u32', + 'u64', + 'f32', + 'f64', + 'char', + 'string', + 'record', + 'variant', + 'enum', + 'flags', + 'tuple', + 'list', + 'fixed-list', + 'map', + 'option', + 'result', + 'text', + 'binary', + 'path', + 'url', + 'datetime', + 'duration', + 'quantity', + 'union', + 'secret', + 'quota-token', + 'permission-card', + 'future', + 'stream', +] as const; + +const supportedRestrictionKinds = [ + 'numeric-minimum', + 'numeric-maximum', + 'numeric-unit', + 'text-languages', + 'text-min-length', + 'text-max-length', + 'text-regex', + 'binary-mime-types', + 'binary-min-bytes', + 'binary-max-bytes', + 'path-direction', + 'path-kind', + 'path-mime-types', + 'path-extensions', + 'url-schemes', + 'url-hosts', + 'quantity-base-unit', + 'quantity-suffixes', + 'quantity-minimum', + 'quantity-maximum', + 'union-prefix', + 'union-suffix', + 'union-regex', + 'union-field', +] as const; + +function schema(root: SchemaType): SchemaRef { + return new SchemaRef({ defs: new Map(), root }); +} + +function fixture(name: string): SchemaRef { + switch (name) { + case 's64': + return schema(t.s64()); + case 'constrained-s64': + return schema( + t.s64({ + min: { tag: 'signed', val: -9_007_199_254_740_993n }, + max: { tag: 'signed', val: 9_007_199_254_740_993n }, + }), + ); + case 'u64': + return schema(t.u64()); + case 'binary': + return schema(schemaType({ tag: 'binary', restrictions: {} })); + case 'duration': + return schema(t.duration()); + case 'quantity': + return schema(t.quantity({ baseUnit: 'm', allowedSuffixes: [] })); + case 'optional-record': { + const graph: SchemaGraph = { + defs: new Map([['conformance.optional', { body: t.option(t.string()) }]]), + root: t.record([ + field('direct', t.option(t.string())), + field('referenced', t.ref('conformance.optional')), + ]), + }; + return new SchemaRef(graph); + } + case 'tool-input': + return schema( + t.record([ + field('pattern', t.string()), + field('paths', t.list(t.string())), + field('ignoreCase', t.option(t.bool())), + ]), + ); + case 'config-entry': + return schema(t.record([field('path', t.list(t.string())), field('value', t.s64())])); + case 'constrained-u32': + return schema( + t.u32({ + min: { tag: 'unsigned', val: 2n }, + max: { tag: 'unsigned', val: 10n }, + }), + ); + case 'constrained-f64': + return schema( + t.f64({ + min: { tag: 'float-bits', val: 0xbff8000000000000n }, + max: { tag: 'float-bits', val: 0x4004000000000000n }, + }), + ); + case 'constrained-text': + return schema( + schemaType({ + tag: 'text', + restrictions: { + languages: ['en', 'de'], + minLength: 2, + maxLength: 8, + regex: '^[a-z]+$', + }, + }), + ); + case 'constrained-binary': + return schema( + schemaType({ + tag: 'binary', + restrictions: { + mimeTypes: ['image/png', 'application/octet-stream'], + minBytes: 2, + maxBytes: 4, + }, + }), + ); + case 'result': + return schema(t.result(t.string(), t.u32())); + case 'custom-error': + return schema( + t.result(t.string(), t.record([field('code', t.string()), field('retryable', t.bool())])), + ); + default: + throw new Error(`unknown conformance fixture ${name}`); + } +} + +function atPointer(value: JsonValue, pointer: string): JsonValue { + if (pointer === '') return value; + return pointer + .slice(1) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((current, part) => { + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + throw new Error(`cannot resolve ${pointer}`); + } + const next = current[part]; + if (next === undefined) throw new Error(`missing ${pointer}`); + return next; + }, value); +} + +function expectSubset(actual: JsonValue, expected: JsonValue): void { + if (expected !== null && typeof expected === 'object' && !Array.isArray(expected)) { + expect(actual).not.toBeNull(); + expect(typeof actual).toBe('object'); + expect(Array.isArray(actual)).toBe(false); + for (const [key, value] of Object.entries(expected)) { + expect(Object.prototype.hasOwnProperty.call(actual, key)).toBe(true); + expectSubset((actual as Record)[key], value); + } + } else { + expect(actual).toEqual(expected); + } +} + +function assertSemantic(testCase: ConformanceCase): void { + const expected = testCase.expected as Record; + switch (testCase.fixture) { + case 'unsupported-leaves': { + const unsupported = [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + schemaType({ tag: 'future', element: t.string() }), + t.stream(t.string()), + ]; + expect(unsupported).toHaveLength(expected.count as number); + for (const type of unsupported) expectSubset(schema(type).toJsonSchema(), expected.schema); + break; + } + case 'all-kinds': + expect(supportedSchemaKinds).toEqual(expected.names); + expect(corpus.schemaKinds).toEqual(expected.names); + break; + case 'all-restrictions': + expect(supportedRestrictionKinds).toEqual(expected.names); + expect(corpus.restrictionKinds).toEqual(expected.names); + break; + case 'graph': { + const referenced = fixture('optional-record'); + const inline = schema( + t.record([ + field('direct', t.option(t.string())), + field('referenced', t.option(t.string())), + ]), + ); + expect(referenced.packJson({})).toEqual(inline.packJson({})); + expect(referenced.validateJson({}).success).toBe(true); + break; + } + default: + throw new Error(`unknown semantic conformance fixture ${testCase.fixture}`); + } +} + +function executeCase(testCase: ConformanceCase): void { + switch (testCase.operation) { + case 'roundtrip': { + const ref = fixture(testCase.fixture); + expect(ref.unpackJson(ref.packJson(testCase.input!))).toEqual(testCase.expected); + break; + } + case 'reject': { + const ref = fixture(testCase.fixture); + for (const input of testCase.inputs ?? [testCase.input!]) { + expect( + () => ref.packJson(input), + `${testCase.id}: direct packing accepted the input`, + ).toThrow(SchemaRenderError); + let packed: ReturnType; + try { + packed = fromCanonicalJson(ref.graph, ref.root, input); + } catch { + expect('invalid-json').toBe((testCase.expected as { readonly kind: string }).kind); + continue; + } + if (ref.validateValue(packed).success) { + throw new Error(`accepted ${JSON.stringify(input)}`); + } + expect('constraint-violation').toBe((testCase.expected as { readonly kind: string }).kind); + } + break; + } + case 'json-schema': + expectSubset( + atPointer(fixture(testCase.fixture).toJsonSchema(), testCase.path ?? ''), + testCase.expected, + ); + break; + case 'semantic': + assertSemantic(testCase); + break; + default: + throw new Error(`unknown conformance operation ${String(testCase.operation)}`); + } +} + +describe('reflection conformance corpus', () => { + it('has a valid version, unique declared case IDs, and recognized operations', () => { + expect(corpus.version).toBe('1.0.0'); + const ids = corpus.cases.map((testCase) => testCase.id); + expect(new Set(ids).size, 'duplicate corpus case ID').toBe(ids.length); + expect(new Set(corpus.caseIds).size, 'duplicate declared case ID').toBe(corpus.caseIds.length); + expect([...ids].sort()).toEqual([...corpus.caseIds].sort()); + const operations = new Set(['roundtrip', 'reject', 'json-schema', 'semantic']); + for (const testCase of corpus.cases) { + expect(operations.has(testCase.operation), testCase.id).toBe(true); + } + }); + + for (const testCase of corpus.cases) it(testCase.id, () => executeCase(testCase)); +}); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts index fd4f773dcd..846a14636e 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-ref.test.ts @@ -27,11 +27,20 @@ function schema(root: SchemaGraph['root']): SchemaRef { return new SchemaRef({ defs: new Map(), root }); } +function floatBits(value: number): bigint { + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, value); + return view.getBigUint64(0); +} + describe('SchemaRef canonical JSON', () => { - it('requires an explicit null for an absent option in a record', () => { + it('decodes omitted and explicit-null option fields as absent', () => { const ref = schema(t.record([field('maybe', t.option(t.string()))])); - expect(ref.validateJson({}).success).toBe(false); + expect(ref.packJson({})).toEqual(v.record([v.option()])); expect(ref.packJson({ maybe: null })).toEqual(v.record([v.option()])); + + const inheritedName = schema(t.record([field('constructor', t.option(t.string()))])); + expect(inheritedName.packJson({})).toEqual(v.record([v.option()])); }); it('rejects finite JSON numbers that overflow f32 after narrowing', () => { @@ -40,6 +49,11 @@ describe('SchemaRef canonical JSON', () => { expect(() => schema(t.f64()).unpackJson(v.f64(-Infinity))).toThrow(/finite JSON number/); }); + it('rejects native values outside declared restrictions before unpacking', () => { + const ref = schema(t.u32({ min: { tag: 'unsigned', val: 10n } })); + expect(() => ref.unpackJson(v.u32(1))).toThrow(/does not conform/); + }); + it('uses the canonical object representation for text', () => { const ref = schema( schemaType({ @@ -74,6 +88,11 @@ describe('SchemaRef canonical JSON', () => { issues: [{ path: ['bytes'], message: 'invalid base64url without padding' }], }); expect(ref.validateJson({ bytes: 'AQI', mimeType: 'not a mime' }).success).toBe(false); + expect(() => + schema(schemaType({ tag: 'binary', restrictions: {} })).unpackJson( + v.binary(Uint8Array.from([1]), 'not a mime'), + ), + ).toThrow(/invalid MIME type/); }); it('uses a canonical signed decimal nanosecond string for durations', () => { @@ -88,7 +107,7 @@ describe('SchemaRef canonical JSON', () => { }); it('uses canonical signed decimal strings for quantity mantissas', () => { - const ref = schema(t.quantity({ baseUnit: 'm', allowedUnits: [] })); + const ref = schema(t.quantity({ baseUnit: 'm', allowedSuffixes: [] })); const json = { mantissa: '123', scale: -2, unit: 'm' } as const; expect(ref.packJson(json)).toEqual(v.quantity({ mantissa: 123n, scale: -2, unit: 'm' })); @@ -122,10 +141,11 @@ describe('SchemaRef canonical JSON', () => { expect(ref.validateJson(['admin']).success).toBe(false); }); - it('keeps structural packing separate from full restriction validation', () => { + it('validates restrictions while packing canonical JSON', () => { const ref = schema(t.u32({ min: { tag: 'unsigned', val: 5n } })); - expect(ref.packJson(3)).toEqual(v.u32(3)); + expect(() => ref.packJson(3)).toThrow(/does not conform/); + expect(ref.packJson(5)).toEqual(v.u32(5)); expect(ref.validateJson(3)).toEqual({ success: false, issues: [{ path: [], message: 'schema value does not conform to the expected schema' }], @@ -162,7 +182,7 @@ describe('SchemaRef JSON Schema', () => { name: { type: 'string', description: 'Display name', examples: ['Ada'] }, nickname: { oneOf: [{ type: 'null' }, { type: 'string' }] }, }, - required: ['name', 'nickname'], + required: ['name'], additionalProperties: false, }, }, @@ -173,7 +193,7 @@ describe('SchemaRef JSON Schema', () => { const root = t.record([ field('text', schemaType({ tag: 'text', restrictions: {} })), field('duration', t.duration()), - field('quantity', t.quantity({ baseUnit: 'm', allowedUnits: [] })), + field('quantity', t.quantity({ baseUnit: 'm', allowedSuffixes: [] })), ]); expect(schema(root).toJsonSchema()).toMatchObject({ @@ -226,4 +246,133 @@ describe('SchemaRef JSON Schema', () => { 'x-golem-maximum': '18446744073709551615', }); }); + + it('renders declared bounds for every narrow integer and float family', () => { + for (const type of [ + t.s8({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s16({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.s32({ min: { tag: 'signed', val: -12n }, max: { tag: 'signed', val: 12n } }), + t.u8({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u16({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + t.u32({ min: { tag: 'unsigned', val: 2n }, max: { tag: 'unsigned', val: 12n } }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ + minimum: type.body.tag.startsWith('s') ? -12 : 2, + maximum: 12, + }); + } + + for (const type of [ + t.f32({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + t.f64({ + min: { tag: 'float-bits', val: 0xbff0000000000000n }, + max: { tag: 'float-bits', val: 0x3fe0000000000000n }, + }), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ minimum: -1, maximum: 0.5 }); + } + }); + + it('clamps out-of-domain bounds to primitive ranges', () => { + for (const [type, minimum, maximum] of [ + [ + t.s8({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -128, + 127, + ], + [ + t.s16({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -32768, + 32767, + ], + [ + t.s32({ + min: { tag: 'signed', val: -(2n ** 63n) }, + max: { tag: 'signed', val: 2n ** 63n - 1n }, + }), + -(2 ** 31), + 2 ** 31 - 1, + ], + [ + t.u8({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 255, + ], + [ + t.u16({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 65535, + ], + [ + t.u32({ min: { tag: 'unsigned', val: 0n }, max: { tag: 'unsigned', val: 2n ** 64n - 1n } }), + 0, + 2 ** 32 - 1, + ], + ] as const) { + expect(schema(type).toJsonSchema()).toMatchObject({ minimum, maximum }); + } + + expect( + schema( + t.f32({ + min: { tag: 'float-bits', val: floatBits(-1e100) }, + max: { tag: 'float-bits', val: floatBits(1e100) }, + }), + ).toJsonSchema(), + ).toMatchObject({ minimum: -3.4028234663852886e38, maximum: 3.4028234663852886e38 }); + }); + + it('renders enforceable rich-value allowlists and canonical base64url bytes', () => { + const rendered = schema( + t.record([ + field('text', schemaType({ tag: 'text', restrictions: { languages: ['en', 'de'] } })), + field('binary', schemaType({ tag: 'binary', restrictions: { mimeTypes: ['image/png'] } })), + ]), + ).toJsonSchema(); + expect(rendered).toMatchObject({ + properties: { + text: { properties: { language: { enum: ['en', 'de'] } } }, + binary: { properties: { mimeType: { enum: ['image/png'] } } }, + }, + }); + const properties = ( + rendered as { + properties: { text: Record; binary: Record }; + } + ).properties; + expect(properties.text).not.toHaveProperty('description'); + expect(properties.binary).not.toHaveProperty('description'); + const pattern = ( + rendered as { + properties: { binary: { properties: { bytes: { pattern: string } } } }; + } + ).properties.binary.properties.bytes.pattern; + expect(pattern).toBe( + '^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$', + ); + const regex = new RegExp(pattern, 'u'); + for (const valid of ['', 'AQ', 'AQI', 'AQID', '-_8']) expect(regex.test(valid)).toBe(true); + for (const invalid of ['+/8', 'AQ==', '-_9', 'A']) expect(regex.test(invalid)).toBe(false); + }); + + it('makes reflection-only unsupported leaves unsatisfiable', () => { + for (const type of [ + t.secret(t.string()), + t.quotaToken({}), + t.permissionCard({ polymorphic: false }), + schemaType({ tag: 'future', element: t.string() }), + t.stream(t.string()), + ]) { + expect(schema(type).toJsonSchema()).toMatchObject({ not: {} }); + } + }); }); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts new file mode 100644 index 0000000000..b8f97e13dd --- /dev/null +++ b/sdks/ts/packages/golem-ts-sdk/tests/schema-union.test.ts @@ -0,0 +1,29 @@ +// Copyright 2024-2026 Golem Cloud +// +// Licensed under the Golem Source License v1.1 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from 'vitest'; +import { field, t } from '../src/internal/schema-model'; +import { matchesSchemaType } from '../src/schema/union'; + +describe('plain-union structural matching', () => { + it('requires prototype-named record fields to be own properties', () => { + const constructorRecord = t.record([field('constructor', t.string())]); + const toStringRecord = t.record([field('toString', t.string())]); + + expect(matchesSchemaType(new Map(), constructorRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), toStringRecord, {})).toBe(false); + expect(matchesSchemaType(new Map(), constructorRecord, { constructor: 'own' })).toBe(true); + expect(matchesSchemaType(new Map(), toStringRecord, { toString: 'own' })).toBe(true); + }); +}); diff --git a/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts b/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts index 19ad3f8098..e99988558f 100644 --- a/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts +++ b/sdks/ts/packages/golem-ts-sdk/tests/tool-reflection.test.ts @@ -217,20 +217,24 @@ describe('native tool reflection', () => { }); }); - it('builds a canonical input schema and rejects invalid JSON before dispatch', async () => { + it('builds a canonical input schema and accepts omitted options before dispatch', async () => { const { tool, start } = fixture(); const command = tool.client.command([]); expect(command.inputSchema?.toJsonSchema()).toBeDefined(); expect(command.validateJson({ value: 'hello', maybe: null }).success).toBe(true); - expect(command.validateJson({ value: 'hello' }).success).toBe(false); + expect(command.validateJson({ value: 'hello' }).success).toBe(true); expect(start).not.toHaveBeenCalled(); - for (const maybe of [null, 'supplied']) { - await expect(command.invokeJson({ value: 'hello', maybe })).resolves.toBe('ok'); + for (const input of [ + { value: 'hello' }, + { value: 'hello', maybe: null }, + { value: 'hello', maybe: 'supplied' }, + ]) { + await expect(command.invokeJson(input)).resolves.toBe('ok'); const sent = start.mock.calls.at(-1)![1]; expect(schemaShapesMatch(command.inputSchema!.graph, sent.graph)).toBe(true); expect(command.inputSchema!.validateValue(sent.value).success).toBe(true); } - expect(start).toHaveBeenCalledTimes(2); + expect(start).toHaveBeenCalledTimes(3); }); it('rejects a missing declared remote result', async () => { diff --git a/test-components/tool-streaming-moonbit/component/moon.pkg b/test-components/tool-streaming-moonbit/component/moon.pkg index 53127d4997..6ff30ff412 100644 --- a/test-components/tool-streaming-moonbit/component/moon.pkg +++ b/test-components/tool-streaming-moonbit/component/moon.pkg @@ -1,14 +1,14 @@ import { "golemcloud/golem_sdk/async-core" @asyncCore, "golemcloud/golem_sdk/gen", - "golemcloud/golem_sdk/tool" @tool, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/schema" @schema, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/tool", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/schema", // Generated by golem_sdk_tools "golemcloud/golem_sdk/schema_model" @model, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/interface/golem/core/types" @types, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/interface/golem/core/types", // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/golem/tool/common" @toolCommon, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/agents" @agents, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/interface/golem/agent/common" @common, // Generated by golem_sdk_tools - "golemcloud/golem_sdk/rpc" @rpc, // Generated by golem_sdk_tools + "golemcloud/golem_sdk/agents", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/interface/golem/agent/common", // Generated by golem_sdk_tools + "golemcloud/golem_sdk/rpc", // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/golem/agent/host" @agentHost, // Generated by golem_sdk_tools "golemcloud/golem_sdk/interface/wasi/clocks/system-clock" @systemClock, // Generated by golem_sdk_tools } diff --git a/test-data/reflection-conformance/v1.json b/test-data/reflection-conformance/v1.json new file mode 100644 index 0000000000..83a27772d8 --- /dev/null +++ b/test-data/reflection-conformance/v1.json @@ -0,0 +1,451 @@ +{ + "version": "1.0.0", + "schemaKinds": [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", + "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", + "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", + "permission-card", "future", "stream" + ], + "restrictionKinds": [ + "numeric-minimum", "numeric-maximum", "numeric-unit", + "text-languages", "text-min-length", "text-max-length", "text-regex", + "binary-mime-types", "binary-min-bytes", "binary-max-bytes", + "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", + "quantity-minimum", "quantity-maximum", + "union-prefix", "union-suffix", "union-regex", "union-field" + ], + "caseIds": [ + "canonical/s64-min", + "canonical/s64-max", + "canonical/u64-max", + "canonical/duration-min", + "canonical/duration-max", + "canonical/quantity-wide-mantissa", + "canonical/omitted-options", + "canonical/binary-mime", + "canonical/constrained-text-without-language", + "canonical/constrained-binary-without-mime", + "errors/s64-plus", + "errors/s64-leading-zero", + "errors/s64-negative-zero", + "errors/s64-overflow", + "errors/u64-negative", + "errors/u64-overflow", + "errors/wide-wrong-json-type", + "errors/duration-legacy-string", + "errors/quantity-negative-zero", + "errors/quantity-extra-field", + "errors/binary-noncanonical-base64", + "errors/binary-invalid-mime", + "errors/text-extra-field", + "json-schema/s64", + "json-schema/s64-restrictions", + "json-schema/u32-restrictions", + "json-schema/f64-restrictions", + "json-schema/text-restrictions", + "json-schema/binary-restrictions", + "json-schema/u64", + "json-schema/duration-nanoseconds", + "json-schema/quantity-mantissa", + "json-schema/optional-required", + "json-schema/unsupported-leaves", + "schema/all-kinds", + "schema/all-restrictions", + "graph/references-validation-equivalence", + "tool/canonical-input", + "config/canonical-entry", + "constraints/rejected-value", + "compatibility/result", + "compatibility/custom-error" + ], + "cases": [ + { + "id": "canonical/s64-min", + "operation": "roundtrip", + "fixture": "s64", + "input": "-9223372036854775808", + "expected": "-9223372036854775808" + }, + { + "id": "canonical/s64-max", + "operation": "roundtrip", + "fixture": "s64", + "input": "9223372036854775807", + "expected": "9223372036854775807" + }, + { + "id": "canonical/u64-max", + "operation": "roundtrip", + "fixture": "u64", + "input": "18446744073709551615", + "expected": "18446744073709551615" + }, + { + "id": "canonical/duration-min", + "operation": "roundtrip", + "fixture": "duration", + "input": { "nanoseconds": "-9223372036854775808" }, + "expected": { "nanoseconds": "-9223372036854775808" } + }, + { + "id": "canonical/duration-max", + "operation": "roundtrip", + "fixture": "duration", + "input": { "nanoseconds": "9223372036854775807" }, + "expected": { "nanoseconds": "9223372036854775807" } + }, + { + "id": "canonical/quantity-wide-mantissa", + "operation": "roundtrip", + "fixture": "quantity", + "input": { "mantissa": "-9223372036854775808", "scale": -9, "unit": "m" }, + "expected": { "mantissa": "-9223372036854775808", "scale": -9, "unit": "m" } + }, + { + "id": "canonical/omitted-options", + "operation": "roundtrip", + "fixture": "optional-record", + "input": {}, + "expected": { "direct": null, "referenced": null } + }, + { + "id": "canonical/binary-mime", + "operation": "roundtrip", + "fixture": "binary", + "input": { "bytes": "-_8", "mimeType": "application/octet-stream" }, + "expected": { "bytes": "-_8", "mimeType": "application/octet-stream" } + }, + { + "id": "canonical/constrained-text-without-language", + "operation": "roundtrip", + "fixture": "constrained-text", + "input": { "text": "hello" }, + "expected": { "text": "hello" } + }, + { + "id": "canonical/constrained-binary-without-mime", + "operation": "roundtrip", + "fixture": "constrained-binary", + "input": { "bytes": "AQI" }, + "expected": { "bytes": "AQI" } + }, + { + "id": "errors/s64-plus", + "operation": "reject", + "fixture": "s64", + "input": "+1", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-leading-zero", + "operation": "reject", + "fixture": "s64", + "input": "01", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-negative-zero", + "operation": "reject", + "fixture": "s64", + "input": "-0", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/s64-overflow", + "operation": "reject", + "fixture": "s64", + "input": "9223372036854775808", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/u64-negative", + "operation": "reject", + "fixture": "u64", + "input": "-1", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/u64-overflow", + "operation": "reject", + "fixture": "u64", + "input": "18446744073709551616", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/wide-wrong-json-type", + "operation": "reject", + "fixture": "s64", + "input": 1, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/duration-legacy-string", + "operation": "reject", + "fixture": "duration", + "input": "PT1S", + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/quantity-negative-zero", + "operation": "reject", + "fixture": "quantity", + "input": { "mantissa": "-0", "scale": 0, "unit": "m" }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/quantity-extra-field", + "operation": "reject", + "fixture": "quantity", + "input": { "mantissa": "1", "scale": 0, "unit": "m", "extra": true }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/binary-noncanonical-base64", + "operation": "reject", + "fixture": "binary", + "inputs": [ + { "bytes": "+/8", "mimeType": "application/octet-stream" }, + { "bytes": "-_8=", "mimeType": "application/octet-stream" }, + { "bytes": "-_9", "mimeType": "application/octet-stream" }, + { "bytes": "A", "mimeType": "application/octet-stream" }, + { "bytes": "-_8", "mime_type": "application/octet-stream" } + ], + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/binary-invalid-mime", + "operation": "reject", + "fixture": "binary", + "input": { "bytes": "AQ", "mimeType": "not a mime" }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "errors/text-extra-field", + "operation": "reject", + "fixture": "constrained-text", + "input": { "text": "hello", "extra": true }, + "expected": { "kind": "invalid-json" } + }, + { + "id": "json-schema/s64", + "operation": "json-schema", + "fixture": "s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/s64-restrictions", + "operation": "json-schema", + "fixture": "constrained-s64", + "path": "", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9007199254740993", + "x-golem-maximum": "9007199254740993" + } + }, + { + "id": "json-schema/u64", + "operation": "json-schema", + "fixture": "u64", + "path": "", + "expected": { + "type": "string", + "format": "uint64", + "pattern": "^(?:0|[1-9][0-9]*)$", + "x-golem-minimum": "0", + "x-golem-maximum": "18446744073709551615" + } + }, + { + "id": "json-schema/u32-restrictions", + "operation": "json-schema", + "fixture": "constrained-u32", + "path": "", + "expected": { + "type": "integer", + "minimum": 2, + "maximum": 10 + } + }, + { + "id": "json-schema/f64-restrictions", + "operation": "json-schema", + "fixture": "constrained-f64", + "path": "", + "expected": { + "type": "number", + "minimum": -1.5, + "maximum": 2.5 + } + }, + { + "id": "json-schema/text-restrictions", + "operation": "json-schema", + "fixture": "constrained-text", + "path": "", + "expected": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "pattern": "^[a-z]+$" + }, + "language": { + "type": "string", + "enum": ["en", "de"] + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + { + "id": "json-schema/binary-restrictions", + "operation": "json-schema", + "fixture": "constrained-binary", + "path": "", + "expected": { + "type": "object", + "properties": { + "bytes": { + "type": "string", + "contentEncoding": "base64url", + "pattern": "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$", + "minLength": 3, + "maxLength": 6 + }, + "mimeType": { + "type": "string", + "pattern": "^[A-Za-z0-9!#$&^_.+\\-]+\\/[A-Za-z0-9!#$&^_.+\\-]+$", + "enum": ["image/png", "application/octet-stream"] + } + }, + "required": ["bytes"], + "additionalProperties": false + } + }, + { + "id": "json-schema/duration-nanoseconds", + "operation": "json-schema", + "fixture": "duration", + "path": "/properties/nanoseconds", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/quantity-mantissa", + "operation": "json-schema", + "fixture": "quantity", + "path": "/properties/mantissa", + "expected": { + "type": "string", + "format": "int64", + "pattern": "^(?:0|-[1-9][0-9]*|[1-9][0-9]*)$", + "x-golem-minimum": "-9223372036854775808", + "x-golem-maximum": "9223372036854775807" + } + }, + { + "id": "json-schema/optional-required", + "operation": "json-schema", + "fixture": "optional-record", + "path": "/required", + "expected": [] + }, + { + "id": "json-schema/unsupported-leaves", + "operation": "semantic", + "fixture": "unsupported-leaves", + "expected": { "count": 5, "schema": { "not": {} } } + }, + { + "id": "schema/all-kinds", + "operation": "semantic", + "fixture": "all-kinds", + "expected": { + "names": [ + "ref", "bool", "s8", "s16", "s32", "s64", "u8", "u16", "u32", "u64", + "f32", "f64", "char", "string", "record", "variant", "enum", "flags", "tuple", + "list", "fixed-list", "map", "option", "result", "text", "binary", "path", "url", + "datetime", "duration", "quantity", "union", "secret", "quota-token", + "permission-card", "future", "stream" + ] + } + }, + { + "id": "schema/all-restrictions", + "operation": "semantic", + "fixture": "all-restrictions", + "expected": { + "names": [ + "numeric-minimum", "numeric-maximum", "numeric-unit", + "text-languages", "text-min-length", "text-max-length", "text-regex", + "binary-mime-types", "binary-min-bytes", "binary-max-bytes", + "path-direction", "path-kind", "path-mime-types", "path-extensions", + "url-schemes", "url-hosts", "quantity-base-unit", "quantity-suffixes", + "quantity-minimum", "quantity-maximum", + "union-prefix", "union-suffix", "union-regex", "union-field" + ] + } + }, + { + "id": "graph/references-validation-equivalence", + "operation": "semantic", + "fixture": "graph", + "expected": { "valid": true, "equivalent": true } + }, + { + "id": "tool/canonical-input", + "operation": "roundtrip", + "fixture": "tool-input", + "input": { "pattern": "*.rs", "paths": ["src", "tests"] }, + "expected": { "pattern": "*.rs", "paths": ["src", "tests"], "ignoreCase": null } + }, + { + "id": "config/canonical-entry", + "operation": "roundtrip", + "fixture": "config-entry", + "input": { "path": ["limits", "maximum"], "value": "9223372036854775807" }, + "expected": { "path": ["limits", "maximum"], "value": "9223372036854775807" } + }, + { + "id": "constraints/rejected-value", + "operation": "reject", + "fixture": "constrained-u32", + "input": 11, + "expected": { "kind": "constraint-violation" } + }, + { + "id": "compatibility/result", + "operation": "roundtrip", + "fixture": "result", + "input": { "ok": "ready" }, + "expected": { "ok": "ready" } + }, + { + "id": "compatibility/custom-error", + "operation": "roundtrip", + "fixture": "custom-error", + "input": { "err": { "code": "denied", "retryable": false } }, + "expected": { "err": { "code": "denied", "retryable": false } } + } + ] +}