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,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
# The seam between brute's turn pipeline and the AG-UI SSE stream.
|
|
8
|
+
#
|
|
9
|
+
# Brute middleware and terminal procs push `{type:, data:}` events into
|
|
10
|
+
# env[:events]; an EventBridge is that sink, translating each event into
|
|
11
|
+
# the matching typed SSE emitter as it arrives — the browser sees deltas
|
|
12
|
+
# mid-turn, not after.
|
|
13
|
+
#
|
|
14
|
+
# pipeline.start(messages, events: AgUi::EventBridge.new(stream))
|
|
15
|
+
#
|
|
16
|
+
# Unknown event types (brute's own :log, :tool_result telemetry, etc.)
|
|
17
|
+
# are ignored — only the AG-UI vocabulary reaches the wire.
|
|
18
|
+
class EventBridge
|
|
19
|
+
TRANSLATIONS = {
|
|
20
|
+
text_message_start: :translate_text_start,
|
|
21
|
+
text_message_content: :translate_text_content,
|
|
22
|
+
text_message_end: :translate_text_end,
|
|
23
|
+
tool_call_start: :translate_tool_call_start,
|
|
24
|
+
tool_call_args: :translate_tool_call_args,
|
|
25
|
+
tool_call_end: :translate_tool_call_end,
|
|
26
|
+
tool_call_result: :translate_tool_call_result,
|
|
27
|
+
activity_snapshot: :translate_activity_snapshot,
|
|
28
|
+
reasoning_start: :translate_reasoning_start,
|
|
29
|
+
reasoning_message_start: :translate_reasoning_message_start,
|
|
30
|
+
reasoning_message_content: :translate_reasoning_message_content,
|
|
31
|
+
reasoning_message_end: :translate_reasoning_message_end,
|
|
32
|
+
reasoning_end: :translate_reasoning_end,
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(stream)
|
|
36
|
+
@stream = stream
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def <<(event)
|
|
40
|
+
handler = TRANSLATIONS[event[:type]]
|
|
41
|
+
if handler
|
|
42
|
+
send(handler, event[:data] || {})
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def translate_text_start(data)
|
|
50
|
+
@stream.text_message_start(message_id: data[:message_id])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Protocol rule: TEXT_MESSAGE_CONTENT.delta must be non-empty —
|
|
54
|
+
# providers occasionally emit empty chunks; drop them here.
|
|
55
|
+
def translate_text_content(data)
|
|
56
|
+
unless data[:delta].to_s.empty?
|
|
57
|
+
@stream.text_message_content(message_id: data[:message_id], delta: data[:delta])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def translate_text_end(data)
|
|
62
|
+
@stream.text_message_end(message_id: data[:message_id])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def translate_tool_call_start(data)
|
|
66
|
+
@stream.tool_call_start(
|
|
67
|
+
tool_call_id: data[:tool_call_id],
|
|
68
|
+
tool_call_name: data[:tool_call_name],
|
|
69
|
+
parent_message_id: data[:parent_message_id],
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def translate_tool_call_args(data)
|
|
74
|
+
unless data[:delta].to_s.empty?
|
|
75
|
+
@stream.tool_call_args(tool_call_id: data[:tool_call_id], delta: data[:delta])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def translate_tool_call_end(data)
|
|
80
|
+
@stream.tool_call_end(tool_call_id: data[:tool_call_id])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def translate_tool_call_result(data)
|
|
84
|
+
@stream.tool_call_result(
|
|
85
|
+
message_id: data[:message_id],
|
|
86
|
+
tool_call_id: data[:tool_call_id],
|
|
87
|
+
content: data[:content],
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def translate_activity_snapshot(data)
|
|
92
|
+
@stream.activity_snapshot(
|
|
93
|
+
message_id: data[:message_id],
|
|
94
|
+
activity_type: data[:activity_type],
|
|
95
|
+
content: data[:content],
|
|
96
|
+
replace: data.fetch(:replace, true),
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def translate_reasoning_start(data)
|
|
101
|
+
@stream.reasoning_start(message_id: data[:message_id])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def translate_reasoning_message_start(data)
|
|
105
|
+
@stream.reasoning_message_start(message_id: data[:message_id])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def translate_reasoning_message_content(data)
|
|
109
|
+
unless data[:delta].to_s.empty?
|
|
110
|
+
@stream.reasoning_message_content(message_id: data[:message_id], delta: data[:delta])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def translate_reasoning_message_end(data)
|
|
115
|
+
@stream.reasoning_message_end(message_id: data[:message_id])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def translate_reasoning_end(data)
|
|
119
|
+
@stream.reasoning_end(message_id: data[:message_id])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
__END__
|
|
125
|
+
|
|
126
|
+
describe "AgUi::EventBridge" do
|
|
127
|
+
read_frames = ->(stream) do
|
|
128
|
+
frames = []
|
|
129
|
+
while (chunk = stream.read)
|
|
130
|
+
frames << JSON.parse(chunk.sub(/\Adata: /, "").strip)
|
|
131
|
+
end
|
|
132
|
+
frames
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "translates text events into SSE frames as they arrive" do
|
|
136
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
137
|
+
bridge = AgUi::EventBridge.new(stream)
|
|
138
|
+
|
|
139
|
+
bridge << { type: :text_message_start, data: { message_id: "m1" } }
|
|
140
|
+
bridge << { type: :text_message_content, data: { message_id: "m1", delta: "Hel" } }
|
|
141
|
+
bridge << { type: :text_message_content, data: { message_id: "m1", delta: "lo" } }
|
|
142
|
+
bridge << { type: :text_message_end, data: { message_id: "m1" } }
|
|
143
|
+
stream.finish
|
|
144
|
+
|
|
145
|
+
frames = read_frames.(stream)
|
|
146
|
+
frames.map { |f| f["type"] }.should == %w[
|
|
147
|
+
TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END
|
|
148
|
+
]
|
|
149
|
+
frames[1]["delta"].should == "Hel"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it "drops empty deltas (protocol rule)" do
|
|
153
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
154
|
+
bridge = AgUi::EventBridge.new(stream)
|
|
155
|
+
|
|
156
|
+
bridge << { type: :text_message_content, data: { message_id: "m1", delta: "" } }
|
|
157
|
+
bridge << { type: :text_message_content, data: { message_id: "m1", delta: nil } }
|
|
158
|
+
stream.finish
|
|
159
|
+
|
|
160
|
+
read_frames.(stream).should == []
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it "ignores brute telemetry and unknown event types, returning self" do
|
|
164
|
+
stream = AgUi::Server::SSE::Stream.new(thread_id: "t1", run_id: "r1")
|
|
165
|
+
bridge = AgUi::EventBridge.new(stream)
|
|
166
|
+
|
|
167
|
+
result = bridge << { type: :log, data: { note: "internal" } }
|
|
168
|
+
bridge << { type: :whatever }
|
|
169
|
+
stream.finish
|
|
170
|
+
|
|
171
|
+
result.should.equal?(bridge)
|
|
172
|
+
read_frames.(stream).should == []
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "ag_ui"
|
|
5
|
+
|
|
6
|
+
module AgUi
|
|
7
|
+
# Translates AG-UI wire messages (RunAgentInput.messages — raw camelCase
|
|
8
|
+
# hashes) into brute's canonical Brute::Message log, ready for the turn
|
|
9
|
+
# pipeline. This is where client-executed tool results re-enter the
|
|
10
|
+
# conversation: the assistant's toolCalls and the matching tool messages
|
|
11
|
+
# must round-trip faithfully or the model repeats calls (doc 03 gotchas).
|
|
12
|
+
module Messages
|
|
13
|
+
class << self
|
|
14
|
+
def to_brute(wire_messages)
|
|
15
|
+
log = Brute.log
|
|
16
|
+
|
|
17
|
+
wire_messages.each do |message|
|
|
18
|
+
case message["role"]
|
|
19
|
+
in "user"
|
|
20
|
+
log.user(text_content(message["content"]))
|
|
21
|
+
in "assistant"
|
|
22
|
+
log << assistant_message(message)
|
|
23
|
+
in "tool"
|
|
24
|
+
log.tool(message["content"].to_s, tool_call_id: message["toolCallId"])
|
|
25
|
+
in "system" | "developer"
|
|
26
|
+
log.system(message["content"].to_s)
|
|
27
|
+
in "activity" | "reasoning"
|
|
28
|
+
# Not part of the LLM conversation: activities render client-side
|
|
29
|
+
# (phase 4) and reasoning is provider-managed (phase 5).
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
log
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def assistant_message(message)
|
|
40
|
+
tool_calls = message["toolCalls"]&.map do |tc|
|
|
41
|
+
Brute::ToolCall.new(
|
|
42
|
+
id: tc["id"],
|
|
43
|
+
name: tc.dig("function", "name"),
|
|
44
|
+
arguments: parse_arguments(tc.dig("function", "arguments")),
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Brute::Message.new(
|
|
49
|
+
role: :assistant,
|
|
50
|
+
content: message["content"],
|
|
51
|
+
tool_calls: tool_calls,
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wire arguments are a JSON-encoded string (OpenAI shape);
|
|
56
|
+
# Brute::ToolCall wants the parsed Hash.
|
|
57
|
+
def parse_arguments(arguments)
|
|
58
|
+
case arguments
|
|
59
|
+
when nil then {}
|
|
60
|
+
when Hash then arguments
|
|
61
|
+
else
|
|
62
|
+
begin
|
|
63
|
+
JSON.parse(arguments.to_s)
|
|
64
|
+
rescue JSON::ParserError
|
|
65
|
+
{}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Multimodal content arrives as an array of InputContent parts.
|
|
71
|
+
# All-text arrays flatten to a plain string (pipeline middleware
|
|
72
|
+
# reads string content); arrays carrying media parts stay raw —
|
|
73
|
+
# the terminal maps them to its provider's attachment API.
|
|
74
|
+
def text_content(content)
|
|
75
|
+
case content
|
|
76
|
+
when Array
|
|
77
|
+
if content.all? { |part| part["type"] == "text" }
|
|
78
|
+
content.map { |part| part["text"] }.join("\n")
|
|
79
|
+
else
|
|
80
|
+
content
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
content.to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
__END__
|
|
91
|
+
|
|
92
|
+
describe "AgUi::Messages.to_brute" do
|
|
93
|
+
it "translates the core roles" do
|
|
94
|
+
log = AgUi::Messages.to_brute([
|
|
95
|
+
{ "id" => "s1", "role" => "system", "content" => "be helpful" },
|
|
96
|
+
{ "id" => "u1", "role" => "user", "content" => "hi" },
|
|
97
|
+
{ "id" => "a1", "role" => "assistant", "content" => "hello!" },
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
log.map(&:role).should == [:system, :user, :assistant]
|
|
101
|
+
log.last.content.should == "hello!"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "round-trips assistant toolCalls with parsed JSON arguments" do
|
|
105
|
+
log = AgUi::Messages.to_brute([
|
|
106
|
+
{ "id" => "a1", "role" => "assistant", "content" => nil,
|
|
107
|
+
"toolCalls" => [{ "id" => "tc1", "type" => "function",
|
|
108
|
+
"function" => { "name" => "navigate", "arguments" => "{\"path\":\"/data\"}" } }] },
|
|
109
|
+
{ "id" => "t1", "role" => "tool", "content" => "{\"ok\":true}", "toolCallId" => "tc1" },
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
assistant = log.first
|
|
113
|
+
assistant.tool_call?.should.be.true
|
|
114
|
+
assistant.tool_calls.first.id.should == "tc1"
|
|
115
|
+
assistant.tool_calls.first.name.should == "navigate"
|
|
116
|
+
assistant.tool_calls.first.arguments.should == { "path" => "/data" }
|
|
117
|
+
|
|
118
|
+
log.last.role.should == :tool
|
|
119
|
+
log.last.tool_call_id.should == "tc1"
|
|
120
|
+
log.last.content.should == "{\"ok\":true}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "tolerates malformed tool-call argument JSON" do
|
|
124
|
+
log = AgUi::Messages.to_brute([
|
|
125
|
+
{ "id" => "a1", "role" => "assistant",
|
|
126
|
+
"toolCalls" => [{ "id" => "tc1", "type" => "function",
|
|
127
|
+
"function" => { "name" => "x", "arguments" => "{nope" } }] },
|
|
128
|
+
])
|
|
129
|
+
log.first.tool_calls.first.arguments.should == {}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "flattens all-text part arrays and preserves arrays carrying media" do
|
|
133
|
+
text_only = AgUi::Messages.to_brute([
|
|
134
|
+
{ "id" => "u1", "role" => "user", "content" => [
|
|
135
|
+
{ "type" => "text", "text" => "hello" },
|
|
136
|
+
{ "type" => "text", "text" => "there" },
|
|
137
|
+
] },
|
|
138
|
+
])
|
|
139
|
+
text_only.first.content.should == "hello\nthere"
|
|
140
|
+
|
|
141
|
+
multimodal = AgUi::Messages.to_brute([
|
|
142
|
+
{ "id" => "u1", "role" => "user", "content" => [
|
|
143
|
+
{ "type" => "text", "text" => "what is this?" },
|
|
144
|
+
{ "type" => "image", "source" => { "type" => "url", "value" => "http://x/y.png" } },
|
|
145
|
+
] },
|
|
146
|
+
])
|
|
147
|
+
multimodal.first.content.should.be.kind_of(Array)
|
|
148
|
+
multimodal.first.content.last["type"].should == "image"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "skips activity and reasoning messages" do
|
|
152
|
+
log = AgUi::Messages.to_brute([
|
|
153
|
+
{ "id" => "act1", "role" => "activity", "activityType" => "a2ui-surface", "content" => {} },
|
|
154
|
+
{ "id" => "r1", "role" => "reasoning", "content" => "thinking..." },
|
|
155
|
+
{ "id" => "u1", "role" => "user", "content" => "hi" },
|
|
156
|
+
])
|
|
157
|
+
log.map(&:role).should == [:user]
|
|
158
|
+
end
|
|
159
|
+
end
|