diff --git a/docs/src/content/next/how-to-guides.mdx b/docs/src/content/next/how-to-guides.mdx index 3f7aea9239..68b3da1f15 100644 --- a/docs/src/content/next/how-to-guides.mdx +++ b/docs/src/content/next/how-to-guides.mdx @@ -8,6 +8,6 @@ Practical, step-by-step guides for building with Golem. Each guide covers a spec - + diff --git a/docs/src/content/next/how-to-guides/scala.mdx b/docs/src/content/next/how-to-guides/scala.mdx index b8fd483c66..8deefc17ec 100644 --- a/docs/src/content/next/how-to-guides/scala.mdx +++ b/docs/src/content/next/how-to-guides/scala.mdx @@ -15,6 +15,7 @@ Guides specific to developing Golem agents in Scala. + diff --git a/docs/src/content/next/how-to-guides/scala/_meta.js b/docs/src/content/next/how-to-guides/scala/_meta.js index c9766a2535..f8875c0ba7 100644 --- a/docs/src/content/next/how-to-guides/scala/_meta.js +++ b/docs/src/content/next/how-to-guides/scala/_meta.js @@ -9,6 +9,7 @@ export default { "golem-annotate-agent-scala": "Annotating Agent Methods (Scala)", "golem-atomic-block-scala": "Atomic Blocks and Durability Controls (Scala)", "golem-call-from-external-scala": "Calling Agents from External Applications (Scala)", + "golem-agent-reflection-scala": "Calling Agents with Runtime Reflection (Scala)", "golem-call-another-agent-scala": "Calling Another Agent (Scala)", "golem-configure-durability-scala": "Configuring Agent Durability (Scala)", "golem-add-cors-scala": "Configuring CORS for Scala HTTP Endpoints", 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 new file mode 100644 index 0000000000..24d8a6d074 --- /dev/null +++ b/docs/src/content/next/how-to-guides/scala/golem-agent-reflection-scala.mdx @@ -0,0 +1,105 @@ +# Calling Agents with Runtime Reflection (Scala) + +Use generated clients when the complete target definition is available at +compile time. Otherwise choose one authority and invocation path explicitly: +caller-authored codecs, runtime-reflected schemas, or direct schema-free +`SchemaValue` calls. + +## Discover and inspect schemas + +```scala +import golem.reflection.Reflection + +val target = Reflection.getAgentType("CounterAgent").flatMap( + _.toRight(golem.reflection.GolemReflectError.Discovery("CounterAgent is unavailable")) +) +val method = target.flatMap( + _.method("add").toRight(golem.reflection.GolemReflectError.Discovery("add is unavailable")) +) +``` + +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. +`getAgentType` returns `Right(None)` for a missing type and reserves `Left` for +discovery or decoding failures. + +## Use the three reflected/value invocation paths + +JSON convenience automatically packs and unpacks: + +```scala +val invocation = client.method("add").flatMap { add => + // The returned Future contains either a reflection error or JSON invocation. + Right(add.invokeJson(Json.Object("by" -> Json.Number(BigDecimal(5))))) +} +``` + +For explicit reflected packing, call `method.definition.input.packJson`, then +`invokeValue`; after awaiting, call the output `SchemaRef.unpackJson`. + +For direct values, bind `agentId.dynamicClient` and manually construct the +positional record: + +```scala +import golem.schema.SchemaValue + +val call = agentId.dynamicClient.map( + _.method("add").invokeValue( + SchemaValue.RecordValue(List(SchemaValue.U32Value(5))) + ) +) +``` + +Direct clients never discover or validate schemas. Constructor and method +record fields must be packed in declaration order; the runtime authoritatively +accepts or rejects the attempt. + +All reflected and direct methods support awaited, trigger, and scheduled calls +through `invokeValue`, `triggerValue`, and `scheduleValue`. Reflected live +streams are supported by awaited value calls. Trigger and scheduled reflected +or caller-codec calls reject methods whose input or output schema contains a +stream. + +## Define a caller-codec typed contract + +`AgentClientDefinition` does not discover remote schemas. `InputRecordCodec` +and `OutputCodec` are the caller's schema authority; the environment-unique +type name is used only to resolve current implementation identity metadata: + +```scala +import golem.reflection._ +import golem.runtime.{InputRecordCodec, OutputCodec} + +val contract = AgentClientDefinition( + "CounterAgent", + InputRecordCodec.single[String]("name") +) +val add = contract.method( + "add", + InputRecordCodec.single[Int]("by"), + OutputCodec.single[Int] +) + +val counter = contract.client.get("main") +val result = counter.map(_.method(add).invoke(5)) +``` + +Use `agentId.client(contract)` to bind the same caller-owned codecs to an +existing durable identity. Both creation and binding fail when the named type +is not registered in the current environment. + +## Lifecycle attempts + +- Use a supplied `AgentId` directly or inspect it with `parts`. +- Use `AgentId.create` for schema-free durable, known-phantom, or newly + generated phantom identities. +- Reflected and caller-codec factories provide `get`, `getPhantom`, and + `newPhantom`. +- Use `DynamicAgentClient.ephemeral(componentId, typeName, constructorValue)` + for a raw ephemeral invocation address. + +An ephemeral address has no guaranteed reusable pre-invocation identity. The +final identity comes from invocation metadata and must not be treated as a +resumable durable identity. diff --git a/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md new file mode 100644 index 0000000000..0ee1655306 --- /dev/null +++ b/golem-skills/skills/scala/golem-agent-reflection-scala/SKILL.md @@ -0,0 +1,110 @@ +--- +name: golem-agent-reflection-scala +description: "Discovering and calling Golem agents through runtime reflection in Scala. Use when agent types or methods are selected dynamically, schemas must be inspected at runtime, caller-owned codecs are needed, or SchemaValue calls must avoid discovery." +--- + +# Calling Agents with Runtime Reflection (Scala) + +Use generated clients when the complete target definition is available at +compile time. Otherwise choose one authority and invocation path explicitly: +caller-authored codecs, runtime-reflected schemas, or direct schema-free +`SchemaValue` calls. + +## Discover and inspect schemas + +```scala +import golem.reflection.Reflection + +val target = Reflection.getAgentType("CounterAgent").flatMap( + _.toRight(golem.reflection.GolemReflectError.Discovery("CounterAgent is unavailable")) +) +val method = target.flatMap( + _.method("add").toRight(golem.reflection.GolemReflectError.Discovery("add is unavailable")) +) +``` + +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. +`getAgentType` returns `Right(None)` for a missing type and reserves `Left` for +discovery or decoding failures. + +## Use the three reflected/value invocation paths + +JSON convenience automatically packs and unpacks: + +```scala +val invocation = client.method("add").flatMap { add => + // The returned Future contains either a reflection error or JSON invocation. + Right(add.invokeJson(Json.Object("by" -> Json.Number(BigDecimal(5))))) +} +``` + +For explicit reflected packing, call `method.definition.input.packJson`, then +`invokeValue`; after awaiting, call the output `SchemaRef.unpackJson`. + +For direct values, bind `agentId.dynamicClient` and manually construct the +positional record: + +```scala +import golem.schema.SchemaValue + +val call = agentId.dynamicClient.map( + _.method("add").invokeValue( + SchemaValue.RecordValue(List(SchemaValue.U32Value(5))) + ) +) +``` + +Direct clients never discover or validate schemas. Constructor and method +record fields must be packed in declaration order; the runtime authoritatively +accepts or rejects the attempt. + +All reflected and direct methods support awaited, trigger, and scheduled calls +through `invokeValue`, `triggerValue`, and `scheduleValue`. Reflected live +streams are supported by awaited value calls. Trigger and scheduled reflected +or caller-codec calls reject methods whose input or output schema contains a +stream. + +## Define a caller-codec typed contract + +`AgentClientDefinition` does not discover remote schemas. `InputRecordCodec` +and `OutputCodec` are the caller's schema authority; the environment-unique +type name is used only to resolve current implementation identity metadata: + +```scala +import golem.reflection._ +import golem.runtime.{InputRecordCodec, OutputCodec} + +val contract = AgentClientDefinition( + "CounterAgent", + InputRecordCodec.single[String]("name") +) +val add = contract.method( + "add", + InputRecordCodec.single[Int]("by"), + OutputCodec.single[Int] +) + +val counter = contract.client.get("main") +val result = counter.map(_.method(add).invoke(5)) +``` + +Use `agentId.client(contract)` to bind the same caller-owned codecs to an +existing durable identity. Both creation and binding fail when the named type +is not registered in the current environment. + +## Lifecycle attempts + +- Use a supplied `AgentId` directly or inspect it with `parts`. +- Use `AgentId.create` for schema-free durable, known-phantom, or newly + generated phantom identities. +- Reflected and caller-codec factories provide `get`, `getPhantom`, and + `newPhantom`. +- Use `DynamicAgentClient.ephemeral(componentId, typeName, constructorValue)` + for a raw ephemeral invocation address. + +An ephemeral address has no guaranteed reusable pre-invocation identity. The +final identity comes from invocation metadata and must not be treated as a +resumable durable identity. diff --git a/golem-skills/tests/harness/scenarios/rpc-5-runtime-reflection.yaml b/golem-skills/tests/harness/scenarios/rpc-5-runtime-reflection.yaml index 3749ae236f..b604bc070a 100644 --- a/golem-skills/tests/harness/scenarios/rpc-5-runtime-reflection.yaml +++ b/golem-skills/tests/harness/scenarios/rpc-5-runtime-reflection.yaml @@ -134,3 +134,62 @@ steps: equals: "reflected value" - path: "$.direct_value" equals: "direct value" + + - id: "create-scala-project" + only_if: + language: "scala" + create_project: + name: test-app + verify: + build: true + + - id: "add-scala-reflected-rpc" + only_if: + language: "scala" + prompt: > + In the Scala test-app project, add a durable `ReflectionTarget` agent + identified by a string `name`, with an `echo` method accepting and + returning a string. Add a durable `ReflectionCaller` whose async `run` + method discovers `ReflectionTarget`, inspects `echo`, and invokes it + through the JSON convenience path with `hello`. + + Also define a discovery-free caller-codec contract using + `InputRecordCodec` and `OutputCodec`, and invoke `echo` with + `caller codec`. Explicitly pack JSON through the reflected method + `SchemaRef`, invoke its value API, await the result, and unpack it as + `reflected value`. Finally bind a dynamic client to the same AgentId, + manually construct the positional `SchemaValue.RecordValue`, and invoke + `echo` with `direct value` without schemas. + + Return a schema-derived record containing `listed`, `typeName`, + `methodName`, `jsonValue`, `callerCodec`, `reflectedValue`, and + `directValue`. Make sure the project builds successfully. + expectedSkills: + - "golem-agent-reflection" + - "golem-agent-reflection-scala" + verify: + build: true + deploy: true + + - id: "verify-scala-reflected-rpc" + only_if: + language: "scala" + invoke_json: + agent: 'ReflectionCaller("main")' + method: "run" + expect: + result_json: + - path: "$.listed" + equals: true + - path: "$.typeName" + equals: "ReflectionTarget" + - path: "$.methodName" + equals: "echo" + - path: "$.jsonValue" + equals: "hello" + - path: "$.callerCodec" + equals: "caller codec" + - path: "$.reflectedValue" + equals: "reflected value" + - path: "$.directValue" + equals: "direct value" diff --git a/sdks/scala/core/js/src/main/scala/golem/reflection/CallerCodecClient.scala b/sdks/scala/core/js/src/main/scala/golem/reflection/CallerCodecClient.scala new file mode 100644 index 0000000000..477a6962a6 --- /dev/null +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/CallerCodecClient.scala @@ -0,0 +1,193 @@ +/* + * 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.runtime.{InputRecordCodec, OutputCodec, OutputMetadata} +import golem.schema.SchemaValue +import golem.{Datetime, Uuid} + +import scala.concurrent.Future +import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue +import scala.util.control.NonFatal + +/** A discovery-free, caller-authored typed agent contract. */ +final class AgentClientDefinition[Constructor] private ( + val name: String, + val mode: AgentMode, + val constructor: InputRecordCodec[Constructor] +) { + val client: CallerCodecClientFactory[Constructor] = new CallerCodecClientFactory(this) + + def method[Input, Output]( + name: String, + input: InputRecordCodec[Input], + output: OutputCodec[Output] + ): CallerCodecMethod[Input, Output] = + CallerCodecMethod(name, input, output) + + def bind(agentId: AgentId): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = + for { + parts <- agentId.parts + _ <- Either.cond( + parts.typeName == name, + (), + GolemReflectError.Identity(s"Agent client contract '$name' cannot bind '${parts.typeName}'") + ) + _ <- Either.cond( + mode == AgentMode.Durable, + (), + GolemReflectError.Identity(s"Cannot bind an existing identity to ephemeral agent type '$name'") + ) + componentId <- Reflection.componentIdFor(name) + transport <- Transport.create(componentId, name, parts.constructorValue, parts.phantomId) + } yield new CallerCodecAgentClient(this, transport) +} + +object AgentClientDefinition { + def apply[Constructor]( + name: String, + constructor: InputRecordCodec[Constructor], + mode: AgentMode = AgentMode.Durable + ): AgentClientDefinition[Constructor] = + new AgentClientDefinition(name, mode, constructor) +} + +final case class CallerCodecMethod[Input, Output]( + name: String, + input: InputRecordCodec[Input], + output: OutputCodec[Output] +) + +final case class CallerCodecPhantomClient[Constructor]( + agentId: AgentId, + phantomId: Uuid, + client: CallerCodecAgentClient[Constructor] +) + +final class CallerCodecClientFactory[Constructor] private[reflection] ( + definition: AgentClientDefinition[Constructor] +) { + def get(input: Constructor): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = + requireDurable("get").flatMap(_ => create(input, None)) + + def getPhantom(input: Constructor, phantomId: Uuid): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = + create(input, Some(phantomId)) + + def newPhantom( + input: Constructor + ): Either[GolemReflectError, Either[CallerCodecAgentClient[Constructor], CallerCodecPhantomClient[Constructor]]] = + if (definition.mode == AgentMode.Ephemeral) create(input, None).map(Left(_)) + else { + val phantom = Uuid.random() + for { + constructor <- encodeConstructor(input) + componentId <- Reflection.componentIdFor(definition.name) + id <- AgentId.create(componentId, definition.name, constructor, Some(phantom)) + transport <- Transport.create(componentId, definition.name, constructor, Some(phantom)) + client = new CallerCodecAgentClient(definition, transport) + } yield Right(CallerCodecPhantomClient(id, phantom, client)) + } + + private def create( + input: Constructor, + phantomId: Option[Uuid] + ): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = + encodeConstructor(input).flatMap(createValue(_, phantomId)) + + private def createValue( + constructor: SchemaValue, + phantomId: Option[Uuid] + ): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = + Reflection + .componentIdFor(definition.name) + .flatMap(Transport.create(_, definition.name, constructor, phantomId)) + .map(new CallerCodecAgentClient(definition, _)) + + private def encodeConstructor(input: Constructor): Either[GolemReflectError, SchemaValue] = + try Right(definition.constructor.toValue(input)) + catch { case NonFatal(error) => Left(GolemReflectError.SchemaEncode(error.getMessage)) } + + private def requireDurable(operation: String): Either[GolemReflectError, Unit] = + Either.cond( + definition.mode == AgentMode.Durable, + (), + GolemReflectError.Identity(s"$operation is not available for ephemeral agent types") + ) +} + +final class CallerCodecAgentClient[Constructor] private[reflection] ( + definition: AgentClientDefinition[Constructor], + transport: Transport +) { + def method[Input, Output](definition: CallerCodecMethod[Input, Output]): CallerCodecBoundMethod[Input, Output] = + new CallerCodecBoundMethod(definition, transport) +} + +final class CallerCodecBoundMethod[Input, Output] private[reflection] ( + definition: CallerCodecMethod[Input, Output], + transport: Transport +) { + def invoke(input: Input): Future[Either[GolemReflectError, TypedInvocation[Output]]] = + encodeInput(input) match { + case Left(error) => Future.successful(Left(error)) + case Right(value) => + transport + .invokeAndAwait(definition.name, value) + .map(_.flatMap { invocation => + decodeOutput(invocation.value).map(output => TypedInvocation(invocation.metadata, output)) + }) + } + + def trigger(input: Input): Either[GolemReflectError, InvocationMetadata] = + rejectNonAwaitedStreams("trigger").flatMap(_ => encodeInput(input)).flatMap(transport.trigger(definition.name, _)) + + def schedule(at: Datetime, input: Input): Either[GolemReflectError, ScheduledInvocation] = + rejectNonAwaitedStreams("schedule") + .flatMap(_ => encodeInput(input)) + .flatMap(transport.schedule(at, definition.name, _)) + + private def encodeInput(input: Input): Either[GolemReflectError, SchemaValue] = + try Right(definition.input.toValue(input)) + catch { case NonFatal(error) => Left(GolemReflectError.SchemaEncode(error.getMessage)) } + + private def decodeOutput(value: Option[SchemaValue]): Either[GolemReflectError, Output] = + definition.output.metadata match { + case OutputMetadata.Unit => + Either.cond( + value.isEmpty, + ().asInstanceOf[Output], + GolemReflectError.SchemaDecode("unit method returned a value") + ) + case OutputMetadata.Single(_) => + value + .toRight(GolemReflectError.SchemaDecode("single-output method returned no value")) + .flatMap(schemaValue => + definition.output.from.get + .fromValue(schemaValue) + .left + .map(error => GolemReflectError.SchemaDecode(error.message)) + ) + } + + private def rejectNonAwaitedStreams(operation: String): Either[GolemReflectError, Unit] = { + val outputContainsStream = definition.output.metadata match { + case OutputMetadata.Unit => false + case OutputMetadata.Single(graph) => graph.containsStream + } + Either.cond( + !definition.input.graph.containsStream && !outputContainsStream, + (), + GolemReflectError.Validation(s"$operation is unavailable for streaming method '${definition.name}'") + ) + } +} + +final case class TypedInvocation[+A](metadata: InvocationMetadata, value: A) 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 new file mode 100644 index 0000000000..d1e09701e8 --- /dev/null +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/Reflection.scala @@ -0,0 +1,470 @@ +/* + * 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.host.SchemaWireInterop +import golem.host.js.{JsComponentId, JsUuid} +import golem.host.js.schema.{ + JsInputSchema, + JsNamedField, + JsOutputSchema, + JsSchemaGraph, + JsSchemaValueTree, + JsUuid => JsSchemaUuid +} +import golem.runtime.rpc.host.{AgentHostApi, WasmRpcApi} +import golem.runtime.rpc.{CancellationToken, InvocationReceipt} +import golem.schema._ +import golem.schema.SchemaTypeBody.RecordType +import golem.schema.validation.ValueValidation +import golem.schema.wire.SchemaWire +import golem.{Datetime, FutureInterop, Uuid} +import zio.blocks.schema.json.Json + +import scala.concurrent.Future +import scala.scalajs.js +import scala.scalajs.js.JSConverters._ +import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue +import scala.util.control.NonFatal + +import ReflectionInternals._ + +sealed trait AgentMode extends Product with Serializable +object AgentMode { + case object Durable extends AgentMode + case object Ephemeral extends AgentMode +} + +final case class ComponentId(uuid: Uuid) { + private[golem] def toJs: JsComponentId = + JsComponentId(JsUuid(js.BigInt(uuid.highBits.toString), js.BigInt(uuid.lowBits.toString))) +} + +object ComponentId { + private[golem] def fromJs(value: JsComponentId): ComponentId = + ComponentId(Uuid(BigInt(value.uuid.highBits.toString), BigInt(value.uuid.lowBits.toString))) +} + +final case class AgentId(componentId: ComponentId, value: String) { + def parts: Either[GolemReflectError, AgentIdParts] = AgentId.parse(this) + def dynamicClient: Either[GolemReflectError, DynamicAgentClient] = DynamicAgentClient.fromAgentId(this) + def client[Constructor]( + definition: AgentClientDefinition[Constructor] + ): Either[GolemReflectError, CallerCodecAgentClient[Constructor]] = definition.bind(this) +} + +final case class AgentIdParts(typeName: String, constructorValue: SchemaValue, phantomId: Option[Uuid]) + +object AgentId { + def create( + componentId: ComponentId, + typeName: String, + constructorValue: SchemaValue, + phantomId: Option[Uuid] = None + ): Either[GolemReflectError, AgentId] = + encode(constructorValue).flatMap(payload => + AgentHostApi + .makeAgentId(typeName, payload, phantomId) + .left + .map(GolemReflectError.Identity.apply) + .map(AgentId(componentId, _)) + ) + + def parse(agentId: AgentId): Either[GolemReflectError, AgentIdParts] = + AgentHostApi + .parseAgentId(agentId.value) + .left + .map(GolemReflectError.Identity.apply) + .flatMap { parts => + try + Right( + AgentIdParts( + parts.agentTypeName, + SchemaWire.schemaValueFromWit(SchemaWireInterop.valueTreeFromJs(parts.payload.value)), + parts.phantom + ) + ) + catch { case NonFatal(error) => Left(GolemReflectError.SchemaDecode(error.getMessage)) } + } +} + +sealed trait GolemReflectError extends Product with Serializable { + def message: String + override def toString: String = message +} + +object GolemReflectError { + final case class Discovery(message: String) extends GolemReflectError + final case class Identity(message: String) extends GolemReflectError + final case class SchemaEncode(message: String) extends GolemReflectError + final case class SchemaDecode(message: String) extends GolemReflectError + final case class Validation(message: String) extends GolemReflectError + final case class Remote(message: String) extends GolemReflectError +} + +final case class AgentMethod( + name: String, + description: String, + promptHint: Option[String], + input: SchemaRef, + output: Option[SchemaRef] +) + +final class AgentType private[reflection] ( + val name: String, + val description: String, + val sourceLanguage: String, + val mode: AgentMode, + val implementedBy: ComponentId, + val constructorInput: SchemaRef, + val methods: List[AgentMethod] +) { + val client: ReflectedAgentClientFactory = new ReflectedAgentClientFactory(this) + + def method(name: String): Option[AgentMethod] = methods.find(_.name == name) + + def agentId(input: Json, phantomId: Option[Uuid] = None): Either[GolemReflectError, AgentId] = + constructorInput + .packJson(input) + .left + .map(error => GolemReflectError.Validation(error.message)) + .flatMap(agentIdValue(_, phantomId)) + + def agentIdValue(input: SchemaValue, phantomId: Option[Uuid] = None): Either[GolemReflectError, AgentId] = + validate(constructorInput, input).flatMap(_ => AgentId.create(implementedBy, name, input, phantomId)) + + def bind(agentId: AgentId): Either[GolemReflectError, ReflectedAgentClient] = + for { + parts <- agentId.parts + _ <- Either.cond( + parts.typeName == name, + (), + GolemReflectError.Identity(s"Agent type '$name' cannot bind '${parts.typeName}'") + ) + _ <- Either.cond( + mode == AgentMode.Durable, + (), + GolemReflectError.Identity(s"Cannot bind an existing identity to ephemeral agent type '$name'") + ) + client <- client.createValue(parts.constructorValue, parts.phantomId) + } yield client +} + +object Reflection { + def getAllAgentTypes(): Either[GolemReflectError, List[AgentType]] = + try sequence(AgentHostApi.getAllAgentTypes().map(decodeAgentType)) + catch { case NonFatal(error) => Left(GolemReflectError.Discovery(error.getMessage)) } + + def getAgentType(name: String): Either[GolemReflectError, Option[AgentType]] = + try + AgentHostApi + .registeredAgentType(name) + .map(decodeAgentType) + .fold[Either[GolemReflectError, Option[AgentType]]](Right(None))(_.map(Some(_))) + catch { case NonFatal(error) => Left(GolemReflectError.Discovery(error.getMessage)) } + + private[reflection] def componentIdFor(name: String): Either[GolemReflectError, ComponentId] = + try + AgentHostApi + .registeredAgentType(name) + .map(value => ComponentId.fromJs(value.implementedBy)) + .toRight(GolemReflectError.Discovery(s"Agent type '$name' is not registered in the current environment")) + catch { case NonFatal(error) => Left(GolemReflectError.Discovery(error.getMessage)) } + + private def decodeAgentType(registered: AgentHostApi.RegisteredAgentType): Either[GolemReflectError, AgentType] = + try { + val raw = registered.agentType + val graph = raw.schema + val decoded = SchemaWire.schemaGraphFromWit(SchemaWireInterop.graphFromJs(graph)) + val methods = raw.methods.toList.map { method => + AgentMethod( + method.name, + method.description, + method.promptHint.toOption, + inputRef(graph, decoded, method.inputSchema), + outputRef(graph, decoded, method.outputSchema) + ) + } + val mode = raw.mode match { + case "durable" => AgentMode.Durable + case "ephemeral" => AgentMode.Ephemeral + case other => throw new IllegalArgumentException(s"unknown agent mode '$other'") + } + Right( + new AgentType( + raw.typeName, + raw.description, + raw.sourceLanguage, + mode, + ComponentId.fromJs(registered.implementedBy), + inputRef(graph, decoded, raw.constructor.inputSchema), + methods + ) + ) + } catch { case NonFatal(error) => Left(GolemReflectError.SchemaDecode(error.getMessage)) } + + private def inputRef(graph: JsSchemaGraph, decoded: SchemaGraph, input: JsInputSchema): SchemaRef = { + if (input.tag != "parameters") throw new IllegalArgumentException(s"unknown input schema '${input.tag}'") + val entries = input.asInstanceOf[js.Dynamic].selectDynamic("val").asInstanceOf[js.Array[JsNamedField]].toList + val fields = entries.collect { + case entry if entry.source.tag == "user-supplied" => + val root = SchemaWire.schemaGraphFromWit(SchemaWireInterop.graphFromJs(graph).copy(root = entry.schema)).root + NamedFieldType(entry.name, root, SchemaWireInterop.metadataFromJs(entry.metadata)) + } + SchemaRef(SchemaGraph(decoded.defs, SchemaType(RecordType(fields)))) + } + + private def outputRef(graph: JsSchemaGraph, decoded: SchemaGraph, output: JsOutputSchema): Option[SchemaRef] = + output.tag match { + case "unit" => None + case "single" => + val root = output.asInstanceOf[js.Dynamic].selectDynamic("val").asInstanceOf[Int] + val rooted = SchemaWire.schemaGraphFromWit(SchemaWireInterop.graphFromJs(graph).copy(root = root)).root + Some(SchemaRef(decoded, rooted)) + case other => throw new IllegalArgumentException(s"unknown output schema '$other'") + } +} + +final case class ReflectedPhantomClient(agentId: AgentId, phantomId: Uuid, client: ReflectedAgentClient) + +final class ReflectedAgentClientFactory private[reflection] (agentType: AgentType) { + def get(input: Json): Either[GolemReflectError, ReflectedAgentClient] = + requireDurable("get").flatMap(_ => pack(input)).flatMap(createValue(_, None)) + + def getValue(input: SchemaValue): Either[GolemReflectError, ReflectedAgentClient] = + requireDurable("getValue").flatMap(_ => createValue(input, None)) + + def getPhantom(input: Json, phantomId: Uuid): Either[GolemReflectError, ReflectedAgentClient] = + pack(input).flatMap(createValue(_, Some(phantomId))) + + def getPhantomValue(input: SchemaValue, phantomId: Uuid): Either[GolemReflectError, ReflectedAgentClient] = + createValue(input, Some(phantomId)) + + def newPhantom(input: Json): Either[GolemReflectError, Either[ReflectedAgentClient, ReflectedPhantomClient]] = + pack(input).flatMap(newPhantomValue) + + def newPhantomValue( + input: SchemaValue + ): Either[GolemReflectError, Either[ReflectedAgentClient, ReflectedPhantomClient]] = + if (agentType.mode == AgentMode.Ephemeral) createValue(input, None).map(Left(_)) + else { + val phantom = Uuid.random() + for { + id <- agentType.agentIdValue(input, Some(phantom)) + client <- createValue(input, Some(phantom)) + } yield Right(ReflectedPhantomClient(id, phantom, client)) + } + + private[reflection] def createValue( + input: SchemaValue, + phantomId: Option[Uuid] + ): Either[GolemReflectError, ReflectedAgentClient] = + validate(agentType.constructorInput, input) + .flatMap(_ => Transport.create(agentType.implementedBy, agentType.name, input, phantomId)) + .map(new ReflectedAgentClient(agentType, _)) + + private def pack(input: Json): Either[GolemReflectError, SchemaValue] = + agentType.constructorInput.packJson(input).left.map(error => GolemReflectError.Validation(error.message)) + + private def requireDurable(operation: String): Either[GolemReflectError, Unit] = + Either.cond( + agentType.mode == AgentMode.Durable, + (), + GolemReflectError.Identity(s"$operation is not available for ephemeral agent types") + ) +} + +final class ReflectedAgentClient private[reflection] (agentType: AgentType, transport: Transport) { + def method(name: String): Either[GolemReflectError, ReflectedAgentMethod] = + agentType + .method(name) + .toRight(GolemReflectError.Discovery(s"Agent type '${agentType.name}' has no method '$name'")) + .map(new ReflectedAgentMethod(_, transport)) +} + +final class ReflectedAgentMethod private[reflection] (val definition: AgentMethod, transport: Transport) { + def invoke(input: Json): Future[Either[GolemReflectError, Invocation[Json]]] = invokeJson(input) + + def invokeJson(input: Json): Future[Either[GolemReflectError, Invocation[Json]]] = + definition.input.packJson(input) match { + case Left(error) => Future.successful(Left(GolemReflectError.Validation(error.message))) + case Right(value) => + invokeValue(value).map(_.flatMap { invocation => + invocation.value match { + case None => Right(Invocation(invocation.metadata, None)) + case Some(result) => + definition.output + .toRight(GolemReflectError.SchemaDecode("unit method returned a value")) + .flatMap(_.unpackJson(result).left.map(error => GolemReflectError.SchemaDecode(error.message))) + .map(json => Invocation(invocation.metadata, Some(json))) + } + }) + } + + def invokeValue(input: SchemaValue): Future[Either[GolemReflectError, Invocation[SchemaValue]]] = + validate(definition.input, input) match { + case Left(error) => Future.successful(Left(error)) + case Right(_) => + transport.invokeAndAwait(definition.name, input).map(_.flatMap(validateInvocationOutput(definition, _))) + } + + def triggerValue(input: SchemaValue): Either[GolemReflectError, InvocationMetadata] = + rejectNonAwaitedStreams("trigger") + .flatMap(_ => validate(definition.input, input)) + .flatMap(_ => transport.trigger(definition.name, input)) + + def triggerJson(input: Json): Either[GolemReflectError, InvocationMetadata] = + definition.input + .packJson(input) + .left + .map(error => GolemReflectError.Validation(error.message)) + .flatMap(triggerValue) + + def scheduleValue(at: Datetime, input: SchemaValue): Either[GolemReflectError, ScheduledInvocation] = + rejectNonAwaitedStreams("schedule") + .flatMap(_ => validate(definition.input, input)) + .flatMap(_ => transport.schedule(at, definition.name, input)) + + def scheduleJson(at: Datetime, input: Json): Either[GolemReflectError, ScheduledInvocation] = + definition.input + .packJson(input) + .left + .map(error => GolemReflectError.Validation(error.message)) + .flatMap(scheduleValue(at, _)) + + private def rejectNonAwaitedStreams(operation: String): Either[GolemReflectError, Unit] = + Either.cond( + !definition.input.containsStream && !definition.output.exists(_.containsStream), + (), + GolemReflectError.Validation(s"$operation is unavailable for streaming method '${definition.name}'") + ) +} + +final case class InvocationMetadata(agentId: AgentId, idempotencyKey: String) +final case class Invocation[+A](metadata: InvocationMetadata, value: Option[A]) +final case class ScheduledInvocation(metadata: InvocationMetadata, cancellationToken: CancellationToken) + +final class DynamicAgentClient private (transport: Transport, val agentId: Option[AgentId]) { + def method(name: String): DynamicAgentMethod = new DynamicAgentMethod(name, transport) +} + +object DynamicAgentClient { + def fromAgentId(agentId: AgentId): Either[GolemReflectError, DynamicAgentClient] = + agentId.parts + .flatMap(parts => Transport.create(agentId.componentId, parts.typeName, parts.constructorValue, parts.phantomId)) + .map(new DynamicAgentClient(_, Some(agentId))) + + /** + * A raw one-shot address. Final identity is supplied by invocation metadata. + */ + def ephemeral( + componentId: ComponentId, + typeName: String, + constructor: SchemaValue + ): Either[GolemReflectError, DynamicAgentClient] = + Transport.create(componentId, typeName, constructor, None).map(new DynamicAgentClient(_, None)) +} + +final class DynamicAgentMethod private[reflection] (val name: String, transport: Transport) { + def invokeValue(input: SchemaValue): Future[Either[GolemReflectError, Invocation[SchemaValue]]] = + transport.invokeAndAwait(name, input) + def triggerValue(input: SchemaValue): Either[GolemReflectError, InvocationMetadata] = transport.trigger(name, input) + def scheduleValue(at: Datetime, input: SchemaValue): Either[GolemReflectError, ScheduledInvocation] = + transport.schedule(at, name, input) +} + +private[reflection] final class Transport private (componentId: ComponentId, raw: WasmRpcApi.WasmRpcClient) { + def invokeAndAwait(method: String, input: SchemaValue): Future[Either[GolemReflectError, Invocation[SchemaValue]]] = + encodeAsync(input).flatMap { payload => + raw.asyncInvokeAndAwaitWithMetadata(method, payload) match { + case Left(error) => Future.successful(Left(GolemReflectError.Remote(error.toString))) + case Right((metadata, pending)) => + FutureInterop + .fromPromise(pending.get()) + .map { result => + decodeOptional(result.toOption).map(value => Invocation(toMetadata(metadata), value)) + } + .recover { case NonFatal(error) => Left(GolemReflectError.Remote(error.getMessage)) } + } + }.recover { case NonFatal(error) => Left(GolemReflectError.SchemaEncode(error.getMessage)) } + + def trigger(method: String, input: SchemaValue): Either[GolemReflectError, InvocationMetadata] = + encode(input).flatMap(payload => + raw + .invokeWithMetadata(method, payload) + .left + .map(error => GolemReflectError.Remote(error.toString)) + .map(toMetadata) + ) + + def schedule(at: Datetime, method: String, input: SchemaValue): Either[GolemReflectError, ScheduledInvocation] = + encode(input).flatMap(payload => + raw + .scheduleCancelableInvocationWithMetadata(at, method, payload) + .left + .map(error => GolemReflectError.Remote(error.toString)) + .map(receipt => ScheduledInvocation(toMetadata(receipt.metadata), receipt.cancellationToken)) + ) + + private def toMetadata(value: golem.runtime.rpc.InvocationMetadata): InvocationMetadata = + InvocationMetadata(AgentId(componentId, value.agentId), value.idempotencyKey) +} + +private[reflection] object Transport { + def create( + componentId: ComponentId, + typeName: String, + constructor: SchemaValue, + phantom: Option[Uuid] + ): Either[GolemReflectError, Transport] = + encode(constructor).map { payload => + val phantomArg = phantom.fold[js.UndefOr[JsSchemaUuid]](js.undefined)(uuid => + JsSchemaUuid(js.BigInt(uuid.highBits.toString), js.BigInt(uuid.lowBits.toString)) + ) + new Transport(componentId, WasmRpcApi.newClient(typeName, payload, phantomArg, js.Array())) + } +} + +private[reflection] object ReflectionInternals { + def validate(schema: SchemaRef, value: SchemaValue): Either[GolemReflectError, Unit] = + schema + .validateValue(value) + .left + .map(errors => GolemReflectError.Validation(errors.map(_.message).mkString("; "))) + .map(_ => ()) + + def validateInvocationOutput( + definition: AgentMethod, + invocation: Invocation[SchemaValue] + ): Either[GolemReflectError, Invocation[SchemaValue]] = + (definition.output, invocation.value) match { + case (None, None) => Right(invocation) + case (Some(schema), Some(value)) => validate(schema, value).map(_ => invocation) + case (None, Some(_)) => Left(GolemReflectError.SchemaDecode("unit method returned a value")) + case (Some(_), None) => Left(GolemReflectError.SchemaDecode("single-output method returned no value")) + } + + def encode(value: SchemaValue): Either[GolemReflectError, JsSchemaValueTree] = + try Right(SchemaWireInterop.valueTreeToJs(SchemaWire.schemaValueToWit(value))) + catch { case NonFatal(error) => Left(GolemReflectError.SchemaEncode(error.getMessage)) } + + def encodeAsync(value: SchemaValue): Future[JsSchemaValueTree] = + SchemaWireInterop.valueTreeToJsAsync(SchemaWire.schemaValueToWit(value)) + + def decodeOptional(value: Option[JsSchemaValueTree]): Either[GolemReflectError, Option[SchemaValue]] = + try Right(value.map(tree => SchemaWire.schemaValueFromWit(SchemaWireInterop.valueTreeFromJs(tree)))) + catch { case NonFatal(error) => Left(GolemReflectError.SchemaDecode(error.getMessage)) } + + def sequence[A](values: List[Either[GolemReflectError, A]]): Either[GolemReflectError, List[A]] = + values.foldRight[Either[GolemReflectError, List[A]]](Right(Nil))((entry, result) => + entry.flatMap(value => result.map(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 new file mode 100644 index 0000000000..553185c9dc --- /dev/null +++ b/sdks/scala/core/js/src/main/scala/golem/reflection/SchemaRef.scala @@ -0,0 +1,590 @@ +/* + * 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.SchemaValue._ +import golem.schema.validation.ValueValidation +import zio.blocks.schema.json.Json + +import scala.collection.immutable.ListMap + +final case class SchemaIssue(message: String, path: List[String] = Nil) + +final class SchemaRef private (val graph: SchemaGraph, val root: SchemaType) { + def containsStream: Boolean = graph.containsStream + + def validateValue(value: SchemaValue): Either[List[SchemaIssue], SchemaValue] = + ValueValidation + .validateValue(graph, root, value) + .left + .map(_.map(error => SchemaIssue(error.message))) + .map(_ => value) + + def validateJson(value: Json): Either[List[SchemaIssue], SchemaValue] = + packJson(value).left.map(error => List(error)).flatMap(validateValue) + + def packJson(value: Json): Either[SchemaIssue, SchemaValue] = + CanonicalJson.pack(graph, root, value) + + 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) +} + +object SchemaRef { + def apply(graph: SchemaGraph): SchemaRef = new SchemaRef(graph, graph.root) + + def apply(graph: SchemaGraph, root: SchemaType): SchemaRef = + new SchemaRef(SchemaGraph(graph.defs, root), root) +} + +private object CanonicalJson { + private val MaxSafeInteger = BigInt("9007199254740991") + + def pack(graph: SchemaGraph, schema: SchemaType, json: Json): Either[SchemaIssue, SchemaValue] = + attempt(packUnsafe(graph, resolve(graph, schema), json)) + + def unpack(graph: SchemaGraph, schema: SchemaType, value: SchemaValue): Either[SchemaIssue, Json] = + attempt(unpackUnsafe(graph, resolve(graph, schema), value)) + + def jsonSchema(graph: SchemaGraph, schema: SchemaType, includeDraftMarker: Boolean): Json = { + val root = schemaJson(graph, schema) + val definitions = Json.Object(graph.defs.toList.map { case (id, definition) => + id -> schemaJson(graph, definition.body) + }: _*) + val rootFields = fields(root).toList ++ + (if (includeDraftMarker) List("$schema" -> Json.String("https://json-schema.org/draft/2020-12/schema")) + else Nil) ++ + (if (graph.defs.isEmpty) Nil else List("$defs" -> definitions)) + Json.Object(rootFields: _*) + } + + private def attempt[A](value: => A): Either[SchemaIssue, A] = + try Right(value) + catch { case error: IllegalArgumentException => Left(SchemaIssue(error.getMessage)) } + + private def fail(message: String): Nothing = throw new IllegalArgumentException(message) + + private def schemaJson(graph: SchemaGraph, schema: SchemaType): Json = { + def typed(name: String, extra: (String, Json)*): Json = Json.Object(("type" -> Json.String(name)) +: extra: _*) + def integer(min: BigInt, max: BigInt): Json = + typed("integer", "minimum" -> number(BigDecimal(min)), "maximum" -> number(BigDecimal(max))) + schema.body match { + case RefType(id) => Json.Object("$ref" -> Json.String(s"#/$$defs/${id.replace("~", "~0").replace("/", "~1")}")) + case BoolType => typed("boolean") + case S8Type(_) => integer(-128, 127) + case S16Type(_) => integer(-32768, 32767) + case S32Type(_) => integer(Int.MinValue, Int.MaxValue) + case S64Type(_) => integer(BigInt(Long.MinValue), BigInt(Long.MaxValue)) + case U8Type(_) => integer(0, 255) + case U16Type(_) => integer(0, 65535) + case U32Type(_) => integer(0, BigInt("4294967295")) + case U64Type(_) => integer(0, (BigInt(1) << 64) - 1) + case F32Type(_) | F64Type(_) => typed("number") + 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.collect { + case field if resolve(graph, field.body).body match { case OptionType(_) => false; case _ => true } => + Json.String(field.name) + }: _*), + "additionalProperties" -> Json.Boolean(false) + ) + case VariantType(cases) => + Json.Object("oneOf" -> Json.Array(cases.map { entry => + entry.payload match { + case None => Json.Object("const" -> Json.String(entry.name)) + case Some(payload) => + Json.Object( + "type" -> Json.String("object"), + "properties" -> Json.Object(entry.name -> schemaJson(graph, payload)), + "required" -> Json.Array(Json.String(entry.name)), + "additionalProperties" -> Json.Boolean(false) + ) + } + }: _*)) + case EnumType(cases) => typed("string", "enum" -> Json.Array(cases.map(Json.String): _*)) + case FlagsType(names) => + typed( + "array", + "items" -> typed("string", "enum" -> Json.Array(names.map(Json.String): _*)), + "uniqueItems" -> Json.Boolean(true) + ) + case TupleType(elements) => + typed( + "array", + "prefixItems" -> Json.Array(elements.map(schemaJson(graph, _)): _*), + "items" -> Json.Boolean(false), + "minItems" -> number(elements.size), + "maxItems" -> number(elements.size) + ) + case ListType(element) => typed("array", "items" -> schemaJson(graph, element)) + case FixedListType(element, length) => + typed( + "array", + "items" -> schemaJson(graph, element), + "minItems" -> number(length), + "maxItems" -> number(length) + ) + case MapType(key, value) => + typed( + "array", + "items" -> typed( + "array", + "prefixItems" -> Json.Array(schemaJson(graph, key), schemaJson(graph, value)), + "items" -> Json.Boolean(false), + "minItems" -> number(2), + "maxItems" -> number(2) + ) + ) + case OptionType(element) => Json.Object("oneOf" -> Json.Array(typed("null"), schemaJson(graph, element))) + case ResultType(ok, err) => + def side(name: String, payload: Option[SchemaType]): Json = Json.Object( + "type" -> Json.String("object"), + "properties" -> Json.Object(name -> payload.fold[Json](typed("null"))(schemaJson(graph, _))), + "required" -> Json.Array(Json.String(name)), + "additionalProperties" -> Json.Boolean(false) + ) + Json.Object("oneOf" -> Json.Array(side("ok", ok), side("err", err))) + case TextType(_) => + typed( + "object", + "properties" -> Json.Object("text" -> typed("string"), "language" -> typed("string")), + "required" -> Json.Array(Json.String("text")), + "additionalProperties" -> Json.Boolean(false) + ) + case BinaryType(_) => + typed( + "object", + "properties" -> Json.Object( + "bytes" -> typed("string", "contentEncoding" -> Json.String("base64url")), + "mimeType" -> typed("string") + ), + "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 QuantityType(_) => + typed( + "object", + "properties" -> Json + .Object("mantissa" -> typed("integer"), "scale" -> typed("integer"), "unit" -> typed("string")), + "required" -> Json.Array(Json.String("mantissa"), Json.String("scale"), Json.String("unit")), + "additionalProperties" -> Json.Boolean(false) + ) + case UnionType(branches) => + Json.Object("oneOf" -> Json.Array(branches.map(branch => schemaJson(graph, branch.body)): _*)) + 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")) + } + } + + private def resolve(graph: SchemaGraph, schema: SchemaType, seen: Set[String] = Set.empty): SchemaType = + schema.body match { + case RefType(id) if seen(id) => fail(s"reference cycle through '$id'") + case RefType(id) => + graph.defs.get(id) match { + case Some(definition) => resolve(graph, definition.body, seen + id) + case None => fail(s"dangling reference '$id'") + } + case _ => schema + } + + private def fields(json: Json): ListMap[String, Json] = json match { + case Json.Object(values) => ListMap(values.toList: _*) + case _ => fail("expected a JSON object") + } + + private def array(json: Json): List[Json] = json match { + case Json.Array(values) => values.toList + case _ => fail("expected a JSON array") + } + + private def string(json: Json): String = json match { + case value: Json.String => value.value + case _ => fail("expected a JSON string") + } + + private def bool(json: Json): Boolean = json match { + case value: Json.Boolean => value.value + case _ => fail("expected a JSON boolean") + } + + private def decimal(json: Json): BigDecimal = json match { + case Json.Number(value) => value + case _ => fail("expected a JSON number") + } + + private def integral(json: Json, min: BigInt, max: BigInt): BigInt = { + val value = decimal(json) + if (!value.isWhole) fail("expected an integer") + val result = value.toBigInt + if (result < min || result > max) fail(s"integer is outside [$min, $max]") + result + } + + private def safeLong(json: Json, unsigned: Boolean): Long = { + val min = if (unsigned) BigInt(0) else -MaxSafeInteger + val value = integral(json, min, MaxSafeInteger) + value.toLong + } + + private def packUnsafe(graph: SchemaGraph, schema: SchemaType, json: Json): SchemaValue = + schema.body match { + case BoolType => BoolValue(bool(json)) + 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 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 F32Type(_) => F32Value(decimal(json).toFloat) + case F64Type(_) => F64Value(decimal(json).toDouble) + case CharType => + val text = string(json) + if (text.codePointCount(0, text.length) != 1) fail("expected one Unicode scalar") + CharValue(text.codePointAt(0)) + case StringType => StringValue(string(json)) + case RecordType(expected) => + 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}'")) + ) + ) + ) + case VariantType(cases) => + json match { + case value: Json.String => + val index = cases.indexWhere(entry => entry.name == value.value && entry.payload.isEmpty) + if (index < 0) fail(s"unknown payload-free variant case '${value.value}'") + VariantValue(index, None) + case _ => + val jsonFields = fields(json) + if (jsonFields.size != 1) fail("expected a single-key variant object") + val (name, payload) = jsonFields.head + val index = cases.indexWhere(_.name == name) + if (index < 0 || cases(index).payload.isEmpty) fail(s"unknown payload variant case '$name'") + VariantValue(index, Some(packUnsafe(graph, resolve(graph, cases(index).payload.get), payload))) + } + case EnumType(cases) => + val name = string(json) + val index = cases.indexOf(name) + if (index < 0) fail(s"unknown enum case '$name'") + EnumValue(index) + case FlagsType(names) => + val selected = array(json).map(string) + selected.find(!names.contains(_)).foreach(name => fail(s"unknown flag '$name'")) + if (selected.distinct.size != selected.size) fail("duplicate flag") + FlagsValue(names.map(selected.contains)) + case TupleType(elements) => + val values = array(json) + if (values.size != elements.size) fail(s"expected ${elements.size} tuple elements") + TupleValue(elements.zip(values).map { case (entry, value) => packUnsafe(graph, resolve(graph, entry), value) }) + case ListType(element) => + ListValue(array(json).map(value => packUnsafe(graph, resolve(graph, element), value))) + case FixedListType(element, length) => + val values = array(json) + if (values.size != length) fail(s"expected $length elements") + FixedListValue(values.map(value => packUnsafe(graph, resolve(graph, element), value))) + case MapType(key, value) => + MapValue(array(json).map { entry => + val pair = array(entry) + if (pair.size != 2) fail("expected a two-element map entry") + SchemaMapEntry( + packUnsafe(graph, resolve(graph, key), pair.head), + packUnsafe(graph, resolve(graph, value), pair(1)) + ) + }) + case OptionType(element) => + json match { + case Json.Null => OptionValue(None) + case other => OptionValue(Some(packUnsafe(graph, resolve(graph, element), other))) + } + case ResultType(ok, err) => + val jsonFields = fields(json) + if (jsonFields.size != 1 || !Set("ok", "err")(jsonFields.head._1)) fail("expected {'ok': ...} or {'err': ...}") + val (side, payload) = jsonFields.head + val expected = if (side == "ok") ok else err + val packed = expected match { + case None if payload == Json.Null => None + case None => fail("expected null unit payload") + case Some(value) => Some(packUnsafe(graph, resolve(graph, value), payload)) + } + ResultValue(if (side == "ok") SchemaResult.Ok(packed) else SchemaResult.Err(packed)) + case TextType(_) => + val jsonFields = fields(json) + TextValue( + string(jsonFields.getOrElse("text", fail("missing field 'text'"))), + jsonFields.get("language").map(string) + ) + case BinaryType(_) => + val jsonFields = fields(json) + BinaryValue( + decodeBase64Url(string(jsonFields.getOrElse("bytes", fail("missing field 'bytes'")))), + jsonFields.get("mimeType").map(string) + ) + 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 QuantityType(_) => + val jsonFields = fields(json) + QuantityValueNode( + QuantityValue( + safeLong(jsonFields("mantissa"), unsigned = false), + integral(jsonFields("scale"), Int.MinValue, Int.MaxValue).toInt, + string(jsonFields("unit")) + ) + ) + case UnionType(branches) => + val matching = branches.filter(branch => discriminatorMatches(branch.discriminator, json)) + if (matching.size != 1) fail(s"expected exactly one matching union branch, found ${matching.size}") + UnionValue(matching.head.tag, packUnsafe(graph, resolve(graph, matching.head.body), json)) + case SecretType(_) | QuotaTokenType(_) | PermissionCardType(_) => + fail("capability values cannot be constructed from JSON") + case FutureType(_) | StreamType(_) => fail("future and stream values have no JSON representation") + case RefType(_) => fail("unresolved schema reference") + } + + private def number(value: BigDecimal): Json = Json.Number(value) + + 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 (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))) + case (StringType, StringValue(x)) => Json.String(x) + case (RecordType(expected), RecordValue(values)) if expected.size == values.size => + Json.Object(expected.zip(values).map { case (field, entry) => + field.name -> unpackUnsafe(graph, resolve(graph, field.body), entry) + }: _*) + case (VariantType(cases), VariantValue(index, payload)) if cases.isDefinedAt(index) => + val entry = cases(index) + payload match { + case None => Json.String(entry.name) + case Some(inner) => + Json.Object( + entry.name -> unpackUnsafe( + graph, + resolve(graph, entry.payload.getOrElse(fail("unexpected variant payload"))), + inner + ) + ) + } + case (EnumType(cases), EnumValue(index)) if cases.isDefinedAt(index) => Json.String(cases(index)) + case (FlagsType(names), FlagsValue(bits)) if names.size == bits.size => + Json.Array(names.zip(bits).collect { case (name, true) => Json.String(name) }: _*) + case (TupleType(types), TupleValue(values)) if types.size == values.size => + Json.Array( + types.zip(values).map { case (entry, inner) => unpackUnsafe(graph, resolve(graph, entry), inner) }: _* + ) + case (ListType(element), ListValue(values)) => + Json.Array(values.map(unpackUnsafe(graph, resolve(graph, element), _)): _*) + case (FixedListType(element, length), FixedListValue(values)) if values.size == length => + Json.Array(values.map(unpackUnsafe(graph, resolve(graph, element), _)): _*) + case (MapType(key, entry), MapValue(values)) => + Json.Array( + values.map(value => + Json.Array( + unpackUnsafe(graph, resolve(graph, key), value.key), + unpackUnsafe(graph, resolve(graph, entry), value.value) + ) + ): _* + ) + case (OptionType(_), OptionValue(None)) => Json.Null + case (OptionType(element), OptionValue(Some(inner))) => unpackUnsafe(graph, resolve(graph, element), inner) + case (ResultType(ok, _), ResultValue(SchemaResult.Ok(inner))) => + val rendered = inner match { + case None => Json.Null + case Some(value) => unpackUnsafe(graph, resolve(graph, ok.getOrElse(fail("unexpected ok payload"))), value) + } + Json.Object("ok" -> rendered) + case (ResultType(_, err), ResultValue(SchemaResult.Err(inner))) => + val rendered = inner match { + case None => Json.Null + case Some(value) => unpackUnsafe(graph, resolve(graph, err.getOrElse(fail("unexpected err payload"))), value) + } + Json.Object("err" -> rendered) + case (TextType(_), TextValue(text, language)) => + Json.Object((List("text" -> Json.String(text)) ++ language.map(value => "language" -> Json.String(value))): _*) + case (BinaryType(_), BinaryValue(bytes, mimeType)) => + Json.Object( + (List("bytes" -> Json.String(encodeBase64Url(bytes))) ++ mimeType.map(value => + "mimeType" -> Json.String(value) + )): _* + ) + case (PathType(_), PathValue(x)) => Json.String(x) + 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 (QuantityType(_), QuantityValueNode(x)) => + Json.Object( + "mantissa" -> number(BigDecimal(x.mantissa)), + "scale" -> number(BigDecimal(x.scale)), + "unit" -> Json.String(x.unit) + ) + case (UnionType(branches), UnionValue(tag, body)) => + val branch = branches.find(_.tag == tag).getOrElse(fail(s"unknown union tag '$tag'")) + unpackUnsafe(graph, resolve(graph, branch.body), body) + case (SecretType(_), _) | (QuotaTokenType(_), _) | (PermissionCardType(_), _) => + fail("capability values cannot be exposed as JSON") + case (FutureType(_), _) | (StreamType(_), _) => fail("future and stream values have no JSON representation") + case _ => fail(s"schema value does not match ${schema.body}") + } + + private def discriminatorMatches(rule: DiscriminatorRule, json: Json): Boolean = rule match { + case DiscriminatorRule.Prefix(value) => + json match { + case text: Json.String => text.value.startsWith(value) + case _ => false + } + case DiscriminatorRule.Suffix(value) => + json match { + case text: Json.String => text.value.endsWith(value) + case _ => false + } + case DiscriminatorRule.Contains(value) => + json match { + case text: Json.String => text.value.contains(value) + case _ => false + } + case DiscriminatorRule.Regex(value) => + json match { + case text: Json.String => value.r.findFirstIn(text.value).nonEmpty + case _ => false + } + case DiscriminatorRule.FieldEquals(field) => + json match { + case Json.Object(values) => + values.toList.find(_._1 == field.fieldName).exists { case (_, value) => + field.literal.forall(expected => value == Json.String(expected)) + } + case _ => false + } + case DiscriminatorRule.FieldAbsent(name) => + json match { + case Json.Object(values) => !values.toList.exists(_._1 == name) + case _ => false + } + } + + private val Base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + + private def encodeBase64Url(bytes: Vector[Byte]): String = { + val result = new StringBuilder + var index = 0 + while (index < bytes.length) { + val first = bytes(index) & 0xff + val second = if (index + 1 < bytes.length) bytes(index + 1) & 0xff else 0 + val third = if (index + 2 < bytes.length) bytes(index + 2) & 0xff else 0 + result += Base64Alphabet.charAt(first >> 2) + result += Base64Alphabet.charAt(((first & 3) << 4) | (second >> 4)) + if (index + 1 < bytes.length) result += Base64Alphabet.charAt(((second & 15) << 2) | (third >> 6)) + if (index + 2 < bytes.length) result += Base64Alphabet.charAt(third & 63) + index += 3 + } + result.result() + } + + private def decodeBase64Url(value: String): Vector[Byte] = { + if (!value.forall(Base64Alphabet.contains(_)) || value.length % 4 == 1) + fail("invalid base64url without padding") + val result = Vector.newBuilder[Byte] + var index = 0 + while (index < value.length) { + val a = Base64Alphabet.indexOf(value(index)) + val b = Base64Alphabet.indexOf(value(index + 1)) + val c = if (index + 2 < value.length) Base64Alphabet.indexOf(value(index + 2)) else 0 + val d = if (index + 3 < value.length) Base64Alphabet.indexOf(value(index + 3)) else 0 + result += ((a << 2) | (b >> 4)).toByte + if (index + 2 < value.length) result += (((b & 15) << 4) | (c >> 2)).toByte + 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") + } + + 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/runtime/rpc/host/WasmRpcApi.scala b/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/host/WasmRpcApi.scala index dc00a54c41..ade7d2a151 100644 --- a/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/host/WasmRpcApi.scala +++ b/sdks/scala/core/js/src/main/scala/golem/runtime/rpc/host/WasmRpcApi.scala @@ -237,7 +237,7 @@ private[golem] object WasmRpcApi { @js.native @JSImport("golem:agent/host@2.0.0", "FutureInvokeResult") - private[rpc] class RawFutureInvokeResult extends js.Object { + private[golem] class RawFutureInvokeResult extends js.Object { def get(): js.Promise[js.UndefOr[JsSchemaValueTree]] = js.native def cancel(): Unit = js.native } 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 new file mode 100644 index 0000000000..e0e8d9f882 --- /dev/null +++ b/sdks/scala/core/js/src/test/scala/golem/reflection/SchemaRefSpec.scala @@ -0,0 +1,129 @@ +/* + * 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.Uuid +import golem.schema._ +import golem.schema.SchemaTypeBody._ +import golem.schema.SchemaValue._ +import zio.test._ +import zio.blocks.schema.json.Json + +import scala.collection.immutable.ListMap + +object SchemaRefSpec extends ZIOSpecDefault { + private val graph = SchemaGraph( + ListMap.empty, + SchemaType( + RecordType( + List( + NamedFieldType("name", SchemaType(StringType)), + NamedFieldType("count", SchemaType(U32Type())), + NamedFieldType("enabled", SchemaType(BoolType)), + NamedFieldType("labels", SchemaType(ListType(SchemaType(StringType)))) + ) + ) + ) + ) + + def spec = suite("SchemaRef")( + test("packs and unpacks canonical record JSON") { + val ref = SchemaRef(graph) + val json = Json.Object( + "name" -> Json.String("worker"), + "count" -> Json.Number(BigDecimal(42)), + "enabled" -> Json.Boolean(true), + "labels" -> Json.Array(Json.String("a"), Json.String("b")) + ) + val expected = RecordValue( + List(StringValue("worker"), U32Value(42), BoolValue(true), ListValue(List(StringValue("a"), StringValue("b")))) + ) + assertTrue(ref.packJson(json) == Right(expected), ref.unpackJson(expected) == Right(json)) + }, + test("rejects unknown fields and invalid direct values") { + val ref = SchemaRef(graph) + val invalidJson = Json.Object( + "name" -> Json.String("worker"), + "count" -> Json.Number(BigDecimal(1)), + "enabled" -> Json.Boolean(true), + "labels" -> Json.Array(), + "extra" -> Json.String("no") + ) + assertTrue( + ref.packJson(invalidJson).isLeft, + ref.validateValue(RecordValue(List(StringValue("too-short")))).isLeft + ) + }, + test("round-trips rich canonical JSON values") { + val rich = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType( + TupleType( + List( + SchemaType(BinaryType(BinaryRestrictions.empty)), + SchemaType(DatetimeType), + SchemaType(DurationType) + ) + ) + ) + ) + ) + val value = TupleValue( + List( + BinaryValue(Vector(0, 1, 2, -1), Some("application/octet-stream")), + DatetimeValue(Datetime(1704067200L, 123000000)), + DurationValue(3723000000004L) + ) + ) + assertTrue(rich.unpackJson(value).flatMap(rich.packJson) == Right(value)) + }, + test("does not expose capabilities as JSON") { + val ref = SchemaRef( + SchemaGraph(ListMap.empty, SchemaType(PermissionCardType(PermissionCardSpec(polymorphic = false)))) + ) + assertTrue(ref.packJson(Json.Null).isLeft) + }, + test("renders canonical JSON Schema") { + val rendered = SchemaRef(graph).toJsonSchema() + assertTrue( + rendered.get("$schema").one == Right(Json.String("https://json-schema.org/draft/2020-12/schema")), + rendered.get("type").one == Right(Json.String("object")) + ) + }, + test("detects nested stream schemas") { + val streaming = SchemaRef( + SchemaGraph( + ListMap.empty, + SchemaType(RecordType(List(NamedFieldType("items", SchemaType(StreamType(Some(SchemaType(StringType)))))))) + ) + ) + assertTrue(!SchemaRef(graph).containsStream, streaming.containsStream) + }, + test("rejects missing, unexpected, and malformed reflected outputs") { + val input = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(RecordType(Nil)))) + val output = SchemaRef(SchemaGraph(ListMap.empty, SchemaType(StringType))) + val metadata = InvocationMetadata(AgentId(ComponentId(Uuid(0, 0)), "test"), "key") + val unit = AgentMethod("unit", "", None, input, None) + val single = AgentMethod("single", "", None, input, Some(output)) + + assertTrue( + ReflectionInternals.validateInvocationOutput(unit, Invocation(metadata, None)).isRight, + ReflectionInternals.validateInvocationOutput(unit, Invocation(metadata, Some(StringValue("extra")))).isLeft, + ReflectionInternals.validateInvocationOutput(single, Invocation(metadata, None)).isLeft, + ReflectionInternals.validateInvocationOutput(single, Invocation(metadata, Some(U32Value(1)))).isLeft, + ReflectionInternals + .validateInvocationOutput(single, Invocation(metadata, Some(StringValue("ok")))) + .isRight + ) + } + ) +}