ask-session-protocol 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +58 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/docs/ask-session-protocol.schema.json +1428 -0
- data/lib/ask/session_protocol/client.rb +260 -0
- data/lib/ask/session_protocol/events.rb +322 -0
- data/lib/ask/session_protocol/host.rb +81 -0
- data/lib/ask/session_protocol/interactions.rb +139 -0
- data/lib/ask/session_protocol/methods.rb +393 -0
- data/lib/ask/session_protocol/schema.rb +117 -0
- data/lib/ask/session_protocol/version.rb +7 -0
- data/lib/ask/session_protocol.rb +37 -0
- data/lib/ask-session-protocol.rb +3 -0
- metadata +127 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module SessionProtocol
|
|
5
|
+
# Resolvable interactions — the human-in-the-loop surface of the
|
|
6
|
+
# protocol.
|
|
7
|
+
#
|
|
8
|
+
# An interaction is created by the host (emitted as an interaction
|
|
9
|
+
# event: approval.required, plan.proposed) with a unique id. Any client
|
|
10
|
+
# may resolve it by id through the interaction/* and plan/* methods;
|
|
11
|
+
# the host routes the resolution to the session and tombstones delivery
|
|
12
|
+
# so each subscriber sees it exactly once.
|
|
13
|
+
#
|
|
14
|
+
# approval.required ──▶ interaction/approve | interaction/reject
|
|
15
|
+
# plan.proposed ──▶ plan/approve | plan/reject
|
|
16
|
+
# user input ──▶ interaction/respond (host → client request)
|
|
17
|
+
module Interactions
|
|
18
|
+
# The interaction kinds.
|
|
19
|
+
KINDS = %w[approval plan user_input].freeze
|
|
20
|
+
|
|
21
|
+
# Resolution statuses. A resolved interaction never returns to pending.
|
|
22
|
+
STATUSES = %w[pending approved rejected responded expired].freeze
|
|
23
|
+
|
|
24
|
+
# An interaction on the wire. Immutable.
|
|
25
|
+
#
|
|
26
|
+
# { "id" => "act_123", "kind" => "approval",
|
|
27
|
+
# "status" => "pending", "payload" => { "toolName" => "bash", ... } }
|
|
28
|
+
Interaction = Data.define(:id, :kind, :status, :payload) do
|
|
29
|
+
def initialize(id:, kind:, status: "pending", payload: {})
|
|
30
|
+
unless id.is_a?(String) && !id.empty?
|
|
31
|
+
raise ArgumentError, "interaction id must be a non-empty String"
|
|
32
|
+
end
|
|
33
|
+
unless KINDS.include?(kind)
|
|
34
|
+
raise ArgumentError, "unknown interaction kind: #{kind.inspect} (expected one of #{KINDS.inspect})"
|
|
35
|
+
end
|
|
36
|
+
unless STATUSES.include?(status)
|
|
37
|
+
raise ArgumentError, "unknown interaction status: #{status.inspect} (expected one of #{STATUSES.inspect})"
|
|
38
|
+
end
|
|
39
|
+
raise ArgumentError, "interaction payload must be a Hash" unless payload.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
super
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pending?
|
|
45
|
+
status == "pending"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolved?
|
|
49
|
+
status != "pending"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
{ "id" => id, "kind" => kind, "status" => status, "payload" => payload }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── Kind payload shapes ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# Payload shape for kind "approval" (from the approval.required event).
|
|
60
|
+
APPROVAL_PAYLOAD = {
|
|
61
|
+
"toolName" => { type: :string, required: true },
|
|
62
|
+
"args" => { type: :any, required: false },
|
|
63
|
+
"message" => { type: :string, required: false },
|
|
64
|
+
"autoApprovable" => { type: :boolean, required: false }
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
# Payload shape for kind "plan" (from the plan.proposed event).
|
|
68
|
+
PLAN_PAYLOAD = {
|
|
69
|
+
"plan" => { type: :string, required: true }
|
|
70
|
+
}.freeze
|
|
71
|
+
|
|
72
|
+
# Payload shape for kind "user_input" (host → client request asking
|
|
73
|
+
# the human for input; resolve via interaction/respond).
|
|
74
|
+
USER_INPUT_PAYLOAD = {
|
|
75
|
+
"prompt" => { type: :string, required: true, description: "What the agent is asking the human." },
|
|
76
|
+
"options" => { type: :array, required: false, description: "Suggested answers, when the host offers any." }
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# Payload spec per kind. The single source of truth for interaction
|
|
80
|
+
# payload validation and schema generation.
|
|
81
|
+
PAYLOADS = {
|
|
82
|
+
"approval" => APPROVAL_PAYLOAD,
|
|
83
|
+
"plan" => PLAN_PAYLOAD,
|
|
84
|
+
"user_input" => USER_INPUT_PAYLOAD
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
# Build an interaction, validating the payload against its kind.
|
|
88
|
+
#
|
|
89
|
+
# @param id [String] unique interaction id
|
|
90
|
+
# @param kind [String] one of KINDS
|
|
91
|
+
# @param status [String] one of STATUSES
|
|
92
|
+
# @param payload [Hash] kind-specific body
|
|
93
|
+
# @return [Interaction]
|
|
94
|
+
def self.interaction(id:, kind:, status: "pending", payload: {})
|
|
95
|
+
validate_payload!(kind, payload)
|
|
96
|
+
Interaction.new(id: id, kind: kind, status: status, payload: payload)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Rebuild an interaction from its wire shape.
|
|
100
|
+
#
|
|
101
|
+
# @param hash [Hash] { "id" =>, "kind" =>, "status" =>, "payload" => }
|
|
102
|
+
# @return [Interaction]
|
|
103
|
+
def self.from_h(hash)
|
|
104
|
+
hash = hash.transform_keys(&:to_s)
|
|
105
|
+
raise ArgumentError, "interaction must be a Hash" unless hash.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
interaction(
|
|
108
|
+
id: hash["id"],
|
|
109
|
+
kind: hash["kind"],
|
|
110
|
+
status: hash["status"] || "pending",
|
|
111
|
+
payload: hash["payload"] || {}
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Validate a payload against its kind spec. Unknown extra fields are
|
|
116
|
+
# allowed (forward compatibility).
|
|
117
|
+
#
|
|
118
|
+
# @param kind [String] one of KINDS
|
|
119
|
+
# @param payload [Hash]
|
|
120
|
+
# @return [true] when valid
|
|
121
|
+
def self.validate_payload!(kind, payload)
|
|
122
|
+
spec = PAYLOADS[kind]
|
|
123
|
+
raise ArgumentError, "unknown interaction kind: #{kind.inspect}" unless spec
|
|
124
|
+
raise ArgumentError, "payload for #{kind} must be a Hash" unless payload.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
spec.each do |field, field_spec|
|
|
127
|
+
value = payload[field]
|
|
128
|
+
if field_spec[:required] && value.nil?
|
|
129
|
+
raise ArgumentError, "interaction #{kind} missing required payload field #{field.inspect}"
|
|
130
|
+
end
|
|
131
|
+
next if value.nil?
|
|
132
|
+
|
|
133
|
+
Events.validate_field!(kind, field, field_spec, value)
|
|
134
|
+
end
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module SessionProtocol
|
|
5
|
+
# The RPC surface of the session protocol.
|
|
6
|
+
#
|
|
7
|
+
# Direction conventions (JSON-RPC 2.0 over the host's transport):
|
|
8
|
+
#
|
|
9
|
+
# METHODS — client → host requests (host responds with a result)
|
|
10
|
+
# NOTIFICATIONS — host → client notifications (no response expected):
|
|
11
|
+
# session/event carries the canonical event envelope
|
|
12
|
+
# REQUESTS — host → client reverse requests (client must respond):
|
|
13
|
+
# interaction/requestPermission and
|
|
14
|
+
# interaction/requestUserInput, defined for interop
|
|
15
|
+
# with the external app-server protocol standard.
|
|
16
|
+
# The canonical resolution path is the interaction/*
|
|
17
|
+
# and plan/* METHODS against event-visible interactions.
|
|
18
|
+
#
|
|
19
|
+
# The registry below is the single source of truth for the method
|
|
20
|
+
# surface; the JSON Schema artifact is generated from it, and hosts
|
|
21
|
+
# and clients validate their implementations against it.
|
|
22
|
+
module Methods
|
|
23
|
+
# Host capabilities advertised in the `initialize` result. A client
|
|
24
|
+
# may gate UI features on them.
|
|
25
|
+
CAPABILITIES = %w[
|
|
26
|
+
sessionManagement eventStreaming midExecutionInjection
|
|
27
|
+
interactions planMode todos artifacts workspace fileEvents
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Error codes, JSON-RPC 2.0 reserved range plus session/interaction
|
|
31
|
+
# application codes (matching the app-server protocol convention).
|
|
32
|
+
ERROR_CODES = {
|
|
33
|
+
parse_error: -32700,
|
|
34
|
+
invalid_request: -32600,
|
|
35
|
+
method_not_found: -32601,
|
|
36
|
+
invalid_params: -32602,
|
|
37
|
+
internal_error: -32603,
|
|
38
|
+
session_not_found: -32004,
|
|
39
|
+
session_already_exists: -32005,
|
|
40
|
+
interaction_not_found: -32006,
|
|
41
|
+
plan_not_found: -32007,
|
|
42
|
+
workspace_error: -32008,
|
|
43
|
+
not_implemented: -32009
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Client → host methods with their params and result shapes. Each
|
|
47
|
+
# shape uses the same field-spec language as Events::TYPES.
|
|
48
|
+
METHODS = {
|
|
49
|
+
# ── Handshake ───────────────────────────────────────────────────
|
|
50
|
+
"ping" => {
|
|
51
|
+
description: "Liveness check.",
|
|
52
|
+
params: {},
|
|
53
|
+
result: {
|
|
54
|
+
"status" => { type: :string, required: true },
|
|
55
|
+
"version" => { type: :string, required: true, description: "Gem/host version." },
|
|
56
|
+
"protocolVersion" => { type: :string, required: true, description: "Negotiated wire version." },
|
|
57
|
+
"uptime" => { type: :integer, required: false, description: "Host uptime in seconds." },
|
|
58
|
+
"sessions" => { type: :integer, required: false, description: "Live session count." }
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"initialize" => {
|
|
62
|
+
description: "Handshake: negotiate protocol version and exchange capabilities.",
|
|
63
|
+
params: {
|
|
64
|
+
"client" => { type: :object, required: false, description: "{name, version} of the client." },
|
|
65
|
+
"capabilities" => { type: :array, required: false, description: "Client capabilities (informational)." }
|
|
66
|
+
},
|
|
67
|
+
result: {
|
|
68
|
+
"protocolVersion" => { type: :string, required: true },
|
|
69
|
+
"capabilities" => { type: :array, required: true, description: "Host capabilities (see CAPABILITIES)." },
|
|
70
|
+
"server" => { type: :object, required: true, description: "{name, version} of the host." }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
# ── Session lifecycle ────────────────────────────────────────────
|
|
75
|
+
"session/create" => {
|
|
76
|
+
description: "Create a session in a workspace.",
|
|
77
|
+
params: {
|
|
78
|
+
"workspace" => { type: :object, required: false, description: "{workspacePath} of the project." },
|
|
79
|
+
"mode" => { type: :string, required: false, description: "Permission mode, e.g. on_request, full_access." },
|
|
80
|
+
"model" => { type: :string, required: false, description: "Model identifier." },
|
|
81
|
+
"tools" => { type: :array, required: false, description: "Tool names to enable." },
|
|
82
|
+
"systemPrompt" => { type: :string, required: false, description: "System prompt override." }
|
|
83
|
+
},
|
|
84
|
+
result: {
|
|
85
|
+
"session" => { type: :object, required: true, description: "{sessionId, model, createdAt}." }
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"session/list" => {
|
|
89
|
+
description: "List sessions, most recent first.",
|
|
90
|
+
params: {
|
|
91
|
+
"limit" => { type: :integer, required: false, description: "Max sessions (default 20)." }
|
|
92
|
+
},
|
|
93
|
+
result: {
|
|
94
|
+
"sessions" => { type: :array, required: true, description: "Session summaries." }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"session/resume" => {
|
|
98
|
+
description: "Resume/attach to an existing session.",
|
|
99
|
+
params: {
|
|
100
|
+
"sessionId" => { type: :string, required: true }
|
|
101
|
+
},
|
|
102
|
+
result: {
|
|
103
|
+
"sessionId" => { type: :string, required: true },
|
|
104
|
+
"running" => { type: :boolean, required: true, description: "A turn is in progress." },
|
|
105
|
+
"idle" => { type: :boolean, required: true, description: "The session is ready for a prompt." },
|
|
106
|
+
"createdAt" => { type: :string, required: true, description: "ISO 8601 creation time." }
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"session/subscribe" => {
|
|
110
|
+
description: "Subscribe to a session's event stream with replay support.",
|
|
111
|
+
params: {
|
|
112
|
+
"sessionId" => { type: :string, required: true },
|
|
113
|
+
"deliveryKind" => { type: :string, required: false, enum: DELIVERY_KINDS, description: "Default: replay." },
|
|
114
|
+
"afterSeq" => { type: :integer, required: false, description: "Replay events after this seq." },
|
|
115
|
+
"includeSnapshot" => { type: :boolean, required: false, description: "Include a state snapshot in the result." }
|
|
116
|
+
},
|
|
117
|
+
result: {
|
|
118
|
+
"subscription" => { type: :object, required: true, description: "{sessionId, deliveryKind}." },
|
|
119
|
+
"snapshot" => { type: :array, required: false, description: "Events after afterSeq, when includeSnapshot." }
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
"session/events" => {
|
|
123
|
+
description: "Poll events after a seq (for clients without a subscription).",
|
|
124
|
+
params: {
|
|
125
|
+
"sessionId" => { type: :string, required: true },
|
|
126
|
+
"afterSeq" => { type: :integer, required: false, description: "Default 0." },
|
|
127
|
+
"limit" => { type: :integer, required: false, description: "Max events to return." }
|
|
128
|
+
},
|
|
129
|
+
result: {
|
|
130
|
+
"events" => { type: :array, required: true, description: "Canonical event envelopes." }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"session/send" => {
|
|
134
|
+
description: "Inject a message mid-run (barge-in) or prompt an idle session.",
|
|
135
|
+
params: {
|
|
136
|
+
"sessionId" => { type: :string, required: true },
|
|
137
|
+
"content" => { type: :string, required: true, description: "The message text." },
|
|
138
|
+
"expectedTurnId" => { type: :string, required: false, description: "Staleness guard: only steer this turn." }
|
|
139
|
+
},
|
|
140
|
+
result: {
|
|
141
|
+
"accepted" => { type: :boolean, required: true },
|
|
142
|
+
"status" => { type: :string, required: false, enum: %w[queued steered stale], description: "steered: injected now; queued: next turn; stale: turn mismatch." },
|
|
143
|
+
"turnId" => { type: :string, required: false, description: "The turn the message applies to." }
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
"session/abort" => {
|
|
147
|
+
description: "Abort the running turn.",
|
|
148
|
+
params: {
|
|
149
|
+
"sessionId" => { type: :string, required: true }
|
|
150
|
+
},
|
|
151
|
+
result: {
|
|
152
|
+
"aborted" => { type: :boolean, required: true },
|
|
153
|
+
"sessionId" => { type: :string, required: true }
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
"session/close" => {
|
|
157
|
+
description: "Close the session and release its resources.",
|
|
158
|
+
params: {
|
|
159
|
+
"sessionId" => { type: :string, required: true }
|
|
160
|
+
},
|
|
161
|
+
result: {
|
|
162
|
+
"closed" => { type: :boolean, required: true },
|
|
163
|
+
"sessionId" => { type: :string, required: true }
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
# ── Artifacts ────────────────────────────────────────────────────
|
|
168
|
+
"session/artifacts" => {
|
|
169
|
+
description: "List the session's tool artifacts.",
|
|
170
|
+
params: {
|
|
171
|
+
"sessionId" => { type: :string, required: true }
|
|
172
|
+
},
|
|
173
|
+
result: {
|
|
174
|
+
"artifacts" => { type: :array, required: true, description: "Artifact summaries." }
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
"session/artifact/get" => {
|
|
178
|
+
description: "Fetch one artifact's content.",
|
|
179
|
+
params: {
|
|
180
|
+
"sessionId" => { type: :string, required: true },
|
|
181
|
+
"artifactId" => { type: :string, required: true }
|
|
182
|
+
},
|
|
183
|
+
result: {
|
|
184
|
+
"artifact" => { type: :object, required: true, description: "Artifact record with content or uri." }
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
# ── Interactions (approval, user input) ──────────────────────────
|
|
189
|
+
"interaction/list" => {
|
|
190
|
+
description: "List pending interactions for a session.",
|
|
191
|
+
params: {
|
|
192
|
+
"sessionId" => { type: :string, required: true }
|
|
193
|
+
},
|
|
194
|
+
result: {
|
|
195
|
+
"interactions" => { type: :array, required: true, description: "Pending interaction records." }
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"interaction/approve" => {
|
|
199
|
+
description: "Approve a pending interaction by id (approval.required).",
|
|
200
|
+
params: {
|
|
201
|
+
"sessionId" => { type: :string, required: true },
|
|
202
|
+
"interactionId" => { type: :string, required: true }
|
|
203
|
+
},
|
|
204
|
+
result: {
|
|
205
|
+
"approved" => { type: :boolean, required: true },
|
|
206
|
+
"interactionId" => { type: :string, required: true }
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"interaction/reject" => {
|
|
210
|
+
description: "Reject a pending interaction by id (approval.required).",
|
|
211
|
+
params: {
|
|
212
|
+
"sessionId" => { type: :string, required: true },
|
|
213
|
+
"interactionId" => { type: :string, required: true }
|
|
214
|
+
},
|
|
215
|
+
result: {
|
|
216
|
+
"rejected" => { type: :boolean, required: true },
|
|
217
|
+
"interactionId" => { type: :string, required: true }
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
"interaction/approve-all" => {
|
|
221
|
+
description: "Approve every pending approval interaction.",
|
|
222
|
+
params: {
|
|
223
|
+
"sessionId" => { type: :string, required: true }
|
|
224
|
+
},
|
|
225
|
+
result: {
|
|
226
|
+
"approved" => { type: :integer, required: true, description: "Number approved." }
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
"interaction/reject-all" => {
|
|
230
|
+
description: "Reject every pending approval interaction.",
|
|
231
|
+
params: {
|
|
232
|
+
"sessionId" => { type: :string, required: true }
|
|
233
|
+
},
|
|
234
|
+
result: {
|
|
235
|
+
"rejected" => { type: :integer, required: true, description: "Number rejected." }
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
"interaction/respond" => {
|
|
239
|
+
description: "Answer a user_input interaction by id (elicitation).",
|
|
240
|
+
params: {
|
|
241
|
+
"sessionId" => { type: :string, required: true },
|
|
242
|
+
"interactionId" => { type: :string, required: true },
|
|
243
|
+
"response" => { type: :string, required: true }
|
|
244
|
+
},
|
|
245
|
+
result: {
|
|
246
|
+
"responded" => { type: :boolean, required: true },
|
|
247
|
+
"interactionId" => { type: :string, required: true }
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
# ── Plan mode ────────────────────────────────────────────────────
|
|
252
|
+
"plan/approve" => {
|
|
253
|
+
description: "Approve the pending plan proposal (plan.proposed).",
|
|
254
|
+
params: {
|
|
255
|
+
"sessionId" => { type: :string, required: true }
|
|
256
|
+
},
|
|
257
|
+
result: {
|
|
258
|
+
"approved" => { type: :boolean, required: true }
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
"plan/reject" => {
|
|
262
|
+
description: "Reject the pending plan proposal; the agent stays in plan mode.",
|
|
263
|
+
params: {
|
|
264
|
+
"sessionId" => { type: :string, required: true }
|
|
265
|
+
},
|
|
266
|
+
result: {
|
|
267
|
+
"rejected" => { type: :boolean, required: true }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
# ── Workspace ────────────────────────────────────────────────────
|
|
272
|
+
"workspace/readState" => {
|
|
273
|
+
description: "Read the host's workspace state (path, git, mode).",
|
|
274
|
+
params: {},
|
|
275
|
+
result: {
|
|
276
|
+
"workspace" => { type: :object, required: true, description: "{path, name, gitBranch, mode}." }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}.freeze
|
|
280
|
+
|
|
281
|
+
# Host → client notifications (fire-and-forget).
|
|
282
|
+
NOTIFICATIONS = {
|
|
283
|
+
"session/event" => {
|
|
284
|
+
description: "A canonical session event envelope: {type, seq, payload}.",
|
|
285
|
+
params: {
|
|
286
|
+
"event" => { type: :object, required: true, description: "The canonical event envelope." }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}.freeze
|
|
290
|
+
|
|
291
|
+
# Host → client reverse requests (client must respond) — defined for
|
|
292
|
+
# interop with the external app-server protocol standard. The
|
|
293
|
+
# canonical resolution path is the interaction/* methods.
|
|
294
|
+
REQUESTS = {
|
|
295
|
+
"interaction/requestPermission" => {
|
|
296
|
+
description: "Ask the client to resolve a tool approval (app-server interop).",
|
|
297
|
+
params: {
|
|
298
|
+
"requestId" => { type: :string, required: true },
|
|
299
|
+
"toolName" => { type: :string, required: true },
|
|
300
|
+
"input" => { type: :any, required: false },
|
|
301
|
+
"riskLevel" => { type: :string, required: false, enum: %w[low medium high critical] },
|
|
302
|
+
"reason" => { type: :string, required: false }
|
|
303
|
+
},
|
|
304
|
+
result: {
|
|
305
|
+
"decision" => { type: :string, required: true, enum: %w[allow deny] }
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"interaction/requestUserInput" => {
|
|
309
|
+
description: "Ask the client for free-form user input (app-server interop).",
|
|
310
|
+
params: {
|
|
311
|
+
"requestId" => { type: :string, required: true },
|
|
312
|
+
"prompt" => { type: :string, required: true },
|
|
313
|
+
"options" => { type: :array, required: false }
|
|
314
|
+
},
|
|
315
|
+
result: {
|
|
316
|
+
"response" => { type: :string, required: true }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}.freeze
|
|
320
|
+
|
|
321
|
+
class << self
|
|
322
|
+
# The client → host method names, in registry order.
|
|
323
|
+
def method_names
|
|
324
|
+
METHODS.keys
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Whether `name` is a canonical client → host method.
|
|
328
|
+
def known?(name)
|
|
329
|
+
METHODS.key?(name)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Whether `name` is a host → client notification.
|
|
333
|
+
def notification?(name)
|
|
334
|
+
NOTIFICATIONS.key?(name)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Whether `name` is a host → client reverse request.
|
|
338
|
+
def request?(name)
|
|
339
|
+
REQUESTS.key?(name)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# All method-like names across the three surfaces.
|
|
343
|
+
def all_names
|
|
344
|
+
METHODS.keys + NOTIFICATIONS.keys + REQUESTS.keys
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Validate a params hash against a method's params spec.
|
|
348
|
+
# Raises ArgumentError on unknown methods, missing required fields,
|
|
349
|
+
# or type/enum mismatches. Unknown extra fields are allowed.
|
|
350
|
+
#
|
|
351
|
+
# @param name [String] client → host method name
|
|
352
|
+
# @param params [Hash]
|
|
353
|
+
# @return [true] when valid
|
|
354
|
+
def validate_params!(name, params)
|
|
355
|
+
spec = METHODS[name]
|
|
356
|
+
raise ArgumentError, "unknown session protocol method: #{name.inspect}" unless spec
|
|
357
|
+
raise ArgumentError, "params for #{name} must be a Hash" unless params.is_a?(Hash)
|
|
358
|
+
|
|
359
|
+
validate_shape!(name, spec[:params], params)
|
|
360
|
+
true
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Validate a result hash against a method's result spec.
|
|
364
|
+
#
|
|
365
|
+
# @param name [String] client → host method name
|
|
366
|
+
# @param result [Hash]
|
|
367
|
+
# @return [true] when valid
|
|
368
|
+
def validate_result!(name, result)
|
|
369
|
+
spec = METHODS[name]
|
|
370
|
+
raise ArgumentError, "unknown session protocol method: #{name.inspect}" unless spec
|
|
371
|
+
raise ArgumentError, "result for #{name} must be a Hash" unless result.is_a?(Hash)
|
|
372
|
+
|
|
373
|
+
validate_shape!(name, spec[:result], result)
|
|
374
|
+
true
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# @api private
|
|
378
|
+
def validate_shape!(name, shape, value)
|
|
379
|
+
shape.each do |field, field_spec|
|
|
380
|
+
field_value = value[field]
|
|
381
|
+
if field_spec[:required] && field_value.nil?
|
|
382
|
+
raise ArgumentError, "method #{name} missing required field #{field.inspect}"
|
|
383
|
+
end
|
|
384
|
+
next if field_value.nil?
|
|
385
|
+
|
|
386
|
+
Events.validate_field!(name, field, field_spec, field_value)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
private :validate_shape!
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module SessionProtocol
|
|
5
|
+
# Generates a JSON Schema (draft 2020-12) document from the contract
|
|
6
|
+
# registries (Events::TYPES, Interactions::PAYLOADS, Methods::METHODS).
|
|
7
|
+
#
|
|
8
|
+
# The output is a static artifact (docs/ask-session-protocol.schema.json,
|
|
9
|
+
# regenerated by `rake schema`) so non-Ruby clients — bots, IDEs —
|
|
10
|
+
# can validate against the contract without loading this gem.
|
|
11
|
+
module Schema
|
|
12
|
+
# @return [Hash] the JSON Schema document
|
|
13
|
+
def self.build
|
|
14
|
+
{
|
|
15
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
16
|
+
"title" => "Ask Session Protocol",
|
|
17
|
+
"description" => "Canonical wire contract for ask agent sessions: " \
|
|
18
|
+
"event vocabulary, interactions, and the RPC method surface. " \
|
|
19
|
+
"See the ask-session-protocol gem for the authoritative registry.",
|
|
20
|
+
"protocolVersion" => Ask::SessionProtocol::PROTOCOL_VERSION,
|
|
21
|
+
"$defs" => {
|
|
22
|
+
"event" => build_event_schema,
|
|
23
|
+
"interaction" => build_interaction_schema,
|
|
24
|
+
"method" => build_method_schema
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Convert a registry field spec to a JSON Schema fragment.
|
|
30
|
+
#
|
|
31
|
+
# @param field_spec [Hash] { type:, required:, enum:, description: }
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def self.field_schema(field_spec)
|
|
34
|
+
schema = {}
|
|
35
|
+
schema["description"] = field_spec[:description] if field_spec[:description]
|
|
36
|
+
schema["enum"] = field_spec[:enum] if field_spec[:enum]
|
|
37
|
+
|
|
38
|
+
case field_spec[:type]
|
|
39
|
+
when :any then schema
|
|
40
|
+
when :number then schema.merge("type" => "number")
|
|
41
|
+
when :object then schema.merge("type" => "object")
|
|
42
|
+
when :array then schema.merge("type" => "array")
|
|
43
|
+
else schema.merge("type" => field_spec[:type].to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Event envelope schema: one schema per canonical event type.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash] "$defs/event"
|
|
50
|
+
def self.build_event_schema
|
|
51
|
+
Events::TYPES.each_with_object({}) do |(type, spec), out|
|
|
52
|
+
out[type] = {
|
|
53
|
+
"type" => "object",
|
|
54
|
+
"description" => spec[:description],
|
|
55
|
+
"properties" => {
|
|
56
|
+
"type" => { "const" => type },
|
|
57
|
+
"seq" => { "type" => "integer", "minimum" => 1 },
|
|
58
|
+
"payload" => build_payload_schema(spec[:payload])
|
|
59
|
+
},
|
|
60
|
+
"required" => %w[type seq payload],
|
|
61
|
+
"additionalProperties" => false
|
|
62
|
+
}
|
|
63
|
+
out[type]["x-interaction"] = true if spec[:interaction]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Interaction schema per kind.
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] "$defs/interaction"
|
|
70
|
+
def self.build_interaction_schema
|
|
71
|
+
Interactions::PAYLOADS.each_with_object({}) do |(kind, payload), out|
|
|
72
|
+
out[kind] = {
|
|
73
|
+
"type" => "object",
|
|
74
|
+
"properties" => {
|
|
75
|
+
"id" => { "type" => "string" },
|
|
76
|
+
"kind" => { "const" => kind },
|
|
77
|
+
"status" => { "type" => "string", "enum" => Interactions::STATUSES },
|
|
78
|
+
"payload" => build_payload_schema(payload)
|
|
79
|
+
},
|
|
80
|
+
"required" => %w[id kind status payload],
|
|
81
|
+
"additionalProperties" => false
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Method schema per client → host method: params and result.
|
|
87
|
+
#
|
|
88
|
+
# @return [Hash] "$defs/method"
|
|
89
|
+
def self.build_method_schema
|
|
90
|
+
Methods::METHODS.each_with_object({}) do |(name, spec), out|
|
|
91
|
+
out[name] = {
|
|
92
|
+
"type" => "object",
|
|
93
|
+
"description" => spec[:description],
|
|
94
|
+
"properties" => {
|
|
95
|
+
"method" => { "const" => name },
|
|
96
|
+
"params" => build_payload_schema(spec[:params])
|
|
97
|
+
},
|
|
98
|
+
"required" => %w[method params],
|
|
99
|
+
"additionalProperties" => false
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
def self.build_payload_schema(fields)
|
|
106
|
+
properties = fields.each_with_object({}) do |(field, field_spec), out|
|
|
107
|
+
out[field] = field_schema(field_spec)
|
|
108
|
+
end
|
|
109
|
+
required = fields.select { |_field, spec| spec[:required] }.keys
|
|
110
|
+
schema = { "type" => "object", "properties" => properties }
|
|
111
|
+
schema["required"] = required unless required.empty?
|
|
112
|
+
schema
|
|
113
|
+
end
|
|
114
|
+
private_class_method :build_payload_schema
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|