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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- 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
|