ag-ui 0.1.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/LICENSE +21 -0
- data/data/ag_ui.json +3890 -0
- data/data/generate-ag-ui-schema.py +50 -0
- data/lib/ag_ui/a2ui/catalog.rb +106 -0
- data/lib/ag_ui/a2ui/recovery.rb +216 -0
- data/lib/ag_ui/a2ui/validate.rb +616 -0
- data/lib/ag_ui/event_bridge.rb +174 -0
- data/lib/ag_ui/messages.rb +159 -0
- data/lib/ag_ui/middleware/a2ui.rb +442 -0
- data/lib/ag_ui/middleware/forwarded_props.rb +38 -0
- data/lib/ag_ui/middleware/system_prompt.rb +90 -0
- data/lib/ag_ui/middleware/tool_router.rb +251 -0
- data/lib/ag_ui/protocol/json_schema/definition.rb +233 -0
- data/lib/ag_ui/protocol/json_schema/validation_error.rb +122 -0
- data/lib/ag_ui/protocol/json_schema.rb +270 -0
- data/lib/ag_ui/run_input.rb +149 -0
- data/lib/ag_ui/run_loop.rb +285 -0
- data/lib/ag_ui/run_store.rb +180 -0
- data/lib/ag_ui/server/info.rb +85 -0
- data/lib/ag_ui/server/middleware/sse_stream.rb +160 -0
- data/lib/ag_ui/server/sse/event_encoder.rb +69 -0
- data/lib/ag_ui/server/sse/stream.rb +235 -0
- data/lib/ag_ui/server/triage.rb +208 -0
- data/lib/ag_ui/server.rb +316 -0
- data/lib/ag_ui/terminals/ruby_llm.rb +558 -0
- data/lib/ag_ui/version.rb +5 -0
- data/lib/ag_ui.rb +32 -0
- metadata +242 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "console"
|
|
6
|
+
require "ag_ui"
|
|
7
|
+
|
|
8
|
+
module AgUi
|
|
9
|
+
module Middleware
|
|
10
|
+
# Brute-style turn middleware implementing both halves of the AG-UI
|
|
11
|
+
# tool model (doc 03).
|
|
12
|
+
#
|
|
13
|
+
# Way in: advertises the run's client tool definitions
|
|
14
|
+
# (RunAgentInput.tools) plus the app's SERVER tool definitions on
|
|
15
|
+
# env[:tools] (idempotent — the loop re-enters this middleware every
|
|
16
|
+
# iteration).
|
|
17
|
+
#
|
|
18
|
+
# Way out, per tool call the model made:
|
|
19
|
+
# - every call emits TOOL_CALL_START / ARGS / END
|
|
20
|
+
# - a SERVER tool ({name:, description:, parameters:, handler:})
|
|
21
|
+
# executes inline: its result is appended as a :tool message
|
|
22
|
+
# (Loop::ToolResult re-invokes the stack so the model continues
|
|
23
|
+
# the same run) and emitted as TOOL_CALL_RESULT — the Node
|
|
24
|
+
# runtime's exact shape
|
|
25
|
+
# - a CLIENT tool sets env[:should_exit]: the run ends with plain
|
|
26
|
+
# RUN_FINISHED and the browser executes it (multi-run model)
|
|
27
|
+
# - mixed turns: server tools still execute, but any client call
|
|
28
|
+
# ends the run
|
|
29
|
+
class ToolRouter
|
|
30
|
+
def initialize(app, tools: nil, server_tools: nil)
|
|
31
|
+
@app = app
|
|
32
|
+
@client_tools = tools || []
|
|
33
|
+
@server_tools = (server_tools || []).to_h { |t| [t[:name].to_s, t] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
advertise(env)
|
|
38
|
+
|
|
39
|
+
@app.call(env)
|
|
40
|
+
|
|
41
|
+
last = env[:messages].last
|
|
42
|
+
if last.respond_to?(:tool_call?) && last.tool_call?
|
|
43
|
+
route(env, last.tool_calls)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
env
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def advertise(env)
|
|
52
|
+
env[:tools] ||= []
|
|
53
|
+
known = env[:tools].map { |d| definition_name(d) }
|
|
54
|
+
|
|
55
|
+
definitions = @client_tools + @server_tools.values.map { |t| server_definition(t) }
|
|
56
|
+
definitions.each do |definition|
|
|
57
|
+
name = definition_name(definition)
|
|
58
|
+
unless known.include?(name)
|
|
59
|
+
env[:tools] << definition
|
|
60
|
+
known << name
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def definition_name(definition)
|
|
66
|
+
if definition.respond_to?(:to_h)
|
|
67
|
+
definition.to_h["name"]
|
|
68
|
+
else
|
|
69
|
+
definition["name"]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def server_definition(tool)
|
|
74
|
+
{
|
|
75
|
+
"name" => tool[:name].to_s,
|
|
76
|
+
"description" => tool[:description].to_s,
|
|
77
|
+
"parameters" => tool[:parameters] || { "type" => "object" },
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def route(env, tool_calls)
|
|
82
|
+
client_called = false
|
|
83
|
+
|
|
84
|
+
tool_calls.each do |tool_call|
|
|
85
|
+
emit_call(env[:events], tool_call)
|
|
86
|
+
|
|
87
|
+
server = @server_tools[tool_call.name]
|
|
88
|
+
if server
|
|
89
|
+
execute_server_tool(env, tool_call, server)
|
|
90
|
+
else
|
|
91
|
+
client_called = true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if client_called
|
|
96
|
+
env[:should_exit] = true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def emit_call(events, tool_call)
|
|
101
|
+
events << {
|
|
102
|
+
type: :tool_call_start,
|
|
103
|
+
data: { tool_call_id: tool_call.id, tool_call_name: tool_call.name },
|
|
104
|
+
}
|
|
105
|
+
events << {
|
|
106
|
+
type: :tool_call_args,
|
|
107
|
+
data: { tool_call_id: tool_call.id, delta: JSON.generate(tool_call.arguments) },
|
|
108
|
+
}
|
|
109
|
+
events << {
|
|
110
|
+
type: :tool_call_end,
|
|
111
|
+
data: { tool_call_id: tool_call.id },
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def execute_server_tool(env, tool_call, tool)
|
|
116
|
+
begin
|
|
117
|
+
result = tool[:handler].call(tool_call.arguments)
|
|
118
|
+
rescue => e
|
|
119
|
+
Console.error(self, "server tool #{tool_call.name} raised: #{e.message}", e)
|
|
120
|
+
result = { "error" => e.message }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
content = result.is_a?(String) ? result : JSON.generate(result)
|
|
124
|
+
env[:messages].tool(content, tool_call_id: tool_call.id)
|
|
125
|
+
env[:events] << {
|
|
126
|
+
type: :tool_call_result,
|
|
127
|
+
data: {
|
|
128
|
+
message_id: SecureRandom.uuid,
|
|
129
|
+
tool_call_id: tool_call.id,
|
|
130
|
+
content: content,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
__END__
|
|
139
|
+
|
|
140
|
+
describe "AgUi::Middleware::ToolRouter" do
|
|
141
|
+
it "advertises client and server tools idempotently across iterations" do
|
|
142
|
+
seen = nil
|
|
143
|
+
server_tool = { name: "get_time", description: "Now", handler: -> (_args) { "12:00" } }
|
|
144
|
+
mw = AgUi::Middleware::ToolRouter.new(
|
|
145
|
+
->(env) { seen = env[:tools] },
|
|
146
|
+
tools: [{ "name" => "navigate" }],
|
|
147
|
+
server_tools: [server_tool],
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
env = { messages: Brute.log, events: [] }
|
|
151
|
+
mw.call(env)
|
|
152
|
+
mw.call(env) # second loop iteration
|
|
153
|
+
|
|
154
|
+
seen.map { |t| t["name"] }.should == %w[navigate get_time]
|
|
155
|
+
seen.last["parameters"].should == { "type" => "object" }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "executes server tools inline: tool message appended, result emitted, no exit" do
|
|
159
|
+
server_tool = {
|
|
160
|
+
name: "lookup",
|
|
161
|
+
description: "Look something up",
|
|
162
|
+
parameters: { "type" => "object" },
|
|
163
|
+
handler: ->(args) { { "found" => args["id"] } },
|
|
164
|
+
}
|
|
165
|
+
terminal = ->(env) do
|
|
166
|
+
env[:messages] << Brute::Message.new(
|
|
167
|
+
role: :assistant, content: nil,
|
|
168
|
+
tool_calls: [{ id: "tc1", name: "lookup", arguments: { "id" => 42 } }],
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
env = { messages: Brute.log, events: [] }
|
|
173
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
|
|
174
|
+
|
|
175
|
+
env[:messages].last.role.should == :tool
|
|
176
|
+
env[:messages].last.tool_call_id.should == "tc1"
|
|
177
|
+
env[:messages].last.content.should == "{\"found\":42}"
|
|
178
|
+
|
|
179
|
+
env[:events].map { |e| e[:type] }.should == %i[
|
|
180
|
+
tool_call_start tool_call_args tool_call_end tool_call_result
|
|
181
|
+
]
|
|
182
|
+
env[:events].last[:data][:content].should == "{\"found\":42}"
|
|
183
|
+
env.key?(:should_exit).should == false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "captures handler errors as the tool result" do
|
|
187
|
+
server_tool = { name: "boom", description: "x", handler: ->(_a) { raise "kaput" } }
|
|
188
|
+
terminal = ->(env) do
|
|
189
|
+
env[:messages] << Brute::Message.new(
|
|
190
|
+
role: :assistant, content: nil,
|
|
191
|
+
tool_calls: [{ id: "tc1", name: "boom", arguments: {} }],
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
env = { messages: Brute.log, events: [] }
|
|
196
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
|
|
197
|
+
|
|
198
|
+
env[:messages].last.content.should == "{\"error\":\"kaput\"}"
|
|
199
|
+
env.key?(:should_exit).should == false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it "mixed turns execute server tools but still end the run for client calls" do
|
|
203
|
+
server_tool = { name: "lookup", description: "x", handler: ->(_a) { "ok" } }
|
|
204
|
+
terminal = ->(env) do
|
|
205
|
+
env[:messages] << Brute::Message.new(
|
|
206
|
+
role: :assistant, content: nil,
|
|
207
|
+
tool_calls: [
|
|
208
|
+
{ id: "tc1", name: "lookup", arguments: {} },
|
|
209
|
+
{ id: "tc2", name: "navigate", arguments: { "path" => "/x" } },
|
|
210
|
+
],
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
env = { messages: Brute.log, events: [] }
|
|
215
|
+
AgUi::Middleware::ToolRouter.new(terminal, server_tools: [server_tool]).call(env)
|
|
216
|
+
|
|
217
|
+
env[:should_exit].should == true
|
|
218
|
+
env[:events].count { |e| e[:type] == :tool_call_result }.should == 1
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "emits TOOL_CALL events and exits when the turn ends on client tool calls" do
|
|
222
|
+
terminal = ->(env) do
|
|
223
|
+
env[:messages] << Brute::Message.new(
|
|
224
|
+
role: :assistant, content: nil,
|
|
225
|
+
tool_calls: [
|
|
226
|
+
{ id: "tc1", name: "navigate", arguments: { "path" => "/data" } },
|
|
227
|
+
{ id: "tc2", name: "queryDataModel", arguments: { "model" => "contacts" } },
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
env = { messages: Brute.log, events: [] }
|
|
233
|
+
AgUi::Middleware::ToolRouter.new(terminal).call(env)
|
|
234
|
+
|
|
235
|
+
env[:events].map { |e| e[:type] }.should == %i[
|
|
236
|
+
tool_call_start tool_call_args tool_call_end
|
|
237
|
+
tool_call_start tool_call_args tool_call_end
|
|
238
|
+
]
|
|
239
|
+
env[:events][0][:data].should == { tool_call_id: "tc1", tool_call_name: "navigate" }
|
|
240
|
+
env[:events][1][:data][:delta].should == "{\"path\":\"/data\"}"
|
|
241
|
+
env[:should_exit].should == true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "does nothing on the way out for plain text turns" do
|
|
245
|
+
env = { messages: Brute.log, events: [] }
|
|
246
|
+
AgUi::Middleware::ToolRouter.new(->(e) { e[:messages].assistant("hi") }).call(env)
|
|
247
|
+
|
|
248
|
+
env[:events].should == []
|
|
249
|
+
env.key?(:should_exit).should == false
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
module Protocol
|
|
8
|
+
module JsonSchema
|
|
9
|
+
# Base class for schema-validated AG-UI protocol objects.
|
|
10
|
+
# Ported from a2a's Protocol::JsonSchema::Definition.
|
|
11
|
+
#
|
|
12
|
+
# Each AG-UI definition (RunAgentInput, TextMessageContentEvent, …)
|
|
13
|
+
# gets a dynamically-generated subclass of Definition with:
|
|
14
|
+
# - A JSONSchemer sub-schema attached (.schema)
|
|
15
|
+
# - Reader methods for each property (snake_case)
|
|
16
|
+
# - Validation via .valid? / .valid!
|
|
17
|
+
#
|
|
18
|
+
# NOTE: #to_h deep-compacts nil values (matching pydantic's
|
|
19
|
+
# exclude_none on the wire). Inbound payloads that carry explicit
|
|
20
|
+
# nulls (e.g. RunAgentInput.state) should be validated as raw
|
|
21
|
+
# hashes via JsonSchema.schemer, not round-tripped through here.
|
|
22
|
+
class Definition
|
|
23
|
+
def initialize(hash = {})
|
|
24
|
+
props = self.class.schema_properties
|
|
25
|
+
snake = self.class.snake_to_camel_map
|
|
26
|
+
refs = self.class.property_refs
|
|
27
|
+
@data = {}
|
|
28
|
+
|
|
29
|
+
hash.each do |key, value|
|
|
30
|
+
k = key.to_s
|
|
31
|
+
|
|
32
|
+
# Resolve snake_case input to camelCase storage key
|
|
33
|
+
camel = snake[k] || k
|
|
34
|
+
|
|
35
|
+
if props.include?(camel)
|
|
36
|
+
@data[camel] = if value.is_a?(Definition)
|
|
37
|
+
value.to_h
|
|
38
|
+
elsif (ref_info = refs[camel])
|
|
39
|
+
wrap_ref(value, ref_info)
|
|
40
|
+
else
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- class methods overridden by the factory -----------------------
|
|
48
|
+
|
|
49
|
+
def self.schema
|
|
50
|
+
raise "AgUi::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.definition_name
|
|
54
|
+
raise "AgUi::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.schema_properties
|
|
58
|
+
raise "AgUi::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.snake_to_camel_map
|
|
62
|
+
raise "AgUi::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.property_refs
|
|
66
|
+
raise "AgUi::Protocol::JsonSchema::Definition should NOT be instantiated directly"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reverse of snake_to_camel_map. Built first-entry-wins: the
|
|
70
|
+
# factory inserts the snake_case key before the camelCase
|
|
71
|
+
# identity key, so camelCase storage keys map back to their
|
|
72
|
+
# snake_case reader names.
|
|
73
|
+
def self.camel_to_snake_map
|
|
74
|
+
@camel_to_snake_map ||= snake_to_camel_map.each_with_object({}) do |(key, camel), map|
|
|
75
|
+
map[camel] ||= key
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- validation ----------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def valid?
|
|
82
|
+
self.class.schema.valid?(to_h)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def valid!
|
|
86
|
+
errors = self.class.schema.validate(to_h).to_a
|
|
87
|
+
|
|
88
|
+
if errors.empty?
|
|
89
|
+
true
|
|
90
|
+
else
|
|
91
|
+
raise ValidationError.new(
|
|
92
|
+
errors,
|
|
93
|
+
definition_name: self.class.definition_name,
|
|
94
|
+
data: to_h,
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- serialization -------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# Returns the data as a plain Hash with camelCase string keys,
|
|
102
|
+
# matching the JSON wire format. Nested Definition instances
|
|
103
|
+
# are auto-coerced via deep_compact.
|
|
104
|
+
def to_h
|
|
105
|
+
deep_compact(@data)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def ==(other)
|
|
109
|
+
other.is_a?(Definition) && to_h == other.to_h
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- pattern matching ----------------------------------------------
|
|
113
|
+
|
|
114
|
+
# Supports Ruby hash pattern matching with snake_case (or camelCase)
|
|
115
|
+
# keys. Absent properties are omitted so patterns requiring them
|
|
116
|
+
# fail to match; nested Definitions destructure recursively.
|
|
117
|
+
def deconstruct_keys(keys)
|
|
118
|
+
snake = self.class.snake_to_camel_map
|
|
119
|
+
|
|
120
|
+
if keys
|
|
121
|
+
keys.each_with_object({}) do |key, result|
|
|
122
|
+
camel = snake[key.to_s] || key.to_s
|
|
123
|
+
if @data.key?(camel)
|
|
124
|
+
result[key] = @data[camel]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
camel_to_snake = self.class.camel_to_snake_map
|
|
129
|
+
@data.each_with_object({}) do |(camel, value), result|
|
|
130
|
+
result[(camel_to_snake[camel] || camel).to_sym] = value
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def inspect
|
|
136
|
+
"#<#{self.class.definition_name} #{to_h.inspect}>"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def wrap_ref(value, ref_info)
|
|
142
|
+
kind, name = ref_info
|
|
143
|
+
|
|
144
|
+
case kind
|
|
145
|
+
when :object
|
|
146
|
+
value.is_a?(Hash) ? AgUi::Protocol::JsonSchema[name].new(value) : value
|
|
147
|
+
when :array
|
|
148
|
+
if value.is_a?(Array)
|
|
149
|
+
value.map { |el| el.is_a?(Hash) ? AgUi::Protocol::JsonSchema[name].new(el) : el }
|
|
150
|
+
else
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
when :map
|
|
154
|
+
if value.is_a?(Hash)
|
|
155
|
+
value.transform_values { |v| v.is_a?(Hash) ? AgUi::Protocol::JsonSchema[name].new(v) : v }
|
|
156
|
+
else
|
|
157
|
+
value
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def deep_compact(obj)
|
|
165
|
+
case obj
|
|
166
|
+
when Hash
|
|
167
|
+
obj.each_with_object({}) do |(k, v), result|
|
|
168
|
+
compacted = deep_compact(v)
|
|
169
|
+
unless compacted.nil?
|
|
170
|
+
result[k] = compacted
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
when Array
|
|
174
|
+
obj.map { |v| deep_compact(v) }
|
|
175
|
+
when Definition
|
|
176
|
+
obj.to_h
|
|
177
|
+
else
|
|
178
|
+
obj
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
__END__
|
|
187
|
+
|
|
188
|
+
describe "AgUi::Protocol::JsonSchema::Definition" do
|
|
189
|
+
schema = AgUi::Protocol::JsonSchema
|
|
190
|
+
|
|
191
|
+
it "exposes snake_case readers over camelCase storage" do
|
|
192
|
+
event = schema["ToolCallStartEvent"].new(
|
|
193
|
+
type: "TOOL_CALL_START", tool_call_id: "tc1", tool_call_name: "navigate"
|
|
194
|
+
)
|
|
195
|
+
event.tool_call_id.should == "tc1"
|
|
196
|
+
event.tool_call_name.should == "navigate"
|
|
197
|
+
event.to_h["toolCallId"].should == "tc1"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "supports hash pattern matching with snake_case keys" do
|
|
201
|
+
event = schema["ToolCallStartEvent"].new(
|
|
202
|
+
type: "TOOL_CALL_START", tool_call_id: "tc1", tool_call_name: "navigate"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
case event
|
|
206
|
+
in { tool_call_name: String => name }
|
|
207
|
+
name.should == "navigate"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "wraps nested array refs into Definitions" do
|
|
212
|
+
msg = schema["AssistantMessage"].new(
|
|
213
|
+
id: "a1", role: "assistant",
|
|
214
|
+
tool_calls: [{ "id" => "tc1", "type" => "function",
|
|
215
|
+
"function" => { "name" => "navigate", "arguments" => "{}" } }]
|
|
216
|
+
)
|
|
217
|
+
msg.tool_calls.first.should.be.kind_of(AgUi::Protocol::JsonSchema::Definition)
|
|
218
|
+
msg.to_h["toolCalls"].first["function"]["name"].should == "navigate"
|
|
219
|
+
msg.valid?.should == true
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "raises ValidationError with readable message on valid!" do
|
|
223
|
+
event = schema["ToolCallStartEvent"].new(type: "TOOL_CALL_START", tool_call_id: "tc1")
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
event.valid!
|
|
227
|
+
raise "expected ValidationError"
|
|
228
|
+
rescue AgUi::Protocol::JsonSchema::ValidationError => e
|
|
229
|
+
e.message.should.include?("ToolCallStartEvent")
|
|
230
|
+
e.message.should.include?("toolCallName")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
module Protocol
|
|
8
|
+
module JsonSchema
|
|
9
|
+
# Raised when a Definition instance fails schema validation.
|
|
10
|
+
# Ported from a2a. Collects JSONSchemer error details and formats
|
|
11
|
+
# them as a human-readable list with dot-notation field paths.
|
|
12
|
+
#
|
|
13
|
+
# TextMessageContentEvent validation failed:
|
|
14
|
+
# - delta is required but missing
|
|
15
|
+
#
|
|
16
|
+
class ValidationError < StandardError
|
|
17
|
+
attr_reader :errors, :definition_name, :data
|
|
18
|
+
|
|
19
|
+
def initialize(errors, definition_name:, data: nil)
|
|
20
|
+
@errors = errors
|
|
21
|
+
@definition_name = definition_name
|
|
22
|
+
@data = data
|
|
23
|
+
|
|
24
|
+
super(build_message)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_message
|
|
30
|
+
lines = errors.map { |e| " - #{format_error(e)}" }
|
|
31
|
+
"#{definition_name} validation failed:\n#{lines.join("\n")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def format_error(error)
|
|
35
|
+
path = format_path(error)
|
|
36
|
+
type = error["type"]
|
|
37
|
+
|
|
38
|
+
case type
|
|
39
|
+
when "required"
|
|
40
|
+
missing = error.dig("details", "missing_keys")&.join(", ") || "unknown"
|
|
41
|
+
if path.empty?
|
|
42
|
+
"#{missing} is required but missing"
|
|
43
|
+
else
|
|
44
|
+
"#{path}.#{missing} is required but missing"
|
|
45
|
+
end
|
|
46
|
+
when "type"
|
|
47
|
+
expected = Array(error.dig("schema", "type")).join(" or ")
|
|
48
|
+
"#{path.empty? ? "(root)" : path} must be #{expected}"
|
|
49
|
+
when "enum"
|
|
50
|
+
allowed = error.dig("schema", "enum")&.join(", ") || "?"
|
|
51
|
+
"#{path.empty? ? "(root)" : path} must be one of: #{allowed}"
|
|
52
|
+
when "const"
|
|
53
|
+
"#{path.empty? ? "(root)" : path} must be #{error.dig("schema", "const").inspect}"
|
|
54
|
+
when "pattern"
|
|
55
|
+
pattern = error.dig("schema", "pattern")
|
|
56
|
+
"#{path.empty? ? "(root)" : path} must match pattern #{pattern}"
|
|
57
|
+
when "format"
|
|
58
|
+
fmt = error.dig("schema", "format")
|
|
59
|
+
"#{path.empty? ? "(root)" : path} must be a valid #{fmt}"
|
|
60
|
+
when "minimum", "maximum"
|
|
61
|
+
"#{path.empty? ? "(root)" : path} #{error["error"]}"
|
|
62
|
+
when "additionalProperties"
|
|
63
|
+
"#{path.empty? ? "(root)" : path} has unknown properties"
|
|
64
|
+
else
|
|
65
|
+
detail = error["error"] || error["type"] || "invalid"
|
|
66
|
+
"#{path.empty? ? "(root)" : path} #{detail}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Convert a JSON pointer like "/toolCalls/0/function" to
|
|
71
|
+
# dot notation like "toolCalls.0.function".
|
|
72
|
+
def format_path(error)
|
|
73
|
+
pointer = error["data_pointer"].to_s
|
|
74
|
+
|
|
75
|
+
if pointer.empty? || pointer == "/"
|
|
76
|
+
""
|
|
77
|
+
else
|
|
78
|
+
pointer.delete_prefix("/").gsub("/", ".")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
__END__
|
|
87
|
+
|
|
88
|
+
describe "AgUi::Protocol::JsonSchema::ValidationError" do
|
|
89
|
+
error_data = [
|
|
90
|
+
{
|
|
91
|
+
"data_pointer" => "",
|
|
92
|
+
"type" => "required",
|
|
93
|
+
"details" => { "missing_keys" => ["delta"] },
|
|
94
|
+
"schema" => {},
|
|
95
|
+
"error" => "missing keys: delta"
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
err = AgUi::Protocol::JsonSchema::ValidationError.new(
|
|
100
|
+
error_data, definition_name: "TextMessageContentEvent"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
it "includes the definition name and formats required errors" do
|
|
104
|
+
err.message.should.include?("TextMessageContentEvent")
|
|
105
|
+
err.message.should.include?("delta is required but missing")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "stores errors and definition name" do
|
|
109
|
+
err.errors.should == error_data
|
|
110
|
+
err.definition_name.should == "TextMessageContentEvent"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
nested = AgUi::Protocol::JsonSchema::ValidationError.new(
|
|
114
|
+
[{ "data_pointer" => "/function/name", "type" => "type",
|
|
115
|
+
"schema" => { "type" => "string" }, "error" => "wrong type" }],
|
|
116
|
+
definition_name: "ToolCall"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
it "formats nested paths with dot notation" do
|
|
120
|
+
nested.message.should.include?("function.name must be string")
|
|
121
|
+
end
|
|
122
|
+
end
|