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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module AGUI
8
+ # Turns transcript changes into Turbo Streams. The host supplies the
9
+ # row renderer (its own partial or component: a message and its
10
+ # version in, the row's HTML out - the row must carry
11
+ # `data-version`), the target id scheme, and the container new rows
12
+ # append to. The relay stays view-free.
13
+ #
14
+ # Client tools ride the same channel: when a run ends with tool
15
+ # calls the browser must execute, {#client_tool_streams} appends
16
+ # one bridge element per call; the `poetry--agent--agui-client-tool`
17
+ # controller executes it through the registrar and POSTs the result
18
+ # to the continue URL, whose response streams the next run.
19
+ #
20
+ # @example Streaming a run into a page
21
+ # relay = Poetry::Agent::AGUI::Relay.new(transcript: transcript, container: "chat-messages",
22
+ # render: ->(message, version) { render_row(message, version) })
23
+ # client.run(input) { |event| relay.apply(event).each { |stream| write(TurboStream.sse(stream)) } }
24
+ class Relay
25
+ # The bridge controller's identifier.
26
+ CLIENT_TOOL_CONTROLLER = "poetry--agent--agui-client-tool"
27
+
28
+ # @return [Transcript]
29
+ attr_reader :transcript
30
+
31
+ # @param transcript [Transcript]
32
+ # @param render [#call] `(message, version) -> String` the row HTML
33
+ # @param container [String, nil] the id new rows append to (nil: replace only)
34
+ # @param target [#call] `(message) -> String` the row's element id
35
+ # @param action [String] the stream action for updates (`vreplace` by default)
36
+ # @param append_render [#call, nil] `(message, version) -> String` the HTML a
37
+ # first appearance appends - the row inside its list wrapper (a scroller
38
+ # item); defaults to `render`
39
+ def initialize(transcript:, render:, container: nil, target: ->(message) { "row-#{message.id}" }, # rubocop:disable Metrics/ParameterLists
40
+ action: "vreplace", append_render: nil, morph: false)
41
+ @transcript = transcript
42
+ @render = render
43
+ @append_render = append_render || render
44
+ @container = container
45
+ @target = target
46
+ @action = action
47
+ @morph = morph
48
+ @seen = {}
49
+ end
50
+
51
+ # Applies an event and answers the Turbo Streams it produced: an
52
+ # append for a message's first appearance (when a container is
53
+ # set), then the update action for every change.
54
+ #
55
+ # @param event [Hash]
56
+ # @return [Array<String>]
57
+ def apply(event)
58
+ @transcript.apply(event).filter_map { |id| stream_for(id) }
59
+ end
60
+
61
+ # Marks message ids the page already renders, so their next change
62
+ # is an update rather than an append (server-rendered history).
63
+ #
64
+ # @param ids [Array<String>]
65
+ # @return [self]
66
+ def mark_seen(*ids)
67
+ ids.flatten.each { |id| @seen[id.to_s] = true }
68
+ self
69
+ end
70
+
71
+ # The stream for one message id (nil when the message is unknown).
72
+ #
73
+ # @param id [String]
74
+ # @return [String, nil]
75
+ def stream_for(id)
76
+ message = @transcript.message(id)
77
+ return nil unless message
78
+
79
+ first = !@seen[id]
80
+ @seen[id] = true
81
+ return TurboStream.append(@container, @append_render.call(message, message.version)) if @container && first
82
+
83
+ TurboStream.build(@action, @target.call(message), @render.call(message, message.version),
84
+ method: @morph ? "morph" : nil)
85
+ end
86
+
87
+ # Bridge elements for every pending client tool call.
88
+ #
89
+ # @param continue_url [String] where the browser POSTs `{ toolCallId, name, content, error }`
90
+ # @param container [String] the element the bridge elements append to
91
+ # @return [Array<String>]
92
+ def client_tool_streams(continue_url:, container: @container)
93
+ @transcript.pending_client_tools.map do |pending|
94
+ call = { "toolCallId" => pending[:tool_call_id], "name" => pending[:name], "args" => pending[:input] || {} }
95
+ id = TurboStream.escape(pending[:tool_call_id])
96
+ element = "<div id=\"agui-client-tool-#{id}\" hidden data-controller=\"#{CLIENT_TOOL_CONTROLLER}\" " \
97
+ "data-#{CLIENT_TOOL_CONTROLLER}-call-value=\"#{TurboStream.escape(JSON.generate(call))}\" " \
98
+ "data-#{CLIENT_TOOL_CONTROLLER}-url-value=\"#{TurboStream.escape(continue_url)}\"></div>"
99
+ TurboStream.append(container, element)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module AGUI
8
+ # Builds the `RunAgentInput` wire hash an AG-UI agent accepts:
9
+ # camelCased keys, messages in the protocol's shapes, the
10
+ # frontend-defined tools, context entries, state, forwarded props,
11
+ # and the resume entries that answer interrupts.
12
+ module RunInput
13
+ module_function
14
+
15
+ # @param thread_id [String] the conversation thread
16
+ # @param messages [Array<Hash>] protocol messages (`id`, `role`, `content`, ...)
17
+ # @param run_id [String] defaults to a fresh UUID
18
+ # @param tools [Array<Hash>] frontend-defined tools ({AGUI.tool_descriptor})
19
+ # @param context [Array<Hash>] `{ "description", "value" }` entries
20
+ # @param state [Hash] the shared state to send
21
+ # @param forwarded_props [Hash] integration-specific props
22
+ # @param parent_run_id [String, nil] the run this one branches from
23
+ # @param resume [Array<Hash>, nil] `{ "interruptId", "status", "payload" }` answers
24
+ # @return [Hash] the wire hash (string keys)
25
+ # @example A first run with one client tool
26
+ # RunInput.build(thread_id: "t1", messages: [RunInput.user_message("hi")],
27
+ # tools: [Poetry::Agent::AGUI.tool_descriptor("sections", definition)])
28
+ def build( # rubocop:disable Metrics/ParameterLists -- one keyword per RunAgentInput field
29
+ thread_id:, messages:, run_id: SecureRandom.uuid, tools: [], context: [], state: {},
30
+ forwarded_props: {}, parent_run_id: nil, resume: nil
31
+ )
32
+ input = {
33
+ "threadId" => thread_id,
34
+ "runId" => run_id,
35
+ "state" => state,
36
+ "messages" => messages,
37
+ "tools" => tools,
38
+ "context" => context,
39
+ "forwardedProps" => forwarded_props
40
+ }
41
+ input["parentRunId"] = parent_run_id if parent_run_id
42
+ input["resume"] = resume if resume
43
+ input
44
+ end
45
+
46
+ # A user message.
47
+ #
48
+ # @param content [String]
49
+ # @param id [String]
50
+ # @return [Hash]
51
+ def user_message(content, id: SecureRandom.uuid)
52
+ { "id" => id, "role" => "user", "content" => content }
53
+ end
54
+
55
+ # A tool-result message answering a tool call the browser ran.
56
+ #
57
+ # @param tool_call_id [String]
58
+ # @param content [String] the result as text (JSON for structured results)
59
+ # @param error [String, nil] set when the tool failed
60
+ # @param id [String]
61
+ # @return [Hash]
62
+ def tool_message(tool_call_id, content, error: nil, id: SecureRandom.uuid)
63
+ message = { "id" => id, "role" => "tool", "content" => content.to_s, "toolCallId" => tool_call_id }
64
+ message["error"] = error if error
65
+ message
66
+ end
67
+
68
+ # A resume entry answering an interrupt.
69
+ #
70
+ # @param interrupt_id [String]
71
+ # @param status [String, nil] e.g. "approved", "rejected"
72
+ # @param payload [Object, nil]
73
+ # @return [Hash]
74
+ def resume_entry(interrupt_id, status: nil, payload: nil)
75
+ entry = { "interruptId" => interrupt_id }
76
+ entry["status"] = status if status
77
+ entry["payload"] = payload unless payload.nil?
78
+ entry
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Agent
7
+ module AGUI
8
+ # A `text/event-stream` parser for AG-UI: every event is a JSON
9
+ # object on one or more `data:` lines, terminated by a blank line.
10
+ # Incremental (feed chunks as they arrive) and tolerant of comments,
11
+ # `event:` / `id:` / `retry:` fields, and CRLF.
12
+ module SSE
13
+ # The incremental parser.
14
+ class Parser
15
+ # Lines that carried data the parser could not read as JSON.
16
+ #
17
+ # @return [Array<String>]
18
+ attr_reader :errors
19
+
20
+ def initialize
21
+ @buffer = +""
22
+ @data = []
23
+ @errors = []
24
+ end
25
+
26
+ # Feeds a chunk and yields each completed event.
27
+ #
28
+ # @param chunk [String]
29
+ # @yieldparam event [Hash] the parsed JSON object (string keys)
30
+ # @return [void]
31
+ def feed(chunk, &)
32
+ @buffer << chunk
33
+ while (newline = @buffer.index("\n"))
34
+ line = @buffer.slice!(0..newline).chomp
35
+ consume(line, &)
36
+ end
37
+ end
38
+
39
+ # Flushes a trailing event that lacked its blank line.
40
+ #
41
+ # @yieldparam event [Hash]
42
+ # @return [void]
43
+ def finish(&)
44
+ consume(@buffer.chomp, &) unless @buffer.empty?
45
+ @buffer = +""
46
+ dispatch(&)
47
+ end
48
+
49
+ private
50
+
51
+ def consume(line, &)
52
+ if line.empty?
53
+ dispatch(&)
54
+ elsif line.start_with?("data:")
55
+ @data << line.delete_prefix("data:").delete_prefix(" ")
56
+ end
57
+ # Comments (":"), event:, id:, retry: carry nothing AG-UI reads.
58
+ end
59
+
60
+ def dispatch
61
+ return if @data.empty?
62
+
63
+ payload = @data.join("\n")
64
+ @data = []
65
+ event = JSON.parse(payload)
66
+ yield event if event.is_a?(Hash) && block_given?
67
+ rescue JSON::ParserError
68
+ @errors << payload
69
+ end
70
+ end
71
+
72
+ module_function
73
+
74
+ # Parses a complete stream (a String or anything responding to
75
+ # `each` with chunks) and yields every event.
76
+ #
77
+ # @param source [String, #each]
78
+ # @yieldparam event [Hash]
79
+ # @return [Array<Hash>] every event, when no block is given
80
+ # @example
81
+ # Poetry::Agent::AGUI::SSE.parse("data: {\"type\":\"RUN_STARTED\"}\n\n") # => [{ "type" => "RUN_STARTED" }]
82
+ def parse(source, &block)
83
+ events = []
84
+ collector = block || ->(event) { events << event }
85
+ parser = Parser.new
86
+ if source.is_a?(String)
87
+ parser.feed(source, &collector)
88
+ else
89
+ source.each { |chunk| parser.feed(chunk.to_s, &collector) }
90
+ end
91
+ parser.finish(&collector)
92
+ block ? nil : events
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end