poetry-agent 0.0.2

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. metadata +117 -0
@@ -0,0 +1,540 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module AGUI
8
+ # Folds an AG-UI event stream into what a chat page renders: the
9
+ # messages in order, each assistant message as Chat-shaped parts
10
+ # (`{kind: :text, text:}`, `{kind: :reasoning, text:}`,
11
+ # `{kind: :tool, name:, input:, output:, state:, tool_call_id:}`),
12
+ # the shared state (snapshots and JSON Patch deltas), activities,
13
+ # the run's status, its interrupts, and the tool calls the browser
14
+ # must execute before the next run.
15
+ #
16
+ # Every change bumps {#version}, and {#apply} answers the ids of the
17
+ # messages it touched, so a relay re-renders exactly those rows with
18
+ # a monotonic version the page's versioned replace honors.
19
+ class Transcript
20
+ # One message. `role` is the protocol's ("user", "assistant",
21
+ # "tool", "activity", ...); `parts` is the render-ready list.
22
+ Message = Struct.new(:id, :role, :parts, :version, keyword_init: true)
23
+
24
+ # The messages in arrival order.
25
+ #
26
+ # @return [Array<Message>]
27
+ attr_reader :messages
28
+
29
+ # The shared state after the last snapshot / delta.
30
+ #
31
+ # @return [Hash]
32
+ attr_reader :state
33
+
34
+ # Activities by message id: `{ "type" => ..., "content" => ... }`.
35
+ #
36
+ # @return [Hash{String => Hash}]
37
+ attr_reader :activities
38
+
39
+ # The run: `{ thread_id:, run_id:, status:, interrupts:, error:, result: }`;
40
+ # status is :idle, :running, :finished, :interrupted, or :error.
41
+ #
42
+ # @return [Hash]
43
+ attr_reader :run
44
+
45
+ # The frontend-defined tool names the browser executes.
46
+ #
47
+ # @return [Array<String>]
48
+ attr_reader :client_tools
49
+
50
+ # Tool calls to client tools awaiting execution:
51
+ # `{ tool_call_id:, name:, input:, message_id: }`.
52
+ #
53
+ # @return [Array<Hash>]
54
+ attr_reader :pending_client_tools
55
+
56
+ # RAW and CUSTOM events, in order.
57
+ #
58
+ # @return [Array<Hash>]
59
+ attr_reader :custom_events
60
+
61
+ # Event types this transcript did not understand.
62
+ #
63
+ # @return [Array<String>]
64
+ attr_reader :unknown_events
65
+
66
+ # A monotonic clock over every applied change.
67
+ #
68
+ # @return [Integer]
69
+ attr_reader :version
70
+
71
+ # @param client_tools [Array<String>] names of tools the browser executes
72
+ def initialize(client_tools: [])
73
+ @client_tools = client_tools.map(&:to_s)
74
+ @messages = []
75
+ @state = {}
76
+ @activities = {}
77
+ @run = { status: :idle, interrupts: [] }
78
+ @pending_client_tools = []
79
+ @custom_events = []
80
+ @unknown_events = []
81
+ @steps = []
82
+ @version = 0
83
+ @open_text = {}
84
+ @tool_parts = {}
85
+ @tool_args = {}
86
+ @current_message_id = nil
87
+ end
88
+
89
+ # Applies one event.
90
+ #
91
+ # @param event [Hash] an AG-UI event (camelCase or snake_case keys)
92
+ # @return [Array<String>] the ids of the messages this event changed
93
+ def apply(event)
94
+ type = AGUI.field(event, "type").to_s
95
+ handler = HANDLERS[type]
96
+ unless handler
97
+ @unknown_events << type
98
+ return []
99
+ end
100
+
101
+ Array(send(handler, event)).compact
102
+ end
103
+
104
+ # Applies every event of a stream.
105
+ #
106
+ # @param events [#each] event hashes
107
+ # @return [self]
108
+ def apply_all(events)
109
+ events.each { |event| apply(event) }
110
+ self
111
+ end
112
+
113
+ # @param id [String]
114
+ # @return [Message, nil]
115
+ def message(id)
116
+ @messages.find { |message| message.id == id }
117
+ end
118
+
119
+ # The render-ready frame of one message.
120
+ #
121
+ # @param id [String]
122
+ # @return [Hash] `{ parts:, version: }`
123
+ def frame(id)
124
+ found = message(id)
125
+ found ? { parts: found.parts.map(&:dup), version: found.version } : { parts: [], version: 0 }
126
+ end
127
+
128
+ # @return [Boolean] the run ended (finished, interrupted, or errored)
129
+ def ended?
130
+ %i[finished interrupted error].include?(@run[:status])
131
+ end
132
+
133
+ # @return [Boolean]
134
+ def interrupted?
135
+ @run[:status] == :interrupted
136
+ end
137
+
138
+ # The open interrupts (string-keyed hashes as on the wire).
139
+ #
140
+ # @return [Array<Hash>]
141
+ def interrupts
142
+ @run[:interrupts]
143
+ end
144
+
145
+ # The run error, if any: `{ message:, code: }`.
146
+ #
147
+ # @return [Hash, nil]
148
+ def error
149
+ @run[:error]
150
+ end
151
+
152
+ # The messages as the next run's `RunAgentInput.messages`: user
153
+ # and assistant messages (assistant tool calls in the protocol's
154
+ # `toolCalls` shape) and a tool message for every finished tool
155
+ # call; reasoning and activities stay client-side, as the
156
+ # protocol says.
157
+ #
158
+ # @return [Array<Hash>]
159
+ def messages_for_input
160
+ @messages.flat_map do |message|
161
+ case message.role
162
+ when "user" then [{ "id" => message.id, "role" => "user", "content" => text_of(message) }]
163
+ when "assistant" then assistant_wire(message)
164
+ else []
165
+ end
166
+ end
167
+ end
168
+
169
+ # Marks a client tool call as executed and records its result, so
170
+ # the next run's input carries the tool message.
171
+ #
172
+ # @param tool_call_id [String]
173
+ # @param content [Object] the result (a string, or data serialized as JSON)
174
+ # @param error [String, nil]
175
+ # @return [String, nil] the id of the message that changed
176
+ def resolve_client_tool(tool_call_id, content, error: nil)
177
+ part = @tool_parts[tool_call_id]
178
+ @pending_client_tools.reject! { |pending| pending[:tool_call_id] == tool_call_id }
179
+ return nil unless part
180
+
181
+ part[:output] = error ? { "error" => error } : parse_json(content)
182
+ part[:state] = error ? :error : :done
183
+ touch(part[:message_id])
184
+ end
185
+
186
+ private
187
+
188
+ HANDLERS = {
189
+ "RUN_STARTED" => :run_started, "RUN_FINISHED" => :run_finished, "RUN_ERROR" => :run_error,
190
+ "STEP_STARTED" => :step, "STEP_FINISHED" => :step,
191
+ "TEXT_MESSAGE_START" => :text_start, "TEXT_MESSAGE_CONTENT" => :text_content,
192
+ "TEXT_MESSAGE_END" => :text_end, "TEXT_MESSAGE_CHUNK" => :text_chunk,
193
+ "REASONING_MESSAGE_START" => :reasoning_start, "REASONING_MESSAGE_CONTENT" => :reasoning_content,
194
+ "REASONING_MESSAGE_END" => :reasoning_end, "REASONING_MESSAGE_CHUNK" => :reasoning_chunk,
195
+ "THINKING_TEXT_MESSAGE_START" => :reasoning_start, "THINKING_TEXT_MESSAGE_CONTENT" => :reasoning_content,
196
+ "THINKING_TEXT_MESSAGE_END" => :reasoning_end,
197
+ "REASONING_START" => :noop, "REASONING_END" => :noop, "REASONING_ENCRYPTED_VALUE" => :noop,
198
+ "THINKING_START" => :noop, "THINKING_END" => :noop,
199
+ "TOOL_CALL_START" => :tool_start, "TOOL_CALL_ARGS" => :tool_args, "TOOL_CALL_END" => :tool_end,
200
+ "TOOL_CALL_CHUNK" => :tool_chunk, "TOOL_CALL_RESULT" => :tool_result,
201
+ "STATE_SNAPSHOT" => :state_snapshot, "STATE_DELTA" => :state_delta,
202
+ "MESSAGES_SNAPSHOT" => :messages_snapshot,
203
+ "ACTIVITY_SNAPSHOT" => :activity_snapshot, "ACTIVITY_DELTA" => :activity_delta,
204
+ "SUBAGENT_STARTED" => :noop, "SUBAGENT_FINISHED" => :noop, "SUBAGENT_ERROR" => :noop,
205
+ "RAW" => :custom, "CUSTOM" => :custom
206
+ }.freeze
207
+ private_constant :HANDLERS
208
+
209
+ def f(event, name) = AGUI.field(event, name)
210
+
211
+ def noop(_event) = []
212
+
213
+ def custom(event)
214
+ @custom_events << event
215
+ []
216
+ end
217
+
218
+ def step(event)
219
+ @steps << [f(event, "type"), f(event, "stepName")]
220
+ []
221
+ end
222
+
223
+ # --- run lifecycle ---
224
+
225
+ def run_started(event)
226
+ @run = { thread_id: f(event, "threadId"), run_id: f(event, "runId"), parent_run_id: f(event, "parentRunId"),
227
+ status: :running, interrupts: [], error: nil, result: nil }
228
+ @version += 1
229
+ []
230
+ end
231
+
232
+ def run_finished(event)
233
+ outcome = f(event, "outcome")
234
+ interrupts = if outcome.is_a?(Hash) && AGUI.field(outcome,
235
+ "type").to_s == "interrupt"
236
+ Array(AGUI.field(outcome,
237
+ "interrupts"))
238
+ else
239
+ []
240
+ end
241
+ @run[:interrupts] = interrupts.map { |interrupt| stringify(interrupt) }
242
+ @run[:result] = f(event, "result")
243
+ @run[:status] = interrupts.any? ? :interrupted : :finished
244
+ @version += 1
245
+ close_open_texts
246
+ end
247
+
248
+ def run_error(event)
249
+ @run[:status] = :error
250
+ @run[:error] = { message: f(event, "message").to_s, code: f(event, "code") }
251
+ @version += 1
252
+ close_open_texts
253
+ end
254
+
255
+ # --- text and reasoning ---
256
+
257
+ def text_start(event) = start_part(f(event, "messageId"), f(event, "role") || "assistant", :text)
258
+ def text_content(event) = append_part(f(event, "messageId"), :text, f(event, "delta"))
259
+ def text_end(event) = end_part(f(event, "messageId"), :text)
260
+ def reasoning_start(event) = start_part(f(event, "messageId"), "assistant", :reasoning)
261
+ def reasoning_content(event) = append_part(f(event, "messageId"), :reasoning, f(event, "delta"))
262
+ def reasoning_end(event) = end_part(f(event, "messageId"), :reasoning)
263
+
264
+ def text_chunk(event) = chunk(event, :text, f(event, "role") || "assistant")
265
+ def reasoning_chunk(event) = chunk(event, :reasoning, "assistant")
266
+
267
+ def chunk(event, kind, role)
268
+ id = f(event, "messageId") || @current_message_id
269
+ return [] unless id
270
+
271
+ changed = @open_text.key?([id, kind]) ? [] : start_part(id, role, kind)
272
+ delta = f(event, "delta")
273
+ changed |= append_part(id, kind, delta) if delta && !delta.empty?
274
+ changed
275
+ end
276
+
277
+ def start_part(id, role, kind)
278
+ return [] unless id
279
+
280
+ message = ensure_message(id, role)
281
+ part = { kind: kind, text: +"" }
282
+ message.parts << part
283
+ @open_text[[id, kind]] = part
284
+ @current_message_id = id
285
+ [touch(id)]
286
+ end
287
+
288
+ def append_part(id, kind, delta)
289
+ id ||= @current_message_id
290
+ return [] unless id && delta
291
+
292
+ part = @open_text[[id, kind]]
293
+ unless part
294
+ ensure_message(id, "assistant")
295
+ part = { kind: kind, text: +"" }
296
+ message(id).parts << part
297
+ @open_text[[id, kind]] = part
298
+ end
299
+ part[:text] = part[:text] + delta.to_s
300
+ [touch(id)]
301
+ end
302
+
303
+ def end_part(id, kind)
304
+ @open_text.delete([id || @current_message_id, kind])
305
+ []
306
+ end
307
+
308
+ # A run's end closes streaming text and settles chunked tool calls
309
+ # that never saw an END event.
310
+ def close_open_texts
311
+ @open_text.clear
312
+ @tool_args.each_key do |tool_call_id|
313
+ part = @tool_parts[tool_call_id]
314
+ next unless part && part[:input].nil?
315
+
316
+ part[:input] = parse_json(@tool_args[tool_call_id].to_s)
317
+ end
318
+ []
319
+ end
320
+
321
+ # --- tool calls ---
322
+
323
+ def tool_start(event)
324
+ tool_call_id = f(event, "toolCallId").to_s
325
+ name = f(event, "toolCallName").to_s
326
+ message_id = f(event, "parentMessageId") || @current_message_id || "message-#{tool_call_id}"
327
+ ensure_message(message_id, "assistant")
328
+ part = { kind: :tool, name: name, input: nil, output: nil, state: :loading,
329
+ tool_call_id: tool_call_id, message_id: message_id }
330
+ message(message_id).parts << part
331
+ @tool_parts[tool_call_id] = part
332
+ @tool_args[tool_call_id] = +""
333
+ @current_message_id = message_id
334
+ [touch(message_id)]
335
+ end
336
+
337
+ def tool_args(event)
338
+ tool_call_id = f(event, "toolCallId").to_s
339
+ part = @tool_parts[tool_call_id]
340
+ return [] unless part
341
+
342
+ @tool_args[tool_call_id] << f(event, "delta").to_s
343
+ [touch(part[:message_id])]
344
+ end
345
+
346
+ def tool_end(event)
347
+ tool_call_id = f(event, "toolCallId").to_s
348
+ part = @tool_parts[tool_call_id]
349
+ return [] unless part
350
+
351
+ part[:input] = parse_json(@tool_args.delete(tool_call_id).to_s)
352
+ if @client_tools.include?(part[:name])
353
+ part[:state] = :awaiting_client
354
+ @pending_client_tools << { tool_call_id: tool_call_id, name: part[:name], input: part[:input],
355
+ message_id: part[:message_id] }
356
+ end
357
+ [touch(part[:message_id])]
358
+ end
359
+
360
+ def tool_chunk(event)
361
+ tool_call_id = f(event, "toolCallId")
362
+ changed = []
363
+ changed |= tool_start(event) if tool_call_id && !@tool_parts.key?(tool_call_id.to_s)
364
+ delta = f(event, "delta")
365
+ id = (tool_call_id || @tool_parts.keys.last).to_s
366
+ changed |= tool_args({ "toolCallId" => id, "delta" => delta }) if delta
367
+ # A chunked call has no END event: its input is whatever has parsed so far.
368
+ part = @tool_parts[id]
369
+ if part && (parsed = parse_json(@tool_args[id].to_s)).is_a?(Hash)
370
+ part[:input] = parsed
371
+ end
372
+ changed
373
+ end
374
+
375
+ def tool_result(event)
376
+ tool_call_id = f(event, "toolCallId").to_s
377
+ part = @tool_parts[tool_call_id]
378
+ return [] unless part
379
+
380
+ part[:input] ||= parse_json(@tool_args.delete(tool_call_id).to_s) if @tool_args.key?(tool_call_id)
381
+ part[:output] = parse_json(f(event, "content"))
382
+ part[:state] = :done
383
+ @pending_client_tools.reject! { |pending| pending[:tool_call_id] == tool_call_id }
384
+ [touch(part[:message_id])]
385
+ end
386
+
387
+ # --- state, messages, activities ---
388
+
389
+ def state_snapshot(event)
390
+ @state = JsonPatch.deep_copy(f(event, "snapshot") || {})
391
+ @version += 1
392
+ []
393
+ end
394
+
395
+ def state_delta(event)
396
+ @state = JsonPatch.apply(@state, f(event, "delta") || [])
397
+ @version += 1
398
+ []
399
+ rescue JsonPatch::Error => e
400
+ @unknown_events << "STATE_DELTA(#{e.message})"
401
+ []
402
+ end
403
+
404
+ def messages_snapshot(event)
405
+ rebuilt = Array(f(event, "messages")).filter_map { |message| snapshot_message(stringify(message)) }
406
+ @messages = rebuilt
407
+ @open_text.clear
408
+ @version += 1
409
+ @messages.each { |message| message.version = @version }
410
+ @messages.map(&:id)
411
+ end
412
+
413
+ # One snapshot message as a Message - or nil for a tool result, which
414
+ # lands on its call's part instead.
415
+ def snapshot_message(wire)
416
+ id = wire["id"].to_s
417
+ case wire["role"].to_s
418
+ when "user", "system", "developer"
419
+ Message.new(id: id, role: wire["role"].to_s, parts: [{ kind: :text, text: wire["content"].to_s }],
420
+ version: 0)
421
+ when "assistant" then snapshot_assistant(wire)
422
+ when "tool" then snapshot_tool_result(wire)
423
+ when "activity"
424
+ part = { kind: :activity, activity_type: wire["activityType"], content: wire["content"] }
425
+ Message.new(id: id, role: "activity", parts: [part], version: 0)
426
+ when "reasoning"
427
+ Message.new(id: id, role: "assistant", parts: [{ kind: :reasoning, text: wire["content"].to_s }],
428
+ version: 0)
429
+ end
430
+ end
431
+
432
+ def snapshot_assistant(wire)
433
+ parts = []
434
+ parts << { kind: :text, text: wire["content"] } if wire["content"].is_a?(String) && !wire["content"].empty?
435
+ Array(wire["toolCalls"]).each do |call|
436
+ function = call["function"] || {}
437
+ part = { kind: :tool, name: function["name"].to_s, input: parse_json(function["arguments"]),
438
+ output: nil, state: :loading, tool_call_id: call["id"].to_s, message_id: wire["id"].to_s }
439
+ parts << part
440
+ @tool_parts[part[:tool_call_id]] = part
441
+ end
442
+ Message.new(id: wire["id"].to_s, role: "assistant", parts: parts, version: 0)
443
+ end
444
+
445
+ def snapshot_tool_result(wire)
446
+ part = @tool_parts[wire["toolCallId"].to_s]
447
+ return nil unless part
448
+
449
+ part[:output] = wire["error"] ? { "error" => wire["error"] } : parse_json(wire["content"])
450
+ part[:state] = wire["error"] ? :error : :done
451
+ nil
452
+ end
453
+
454
+ def activity_snapshot(event)
455
+ id = f(event, "messageId").to_s
456
+ replace = f(event, "replace")
457
+ return [] if replace == false && @activities.key?(id)
458
+
459
+ @activities[id] =
460
+ { "type" => f(event, "activityType"), "content" => JsonPatch.deep_copy(f(event, "content")) }
461
+ message = ensure_message(id, "activity")
462
+ message.parts = [{ kind: :activity, activity_type: @activities[id]["type"],
463
+ content: @activities[id]["content"] }]
464
+ [touch(id)]
465
+ end
466
+
467
+ def activity_delta(event)
468
+ id = f(event, "messageId").to_s
469
+ activity = @activities[id]
470
+ return [] unless activity
471
+
472
+ activity["content"] = JsonPatch.apply(activity["content"], f(event, "patch") || [])
473
+ message(id).parts = [{ kind: :activity, activity_type: activity["type"], content: activity["content"] }]
474
+ [touch(id)]
475
+ rescue JsonPatch::Error => e
476
+ @unknown_events << "ACTIVITY_DELTA(#{e.message})"
477
+ []
478
+ end
479
+
480
+ # --- helpers ---
481
+
482
+ def ensure_message(id, role)
483
+ id = id.to_s
484
+ message(id) || Message.new(id: id, role: role.to_s, parts: [], version: 0).tap do |created|
485
+ @messages << created
486
+ end
487
+ end
488
+
489
+ def touch(id)
490
+ @version += 1
491
+ found = message(id)
492
+ found.version = @version if found
493
+ id
494
+ end
495
+
496
+ def text_of(message)
497
+ message.parts.select { |part| part[:kind] == :text }.map { |part| part[:text] }.join
498
+ end
499
+
500
+ def assistant_wire(message)
501
+ wire = { "id" => message.id, "role" => "assistant", "content" => text_of(message) }
502
+ calls = message.parts.select { |part| part[:kind] == :tool }
503
+ if calls.any?
504
+ wire["toolCalls"] = calls.map do |part|
505
+ { "id" => part[:tool_call_id], "type" => "function",
506
+ "function" => { "name" => part[:name], "arguments" => JSON.generate(part[:input] || {}) } }
507
+ end
508
+ end
509
+ results = calls.reject { |part| part[:output].nil? }.map do |part|
510
+ error = part[:output].is_a?(Hash) ? part[:output]["error"] : nil
511
+ RunInput.tool_message(part[:tool_call_id], error || as_text(part[:output]),
512
+ error: error, id: "#{part[:tool_call_id]}-result")
513
+ end
514
+ [wire, *results]
515
+ end
516
+
517
+ def as_text(value)
518
+ value.is_a?(String) ? value : JSON.generate(value)
519
+ end
520
+
521
+ def parse_json(text)
522
+ return text unless text.is_a?(String)
523
+ return text if text.empty?
524
+
525
+ JSON.parse(text)
526
+ rescue JSON::ParserError
527
+ text
528
+ end
529
+
530
+ def stringify(value)
531
+ case value
532
+ when Hash then value.to_h { |key, inner| [key.to_s, stringify(inner)] }
533
+ when Array then value.map { |inner| stringify(inner) }
534
+ else value
535
+ end
536
+ end
537
+ end
538
+ end
539
+ end
540
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module AGUI
8
+ # Turbo Stream builders for the relay: plain strings, no view
9
+ # context needed. `vreplace` is the versioned replace the runtime
10
+ # installs on Turbo (`registerPoetryAgent`) - it applies a frame
11
+ # only when its `data-version` is newer than the row's, so an
12
+ # out-of-order delivery can never paint an older state over a
13
+ # newer one.
14
+ module TurboStream
15
+ module_function
16
+
17
+ # @param action [String] a Turbo Stream action (`append`, `replace`, `vreplace`, `remove`, ...)
18
+ # @param target [String] the target element id
19
+ # @param html [String, nil] the template content (already rendered, trusted)
20
+ # @param method [String, nil] Turbo's `method` attribute (`"morph"` morphs instead of swapping)
21
+ # @return [String]
22
+ def build(action, target, html = nil, method: nil)
23
+ attributes = %(action="#{escape(action)}" target="#{escape(target)}")
24
+ attributes += %( method="#{escape(method)}") if method
25
+ open = "<turbo-stream #{attributes}>"
26
+ return "#{open}</turbo-stream>" if html.nil?
27
+
28
+ "#{open}<template>#{html}</template></turbo-stream>"
29
+ end
30
+
31
+ # @param target [String]
32
+ # @param html [String]
33
+ # @param morph [Boolean] morph the target (Turbo's idiomorph) instead of swapping it, so
34
+ # local state - typed text, a selected tab, an open dialog - survives the update
35
+ # @return [String]
36
+ def vreplace(target, html, morph: false) = build("vreplace", target, html, method: morph ? "morph" : nil)
37
+
38
+ # @param target [String]
39
+ # @param html [String]
40
+ # @return [String]
41
+ def append(target, html) = build("append", target, html)
42
+
43
+ # @param target [String]
44
+ # @param html [String]
45
+ # @return [String]
46
+ def replace(target, html) = build("replace", target, html)
47
+
48
+ # @param target [String]
49
+ # @return [String]
50
+ def remove(target) = build("remove", target)
51
+
52
+ # One SSE frame carrying the streams (newlines folded, as Turbo's
53
+ # stream source expects one `data:` line).
54
+ #
55
+ # @param html [String]
56
+ # @return [String]
57
+ def sse(html)
58
+ "data: #{html.tr("\n", " ")}\n\n"
59
+ end
60
+
61
+ # @api private
62
+ def escape(value)
63
+ CGI.escapeHTML(value.to_s)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agui/json_patch"
4
+ require_relative "agui/sse"
5
+ require_relative "agui/run_input"
6
+ require_relative "agui/client"
7
+ require_relative "agui/transcript"
8
+ require_relative "agui/turbo_stream"
9
+ require_relative "agui/relay"
10
+
11
+ module Poetry
12
+ module Agent
13
+ # The AG-UI surface: a Rails-side CLIENT of the Agent-User Interaction
14
+ # protocol. An agent backend (any AG-UI integration, or a Ruby server)
15
+ # streams events - text deltas, tool calls, state, activities, run
16
+ # lifecycle, interrupts - and this module turns that stream into
17
+ # server-rendered chat frames a Hotwire page updates through Turbo
18
+ # Streams, the same pipeline the chat replay rig proves.
19
+ #
20
+ # The pieces, each usable alone:
21
+ #
22
+ # - {SSE} parses `text/event-stream` chunks into event hashes.
23
+ # - {Client} POSTs a run to an AG-UI endpoint and yields its events.
24
+ # - {RunInput} builds the `RunAgentInput` wire hash, and
25
+ # {.tool_descriptor} advertises a rendered component's declared
26
+ # tools as frontend-defined tools the browser executes.
27
+ # - {Transcript} folds events into messages (Chat-shaped parts),
28
+ # shared state (JSON Patch), activities, the run status, pending
29
+ # client tools, and interrupts.
30
+ # - {Relay} renders each change as a versioned Turbo Stream through a
31
+ # host-supplied row renderer, plus the client-tool bridge element
32
+ # the `poetry--agent--agui-client-tool` controller executes.
33
+ #
34
+ # Nothing here calls a model: the agent is whatever the host points
35
+ # the client at.
36
+ module AGUI
37
+ # The AG-UI event types this transcript understands (the wire
38
+ # strings; deprecated THINKING_* aliases included).
39
+ EVENT_TYPES = %w[
40
+ RUN_STARTED RUN_FINISHED RUN_ERROR STEP_STARTED STEP_FINISHED
41
+ TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END TEXT_MESSAGE_CHUNK
42
+ REASONING_START REASONING_MESSAGE_START REASONING_MESSAGE_CONTENT REASONING_MESSAGE_END
43
+ REASONING_MESSAGE_CHUNK REASONING_END REASONING_ENCRYPTED_VALUE
44
+ THINKING_START THINKING_TEXT_MESSAGE_START THINKING_TEXT_MESSAGE_CONTENT THINKING_TEXT_MESSAGE_END THINKING_END
45
+ TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_CHUNK TOOL_CALL_RESULT
46
+ STATE_SNAPSHOT STATE_DELTA MESSAGES_SNAPSHOT ACTIVITY_SNAPSHOT ACTIVITY_DELTA
47
+ SUBAGENT_STARTED SUBAGENT_FINISHED SUBAGENT_ERROR RAW CUSTOM
48
+ ].freeze
49
+
50
+ # The frontend-defined tool descriptor for one of a rendered
51
+ # component's declared tools: the MCP `Tool` shape the registry
52
+ # projects, renamed to AG-UI's `parameters` and prefixed with the
53
+ # instance name exactly as the WebMCP registrar registers it, so a
54
+ # call the agent makes is executable in the browser by name.
55
+ #
56
+ # @param instance [String] the `webmcp:` instance name
57
+ # @param definition [Hash] one entry of `Component#webmcp_tools`
58
+ # @return [Hash] `{ "name", "description", "parameters" }`
59
+ # @example
60
+ # Poetry::Agent::AGUI.tool_descriptor("sections", tabs.webmcp_tools.first)
61
+ # # => { "name" => "poetry.sections.set_value", "description" => "...", "parameters" => {...} }
62
+ def self.tool_descriptor(instance, definition)
63
+ {
64
+ "name" => "poetry.#{instance}.#{definition["name"]}",
65
+ "description" => definition["description"],
66
+ "parameters" => definition["inputSchema"] || { "type" => "object", "properties" => {} }
67
+ }
68
+ end
69
+
70
+ # Reads a wire field from an event or message that may arrive
71
+ # camelCased (the protocol) or snake_cased (a Ruby producer).
72
+ #
73
+ # @param hash [Hash]
74
+ # @param name [String] the camelCase name
75
+ # @return [Object, nil]
76
+ def self.field(hash, name)
77
+ return nil unless hash.is_a?(Hash)
78
+
79
+ snake = name.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
80
+ [name, snake, name.to_sym, snake.to_sym].each do |key|
81
+ return hash[key] if hash.key?(key)
82
+ end
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end