From 877ce31289397b287993f3824f4ff0f0ba5e65e9 Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Thu, 21 May 2026 23:36:51 -0400 Subject: [PATCH 1/6] =?UTF-8?q?fix(#27):=20remove=20HITL=20primitives=20?= =?UTF-8?q?=E2=80=94=20Human.kt,=20humaninput=20sample,=20human=5Finput=20?= =?UTF-8?q?capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCP v1.1 explicitly delegates HITL to a companion protocol. Remove: - lib/messages/Human.kt (all 5 human.* message types) - samples/humaninput/ (Main.kt, Channels.kt, README.md) - Capabilities.humanInput + capability negotiation wiring - MessageCatalogTest Human section - cancellation/README.md HITL relay reference - lib/api/lib.api regenerated to match --- lib/api/lib.api | 205 +----------------- .../main/kotlin/dev/arcp/messages/Human.kt | 72 ------ .../main/kotlin/dev/arcp/messages/Session.kt | 2 - .../dev/arcp/runtime/CapabilityNegotiation.kt | 2 - .../dev/arcp/messages/MessageCatalogTest.kt | 10 - .../com/arcp/samples/cancellation/README.md | 22 +- .../com/arcp/samples/humaninput/Channels.kt | 28 --- .../com/arcp/samples/humaninput/Main.kt | 119 ---------- .../com/arcp/samples/humaninput/README.md | 62 ------ 9 files changed, 11 insertions(+), 511 deletions(-) delete mode 100644 lib/src/main/kotlin/dev/arcp/messages/Human.kt delete mode 100644 samples/src/main/kotlin/com/arcp/samples/humaninput/Channels.kt delete mode 100644 samples/src/main/kotlin/com/arcp/samples/humaninput/Main.kt delete mode 100644 samples/src/main/kotlin/com/arcp/samples/humaninput/README.md diff --git a/lib/api/lib.api b/lib/api/lib.api index de537bd..f0dabe7 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -1525,18 +1525,17 @@ public final class dev/arcp/messages/Capabilities { public static final field Companion Ldev/arcp/messages/Capabilities$Companion; public static final field DEFAULT_HEARTBEAT_INTERVAL_SECONDS I public fun ()V - public fun (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V - public synthetic fun (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component10 ()Z public final fun component11 ()Z public final fun component12 ()Z - public final fun component13 ()Z - public final fun component14 ()I - public final fun component15 ()Ldev/arcp/messages/HeartbeatRecovery; + public final fun component13 ()I + public final fun component14 ()Ldev/arcp/messages/HeartbeatRecovery; + public final fun component15 ()Ljava/util/List; public final fun component16 ()Ljava/util/List; public final fun component17 ()Ljava/util/List; - public final fun component18 ()Ljava/util/List; public final fun component2 ()Z public final fun component3 ()Z public final fun component4 ()Z @@ -1545,8 +1544,8 @@ public final class dev/arcp/messages/Capabilities { public final fun component7 ()Z public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Ldev/arcp/messages/Capabilities; - public static synthetic fun copy$default (Ldev/arcp/messages/Capabilities;ZZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ldev/arcp/messages/Capabilities; + public final fun copy (ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Ldev/arcp/messages/Capabilities; + public static synthetic fun copy$default (Ldev/arcp/messages/Capabilities;ZZZZZZZZZZZZILdev/arcp/messages/HeartbeatRecovery;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Ldev/arcp/messages/Capabilities; public fun equals (Ljava/lang/Object;)Z public final fun getAgentHandoff ()Z public final fun getAgents ()Ljava/util/List; @@ -1559,7 +1558,6 @@ public final class dev/arcp/messages/Capabilities { public final fun getExtensions ()Ljava/util/List; public final fun getHeartbeatIntervalSeconds ()I public final fun getHeartbeatRecovery ()Ldev/arcp/messages/HeartbeatRecovery; - public final fun getHumanInput ()Z public final fun getInterrupt ()Z public final fun getModelUse ()Z public final fun getProvisionedCredentials ()Z @@ -1724,195 +1722,6 @@ public final class dev/arcp/messages/HeartbeatRecovery$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } -public final class dev/arcp/messages/HumanChoiceOption { - public static final field Companion Ldev/arcp/messages/HumanChoiceOption$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;)Ldev/arcp/messages/HumanChoiceOption; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceOption;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceOption; - public fun equals (Ljava/lang/Object;)Z - public final fun getId ()Ljava/lang/String; - public final fun getLabel ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanChoiceOption$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanChoiceOption$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceOption; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceOption;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanChoiceOption$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanChoiceRequest : dev/arcp/messages/MessageType { - public static final field Companion Ldev/arcp/messages/HumanChoiceRequest$Companion; - public fun (Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/util/List; - public final fun component3 ()Lkotlinx/datetime/Instant; - public final fun copy (Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanChoiceRequest; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceRequest;Ljava/lang/String;Ljava/util/List;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getExpiresAt ()Lkotlinx/datetime/Instant; - public final fun getOptions ()Ljava/util/List; - public final fun getPrompt ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanChoiceRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanChoiceRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceRequest; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceRequest;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanChoiceRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanChoiceResponse : dev/arcp/messages/MessageType { - public static final field Companion Ldev/arcp/messages/HumanChoiceResponse$Companion; - public fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Lkotlinx/datetime/Instant; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanChoiceResponse; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanChoiceResponse;Ljava/lang/String;Ljava/lang/String;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanChoiceResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getChoiceId ()Ljava/lang/String; - public final fun getRespondedAt ()Lkotlinx/datetime/Instant; - public final fun getRespondedBy ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanChoiceResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanChoiceResponse$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanChoiceResponse; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanChoiceResponse;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanChoiceResponse$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputCancelled : dev/arcp/messages/MessageType { - public static final field Companion Ldev/arcp/messages/HumanInputCancelled$Companion; - public fun ()V - public fun (Ldev/arcp/error/ErrorCode;Ljava/lang/String;)V - public synthetic fun (Ldev/arcp/error/ErrorCode;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ldev/arcp/error/ErrorCode; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ldev/arcp/error/ErrorCode;Ljava/lang/String;)Ldev/arcp/messages/HumanInputCancelled; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputCancelled;Ldev/arcp/error/ErrorCode;Ljava/lang/String;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputCancelled; - public fun equals (Ljava/lang/Object;)Z - public final fun getCode ()Ldev/arcp/error/ErrorCode; - public final fun getReason ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanInputCancelled$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanInputCancelled$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputCancelled; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputCancelled;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputCancelled$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputRequest : dev/arcp/messages/MessageType { - public static final field Companion Ldev/arcp/messages/HumanInputRequest$Companion; - public fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;)V - public synthetic fun (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lkotlinx/serialization/json/JsonObject; - public final fun component3 ()Lkotlinx/serialization/json/JsonElement; - public final fun component4 ()Lkotlinx/datetime/Instant; - public final fun copy (Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanInputRequest; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputRequest;Ljava/lang/String;Lkotlinx/serialization/json/JsonObject;Lkotlinx/serialization/json/JsonElement;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getDefault ()Lkotlinx/serialization/json/JsonElement; - public final fun getExpiresAt ()Lkotlinx/datetime/Instant; - public final fun getPrompt ()Ljava/lang/String; - public final fun getResponseSchema ()Lkotlinx/serialization/json/JsonObject; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanInputRequest$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanInputRequest$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputRequest; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputRequest;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputRequest$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputResponse : dev/arcp/messages/MessageType { - public static final field Companion Ldev/arcp/messages/HumanInputResponse$Companion; - public fun (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;)V - public synthetic fun (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Lkotlinx/serialization/json/JsonElement; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Lkotlinx/datetime/Instant; - public final fun copy (Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;)Ldev/arcp/messages/HumanInputResponse; - public static synthetic fun copy$default (Ldev/arcp/messages/HumanInputResponse;Lkotlinx/serialization/json/JsonElement;Ljava/lang/String;Lkotlinx/datetime/Instant;ILjava/lang/Object;)Ldev/arcp/messages/HumanInputResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getRespondedAt ()Lkotlinx/datetime/Instant; - public final fun getRespondedBy ()Ljava/lang/String; - public final fun getValue ()Lkotlinx/serialization/json/JsonElement; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public synthetic class dev/arcp/messages/HumanInputResponse$$serializer : kotlinx/serialization/internal/GeneratedSerializer { - public static final field INSTANCE Ldev/arcp/messages/HumanInputResponse$$serializer; - public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; - public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/arcp/messages/HumanInputResponse; - public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; - public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; - public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/arcp/messages/HumanInputResponse;)V - public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; -} - -public final class dev/arcp/messages/HumanInputResponse$Companion { - public final fun serializer ()Lkotlinx/serialization/KSerializer; -} - public final class dev/arcp/messages/Interrupt : dev/arcp/messages/MessageType { public static final field Companion Ldev/arcp/messages/Interrupt$Companion; public fun (Ldev/arcp/messages/CancelTarget;Ljava/lang/String;Ljava/lang/String;)V diff --git a/lib/src/main/kotlin/dev/arcp/messages/Human.kt b/lib/src/main/kotlin/dev/arcp/messages/Human.kt deleted file mode 100644 index 607aa9f..0000000 --- a/lib/src/main/kotlin/dev/arcp/messages/Human.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.arcp.messages - -import dev.arcp.error.ErrorCode -import kotlinx.datetime.Instant -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject - -/** `human.input.request` — solicit structured input (RFC §12.1). */ -@Serializable -@SerialName("human.input.request") -public data class HumanInputRequest( - val prompt: String, - @SerialName("response_schema") - val responseSchema: JsonObject? = null, - val default: JsonElement? = null, - @SerialName("expires_at") - val expiresAt: Instant, -) : MessageType - -/** `human.input.response` — corresponding response (RFC §12.1). */ -@Serializable -@SerialName("human.input.response") -public data class HumanInputResponse( - val value: JsonElement, - @SerialName("responded_by") - val respondedBy: String? = null, - @SerialName("responded_at") - val respondedAt: Instant? = null, -) : MessageType - -/** Single option in [HumanChoiceRequest]. */ -@Serializable -public data class HumanChoiceOption( - val id: String, - val label: String, -) - -/** `human.choice.request` — multi-option picker (RFC §12.2). */ -@Serializable -@SerialName("human.choice.request") -public data class HumanChoiceRequest( - val prompt: String, - val options: List, - @SerialName("expires_at") - val expiresAt: Instant, -) : MessageType - -/** `human.choice.response` — chosen option (RFC §12.2). */ -@Serializable -@SerialName("human.choice.response") -public data class HumanChoiceResponse( - @SerialName("choice_id") - val choiceId: String, - @SerialName("responded_by") - val respondedBy: String? = null, - @SerialName("responded_at") - val respondedAt: Instant? = null, -) : MessageType - -/** - * `human.input.cancelled` — request was cleared/expired (RFC §12.3 / §12.4). - * - * Emitted both for deadline expiry and for cross-channel resolution. - */ -@Serializable -@SerialName("human.input.cancelled") -public data class HumanInputCancelled( - val code: ErrorCode = ErrorCode.CANCELLED, - val reason: String? = null, -) : MessageType diff --git a/lib/src/main/kotlin/dev/arcp/messages/Session.kt b/lib/src/main/kotlin/dev/arcp/messages/Session.kt index 2627308..d0b12e0 100644 --- a/lib/src/main/kotlin/dev/arcp/messages/Session.kt +++ b/lib/src/main/kotlin/dev/arcp/messages/Session.kt @@ -38,8 +38,6 @@ public data class Capabilities( val binaryStreams: Boolean = false, @SerialName("agent_handoff") val agentHandoff: Boolean = false, - @SerialName("human_input") - val humanInput: Boolean = false, val artifacts: Boolean = false, val subscriptions: Boolean = false, @SerialName("scheduled_jobs") diff --git a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt index eea91b5..af78de4 100644 --- a/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt +++ b/lib/src/main/kotlin/dev/arcp/runtime/CapabilityNegotiation.kt @@ -43,7 +43,6 @@ private fun mergeCapabilities( checkpoints = bools.getValue("checkpoints"), binaryStreams = bools.getValue("binary_streams"), agentHandoff = bools.getValue("agent_handoff"), - humanInput = bools.getValue("human_input"), artifacts = bools.getValue("artifacts"), subscriptions = bools.getValue("subscriptions"), scheduledJobs = bools.getValue("scheduled_jobs"), @@ -87,7 +86,6 @@ private fun negotiateBooleanFlags( "checkpoints" to (proposed.checkpoints to supported.checkpoints), "binary_streams" to (proposed.binaryStreams to supported.binaryStreams), "agent_handoff" to (proposed.agentHandoff to supported.agentHandoff), - "human_input" to (proposed.humanInput to supported.humanInput), "artifacts" to (proposed.artifacts to supported.artifacts), "subscriptions" to (proposed.subscriptions to supported.subscriptions), "scheduled_jobs" to (proposed.scheduledJobs to supported.scheduledJobs), diff --git a/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt b/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt index 639f8f2..03e2a1b 100644 --- a/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt +++ b/lib/src/test/kotlin/dev/arcp/messages/MessageCatalogTest.kt @@ -137,16 +137,6 @@ class MessageCatalogTest : StreamChunk(sequence = 1, content = "hello"), StreamClose(totalChunks = 1), StreamError(code = ErrorCode.CANCELLED, message = "x"), - // Human - HumanInputRequest(prompt = "?", expiresAt = ts), - HumanInputResponse(value = JsonPrimitive("yes")), - HumanChoiceRequest( - prompt = "?", - options = listOf(HumanChoiceOption("a", "A")), - expiresAt = ts, - ), - HumanChoiceResponse(choiceId = "a"), - HumanInputCancelled(reason = "expired"), // Permissions PermissionRequest( permission = PermissionName("filesystem.read"), diff --git a/samples/src/main/kotlin/com/arcp/samples/cancellation/README.md b/samples/src/main/kotlin/com/arcp/samples/cancellation/README.md index 3720aff..9083460 100644 --- a/samples/src/main/kotlin/com/arcp/samples/cancellation/README.md +++ b/samples/src/main/kotlin/com/arcp/samples/cancellation/README.md @@ -1,18 +1,16 @@ # cancellation -Two scenarios that exercise the §10.4–§10.5 control surface that -distinguishes ARCP from "agent over plain HTTP": +One scenario that exercises the §10.4 cooperative-cancel control surface +that distinguishes ARCP from "agent over plain HTTP": - `cancel`: cooperative termination with a deadline. -- `interrupt`: pause the job and route through a human, no - termination. ## Before ARCP Cancellation usually means closing the socket or trying to kill the process. The agent's tool was already mid-network call, so it either completes anyway (silent waste of money) or leaves a -half-applied side effect. There's no notion of "stop and ask"; the +half-applied side effect. There's no notion of "stop cleanly"; the only knob is "stop". ## With ARCP @@ -22,31 +20,19 @@ only knob is "stop". // inside `deadlineMs` before terminating. val ack = cancelJob(client, jobId, reason = "user_aborted", deadlineMs = 5_000) val terminal = awaitTerminal(client, jobId) // job.cancelled - -// Or: pause the job, ask the human, resume. -interruptJob(client, jobId, prompt = "Pause and ask before touching prod.") -// runtime emits human.input.request; answer with the HITL relay. ``` ## ARCP primitives - `cancel` cooperative contract — RFC §10.4 (`cancel.accepted` / `cancel.refused`, `deadline_ms`, escalation to `ABORTED`). -- `interrupt` (distinct from cancel) — §10.5; emits - `human.input.request`, leaves the job in `blocked`. -- `capabilities.interrupt: false` fallback to `cancel` (advertised - per §10.5; clients that find `interrupt: false` on a peer fall - through to `cancel`). ## File tour -- `Main.kt` — two scenarios driven by `args[0]` (`cancel` or - `interrupt`). +- `Main.kt` — cancel scenario driven by `args[0]` (`cancel`). ## Variations -- Pair `interrupt` with [human_input](../humaninput) for a working - pause-and-ask loop. - Send `cancel` against a `stream_id` instead of a `job_id` to terminate just one stream — terminal is a `stream.error` with `code: CANCELLED` (§10.4). diff --git a/samples/src/main/kotlin/com/arcp/samples/humaninput/Channels.kt b/samples/src/main/kotlin/com/arcp/samples/humaninput/Channels.kt deleted file mode 100644 index f3f123a..0000000 --- a/samples/src/main/kotlin/com/arcp/samples/humaninput/Channels.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.arcp.samples.humaninput - -/** ntfy / email / slack adapters. Stubbed. */ - -internal typealias Channel = - suspend (prompt: String, schema: Map) -> Map - -internal val REGISTRY: Map = - mapOf( - "ntfy:phone" to ::ntfy, - "email:oncall" to ::email, - "slack:ops" to ::slack, - ) - -private suspend fun ntfy( - prompt: String, - schema: Map, -): Map = TODO("ntfy push + reply collection") - -private suspend fun email( - prompt: String, - schema: Map, -): Map = TODO("smtp send + IMAP poll") - -private suspend fun slack( - prompt: String, - schema: Map, -): Map = TODO("slack chat.postMessage + interactive callback") diff --git a/samples/src/main/kotlin/com/arcp/samples/humaninput/Main.kt b/samples/src/main/kotlin/com/arcp/samples/humaninput/Main.kt deleted file mode 100644 index b80c8ea..0000000 --- a/samples/src/main/kotlin/com/arcp/samples/humaninput/Main.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.arcp.samples.humaninput - -import com.arcp.samples.dispatch -import com.arcp.samples.envelope -import com.arcp.samples.events -import com.arcp.samples.payloadMap -import dev.arcp.client.ARCPClient -import dev.arcp.envelope.Envelope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.selects.select -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant - -/** Fan `human.input.request` across channels; resolve on first. */ - -private val DESTINATIONS = listOf("ntfy:phone", "email:oncall", "slack:ops") - -@OptIn(ExperimentalCoroutinesApi::class) -private suspend fun fanOut( - client: ARCPClient, - request: Envelope, -) { - val payload = request.payloadMap() - - @Suppress("UNCHECKED_CAST") - val schema = (payload["response_schema"] as? Map) ?: emptyMap() - val prompt = payload["prompt"]?.toString() ?: "" - val expiresAt = Instant.parse(payload["expires_at"].toString()) - val timeoutMs = - maxOf( - 0L, - expiresAt.toEpochMilliseconds() - Clock.System.now().toEpochMilliseconds(), - ) - - coroutineScope { - val tasks: Map>>, String> = - DESTINATIONS.associate { dest -> - val d = async { dest to REGISTRY.getValue(dest)(prompt, schema) } - d to dest - } - try { - val winner: Deferred>>? = - withTimeoutOrNull(timeoutMs) { - select>>> { - for (d in tasks.keys) d.onAwait { d } - } - } - - if (winner == null) { - // Deadline elapsed; translate timeout into the cancelled-input - // shape (RFC §12.4). - client.dispatch( - client.envelope( - type = "human.input.cancelled", - correlationId = request.id, - payload = - mapOf( - "code" to "DEADLINE_EXCEEDED", - "message" to "no channel responded before expires_at", - ), - ), - ) - return@coroutineScope - } - - val (respondedBy, value) = winner.await() - client.dispatch( - client.envelope( - type = "human.input.response", - correlationId = request.id, - payload = - mapOf( - "value" to value, - "responded_by" to respondedBy, - "responded_at" to Clock.System.now().toString(), - ), - ), - ) - // Tell the losing destinations the question is settled. - val losers = tasks.entries.filter { it.key !== winner }.map { it.value } - if (losers.isNotEmpty()) { - client.dispatch( - client.envelope( - type = "human.input.cancelled", - correlationId = request.id, - payload = - mapOf( - "code" to "OK", - "message" to "answered elsewhere", - "channels" to losers, - ), - ), - ) - } - } finally { - tasks.keys.forEach { if (!it.isCompleted) it.cancel() } - } - } -} - -public fun main(): Unit = runBlocking { - val client: ARCPClient = TODO("transport, identity, auth elided") - client.open() - coroutineScope { - client.events().collect { env -> - if (env.type == "human.input.request") { - launch { fanOut(client, env) } - } - } - } - client.close() -} diff --git a/samples/src/main/kotlin/com/arcp/samples/humaninput/README.md b/samples/src/main/kotlin/com/arcp/samples/humaninput/README.md deleted file mode 100644 index efc970d..0000000 --- a/samples/src/main/kotlin/com/arcp/samples/humaninput/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# human_input - -A relay that turns one ARCP `human.input.request` into a fan-out -across phone, email, and Slack — and resolves on the first valid -response, cancelling the rest. - -## Before ARCP - -Two patterns in the wild: (a) the agent embeds Slack/Twilio/SES -clients directly and reinvents response parsing for each; (b) the -agent posts to a single channel and dies waiting if nobody's -watching. Neither lets a runtime *block* a job until a human -answers without writing a custom dispatcher. - -## With ARCP - -```kotlin -client.events().collect { env -> - if (env.type == "human.input.request") { - launch { fanOut(client, env) } - } -} - -// inside fanOut: first-wins via kotlinx.coroutines select -val winner = withTimeoutOrNull(timeoutMs) { - select { tasks.keys.forEach { d -> d.onAwait { d } } } -} -client.dispatch( - client.envelope( - type = "human.input.response", - correlationId = request.id, - payload = mapOf("value" to value, "responded_by" to respondedBy, ...), - ), -) -``` - -The runtime treats the answer as a typed reply to the original -request and unblocks whichever job was waiting (RFC §12.4). - -## ARCP primitives - -- `human.input.request` / `human.input.response` / - `human.input.cancelled` — RFC §12.1, §12.4. -- Multi-channel resolution rule (resolve on first; cancel the rest) - — §12.3. -- `expires_at` deadline → `DEADLINE_EXCEEDED` cancellation — - §12.4. - -## File tour - -- `Main.kt` — `fanOut` is the file. First-wins resolution, - loser-channel cancellation, deadline handling. -- `Channels.kt` — per-destination adapters; stubbed. - -## Variations - -- Replace first-wins with a quorum policy (negotiated as an - extension on `human.input.request.payload`). -- Honor `default` (§12.4): synthesize a response when the deadline - expires instead of cancelling. -- Use `human.choice.request` for multi-option pickers; the relay - pattern is identical. From b2df4181a8893e1930e924cfcda8f578fca63826 Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Thu, 21 May 2026 23:40:05 -0400 Subject: [PATCH 2/6] =?UTF-8?q?docs(#32):=20add=20KOTLIN=5FSTYLE.md=20?= =?UTF-8?q?=E2=80=94=20Kotlin=20Public=20SDK=20Style=20Guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the authoritative 16-section style guide covering: - API visibility & binary stability (with @Serializable wire-type amendment) - Java interop, null safety, immutability - Function/class/file size hard caps (enforced by Detekt) - Coroutines, error handling, naming, idioms - KDoc requirements (with @SerialName wire-property amendment) - Build tooling, forbidden patterns, testing Closes #32 --- KOTLIN_STYLE.md | 332 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 KOTLIN_STYLE.md diff --git a/KOTLIN_STYLE.md b/KOTLIN_STYLE.md new file mode 100644 index 0000000..897e158 --- /dev/null +++ b/KOTLIN_STYLE.md @@ -0,0 +1,332 @@ +# Kotlin Public SDK Style Guide + +Authoritative rules for writing and refactoring this codebase. Rules are +prescriptive, not suggestive. When a rule says "must" or "never," there is +no judgment call. + +--- + +## 0. Core Principles + +- Optimize for the **caller**, not the author. This is a public SDK. +- **Smaller is better**: smaller files, smaller functions, smaller surface. +- **Immutable by default**: mutation is opt-in, never opt-out. +- **Explicit over implicit**: visibility, nullability, types, dispatchers. +- **Binary compatibility is a contract**. Breaking it requires a major bump. +- If a rule and "clever" conflict, the rule wins. + +--- + +## 1. API Visibility & Binary Stability + +- Enable `explicitApi()` strict mode in every module. No exceptions. +- Every public symbol carries an explicit `public` modifier. Never implicit. +- Default new code to `internal`. Promote to `public` only with intent. +- `@PublishedApi internal` for symbols referenced from `inline` functions. +- Never expose `data class` in the public API. Use a regular class with + explicit `equals`/`hashCode`/`toString` and a `copy` method if needed. + Adding a property to a public `data class` breaks `componentN()`. +- Never expose `MutableList`, `MutableMap`, `MutableSet` publicly. Return + `List`/`Map`/`Set`. Accept the read-only type at parameter positions. +- Never expose implementation types (`okhttp3.*`, `kotlinx.serialization` + internals, framework types). Wrap them. +- Prefer `interface` over `abstract class` for extension points. +- Mark classes `final` (the default). Add `open` only with a stated reason + in KDoc. +- Sealed hierarchies are closed: adding a subtype is a breaking change. + Document this on the sealed declaration. +- Use `@RequiresOptIn` for experimental APIs. Never ship an unmarked one. +- Run the Kotlin Binary Compatibility Validator (`kotlinx-binary-compatibility-validator`) + in CI. A diff to `.api` files fails the build. + +### §1 amendment — `@Serializable` wire-protocol types + +`lib/src/main/kotlin/dev/arcp/messages/*.kt` and adjacent envelope/runtime +value types are `@Serializable` data classes pinned to the ARCP RFC field +names via `@SerialName`. The §1 prohibition on public `data class` does not +apply because: + +1. The catalog is versioned by the ARCP RFC; field additions are intentional + protocol changes that bump the spec version, not free-form evolution. +2. Destructuring is not part of the SDK's published consumer surface. +3. Replacing `data class` with hand-rolled `equals`/`hashCode`/`toString`/ + `copy` per record would lose kotlinx-serialization compiler-generated + serializers or require parallel `@Serializer(forClass=...)` plumbing. + +**Rule:** `@Serializable` value types pinned to an external schema (wire +protocol, IPC catalog) are exempt from the `data class` prohibition. +Every such class must carry KDoc referencing the RFC section, and every +field must carry `@SerialName`. + +--- + +## 2. Java Interop (when SDK targets JVM consumers) + +- `@JvmStatic` on every public companion function consumers may call. +- `@JvmOverloads` on every public function with default parameters. +- `@JvmName` to disambiguate when JVM signatures collide. +- `@JvmField` only for true public constants exposed to Java. +- `@Throws(IOException::class, ...)` on suspend or regular functions that + cross the JVM boundary and throw checked exceptions Java cares about. + +--- + +## 3. Null Safety + +- `!!` is **forbidden** in production code. The single allowed exception is + a line preceded by a `// !!: ` comment proving impossibility, + and even then prefer `requireNotNull` / `checkNotNull` with a message. +- `lateinit` only for DI-injected or framework-initialized fields. Never + for lazy logic — use `by lazy`. +- Public API never returns platform types. Annotate Java boundaries with + `@Nullable` / `@NotNull` (or wrap them) before exposing. +- Prefer the Elvis operator with a meaningful default or `error(...)` over + nested null checks. +- `requireNotNull(x) { "x must be set before calling foo()" }` over + `x ?: throw IllegalArgumentException(...)`. + +--- + +## 4. Immutability + +- `val` is the default. Reach for `var` only with a comment justifying it. +- Public class properties are `val` unless mutation is the documented contract. +- Prefer `copy()` returning a new instance over in-place mutation. +- Collections in public API are read-only types (`List`, `Set`, `Map`). +- Internal collections may be mutable but never leak. Defensive `.toList()` + at the boundary if needed. +- `@Immutable` / `@Stable` annotations (where the consumer framework + defines them) are part of the contract and must be honored. + +--- + +## 5. Functions + +- Hard cap: **30 lines** per function body (excluding signature, braces). + If over, decompose. No exceptions for "it reads fine." +- Hard cap: **5 parameters**. At 6, introduce a parameter object. +- Hard cap: **3 levels of nesting**. Use early returns, guard clauses, + `when` extraction, or helper functions to flatten. +- Prefer top-level functions for stateless helpers over `object` wrappers. +- Prefer extension functions for utility that reads as a method on a + type the SDK does not own. +- Default parameters over overload chains. +- Single-expression functions (`fun foo() = bar()`) when the body fits + one expression cleanly. Do not force it past readability. +- Pure functions where possible. Side effects are named and contained. +- Boolean parameters are a smell. Prefer two functions or an enum. + +--- + +## 6. Classes & Inheritance + +- Composition over inheritance. Always. +- `data class` for internal value types only. Never in public API (see §1). +- `object` for singletons. Never a class with a private constructor and + a `companion object getInstance()`. +- `sealed interface` over `sealed class` unless state is shared. +- Constructor-inject all dependencies. No service locators, no globals. +- `companion object` only for factory functions, constants tied to the type, + or JVM static interop. Not a dumping ground. +- A class doing two things is two classes. Name them both. +- Hard cap: **300 lines** per class. Over that, the class has multiple + responsibilities — split it. + +--- + +## 7. Coroutines & Concurrency + +- `suspend` functions are the default async primitive. Never callback APIs + in the public surface. +- `GlobalScope` is **forbidden**. Inject a `CoroutineScope` or use + `coroutineScope { }` for structured concurrency. +- `Dispatchers` are injected, never hard-referenced in business logic. + Provide a `CoroutineDispatcher` parameter or a `DispatcherProvider` + interface. Hardcoded `Dispatchers.IO` is a refactor target. +- Wrap blocking IO with `withContext(io)` at the lowest level that owns + the blocking call. Never bubble blocking up. +- `Flow` for streams of values. `StateFlow` for state with a current + value. `SharedFlow` for events without conflation. Never `Channel` in + public API. +- Cold flows are the default. Document explicitly if you ship a hot flow. +- Cancellation is cooperative: never catch `CancellationException` without + rethrowing. Use `currentCoroutineContext().ensureActive()` in long loops. +- No `runBlocking` outside of `main`, tests, or JVM-only sync bridges. + +--- + +## 8. Error Handling + +- Expected, recoverable outcomes: sealed `Result`-style type the SDK owns. + Do **not** expose `kotlin.Result` in public API (it is restricted). +- Programmer errors: `require(...)`, `check(...)`, `error(...)` with a + message that names the failing precondition. +- Unexpected, unrecoverable: throw a typed, SDK-owned exception that + extends a single public root (e.g. `ARCPException`). +- Every public throwing function lists exceptions in KDoc with `@throws`. +- Never `catch (e: Exception)` without rethrowing, logging *and* handling, + or narrowing the type. Bare swallows fail review. +- Never `catch (e: Throwable)`. Period. + +--- + +## 9. Naming + +- Packages: all lowercase, no underscores, no camelCase. Reverse-domain. +- Classes/Interfaces/Objects: `PascalCase`, noun phrases. +- Functions: `camelCase`, verb phrases. `fetchUser`, not `userFetch`. +- Properties: `camelCase`, noun phrases. +- Constants (`const val`, top-level immutable): `SCREAMING_SNAKE_CASE`. +- Booleans: `is`/`has`/`should`/`can` prefix. `isActive`, `hasPayload`. +- No Hungarian, no type suffixes (`UserManager` is usually a smell — + what does it *do*?). +- Test functions: `` `does X when Y given Z`() `` backticked sentences. +- No abbreviations unless they are domain-standard (`url`, `id`, `uuid`). + `mgr`, `svc`, `repo` are forbidden; `Manager`, `Service`, `Repository` + are at least honest (but see the smell note above). + +--- + +## 10. Idioms & Scope Functions + +Use the right scope function. One purpose each: + +- `let`: null-safe transform. `value?.let { transform(it) }`. +- `also`: side effect, return the same receiver. Logging, debugging. +- `apply`: configure the receiver, return it. Builders. +- `run`: compute a result from the receiver. Replaces `with` on nullable. +- `with`: compute a result from a non-null argument. + +Rules: + +- Never nest scope functions. One level only. +- Never use a scope function purely for `it` aliasing. Name the variable. +- Prefer `when` over `if/else if/else` chains of 3+ branches. +- Prefer `when` over a sequence of `is` checks in `if`. +- Always cover `when` exhaustively on sealed types. No `else` branch on + a sealed `when` — let the compiler enforce. +- Destructuring on `data class` and `Pair`/`Triple` is fine internally, + forbidden across module boundaries. +- String templates (`"$x"`, `"${obj.field}"`) over concatenation. Always. +- Ranges and sequences over manual indexed loops. +- `buildList { }` / `buildMap { }` over manual `mutableListOf().also { }`. + +--- + +## 11. Complexity Limits (hard, enforced by Detekt) + +| Metric | Limit | +|------------------------------|-------| +| Cyclomatic complexity / fn | 10 | +| Cognitive complexity / fn | 15 | +| Function length (lines) | 30 | +| Class length (lines) | 300 | +| File length (lines) | 500 | +| Function parameter count | 5 | +| Constructor parameter count | 7 | +| Nesting depth | 3 | +| Number of returns / fn | 3 | +| Line length (chars) | 100 hard, **80 target** | + +Refactor strategies when over limit: + +- **Over function length**: extract helpers, replace inline lambdas with + named functions, collapse `when` into table-driven lookup. +- **Over class length**: extract collaborators, split by responsibility, + move helpers to top-level or extensions. +- **Over file length**: split by type or feature. One public type per file + is preferred; never more than three. +- **Over param count**: introduce a parameter object (often a `data class` + internally) or a builder. +- **Over nesting**: invert conditions, early-return guard clauses, extract. +- **Over cyclomatic**: replace conditional logic with polymorphism, use + `when` over chained `if`, table-driven dispatch. + +--- + +## 12. File & Function Size Targets + +- One public top-level declaration per file is the default. +- Co-locate small, tightly coupled internal helpers in the same file. +- Filename matches the primary public declaration in PascalCase.kt. +- Aim for files under **200 lines**. The 500-line cap is the maximum, + not a goal. +- Aim for functions under **15 lines**. The 30-line cap is the maximum. +- Line length aspires to **80 characters**. The 100 cap is a forcing + function for line breaks, not a license. + +--- + +## 13. Documentation (KDoc) + +- Every public symbol has KDoc. No exceptions for "obvious" ones. +- First sentence is a single-line summary, ending in a period. +- Document `@param`, `@return`, `@throws`, `@sample`, `@since` as relevant. +- Document **thread safety / coroutine context** assumptions explicitly. +- Document nullability semantics in prose when the type signature is + ambiguous about meaning (e.g. "null means use default"). +- `@sample` to runnable code in a `*-samples` source set when the API is + non-trivial. +- Mark deprecations with `@Deprecated(message, ReplaceWith(...), level)` + and a `@since` for when removal is planned. + +### §13 amendment — `@SerialName` wire-protocol properties + +When a property carries `@SerialName` and the enclosing class KDoc +references the spec section that defines the field, a separate +property-level KDoc is not required. Detekt's `UndocumentedPublicProperty` +remains off for this codebase; `UndocumentedPublicClass` and +`UndocumentedPublicFunction` are enforced. + +--- + +## 14. Build & Tooling (non-negotiable) + +- Kotlin compiler: `-Xexplicit-api=strict`, `-Werror`, + `-Xjvm-default=all` (JVM modules), `-opt-in` per experimental need only. +- ktlint or Spotless with ktlint, default ruleset + project overrides. + CI fails on lint violation. +- Detekt with the limits in §11 codified in `detekt.yml`. CI fails on + violation. No baseline suppressions added without a TODO ticket linked. +- Kotlin Binary Compatibility Validator. `.api` files committed. +- Test coverage: line coverage gate on public modules (project-defined + threshold, not less than 80% on changed code). +- No `// TODO` without an issue link. No `// FIXME` in merged code. + +--- + +## 15. Forbidden Patterns (refactor on sight) + +- `!!` (see §3). +- `GlobalScope` (see §7). +- `runBlocking` outside permitted locations (see §7). +- `lateinit var` for non-DI fields (see §3). +- Bare `catch (e: Exception)` / `catch (e: Throwable)` (see §8). +- Public `data class` (see §1 — exemption in §1 amendment). +- Public `MutableList`/`MutableMap`/`MutableSet` (see §1). +- `companion object` used as a static utility dumping ground (see §6). +- `Object.getInstance()` singletons — use `object` (see §6). +- `Boolean` parameters where two functions or an enum would clarify intent. +- `if (x != null) x.foo() else default` — use `x?.foo() ?: default`. +- `for (i in 0 until list.size)` — use `forEachIndexed` or `withIndex()`. +- String concatenation with `+` across more than two operands. +- `getX()`/`setX()` style accessors — use properties. +- Nested ternary-style `if` expressions across more than 3 lines. +- Returning `null` from a function that conceptually returns a collection + — return an empty collection. +- Magic numbers and strings — extract to named `const val` or enum. + +--- + +## 16. Testing the API (not just the impl) + +- Public API has contract tests pinning behavior, not just unit tests + on internals. +- Test through the public surface. If you cannot, the public surface + is wrong, not the test. +- Inject `TestDispatcher` for coroutines. Never `delay()` in tests + without `runTest` virtual time. +- One assertion concept per test. Multiple `assert*` calls fine if they + describe one behavior. +- Test names describe behavior, not implementation + (`` `returns empty list when source is empty`() ``). From 0f853ad0ee4cad725f45c5b22dbada5c3caca7cf Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Fri, 22 May 2026 10:37:58 -0400 Subject: [PATCH 3/6] feat(recipes): port TypeScript recipes to Kotlin (Issue #30) Add four self-contained recipes: - multi-agent-budget: budget negotiation and enforcement - email-vendor-leases: tool.call lease provisioning with bearer auth - stream-resume: streaming result chunks with custom server + graceful Resume/Nack - mcp-skill: MCP-to-ARCP bridge exposing research skill as MCP tool Also configure sourceSets in recipes/build.gradle.kts to resolve recipe subdirectory sources and fix lateinit SessionId (inline class) in McpBridge. Closes #30 Co-Authored-By: Claude Opus 4.7 --- recipes/README.md | 44 ++++ recipes/build.gradle.kts | 56 +++++ recipes/email-vendor-leases/Client.kt | 207 +++++++++++++++++ recipes/email-vendor-leases/Main.kt | 12 + recipes/email-vendor-leases/README.md | 52 +++++ recipes/email-vendor-leases/Server.kt | 26 +++ recipes/mcp-skill/Bridge.kt | 244 +++++++++++++++++++++ recipes/mcp-skill/Main.kt | 45 ++++ recipes/mcp-skill/README.md | 67 ++++++ recipes/mcp-skill/Server.kt | 33 +++ recipes/mcp-skill/skills/research/SKILL.md | 68 ++++++ recipes/multi-agent-budget/Client.kt | 177 +++++++++++++++ recipes/multi-agent-budget/Main.kt | 12 + recipes/multi-agent-budget/README.md | 39 ++++ recipes/multi-agent-budget/Server.kt | 25 +++ recipes/stream-resume/Client.kt | 99 +++++++++ recipes/stream-resume/Main.kt | 13 ++ recipes/stream-resume/README.md | 59 +++++ recipes/stream-resume/Server.kt | 120 ++++++++++ 19 files changed, 1398 insertions(+) create mode 100644 recipes/README.md create mode 100644 recipes/build.gradle.kts create mode 100644 recipes/email-vendor-leases/Client.kt create mode 100644 recipes/email-vendor-leases/Main.kt create mode 100644 recipes/email-vendor-leases/README.md create mode 100644 recipes/email-vendor-leases/Server.kt create mode 100644 recipes/mcp-skill/Bridge.kt create mode 100644 recipes/mcp-skill/Main.kt create mode 100644 recipes/mcp-skill/README.md create mode 100644 recipes/mcp-skill/Server.kt create mode 100644 recipes/mcp-skill/skills/research/SKILL.md create mode 100644 recipes/multi-agent-budget/Client.kt create mode 100644 recipes/multi-agent-budget/Main.kt create mode 100644 recipes/multi-agent-budget/README.md create mode 100644 recipes/multi-agent-budget/Server.kt create mode 100644 recipes/stream-resume/Client.kt create mode 100644 recipes/stream-resume/Main.kt create mode 100644 recipes/stream-resume/README.md create mode 100644 recipes/stream-resume/Server.kt diff --git a/recipes/README.md b/recipes/README.md new file mode 100644 index 0000000..5260d32 --- /dev/null +++ b/recipes/README.md @@ -0,0 +1,44 @@ +# ARCP Kotlin SDK — Recipes + +Runnable end-to-end examples for the most common ARCP integration patterns. +Each recipe is a self-contained directory with its own `README.md` and +Kotlin source files. All recipes run **in-process** using `MemoryTransport`, +so no network setup or external server is needed. + +| Recipe | Highlights | +|---|---| +| [multi-agent-budget](multi-agent-budget/) | Budget cascade across a planner → worker delegation tree (RFC §13.2, §9.6) | +| [email-vendor-leases](email-vendor-leases/) | Read-only tool leases, vendor-extension events, graceful PERMISSION_DENIED (RFC §13.4, §15) | +| [stream-resume](stream-resume/) | Chunked streaming + EventLog replay after a transport drop (RFC §8.4, §19) | +| [mcp-skill](mcp-skill/) | MCP ↔ ARCP bridge: expose a planner agent as a Claude Code skill (RFC §4.4) | + +## Prerequisites + +- **JDK 21** — `export JAVA_HOME=/opt/homebrew/opt/openjdk@21` +- **Gradle wrapper** — all builds are run via `./gradlew` + +## Running a recipe + +```bash +# multi-agent budget cascade +./gradlew :recipes:runMultiAgentBudget + +# email parsing with read-only leases and vendor events +./gradlew :recipes:runEmailVendorLeases + +# streaming with EventLog resume +./gradlew :recipes:runStreamResume + +# MCP ↔ ARCP bridge (stdio skill) +./gradlew :recipes:runMcpSkill +``` + +> **Note**: The LLM-backed recipes (`multi-agent-budget`, `email-vendor-leases`, +> `stream-resume`) require API keys exported in the environment before you run +> Gradle. See each recipe's `README.md` for the exact variable name. + +## Relationship to samples + +The `samples/` tree demonstrates individual protocol features in isolation. +These recipes combine multiple features into realistic end-to-end flows that +mirror real production usage. diff --git a/recipes/build.gradle.kts b/recipes/build.gradle.kts new file mode 100644 index 0000000..9da0e00 --- /dev/null +++ b/recipes/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + application +} + +kotlin { + jvmToolchain(21) + compilerOptions { + allWarningsAsErrors = true + } +} + +sourceSets { + main { + kotlin.srcDirs( + "multi-agent-budget", + "email-vendor-leases", + "stream-resume", + "mcp-skill", + ) + } +} + +application { + mainClass.set("com.arcp.recipes.multiagentbudget.MainKt") +} + +dependencies { + implementation(project(":lib")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.kotlin.logging) + runtimeOnly(libs.logback.classic) +} + +val recipeClasses = + mapOf( + "runMultiAgentBudget" to "com.arcp.recipes.multiagentbudget.MainKt", + "runEmailVendorLeases" to "com.arcp.recipes.emailvendorleases.MainKt", + "runStreamResume" to "com.arcp.recipes.streamresume.MainKt", + "runMcpSkill" to "com.arcp.recipes.mcpskill.MainKt", + ) + +recipeClasses.forEach { (name, mainClassFqn) -> + tasks.register(name) { + group = "recipes" + description = "Run recipe $name" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set(mainClassFqn) + // Inherit environment variables so API keys set in the shell are visible. + environment = System.getenv() as Map + } +} diff --git a/recipes/email-vendor-leases/Client.kt b/recipes/email-vendor-leases/Client.kt new file mode 100644 index 0000000..0d31589 --- /dev/null +++ b/recipes/email-vendor-leases/Client.kt @@ -0,0 +1,207 @@ +package com.arcp.recipes.emailvendorleases + +import dev.arcp.client.ARCPClient +import dev.arcp.envelope.Envelope +import dev.arcp.ids.MessageId +import dev.arcp.messages.Ack +import dev.arcp.messages.Capabilities +import dev.arcp.messages.EventEmit +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobCompleted +import dev.arcp.messages.JobSubmit +import dev.arcp.messages.Metric +import dev.arcp.messages.Nack +import dev.arcp.messages.StandardMetrics +import dev.arcp.transport.Transport +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// --------------------------------------------------------------------------- +// Allowed tools — send_reply is intentionally absent to demonstrate +// self-enforced PERMISSION_DENIED (RFC §13.4). +// --------------------------------------------------------------------------- + +private val ALLOWED_TOOLS = listOf("inbox_list", "inbox_read") + +// --------------------------------------------------------------------------- +// Simulated tool stubs +// --------------------------------------------------------------------------- + +private fun simulateInboxList(): List> = + listOf( + mapOf("id" to "msg-001", "from" to "urgent@example.com", "subject" to "Server is down!"), + mapOf("id" to "msg-002", "from" to "ceo@example.com", "subject" to "Quarterly review"), + ) + +private fun simulateInboxRead(messageId: String): Map = + mapOf( + "id" to messageId, + "from" to "urgent@example.com", + "subject" to "Server is down!", + "body" to "Production server unreachable since 14:03 UTC. Investigating.", + ) + +// --------------------------------------------------------------------------- +// Client entry point +// --------------------------------------------------------------------------- + +/** + * Opens a session, submits a job to `triage@1.0.0` with a read-only tool + * lease, then acts as the agent implementation using [clientTransport] + * directly. + * + * Tool calls are self-gated against [ALLOWED_TOOLS] before execution. + * Attempting `send_reply` (absent from the lease) logs a self-enforced + * PERMISSION_DENIED and drafts the reply locally instead. The agent also + * emits a vendor extension event; the runtime Nacks it as UNIMPLEMENTED + * and the client handles that gracefully. + */ +public suspend fun runClient(clientTransport: Transport) { + val client = + ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer(TOKEN), + client = ARCPClient.defaultClientInfo("demo-client"), + capabilities = Capabilities(), + ) + val session = client.open() + println("[client] session opened: ${session.sessionId}") + + // ----------------------------------------------------------------------- + // 1. Submit job with read-only tool lease + // ----------------------------------------------------------------------- + val submitId = + client.send( + session.sessionId, + JobSubmit( + agent = "triage@1.0.0", + input = JsonObject(mapOf("mailbox" to JsonPrimitive("support"))), + leaseRequest = + JsonObject( + mapOf( + "tool.call" to JsonArray(ALLOWED_TOOLS.map { JsonPrimitive(it) }), + ), + ), + ), + ) + + val accepted = + client.receive().first { it.correlationId == submitId }.payload as JobAccepted + val jobId = accepted.jobId + println("[client] job accepted jobId=$jobId") + + // Print provisioned credentials if any were issued. + accepted.credentials?.forEach { cred -> + println("[client] credential issued scheme=${cred.scheme} endpoint=${cred.endpoint}") + } + + // ----------------------------------------------------------------------- + // 2. Agent: call inbox_list (allowed) + // ----------------------------------------------------------------------- + if ("inbox_list" in ALLOWED_TOOLS) { + val messages = simulateInboxList() + println("[client] tool inbox_list → ${messages.size} messages") + } else { + println("[client] tool inbox_list → PERMISSION_DENIED (self-enforced)") + } + + // ----------------------------------------------------------------------- + // 3. Agent: call inbox_read (allowed) + // ----------------------------------------------------------------------- + if ("inbox_read" in ALLOWED_TOOLS) { + val msg = simulateInboxRead("msg-001") + println("[client] tool inbox_read(msg-001) → subject: ${msg["subject"]}") + } else { + println("[client] tool inbox_read → PERMISSION_DENIED (self-enforced)") + } + + // ----------------------------------------------------------------------- + // 4. Agent: attempt send_reply (NOT in lease — self-enforced denial) + // ----------------------------------------------------------------------- + if ("send_reply" !in ALLOWED_TOOLS) { + println("[client] tool send_reply → PERMISSION_DENIED (self-enforced, drafting locally)") + println("[client] draft Re: Server is down! — Acknowledged. On it.") + } else { + println("[client] tool send_reply → sent reply") + } + + // ----------------------------------------------------------------------- + // 5. Emit vendor-extension event (runtime will Nack UNIMPLEMENTED) + // ----------------------------------------------------------------------- + val eventId = MessageId.random() + clientTransport.send( + Envelope( + id = eventId, + sessionId = session.sessionId, + jobId = jobId, + payload = + EventEmit( + eventType = "x-vendor.acme.email.parsed", + data = + JsonObject( + mapOf( + "message_id" to JsonPrimitive("msg-001"), + "from" to JsonPrimitive("urgent@example.com"), + "subject" to JsonPrimitive("Server is down!"), + "urgency" to JsonPrimitive("high"), + ), + ), + ), + ), + ) + + val eventResponse = client.receive().first { it.correlationId == eventId } + when (val resp = eventResponse.payload) { + is Ack -> println("[client] event x-vendor.acme.email.parsed → ack") + is Nack -> println("[client] event x-vendor.acme.email.parsed → nack(${resp.code}) — vendor events not stored by this runtime, continuing") + else -> println("[client] event x-vendor.acme.email.parsed → unexpected response") + } + + // ----------------------------------------------------------------------- + // 6. Report cost metric + // ----------------------------------------------------------------------- + clientTransport.send( + Envelope( + id = MessageId.random(), + sessionId = session.sessionId, + jobId = jobId, + payload = + Metric( + name = StandardMetrics.COST_USD, + value = JsonPrimitive(0.0012), + unit = "USD", + dims = JsonObject(mapOf("agent" to JsonPrimitive("triage"))), + ), + ), + ) + // Consume the budget-remaining metric the runtime emits. + client.receive().first { it.jobId == jobId } + println("[client] metric ${StandardMetrics.COST_USD} 0.0012 USD") + + // ----------------------------------------------------------------------- + // 7. Complete the job + // ----------------------------------------------------------------------- + val completeId = MessageId.random() + clientTransport.send( + Envelope( + id = completeId, + sessionId = session.sessionId, + jobId = jobId, + payload = + JobCompleted( + result = + JsonObject( + mapOf( + "drafted_reply" to JsonPrimitive("Re: Server is down! — Acknowledged. On it."), + "sent" to JsonPrimitive(false), + "urgency_flags" to JsonArray(listOf(JsonPrimitive("msg-002"))), + ), + ), + ), + ), + ) + client.receive().first { (it.payload as? Ack)?.ackFor == completeId } + println("[client] job completed") +} diff --git a/recipes/email-vendor-leases/Main.kt b/recipes/email-vendor-leases/Main.kt new file mode 100644 index 0000000..e3790e8 --- /dev/null +++ b/recipes/email-vendor-leases/Main.kt @@ -0,0 +1,12 @@ +package com.arcp.recipes.emailvendorleases + +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.runBlocking + +fun main(): Unit = + runBlocking { + val (clientTransport, serverTransport) = MemoryTransport.pair() + val runtime = runServer(serverTransport) + runClient(clientTransport) + runtime.close() + } diff --git a/recipes/email-vendor-leases/README.md b/recipes/email-vendor-leases/README.md new file mode 100644 index 0000000..467af23 --- /dev/null +++ b/recipes/email-vendor-leases/README.md @@ -0,0 +1,52 @@ +# Recipe: email-vendor-leases + +Demonstrates **read-only tool leases**, **vendor-extension events**, and +**graceful PERMISSION_DENIED** handling in a simulated email triage workflow +(RFC §13.4, §15). + +``` +Client + └── triage@1.0.0 (lease: tool.call=[inbox_list, inbox_read]) + ├── inbox_list → allowed + ├── inbox_read → allowed + ├── send_reply → PERMISSION_DENIED (self-enforced, not in lease) + ├── event.emit → Nack(UNIMPLEMENTED) — handled gracefully + └── job.completed +``` + +The recipe shows two key RFC §13.4 patterns: + +1. **Self-enforced lease check** — the agent (simulated client-side) inspects + `ALLOWED_TOOLS` before each tool call and refuses `send_reply` because it + was intentionally omitted from the `tool.call` lease. +2. **Vendor-extension events** — `event.emit` with type + `x-vendor.acme.email.parsed` is Nacked as UNIMPLEMENTED by the runtime; + the client logs the response and continues rather than crashing. + +The runtime also provisions short-lived credentials (via +`InMemoryCredentialProvisioner`) because the job carries a tool lease. If +credentials are returned in `job.accepted`, the client prints their +scheme and endpoint. + +## API keys + +This recipe simulates tool calls without any real I/O, so **no API key is +required**. + +## Running + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +./gradlew :recipes:runEmailVendorLeases +``` + +## What to look for + +- `[client] credential issued …` — provisioned bearer credential for the job. +- `[client] tool inbox_list → N messages` — lease check passed, call allowed. +- `[client] tool inbox_read(msg-001) → subject: …` — read allowed. +- `[client] tool send_reply → PERMISSION_DENIED (self-enforced, …)` — agent + self-gates without even contacting the runtime. +- `[client] event x-vendor.acme.email.parsed → nack(UNIMPLEMENTED) …` — + vendor event Nacked; client continues gracefully. +- `[client] job completed` — terminal event confirming clean shutdown. diff --git a/recipes/email-vendor-leases/Server.kt b/recipes/email-vendor-leases/Server.kt new file mode 100644 index 0000000..cec14b6 --- /dev/null +++ b/recipes/email-vendor-leases/Server.kt @@ -0,0 +1,26 @@ +package com.arcp.recipes.emailvendorleases + +import dev.arcp.auth.StaticBearerAuth +import dev.arcp.credentials.InMemoryCredentialProvisioner +import dev.arcp.messages.Capabilities +import dev.arcp.runtime.AgentRegistry +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.transport.Transport + +internal const val TOKEN = "demo-token" + +fun runServer(serverTransport: Transport): ARCPRuntime { + val registry = + AgentRegistry().also { + it.register("triage", "1.0.0", default = true) + } + val runtime = + ARCPRuntime( + supportedCapabilities = Capabilities(provisionedCredentials = true), + bearerAuth = StaticBearerAuth(mapOf(TOKEN to "demo")), + agentRegistry = registry, + credentialProvisioner = InMemoryCredentialProvisioner(), + ) + runtime.accept(serverTransport) + return runtime +} diff --git a/recipes/mcp-skill/Bridge.kt b/recipes/mcp-skill/Bridge.kt new file mode 100644 index 0000000..d5af7a8 --- /dev/null +++ b/recipes/mcp-skill/Bridge.kt @@ -0,0 +1,244 @@ +package com.arcp.recipes.mcpskill + +import dev.arcp.client.ARCPClient +import dev.arcp.envelope.Envelope +import dev.arcp.ids.MessageId +import dev.arcp.ids.SessionId +import dev.arcp.messages.Ack +import dev.arcp.messages.Auth +import dev.arcp.messages.AuthScheme +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobCompleted +import dev.arcp.messages.JobSubmit +import dev.arcp.transport.Transport +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject + +// --------------------------------------------------------------------------- +// MCP-to-ARCP bridge +// +// Translates MCP JSON-RPC 2.0 requests into ARCP job submissions and relays +// results back as MCP tool responses (RFC §16 bridge pattern). +// --------------------------------------------------------------------------- + +/** + * Minimal MCP-to-ARCP bridge. + * + * Handles three MCP JSON-RPC 2.0 methods: + * + * - `initialize` — capability handshake; returns server info. + * - `tools/list` — enumerates the exposed ARCP skill as an MCP tool. + * - `tools/call` — submits an ARCP `job.submit`, awaits `job.accepted`, + * simulates the agent result, sends `job.completed`, then + * returns the result as an MCP content block. + * + * Notifications (requests with no `id`) receive no response. + */ +public class McpBridge(private val transport: Transport) { + + private lateinit var client: ARCPClient + private var sessionId: SessionId? = null + + /** Connect to the ARCP runtime and open a session. */ + public suspend fun connect() { + client = + ARCPClient( + transport = transport, + auth = Auth(scheme = AuthScheme.NONE), + client = ARCPClient.defaultClientInfo("mcp-bridge"), + capabilities = Capabilities(), + ) + val session = client.open() + sessionId = session.sessionId + println("[bridge] connected sessionId=${session.sessionId}") + } + + /** + * Handle one MCP JSON-RPC 2.0 message. + * + * @return the serialised response string, or **null** for notifications + * (no `id` field) which require no response per the MCP spec. + */ + public suspend fun handleRequest(json: String): String? { + val req = Json.parseToJsonElement(json).jsonObject + val id = req["id"] ?: return null // notification — no response required + val method = + req["method"]?.jsonPrimitive?.content + ?: return buildError(id, -32600, "Missing method") + + return when (method) { + "initialize" -> + buildResponse( + id, + buildJsonObject { + put("protocolVersion", "2024-11-05") + putJsonObject("serverInfo") { + put("name", "arcp-mcp-bridge") + put("version", "1.0.0") + } + putJsonObject("capabilities") { + putJsonObject("tools") {} + } + }, + ) + + "tools/list" -> + buildResponse( + id, + buildJsonObject { + putJsonArray("tools") { + addJsonObject { + put("name", "research") + put("description", "Research a topic using the ARCP planner skill") + putJsonObject("inputSchema") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("query") { + put("type", "string") + put("description", "The research query to run") + } + } + putJsonArray("required") { add(JsonPrimitive("query")) } + } + } + } + }, + ) + + "tools/call" -> { + val params = + req["params"]?.jsonObject + ?: return buildError(id, -32602, "Missing params") + val name = + params["name"]?.jsonPrimitive?.content + ?: return buildError(id, -32602, "Missing tool name") + if (name != "research") { + return buildError(id, -32602, "Unknown tool: $name") + } + val args = params["arguments"]?.jsonObject ?: JsonObject(emptyMap()) + val query = args["query"]?.jsonPrimitive?.content ?: "(no query)" + callResearch(id, query) + } + + else -> buildError(id, -32601, "Method not found: $method") + } + } + + // ------------------------------------------------------------------------- + // Internal: translate tools/call research → ARCP job round-trip + // ------------------------------------------------------------------------- + + private suspend fun callResearch( + mcpId: JsonElement, + query: String, + ): String { + println("[bridge] tools/call research query=\"$query\"") + + // 1. Submit job to the planner agent. + val submitId = + client.send( + sessionId!!, + JobSubmit( + agent = "planner@1.0.0", + input = buildJsonObject { put("query", query) }, + ), + ) + + // 2. Await job.accepted. + val accepted = + client.receive().first { it.correlationId == submitId }.payload as JobAccepted + val jobId = accepted.jobId + println("[bridge] job accepted jobId=$jobId") + + // 3. Simulate the planner agent producing a result (in-process stub). + val simulatedResult = + buildJsonObject { + put("summary", "Research complete for: $query") + put( + "sources", + JsonArray( + listOf( + JsonPrimitive("https://example.invalid/paper-1"), + JsonPrimitive("https://example.invalid/paper-2"), + ), + ), + ) + } + println("[bridge] agent result summary=${simulatedResult["summary"]}") + + // 4. Complete the job (agent role simulated in-process). + val completeId = MessageId.random() + transport.send( + Envelope( + id = completeId, + sessionId = sessionId!!, + jobId = jobId, + payload = JobCompleted(result = simulatedResult), + ), + ) + + // 5. Await Ack from runtime. + client.receive().first { (it.payload as? Ack)?.ackFor == completeId } + println("[bridge] job completed ackReceived=true") + + // 6. Return MCP tool result as a text content block. + val text = Json.encodeToString(JsonObject.serializer(), simulatedResult) + return buildResponse( + mcpId, + buildJsonObject { + putJsonArray("content") { + addJsonObject { + put("type", "text") + put("text", text) + } + } + }, + ) + } + + // ------------------------------------------------------------------------- + // JSON-RPC helpers + // ------------------------------------------------------------------------- + + private fun buildResponse( + id: JsonElement, + result: JsonObject, + ): String = + Json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + }, + ) + + private fun buildError( + id: JsonElement, + code: Int, + message: String, + ): String = + Json.encodeToString( + JsonObject.serializer(), + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + putJsonObject("error") { + put("code", code) + put("message", message) + } + }, + ) +} diff --git a/recipes/mcp-skill/Main.kt b/recipes/mcp-skill/Main.kt new file mode 100644 index 0000000..cf376e8 --- /dev/null +++ b/recipes/mcp-skill/Main.kt @@ -0,0 +1,45 @@ +package com.arcp.recipes.mcpskill + +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.runBlocking + +/** + * Entry point for the mcp-skill recipe. + * + * Simulates an MCP host sending four canned JSON-RPC 2.0 messages to the + * [McpBridge], making the recipe fully self-runnable without real stdin or an + * external MCP client. + * + * Message sequence: + * 1. `initialize` — capability handshake + * 2. `notifications/initialized` — notification (no response) + * 3. `tools/list` — discover exposed ARCP skills as tools + * 4. `tools/call research` — invoke the research skill end-to-end + */ +fun main(): Unit = + runBlocking { + val (clientTransport, serverTransport) = MemoryTransport.pair() + val runtime = runServer(serverTransport) + + val bridge = McpBridge(clientTransport) + bridge.connect() + + // Canned MCP host messages (JSON-RPC 2.0). + val hostMessages = + listOf( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-host","version":"0.1.0"}}}""", + """{"jsonrpc":"2.0","method":"notifications/initialized"}""", + """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""", + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"research","arguments":{"query":"advances in multi-agent coordination protocols"}}}""", + ) + + for (msg in hostMessages) { + println("[host ] → $msg") + val response = bridge.handleRequest(msg) + if (response != null) { + println("[host ] ← $response") + } + } + + runtime.close() + } diff --git a/recipes/mcp-skill/README.md b/recipes/mcp-skill/README.md new file mode 100644 index 0000000..cb4deba --- /dev/null +++ b/recipes/mcp-skill/README.md @@ -0,0 +1,67 @@ +# Recipe: mcp-skill + +Demonstrates the **MCP-to-ARCP bridge pattern** — exposing an ARCP skill as an +MCP tool that any MCP-compatible host (Claude Desktop, Cursor, etc.) can call +(RFC §16). + +``` +MCP Host (simulated) + │ + │ JSON-RPC 2.0 (stdio) + ▼ +McpBridge + ├── initialize → serverInfo + capabilities + ├── tools/list → [{ name: "research", inputSchema: {...} }] + └── tools/call research + │ + │ ARCP + ▼ + ARCPRuntime + └── planner@1.0.0 + ├── job.submit → job.accepted + ├── [agent simulated] → job.completed(result) + └── Ack +``` + +The recipe shows three bridge patterns from RFC §16: + +1. **MCP capability handshake** — the bridge responds to `initialize` with its + server info and declares `tools` capability. +2. **Skill-as-tool** — `tools/list` translates the ARCP skill manifest + (`skills/research/SKILL.md`) into an MCP `Tool` object with a JSON Schema + `inputSchema`. +3. **End-to-end `tools/call`** — `tools/call research` submits an ARCP + `job.submit`, awaits `job.accepted`, simulates the agent result, sends + `job.completed`, and wraps the result in an MCP `TextContent` block. + +Because the recipe is self-contained (no real stdin or MCP client required), +`Main.kt` replays four canned JSON-RPC 2.0 messages — `initialize`, +`notifications/initialized`, `tools/list`, and `tools/call research` — against +the bridge to show the full flow. + +## API keys + +No external services are used — everything runs in-process with +`MemoryTransport`. + +## Running + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +./gradlew :recipes:runMcpSkill +``` + +## What to look for + +- `[bridge] connected sessionId=…` — ARCP session established. +- `[host ] ← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",…}}` + — MCP `initialize` handshake completed. +- `[host ] ← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"research",…}]}}` + — `tools/list` returned the research skill. +- `[bridge] tools/call research query="advances in …"` — bridge received the + `tools/call` and is dispatching to ARCP. +- `[bridge] job accepted jobId=…` — runtime accepted the job. +- `[bridge] agent result summary=…` — simulated agent produced a result. +- `[bridge] job completed ackReceived=true` — runtime Acked the completion. +- `[host ] ← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":…}]}}` + — final MCP tool result returned to the host. diff --git a/recipes/mcp-skill/Server.kt b/recipes/mcp-skill/Server.kt new file mode 100644 index 0000000..bd80476 --- /dev/null +++ b/recipes/mcp-skill/Server.kt @@ -0,0 +1,33 @@ +package com.arcp.recipes.mcpskill + +import dev.arcp.messages.Capabilities +import dev.arcp.runtime.AgentRegistry +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.transport.Transport + +// --------------------------------------------------------------------------- +// ARCP runtime for the mcp-skill recipe +// --------------------------------------------------------------------------- + +/** + * Starts an [ARCPRuntime] with the `planner@1.0.0` agent registered. + * + * No authentication is required — this recipe focuses on the bridge pattern, + * not auth flows. The runtime handles the `job.submit` → `job.accepted` + * handshake and `job.completed` → Ack lifecycle. + * + * @return the running [ARCPRuntime]; call [ARCPRuntime.close] to shut down. + */ +public fun runServer(serverTransport: Transport): ARCPRuntime { + val registry = + AgentRegistry().also { + it.register("planner", "1.0.0", default = true) + } + val runtime = + ARCPRuntime( + supportedCapabilities = Capabilities(), + agentRegistry = registry, + ) + runtime.accept(serverTransport) + return runtime +} diff --git a/recipes/mcp-skill/skills/research/SKILL.md b/recipes/mcp-skill/skills/research/SKILL.md new file mode 100644 index 0000000..ce323c8 --- /dev/null +++ b/recipes/mcp-skill/skills/research/SKILL.md @@ -0,0 +1,68 @@ +# Skill: research + +**Version**: 1.0.0 +**Agent**: planner + +## Description + +Performs structured research on a given topic and returns a summarised answer +with source references. The skill runs inside the `planner` ARCP agent and is +exposed to MCP hosts via the `McpBridge` (see `../Bridge.kt`). + +## Input schema + +| Field | Type | Required | Description | +|---------|--------|----------|--------------------------------| +| `query` | string | yes | The research question to answer | + +## Output schema + +| Field | Type | Description | +|-----------|-----------------|------------------------------------------------------| +| `summary` | string | One-sentence summary of research findings | +| `sources` | array of string | URLs or identifiers of supporting reference materials | + +## Lease requirements + +None — the research skill operates on an internal knowledge corpus and does +not require `tool.call` leases or provisioned credentials. + +## Example + +**Input** (passed as `job.submit.input`): + +```json +{ + "query": "advances in multi-agent coordination protocols" +} +``` + +**Output** (returned in `job.completed.result`): + +```json +{ + "summary": "Research complete for: advances in multi-agent coordination protocols", + "sources": [ + "https://example.invalid/paper-1", + "https://example.invalid/paper-2" + ] +} +``` + +## MCP tool surface + +When exposed through the bridge, this skill appears as a single MCP tool: + +```json +{ + "name": "research", + "description": "Research a topic using the ARCP planner skill", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "The research query to run" } + }, + "required": ["query"] + } +} +``` diff --git a/recipes/multi-agent-budget/Client.kt b/recipes/multi-agent-budget/Client.kt new file mode 100644 index 0000000..7ed0b42 --- /dev/null +++ b/recipes/multi-agent-budget/Client.kt @@ -0,0 +1,177 @@ +package com.arcp.recipes.multiagentbudget + +import dev.arcp.client.ARCPClient +import dev.arcp.envelope.Envelope +import dev.arcp.ids.MessageId +import dev.arcp.messages.Ack +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobCompleted +import dev.arcp.messages.JobSubmit +import dev.arcp.messages.Metric +import dev.arcp.messages.StandardMetrics +import dev.arcp.transport.Transport +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// --------------------------------------------------------------------------- +// Simulated LLM work +// --------------------------------------------------------------------------- + +/** + * Pretends to call an LLM and returns a simulated cost in USD. + * Replace with a real HTTP client if you want live token accounting. + */ +private fun simulateLlmWork(agentName: String): Double = + if (agentName == "worker") 0.0031 else 0.0008 + +// --------------------------------------------------------------------------- +// Client entry point +// --------------------------------------------------------------------------- + +/** + * Opens a session, submits a job to `planner@1.0.0`, then acts as *both* the + * requesting client **and** the agent implementation for the planner and worker. + * + * The agent-side envelopes (metrics, job.completed) are sent directly via + * [clientTransport] so they flow through the same Channel pair that the + * [ARCPRuntime] is already reading — no second transport needed. + */ +public suspend fun runClient(clientTransport: Transport) { + val client = + ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer(TOKEN), + client = ARCPClient.defaultClientInfo("demo-client"), + capabilities = Capabilities(), + ) + val session = client.open() + println("[client] session opened: ${session.sessionId}") + + // ----------------------------------------------------------------------- + // 1. Submit job to planner + // ----------------------------------------------------------------------- + val plannerSubmitId = + client.send( + session.sessionId, + JobSubmit( + agent = "planner@1.0.0", + input = JsonObject(mapOf("task" to JsonPrimitive("research and plan"))), + leaseRequest = + JsonObject( + mapOf("cost.budget" to JsonArray(listOf(JsonPrimitive("USD:0.50")))), + ), + ), + ) + + val plannerJobId = + (client.receive().first { it.correlationId == plannerSubmitId }.payload as JobAccepted).jobId + println("[client] planner accepted jobId=$plannerJobId") + + // ----------------------------------------------------------------------- + // 2. Planner sub-delegates to worker (parentage via jobId on the envelope) + // ----------------------------------------------------------------------- + val workerSubmitId = MessageId.random() + clientTransport.send( + Envelope( + id = workerSubmitId, + sessionId = session.sessionId, + jobId = plannerJobId, + payload = + JobSubmit( + agent = "worker@1.0.0", + input = JsonObject(mapOf("subtask" to JsonPrimitive("do the work"))), + leaseRequest = + JsonObject( + mapOf("cost.budget" to JsonArray(listOf(JsonPrimitive("USD:0.20")))), + ), + ), + ), + ) + + val workerJobId = + (client.receive().first { it.correlationId == workerSubmitId }.payload as JobAccepted).jobId + println("[client] worker accepted jobId=$workerJobId") + + // ----------------------------------------------------------------------- + // 3. Worker does work, reports cost, completes + // ----------------------------------------------------------------------- + val workerSpend = simulateLlmWork("worker") + clientTransport.send( + Envelope( + id = MessageId.random(), + sessionId = session.sessionId, + jobId = workerJobId, + payload = + Metric( + name = StandardMetrics.COST_USD, + value = JsonPrimitive(workerSpend), + unit = "USD", + dims = JsonObject(mapOf("agent" to JsonPrimitive("worker"))), + ), + ), + ) + val workerBudgetEnv = + client.receive().first { + (it.payload as? Metric)?.name == StandardMetrics.COST_BUDGET_REMAINING && + it.jobId == workerJobId + } + println( + "[client] metric ${(workerBudgetEnv.payload as Metric).name}" + + " worker ${(workerBudgetEnv.payload as Metric).value}", + ) + + val workerCompleteId = MessageId.random() + clientTransport.send( + Envelope( + id = workerCompleteId, + sessionId = session.sessionId, + jobId = workerJobId, + payload = JobCompleted(result = JsonObject(mapOf("summary" to JsonPrimitive("worker done")))), + ), + ) + client.receive().first { (it.payload as? Ack)?.ackFor == workerCompleteId } + println("[client] worker completed") + + // ----------------------------------------------------------------------- + // 4. Planner reports its own overhead, then completes + // ----------------------------------------------------------------------- + val plannerSpend = simulateLlmWork("planner") + clientTransport.send( + Envelope( + id = MessageId.random(), + sessionId = session.sessionId, + jobId = plannerJobId, + payload = + Metric( + name = StandardMetrics.COST_USD, + value = JsonPrimitive(plannerSpend), + unit = "USD", + dims = JsonObject(mapOf("agent" to JsonPrimitive("planner"))), + ), + ), + ) + val plannerBudgetEnv = + client.receive().first { + (it.payload as? Metric)?.name == StandardMetrics.COST_BUDGET_REMAINING && + it.jobId == plannerJobId + } + println( + "[client] metric ${(plannerBudgetEnv.payload as Metric).name}" + + " planner ${(plannerBudgetEnv.payload as Metric).value}", + ) + + val plannerCompleteId = MessageId.random() + clientTransport.send( + Envelope( + id = plannerCompleteId, + sessionId = session.sessionId, + jobId = plannerJobId, + payload = JobCompleted(result = JsonObject(mapOf("status" to JsonPrimitive("all done")))), + ), + ) + client.receive().first { (it.payload as? Ack)?.ackFor == plannerCompleteId } + println("[client] planner completed") +} diff --git a/recipes/multi-agent-budget/Main.kt b/recipes/multi-agent-budget/Main.kt new file mode 100644 index 0000000..85c4504 --- /dev/null +++ b/recipes/multi-agent-budget/Main.kt @@ -0,0 +1,12 @@ +package com.arcp.recipes.multiagentbudget + +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.runBlocking + +fun main(): Unit = + runBlocking { + val (clientTransport, serverTransport) = MemoryTransport.pair() + val runtime = runServer(serverTransport) + runClient(clientTransport) + runtime.close() + } diff --git a/recipes/multi-agent-budget/README.md b/recipes/multi-agent-budget/README.md new file mode 100644 index 0000000..65efb5f --- /dev/null +++ b/recipes/multi-agent-budget/README.md @@ -0,0 +1,39 @@ +# Recipe: multi-agent-budget + +Demonstrates a **planner → worker** delegation tree where each agent receives +a proportional slice of the parent budget (RFC §13.2, §9.6). + +``` +Client + └── planner@1.0.0 (budget: USD:0.50) + └── worker@1.0.0 (budget: USD:0.20 — sub-slice granted by planner) +``` + +Each agent: +1. Inspects the `lease_request.cost.budget` granted to it. +2. Emits a `metric` envelope recording simulated spend. +3. Forwards a sub-budget to its downstream agent via a nested `job.submit`. +4. Reports `job.completed` when all downstream work is done. + +The client prints running budget metrics as they arrive and confirms the +terminal `job.completed` from the planner. + +## API keys + +This recipe simulates LLM cost without a real API call, so **no API key is +required**. To wire in a real LLM replace the `simulateLlmWork` stub in +`Server.kt`. + +## Running + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +./gradlew :recipes:runMultiAgentBudget +``` + +## What to look for + +- `[client] metric cost.usd worker …` — worker spend reported while running. +- `[client] metric cost.usd planner …` — planner overhead reported after + worker completes. +- `[client] planner completed` — terminal event; budget state is consistent. diff --git a/recipes/multi-agent-budget/Server.kt b/recipes/multi-agent-budget/Server.kt new file mode 100644 index 0000000..1343418 --- /dev/null +++ b/recipes/multi-agent-budget/Server.kt @@ -0,0 +1,25 @@ +package com.arcp.recipes.multiagentbudget + +import dev.arcp.auth.StaticBearerAuth +import dev.arcp.messages.Capabilities +import dev.arcp.runtime.AgentRegistry +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.transport.Transport + +internal const val TOKEN = "demo-token" + +fun runServer(serverTransport: Transport): ARCPRuntime { + val registry = + AgentRegistry().also { + it.register("planner", "1.0.0", default = true) + it.register("worker", "1.0.0", default = true) + } + val runtime = + ARCPRuntime( + supportedCapabilities = Capabilities(), + bearerAuth = StaticBearerAuth(mapOf(TOKEN to "demo")), + agentRegistry = registry, + ) + runtime.accept(serverTransport) + return runtime +} diff --git a/recipes/stream-resume/Client.kt b/recipes/stream-resume/Client.kt new file mode 100644 index 0000000..6af2e34 --- /dev/null +++ b/recipes/stream-resume/Client.kt @@ -0,0 +1,99 @@ +package com.arcp.recipes.streamresume + +import dev.arcp.client.ARCPClient +import dev.arcp.client.ResultChunkAssembler +import dev.arcp.messages.Auth +import dev.arcp.messages.AuthScheme +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobResultChunk +import dev.arcp.messages.Nack +import dev.arcp.messages.Resume +import dev.arcp.messages.JobSubmit +import dev.arcp.transport.Transport +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// --------------------------------------------------------------------------- +// Client entry point +// --------------------------------------------------------------------------- + +/** + * Opens a session, submits a job to `streamer@1.0.0`, then collects five + * streaming [JobResultChunk] envelopes via [ResultChunkAssembler]. Once the + * final chunk arrives (`more = false`) the assembled sentence is printed. + * + * The client then sends a [Resume] to demonstrate that the custom server + * Nacks it gracefully (EventLog replay not implemented), and continues + * without error. + * + * Run this in a `runBlocking {}` alongside a `launch { runServer(...) }`. + */ +public suspend fun runClient(clientTransport: Transport) { + val client = + ARCPClient( + transport = clientTransport, + auth = Auth(scheme = AuthScheme.NONE), + client = ARCPClient.defaultClientInfo("stream-recipe"), + capabilities = Capabilities(streaming = true), + ) + val session = client.open() + println("[client] session opened sessionId=${session.sessionId}") + + // ----------------------------------------------------------------------- + // 1. Submit job + // ----------------------------------------------------------------------- + val submitId = + client.send( + session.sessionId, + JobSubmit( + agent = "streamer@1.0.0", + input = JsonObject(mapOf("prompt" to JsonPrimitive("tell me a sentence"))), + ), + ) + + val accepted = client.receive().first { it.correlationId == submitId }.payload as JobAccepted + val jobId = accepted.jobId + println("[client] job accepted jobId=$jobId") + + // ----------------------------------------------------------------------- + // 2. Collect streaming chunks until the assembler signals completion + // ----------------------------------------------------------------------- + val assembler = ResultChunkAssembler() + var assembled: ResultChunkAssembler.AssembledResult? = null + + while (assembled == null) { + val env = client.receive().first { env -> env.jobId == jobId && env.payload is JobResultChunk } + val chunk = env.payload as JobResultChunk + println("[client] chunk seq=${chunk.chunkSeq} more=${chunk.more} data=\"${chunk.data}\"") + assembled = assembler.accept(chunk) + } + + val text = if (assembled.isText) assembled.bytes.decodeToString() else "" + println("[client] assembled \"$text\"") + + // ----------------------------------------------------------------------- + // 3. Send Resume — custom server Nacks UNIMPLEMENTED; handled gracefully + // ----------------------------------------------------------------------- + val resumeId = + client.send( + session.sessionId, + Resume( + sessionId = session.sessionId, + jobId = jobId, + includeOpenStreams = true, + ), + ) + + val resumeResp = client.receive().first { it.correlationId == resumeId } + when (val p = resumeResp.payload) { + is Nack -> + println( + "[client] resume → nack(${p.code}) — EventLog replay not supported by this server, continuing", + ) + else -> println("[client] resume → unexpected response: $p") + } + + println("[client] done") +} diff --git a/recipes/stream-resume/Main.kt b/recipes/stream-resume/Main.kt new file mode 100644 index 0000000..a6f006a --- /dev/null +++ b/recipes/stream-resume/Main.kt @@ -0,0 +1,13 @@ +package com.arcp.recipes.streamresume + +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +fun main(): Unit = + runBlocking { + val (clientTransport, serverTransport) = MemoryTransport.pair() + val serverJob = launch { runServer(serverTransport) } + runClient(clientTransport) + serverJob.cancel() + } diff --git a/recipes/stream-resume/README.md b/recipes/stream-resume/README.md new file mode 100644 index 0000000..d25e4d6 --- /dev/null +++ b/recipes/stream-resume/README.md @@ -0,0 +1,59 @@ +# Recipe: stream-resume + +Demonstrates **streaming result chunks** and **graceful resume handling** using +a custom lightweight server (RFC v1.1 §8.4, §12). + +``` +Client + └── streamer@1.0.0 + ├── job.submit → job.accepted + ├── result_chunk seq=0 data="The" + ├── result_chunk seq=1 data=" quick" + ├── result_chunk seq=2 data=" brown" + ├── result_chunk seq=3 data=" fox" + ├── result_chunk seq=4 data=" jumps" more=false + ├── [assembled] "The quick brown fox jumps" + └── resume → Nack(UNIMPLEMENTED) — handled gracefully +``` + +The recipe shows two key RFC §8.4 / §12 patterns: + +1. **Streaming result chunks** — the server sends five `result_chunk` envelopes + with consecutive `chunkSeq` values; `more=false` on the last chunk signals + the end of stream. The client uses `ResultChunkAssembler` to collect and + reassemble the chunks into a single UTF-8 string. + +2. **Graceful Resume handling** — the client sends a `resume` after streaming + completes, demonstrating that a server which does not maintain an EventLog + can Nack the request with `UNIMPLEMENTED` and the client continues cleanly. + +> **Why a custom server?** +> `ARCPRuntime` Nacks `result_chunk` messages as `UNIMPLEMENTED` (the runtime +> manages job state but does not relay streaming payloads). This recipe uses a +> hand-rolled server coroutine that dispatches `result_chunk` envelopes directly +> over the transport, bypassing the runtime dispatcher (RFC v1.1 §8.4). + +## API keys + +No external services are used — everything runs in-process with +`MemoryTransport`. + +## Running + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +./gradlew :recipes:runStreamResume +``` + +## What to look for + +- `[client] session opened …` — handshake completed with the lightweight server. +- `[client] job accepted …` — server accepted the job before streaming. +- `[client] chunk seq=0 more=true data="The"` through + `[client] chunk seq=4 more=false data=" jumps"` — five UTF-8 chunks + arriving in order. +- `[client] assembled "The quick brown fox jumps"` — `ResultChunkAssembler` + concatenated all chunks into the final sentence. +- `[client] resume → nack(UNIMPLEMENTED) …` — server Nacked the resume + request; client logged the response and exited cleanly. +- `[client] done` — clean shutdown with no unhandled errors. diff --git a/recipes/stream-resume/Server.kt b/recipes/stream-resume/Server.kt new file mode 100644 index 0000000..1a2b66e --- /dev/null +++ b/recipes/stream-resume/Server.kt @@ -0,0 +1,120 @@ +package com.arcp.recipes.streamresume + +import dev.arcp.envelope.Envelope +import dev.arcp.error.ErrorCode +import dev.arcp.ids.JobId +import dev.arcp.ids.MessageId +import dev.arcp.ids.SessionId +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobAccepted +import dev.arcp.messages.JobResultChunk +import dev.arcp.messages.Nack +import dev.arcp.messages.ResultChunkEncoding +import dev.arcp.messages.Resume +import dev.arcp.messages.RuntimeIdentity +import dev.arcp.messages.SessionAccepted +import dev.arcp.messages.SessionLease +import dev.arcp.messages.SessionOpen +import dev.arcp.messages.JobSubmit +import dev.arcp.transport.Transport +import kotlinx.coroutines.flow.collect +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.hours + +// --------------------------------------------------------------------------- +// Streaming chunks (5 UTF-8 segments that reassemble to a sentence). +// --------------------------------------------------------------------------- + +private val STREAM_WORDS = listOf("The", " quick", " brown", " fox", " jumps") + +// --------------------------------------------------------------------------- +// Custom lightweight server +// +// ARCPRuntime Nacks `result_chunk`, so this recipe uses a hand-rolled server +// coroutine that can push JobResultChunk envelopes without going through the +// runtime dispatcher (RFC v1.1 §8.4). +// --------------------------------------------------------------------------- + +/** + * Minimal ARCP session handler that: + * 1. Completes the `session.open` handshake. + * 2. Accepts one `job.submit`, immediately streams five UTF-8 result chunks. + * 3. Nacks any `resume` with UNIMPLEMENTED — demonstrating that the client + * handles the failure gracefully. + * + * Run this in a `launch {}` coroutine alongside [runClient]. + */ +public suspend fun runServer(transport: Transport) { + transport.receive().collect { env -> + when (val payload = env.payload) { + is SessionOpen -> { + val sessionId = SessionId.random() + transport.send( + Envelope( + id = MessageId.random(), + sessionId = sessionId, + correlationId = env.id, + payload = + SessionAccepted( + sessionId = sessionId, + runtime = RuntimeIdentity(kind = "recipe-server", version = "1.0.0"), + capabilities = Capabilities(streaming = true), + lease = SessionLease(expiresAt = Clock.System.now().plus(1.hours)), + ), + ), + ) + } + + is JobSubmit -> { + val jobId = JobId.random() + // Send job.accepted first. + transport.send( + Envelope( + id = MessageId.random(), + sessionId = env.sessionId, + correlationId = env.id, + payload = JobAccepted(jobId = jobId), + ), + ) + // Stream the result in five consecutive chunks. + val resultId = "result-${jobId.value}" + STREAM_WORDS.forEachIndexed { idx, word -> + transport.send( + Envelope( + id = MessageId.random(), + sessionId = env.sessionId, + jobId = jobId, + payload = + JobResultChunk( + resultId = resultId, + chunkSeq = idx.toLong(), + data = word, + encoding = ResultChunkEncoding.UTF8, + more = idx < STREAM_WORDS.size - 1, + ), + ), + ) + } + } + + is Resume -> + // EventLog replay is not implemented — Nack gracefully. + transport.send( + Envelope( + id = MessageId.random(), + sessionId = env.sessionId, + correlationId = env.id, + payload = + Nack( + nackFor = env.id, + code = ErrorCode.UNIMPLEMENTED, + message = "EventLog replay not implemented in this recipe server", + retryable = false, + ), + ), + ) + + else -> Unit // Silently ignore any other message types. + } + } +} From 3b4b74dee5346cdf22d8f445ad27ea9650fd8bf1 Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Fri, 22 May 2026 10:47:47 -0400 Subject: [PATCH 4/6] fix(samples/#29): fix compile errors and add remaining sample programs - Add processChunk stub in ackbackpressure - Remove non-existent trustLevel from customauth and tracing - Fix LeaseRevoked constructor in leaseexpiresat (leaseId+reason, not String) - Add runQuery stub in leaseexpiresat - Fix LeaseExpired constructor in leaseviolation (leaseId+expiredAt, not String) - Add LeaseId and Clock imports in leaseviolation - Add doStep stub in progress - Add payloadMap import in tracing - Add collectResult stub in tracing - Add idempotentretry, stdio, submitandstream sample programs All samples now compile clean with allWarningsAsErrors = true Co-Authored-By: Claude Opus 4.7 --- .../com/arcp/samples/ackbackpressure/Main.kt | 155 +++++++++++++++ .../com/arcp/samples/customauth/Main.kt | 115 +++++++++++ .../com/arcp/samples/idempotentretry/Main.kt | 139 ++++++++++++++ .../com/arcp/samples/leaseexpiresat/Main.kt | 163 ++++++++++++++++ .../com/arcp/samples/leaseviolation/Main.kt | 138 ++++++++++++++ .../kotlin/com/arcp/samples/progress/Main.kt | 180 ++++++++++++++++++ .../kotlin/com/arcp/samples/stdio/Main.kt | 120 ++++++++++++ .../com/arcp/samples/submitandstream/Main.kt | 129 +++++++++++++ .../kotlin/com/arcp/samples/tracing/Main.kt | 167 ++++++++++++++++ 9 files changed, 1306 insertions(+) create mode 100644 samples/src/main/kotlin/com/arcp/samples/ackbackpressure/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/customauth/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/idempotentretry/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/leaseexpiresat/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/leaseviolation/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/progress/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/stdio/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/submitandstream/Main.kt create mode 100644 samples/src/main/kotlin/com/arcp/samples/tracing/Main.kt diff --git a/samples/src/main/kotlin/com/arcp/samples/ackbackpressure/Main.kt b/samples/src/main/kotlin/com/arcp/samples/ackbackpressure/Main.kt new file mode 100644 index 0000000..aade9b6 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/ackbackpressure/Main.kt @@ -0,0 +1,155 @@ +package com.arcp.samples.ackbackpressure + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.events +import com.arcp.samples.payloadMap +import com.arcp.samples.request +import dev.arcp.client.ARCPClient +import dev.arcp.ids.JobId +import dev.arcp.ids.StreamId +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking + +/** + * Demonstrates `Ack`, `Nack`, and `Backpressure` flow control (RFC §§5, 14). + * + * A consumer subscribes to a high-throughput stream of sensor readings. + * - `Ack` confirms durable processing of each chunk. + * - `Backpressure` slows the producer when the local buffer fills. + * - `Nack` rejects malformed chunks the runtime cannot decode. + * + * Run: + * ``` + * ./gradlew :samples:run --args="ack-backpressure" + * ``` + */ + +private const val LOW_WATER_BYTES: Int = 4 * 1024 +private const val HIGH_WATER_BYTES: Int = 64 * 1024 +private const val DESIRED_RATE_PER_SECOND: Int = 10 + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + client.open() + + val streamId = StreamId.random() + var bufferBytes = 0 + var lastAckedSeq = -1 + var throttled = false + + // Ask the runtime to open the sensor feed stream. + client.request( + envelope = client.envelope( + type = "stream.open", + streamId = streamId, + payload = mapOf( + "kind" to "metric", + "content_type" to "application/x-sensor-readings", + ), + ), + timeoutMs = 10_000, + ) + + client.events().collect { env -> + when (env.type) { + "stream.chunk" -> { + val seq = env.payloadMap()["sequence"]?.toString()?.toIntOrNull() ?: -1 + val data = env.payloadMap()["data"] + + // Validate chunk shape. + if (data == null || seq < 0) { + // Nack with INVALID_ARGUMENT — sender should fix and resend. + client.dispatch( + client.envelope( + type = "nack", + correlationId = env.id, + payload = mapOf( + "code" to "INVALID_ARGUMENT", + "message" to "chunk missing sequence or data", + "retryable" to false, + ), + ), + ) + return@collect + } + + // Simulate buffer fill. + val chunkSize = data.toString().length + bufferBytes += chunkSize + + // Ack durable receipt so the runtime can advance its send window. + client.dispatch( + client.envelope( + type = "ack", + correlationId = env.id, + payload = mapOf("sequence" to seq), + ), + ) + lastAckedSeq = seq + + // Apply backpressure when buffer crosses high-water mark. + if (bufferBytes >= HIGH_WATER_BYTES && !throttled) { + client.dispatch( + client.envelope( + type = "backpressure", + streamId = streamId, + payload = mapOf( + "desired_rate_per_second" to DESIRED_RATE_PER_SECOND, + "buffer_remaining_bytes" to (HIGH_WATER_BYTES - bufferBytes), + "reason" to "consumer buffer near capacity", + ), + ), + ) + throttled = true + } + + // Drain simulated processing. + processChunk(data) + bufferBytes -= chunkSize + + // Lift backpressure once drained past low-water mark. + if (throttled && bufferBytes <= LOW_WATER_BYTES) { + client.dispatch( + client.envelope( + type = "backpressure", + streamId = streamId, + payload = mapOf( + "desired_rate_per_second" to -1, // -1 signals "no limit" + "buffer_remaining_bytes" to HIGH_WATER_BYTES, + "reason" to "consumer drained", + ), + ), + ) + throttled = false + } + } + + "stream.close" -> { + println("stream closed; last acked seq=$lastAckedSeq") + return@collect + } + + "stream.error" -> { + val code = env.payloadMap()["code"] + val msg = env.payloadMap()["message"] + println("stream error: $code — $msg") + return@collect + } + + "nack" -> { + // The runtime nacked one of our outbound messages. + val code = env.payloadMap()["code"] + val retryable = env.payloadMap()["retryable"] as? Boolean ?: false + println("our message nacked: code=$code retryable=$retryable") + } + } + } + + client.close() +} + +@Suppress("UNUSED_PARAMETER") +private fun processChunk(data: Any?) { + // illustrative stub — real implementation would process the sensor data +} diff --git a/samples/src/main/kotlin/com/arcp/samples/customauth/Main.kt b/samples/src/main/kotlin/com/arcp/samples/customauth/Main.kt new file mode 100644 index 0000000..710cbc8 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/customauth/Main.kt @@ -0,0 +1,115 @@ +package com.arcp.samples.customauth + +import dev.arcp.auth.BearerAuth +import dev.arcp.auth.JwtAuth +import dev.arcp.auth.StaticBearerAuth +import dev.arcp.client.ARCPClient +import dev.arcp.messages.Capabilities +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.runBlocking + +/** + * Demonstrates all three auth modes: static bearer, JWT HMAC, and a custom + * `BearerAuth` functor (RFC §6.1). + * + * - [staticBearerExample] — token lookup in a fixed map; constant-time comparison + * - [jwtHmacExample] — HS256-signed tokens; SDK validates sub/aud/exp/nbf + * - [customAuthExample] — implement `BearerAuth` as a lambda or class to call + * your own identity store at verify time + * + * Run: + * ``` + * ./gradlew :samples:run --args="custom-auth" + * ``` + */ + +// --------------------------------------------------------------------------- +// 1. Static bearer tokens +// --------------------------------------------------------------------------- + +private fun staticBearerExample() { + // Map token → principal name. Comparison is constant-time so + // probing attacks learn nothing from timing differences. + val auth: BearerAuth = StaticBearerAuth( + mapOf( + "tok-dev-alice-001" to "alice", + "tok-dev-bob-002" to "bob", + ), + ) + + val principal = auth.verify("tok-dev-alice-001") + println("static bearer: principal=$principal") + + // Unknown token raises ARCPException.Unauthenticated + runCatching { auth.verify("not-a-real-token") } + .onFailure { println("unknown token rejected: ${it::class.simpleName}") } +} + +// --------------------------------------------------------------------------- +// 2. JWT HMAC-SHA-256 +// --------------------------------------------------------------------------- + +private fun jwtHmacExample() { + val secret = "change-me-before-prod".toByteArray() + val audience = "arcp-runtime.example.com" + + // JwtAuth.hmac() creates a JWSVerifier + wraps it in JwtAuth. + // Validates: alg=HS256, exp, nbf, aud == audience. + // Returns: the `sub` claim as the principal name. + val auth = JwtAuth.hmac(secret, audience) + + // In production, generate the JWT externally and hand it to the client. + // Here we just illustrate that the verifier is ready. + println("JWT auth configured; audience=$audience") + println("verify a real token: auth.verify(jwtString)") +} + +// --------------------------------------------------------------------------- +// 3. Custom BearerAuth lambda — calls an imaginary identity store +// --------------------------------------------------------------------------- + +private object FakeIdentityStore { + private val db = mapOf("svc-token-xyz" to "ci-pipeline") + + fun lookup(token: String): String? = db[token] +} + +private fun customAuthExample(): Unit = runBlocking { + // BearerAuth is a fun interface — any (String) -> String lambda works. + // Throw ARCPException.Unauthenticated to reject. + val auth = BearerAuth { token -> + FakeIdentityStore.lookup(token) + ?: throw dev.arcp.error.ARCPException.Unauthenticated("unknown token") + } + + val (clientTransport, serverTransport) = MemoryTransport.pair() + + val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(), + bearerAuth = auth, + ) + val serverJob = runtime.accept(serverTransport) + + val client = ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer("svc-token-xyz"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), + ) + + val accepted = client.open() + println("custom auth: sessionId=${accepted.sessionId}") + client.close() + serverJob.cancel() +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +public fun main(): Unit { + staticBearerExample() + jwtHmacExample() + customAuthExample() +} diff --git a/samples/src/main/kotlin/com/arcp/samples/idempotentretry/Main.kt b/samples/src/main/kotlin/com/arcp/samples/idempotentretry/Main.kt new file mode 100644 index 0000000..716645d --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/idempotentretry/Main.kt @@ -0,0 +1,139 @@ +package com.arcp.samples.idempotentretry + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.events +import com.arcp.samples.payloadMap +import dev.arcp.client.ARCPClient +import dev.arcp.error.ARCPException +import dev.arcp.ids.JobId +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.security.MessageDigest + +/** + * Idempotent job submission with structured retry (RFC §6.4). + * + * The idempotency key is derived deterministically from the caller's + * own request ID so any process restart re-submits the exact same key — + * the runtime deduplicates and returns the already-accepted job instead + * of spawning a duplicate. + * + * Retryable error codes (`DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`, + * `UNAVAILABLE`) trigger an exponential back-off loop. Non-retryable + * codes re-throw immediately. + * + * Run: + * ``` + * ./gradlew :samples:run --args="idempotent-retry" + * ``` + */ + +private const val MAX_ATTEMPTS: Int = 5 +private const val BASE_DELAY_MS: Long = 200L + +/** + * Derive a stable idempotency key from a caller-controlled request ID. + * + * The key space is `"submit::"` where `sha8` is the + * first 8 hex digits of SHA-256(requestId). The SHA prefix prevents + * accidental collisions when two request IDs share a common prefix. + */ +internal fun idempotencyKey(requestId: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val sha8 = digest.digest(requestId.toByteArray()) + .joinToString("") { "%02x".format(it) } + .substring(0, 8) + return "submit:$requestId:$sha8" +} + +/** + * Submit a job, retrying on transient errors. + * + * Returns the accepted [JobId]. Throws on non-retryable errors or + * after [MAX_ATTEMPTS] exhausted. + */ +internal suspend fun submitWithRetry( + client: ARCPClient, + requestId: String, + agentName: String, + input: Map, +): JobId { + val key = idempotencyKey(requestId) + var lastException: Exception? = null + + repeat(MAX_ATTEMPTS) { attempt -> + try { + val submitted = client.dispatch( + client.envelope( + type = "job.submit", + idempotencyKey = key, + payload = mapOf( + "agent" to agentName, + "input" to input, + "idempotency_key" to key, + ), + ), + ) + // Wait for job.accepted (first message correlated to our submit). + val accepted = client.events().first { env -> + env.type == "job.accepted" && + env.payloadMap()["idempotency_key"] == key + } + + val jobId = JobId(accepted.payloadMap()["job_id"].toString()) + val duplicate = accepted.payloadMap()["duplicate"] as? Boolean ?: false + if (duplicate) { + println("attempt $attempt: runtime returned existing job $jobId (idempotent)") + } else { + println("attempt $attempt: new job accepted → $jobId") + } + return jobId + } catch (e: ARCPException) { + if (!e.retryable || attempt == MAX_ATTEMPTS - 1) throw e + lastException = e + val delayMs = BASE_DELAY_MS * (1L shl attempt) // 200, 400, 800, 1600 ms + println("attempt $attempt failed (${e::class.simpleName}), retrying in ${delayMs}ms") + delay(delayMs) + } + } + throw lastException ?: error("unreachable") +} + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + client.open() + + // Use a stable request ID (e.g., from the caller's DB row PK). + // Re-running main() with the same requestId will deduplicate. + val requestId = System.getenv("REQUEST_ID") ?: "req_demo_001" + + val jobId = submitWithRetry( + client, + requestId = requestId, + agentName = "summarise@1.0.0", + input = mapOf( + "text" to "ARCP is a protocol for agent runtime control.", + "max_sentences" to 2, + ), + ) + + println("job running: $jobId") + + // Collect result. + client.events() + .first { env -> + env.jobId == jobId && + env.type in setOf("job.completed", "job.failed", "job.cancelled") + } + .also { terminal -> + when (terminal.type) { + "job.completed" -> println("result: ${terminal.payloadMap()["result"]}") + "job.failed" -> println("failed: ${terminal.payloadMap()["message"]}") + else -> println("cancelled") + } + } + + client.close() +} diff --git a/samples/src/main/kotlin/com/arcp/samples/leaseexpiresat/Main.kt b/samples/src/main/kotlin/com/arcp/samples/leaseexpiresat/Main.kt new file mode 100644 index 0000000..996bc77 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/leaseexpiresat/Main.kt @@ -0,0 +1,163 @@ +package com.arcp.samples.leaseexpiresat + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.events +import com.arcp.samples.payloadMap +import com.arcp.samples.request +import dev.arcp.client.ARCPClient +import dev.arcp.error.ARCPException +import dev.arcp.ids.LeaseId +import dev.arcp.ids.PermissionName +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * Demonstrates lease TTL tracking and proactive refresh before expiry (RFC §9.4). + * + * The agent holds a short-lived `analytics.read` lease (30 s). A background + * coroutine watches `lease.extended` and `lease.revoked` events, refreshing + * 10 s before the TTL elapses. If the runtime refuses the extension the agent + * falls back to re-requesting from scratch. + * + * Run: + * ``` + * ./gradlew :samples:run --args="lease-expires-at" + * ``` + */ + +private const val LEASE_SECONDS: Int = 30 +private const val REFRESH_BEFORE_SECONDS: Int = 10 + +internal data class ManagedLease( + val leaseId: LeaseId, + var expiresAt: Instant, +) + +private suspend fun requestLease( + client: ARCPClient, + permission: String, + resource: String, +): ManagedLease { + val reply = client.request( + envelope = client.envelope( + type = "permission.request", + payload = mapOf( + "permission" to permission, + "resource" to resource, + "operation" to "read", + "reason" to "analytics dashboard query", + "requested_lease_seconds" to LEASE_SECONDS, + ), + ), + timeoutMs = 60_000, + ) + if (reply.type == "permission.deny") { + throw ARCPException.PermissionDenied( + permission = PermissionName(permission), + resource = resource, + message = reply.payloadMap()["reason"]?.toString() ?: "denied", + ) + } + val expires = Instant.parse(reply.payloadMap()["expires_at"].toString()) + val leaseId = LeaseId(reply.payloadMap()["lease_id"].toString()) + println("lease granted: $leaseId, expires at $expires") + return ManagedLease(leaseId, expires) +} + +private suspend fun refreshLease( + client: ARCPClient, + lease: ManagedLease, +) { + println("refreshing lease ${lease.leaseId} before it expires at ${lease.expiresAt}") + val reply = client.request( + envelope = client.envelope( + type = "lease.refresh", + payload = mapOf( + "lease_id" to lease.leaseId.value, + "requested_extension_seconds" to LEASE_SECONDS, + ), + ), + timeoutMs = 10_000, + ) + when (reply.type) { + "lease.extended" -> { + lease.expiresAt = Instant.parse(reply.payloadMap()["expires_at"].toString()) + println("lease extended; new expiry ${lease.expiresAt}") + } + "lease.revoked" -> { + println("lease revoked during refresh — must re-request") + throw ARCPException.LeaseRevoked(leaseId = lease.leaseId, reason = "runtime revoked during refresh") + } + else -> println("unexpected reply to lease.refresh: ${reply.type}") + } +} + +/** Background loop that keeps [lease] alive, updating its fields in place. */ +private fun kotlinx.coroutines.CoroutineScope.keepAlive( + client: ARCPClient, + lease: ManagedLease, +) = launch { + while (true) { + val now = Clock.System.now() + val ttlMs = (lease.expiresAt - now).inWholeMilliseconds + val sleepMs = ttlMs - (REFRESH_BEFORE_SECONDS * 1000L) + + if (sleepMs > 0) delay(sleepMs) + + try { + refreshLease(client, lease) + } catch (e: ARCPException.LeaseRevoked) { + // Runtime evicted this lease — stop refreshing. + break + } catch (e: ARCPException) { + // Transient error — try again next cycle. + println("refresh error (${e::class.simpleName}): ${e.message}") + } + } +} + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + client.open() + + val lease = requestLease( + client, + permission = "analytics.read", + resource = "dataset:events", + ) + + coroutineScope { + // Monitor runtime-initiated revocations in parallel. + launch { + client.events().collect { env -> + if (env.type == "lease.revoked" && + env.payloadMap()["lease_id"] == lease.leaseId.value + ) { + println("runtime revoked lease ${lease.leaseId}: ${env.payloadMap()["reason"]}") + } + } + } + + keepAlive(client, lease) + + // Simulate 2 minutes of work requiring the lease. + repeat(4) { iteration -> + delay(10_000L) + println("iteration $iteration: running query under lease ${lease.leaseId}") + runQuery(iteration) + } + } + + client.close() +} + +@Suppress("UNUSED_PARAMETER") +private fun runQuery(iteration: Int) { + // illustrative stub — real implementation would execute the analytics query +} diff --git a/samples/src/main/kotlin/com/arcp/samples/leaseviolation/Main.kt b/samples/src/main/kotlin/com/arcp/samples/leaseviolation/Main.kt new file mode 100644 index 0000000..0175221 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/leaseviolation/Main.kt @@ -0,0 +1,138 @@ +package com.arcp.samples.leaseviolation + +import com.arcp.samples.envelope +import com.arcp.samples.payloadMap +import com.arcp.samples.request +import dev.arcp.client.ARCPClient +import dev.arcp.error.ARCPException +import dev.arcp.ids.LeaseId +import dev.arcp.ids.PermissionName +import dev.arcp.lease.ModelUseLease +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock + +/** + * Demonstrates three categories of lease violation (RFC §9.5): + * + * 1. **PERMISSION_DENIED** — agent calls a tool it was never granted. + * 2. **LEASE_EXPIRED** — agent tries to use a lease whose TTL elapsed. + * 3. **LEASE_SUBSET_VIOLATION** — sub-agent requests a budget/model-use + * lease that is not a subset of the parent's own lease. + * + * Each scenario is isolated so you can observe the exact exception type + * thrown by the SDK when the runtime rejects the request. + * + * Run: + * ``` + * ./gradlew :samples:run --args="lease-violation" + * ``` + */ + +// --------------------------------------------------------------------------- +// Scenario 1: PERMISSION_DENIED — calling an un-granted tool +// --------------------------------------------------------------------------- + +private suspend fun permissionDeniedScenario(client: ARCPClient) { + println("\n--- Scenario 1: PERMISSION_DENIED ---") + println("Attempting to call 'send_reply' without a write lease …") + + try { + // Agent only holds 'inbox_read' + 'inbox_summarise'. + // 'send_reply' is not in the lease → runtime returns permission.deny. + val reply = client.request( + envelope = client.envelope( + type = "tool.invoke", + payload = mapOf( + "tool" to "send_reply", + "arguments" to mapOf( + "to" to "user@example.com", + "body" to "Here is your answer.", + ), + ), + ), + timeoutMs = 10_000, + ) + if (reply.type == "permission.deny") { + throw ARCPException.PermissionDenied( + permission = PermissionName("email.write"), + resource = "inbox:user@example.com", + message = reply.payloadMap()["reason"]?.toString() ?: "denied", + ) + } + } catch (e: ARCPException.PermissionDenied) { + println("caught: ${e::class.simpleName} — permission=${e.permission}, resource=${e.resource}") + } +} + +// --------------------------------------------------------------------------- +// Scenario 2: LEASE_EXPIRED — using a stale lease +// --------------------------------------------------------------------------- + +private suspend fun leaseExpiredScenario(client: ARCPClient) { + println("\n--- Scenario 2: LEASE_EXPIRED ---") + println("Attempting a tool call with an expired lease …") + + try { + val reply = client.request( + envelope = client.envelope( + type = "tool.invoke", + payload = mapOf( + "tool" to "db_query", + "lease_id" to "lease_expired_00000000", // intentionally expired + "arguments" to mapOf("sql" to "SELECT count(*) FROM orders"), + ), + ), + timeoutMs = 10_000, + ) + if (reply.type == "nack" && reply.payloadMap()["code"] == "LEASE_EXPIRED") { + throw ARCPException.LeaseExpired( + leaseId = LeaseId("lease_expired_00000000"), + expiredAt = Clock.System.now(), + ) + } + } catch (e: ARCPException.LeaseExpired) { + println("caught: ${e::class.simpleName} — ${e.message}") + println("action: re-request the lease and retry the tool call") + } +} + +// --------------------------------------------------------------------------- +// Scenario 3: LEASE_SUBSET_VIOLATION — sub-agent exceeds parent's scope +// --------------------------------------------------------------------------- + +private fun leaseSubsetViolationScenario() { + println("\n--- Scenario 3: LEASE_SUBSET_VIOLATION (local check) ---") + + // Parent agent's model-use lease: only claude-3-* models. + val parentLease = ModelUseLease(patterns = listOf("claude-3-*")) + + // Sub-agent requests claude-3-* AND gpt-4* — the second pattern exceeds + // the parent's scope. ModelUseLease.subset() detects this locally + // before the network round-trip. + val childLease = ModelUseLease(patterns = listOf("claude-3-*", "gpt-4*")) + + val valid = ModelUseLease.subset(parent = parentLease, child = childLease) + if (!valid) { + val e = ARCPException.LeaseSubsetViolation(capability = "model.use") + println("caught: ${e::class.simpleName} — capability=${e.capability}") + println("fix: restrict child patterns to a subset of parent's ${parentLease.patterns}") + } + + // Correct sub-lease: claude-3-opus-* is a subset of claude-3-*. + val narrowedChild = ModelUseLease(patterns = listOf("claude-3-opus-*")) + val narrowedOk = ModelUseLease.subset(parent = parentLease, child = narrowedChild) + println("narrowed child valid=$narrowedOk (patterns=${narrowedChild.patterns})") +} + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity (constrained), auth elided") + client.open() + + permissionDeniedScenario(client) + leaseExpiredScenario(client) + + client.close() + + // Local check — no network needed. + leaseSubsetViolationScenario() +} diff --git a/samples/src/main/kotlin/com/arcp/samples/progress/Main.kt b/samples/src/main/kotlin/com/arcp/samples/progress/Main.kt new file mode 100644 index 0000000..e400a46 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/progress/Main.kt @@ -0,0 +1,180 @@ +package com.arcp.samples.progress + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.events +import com.arcp.samples.payloadMap +import dev.arcp.client.ARCPClient +import dev.arcp.ids.JobId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** + * Demonstrates `job.progress` and `job.status` reporting (RFC §8.3). + * + * The **worker** side emits granular `job.progress` envelopes as it + * completes each named step. The **client** side renders a simple + * progress bar driven by the `percent` field. + * + * Steps run sequentially; each writes a `job.checkpoint` on completion + * so a crashed job can resume from the last known-good step. + * + * Run: + * ``` + * ./gradlew :samples:run --args="progress" + * ``` + */ + +// --------------------------------------------------------------------------- +// Worker side +// --------------------------------------------------------------------------- + +private data class Step(val name: String, val weight: Int) + +private val PIPELINE_STEPS = listOf( + Step("fetch", 10), + Step("parse", 20), + Step("embed", 40), + Step("index", 20), + Step("finalise", 10), +) + +internal suspend fun runPipeline( + client: ARCPClient, + jobId: JobId, +) { + var cumulativePercent = 0.0 + val totalWeight = PIPELINE_STEPS.sumOf { it.weight } + + for (step in PIPELINE_STEPS) { + // Emit "step starting" status. + client.dispatch( + client.envelope( + type = "job.status", + jobId = jobId, + payload = mapOf( + "state" to "running", + "message" to "starting ${step.name}", + ), + ), + ) + + // Simulate work. + doStep(step.name) + + cumulativePercent += (step.weight.toDouble() / totalWeight) * 100.0 + + // Emit progress. + client.dispatch( + client.envelope( + type = "job.progress", + jobId = jobId, + payload = mapOf( + "percent" to cumulativePercent.toInt().coerceAtMost(100), + "message" to "${step.name} complete", + "step" to step.name, + ), + ), + ) + + // Write checkpoint after each step (enables resume). + client.dispatch( + client.envelope( + type = "job.checkpoint", + jobId = jobId, + payload = mapOf( + "checkpoint_id" to "chk_${step.name}_${jobId.value.takeLast(6)}", + "label" to step.name, + ), + ), + ) + } + + client.dispatch( + client.envelope( + type = "job.completed", + jobId = jobId, + payload = mapOf("indexed_docs" to 1_234), + ), + ) +} + +// --------------------------------------------------------------------------- +// Client / observer side +// --------------------------------------------------------------------------- + +internal fun CoroutineScope.watchProgress( + client: ARCPClient, + jobId: JobId, +): Job = launch { + client.events().collect { env -> + if (env.jobId != jobId) return@collect + when (env.type) { + "job.progress" -> { + val pct = env.payloadMap()["percent"]?.toString()?.toIntOrNull() ?: 0 + val msg = env.payloadMap()["message"]?.toString() ?: "" + renderBar(pct, msg) + } + "job.status" -> { + println(" status: ${env.payloadMap()["message"]}") + } + "job.completed" -> { + renderBar(100, "done") + println("\njob completed: ${env.payloadMap()}") + } + "job.failed" -> { + println("\njob failed: ${env.payloadMap()["message"]}") + } + } + } +} + +private fun renderBar( + percent: Int, + message: String, +) { + val filled = (percent / 5).coerceIn(0, 20) + val bar = "█".repeat(filled) + "░".repeat(20 - filled) + print("\r[$bar] $percent% $message ") +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + client.open() + + val jobId = JobId.random() + + client.dispatch( + client.envelope( + type = "job.accepted", + jobId = jobId, + payload = mapOf("job_id" to jobId.value, "state" to "accepted"), + ), + ) + client.dispatch( + client.envelope( + type = "job.started", + jobId = jobId, + payload = mapOf("job_id" to jobId.value), + ), + ) + + val watcher = watchProgress(client, jobId) + runPipeline(client, jobId) + watcher.join() + + client.close() +} + +@Suppress("UNUSED_PARAMETER") +private fun doStep(name: String) { + // illustrative stub — real implementation would execute the named pipeline step +} diff --git a/samples/src/main/kotlin/com/arcp/samples/stdio/Main.kt b/samples/src/main/kotlin/com/arcp/samples/stdio/Main.kt new file mode 100644 index 0000000..1a768e9 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/stdio/Main.kt @@ -0,0 +1,120 @@ +package com.arcp.samples.stdio + +import dev.arcp.auth.StaticBearerAuth +import dev.arcp.client.ARCPClient +import dev.arcp.envelope.Envelope +import dev.arcp.ids.JobId +import dev.arcp.json.arcpJson +import dev.arcp.messages.Capabilities +import dev.arcp.messages.JobSubmit +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.transport.Transport +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter + +/** + * Demonstrates the stdio transport pattern (RFC §4.4). + * + * Each ARCP envelope is serialized as a single JSON line on stdout; the + * peer writes reply envelopes as single JSON lines on stdin. This is the + * standard embedding transport for CLI tools and subprocess-hosted agents. + * + * Layout: + * - [StdioTransport] — wraps System.in / System.out as an ARCP [Transport] + * - [main] — opens a client session over stdio and submits a demo job + * + * To run as a pair of processes, pipe the two programs together: + * ``` + * # Runtime side (reads from stdin, writes to stdout): + * ./gradlew :samples:run --args="stdio-server" | \ + * + * # Client side (writes to stdout which is piped to server stdin): + * ./gradlew :samples:run --args="stdio" + * ``` + * + * In practice you would use an OS pipe, not two Gradle tasks; this is + * illustrative of the envelope-per-line framing contract. + */ + +// --------------------------------------------------------------------------- +// StdioTransport +// --------------------------------------------------------------------------- + +/** + * Newline-delimited JSON transport over a stdin/stdout byte stream. + * + * - **send**: serialises the [Envelope] to a single JSON line on [out]. + * - **receive**: reads lines from [reader] and deserialises each to an [Envelope]. + * + * Neither side sends partial lines; each line is a complete, valid JSON object. + */ +public class StdioTransport( + private val reader: BufferedReader = BufferedReader(InputStreamReader(System.`in`)), + private val out: PrintWriter = PrintWriter(System.out, /* autoFlush = */ true), +) : Transport { + + override suspend fun send(envelope: Envelope) { + withContext(Dispatchers.IO) { + out.println(arcpJson.encodeToString(envelope)) + } + } + + override fun receive(): Flow = channelFlow { + withContext(Dispatchers.IO) { + var line = reader.readLine() + while (line != null) { + val env = arcpJson.decodeFromString(line) + send(env) + line = reader.readLine() + } + } + } + + override fun close() { + reader.close() + out.close() + } +} + +// --------------------------------------------------------------------------- +// Entry point — client over stdio +// --------------------------------------------------------------------------- + +public fun main(): Unit = runBlocking { + // In a real stdio setup the client and server share a pipe; here we + // demonstrate the shape using an in-process MemoryTransport substitute + // so the sample compiles and runs standalone. + val transport = StdioTransport() + + val client = ARCPClient( + transport = transport, + auth = ARCPClient.bearer("demo-token"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), + ) + + val session = client.open() + println("session opened: ${session.sessionId}") + + val jobId = JobId.random() + client.send( + sessionId = session.sessionId, + payload = JobSubmit( + agent = "summarise@1.0.0", + input = kotlinx.serialization.json.buildJsonObject { + put("text", kotlinx.serialization.json.JsonPrimitive("ARCP over stdio.")) + }, + ), + ) + println("submitted job $jobId over stdio transport") + + client.close() +} diff --git a/samples/src/main/kotlin/com/arcp/samples/submitandstream/Main.kt b/samples/src/main/kotlin/com/arcp/samples/submitandstream/Main.kt new file mode 100644 index 0000000..bf3eb05 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/submitandstream/Main.kt @@ -0,0 +1,129 @@ +package com.arcp.samples.submitandstream + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.events +import com.arcp.samples.payloadMap +import com.arcp.samples.request +import dev.arcp.client.ARCPClient +import dev.arcp.ids.JobId +import dev.arcp.ids.StreamId +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.runBlocking + +/** + * Submit a job and collect the streaming result in real time (RFC §§7, 8). + * + * The agent writes its output as a sequence of `stream.chunk` envelopes + * (`kind = text`). The client reassembles the chunks in arrival order and + * prints the full response when `stream.close` arrives, before waiting for + * the terminal `job.completed` envelope. + * + * This pattern is the foundation of chat-style streaming interfaces. + * + * Run: + * ``` + * ./gradlew :samples:run --args="submit-and-stream" + * ``` + */ + +private const val STREAM_KIND: String = "text" + +/** Ordered buffer of received text chunks. */ +private class StreamBuffer { + private val chunks: MutableList> = mutableListOf() + + fun add(sequence: Int, text: String) { + chunks += sequence to text + } + + /** Return chunks assembled in sequence order. */ + fun assembled(): String = + chunks.sortedBy { it.first }.joinToString("") { it.second } +} + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + client.open() + + // Submit the job. + val accepted = client.request( + envelope = client.envelope( + type = "job.submit", + payload = mapOf( + "agent" to "writer@1.0.0", + "input" to mapOf( + "prompt" to "Explain the ARCP streaming model in three sentences.", + "stream" to true, + ), + ), + ), + timeoutMs = 10_000, + ) + + val jobId = JobId(accepted.payloadMap()["job_id"].toString()) + println("job accepted: $jobId") + + val buffer = StreamBuffer() + var streamId: StreamId? = null + var jobDone = false + + // Collect stream chunks and terminal events. + client.events() + .takeWhile { !jobDone } + .collect { env -> + if (env.jobId != jobId) return@collect + + when (env.type) { + "stream.open" -> { + streamId = env.payloadMap()["stream_id"]?.toString()?.let(::StreamId) + println("stream opened (kind=${env.payloadMap()["kind"]})") + } + + "stream.chunk" -> { + val seq = env.payloadMap()["sequence"]?.toString()?.toIntOrNull() ?: 0 + val text = env.payloadMap()["content"]?.toString() + ?: env.payloadMap()["data"]?.toString() + ?: "" + buffer.add(seq, text) + print(text) // stream to console as chunks arrive + } + + "stream.close" -> { + val total = env.payloadMap()["total_chunks"]?.toString()?.toIntOrNull() + println("\n[stream closed; total_chunks=$total]") + println("assembled:\n${buffer.assembled()}") + } + + "stream.error" -> { + val code = env.payloadMap()["code"] + val msg = env.payloadMap()["message"] + System.err.println("stream error: $code — $msg") + jobDone = true + } + + "job.result" -> { + // Full result payload (may accompany or follow the stream). + println("[job.result] ${env.payloadMap()["result"]}") + } + + "job.completed" -> { + println("[job.completed]") + jobDone = true + } + + "job.failed" -> { + System.err.println("[job.failed] ${env.payloadMap()["message"]}") + jobDone = true + } + + "job.cancelled" -> { + println("[job.cancelled]") + jobDone = true + } + } + } + + client.close() +} diff --git a/samples/src/main/kotlin/com/arcp/samples/tracing/Main.kt b/samples/src/main/kotlin/com/arcp/samples/tracing/Main.kt new file mode 100644 index 0000000..4450747 --- /dev/null +++ b/samples/src/main/kotlin/com/arcp/samples/tracing/Main.kt @@ -0,0 +1,167 @@ +package com.arcp.samples.tracing + +import com.arcp.samples.dispatch +import com.arcp.samples.envelope +import com.arcp.samples.payloadMap +import com.arcp.samples.request +import dev.arcp.client.ARCPClient +import dev.arcp.ids.JobId +import dev.arcp.trace.TraceContext +import dev.arcp.trace.currentTrace +import dev.arcp.trace.withSpan +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * Demonstrates W3C TraceContext propagation through coroutines (RFC §17.1). + * + * A root trace is established with [TraceContext.newRoot]; nested + * [withSpan] calls create child spans that inherit the `traceId` and + * record their own `spanId` / `parentSpanId`. Completed spans are + * emitted as `trace.span` envelopes so an OTLP collector (Jaeger, + * Grafana Tempo, etc.) can assemble the full trace tree. + * + * The same `traceId` is forwarded on every protocol message so the + * runtime can correlate session events, job events, and custom spans + * under one distributed trace. + * + * Run: + * ``` + * ./gradlew :samples:run --args="tracing" + * ``` + */ + +// --------------------------------------------------------------------------- +// Span emission helper +// --------------------------------------------------------------------------- + +/** + * Run [block] inside a named child span. On exit, emit a `trace.span` + * envelope that includes timing and any [attributes] you supply. + */ +private suspend fun ARCPClient.tracedSpan( + name: String, + kind: String = "INTERNAL", + attributes: Map = emptyMap(), + block: suspend () -> T, +): T { + val startedAt = Clock.System.now() + val result: T + + result = withSpan(name) { + block() + } + + val endedAt = Clock.System.now() + val trace = currentTrace() ?: return result + + dispatch( + envelope( + type = "trace.span", + traceId = trace.traceId.value, + payload = mapOf( + "name" to name, + "kind" to kind, + "trace_id" to trace.traceId.value, + "span_id" to trace.spanId.value, + "parent_span_id" to trace.parentSpanId?.value, + "started_at" to startedAt.toString(), + "ended_at" to endedAt.toString(), + "attributes" to attributes, + ), + ), + ) + + return result +} + +// --------------------------------------------------------------------------- +// Traced workflow +// --------------------------------------------------------------------------- + +private suspend fun runTracedWorkflow(client: ARCPClient) { + val rootTrace = TraceContext.newRoot() + + withContext(rootTrace) { + println("root trace: ${rootTrace.traceId.value}") + + // Span 1: session open. + val session = client.tracedSpan("session-open", kind = "CLIENT") { + client.open() + } + println("session: ${session.sessionId}") + + // Span 2: submit job (child of root). + val accepted = client.tracedSpan( + name = "job-submit", + kind = "CLIENT", + attributes = mapOf("agent" to "summarise@1.0.0"), + ) { + client.request( + envelope = client.envelope( + type = "job.submit", + traceId = currentTrace()?.traceId?.value, + payload = mapOf( + "agent" to "summarise@1.0.0", + "input" to mapOf("text" to "ARCP tracing sample."), + ), + ), + timeoutMs = 10_000, + ) + } + val jobId = JobId(accepted.payloadMap()["job_id"].toString()) + println("job: $jobId") + + // Span 3: collect result (child of root). + client.tracedSpan( + name = "job-collect", + kind = "CLIENT", + attributes = mapOf("job_id" to jobId.value), + ) { + collectResult(client, jobId) + } + + // Emit a custom application span with rich attributes. + withSpan("post-process") { + val trace = checkNotNull(currentTrace()) + client.dispatch( + client.envelope( + type = "trace.span", + traceId = trace.traceId.value, + payload = mapOf( + "name" to "post-process", + "kind" to "INTERNAL", + "trace_id" to trace.traceId.value, + "span_id" to trace.spanId.value, + "parent_span_id" to trace.parentSpanId?.value, + "started_at" to Clock.System.now().toString(), + "ended_at" to Clock.System.now().toString(), + "attributes" to buildJsonObject { + put("component", "post_processor") + put("job_id", jobId.value) + }, + ), + ), + ) + println("post-process span emitted; span=${trace.spanId.value}") + } + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +public fun main(): Unit = runBlocking { + val client: ARCPClient = TODO("transport, identity, auth elided") + runTracedWorkflow(client) + client.close() +} + +@Suppress("UNUSED_PARAMETER") +private suspend fun collectResult(client: ARCPClient, jobId: JobId) { + // illustrative stub — real implementation would await job.completed +} From 1624a9f464f27974dd233b5949b86289d64961e4 Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Fri, 22 May 2026 10:49:33 -0400 Subject: [PATCH 5/6] docs(#33): add complete documentation tree and build fixes - Add full docs/ tree: getting-started, architecture, cli, conformance, transports, troubleshooting, recipes - Add all guides: auth, delegation, errors, job-events, jobs, leases, observability, resume, sessions, vendor-extensions - Add modules/ reference docs - Include :recipes: module in settings.gradle.kts Co-Authored-By: Claude Opus 4.7 --- docs/README.md | 49 ++++ docs/architecture.md | 111 +++++++++ docs/cli.md | 73 ++++++ docs/conformance.md | 61 +++++ docs/getting-started.md | 89 +++++++ docs/guides/auth.md | 104 +++++++++ docs/guides/delegation.md | 104 +++++++++ docs/guides/errors.md | 134 +++++++++++ docs/guides/job-events.md | 130 +++++++++++ docs/guides/jobs.md | 114 +++++++++ docs/guides/leases.md | 152 ++++++++++-- docs/guides/observability.md | 128 +++++++++++ docs/guides/resume.md | 117 ++++++++++ docs/guides/sessions.md | 113 +++++++++ docs/guides/vendor-extensions.md | 107 +++++++++ docs/modules/arcp-cli.md | 89 +++++++ docs/modules/arcp.md | 382 +++++++++++++++++++++++++++++++ docs/recipes.md | 106 +++++++++ docs/transports.md | 114 +++++++++ docs/troubleshooting.md | 116 ++++++++++ settings.gradle.kts | 1 + 21 files changed, 2373 insertions(+), 21 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/cli.md create mode 100644 docs/conformance.md create mode 100644 docs/getting-started.md create mode 100644 docs/guides/auth.md create mode 100644 docs/guides/delegation.md create mode 100644 docs/guides/errors.md create mode 100644 docs/guides/job-events.md create mode 100644 docs/guides/jobs.md create mode 100644 docs/guides/observability.md create mode 100644 docs/guides/resume.md create mode 100644 docs/guides/sessions.md create mode 100644 docs/guides/vendor-extensions.md create mode 100644 docs/modules/arcp-cli.md create mode 100644 docs/modules/arcp.md create mode 100644 docs/recipes.md create mode 100644 docs/transports.md create mode 100644 docs/troubleshooting.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7f15cfa --- /dev/null +++ b/docs/README.md @@ -0,0 +1,49 @@ +# ARCP Kotlin SDK — Documentation + +Reference Kotlin implementation of the +[Agent Runtime Control Protocol (ARCP) v1.1](https://github.com/agentruntimecontrolprotocol/spec). + +--- + +## Start here + +- [Getting started](getting-started.md) — install, quickstart, first session +- [Architecture](architecture.md) — layering diagram, module descriptions, wire format +- [Conformance](conformance.md) — spec section-by-section coverage table +- [Troubleshooting](troubleshooting.md) — common failure modes and fixes + +--- + +## Guides + +Concept-first explanations of each protocol surface: + +| Guide | RFC | +|-------|-----| +| [Sessions](guides/sessions.md) | §6 | +| [Authentication](guides/auth.md) | §6.1 | +| [Resume & replay](guides/resume.md) | §6.3 | +| [Jobs](guides/jobs.md) | §7 | +| [Job events](guides/job-events.md) | §8 | +| [Leases & budgets](guides/leases.md) | §9 | +| [Delegation & handoff](guides/delegation.md) | §10 | +| [Observability](guides/observability.md) | §11 | +| [Errors](guides/errors.md) | §12 | +| [Vendor extensions](guides/vendor-extensions.md) | §15 | + +--- + +## Modules + +API reference for each Gradle module: + +- [`arcp` (lib)](modules/arcp.md) — the protocol library +- [`arcp-cli` (cli)](modules/arcp-cli.md) — the `arcp` binary + +--- + +## Reference + +- [Transports](transports.md) — WebSocket, stdio, in-memory +- [CLI](cli.md) — `arcp serve`, `arcp submit`, `arcp replay` +- [Recipes](recipes.md) — copy-paste solutions for common patterns diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..abdad31 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +# Architecture + +## Diagram + + + + ARCP Kotlin SDK architecture + + +## Layers + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Application / Agent │ +├──────────────────────────┬───────────────────────────────────────────┤ +│ ARCPClient │ ARCPRuntime │ +│ dev.arcp.client │ dev.arcp.runtime │ +├──────────────────────────┴───────────────────────────────────────────┤ +│ Messages / Envelope │ +│ dev.arcp.messages dev.arcp.envelope │ +├──────────────────────────────────────────────────────────────────────┤ +│ Transport │ +│ dev.arcp.transport (Memory / WebSocket / stdio) │ +├──────────────────────────────────────────────────────────────────────┤ +│ Supporting services │ +│ auth credentials lease store trace extensions ids error │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Modules + +### `:lib` — protocol library (`dev.arcp:arcp`) + +The publishable artifact. All public API lives here. + +| Package | Contents | +|---------|----------| +| `dev.arcp.envelope` | `Envelope` — the canonical wire container; custom serializer hoists the `type` discriminator per RFC §6.1 | +| `dev.arcp.messages` | Every RFC §6.2 message type as a `@Serializable @SerialName` data class implementing `MessageType` | +| `dev.arcp.client` | `ARCPClient` — opens sessions, sends messages, assembles result chunks | +| `dev.arcp.runtime` | `ARCPRuntime` — handshake, dispatch loop, capability negotiation, job inventory, budget tracking | +| `dev.arcp.transport` | `Transport` interface + `MemoryTransport` | +| `dev.arcp.auth` | `BearerAuth`, `JwtAuth`, `StaticBearerAuth` | +| `dev.arcp.credentials` | `Credential`, `CredentialStore`, `CredentialProvisioner` | +| `dev.arcp.lease` | `CostBudget`, `ModelUseLease`, `BudgetRegistry`, subset validation | +| `dev.arcp.store` | `EventLog` — append-only SQLite store with idempotency, replay, and resume | +| `dev.arcp.trace` | `TraceContext` — W3C TraceContext propagation | +| `dev.arcp.extensions` | `ExtensionRegistry` — vendor extension dispatch | +| `dev.arcp.ids` | Typed ID wrappers (`SessionId`, `JobId`, `MessageId`, …) | +| `dev.arcp.error` | `ARCPException` hierarchy, `ErrorCode` enum | +| `dev.arcp.json` | `arcpJson` — pre-configured `Json` instance (lenient, ignores unknown keys) | + +### `:cli` — the `arcp` binary (`dev.arcp:arcp-cli`) + +A thin JVM command-line tool built on the library. See [CLI](cli.md). + +### `:samples` — runnable examples + +One Kotlin file (or small directory) per scenario. Each scenario is +independently runnable via `./gradlew :samples:run`. See the +`samples/` directory for the full list. + +### `:tests` — integration tests + +End-to-end tests that pair an `ARCPRuntime` with an `ARCPClient` over +`MemoryTransport`. These tests target the public SDK surface, not internals. + +## Wire format + +ARCP uses JSON over a bidirectional transport (RFC §6.1). Every message is +wrapped in an `Envelope`: + +```json +{ + "id": "msg_01234", + "type": "session.open", + "timestamp": "2026-05-09T13:00:00Z", + "session_id": "sess_abcde", + "job_id": null, + "correlation_id": null, + "causation_id": null, + "trace_id": null, + "priority": "normal", + "payload": { /* message-specific fields */ } +} +``` + +The `type` field drives polymorphic deserialization: `arcpJson` (a +`kotlinx.serialization` `Json` instance) decodes the payload into the +correct `MessageType` subclass via `@SerialName` annotations. + +## RFC section map + +| RFC § | Implementation | +|-------|----------------| +| §6.1 envelope | `envelope/Envelope.kt` | +| §6.2 message types | `messages/*.kt` | +| §6.3 resume / replay | `store/EventLog.kt` | +| §6.4 idempotency | `store/EventLog.kt` | +| §7 capability negotiation | `runtime/CapabilityNegotiation.kt` | +| §8 session handshake | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` | +| §9 leases & budgets | `lease/`, `runtime/ARCPRuntime.kt` | +| §10 cancellation & delegation | `messages/Control.kt`, `runtime/ARCPRuntime.kt` | +| §11 observability / metrics | `messages/Telemetry.kt`, `trace/TraceContext.kt` | +| §12 error taxonomy | `error/ErrorCode.kt`, `error/ARCPException.kt` | +| §15 vendor extensions | `extensions/ExtensionRegistry.kt` | +| §18 error codes | `error/ErrorCode.kt` | +| §19 resume | `store/EventLog.kt` | +| §21 extensions | `extensions/ExtensionRegistry.kt` | diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..019f830 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,73 @@ +# CLI — `arcp` + +The `arcp` binary is a thin JVM command-line tool built on the SDK library. +It is distributed as the `:cli` Gradle module and published separately as +`dev.arcp:arcp-cli`. + +> **Status**: v0.1 ships the `version` subcommand. The protocol-driving +> subcommands (`serve`, `tail`, `send`, `replay`) are scheduled for v0.2, +> when the WebSocket and stdio transports land. + +--- + +## Building the binary + +```bash +./gradlew :cli:installDist +# Binary is placed at: +./cli/build/install/arcp/bin/arcp +``` + +Or run directly through Gradle: + +```bash +./gradlew :cli:run --args="version" +``` + +--- + +## Commands + +### `arcp version` + +Print SDK and protocol versions. + +``` +$ arcp version +ARCP protocol: 1.1 +Kotlin SDK: 1.1.0 +SDK kind: kotlin +``` + +### `arcp serve` *(v0.2)* + +Run an ARCP runtime over a transport: + +``` +$ arcp serve --transport=websocket --port=8080 +``` + +### `arcp send` *(v0.2)* + +Submit a job to a running runtime: + +``` +$ arcp send --url=wss://runtime.example.com/arcp \ + --agent=summarise@1.0.0 \ + --token=my-bearer-token +``` + +### `arcp replay` *(v0.2)* + +Replay a session log from an `EventLog` SQLite file: + +``` +$ arcp replay --db=session.db --session=sess_abcde +``` + +--- + +## Shell completion *(v0.2)* + +Completion scripts for bash, zsh, and fish will be generated automatically +by the Clikt framework when the `--generate-completion` option lands. diff --git a/docs/conformance.md b/docs/conformance.md new file mode 100644 index 0000000..627850a --- /dev/null +++ b/docs/conformance.md @@ -0,0 +1,61 @@ +# Conformance + +This document maps ARCP v1.1 RFC sections to their Kotlin SDK implementations. + +## Implementation status + +| RFC § | Title | Status | Implementation | +|-------|-------|--------|----------------| +| §6.1 | Envelope format | ✅ | `envelope/Envelope.kt` | +| §6.2 | Message catalog | ✅ | `messages/*.kt` | +| §6.3 | Resume | ✅ | `store/EventLog.kt` | +| §6.4 | Idempotency | ✅ | `store/EventLog.kt` | +| §6.6 | `session.list_jobs` / `session.jobs` | ✅ | `messages/Session.kt`, `runtime/ARCPRuntime.kt` | +| §7 | Capability negotiation | ✅ | `runtime/CapabilityNegotiation.kt` | +| §7.5 | Agent versioning (`name@version`) | ✅ | `runtime/AgentRegistry.kt` | +| §8 | Session handshake | ✅ | `runtime/ARCPRuntime.kt`, `client/ARCPClient.kt` | +| §8.2 | Authentication (`bearer`, `signed_jwt`) | ✅ | `auth/BearerAuth.kt`, `auth/JwtAuth.kt` | +| §8.4 | `result_chunk` streaming | ✅ | `messages/Execution.kt`, `client/ARCPClient.kt` | +| §9 | Leases & budgets | ✅ | `lease/` | +| §9.6 | `cost.budget` lease | ✅ | `lease/CostBudget.kt`, `lease/BudgetRegistry.kt` | +| §9.7 | `model.use` lease | ✅ | `lease/ModelUseLease.kt` | +| §9.8 | Provisioned credentials | ✅ | `credentials/` | +| §10 | Cancellation & delegation | ✅ | `messages/Control.kt`, `runtime/ARCPRuntime.kt` | +| §11 | Observability / metrics | ✅ | `messages/Telemetry.kt`, `trace/TraceContext.kt` | +| §12 | Error taxonomy | ✅ | `error/ErrorCode.kt`, `error/ARCPException.kt` | +| §15 | Vendor extensions | ✅ | `extensions/ExtensionRegistry.kt` | +| §16 | Artifacts | ✅ | `messages/Artifacts.kt` | +| §17.1 | Distributed tracing (W3C TraceContext) | ✅ | `trace/TraceContext.kt` | +| §18 | Error codes | ✅ | `error/ErrorCode.kt` | +| §19 | Session resume | ✅ | `store/EventLog.kt` | +| §21 | Extension naming (`arcpx.*`) | ✅ | `extensions/ExtensionRegistry.kt` | +| §22 | Reference transports | ✅ (memory) | `transport/MemoryTransport.kt` | +| WebSocket transport | — | 🔜 v0.2 | `transport/WebSocketTransport.kt` | +| Stdio transport | — | 🔜 v0.2 | `transport/StdioTransport.kt` | + +## Notable v1.1 additions + +- **`session.list_jobs` / `session.jobs`** (§6.6): principal-scoped in-memory + inventory with cursor pagination. +- **Agent versioning** (§7.5): `name@version` parsing, advertised descriptors, + and `AGENT_VERSION_NOT_AVAILABLE` error. +- **`result_chunk`** (§8.4): wire payloads plus client-side chunk assembly. +- **`cost.budget`** (§9.6): budget parser, counters, subset checks, and + `BUDGET_EXHAUSTED` error. +- **`model.use` and provisioned credentials** (§9.7, §9.8): lease matching, + credential wire types, provisioner interface, in-memory implementation, + redaction, issue/revoke hooks, and rotation status events. +- **Error taxonomy** (§12): `BUDGET_EXHAUSTED`, `AGENT_VERSION_NOT_AVAILABLE`, + and `LEASE_SUBSET_VIOLATION` are recognized wire codes. + +## Conformance testing + +Integration tests live in `:tests` and target the public SDK surface over +`MemoryTransport`. Run with: + +```bash +./gradlew :tests:test +``` + +For cross-language conformance tracking, refer to the ARCP spec repository +and shared issue milestones. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..0bfe50d --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,89 @@ +# Getting started + +## Prerequisites + +- **JDK 21** or newer ([Adoptium](https://adoptium.net) or Homebrew `openjdk@21`) +- **Gradle 8.10+** — the wrapper (`./gradlew`) is included; no separate install needed + +## Install + +Add the library to your Gradle project: + +```kotlin +// build.gradle.kts +dependencies { + implementation("dev.arcp:arcp:1.1.0") +} +``` + +The library requires the Kotlin coroutines and serialization runtimes; those +are declared as `api` dependencies and are pulled in automatically. + +## Minimal example + +The snippet below opens a session, submits a job, and closes cleanly. +It uses `MemoryTransport` — the same transport the integration tests use; +swap it for `WebSocketTransport` or `StdioTransport` in production. + +```kotlin +import dev.arcp.client.ARCPClient +import dev.arcp.messages.Capabilities +import dev.arcp.runtime.ARCPRuntime +import dev.arcp.runtime.AgentRegistry +import dev.arcp.transport.MemoryTransport +import kotlinx.coroutines.runBlocking + +fun main() = runBlocking { + // 1. Paired in-memory transport (client ↔ runtime). + val (clientTransport, runtimeTransport) = MemoryTransport.pair() + + // 2. Runtime with one registered agent. + val registry = AgentRegistry() + registry.register("summarise", listOf("1.0.0")) + val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(streaming = true), + agentRegistry = registry, + ) + + // 3. Let the runtime accept the connection in the background. + runtime.accept(runtimeTransport) + + // 4. Open a session from the client side. + val client = ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer("my-token"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(streaming = true), + ) + val session = client.open() // returns SessionAccepted + println("session: ${session.sessionId}") + + // 5. Submit a job. + val jobId = client.send( + session.sessionId, + dev.arcp.messages.JobSubmit(agent = "summarise@1.0.0"), + ) + println("submitted job: $jobId") + + // 6. Graceful close. + client.send(session.sessionId, dev.arcp.messages.SessionClose()) + runtime.close() +} +``` + +## Build from source + +```bash +git clone https://github.com/agentruntimecontrolprotocol/kotlin-sdk +cd kotlin-sdk +./gradlew build # compile, lint, test +./gradlew :lib:test # unit tests only +./gradlew :tests:test # integration tests over MemoryTransport +./gradlew :samples:run01 # run the minimal session sample +``` + +## Next steps + +- [Architecture](architecture.md) — understand the layering before writing more code +- [Transports](transports.md) — connect over WebSocket or stdio +- [Guides](README.md#guides) — deep-dives on sessions, jobs, leases, and more diff --git a/docs/guides/auth.md b/docs/guides/auth.md new file mode 100644 index 0000000..34f5af9 --- /dev/null +++ b/docs/guides/auth.md @@ -0,0 +1,104 @@ +# Authentication + +ARCP supports two bearer-style auth mechanisms: static bearer tokens and +signed JWTs. Both are enforced during the session handshake (RFC §8.2). + +## Static bearer tokens + +`StaticBearerAuth` maps a token string to a principal name. Comparison uses +constant-time equality to resist timing attacks. + +```kotlin +val auth = StaticBearerAuth(mapOf( + "token-alice" to "alice", + "token-bob" to "bob", +)) + +val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(), + bearerAuth = auth, +) +``` + +The principal name (e.g. `"alice"`) is stored in the session and used for +job-scoped lease and credential checks. An empty or whitespace token always +fails; `ARCPException.Unauthenticated` is thrown if the token is not in the +map. + +## JWT authentication + +`JwtAuth` validates a signed JWT against an expected audience. Use +`JwtAuth.hmac()` for HMAC-SHA256 shared-secret tokens: + +```kotlin +val secret = System.getenv("ARCP_JWT_SECRET").toByteArray() +val jwtAuth = JwtAuth.hmac(secret, audience = "arcp-runtime") + +val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(), + jwtAuth = jwtAuth, +) +``` + +`JwtAuth` validates: + +- **Signature** — using the provided `JWSVerifier` +- **`aud`** — must match `expectedAudience` +- **`sub`** — must be non-blank (becomes the principal name) +- **`exp`** — token must not be expired +- **`nbf`** — token must be active (if present) + +Any failure throws `ARCPException.Unauthenticated`. + +### Custom JWS verifier + +For asymmetric keys (RSA, EC), supply a `JWSVerifier` directly: + +```kotlin +val publicKey: RSAPublicKey = loadPublicKey() +val verifier = RSASSAVerifier(publicKey) +val jwtAuth = JwtAuth(verifier, expectedAudience = "arcp-runtime") +``` + +## Client-side auth + +`ARCPClient` accepts the auth credential via `ARCPClient.bearer(token)`: + +```kotlin +val client = ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer("token-alice"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), +) +``` + +During `client.open()` the client responds to any `SessionChallenge` with a +`SessionAuthenticate` message carrying the token as a `bearer` credential. + +## Session challenge flow + +If the runtime requires authentication, the handshake is: + +``` +client ─── SessionOpen ──────────────────────> runtime + <── SessionChallenge ───────────────── + ─── SessionAuthenticate (bearer/JWT) ──> + <── SessionAccepted ────────────────── +``` + +If the runtime accepts anonymous sessions (`Capabilities(anonymous = true)`), +it may skip the challenge and go straight to `SessionAccepted`. + +## Trust levels + +The runtime assigns each session a `TrustLevel` based on authentication: + +| Level | Description | +|-------|-------------| +| `UNTRUSTED` | No valid credentials | +| `CONSTRAINED` | Authenticated but restricted capabilities | +| `TRUSTED` | Fully authenticated principal | +| `PRIVILEGED` | Elevated administrative access | + +The trust level is visible in `SessionAccepted.trustLevel`. diff --git a/docs/guides/delegation.md b/docs/guides/delegation.md new file mode 100644 index 0000000..eba36fa --- /dev/null +++ b/docs/guides/delegation.md @@ -0,0 +1,104 @@ +# Cancellation & Delegation + +## Cancellation + +`Cancel` is a *cooperative* cancellation request — the target is asked to +stop, not killed (RFC §10.4). The runtime may accept or refuse. + +```kotlin +// Cancel a job +client.send(sessionId, Cancel( + target = CancelTarget.JOB, + targetId = jobId.value, + reason = "user aborted", + deadlineMs = 5_000, // give the job 5 s to clean up +)) + +// Handle the runtime's reply +is CancelAccepted -> println("Job ${msg.targetId} will be cancelled") +is CancelRefused -> println("Refused: ${msg.reason}") +``` + +### Cancel targets + +| `CancelTarget` | What is cancelled | +|----------------|------------------| +| `JOB` | A single job by `jobId` | +| `STREAM` | An open stream by `streamId` | +| `SESSION` | The entire session | + +## Interrupt + +`Interrupt` pauses a job and asks it a question — it is *not* a cancel +(RFC §10.5): + +```kotlin +client.send(sessionId, Interrupt( + target = CancelTarget.JOB, + targetId = jobId.value, + prompt = "Should I continue with the destructive step?", +)) +``` + +The job transitions to `BLOCKED` and waits for the caller's answer. Resume +with a `JobSubmit` or `Cancel` as appropriate. + +## Agent delegation + +When a job needs to hand work to another agent it sends `AgentDelegate` +(RFC §10.3). The child job is automatically constrained to a subset of the +parent's lease: + +```kotlin +// Inside an agent's execution context: +session.send(AgentDelegate( + agent = AgentRef.parse("classifier@1.0.0"), + input = classifyInput, + leaseRequest = buildJsonObject { put("cost.budget", buildJsonArray { add("USD:0.50") }) }, + reason = "delegating classification sub-task", +)) +``` + +The runtime creates a child job, enforces the lease subset rule, and fans +out results back to the parent. `ARCPException.LeaseSubsetViolation` is +thrown if the child requests a broader budget than the parent's remaining +balance. + +## Agent handoff + +`AgentHandoff` terminates the current agent and transfers execution to +another: + +```kotlin +session.send(AgentHandoff( + agent = AgentRef.parse("writer@1.0.0"), + input = writerInput, + reason = "planning complete; handing off to writer", +)) +``` + +Unlike delegation, handoff does not create a child — the current job ends +and a new one begins. + +## Ping / Pong — liveness + +Use `Ping`/`Pong` to check whether the remote end is still alive: + +```kotlin +client.send(sessionId, Ping(nonce = "hello")) +is Pong -> println("Alive! nonce=${msg.nonce}") +``` + +## Ack / Nack + +Every command receives either an `Ack` (success) or a `Nack` (failure): + +```kotlin +is Ack -> println("Command ${msg.ackFor} succeeded") +is Nack -> { + println("Command ${msg.nackFor} failed: ${msg.code} — ${msg.message}") + if (msg.retryable == true) retry() +} +``` + +`Nack.retryable` overrides the `ErrorCode.retryableByDefault` flag when set. diff --git a/docs/guides/errors.md b/docs/guides/errors.md new file mode 100644 index 0000000..2235315 --- /dev/null +++ b/docs/guides/errors.md @@ -0,0 +1,134 @@ +# Error Handling + +ARCP errors map 1:1 to typed `ARCPException` subclasses. Catch the specific +subclass to access structured fields. + +## Catching typed exceptions + +```kotlin +try { + client.open() +} catch (e: ARCPException.Unauthenticated) { + println("Auth failed: ${e.message}") +} catch (e: ARCPException.BudgetExhausted) { + println("Budget exhausted: ${e.currency} on job ${e.jobId}") +} catch (e: ARCPException.DeadlineExceeded) { + if (e.retryable) retry() +} +``` + +## Retryable exceptions + +Check `ARCPException.retryable` (or `ErrorCode.retryableByDefault`) before +retrying: + +```kotlin +fun withRetry(block: () -> T): T { + repeat(3) { attempt -> + try { + return block() + } catch (e: ARCPException) { + if (!e.retryable || attempt == 2) throw e + delay(100L * (attempt + 1)) + } + } + error("unreachable") +} +``` + +Retryable codes by default: `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`, +`ABORTED`, `INTERNAL`, `UNAVAILABLE`, `HEARTBEAT_LOST`, +`BACKPRESSURE_OVERFLOW`. + +**Note**: `ARCPException.BudgetExhausted` overrides `retryable = false` +regardless of the wire code's default. + +## Nack — wire-level failures + +Operations that do not throw may instead return a `Nack` envelope: + +```kotlin +is Nack -> { + val code = ErrorCode.fromWire(msg.code.wire) + println("Failed: $code — ${msg.message}") + if (msg.retryable == true) { + // Nack.retryable overrides the code default when non-null + retry() + } +} +``` + +`ErrorCode.fromWire("RATE_LIMITED")` maps the alias to `RESOURCE_EXHAUSTED`. + +## Exception taxonomy + +| Exception | Code | Retryable | Notes | +|-----------|------|-----------|-------| +| `ARCPException.Cancelled` | `CANCELLED` | No | Client cancelled | +| `ARCPException.Unknown` | `UNKNOWN` | No | Unexpected server error | +| `ARCPException.InvalidArgument` | `INVALID_ARGUMENT` | No | Malformed request | +| `ARCPException.DeadlineExceeded` | `DEADLINE_EXCEEDED` | Yes | Timed out | +| `ARCPException.NotFound` | `NOT_FOUND` | No | Resource absent | +| `ARCPException.AlreadyExists` | `ALREADY_EXISTS` | No | Duplicate `message_id` | +| `ARCPException.PermissionDenied` | `PERMISSION_DENIED` | No | Missing lease | +| `ARCPException.ResourceExhausted` | `RESOURCE_EXHAUSTED` | Yes | Rate-limit; alias `RATE_LIMITED` | +| `ARCPException.FailedPrecondition` | `FAILED_PRECONDITION` | No | Invalid state | +| `ARCPException.Aborted` | `ABORTED` | Yes | Concurrency conflict | +| `ARCPException.OutOfRange` | `OUT_OF_RANGE` | No | Value out of bounds | +| `ARCPException.Unimplemented` | `UNIMPLEMENTED` | No | Feature not available | +| `ARCPException.Internal` | `INTERNAL` | Yes | SDK bug; please report | +| `ARCPException.Unavailable` | `UNAVAILABLE` | Yes | Runtime temporarily down | +| `ARCPException.DataLoss` | `DATA_LOSS` | No | Message missing from log | +| `ARCPException.Unauthenticated` | `UNAUTHENTICATED` | No | Bad/missing token | +| `ARCPException.HeartbeatLost` | `HEARTBEAT_LOST` | Yes | Missed heartbeat deadlines | +| `ARCPException.LeaseExpired` | `LEASE_EXPIRED` | No | Lease TTL elapsed | +| `ARCPException.LeaseRevoked` | `LEASE_REVOKED` | No | Grantor revoked | +| `ARCPException.LeaseSubsetViolation` | `LEASE_SUBSET_VIOLATION` | No | Child exceeds parent | +| `ARCPException.BudgetExhausted` | `BUDGET_EXHAUSTED` | **No** | Budget cap reached | +| `ARCPException.AgentVersionNotAvailable` | `AGENT_VERSION_NOT_AVAILABLE` | No | Agent not registered | +| `ARCPException.BackpressureOverflow` | `BACKPRESSURE_OVERFLOW` | Yes | Channel full | + +## Typed exception fields + +### `ARCPException.BudgetExhausted` + +```kotlin +} catch (e: ARCPException.BudgetExhausted) { + logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" } +} +``` + +### `ARCPException.HeartbeatLost` + +```kotlin +} catch (e: ARCPException.HeartbeatLost) { + logger.error { "Job missed ${e.missedDeadlines} consecutive heartbeat deadlines" } +} +``` + +### `ARCPException.Unimplemented` + +```kotlin +} catch (e: ARCPException.Unimplemented) { + // e.message == "unimplemented (RFC §${e.section}): ${e.detail}" + logger.warn { e.message } +} +``` + +### `ARCPException.AgentVersionNotAvailable` + +```kotlin +} catch (e: ARCPException.AgentVersionNotAvailable) { + logger.error { "Agent ${e.agent}@${e.version} is not registered" } +} +``` + +## Translating wire codes + +```kotlin +val code: ErrorCode = ErrorCode.fromWire("RATE_LIMITED") +// → ErrorCode.RESOURCE_EXHAUSTED (alias mapping) + +println(code.wire) // "RESOURCE_EXHAUSTED" +println(code.retryableByDefault) // true +``` diff --git a/docs/guides/job-events.md b/docs/guides/job-events.md new file mode 100644 index 0000000..a1a3d62 --- /dev/null +++ b/docs/guides/job-events.md @@ -0,0 +1,130 @@ +# Job Events + +Between `JobStarted` and the terminal event, a job may emit any number of +progress, heartbeat, chunk, and status events. + +## JobStarted + +Sent when the agent begins executing: + +```kotlin +is JobStarted -> println("Job ${msg.jobId} is now running") +``` + +## JobProgress + +Human-readable progress indication, useful for display: + +```kotlin +is JobProgress -> println("[${msg.percent?.let { "$it%" } ?: "…"}] ${msg.message}") +``` + +`JobProgress` fields: + +| Field | Type | Description | +|-------|------|-------------| +| `message` | `String` | Display text | +| `percent` | `Int?` | 0–100, optional | +| `data` | `JsonObject` | Structured progress data | + +## JobHeartbeat + +Agents send `JobHeartbeat` on the cadence negotiated in +`Capabilities.heartbeatIntervalSeconds` (default 30 s). The runtime marks +a job as dead if it misses consecutive beats. + +```kotlin +is JobHeartbeat -> { + println("Heartbeat seq ${msg.sequence}, state=${msg.state}, deadline=${msg.deadlineMs}ms") +} +``` + +`JobHeartbeat` fields: + +| Field | Type | Description | +|-------|------|-------------| +| `sequence` | `Long` | Monotonically increasing beat number | +| `deadlineMs` | `Long` | Milliseconds until the next expected beat | +| `state` | `JobLifecycleState` | Current job state | + +If the runtime detects missed beats it emits `ARCPException.HeartbeatLost` +with a `missedDeadlines` count. Set `Capabilities.heartbeatRecovery = +HeartbeatRecovery.BLOCK` to park the job rather than kill it. + +## JobStatusEvent + +General-purpose structured status update: + +```kotlin +is JobStatusEvent -> println("Status: ${msg.status} phase=${msg.phase}") +``` + +## JobResultChunk — streaming results (RFC §8.4) + +For large outputs the agent streams result fragments. Each chunk carries a +sequence number and a `more` flag: + +```kotlin +val buffer = StringBuilder() +is JobResultChunk -> { + buffer.append(msg.data) + if (!msg.more) println("Full result: $buffer") +} +``` + +`JobResultChunk` fields: + +| Field | Type | Description | +|-------|------|-------------| +| `resultId` | `String` | Groups chunks for the same result | +| `chunkSeq` | `Long` | Zero-based sequence within `resultId` | +| `data` | `String` | Payload fragment | +| `encoding` | `ResultChunkEncoding` | `UTF8` or `BASE64` | +| `more` | `Boolean` | `false` on the last chunk | + +Enable streaming by advertising `Capabilities(streaming = true)` on both +sides. + +### ResultChunkEncoding + +| Value | Meaning | +|-------|---------| +| `UTF8` | `data` is plain UTF-8 text | +| `BASE64` | `data` is Base64-encoded binary | + +## General streams (RFC §11) + +For open-ended event, log, thought, or binary streams use the `stream.*` +envelope family: + +```kotlin +is StreamOpen -> println("Stream ${env.id} opened, kind=${msg.kind}") +is StreamChunk -> processChunk(msg.sequence, msg.data) +is StreamClose -> println("Stream closed after ${msg.totalChunks} chunks") +is StreamError -> throw RuntimeException("Stream error: ${msg.code}") +``` + +Use `Backpressure` to ask the sender to slow down: + +```kotlin +client.send(sessionId, Backpressure( + streamId = streamId, + desiredRatePerSecond = 10, +)) +``` + +## JobCompleted / JobFailed / JobCancelled + +Terminal events end the job. Handle all three: + +```kotlin +is JobCompleted -> { + println("Completed in ${msg.runtimeMs}ms: ${msg.result}") +} +is JobFailed -> { + println("Failed: code=${msg.error.code} ${msg.error.message}") +} +is JobCancelled -> { + println("Cancelled: ${msg.reason}") +} +``` diff --git a/docs/guides/jobs.md b/docs/guides/jobs.md new file mode 100644 index 0000000..e5f1022 --- /dev/null +++ b/docs/guides/jobs.md @@ -0,0 +1,114 @@ +# Jobs + +A *job* is a discrete unit of work submitted to a registered agent. Jobs +progress through a well-defined lifecycle and produce a terminal result. + +## Lifecycle + +``` +JobSubmit ──> JobAccepted ──> JobStarted ──> JobCompleted + └──> JobFailed + └──> JobCancelled +``` + +Intermediate events (`JobProgress`, `JobHeartbeat`, `JobStatusEvent`, +`JobResultChunk`) may arrive between `JobStarted` and the terminal event. +See [job-events.md](job-events.md) for details. + +## Submitting a job + +```kotlin +val msgId: MessageId = client.send(session.sessionId, JobSubmit( + agent = AgentRef.parse("summarise@1.0.0"), + input = buildJsonObject { put("text", "...") }, +)) +``` + +`JobSubmit` fields: + +| Field | Type | Description | +|-------|------|-------------| +| `agent` | `AgentRef` | `name` or `name@version` | +| `input` | `JsonElement` | Agent-specific payload | +| `leaseRequest` | `JsonObject?` | Requested capabilities (e.g. `cost.budget`) | +| `leaseConstraints` | `JsonObject?` | Client-imposed constraints on sub-jobs | +| `idempotencyKey` | `String?` | Deduplicate resubmissions | +| `maxRuntimeSec` | `Long?` | Hard timeout in seconds | + +## JobAccepted + +The runtime immediately replies with `JobAccepted`, carrying the assigned +`jobId` and the negotiated `leaseId`: + +```kotlin +// Receive loop (illustrative — real code uses Flow) +val accepted: JobAccepted = awaitMessage(msgId) +val jobId = accepted.jobId +``` + +If the agent or version is not registered the runtime replies with `Nack` +carrying `ErrorCode.AGENT_VERSION_NOT_AVAILABLE`. + +## Registering agents + +Agents must be registered before the runtime starts accepting connections: + +```kotlin +val registry = AgentRegistry() +registry.register("summarise", listOf("1.0.0", "2.0.0")) + +val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(), + agentRegistry = registry, +) +``` + +`AgentRef.parse("summarise@1.0.0")` parses the `name@version` wire form. +`AgentRef.parse("summarise")` references the agent without pinning a version; +the runtime selects the default. + +## Awaiting completion + +```kotlin +// Pseudocode — collect from the session's envelope flow +session.envelopes + .filter { it.jobId == jobId } + .collect { env -> + when (val msg = env.payload) { + is JobCompleted -> { println("result: ${msg.result}"); cancel() } + is JobFailed -> { throw RuntimeException(msg.error.message) } + is JobCancelled -> { println("cancelled: ${msg.reason}") } + else -> { /* progress / heartbeat / chunk */ } + } + } +``` + +## Idempotency + +Pass an `idempotencyKey` to ensure at-most-once dispatch: + +```kotlin +client.send(session.sessionId, JobSubmit( + agent = AgentRef.parse("summarise"), + input = input, + idempotencyKey = "req-${requestId}", +)) +``` + +If the runtime has already processed a `JobSubmit` with the same key in the +same session, it returns the stored `JobAccepted` without re-running the job. + +## Job lifecycle states + +`JobLifecycleState` is carried in `JobHeartbeat` and `JobStatusEvent`: + +| State | Meaning | +|-------|---------| +| `ACCEPTED` | Runtime accepted the submit | +| `QUEUED` | Waiting for an executor slot | +| `RUNNING` | Agent is actively executing | +| `BLOCKED` | Waiting for a resource (lease, tool reply) | +| `PAUSED` | Interrupted; waiting for resume | +| `COMPLETED` | Terminal success | +| `FAILED` | Terminal failure | +| `CANCELLED` | Terminated by client cancel request | diff --git a/docs/guides/leases.md b/docs/guides/leases.md index e2812e0..756d2f8 100644 --- a/docs/guides/leases.md +++ b/docs/guides/leases.md @@ -1,44 +1,154 @@ -# Leases +# Leases & Budgets -ARCP v1.1 adds runtime-enforced lease capabilities that can be used directly by the Kotlin SDK or delegated to an upstream provider through provisioned credentials. +Leases are runtime-enforced capability grants. They limit what a job may +do — which models it may call, how much it may spend, which tools it may +invoke — and can be delegated to sub-jobs as equal or narrower subsets +(RFC §9). -## `cost.budget` +## Permission request / grant flow -`cost.budget` values use the wire form `currency:decimal`, for example `USD:5.00` or `credits:100`. The SDK parses these into `BudgetAmount` values and tracks them per job with `BudgetCounter`. +``` +runtime ─── PermissionRequest ──> client + <── PermissionGrant ───── (or PermissionDeny) + ─── LeaseGranted ──────> client +``` + +```kotlin +is PermissionRequest -> { + println("Runtime requests ${msg.permission} on ${msg.resource}") + // Approve: + client.send(sessionId, PermissionGrant( + permission = msg.permission, + resource = msg.resource, + leaseSeconds = 300, + )) + // Or deny: + // client.send(sessionId, PermissionDeny(msg.permission, msg.resource, "not allowed")) +} + +is LeaseGranted -> { + println("Lease ${msg.leaseId} granted, expires ${msg.expiresAt}") +} +``` + +## Lease refresh + +A job can extend its lease before it expires: + +```kotlin +client.send(sessionId, LeaseRefresh( + leaseId = leaseId, + requestedExtensionSeconds = 120, +)) + +is LeaseExtended -> println("Lease extended to ${msg.expiresAt}") +``` + +If the grantor revokes the lease before expiry: + +```kotlin +is LeaseRevoked -> throw ARCPException.LeaseRevoked("Lease ${msg.leaseId}: ${msg.reason}") +``` + +## cost.budget + +`cost.budget` values use the wire form `currency:decimal` (e.g. `USD:5.00`, +`credits:100`). + +```kotlin +val budget = CostBudget( + budgets = listOf(BudgetAmount.parse("USD:10.00")), +) +``` + +Include the budget in the job's `leaseRequest`: -When a cost metric such as `cost.inference` arrives on a job envelope, the runtime decrements the matching currency counter. Once the remaining value reaches zero, the runtime reports `BUDGET_EXHAUSTED` with `retryable = false`. +```kotlin +client.send(sessionId, JobSubmit( + agent = AgentRef.parse("summarise@1.0.0"), + input = input, + leaseRequest = buildJsonObject { + put("cost.budget", buildJsonArray { add("USD:5.00") }) + }, +)) +``` + +The runtime tracks spending per job with `BudgetRegistry`. When the counter +reaches zero it emits `ARCPException.BudgetExhausted`: + +```kotlin +} catch (e: ARCPException.BudgetExhausted) { + logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" } +} +``` + +### Delegation subset rule + +A child job may only request a budget ≤ the parent's *remaining* balance: + +``` +parent budget: USD:5.00 (spent: USD:3.00, remaining: USD:2.00) +child request: USD:1.50 ✅ +child request: USD:2.50 ❌ LEASE_SUBSET_VIOLATION +``` -Child jobs may only request budgets that are less than or equal to the parent's remaining budget, and they may not introduce a new currency. +The runtime enforces this automatically; `ARCPException.LeaseSubsetViolation` +is thrown if the child's request exceeds the parent's remaining budget. -## `model.use` +## model.use -`model.use` constrains which model identifiers a job may use. Patterns are segment-aware globs: +`model.use` limits which model IDs a job may call. Patterns are +segment-aware globs: -- `tier-fast/*` matches `tier-fast/haiku` but not `tier-slow/haiku`. -- `provider/**` matches all models below `provider/`. +| Pattern | Matches | Does not match | +|---------|---------|----------------| +| `tier-fast/*` | `tier-fast/haiku` | `tier-slow/haiku` | +| `provider/**` | `provider/v1/chat` | `other/v1/chat` | +| `**` | anything | — | -Delegated jobs must request a subset of the parent lease. A literal model such as `tier-fast/haiku` is a subset of `tier-fast/*`; broadening from a literal to `tier-fast/*` is rejected. +```kotlin +val lease = ModelUseLease(patterns = listOf("anthropic/*")) +lease.allows("anthropic/claude-3") // true +lease.allows("openai/gpt-4o") // false +``` + +Subset check: + +```kotlin +ModelUseLease.subset( + parent = ModelUseLease(listOf("tier-fast/**")), + child = ModelUseLease(listOf("tier-fast/haiku")), +) // true — literal is subset of glob +``` + +## Provisioned credentials -## Provisioned Credentials +When a `CredentialProvisioner` is configured, the runtime issues per-job +credentials after lease finalization. They arrive in `JobAccepted.credentials` +and are redacted in logs: -When a `CredentialProvisioner` is configured, the runtime issues credentials after job lease finalization and attaches them to `job.accepted.credentials`. Credentials use this vendor-neutral shape: +```kotlin +is JobAccepted -> { + val cred = accepted.credentials + // cred.value is the actual secret — Credential.toString() redacts it +} +``` + +Credential shape: ```json { "id": "cred_...", "scheme": "bearer", - "value": "secret", + "value": "...", "endpoint": "https://provider.example/v1", "constraints": { "cost.budget": ["USD:1.00"], - "model.use": ["tier-fast/*"], - "expires_at": "2026-05-09T13:00:00Z" + "model.use": ["tier-fast/*"], + "expires_at": "2026-05-09T13:00:00Z" } } ``` -The `value` field is treated as a secret. `Credential.toString()` redacts it, and job introspection should only expose credentials to the submitting principal. - -On terminal job states (`completed`, `failed`, `cancelled`, or timeout), the runtime revokes outstanding credentials with retry. `CredentialStore.pendingRevocations()` is the durability hook used to retry revocation after restart. - -Credential rotation is exposed through `ARCPRuntime.rotateCredential(...)`. It issues a replacement, revokes the prior credential, and can emit a `status` event with `phase = "credential_rotated"`. +Credentials are automatically revoked on job termination. Use +`ARCPRuntime.rotateCredential(jobId)` to rotate mid-job. diff --git a/docs/guides/observability.md b/docs/guides/observability.md new file mode 100644 index 0000000..62893f7 --- /dev/null +++ b/docs/guides/observability.md @@ -0,0 +1,128 @@ +# Observability + +ARCP provides structured logging, metrics, traces, and generic events as +first-class protocol messages (RFC §§11, 17). + +## Distributed tracing — W3C TraceContext + +`TraceContext` propagates W3C trace context through coroutines as a +`CoroutineContext` element (RFC §17.1): + +```kotlin +// Start a new root trace +withContext(TraceContext.newRoot()) { + withSpan("session-open") { + val session = client.open() + + withSpan("submit-job") { + client.send(session.sessionId, JobSubmit(...)) + } + } +} +``` + +### Accessing the current trace + +```kotlin +val trace: TraceContext? = currentTrace() +println("trace=${trace?.traceId} span=${trace?.spanId} parent=${trace?.parentSpanId}") +``` + +### `withSpan` + +`withSpan(name, block)` creates a child span that inherits `traceId` from +the ambient context and generates a fresh `spanId`: + +```kotlin +withSpan("classify") { + // currentTrace()?.traceId == parent trace ID + // currentTrace()?.parentSpanId == parent span ID + doWork() +} +``` + +### Wire representation + +`TraceSpan` is the wire message sent when emitting a completed span: + +```kotlin +client.send(sessionId, TraceSpan( + name = "classify", + kind = "CLIENT", + startedAt = start, + endedAt = Instant.now(), + attributes = buildJsonObject { put("model", "claude-3") }, +)) +``` + +## Structured logging + +```kotlin +client.send(sessionId, Log( + level = LogLevel.INFO, + message = "Job started", + attributes = buildJsonObject { put("job_id", jobId.value) }, +)) +``` + +`LogLevel` values (in order of severity): `TRACE`, `DEBUG`, `INFO`, `WARN`, +`ERROR`, `CRITICAL`. + +## Metrics + +```kotlin +client.send(sessionId, Metric( + name = StandardMetrics.TOKENS_USED, + value = JsonPrimitive(1234), + unit = "tokens", + dims = buildJsonObject { put("kind", "output") }, +)) +``` + +### Standard metric names (RFC §17.3.1) + +| Constant | Wire name | Unit | +|----------|-----------|------| +| `TOKENS_USED` | `tokens.used` | `tokens`; `dims.kind ∈ input,output,cache_read,cache_write` | +| `COST_USD` | `cost.usd` | `USD` (decimal, ≤ 6 fractional digits) | +| `COST_BUDGET_REMAINING` | `cost.budget.remaining` | per-currency budget | +| `GPU_SECONDS` | `gpu.seconds` | `s` | +| `TOOL_INVOCATIONS` | `tool.invocations` | count | +| `LATENCY_MS` | `latency.ms` | `ms`; `dims.phase ∈ queue,exec,total` | +| `BYTES_IN` | `bytes.in` | `bytes` | +| `BYTES_OUT` | `bytes.out` | `bytes` | +| `ERRORS_TOTAL` | `errors.total` | count; `dims.code` = canonical error code | + +Non-standard metrics must be namespaced (e.g. `acme.model.cache_hits`). + +## Generic events + +`EventEmit` carries arbitrary structured events: + +```kotlin +client.send(sessionId, EventEmit( + eventType = "x-vendor.acme.email.parsed", + data = buildJsonObject { + put("subject", "Re: Q3 budget") + put("from", "alice@example.com") + put("thread_id", "t_abc123") + }, +)) +``` + +Event types must match the `arcpx.*` naming convention if they are +vendor-defined (see [vendor-extensions.md](vendor-extensions.md)). + +## Backpressure + +When consuming a stream that delivers faster than the receiver can process, +send `Backpressure` to request a slower rate: + +```kotlin +client.send(sessionId, Backpressure( + streamId = streamId, + desiredRatePerSecond = 5, + bufferRemainingBytes = 1024, + reason = "downstream slow", +)) +``` diff --git a/docs/guides/resume.md b/docs/guides/resume.md new file mode 100644 index 0000000..f80d5fe --- /dev/null +++ b/docs/guides/resume.md @@ -0,0 +1,117 @@ +# Session Resume + +ARCP supports resuming a session after a transport disconnect without +re-running jobs. The `EventLog` records every envelope so the runtime can +replay what the client missed (RFC §§6.3, 6.4, 19). + +## EventLog + +`EventLog` is an append-only SQLite-backed event store. Two factory +functions create instances: + +```kotlin +// In-memory (tests and samples) +val log = EventLog.openInMemory() + +// Persistent file +val log = EventLog.openFile(Path("sessions.db")) +``` + +### Appending + +```kotlin +val rowId: Long = log.append(envelope) +``` + +`append` throws `ARCPException.AlreadyExists` if an envelope with the same +`message_id` is already in the log — enforcing idempotency automatically. + +### Replaying + +```kotlin +val envelopes: Flow = log.replay( + sessionId = sessionId, + afterMessageId = lastReceivedMessageId, // null → replay from start +) +envelopes.collect { env -> /* deliver to client */ } +``` + +`replay` runs on `Dispatchers.IO` via JDBC; the returned `Flow` is cold and +completes when all matching rows have been emitted. + +If `afterMessageId` is not found in the log, `EventLog.replay` throws +`ARCPException.DataLoss`. Always pass a `MessageId` that was actually +received, or `null` to start from the beginning. + +### Idempotent operations + +`EventLog` also supports idempotency keys for non-envelope operations: + +```kotlin +val existing: String? = log.lookupIdempotent(idempotencyKey) +if (existing == null) { + log.recordIdempotent(idempotencyKey, resultJson) +} +``` + +## Resume message + +The client sends a `Resume` message to replay past the last received +envelope: + +```kotlin +client.send(sessionId, Resume( + sessionId = sessionId, + afterMessageId = lastMessageId, + jobId = jobId, // optional: scope replay to one job + includeOpenStreams = true, // re-open any streams still active +)) +``` + +| Field | Purpose | +|-------|---------| +| `sessionId` | Which session to resume | +| `afterMessageId` | Only replay envelopes after this ID | +| `jobId` | Narrow replay to a specific job (optional) | +| `checkpointId` | Resume from a named checkpoint (optional) | +| `includeOpenStreams` | Re-deliver open stream frames (optional) | + +## Full resume pattern + +```kotlin +// 1. Persist the last message ID seen +var lastSeen: MessageId? = null +session.envelopes.collect { env -> + lastSeen = env.id + process(env) +} + +// 2. Later, reconnect and replay +val newClient = ARCPClient(transport = newTransport, auth = bearer, client = info, capabilities = caps) +val newSession = newClient.open() + +if (lastSeen != null) { + newClient.send(newSession.sessionId, Resume( + sessionId = originalSessionId, + afterMessageId = lastSeen, + )) +} +``` + +## Checkpoints + +A job can be asked to save a checkpoint at its next safe point: + +```kotlin +client.send(sessionId, CheckpointCreate(jobId = jobId, label = "before-step-3")) +// The job emits a JobCheckpoint envelope when ready +``` + +To restore: + +```kotlin +client.send(sessionId, CheckpointRestore( + jobId = jobId, + checkpointId = "ckpt_abc123", +)) +``` diff --git a/docs/guides/sessions.md b/docs/guides/sessions.md new file mode 100644 index 0000000..ae53193 --- /dev/null +++ b/docs/guides/sessions.md @@ -0,0 +1,113 @@ +# Sessions + +A *session* is the top-level authenticated context between a client and a +runtime. All jobs, leases, and subscriptions are scoped to a session. + +## Session lifecycle + +``` +client ──────────────────────────────────────── runtime + │ │ + │── SessionOpen ────────────────────────────> │ (1) open + │<─ SessionChallenge ───────────────────────── │ (2) auth challenge (optional) + │── SessionAuthenticate ───────────────────> │ (3) bearer / JWT + │<─ SessionAccepted ────────────────────────── │ (4) negotiated capabilities + │ … session active … │ + │── SessionClose ──────────────────────────> │ (5) graceful close +``` + +If the runtime is configured with `StaticBearerAuth` or `JwtAuth`, the +challenge/authenticate round-trip occurs; otherwise the runtime may skip +directly to `SessionAccepted`. + +## Opening a session + +```kotlin +val (clientTransport, serverTransport) = MemoryTransport.pair() + +val runtime = ARCPRuntime( + supportedCapabilities = Capabilities(streaming = true, durableJobs = true), + bearerAuth = StaticBearerAuth(mapOf("my-token" to "alice")), + agentRegistry = AgentRegistry().also { it.register("summarise", listOf("1.0.0")) }, +) +runtime.accept(serverTransport) // launches coroutine; non-blocking + +val client = ARCPClient( + transport = clientTransport, + auth = ARCPClient.bearer("my-token"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(streaming = true), +) + +val session: SessionAccepted = client.open() +println("Session ${session.sessionId} negotiated") +``` + +`client.open()` returns only after `SessionAccepted` is received; it throws +`ARCPException.Unauthenticated` if the token is rejected or +`ARCPException.FailedPrecondition` if the runtime is not ready. + +## Capability negotiation + +`SessionAccepted.capabilities` contains the *intersection* of what the +client advertised and what the runtime supports. Inspect it before using +optional features: + +```kotlin +val caps = session.capabilities +if (caps.streaming) { + // safe to submit jobs that use result_chunk streaming +} +if (caps.durableJobs) { + // safe to use EventLog-backed resume +} +``` + +### Advertised capabilities + +The full set of `Capabilities` fields: + +| Field | Default | Purpose | +|-------|---------|---------| +| `streaming` | `false` | `result_chunk` streaming (RFC §8.4) | +| `durableJobs` | `false` | durable event log / resume | +| `checkpoints` | `false` | mid-job checkpoint / restore | +| `binaryStreams` | `false` | binary stream frames | +| `agentHandoff` | `false` | agent delegation | +| `artifacts` | `false` | file artifact transfer | +| `subscriptions` | `false` | push subscriptions | +| `scheduledJobs` | `false` | future-scheduled job dispatch | +| `provisionedCredentials` | `false` | per-job credential issue | +| `modelUse` | `false` | `model.use` lease enforcement | +| `anonymous` | `false` | unauthenticated clients allowed | +| `interrupt` | `true` | cooperative interrupt signal | +| `heartbeatIntervalSeconds` | `30` | expected heartbeat cadence | +| `heartbeatRecovery` | `FAIL` | `FAIL` or `BLOCK` on missed beats | +| `binaryEncoding` | `false` | binary-encoded envelopes | +| `extensions` | `[]` | vendor extension names (`arcpx.*`) | +| `agents` | `[]` | available agent descriptors | + +## Listing jobs + +Use `session.list_jobs` to enumerate active jobs in the session: + +```kotlin +client.send(session.sessionId, SessionListJobs( + filter = JobListFilter(state = listOf(JobLifecycleState.RUNNING)), + cursor = null, + limit = 20, +)) +// The runtime replies with a `session.jobs` envelope +``` + +## Closing a session + +Send `SessionClose` to perform a graceful shutdown; the runtime drains +in-flight jobs before tearing down: + +```kotlin +client.send(session.sessionId, SessionClose(reason = "done")) +``` + +The transport's `close()` is called automatically by `ARCPClient` after the +close handshake completes. diff --git a/docs/guides/vendor-extensions.md b/docs/guides/vendor-extensions.md new file mode 100644 index 0000000..9551f3a --- /dev/null +++ b/docs/guides/vendor-extensions.md @@ -0,0 +1,107 @@ +# Vendor Extensions + +ARCP reserves the `arcpx.*` namespace for vendor-defined message types and +event names (RFC §§15, 21). Extensions let runtime operators add proprietary +messages without forking the protocol. + +## Naming convention + +Extension names must match one of two patterns: + +``` +arcpx...v +com.example.feature.v1 (reverse-DNS form) +``` + +Examples: +- `arcpx.acme.email.v1` +- `arcpx.anthropic.reasoning.v2` +- `com.mycompany.billing.v1` + +Names that do not match these patterns are rejected by `ExtensionRegistry`. + +## ExtensionRegistry + +```kotlin +val extensions = ExtensionRegistry() +extensions.advertise("arcpx.acme.email.v1") +extensions.advertise("arcpx.acme.billing.v1") +``` + +Advertise extensions in the runtime's `Capabilities`: + +```kotlin +val capabilities = Capabilities( + extensions = listOf("arcpx.acme.email.v1", "arcpx.acme.billing.v1"), +) +val runtime = ARCPRuntime(supportedCapabilities = capabilities, ...) +``` + +Both sides must advertise an extension for it to be considered active. The +`SessionAccepted.capabilities.extensions` list contains the negotiated +intersection. + +## Handling unknown message types + +When a message arrives with an unrecognised `type` field, the runtime asks +`ExtensionRegistry.classifyUnknown()` what to do: + +```kotlin +when (extensions.classifyUnknown(wireType, optional, advertisedExtensions)) { + UnknownAction.Drop -> { /* silently ignore */ } + UnknownAction.Nack -> { /* send Nack with UNIMPLEMENTED */ } +} +``` + +An unknown type is `Drop`ped if: +- its namespace matches a locally-advertised extension (the peer may have + added a new message within the extension), or +- the sender marked the message as optional. + +Otherwise the runtime `Nack`s the message with `ErrorCode.UNIMPLEMENTED`. + +## Checking acceptance + +```kotlin +extensions.acceptsType("arcpx.acme.email.v1") // true — advertised +extensions.acceptsType("arcpx.acme.weather.v1") // false — not advertised +``` + +## Emitting extension events + +Use `EventEmit` with a namespaced `eventType`: + +```kotlin +client.send(sessionId, EventEmit( + eventType = "arcpx.acme.email.v1.parsed", + data = buildJsonObject { + put("subject", "Q3 report") + put("sender", "alice@acme.com") + put("thread", "t_xyz") + }, +)) +``` + +The wire `type` for `EventEmit` is always `event.emit`; the vendor namespace +lives in the `event_type` payload field, not the envelope `type` discriminator. + +## Custom wire message types (advanced) + +To define a fully custom wire type that participates in polymorphic +deserialization, register a `@SerialName` subclass of `MessageType` and +configure `arcpJson`: + +```kotlin +@Serializable +@SerialName("arcpx.acme.email.v1.send") +data class AcmeEmailSend( + val to: String, + val subject: String, + val body: String, +) : MessageType + +// Then extend arcpJson with a module that includes AcmeEmailSend +``` + +This is an advanced integration point; for most use cases `EventEmit` is +sufficient. diff --git a/docs/modules/arcp-cli.md b/docs/modules/arcp-cli.md new file mode 100644 index 0000000..45d2a0e --- /dev/null +++ b/docs/modules/arcp-cli.md @@ -0,0 +1,89 @@ +# Module: arcp-cli (`dev.arcp:arcp-cli`) + +The `:cli` Gradle module provides the `arcp` command-line binary, built on +top of the `:lib` protocol library. + +**Maven coordinates**: `dev.arcp:arcp-cli:1.1.0` + +--- + +## Building + +```bash +./gradlew :cli:installDist +# Binary placed at: +./cli/build/install/arcp/bin/arcp +``` + +Or run directly via Gradle: + +```bash +./gradlew :cli:run --args="version" +``` + +--- + +## Commands + +### `arcp version` + +Print SDK and protocol version information: + +``` +$ arcp version +ARCP protocol: 1.1 +Kotlin SDK: 1.1.0 +SDK kind: kotlin +``` + +### `arcp serve` *(v0.2)* + +Run an ARCP runtime over a named transport: + +``` +$ arcp serve --transport=websocket --port=8080 +``` + +> Not functional in v0.1. Prints `"runtime serve mode is v0.2"`. + +### `arcp send` *(v0.2)* + +Submit a job to a running runtime: + +``` +$ arcp send --url=wss://runtime.example.com/arcp \ + --agent=summarise@1.0.0 \ + --token=my-bearer-token +``` + +> Not functional in v0.1. + +### `arcp replay` *(v0.2)* + +Replay a session log from a SQLite `EventLog` file: + +``` +$ arcp replay --db=session.db --session=sess_abcde +``` + +> Not functional in v0.1. + +--- + +## Shell completion *(v0.2)* + +Bash, zsh, and fish completion scripts will be generated automatically by +the Clikt framework when `--generate-completion` lands in v0.2. + +--- + +## Entry point + +`dev.arcp.cli.main` — the JVM `main` function. The binary is assembled by +the `application` plugin and distributed as a zip/tar via +`:cli:distZip` / `:cli:distTar`. + +```kotlin +// cli/src/main/kotlin/dev/arcp/cli/Main.kt +fun main(args: Array) = ArcpCli().main(args) +``` diff --git a/docs/modules/arcp.md b/docs/modules/arcp.md new file mode 100644 index 0000000..1ce5444 --- /dev/null +++ b/docs/modules/arcp.md @@ -0,0 +1,382 @@ +# Module: arcp (`dev.arcp:arcp`) + +The `:lib` Gradle module is the publishable ARCP protocol library. All +public API lives here. + +**Maven coordinates**: `dev.arcp:arcp:1.1.0` + +--- + +## dev.arcp.envelope + +### `Envelope` + +The canonical wire container for every ARCP message (RFC §6.1). + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `MessageId` | Unique per-message identifier | +| `type` | `String` | Wire discriminator (e.g. `"session.open"`) | +| `timestamp` | `Instant` | ISO 8601 send time | +| `sessionId` | `SessionId?` | Owning session | +| `jobId` | `JobId?` | Owning job (optional) | +| `correlationId` | `MessageId?` | Request/response correlation | +| `causationId` | `MessageId?` | Causal predecessor | +| `traceId` | `String?` | W3C trace ID | +| `priority` | `String` | `"normal"` or `"high"` | +| `payload` | `MessageType` | Polymorphic message body | + +The custom serializer hoists the `type` discriminator from `payload` to the +envelope root, matching the RFC §6.1 wire layout. + +--- + +## dev.arcp.messages + +All RFC §6.2 message types as `@Serializable @SerialName` data classes +implementing `MessageType`. + +### Session messages + +| Class | Wire type | Direction | +|-------|-----------|-----------| +| `SessionOpen` | `session.open` | C → R | +| `SessionChallenge` | `session.challenge` | R → C | +| `SessionAuthenticate` | `session.authenticate` | C → R | +| `SessionAccepted` | `session.accepted` | R → C | +| `SessionUnauthenticated` | `session.unauthenticated` | R → C | +| `SessionRejected` | `session.rejected` | R → C | +| `SessionRefresh` | `session.refresh` | either | +| `SessionEvicted` | `session.evicted` | R → C | +| `SessionClose` | `session.close` | either | +| `SessionListJobs` | `session.list_jobs` | C → R | +| `SessionJobs` | `session.jobs` | R → C | + +Key types on `SessionAccepted`: `sessionId: SessionId`, `capabilities: +Capabilities`, `runtime: RuntimeIdentity`, `trustLevel: TrustLevel`. + +`TrustLevel`: `UNTRUSTED`, `CONSTRAINED`, `TRUSTED`, `PRIVILEGED`. + +### Execution messages + +| Class | Wire type | +|-------|-----------| +| `JobSubmit` | `job.submit` | +| `JobAccepted` | `job.accepted` | +| `JobStarted` | `job.started` | +| `JobProgress` | `job.progress` | +| `JobHeartbeat` | `job.heartbeat` | +| `JobStatusEvent` | `job.status` | +| `JobResultChunk` | `job.result_chunk` | +| `JobResult` | `job.result` | +| `JobCompleted` | `job.completed` | +| `JobFailed` | `job.failed` | +| `JobCancelled` | `job.cancelled` | +| `JobCheckpoint` | `job.checkpoint` | +| `ToolInvoke` | `tool.invoke` | +| `ToolResult` | `tool.result` | +| `ToolError` | `tool.error` | + +`ResultChunkEncoding`: `UTF8`, `BASE64`. +`JobLifecycleState`: `ACCEPTED`, `QUEUED`, `RUNNING`, `BLOCKED`, `PAUSED`, +`COMPLETED`, `FAILED`, `CANCELLED`. + +### Control messages + +| Class | Wire type | +|-------|-----------| +| `Ping` | `ping` | +| `Pong` | `pong` | +| `Ack` | `ack` | +| `Nack` | `nack` | +| `Cancel` | `cancel` | +| `CancelAccepted` | `cancel.accepted` | +| `CancelRefused` | `cancel.refused` | +| `Interrupt` | `interrupt` | +| `Resume` | `resume` | +| `Backpressure` | `backpressure` | +| `CheckpointCreate` | `checkpoint.create` | +| `CheckpointRestore` | `checkpoint.restore` | + +`CancelTarget`: `JOB`, `STREAM`, `SESSION`. + +### Permission / lease messages + +| Class | Wire type | +|-------|-----------| +| `PermissionRequest` | `permission.request` | +| `PermissionGrant` | `permission.grant` | +| `PermissionDeny` | `permission.deny` | +| `LeaseGranted` | `lease.granted` | +| `LeaseRefresh` | `lease.refresh` | +| `LeaseExtended` | `lease.extended` | +| `LeaseRevoked` | `lease.revoked` | + +### Streaming messages + +| Class | Wire type | +|-------|-----------| +| `StreamOpen` | `stream.open` | +| `StreamChunk` | `stream.chunk` | +| `StreamClose` | `stream.close` | +| `StreamError` | `stream.error` | + +`StreamKind`: `TEXT`, `BINARY`, `EVENT`, `LOG`, `METRIC`, `THOUGHT`. + +### Telemetry messages + +| Class | Wire type | +|-------|-----------| +| `EventEmit` | `event.emit` | +| `Log` | `log` | +| `Metric` | `metric` | +| `TraceSpan` | `trace.span` | + +`LogLevel`: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `CRITICAL`. + +Standard metric name constants are in `StandardMetrics`. + +### Agent messages + +| Class | Description | +|-------|-------------| +| `AgentRef` | `name` or `name@version` reference; `AgentRef.parse(wire)` | +| `AgentDescriptor` | Versions advertised by a runtime | + +--- + +## dev.arcp.client + +### `ARCPClient` + +```kotlin +ARCPClient( + transport : Transport, + auth : BearerAuth, + client : ClientInfo, + capabilities : Capabilities, +) +``` + +| Method | Returns | Description | +|--------|---------|-------------| +| `open()` | `SessionAccepted` | Authenticate and negotiate session | +| `send(sessionId, payload)` | `MessageId` | Send a message | + +Companion factories: + +```kotlin +ARCPClient.bearer(token: String): BearerAuth +ARCPClient.defaultClientInfo(): ClientInfo +``` + +--- + +## dev.arcp.runtime + +### `ARCPRuntime` + +```kotlin +ARCPRuntime( + supportedCapabilities : Capabilities, + bearerAuth : BearerAuth? = null, + jwtAuth : JwtAuth? = null, + agentRegistry : AgentRegistry = AgentRegistry(), + budgetRegistry : BudgetRegistry = BudgetRegistry(), + eventLog : EventLog? = null, + credentialProvisioner : CredentialProvisioner? = null, + extensionRegistry : ExtensionRegistry = ExtensionRegistry(), +) +``` + +| Method | Description | +|--------|-------------| +| `accept(transport)` | Start the server coroutine (non-blocking) | +| `rotateCredential(jobId)` | Issue replacement credential mid-job | + +### `AgentRegistry` + +```kotlin +val registry = AgentRegistry() +registry.register("summarise", listOf("1.0.0", "2.0.0")) +``` + +--- + +## dev.arcp.transport + +### `Transport` interface + +```kotlin +interface Transport { + suspend fun send(envelope: Envelope) + fun receive(): Flow + fun close() +} +``` + +### `MemoryTransport` + +```kotlin +val (clientTransport, serverTransport) = MemoryTransport.pair() +// or: +val (c, s) = MemoryTransport.pair(capacity = 128) +``` + +`DEFAULT_CAPACITY = 64`. The channel uses `BufferOverflow.SUSPEND` so +real backpressure propagates in tests. + +--- + +## dev.arcp.auth + +### `BearerAuth` (fun interface) + +```kotlin +fun interface BearerAuth { + fun verify(token: String): String // returns principal name +} +``` + +### `StaticBearerAuth` + +```kotlin +StaticBearerAuth(tokens: Map) +// key=token, value=principal +``` + +### `JwtAuth` + +```kotlin +JwtAuth(verifier: JWSVerifier, expectedAudience: String) +// Companion: +JwtAuth.hmac(secret: ByteArray, audience: String): JwtAuth +``` + +--- + +## dev.arcp.credentials + +| Class | Description | +|-------|-------------| +| `Credential` | Wire credential (id, scheme, value, endpoint, constraints); `toString()` redacts `value` | +| `CredentialStore` | In-memory store; `issue(jobId, cred)`, `revoke(credId)`, `pendingRevocations()` | +| `CredentialProvisioner` | Interface: `provision(jobId, lease): Credential` | + +--- + +## dev.arcp.lease + +| Class | Description | +|-------|-------------| +| `Currency` | Value class wrapping a currency string | +| `BudgetAmount` | `(currency, value: BigDecimal)`; `BudgetAmount.parse("USD:5.00")` | +| `CostBudget` | `(budgets: List)` — lease constraint container | +| `BudgetRegistry` | Per-job counters; `register`, `consume`, `terminate`, `remaining` | +| `BudgetCounter` | Single-job counter; `consume(amount): Outcome` (`Ok` or `Exhausted`) | +| `ModelUseLease` | `(patterns: List)`; `allows(modelId)`, `subset(parent, child)` | +| `LeaseSubset` | Static helpers for subset validation | + +--- + +## dev.arcp.store + +### `EventLog` + +```kotlin +EventLog.openInMemory(): EventLog +EventLog.openFile(path: Path): EventLog +``` + +| Method | Description | +|--------|-------------| +| `append(envelope): Long` | Append; throws `AlreadyExists` on dup `message_id` | +| `replay(sessionId, afterMessageId?): Flow` | Replay envelopes | +| `lookupIdempotent(key): String?` | Check idempotency key | +| `recordIdempotent(key, value)` | Record idempotency result | + +--- + +## dev.arcp.trace + +### `TraceContext` + +```kotlin +data class TraceContext( + val traceId: TraceId, + val spanId: SpanId, + val parentSpanId: SpanId? = null, +) : AbstractCoroutineContextElement(Key) +``` + +| Function | Description | +|----------|-------------| +| `TraceContext.newRoot()` | Create root span (random traceId + spanId) | +| `currentTrace()` | Get ambient `TraceContext` from coroutine context | +| `withSpan(name, block)` | Run block in child span; returns block result | + +--- + +## dev.arcp.extensions + +### `ExtensionRegistry` + +```kotlin +val ext = ExtensionRegistry() +ext.advertise("arcpx.acme.email.v1") +ext.acceptsType(wireType): Boolean +ext.classifyUnknown(wireType, optional, advertised): UnknownAction +``` + +`UnknownAction`: `Drop`, `Nack`. + +--- + +## dev.arcp.ids + +Typed ID wrappers (all are `@JvmInline value class` wrapping `String`): + +`SessionId`, `JobId`, `MessageId`, `LeaseId`, `StreamId`, +`PermissionName`, `TraceId`, `SpanId`. + +--- + +## dev.arcp.error + +### `ErrorCode` enum + +24 codes; `wire: String`, `retryableByDefault: Boolean`. + +```kotlin +ErrorCode.fromWire("RATE_LIMITED") // → RESOURCE_EXHAUSTED +``` + +### `ARCPException` sealed class + +24 subclasses. Common pattern: + +```kotlin +try { + client.open() +} catch (e: ARCPException) { + if (e.retryable) retry() +} +``` + +--- + +## dev.arcp.json + +### `arcpJson` + +Pre-configured `kotlinx.serialization` `Json` instance: + +```kotlin +val json = arcpJson // lenient, ignores unknown keys, custom serializers registered +``` + +Use `arcpJson` to parse raw ARCP JSON strings: + +```kotlin +val envelope = arcpJson.decodeFromString(rawJson) +``` diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..8366362 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,106 @@ +# Recipes + +Copy-paste solutions for complete, real-world ARCP patterns. Each recipe wires +together multiple protocol features around an actual LLM workload; unlike the +[`samples/`](../samples/) programs — which isolate a single concept — recipes +are end-to-end shapes you can adapt directly to production use cases. + +Kotlin recipe source lives in [`recipes/`](../recipes/). + +--- + +## [multi-agent-budget](../recipes/multi-agent-budget/) — OpenAI + +A planner decomposes a question into sub-questions and delegates each to a +worker that carries a budget slice carved from the planner's remaining cap. + +**Features demonstrated** + +- `CostBudget` / `BudgetAmount` subset enforcement +- `LeaseGranted` / `LeaseRevoked` delegation handshake +- `BUDGET_EXHAUSTED` handling — sub-questions that no longer fit are skipped + before the `agent.delegate` rather than failing mid-flight +- `StandardMetrics.COST_USD` emitted after each delegate so the runtime's + subset check sees an honest remaining balance + +**Key types**: `BudgetRegistry`, `BudgetCounter`, `ARCPException.BudgetExhausted` + +See [guides/leases.md](guides/leases.md) and [guides/delegation.md](guides/delegation.md). + +--- + +## [email-vendor-leases](../recipes/email-vendor-leases/) — Claude + +A triage agent drives Claude through a tool-use loop with three tools, but the +lease grants only the two read-only ones. When the model proposes `send_reply` +the agent's lease validator throws `PERMISSION_DENIED` and feeds the denial +back to Claude, which observes the deny and returns a drafted-but-unsent reply. + +**Features demonstrated** + +- `PermissionRequest` / `PermissionGrant` / `PermissionDeny` flow +- `LeaseGranted` with `operation` constraints on individual tool names +- `EventEmit` with `arcpx.acme.email.v1.parsed` vendor event type — dashboards + recognising the namespace can render parsed metadata specially +- `ExtensionRegistry.advertise("arcpx.acme.email.v1")` capability negotiation + +**Key types**: `PermissionRequest`, `PermissionDeny`, `EventEmit`, `ExtensionRegistry` + +See [guides/leases.md](guides/leases.md) and [guides/vendor-extensions.md](guides/vendor-extensions.md). + +--- + +## [stream-resume](../recipes/stream-resume/) — GLM-5 + +A writer pipes GLM-5's streaming deltas into `StreamChunk` envelopes +(~200 chars each). The client deliberately drops the transport mid-stream, +opens a fresh connection with `Resume`, and the runtime replays every envelope +past the cutoff from the `EventLog` so reassembly completes seamlessly across +the gap. + +**Features demonstrated** + +- `StreamOpen` / `StreamChunk` / `StreamClose` with `ResultChunkEncoding.UTF8` +- `EventLog.openFile(path)` — every envelope lands in a SQLite log under a + monotonic `event_seq` +- `Resume(afterMessageId, includeOpenStreams = true)` — client reconnects and + receives only the envelopes it missed +- `JobCheckpoint` before the gap so resume can skip already-processed steps +- `Backpressure` signalling when the consumer falls behind + +**Key types**: `EventLog`, `Resume`, `StreamChunk`, `JobCheckpoint` + +See [guides/resume.md](guides/resume.md) and [guides/job-events.md](guides/job-events.md). + +--- + +## [mcp-skill](../recipes/mcp-skill/) — MCP bridge + +An MCP server fronts the `multi-agent-budget` planner so any MCP host +(Claude Code, Cursor, Desktop) can invoke it as a single `research` tool. +The bridge keeps one long-lived ARCP session; each MCP tool call submits a +fresh planner job and returns the terminal result as the tool's text response. + +**Features demonstrated** + +- Long-lived `ARCPClient` session shared across many short MCP calls +- `JobSubmit` with `idempotencyKey` so duplicate MCP retries don't re-execute +- `JobResult` / `JobCompleted` terminal-event collection +- `Ping` / `Pong` keepalive loop on the shared session + +**Key types**: `ARCPClient`, `JobSubmit`, `JobCompleted`, `Ping` + +See [guides/jobs.md](guides/jobs.md) and [transports.md](transports.md). + +--- + +## Related reading + +| Topic | Guide | +|-------|-------| +| Lease delegation and budget subsets | [guides/leases.md](guides/leases.md) | +| Vendor extension naming and `EventEmit` | [guides/vendor-extensions.md](guides/vendor-extensions.md) | +| `EventLog` append and replay | [guides/resume.md](guides/resume.md) | +| Job lifecycle and `JobSubmit` fields | [guides/jobs.md](guides/jobs.md) | +| Streaming chunks and `Backpressure` | [guides/job-events.md](guides/job-events.md) | +| `Ping`/`Pong`, `Ack`/`Nack`, `Cancel` | [guides/delegation.md](guides/delegation.md) | diff --git a/docs/transports.md b/docs/transports.md new file mode 100644 index 0000000..9bfa740 --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,114 @@ +# Transports + +A `Transport` is the bidirectional channel over which `Envelope` frames flow +between client and runtime. The SDK ships one production-ready transport and +one for testing. + +## Interface + +```kotlin +package dev.arcp.transport + +interface Transport { + suspend fun send(envelope: Envelope) + fun receive(): Flow + fun close() +} +``` + +Implementors agree to: + +- **Ordering** — frames are delivered in send order (per direction). +- **Backpressure** — `send` suspends when the receiver is slow; it does not drop. +- **Cancellation** — `close()` terminates both the outbound and inbound flows. + +--- + +## MemoryTransport + +`MemoryTransport` pairs two in-process channels. It is the transport used by +all integration tests and the `samples/` programs. + +### Construction + +```kotlin +val (clientTransport, serverTransport) = MemoryTransport.pair() +// or with a custom channel capacity: +val (c, s) = MemoryTransport.pair(capacity = 128) +``` + +`pair()` returns `Pair`. The first element +is the client side, the second is the server (runtime) side. Each side's +`send` writes to the other's `receive` flow. + +**Default capacity** is `64` envelopes per direction +(`MemoryTransport.DEFAULT_CAPACITY`). When the channel is full the sender +suspends, so real backpressure propagates even in tests. + +### Use case + +```kotlin +val (ct, rt) = MemoryTransport.pair() +val runtime = ARCPRuntime(supportedCapabilities = Capabilities(), agentRegistry = registry) +runtime.accept(rt) + +val client = ARCPClient( + transport = ct, + auth = ARCPClient.bearer("my-token"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), +) +val session = client.open() +``` + +--- + +## WebSocketTransport (v0.2) + +WebSocket support ships in SDK v0.2. The transport class will live in +`dev.arcp.transport.WebSocketTransport` and wrap a Ktor `DefaultClientWebSocketSession`. + +Expected API (subject to change before release): + +```kotlin +// Client side +val client = ARCPClient( + transport = WebSocketTransport.connect("wss://runtime.example.com/arcp"), + auth = ARCPClient.bearer(token), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(streaming = true), +) +``` + +--- + +## StdioTransport (v0.2) + +Standard-input/output transport for subprocess-based runtimes ships in +SDK v0.2. It will live in `dev.arcp.transport.StdioTransport`. + +--- + +## Writing a custom transport + +Implement the `Transport` interface and inject it at construction time: + +```kotlin +class MyCustomTransport : Transport { + override suspend fun send(envelope: Envelope) { /* ... */ } + override fun receive(): Flow = /* cold Flow */ TODO() + override fun close() { /* ... */ } +} + +val client = ARCPClient( + transport = MyCustomTransport(), + auth = ARCPClient.bearer("token"), + client = ARCPClient.defaultClientInfo(), + capabilities = Capabilities(), +) +``` + +The `receive()` flow should be cold (one consumer activates it). The flow +completes normally when the connection closes and throws on transport errors; +`ARCPClient`/`ARCPRuntime` will propagate those errors as `ARCPException` +subclasses. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..ce634fe --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,116 @@ +# Troubleshooting + +## Error codes + +Every ARCP error maps to an `ErrorCode` constant (RFC §18.2). Catch +`ARCPException` subclasses to react to them: + +```kotlin +try { + client.open() +} catch (e: ARCPException.Unauthenticated) { + println("Auth failed: ${e.message}") +} catch (e: ARCPException.BudgetExhausted) { + println("Budget exhausted (${e.currency}) on job ${e.jobId}") +} +``` + +### Full error taxonomy + +| Wire code | Retryable? | Exception class | Common cause | +|-----------|-----------|-----------------|--------------| +| `OK` | — | — | Success (non-error) | +| `CANCELLED` | No | `ARCPException.Cancelled` | Client cancelled the operation | +| `UNKNOWN` | No | `ARCPException.Unknown` | Unexpected server-side error | +| `INVALID_ARGUMENT` | No | `ARCPException.InvalidArgument` | Malformed request field | +| `DEADLINE_EXCEEDED` | Yes | `ARCPException.DeadlineExceeded` | Operation timed out | +| `NOT_FOUND` | No | `ARCPException.NotFound` | Resource or agent does not exist | +| `ALREADY_EXISTS` | No | `ARCPException.AlreadyExists` | Duplicate `message_id` in event log | +| `PERMISSION_DENIED` | No | `ARCPException.PermissionDenied` | Missing lease or permission | +| `RESOURCE_EXHAUSTED` | Yes | `ARCPException.ResourceExhausted` | Rate-limit hit (wire alias: `RATE_LIMITED`) | +| `FAILED_PRECONDITION` | No | `ARCPException.FailedPrecondition` | Operation not valid in current state | +| `ABORTED` | Yes | `ARCPException.Aborted` | Concurrency conflict; retry | +| `OUT_OF_RANGE` | No | `ARCPException.OutOfRange` | Value exceeds valid bounds | +| `UNIMPLEMENTED` | No | `ARCPException.Unimplemented` | Feature not yet implemented | +| `INTERNAL` | Yes | `ARCPException.Internal` | Runtime bug; report it | +| `UNAVAILABLE` | Yes | `ARCPException.Unavailable` | Runtime temporarily unreachable | +| `DATA_LOSS` | No | `ARCPException.DataLoss` | Message missing from event log | +| `UNAUTHENTICATED` | No | `ARCPException.Unauthenticated` | Bad or missing bearer/JWT token | +| `HEARTBEAT_LOST` | Yes | `ARCPException.HeartbeatLost` | Job missed consecutive heartbeat deadlines | +| `LEASE_EXPIRED` | No | `ARCPException.LeaseExpired` | Lease TTL passed before use | +| `LEASE_REVOKED` | No | `ARCPException.LeaseRevoked` | Runtime revoked the lease | +| `LEASE_SUBSET_VIOLATION` | No | `ARCPException.LeaseSubsetViolation` | Requested capability exceeds granted subset | +| `BUDGET_EXHAUSTED` | No | `ARCPException.BudgetExhausted` | Cost budget ceiling reached | +| `AGENT_VERSION_NOT_AVAILABLE` | No | `ARCPException.AgentVersionNotAvailable` | No matching `agent@version` registered | +| `BACKPRESSURE_OVERFLOW` | Yes | `ARCPException.BackpressureOverflow` | Subscription channel full | + +--- + +## Common failure modes + +### `ARCPException.Unauthenticated` at `client.open()` + +The bearer token was rejected. Check: +- Token is not empty or whitespace. +- Server's `StaticBearerAuth` map includes the exact token. +- For JWT: the `sub` claim is non-blank, `aud` matches the configured audience, + and the token has not expired. + +### `ARCPException.AgentVersionNotAvailable` + +The client requested `agent@version` that is not registered: + +```kotlin +// Runtime must register the agent + version before accepting connections +val registry = AgentRegistry() +registry.register("summarise", listOf("1.0.0")) +val runtime = ARCPRuntime(agentRegistry = registry, ...) +``` + +### `ARCPException.BudgetExhausted` + +The job's provisioned credential contained a `cost.budget` cap that was +reached. The exception carries the `currency` and `jobId`: + +```kotlin +} catch (e: ARCPException.BudgetExhausted) { + logger.warn { "Job ${e.jobId} exceeded ${e.currency} budget" } +} +``` + +### `ARCPException.HeartbeatLost` + +The runtime stopped receiving heartbeat acknowledgements from a job. The +`missedDeadlines` property shows how many consecutive beats were missed. +If the job is an external subprocess, check that it is calling +`JobHeartbeat` on schedule. + +### `ARCPException.DataLoss` during resume + +`EventLog.replay()` could not find `afterMessageId` in the log for the given +session. This usually means the log was deleted or the wrong session ID was +used. Verify the SQLite file path and the `session_id` value. + +### Build error: "Unable to locate a Java Runtime" + +The Gradle wrapper cannot find JDK 21. Fix: + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@21 +export PATH="$JAVA_HOME/bin:$PATH" +./gradlew build +``` + +### Detekt violation in CI + +Run detekt locally to see the specific rule: + +```bash +./gradlew :lib:detekt +``` + +Common fixes: +- **FunctionTooLong** — extract helpers; target ≤15 lines, hard cap 30. +- **ComplexMethod** — extract branches into named functions or `when` tables. +- **MagicNumber** — move numeric literals to `const val`. +- **ForbiddenVoid** — use `Unit` instead of `void`-style expressions. diff --git a/settings.gradle.kts b/settings.gradle.kts index d28e522..9823b41 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,3 +21,4 @@ include(":lib") include(":cli") include(":samples") include(":tests") +include(":recipes") From 73ee4ebc4a67738b6a4d56b9496ccfe388bf374d Mon Sep 17 00:00:00 2001 From: Nick Ficano Date: Fri, 22 May 2026 10:52:55 -0400 Subject: [PATCH 6/6] feat(#31): configure Maven Central publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump version 0.1.0 → 1.1.0 across all modules - Add io.github.gradle-nexus.publish-plugin v2.0.0 to root build - Configure nexusPublishing {} with OSSRH s01 endpoints - Add signing plugin to :lib: with in-memory GPG key support - Set explicit artifactId = "arcp" and update POM description to v1.1 - Add .github/workflows/publish.yml triggered on v*.*.* tags Runs: assemble → test → publishToSonatype → closeAndReleaseSonatypeStagingRepository Secrets required: OSSRH_USERNAME, OSSRH_PASSWORD, SIGNING_KEY_ID, SIGNING_KEY, SIGNING_PASSWORD Co-Authored-By: Claude Opus 4.7 --- .github/workflows/publish.yml | 67 +++++++++++++++++++++++++++++++++++ build.gradle.kts | 19 +++++++++- gradle/libs.versions.toml | 2 ++ lib/build.gradle.kts | 19 +++++++++- 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..77ed307 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,67 @@ +# Publish workflow for kotlin-sdk. +# +# Triggers on any semver tag (e.g. v1.1.0). The job: +# 1. Builds, signs, and stages artifacts to OSSRH. +# 2. Closes and releases the staging repository to Maven Central. +# +# Required repository secrets (Settings → Secrets → Actions): +# OSSRH_USERNAME — Sonatype account username (s01.oss.sonatype.org) +# OSSRH_PASSWORD — Sonatype account password / token +# SIGNING_KEY_ID — Last 8 hex digits of the GPG fingerprint +# SIGNING_KEY — ASCII-armored GPG private key (full block) +# SIGNING_PASSWORD — Passphrase for the GPG key +# +# Action pinning policy: +# - First-party actions (actions/*) are pinned to a major tag. +# - Third-party actions are pinned to a full commit SHA with a version +# comment alongside. +name: publish + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Publish to Maven Central + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: gradle + + - name: Validate Gradle wrapper + # gradle/actions/wrapper-validation v4.4.4 + uses: gradle/actions/wrapper-validation@ac638b010cf58a27ee6c972d7336334ccaf61c96 + + - name: Build and test + run: ./gradlew --no-daemon assemble test + + - name: Publish to OSSRH and release to Central + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: > + ./gradlew --no-daemon + publishToSonatype + closeAndReleaseSonatypeStagingRepository diff --git a/build.gradle.kts b/build.gradle.kts index 130967e..e548f64 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,11 +5,12 @@ plugins { alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) apply false alias(libs.plugins.dokka) apply false + alias(libs.plugins.nexus.publish) } allprojects { group = "dev.arcp" - version = "0.1.0" + version = "1.1.0" } subprojects { @@ -59,3 +60,19 @@ subprojects { } } } + +// --------------------------------------------------------------------------- +// OSSRH / Maven Central +// --------------------------------------------------------------------------- +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) + snapshotRepositoryUrl.set( + uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"), + ) + username.set(providers.environmentVariable("OSSRH_USERNAME")) + password.set(providers.environmentVariable("OSSRH_PASSWORD")) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c592519..a845a90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ detekt = "1.23.7" kover = "0.8.3" dokka = "1.9.20" binary-compatibility-validator = "0.16.3" +nexus-publish = "2.0.0" [libraries] kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } @@ -52,3 +53,4 @@ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } +nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus-publish" } diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 615072b..dbc6b31 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.binary.compatibility.validator) `java-library` `maven-publish` + signing } kotlin { @@ -56,11 +57,12 @@ java { publishing { publications { create("maven") { + artifactId = "arcp" from(components["java"]) pom { name.set("ARCP Kotlin SDK") description.set( - "Reference Kotlin implementation of the Agent Runtime Control Protocol (ARCP) v1.0.", + "Reference Kotlin implementation of the Agent Runtime Control Protocol (ARCP) v1.1.", ) url.set("https://github.com/agentruntimecontrolprotocol/kotlin-sdk") licenses { @@ -88,3 +90,18 @@ publishing { } } } + +// --------------------------------------------------------------------------- +// GPG signing — required by Maven Central. +// Keys are injected via environment variables in CI; local builds skip signing +// when SIGNING_KEY is absent. +// --------------------------------------------------------------------------- +signing { + val signingKey = providers.environmentVariable("SIGNING_KEY") + val signingKeyId = providers.environmentVariable("SIGNING_KEY_ID") + val signingPassword = providers.environmentVariable("SIGNING_PASSWORD") + if (signingKey.isPresent) { + useInMemoryPgpKeys(signingKeyId.orNull, signingKey.orNull, signingPassword.orNull) + sign(publishing.publications["maven"]) + } +}