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,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
module Protocol
|
|
8
|
+
# Schema-validated AG-UI protocol objects, powered by json_schemer.
|
|
9
|
+
# Same mechanism as a2a's Protocol::JsonSchema, over the schema bundle
|
|
10
|
+
# generated from the reference Python SDK's pydantic models
|
|
11
|
+
# (data/generate-ag-ui-schema.py -> data/ag_ui.json).
|
|
12
|
+
#
|
|
13
|
+
# AgUi::Protocol::JsonSchema["TextMessageContentEvent"]
|
|
14
|
+
# #=> Class < Definition with .schema, .valid?, reader methods
|
|
15
|
+
#
|
|
16
|
+
# AgUi::Protocol::JsonSchema.list_definitions
|
|
17
|
+
# #=> ["ActivityDeltaEvent", "ActivityMessage", ...]
|
|
18
|
+
#
|
|
19
|
+
module JsonSchema
|
|
20
|
+
DATA_PATH = File.expand_path("../../../data/ag_ui.json", __dir__).freeze
|
|
21
|
+
|
|
22
|
+
@definition_classes = {}
|
|
23
|
+
@schemer = nil
|
|
24
|
+
@raw_schema = nil
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Look up a definition by model class name.
|
|
28
|
+
#
|
|
29
|
+
# AgUi::Protocol::JsonSchema["RunAgentInput"]
|
|
30
|
+
# #=> Class < Definition
|
|
31
|
+
#
|
|
32
|
+
def [](name)
|
|
33
|
+
@definition_classes[name] ||= begin
|
|
34
|
+
definitions = raw_schema.fetch("definitions", {})
|
|
35
|
+
|
|
36
|
+
unless definitions.key?(name)
|
|
37
|
+
raise "No AG-UI definition found for #{name.inspect}!" \
|
|
38
|
+
"\nAvailable: #{list_definitions.join(", ")}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ref_schema = schemer.ref("#/definitions/#{name}")
|
|
42
|
+
build_definition_class(ref_schema, name, definitions[name])
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# All available definition names, sorted.
|
|
47
|
+
def list_definitions
|
|
48
|
+
raw_schema.fetch("definitions", {}).keys.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Definition names for concrete events (have a const "type"),
|
|
52
|
+
# mapped from wire event type to definition name:
|
|
53
|
+
#
|
|
54
|
+
# { "TEXT_MESSAGE_CONTENT" => "TextMessageContentEvent", ... }
|
|
55
|
+
#
|
|
56
|
+
def event_types
|
|
57
|
+
@event_types ||= raw_schema.fetch("definitions", {}).each_with_object({}) do |(name, defn), map|
|
|
58
|
+
const = defn.dig("properties", "type", "const")
|
|
59
|
+
if const
|
|
60
|
+
map[const] = name
|
|
61
|
+
end
|
|
62
|
+
end.freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The JSONSchemer instance for the full AG-UI schema bundle.
|
|
66
|
+
def schemer
|
|
67
|
+
@schemer ||= JSONSchemer.schema(raw_schema)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The parsed schema hash (all $refs are already internal —
|
|
71
|
+
# the generator emits #/definitions/... pointers).
|
|
72
|
+
def raw_schema
|
|
73
|
+
@raw_schema ||= JSON.parse(File.read(DATA_PATH))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reset all cached state (useful for tests).
|
|
77
|
+
def reset!
|
|
78
|
+
@definition_classes.clear
|
|
79
|
+
@schemer = nil
|
|
80
|
+
@raw_schema = nil
|
|
81
|
+
@event_types = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Build a Definition subclass for a specific AG-UI type.
|
|
87
|
+
def build_definition_class(schema_instance, definition_name, raw_definition)
|
|
88
|
+
properties = raw_definition.fetch("properties", {})
|
|
89
|
+
camel_keys = properties.keys
|
|
90
|
+
snake_to_camel = build_snake_to_camel(camel_keys)
|
|
91
|
+
prop_refs = build_property_refs(properties)
|
|
92
|
+
|
|
93
|
+
reader_pairs = camel_keys.map { |ck| [camel_to_snake(ck).to_sym, ck] }
|
|
94
|
+
|
|
95
|
+
Class.new(Definition) do
|
|
96
|
+
@schema = schema_instance
|
|
97
|
+
@definition_name = definition_name
|
|
98
|
+
@schema_properties = camel_keys
|
|
99
|
+
@snake_to_camel = snake_to_camel
|
|
100
|
+
@property_refs = prop_refs
|
|
101
|
+
|
|
102
|
+
class << self
|
|
103
|
+
def schema = @schema
|
|
104
|
+
def definition_name = @definition_name
|
|
105
|
+
def schema_properties = @schema_properties
|
|
106
|
+
def snake_to_camel_map = @snake_to_camel
|
|
107
|
+
def property_refs = @property_refs
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
reader_pairs.each do |snake_sym, camel_key|
|
|
111
|
+
define_method(snake_sym) { @data[camel_key] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Inspect each property's schema for $ref pointers and build a map
|
|
117
|
+
# of { camelKey => [:kind, "DefinitionName"] } so Definition can
|
|
118
|
+
# auto-wrap nested Hashes. Pydantic wraps optional properties in
|
|
119
|
+
# anyOf [<schema>, {type: null}], so detection looks through the
|
|
120
|
+
# first non-null anyOf variant.
|
|
121
|
+
#
|
|
122
|
+
# :object — direct $ref
|
|
123
|
+
# :array — items.$ref
|
|
124
|
+
# :map — additionalProperties.$ref
|
|
125
|
+
def build_property_refs(properties)
|
|
126
|
+
definitions = raw_schema.fetch("definitions", {})
|
|
127
|
+
refs = {}
|
|
128
|
+
|
|
129
|
+
properties.each do |camel_key, prop_schema|
|
|
130
|
+
schema = unwrap_optional(prop_schema)
|
|
131
|
+
|
|
132
|
+
kind, ref =
|
|
133
|
+
if (r = schema["$ref"])
|
|
134
|
+
[:object, r]
|
|
135
|
+
elsif schema["type"] == "array" && (r = schema.dig("items", "$ref"))
|
|
136
|
+
[:array, r]
|
|
137
|
+
elsif schema["type"] == "object" && (r = schema.dig("additionalProperties", "$ref"))
|
|
138
|
+
[:map, r]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if ref
|
|
142
|
+
name = ref_name_for(ref)
|
|
143
|
+
if name && definitions.dig(name, "properties")
|
|
144
|
+
refs[camel_key] = [kind, name]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
refs
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Pydantic optional: anyOf [<schema>, {"type": "null"}] — return
|
|
153
|
+
# the non-null variant (only when the anyOf is that exact shape).
|
|
154
|
+
def unwrap_optional(prop_schema)
|
|
155
|
+
variants = prop_schema["anyOf"]
|
|
156
|
+
if variants
|
|
157
|
+
non_null = variants.reject { |s| s == { "type" => "null" } }
|
|
158
|
+
non_null.length == 1 ? non_null.first : prop_schema
|
|
159
|
+
else
|
|
160
|
+
prop_schema
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def ref_name_for(ref)
|
|
165
|
+
if ref.start_with?("#/definitions/")
|
|
166
|
+
ref.sub("#/definitions/", "")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def build_snake_to_camel(camel_keys)
|
|
171
|
+
map = {}
|
|
172
|
+
camel_keys.each do |camel|
|
|
173
|
+
snake = camel_to_snake(camel)
|
|
174
|
+
map[snake] = camel
|
|
175
|
+
map[camel] = camel
|
|
176
|
+
end
|
|
177
|
+
map
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def camel_to_snake(str)
|
|
181
|
+
str.gsub(/([A-Z])/) { "_#{$1.downcase}" }
|
|
182
|
+
.delete_prefix("_")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
__END__
|
|
190
|
+
|
|
191
|
+
describe "AgUi::Protocol::JsonSchema" do
|
|
192
|
+
schema = AgUi::Protocol::JsonSchema
|
|
193
|
+
|
|
194
|
+
it "loads the raw schema with definitions" do
|
|
195
|
+
schema.raw_schema["definitions"].should.be.kind_of(Hash)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "contains only internal $refs" do
|
|
199
|
+
external = []
|
|
200
|
+
walk = ->(obj) do
|
|
201
|
+
case obj
|
|
202
|
+
when Hash
|
|
203
|
+
obj.each do |k, v|
|
|
204
|
+
if k == "$ref" && v.is_a?(String) && !v.start_with?("#")
|
|
205
|
+
external << v
|
|
206
|
+
else
|
|
207
|
+
walk.(v)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
when Array
|
|
211
|
+
obj.each { |v| walk.(v) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
walk.(schema.raw_schema["definitions"])
|
|
215
|
+
external.should == []
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "lists definitions sorted, covering the core vocabulary" do
|
|
219
|
+
defs = schema.list_definitions
|
|
220
|
+
defs.should == defs.sort
|
|
221
|
+
%w[RunAgentInput RunStartedEvent RunFinishedEvent TextMessageContentEvent
|
|
222
|
+
ToolCallStartEvent ActivitySnapshotEvent AssistantMessage ToolMessage
|
|
223
|
+
AgentCapabilities].each { |d| defs.should.include?(d) }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "maps wire event types to definition names" do
|
|
227
|
+
schema.event_types["TEXT_MESSAGE_CONTENT"].should == "TextMessageContentEvent"
|
|
228
|
+
schema.event_types["RUN_STARTED"].should == "RunStartedEvent"
|
|
229
|
+
schema.event_types["ACTIVITY_SNAPSHOT"].should == "ActivitySnapshotEvent"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "returns cached Definition subclasses" do
|
|
233
|
+
a = schema["RunStartedEvent"]
|
|
234
|
+
b = schema["RunStartedEvent"]
|
|
235
|
+
(a < AgUi::Protocol::JsonSchema::Definition).should == true
|
|
236
|
+
a.object_id.should == b.object_id
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it "builds definitions that serialize snake_case input to camelCase wire form" do
|
|
240
|
+
event = schema["TextMessageContentEvent"].new(
|
|
241
|
+
type: "TEXT_MESSAGE_CONTENT", message_id: "m1", delta: "Hi"
|
|
242
|
+
)
|
|
243
|
+
event.valid?.should == true
|
|
244
|
+
event.to_h.should == { "type" => "TEXT_MESSAGE_CONTENT", "messageId" => "m1", "delta" => "Hi" }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "rejects invalid events" do
|
|
248
|
+
event = schema["TextMessageContentEvent"].new(type: "TEXT_MESSAGE_CONTENT", message_id: "m1")
|
|
249
|
+
event.valid?.should == false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it "detects pydantic-optional anyOf refs as :object property refs" do
|
|
253
|
+
schema["RunStartedEvent"].property_refs["input"].should == [:object, "RunAgentInput"]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "validates RunAgentInput built via Definition" do
|
|
257
|
+
input = schema["RunAgentInput"].new(
|
|
258
|
+
thread_id: "t1", run_id: "r1", state: {}, messages: [],
|
|
259
|
+
tools: [], context: [], forwarded_props: {}
|
|
260
|
+
)
|
|
261
|
+
input.valid?.should == true
|
|
262
|
+
input.to_h["threadId"].should == "t1"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
it "validates a raw wire hash with explicit nulls via schemer (inbound path)" do
|
|
266
|
+
raw = { "threadId" => "t1", "runId" => "r1", "state" => nil, "messages" => [],
|
|
267
|
+
"tools" => [], "context" => [], "forwardedProps" => nil, "extra" => 1 }
|
|
268
|
+
schema.schemer.ref("#/definitions/RunAgentInput").valid?(raw).should == true
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
# Parses and validates the POST body of /agent/:id/run — the
|
|
8
|
+
# RunAgentInput envelope (threadId, runId, messages, tools, context,
|
|
9
|
+
# forwardedProps, state, resume?).
|
|
10
|
+
#
|
|
11
|
+
# Validation runs against the RAW parsed hash so explicit nulls
|
|
12
|
+
# (state: null, forwardedProps: null) survive — Definition#to_h
|
|
13
|
+
# compacts nils away, which would fail required-field checks.
|
|
14
|
+
# The returned Definition gives snake_case readers and pattern
|
|
15
|
+
# matching over the same data.
|
|
16
|
+
#
|
|
17
|
+
# input = AgUi::RunInput.parse(request_body)
|
|
18
|
+
# input.thread_id #=> "t1"
|
|
19
|
+
# input.messages #=> [Definition(UserMessage), ...]
|
|
20
|
+
#
|
|
21
|
+
module RunInput
|
|
22
|
+
# Raised on malformed JSON or schema-invalid input. The route layer
|
|
23
|
+
# renders this as 400 {"error": "Invalid request body", "details": ...}.
|
|
24
|
+
class InvalidError < StandardError; end
|
|
25
|
+
|
|
26
|
+
SCHEMA_REF = "#/definitions/RunAgentInput"
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def parse(body)
|
|
30
|
+
begin
|
|
31
|
+
raw = JSON.parse(body)
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise InvalidError, "malformed JSON: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless raw.is_a?(Hash)
|
|
37
|
+
raise InvalidError, "expected a JSON object, got #{raw.class}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
errors = schema.validate(raw).to_a
|
|
41
|
+
unless errors.empty?
|
|
42
|
+
validation_error = Protocol::JsonSchema::ValidationError.new(
|
|
43
|
+
errors,
|
|
44
|
+
definition_name: "RunAgentInput",
|
|
45
|
+
data: raw,
|
|
46
|
+
)
|
|
47
|
+
raise InvalidError, validation_error.message
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Protocol::JsonSchema["RunAgentInput"].new(raw)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def schema
|
|
54
|
+
@schema ||= Protocol::JsonSchema.schemer.ref(SCHEMA_REF)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
__END__
|
|
61
|
+
|
|
62
|
+
describe "AgUi::RunInput" do
|
|
63
|
+
minimal = {
|
|
64
|
+
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
|
65
|
+
"messages" => [], "tools" => [], "context" => [], "forwardedProps" => nil,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it "parses a minimal valid body with explicit nulls" do
|
|
69
|
+
input = AgUi::RunInput.parse(JSON.generate(minimal))
|
|
70
|
+
input.thread_id.should == "t1"
|
|
71
|
+
input.run_id.should == "r1"
|
|
72
|
+
input.messages.should == []
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "parses the full envelope: messages, tools, context" do
|
|
76
|
+
body = minimal.merge(
|
|
77
|
+
"messages" => [
|
|
78
|
+
{ "id" => "u1", "role" => "user", "content" => "hi" },
|
|
79
|
+
{ "id" => "a1", "role" => "assistant",
|
|
80
|
+
"toolCalls" => [{ "id" => "tc1", "type" => "function",
|
|
81
|
+
"function" => { "name" => "navigate", "arguments" => "{}" } }] },
|
|
82
|
+
{ "id" => "tr1", "role" => "tool", "content" => "{}", "toolCallId" => "tc1" },
|
|
83
|
+
],
|
|
84
|
+
"tools" => [{ "name" => "navigate", "description" => "Go to a page",
|
|
85
|
+
"parameters" => { "type" => "object" } }],
|
|
86
|
+
"context" => [{ "description" => "currentPath", "value" => "/data" }],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
input = AgUi::RunInput.parse(JSON.generate(body))
|
|
90
|
+
input.messages.length.should == 3
|
|
91
|
+
input.tools.first.name.should == "navigate"
|
|
92
|
+
input.context.first.value.should == "/data"
|
|
93
|
+
|
|
94
|
+
# Messages are a discriminated union — they stay raw camelCase hashes.
|
|
95
|
+
input.messages.last["toolCallId"].should == "tc1"
|
|
96
|
+
|
|
97
|
+
case input
|
|
98
|
+
in { thread_id: String => tid }
|
|
99
|
+
tid.should == "t1"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "accepts multimodal user message content parts" do
|
|
104
|
+
body = minimal.merge(
|
|
105
|
+
"messages" => [
|
|
106
|
+
{ "id" => "u1", "role" => "user", "content" => [
|
|
107
|
+
{ "type" => "text", "text" => "what is this?" },
|
|
108
|
+
{ "type" => "image",
|
|
109
|
+
"source" => { "type" => "data", "value" => "aGk=", "mimeType" => "image/png" } },
|
|
110
|
+
] },
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
input = AgUi::RunInput.parse(JSON.generate(body))
|
|
115
|
+
input.messages.first["content"].length.should == 2
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "tolerates unknown extra fields (extra=allow)" do
|
|
119
|
+
input = AgUi::RunInput.parse(JSON.generate(minimal.merge("lastSeenEventId" => "5")))
|
|
120
|
+
input.thread_id.should == "t1"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "rejects malformed JSON" do
|
|
124
|
+
begin
|
|
125
|
+
AgUi::RunInput.parse("{nope")
|
|
126
|
+
raise "expected InvalidError"
|
|
127
|
+
rescue AgUi::RunInput::InvalidError => e
|
|
128
|
+
e.message.should.include?("malformed JSON")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "rejects non-object bodies" do
|
|
133
|
+
begin
|
|
134
|
+
AgUi::RunInput.parse("[1,2]")
|
|
135
|
+
raise "expected InvalidError"
|
|
136
|
+
rescue AgUi::RunInput::InvalidError => e
|
|
137
|
+
e.message.should.include?("expected a JSON object")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "rejects schema-invalid bodies with readable details" do
|
|
142
|
+
begin
|
|
143
|
+
AgUi::RunInput.parse(JSON.generate({ "threadId" => "t1" }))
|
|
144
|
+
raise "expected InvalidError"
|
|
145
|
+
rescue AgUi::RunInput::InvalidError => e
|
|
146
|
+
e.message.should.include?("runId")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "console"
|
|
5
|
+
require "ag_ui"
|
|
6
|
+
|
|
7
|
+
module AgUi
|
|
8
|
+
# The run loop: drives one AG-UI run through a brute turn pipeline.
|
|
9
|
+
#
|
|
10
|
+
# The terminal block is the LLM call (ruby_llm or anything else) — it
|
|
11
|
+
# receives brute's env ({messages:, events:, ...}), streams deltas into
|
|
12
|
+
# env[:events] (translated live to SSE by EventBridge) and appends the
|
|
13
|
+
# assistant message to env[:messages]. The gem stays LLM-agnostic.
|
|
14
|
+
#
|
|
15
|
+
# run_loop = AgUi::RunLoop.new(system_prompt: PROMPT) do |env|
|
|
16
|
+
# env[:events] << { type: :text_message_start, data: { message_id: id } }
|
|
17
|
+
# ... stream provider chunks ...
|
|
18
|
+
# env[:events] << { type: :text_message_end, data: { message_id: id } }
|
|
19
|
+
# env[:messages].assistant(full_text)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# app = AgUi.agent(agent_id: "default", &run_loop)
|
|
23
|
+
#
|
|
24
|
+
# Lifecycle per run (the AG-UI contract): RUN_STARTED first; then the
|
|
25
|
+
# pipeline streams; then RUN_FINISHED — or RUN_ERROR if the pipeline
|
|
26
|
+
# raised. Schema-validation failures are wire-contract bugs and re-raise
|
|
27
|
+
# after RUN_ERROR so they fail loudly in dev.
|
|
28
|
+
class RunLoop
|
|
29
|
+
# a2ui: nil/false = off; an AgUi::A2ui::Catalog = on with that catalog;
|
|
30
|
+
# true = on degraded (tool injected, no component schema).
|
|
31
|
+
# server_tools: [{name:, description:, parameters:, handler:}] execute
|
|
32
|
+
# inline and the turn loops (Loop::ToolResult) until the model answers
|
|
33
|
+
# in text, a client tool defers to the browser, or max_iterations hits.
|
|
34
|
+
def initialize(system_prompt: nil, validate: true, middleware: [], a2ui: nil,
|
|
35
|
+
server_tools: [], max_iterations: 10, &terminal)
|
|
36
|
+
unless terminal
|
|
37
|
+
raise ArgumentError, "RunLoop requires a terminal block (the LLM call)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@system_prompt = system_prompt
|
|
41
|
+
@validate = validate
|
|
42
|
+
@middleware = middleware
|
|
43
|
+
@a2ui = a2ui
|
|
44
|
+
@server_tools = server_tools
|
|
45
|
+
@max_iterations = max_iterations
|
|
46
|
+
@terminal = terminal
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def a2ui_enabled?
|
|
50
|
+
!(@a2ui.nil? || @a2ui == false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# AgUi.agent takes a block; RunLoop quacks like one.
|
|
54
|
+
def to_proc
|
|
55
|
+
run_loop = self
|
|
56
|
+
proc { |rack_env| run_loop.handle(rack_env) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle(rack_env)
|
|
60
|
+
input = rack_env["ag_ui.input"]
|
|
61
|
+
|
|
62
|
+
rack_env["ag_ui.stream"].open(
|
|
63
|
+
thread_id: input.thread_id,
|
|
64
|
+
run_id: input.run_id,
|
|
65
|
+
validate: @validate,
|
|
66
|
+
) do |stream|
|
|
67
|
+
stream.run_started
|
|
68
|
+
run(stream, input)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def run(stream, input)
|
|
75
|
+
pipeline(input).start(
|
|
76
|
+
Messages.to_brute(input.messages),
|
|
77
|
+
events: EventBridge.new(stream),
|
|
78
|
+
)
|
|
79
|
+
stream.run_finished
|
|
80
|
+
rescue Protocol::JsonSchema::ValidationError => e
|
|
81
|
+
Console.error(self, "wire-contract bug: #{e.message}", e)
|
|
82
|
+
stream.run_error(message: "Internal error", code: "validation")
|
|
83
|
+
raise
|
|
84
|
+
rescue => e
|
|
85
|
+
Console.error(self, "run failed: #{e.class}: #{e.message}", e)
|
|
86
|
+
stream.run_error(message: e.message, code: e.class.name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def pipeline(input)
|
|
90
|
+
agent = Brute.agent
|
|
91
|
+
agent.use(
|
|
92
|
+
AgUi::Middleware::SystemPrompt,
|
|
93
|
+
prompt: @system_prompt,
|
|
94
|
+
context: input.context,
|
|
95
|
+
)
|
|
96
|
+
agent.use(AgUi::Middleware::ForwardedProps, props: input.forwarded_props)
|
|
97
|
+
agent.use(Brute::Middleware::Loop::ToolResult)
|
|
98
|
+
agent.use(Brute::Middleware::MaxIterations, max_iterations: @max_iterations)
|
|
99
|
+
if a2ui_enabled?
|
|
100
|
+
catalog = @a2ui.is_a?(AgUi::A2ui::Catalog) ? @a2ui : nil
|
|
101
|
+
agent.use(AgUi::Middleware::A2ui, catalog: catalog)
|
|
102
|
+
end
|
|
103
|
+
agent.use(AgUi::Middleware::ToolRouter, tools: input.tools, server_tools: @server_tools)
|
|
104
|
+
@middleware.each do |(klass, options)|
|
|
105
|
+
agent.use(klass, **(options || {}))
|
|
106
|
+
end
|
|
107
|
+
agent.run(@terminal)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
__END__
|
|
113
|
+
|
|
114
|
+
describe "AgUi::RunLoop" do
|
|
115
|
+
minimal_input = JSON.generate({
|
|
116
|
+
"threadId" => "t1", "runId" => "r1", "state" => nil,
|
|
117
|
+
"messages" => [{ "id" => "u1", "role" => "user", "content" => "hi" }],
|
|
118
|
+
"tools" => [], "context" => [{ "description" => "currentPath", "value" => "/data" }],
|
|
119
|
+
"forwardedProps" => nil,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
request = ->(app, body) do
|
|
123
|
+
app.call({
|
|
124
|
+
"REQUEST_METHOD" => "POST",
|
|
125
|
+
"PATH_INFO" => "/agent/default/run",
|
|
126
|
+
"rack.input" => StringIO.new(body),
|
|
127
|
+
})
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
read_frames = ->(stream) do
|
|
131
|
+
frames = []
|
|
132
|
+
while (chunk = stream.read)
|
|
133
|
+
frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
134
|
+
end
|
|
135
|
+
frames
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "streams a full run through a brute pipeline" do
|
|
139
|
+
seen_env = nil
|
|
140
|
+
run_loop = AgUi::RunLoop.new(system_prompt: "Be terse.") do |env|
|
|
141
|
+
seen_env = env
|
|
142
|
+
env[:events] << { type: :text_message_start, data: { message_id: "m1" } }
|
|
143
|
+
env[:events] << { type: :text_message_content, data: { message_id: "m1", delta: "Hello" } }
|
|
144
|
+
env[:events] << { type: :text_message_end, data: { message_id: "m1" } }
|
|
145
|
+
env[:messages].assistant("Hello")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
149
|
+
_status, _headers, body = request.(app, minimal_input)
|
|
150
|
+
|
|
151
|
+
frames = read_frames.(body)
|
|
152
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
153
|
+
RUN_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END RUN_FINISHED
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
# The pipeline saw: system prompt (with context addendum) + history.
|
|
157
|
+
seen_env[:messages].map(&:role).should == [:system, :user, :assistant]
|
|
158
|
+
seen_env[:messages].first.content.should.include?("Be terse.")
|
|
159
|
+
seen_env[:messages].first.content.should.include?("currentPath: /data")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "ends the run with RUN_ERROR when the terminal raises" do
|
|
163
|
+
run_loop = AgUi::RunLoop.new { |_env| raise "provider exploded" }
|
|
164
|
+
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
165
|
+
|
|
166
|
+
_status, _headers, body = request.(app, minimal_input)
|
|
167
|
+
frames = read_frames.(body)
|
|
168
|
+
|
|
169
|
+
frames.map { |f| f["type"] }.should == %w[RUN_STARTED RUN_ERROR]
|
|
170
|
+
frames.last["message"].should == "provider exploded"
|
|
171
|
+
frames.last["code"].should == "RuntimeError"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "supports extra brute middleware" do
|
|
175
|
+
marker = Class.new do
|
|
176
|
+
def initialize(app, note:)
|
|
177
|
+
@app = app
|
|
178
|
+
@note = note
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def call(env)
|
|
182
|
+
env[:metadata][:note] = @note
|
|
183
|
+
@app.call(env)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
seen = nil
|
|
188
|
+
run_loop = AgUi::RunLoop.new(middleware: [[marker, { note: "hi" }]]) do |env|
|
|
189
|
+
seen = env[:metadata][:note]
|
|
190
|
+
env[:messages].assistant("ok")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
request.(AgUi.agent(&run_loop), minimal_input)
|
|
194
|
+
seen.should == "hi"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "requires a terminal block" do
|
|
198
|
+
lambda { AgUi::RunLoop.new }.should.raise(ArgumentError)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "executes server tools inline and loops the turn to completion" do
|
|
202
|
+
weather_tool = {
|
|
203
|
+
name: "get_weather",
|
|
204
|
+
description: "Weather for a city",
|
|
205
|
+
parameters: { "type" => "object" },
|
|
206
|
+
handler: ->(args) { { "city" => args["city"], "temp" => 21 } },
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
iterations = 0
|
|
210
|
+
terminal = ->(env) do
|
|
211
|
+
iterations += 1
|
|
212
|
+
if iterations == 1
|
|
213
|
+
env[:messages] << Brute::Message.new(
|
|
214
|
+
role: :assistant, content: nil,
|
|
215
|
+
tool_calls: [{ id: "tc1", name: "get_weather", arguments: { "city" => "Lisbon" } }],
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
# The model sees its own call + the executed result.
|
|
219
|
+
env[:messages].last.role.should == :tool
|
|
220
|
+
env[:messages].last.content.should == "{\"city\":\"Lisbon\",\"temp\":21}"
|
|
221
|
+
env[:events] << { type: :text_message_start, data: { message_id: "m2" } }
|
|
222
|
+
env[:events] << { type: :text_message_content, data: { message_id: "m2", delta: "21C in Lisbon" } }
|
|
223
|
+
env[:events] << { type: :text_message_end, data: { message_id: "m2" } }
|
|
224
|
+
env[:messages].assistant("21C in Lisbon")
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
run_loop = AgUi::RunLoop.new(server_tools: [weather_tool], &terminal)
|
|
229
|
+
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
230
|
+
|
|
231
|
+
_status, _headers, stream = request.(app, minimal_input)
|
|
232
|
+
frames = read_frames.(stream)
|
|
233
|
+
|
|
234
|
+
iterations.should == 2
|
|
235
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
236
|
+
RUN_STARTED
|
|
237
|
+
TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_RESULT
|
|
238
|
+
TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END
|
|
239
|
+
RUN_FINISHED
|
|
240
|
+
]
|
|
241
|
+
frames[4]["content"].should == "{\"city\":\"Lisbon\",\"temp\":21}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
it "streams the full A2UI sequence when the model renders a surface" do
|
|
245
|
+
catalog = AgUi::A2ui::Catalog.new(
|
|
246
|
+
catalog_id: "host://ai-catalog",
|
|
247
|
+
components: { "Card" => {} },
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
terminal = ->(env) do
|
|
251
|
+
env[:tools].map { |t| t["name"] }.should.include?("render_a2ui")
|
|
252
|
+
env[:messages].first.content.should.include?("A2UI Component Schema")
|
|
253
|
+
|
|
254
|
+
env[:messages] << Brute::Message.new(
|
|
255
|
+
role: :assistant, content: nil,
|
|
256
|
+
tool_calls: [{ id: "tc9", name: "render_a2ui", arguments: {
|
|
257
|
+
"surfaceId" => "s1",
|
|
258
|
+
"components" => [{ "id" => "root", "component" => "Card" }],
|
|
259
|
+
} }],
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
run_loop = AgUi::RunLoop.new(a2ui: catalog, &terminal)
|
|
264
|
+
app = AgUi.agent(agent_id: "default", &run_loop)
|
|
265
|
+
|
|
266
|
+
_status, _headers, stream = request.(app, minimal_input)
|
|
267
|
+
frames = read_frames.(stream)
|
|
268
|
+
|
|
269
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
270
|
+
RUN_STARTED TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END
|
|
271
|
+
ACTIVITY_SNAPSHOT TOOL_CALL_RESULT RUN_FINISHED
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
snapshot = frames[4]
|
|
275
|
+
snapshot["messageId"].should == "a2ui-surface-tc9"
|
|
276
|
+
snapshot["activityType"].should == "a2ui-surface"
|
|
277
|
+
snapshot["replace"].should == true
|
|
278
|
+
snapshot["content"]["a2ui_operations"][0]["createSurface"]["catalogId"]
|
|
279
|
+
.should == "host://ai-catalog"
|
|
280
|
+
|
|
281
|
+
result = frames[5]
|
|
282
|
+
result["toolCallId"].should == "tc9"
|
|
283
|
+
result["content"].should == "{\"status\":\"rendered\"}"
|
|
284
|
+
end
|
|
285
|
+
end
|