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.
@@ -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