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,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