ask-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/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +125 -0
- data/lib/ask/ag_ui/emitter.rb +334 -0
- data/lib/ask/ag_ui/run.rb +226 -0
- data/lib/ask/ag_ui/run_store.rb +106 -0
- data/lib/ask/ag_ui/server.rb +244 -0
- data/lib/ask/ag_ui/version.rb +8 -0
- data/lib/ask/ag_ui.rb +20 -0
- data/lib/ask-ag-ui.rb +7 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a496b953fbbbf38bde6bfb2cc1771fc67d8b47a8ed6789ef453e18b8a7d68161
|
|
4
|
+
data.tar.gz: 94edd256f17e7b5de58a3de085d2d857adc06ec35bb58e83d1e9f92feac457bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 711674f104ef94ed5c9e44293781b74970beaf08222cec1e42865bf304b70e8a94ce80849c0f1884f5d4be254d841dfb85547d610d15892d23c61f17bbe76496
|
|
7
|
+
data.tar.gz: cafe0507a38288621887eb3d31cb663676326d4da15814fd4716cd16fd2c045d9587347ed97c421cb71ecf979a6add14775b5bdc52a34f829f5ddca3f1a923af
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to ask-ag-ui are documented here, following
|
|
4
|
+
the keep-a-changelog format.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.0] — 2026-09-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Ask::AGUI::Emitter` — the seam that turns ask-rb agent/session events
|
|
13
|
+
into AG-UI protocol frames. Given the run context (thread id, run id,
|
|
14
|
+
input messages) and a stream of duck-typed agent events, it emits
|
|
15
|
+
`RUN_STARTED`, the text/reasoning/tool-call chains, `RUN_FINISHED` /
|
|
16
|
+
`RUN_ERROR`, and a generic `CUSTOM` passthrough for app-defined events.
|
|
17
|
+
All frames are built with `AgUiProtocol::Core::Events::*` and encoded
|
|
18
|
+
with `AgUiProtocol::Encoder::EventEncoder`. Adds the `ag-ui-protocol`
|
|
19
|
+
runtime dependency.
|
|
20
|
+
- `Ask::AGUI::Server` — the mountable Rack surface AG-UI clients expect:
|
|
21
|
+
`GET /info` (agents map with name, class, and capability information
|
|
22
|
+
built from ag-ui-protocol's capability and identity types, plus the
|
|
23
|
+
transport mode), `POST /agent/:id/run` (parses the run input, drives one
|
|
24
|
+
`Emitter` for the run, streams its SSE frames as the host block's agent
|
|
25
|
+
events arrive; malformed input answers 400 JSON),
|
|
26
|
+
`POST /agent/:id/connect` (replays recorded frames, completing
|
|
27
|
+
immediately when there is nothing to replay), and
|
|
28
|
+
`POST /agent/:id/stop/:thread_id` (JSON acknowledgement with cooperative
|
|
29
|
+
cancel). The host supplies the work as a block; the gem owns the socket
|
|
30
|
+
and the framing, with no Rails or ask-agent dependency. Plain Rack 3 and
|
|
31
|
+
plain Ruby — an enumerable body, no async or falcon requirement.
|
|
32
|
+
- `Ask::AGUI::Run` — the parsed run input handed to the host block
|
|
33
|
+
(thread id, run id, messages, tools, context, forwarded props, state),
|
|
34
|
+
with best-effort coercion to `AgUiProtocol::Core::Types`.
|
|
35
|
+
- `Ask::AGUI::RunStore` — the run-store interface plus the in-memory
|
|
36
|
+
implementation backing replay and stop. In-memory frames live in one
|
|
37
|
+
process only; back replay and stop with a shared store past one process.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# ask-ag-ui
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/ask-ag-ui)
|
|
4
|
+
|
|
5
|
+
The AG-UI (Agent-User Interaction) protocol server for the ask-rb ecosystem — SSE event streaming and the runtime surface that assistant-ui and CopilotKit frontends expect.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "ask-ag-ui"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "ask-ag-ui"
|
|
17
|
+
|
|
18
|
+
Ask::AGUI::VERSION # => "0.1.0"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The seam: `Ask::AGUI::Emitter`
|
|
22
|
+
|
|
23
|
+
The emitter turns ask-rb agent/session events into AG-UI protocol frames.
|
|
24
|
+
A transport drives one emitter per run: it hands over the AG-UI run context,
|
|
25
|
+
feeds it agent events, and writes the SSE frames it answers to the stream.
|
|
26
|
+
The transport owns the socket — the emitter only translates and encodes.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "ask-ag-ui"
|
|
30
|
+
|
|
31
|
+
messages = [AgUiProtocol::Core::Types::UserMessage.new(id: "u1", content: "Hi")]
|
|
32
|
+
emitter = Ask::AGUI::Emitter.new(thread_id: "t1", run_id: "r1", messages: messages)
|
|
33
|
+
|
|
34
|
+
emitter.handle(turn_start_event).each { |frame| stream.write(frame) }
|
|
35
|
+
# => ["data: {\"type\":\"RUN_STARTED\",...}\n\n", ...]
|
|
36
|
+
|
|
37
|
+
emitter.finish.each { |frame| stream.write(frame) }
|
|
38
|
+
# => ["data: {\"type\":\"RUN_FINISHED\",...}\n\n"]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The vocabulary, matched by class name so the emitter never depends on
|
|
42
|
+
ask-agent internals:
|
|
43
|
+
|
|
44
|
+
| Agent event | AG-UI events |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `TurnStart` | `RUN_STARTED` |
|
|
47
|
+
| `TextDelta` | `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` → `TEXT_MESSAGE_END` (only empty deltas dropped — a space is content) |
|
|
48
|
+
| `ThinkingDelta` | `REASONING_START` → `REASONING_MESSAGE_START` → `REASONING_MESSAGE_CONTENT` → `REASONING_MESSAGE_END` → `REASONING_END` (empty deltas dropped) |
|
|
49
|
+
| `ToolCallDelta`, `ToolExecutionStart`, `ToolExecutionEnd` | `TOOL_CALL_START` → `TOOL_CALL_ARGS` → `TOOL_CALL_END` → `TOOL_CALL_RESULT` (empty args deltas dropped) |
|
|
50
|
+
| `SessionEnd` / `#finish` | `RUN_FINISHED` |
|
|
51
|
+
| `Error` / `#fail` | `RUN_ERROR` |
|
|
52
|
+
| anything else | one generic `CUSTOM` passthrough (`name` + `value`) |
|
|
53
|
+
|
|
54
|
+
Every frame is built with `AgUiProtocol::Core::Events::*` and encoded with
|
|
55
|
+
`AgUiProtocol::Encoder::EventEncoder` — event JSON is never hand-rolled.
|
|
56
|
+
|
|
57
|
+
## Mounting: `Ask::AGUI::Server`
|
|
58
|
+
|
|
59
|
+
The server is the conventional Rack surface AG-UI clients expect. The host
|
|
60
|
+
owns the agent and answers agent events; the gem owns the socket, the
|
|
61
|
+
framing, and one `Emitter` per run. Plain Rack 3 with an enumerable body —
|
|
62
|
+
it runs under any Rack server, with no Rails, ask-agent, or async
|
|
63
|
+
dependency.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
require "ask-ag-ui"
|
|
67
|
+
|
|
68
|
+
app = Ask::AGUI::Server.new(agent_id: "default") do |run|
|
|
69
|
+
# run.thread_id, run.run_id, run.messages, run.tools,
|
|
70
|
+
# run.context, run.forwarded_props — answer agent events:
|
|
71
|
+
[TurnStart.new, TextDelta.new(content: "Hello")]
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Rackup:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config.ru
|
|
79
|
+
require "ask-ag-ui"
|
|
80
|
+
|
|
81
|
+
run Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Rails:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# config/routes.rb
|
|
88
|
+
mount Ask::AGUI::Server.new(agent_id: "default") { |run| MyAgent.events_for(run) },
|
|
89
|
+
at: "/api/copilotkit"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Routes: `GET /info`, `POST /agent/:id/run` (SSE), `POST /agent/:id/connect`
|
|
93
|
+
(replays recorded frames, or an immediately-completed empty stream when
|
|
94
|
+
there is nothing to replay), `POST /agent/:id/stop/:thread_id` (JSON ack).
|
|
95
|
+
Malformed run input answers `400` with a JSON error body.
|
|
96
|
+
|
|
97
|
+
One curl example (run a thread through the stub above):
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
curl -N -X POST http://localhost:9292/agent/default/run \
|
|
101
|
+
-H 'Content-Type: application/json' \
|
|
102
|
+
-d '{"threadId":"t1","runId":"r1","messages":[{"id":"u1","role":"user","content":"Hi"}],"tools":[],"context":[]}'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The in-memory run store keeps frames in this process only — replay and
|
|
106
|
+
stop need a shared store once you run more than one process.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
bundle install
|
|
112
|
+
bundle exec rake test
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The suite validates every emitted SSE frame against the AG-UI protocol's
|
|
116
|
+
canonical JSON Schema, vendored at `test/fixtures/ag_ui.json` (generated
|
|
117
|
+
from the reference Python SDK; the copy came from the reference
|
|
118
|
+
implementation's `data/ag_ui.json`). See `test/fixtures/README.md` for
|
|
119
|
+
provenance and refresh instructions. The fixture resolves through a
|
|
120
|
+
repo-relative path, so a fresh clone needs nothing outside the repo — and
|
|
121
|
+
a missing fixture fails loudly (`ENOENT`) rather than skipping validation.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "ag_ui_protocol"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module AGUI
|
|
9
|
+
# The seam between ask-rb agent/session events and the AG-UI protocol.
|
|
10
|
+
#
|
|
11
|
+
# A transport drives one Emitter per run: it hands the emitter the AG-UI
|
|
12
|
+
# run context (thread id, run id, input messages), feeds it the stream of
|
|
13
|
+
# agent events via {#handle}, and collects the SSE frames it returns.
|
|
14
|
+
# The transport owns the socket; the emitter only translates and encodes.
|
|
15
|
+
#
|
|
16
|
+
# emitter = Ask::AGUI::Emitter.new(thread_id: "t1", run_id: "r1", messages: [...])
|
|
17
|
+
# frames = emitter.handle(agent_event) # => ["data: {...}\n\n", ...]
|
|
18
|
+
# frames = emitter.finish # => ["data: {\"type\":\"RUN_FINISHED\",...}\n\n"]
|
|
19
|
+
#
|
|
20
|
+
# Event vocabulary (matched by class name, so the emitter never depends
|
|
21
|
+
# on ask-agent — any duck-typed object with the same shape works):
|
|
22
|
+
#
|
|
23
|
+
# * `TurnStart` / `SessionStart` → `RUN_STARTED` (first one wins)
|
|
24
|
+
# * `TextDelta` (`content`) → `TEXT_MESSAGE_START`, then
|
|
25
|
+
# `TEXT_MESSAGE_CONTENT` per delta (only empty deltas are dropped — a
|
|
26
|
+
# space is content, and dropping it would fuse the words around it),
|
|
27
|
+
# then `TEXT_MESSAGE_END` on `MessageEnd`
|
|
28
|
+
# * `ThinkingDelta` (`content`) → `REASONING_START` →
|
|
29
|
+
# `REASONING_MESSAGE_START` → `REASONING_MESSAGE_CONTENT` per delta
|
|
30
|
+
# (empty deltas dropped) → `REASONING_MESSAGE_END` → `REASONING_END`
|
|
31
|
+
# on `MessageEnd`
|
|
32
|
+
# * `ToolCallDelta` (`name`, `arguments`, `id`) → `TOOL_CALL_START`
|
|
33
|
+
# (once per id) → `TOOL_CALL_ARGS` per non-empty delta;
|
|
34
|
+
# `ToolExecutionStart` opens calls the stream never announced;
|
|
35
|
+
# `MessageEnd` closes open calls with `TOOL_CALL_END`;
|
|
36
|
+
# `ToolExecutionEnd` (`name`, `id`, `result`, …) closes the call if
|
|
37
|
+
# still open and emits `TOOL_CALL_RESULT`
|
|
38
|
+
# * `SessionEnd` → `RUN_FINISHED` (first one wins); `Error` (`error`) →
|
|
39
|
+
# `RUN_ERROR`. {#finish} and {#fail} drive the same endings manually.
|
|
40
|
+
# * Anything else rides one generic `CUSTOM` passthrough
|
|
41
|
+
# (`name` = event class name, `value` = its `to_h`): the emitter knows
|
|
42
|
+
# nothing about any chat application's specific states.
|
|
43
|
+
#
|
|
44
|
+
# `MessageEnd` and `TurnEnd` carry no AG-UI counterpart of their own —
|
|
45
|
+
# they only close whatever text, reasoning, or tool call is still open.
|
|
46
|
+
#
|
|
47
|
+
# Every frame is built with `AgUiProtocol::Core::Events::*` and encoded
|
|
48
|
+
# with `AgUiProtocol::Encoder::EventEncoder` — event JSON is never
|
|
49
|
+
# hand-rolled. Each {#handle}/{#start}/{#finish}/{#fail} call answers an
|
|
50
|
+
# array of `"data: <json>\n\n"` SSE frame strings (possibly empty).
|
|
51
|
+
class Emitter
|
|
52
|
+
# @return [String] the AG-UI thread this emitter streams for.
|
|
53
|
+
attr_reader :thread_id
|
|
54
|
+
|
|
55
|
+
# @return [String] the AG-UI run this emitter streams for.
|
|
56
|
+
attr_reader :run_id
|
|
57
|
+
|
|
58
|
+
# @param thread_id [String] AG-UI thread id (echoed on RUN_STARTED/RUN_FINISHED).
|
|
59
|
+
# @param run_id [String] AG-UI run id (echoed on RUN_STARTED/RUN_FINISHED).
|
|
60
|
+
# @param messages [Array<AgUiProtocol::Core::Types::BaseMessage,
|
|
61
|
+
# AgUiProtocol::Core::Types::ActivityMessage>] input messages carried
|
|
62
|
+
# on the RUN_STARTED event.
|
|
63
|
+
def initialize(thread_id:, run_id:, messages: [])
|
|
64
|
+
@thread_id = thread_id
|
|
65
|
+
@run_id = run_id
|
|
66
|
+
@input = AgUiProtocol::Core::Types::RunAgentInput.new(
|
|
67
|
+
thread_id: thread_id,
|
|
68
|
+
run_id: run_id,
|
|
69
|
+
state: {},
|
|
70
|
+
messages: messages,
|
|
71
|
+
tools: [],
|
|
72
|
+
context: [],
|
|
73
|
+
forwarded_props: {}
|
|
74
|
+
)
|
|
75
|
+
@encoder = AgUiProtocol::Encoder::EventEncoder.new
|
|
76
|
+
@started = false
|
|
77
|
+
@terminal = false
|
|
78
|
+
@text_message_id = nil
|
|
79
|
+
@reasoning_message_id = nil
|
|
80
|
+
@seen_tool_calls = []
|
|
81
|
+
@open_tool_calls = []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Feed one agent event through the translator.
|
|
85
|
+
#
|
|
86
|
+
# @param event [Object] a duck-typed agent event (see class docs).
|
|
87
|
+
# @return [Array<String>] SSE frames to write to the stream, in order.
|
|
88
|
+
def handle(event)
|
|
89
|
+
case event_name(event)
|
|
90
|
+
when "TurnStart", "SessionStart"
|
|
91
|
+
start
|
|
92
|
+
when "TextDelta"
|
|
93
|
+
handle_text_delta(event)
|
|
94
|
+
when "ThinkingDelta"
|
|
95
|
+
handle_reasoning_delta(event)
|
|
96
|
+
when "ToolCallDelta"
|
|
97
|
+
handle_tool_call_delta(event)
|
|
98
|
+
when "ToolExecutionStart"
|
|
99
|
+
handle_tool_execution_start(event)
|
|
100
|
+
when "ToolExecutionEnd"
|
|
101
|
+
handle_tool_execution_end(event)
|
|
102
|
+
when "MessageEnd", "TurnEnd"
|
|
103
|
+
close_open_messages
|
|
104
|
+
when "SessionEnd"
|
|
105
|
+
finish
|
|
106
|
+
when "Error"
|
|
107
|
+
fail_with(event_error(event))
|
|
108
|
+
else
|
|
109
|
+
handle_custom(event)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Open the run. Idempotent: only the first call emits RUN_STARTED.
|
|
114
|
+
#
|
|
115
|
+
# @return [Array<String>] zero or one SSE frames.
|
|
116
|
+
def start
|
|
117
|
+
return [] if @started
|
|
118
|
+
|
|
119
|
+
@started = true
|
|
120
|
+
[encode(AgUiProtocol::Core::Events::RunStartedEvent.new(
|
|
121
|
+
thread_id: @thread_id, run_id: @run_id, input: @input
|
|
122
|
+
))]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Close the run normally. Closes any open message first, then emits
|
|
126
|
+
# RUN_FINISHED once — late calls only close stragglers.
|
|
127
|
+
#
|
|
128
|
+
# @return [Array<String>] SSE frames to write to the stream, in order.
|
|
129
|
+
def finish
|
|
130
|
+
frames = close_open_messages
|
|
131
|
+
return frames if @terminal
|
|
132
|
+
|
|
133
|
+
@terminal = true
|
|
134
|
+
frames << encode(AgUiProtocol::Core::Events::RunFinishedEvent.new(
|
|
135
|
+
thread_id: @thread_id, run_id: @run_id
|
|
136
|
+
))
|
|
137
|
+
frames
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Close the run with an error. Accepts an exception, a message string,
|
|
141
|
+
# or a duck-typed `Error` event (anything answering `error`).
|
|
142
|
+
# Idempotent like {#finish}; after a terminal event the run stays shut.
|
|
143
|
+
#
|
|
144
|
+
# @param reason [Exception, String, Object] what went wrong.
|
|
145
|
+
# @return [Array<String>] SSE frames to write to the stream, in order.
|
|
146
|
+
def fail(reason)
|
|
147
|
+
fail_with(reason)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def fail_with(reason)
|
|
153
|
+
frames = close_open_messages
|
|
154
|
+
return frames if @terminal
|
|
155
|
+
|
|
156
|
+
@terminal = true
|
|
157
|
+
frames << encode(AgUiProtocol::Core::Events::RunErrorEvent.new(message: error_message(reason)))
|
|
158
|
+
frames
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_text_delta(event)
|
|
162
|
+
frames = start
|
|
163
|
+
delta = event.respond_to?(:content) ? event.content : nil
|
|
164
|
+
return frames if delta.to_s.empty?
|
|
165
|
+
|
|
166
|
+
unless @text_message_id
|
|
167
|
+
@text_message_id = SecureRandom.uuid
|
|
168
|
+
frames << encode(AgUiProtocol::Core::Events::TextMessageStartEvent.new(message_id: @text_message_id))
|
|
169
|
+
end
|
|
170
|
+
frames << encode(AgUiProtocol::Core::Events::TextMessageContentEvent.new(
|
|
171
|
+
message_id: @text_message_id, delta: delta.to_s
|
|
172
|
+
))
|
|
173
|
+
frames
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def handle_reasoning_delta(event)
|
|
177
|
+
frames = start
|
|
178
|
+
delta = event.respond_to?(:content) ? event.content : nil
|
|
179
|
+
return frames if delta.to_s.empty?
|
|
180
|
+
|
|
181
|
+
unless @reasoning_message_id
|
|
182
|
+
@reasoning_message_id = SecureRandom.uuid
|
|
183
|
+
frames << encode(AgUiProtocol::Core::Events::ReasoningStartEvent.new(message_id: @reasoning_message_id))
|
|
184
|
+
frames << encode(AgUiProtocol::Core::Events::ReasoningMessageStartEvent.new(
|
|
185
|
+
message_id: @reasoning_message_id
|
|
186
|
+
))
|
|
187
|
+
end
|
|
188
|
+
frames << encode(AgUiProtocol::Core::Events::ReasoningMessageContentEvent.new(
|
|
189
|
+
message_id: @reasoning_message_id, delta: delta.to_s
|
|
190
|
+
))
|
|
191
|
+
frames
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def handle_tool_call_delta(event)
|
|
195
|
+
frames = start
|
|
196
|
+
id = event_id(event)
|
|
197
|
+
open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
|
|
198
|
+
args = args_delta(event_arguments(event))
|
|
199
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: id, delta: args)) if args
|
|
200
|
+
frames
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def handle_tool_execution_start(event)
|
|
204
|
+
frames = start
|
|
205
|
+
id = event_id(event)
|
|
206
|
+
return frames if @seen_tool_calls.include?(id)
|
|
207
|
+
|
|
208
|
+
open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
|
|
209
|
+
args = args_delta(event_arguments(event))
|
|
210
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallArgsEvent.new(tool_call_id: id, delta: args)) if args
|
|
211
|
+
frames
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def handle_tool_execution_end(event)
|
|
215
|
+
frames = start
|
|
216
|
+
id = event_id(event)
|
|
217
|
+
unless @seen_tool_calls.include?(id)
|
|
218
|
+
open_tool_call(frames, id: id, name: event_name_or(event, id), parent_message_id: @text_message_id)
|
|
219
|
+
end
|
|
220
|
+
if @open_tool_calls.delete(id)
|
|
221
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: id))
|
|
222
|
+
end
|
|
223
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallResultEvent.new(
|
|
224
|
+
message_id: SecureRandom.uuid, tool_call_id: id, content: result_content(event_result(event)), role: "tool"
|
|
225
|
+
))
|
|
226
|
+
frames
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def handle_custom(event)
|
|
230
|
+
value = event.respond_to?(:to_h) ? event.to_h : {}
|
|
231
|
+
value = {} if value.nil?
|
|
232
|
+
[encode(AgUiProtocol::Core::Events::CustomEvent.new(name: event_name(event), value: value))]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def close_open_messages
|
|
236
|
+
frames = []
|
|
237
|
+
if @reasoning_message_id
|
|
238
|
+
frames << encode(AgUiProtocol::Core::Events::ReasoningMessageEndEvent.new(message_id: @reasoning_message_id))
|
|
239
|
+
frames << encode(AgUiProtocol::Core::Events::ReasoningEndEvent.new(message_id: @reasoning_message_id))
|
|
240
|
+
@reasoning_message_id = nil
|
|
241
|
+
end
|
|
242
|
+
if @text_message_id
|
|
243
|
+
frames << encode(AgUiProtocol::Core::Events::TextMessageEndEvent.new(message_id: @text_message_id))
|
|
244
|
+
@text_message_id = nil
|
|
245
|
+
end
|
|
246
|
+
@open_tool_calls.each do |id|
|
|
247
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallEndEvent.new(tool_call_id: id))
|
|
248
|
+
end
|
|
249
|
+
@open_tool_calls = []
|
|
250
|
+
frames
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def open_tool_call(frames, id:, name:, parent_message_id:)
|
|
254
|
+
return if @seen_tool_calls.include?(id)
|
|
255
|
+
|
|
256
|
+
@seen_tool_calls << id
|
|
257
|
+
@open_tool_calls << id
|
|
258
|
+
frames << encode(AgUiProtocol::Core::Events::ToolCallStartEvent.new(
|
|
259
|
+
tool_call_id: id, tool_call_name: name, parent_message_id: parent_message_id
|
|
260
|
+
))
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def encode(event)
|
|
264
|
+
@encoder.encode(event)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def event_name(event)
|
|
268
|
+
event.class.name.to_s.split("::").last
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def event_id(event)
|
|
272
|
+
event.respond_to?(:id) ? event.id.to_s : SecureRandom.uuid
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def event_name_or(event, fallback)
|
|
276
|
+
name = event.respond_to?(:name) ? event.name : nil
|
|
277
|
+
name = nil if name.to_s.empty?
|
|
278
|
+
name ? name.to_s : fallback
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def event_arguments(event)
|
|
282
|
+
event.respond_to?(:arguments) ? event.arguments : nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def event_result(event)
|
|
286
|
+
event.respond_to?(:result) ? event.result : nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def event_error(event)
|
|
290
|
+
event.respond_to?(:error) ? event.error : event
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def error_message(reason)
|
|
294
|
+
if reason.is_a?(Exception)
|
|
295
|
+
reason.message
|
|
296
|
+
elsif reason.respond_to?(:error)
|
|
297
|
+
reason.error.to_s
|
|
298
|
+
else
|
|
299
|
+
reason.to_s
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Tool arguments arrive as a JSON string or a Hash — the wire wants a
|
|
304
|
+
# string delta. Answers nil when there is nothing to send.
|
|
305
|
+
def args_delta(arguments)
|
|
306
|
+
return nil if arguments.nil?
|
|
307
|
+
return nil if arguments.respond_to?(:empty?) && arguments.empty?
|
|
308
|
+
|
|
309
|
+
delta = arguments.is_a?(String) ? arguments : JSON.generate(arguments)
|
|
310
|
+
delta.empty? ? nil : delta
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Tool results arrive as the executor's legacy hash ({message:, result:,
|
|
314
|
+
# …}), a plain string, or a duck-typed object — the wire wants a string.
|
|
315
|
+
def result_content(result)
|
|
316
|
+
return result.to_s if result.nil? || result.is_a?(String)
|
|
317
|
+
|
|
318
|
+
hash = result_hash(result)
|
|
319
|
+
if hash
|
|
320
|
+
content = hash[:message] || hash["message"] || hash[:result] || hash["result"]
|
|
321
|
+
return content.to_s unless content.nil?
|
|
322
|
+
end
|
|
323
|
+
result.to_s
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def result_hash(result)
|
|
327
|
+
return result if result.is_a?(Hash)
|
|
328
|
+
return result.to_h if result.respond_to?(:to_h)
|
|
329
|
+
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ag_ui_protocol"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module AGUI
|
|
8
|
+
# The parsed AG-UI run input for one `POST /agent/:id/run`.
|
|
9
|
+
#
|
|
10
|
+
# The server parses the request body into a Run and hands it to the
|
|
11
|
+
# host's block. The host reads what it needs (messages, tools, context,
|
|
12
|
+
# forwarded props) and answers the stream of duck-typed agent events;
|
|
13
|
+
# the server drives one {Emitter} with those events and owns the socket.
|
|
14
|
+
#
|
|
15
|
+
# Message, tool, and context entries are coerced to their
|
|
16
|
+
# `AgUiProtocol::Core::Types` counterparts on a best-effort basis:
|
|
17
|
+
# entries that do not coerce are skipped, never fatal. `state` and
|
|
18
|
+
# `forwarded_props` are opaque user data and ride through verbatim.
|
|
19
|
+
class Run
|
|
20
|
+
# Raised on malformed JSON or a missing `threadId` / `runId`. The
|
|
21
|
+
# server renders this as `400 {"error": ..., "details": ...}`.
|
|
22
|
+
class InvalidError < StandardError; end
|
|
23
|
+
|
|
24
|
+
# @return [String] the `:id` segment of `/agent/:id/run`.
|
|
25
|
+
attr_reader :agent_id
|
|
26
|
+
|
|
27
|
+
# @return [String] the AG-UI thread id.
|
|
28
|
+
attr_reader :thread_id
|
|
29
|
+
|
|
30
|
+
# @return [String] the AG-UI run id.
|
|
31
|
+
attr_reader :run_id
|
|
32
|
+
|
|
33
|
+
# @return [Array<AgUiProtocol::Core::Types::BaseMessage,
|
|
34
|
+
# AgUiProtocol::Core::Types::ActivityMessage>] coerced input messages.
|
|
35
|
+
attr_reader :messages
|
|
36
|
+
|
|
37
|
+
# @return [Array<AgUiProtocol::Core::Types::Tool>] coerced tools.
|
|
38
|
+
attr_reader :tools
|
|
39
|
+
|
|
40
|
+
# @return [Array<AgUiProtocol::Core::Types::Context>] coerced context.
|
|
41
|
+
attr_reader :context
|
|
42
|
+
|
|
43
|
+
# @return [Object] opaque forwarded props, verbatim from the client.
|
|
44
|
+
attr_reader :forwarded_props
|
|
45
|
+
|
|
46
|
+
# @return [Object] opaque agent state, verbatim from the client.
|
|
47
|
+
attr_reader :state
|
|
48
|
+
|
|
49
|
+
# @return [Hash] the raw parsed body.
|
|
50
|
+
attr_reader :raw
|
|
51
|
+
|
|
52
|
+
# @param agent_id [String]
|
|
53
|
+
# @param thread_id [String]
|
|
54
|
+
# @param run_id [String]
|
|
55
|
+
# @param messages [Array]
|
|
56
|
+
# @param tools [Array]
|
|
57
|
+
# @param context [Array]
|
|
58
|
+
# @param forwarded_props [Object]
|
|
59
|
+
# @param state [Object]
|
|
60
|
+
# @param raw [Hash]
|
|
61
|
+
def initialize(agent_id:, thread_id:, run_id:, messages: [], tools: [],
|
|
62
|
+
context: [], forwarded_props: nil, state: nil, raw: {})
|
|
63
|
+
@agent_id = agent_id
|
|
64
|
+
@thread_id = thread_id
|
|
65
|
+
@run_id = run_id
|
|
66
|
+
@messages = messages
|
|
67
|
+
@tools = tools
|
|
68
|
+
@context = context
|
|
69
|
+
@forwarded_props = forwarded_props
|
|
70
|
+
@state = state
|
|
71
|
+
@raw = raw
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse a `POST /agent/:id/run` body into a Run.
|
|
75
|
+
#
|
|
76
|
+
# @param agent_id [String] the `:id` segment of the request path.
|
|
77
|
+
# @param body [String] the raw request body.
|
|
78
|
+
# @return [Run]
|
|
79
|
+
# @raise [InvalidError] when the body is not a JSON object carrying
|
|
80
|
+
# string `threadId` and `runId` members.
|
|
81
|
+
def self.parse(agent_id, body)
|
|
82
|
+
raw = begin
|
|
83
|
+
JSON.parse(body.to_s)
|
|
84
|
+
rescue JSON::ParserError => e
|
|
85
|
+
raise InvalidError, "malformed JSON: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
unless raw.is_a?(Hash)
|
|
89
|
+
raise InvalidError, "expected a JSON object, got #{raw.class}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
thread_id = raw["threadId"] || raw["thread_id"]
|
|
93
|
+
run_id = raw["runId"] || raw["run_id"]
|
|
94
|
+
|
|
95
|
+
unless thread_id.is_a?(String) && !thread_id.empty?
|
|
96
|
+
raise InvalidError, "missing required member \"threadId\""
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
unless run_id.is_a?(String) && !run_id.empty?
|
|
100
|
+
raise InvalidError, "missing required member \"runId\""
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
new(
|
|
104
|
+
agent_id: agent_id.to_s,
|
|
105
|
+
thread_id: thread_id,
|
|
106
|
+
run_id: run_id,
|
|
107
|
+
messages: coerce_messages(raw["messages"]),
|
|
108
|
+
tools: coerce_tools(raw["tools"]),
|
|
109
|
+
context: coerce_context(raw["context"]),
|
|
110
|
+
forwarded_props: raw.key?("forwardedProps") ? raw["forwardedProps"] : raw["forwarded_props"],
|
|
111
|
+
state: raw["state"],
|
|
112
|
+
raw: raw
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Best-effort message coercion. Unknown roles and malformed entries
|
|
117
|
+
# are skipped so one bad message cannot fail the run.
|
|
118
|
+
#
|
|
119
|
+
# @param value [Object]
|
|
120
|
+
# @return [Array]
|
|
121
|
+
def self.coerce_messages(value)
|
|
122
|
+
return [] unless value.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
value.filter_map do |entry|
|
|
125
|
+
coerce_message(entry)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Coerce one raw message hash to its protocol type.
|
|
132
|
+
#
|
|
133
|
+
# @param entry [Object]
|
|
134
|
+
# @return [AgUiProtocol::Core::Types::Model, nil]
|
|
135
|
+
def self.coerce_message(entry)
|
|
136
|
+
return nil unless entry.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
types = AgUiProtocol::Core::Types
|
|
139
|
+
id = (entry["id"] || entry[:id]).to_s
|
|
140
|
+
return nil if id.empty?
|
|
141
|
+
|
|
142
|
+
case entry["role"] || entry[:role]
|
|
143
|
+
when "user"
|
|
144
|
+
types::UserMessage.new(id: id, content: entry["content"] || entry[:content] || "")
|
|
145
|
+
when "assistant"
|
|
146
|
+
types::AssistantMessage.new(
|
|
147
|
+
id: id,
|
|
148
|
+
content: entry["content"] || entry[:content],
|
|
149
|
+
tool_calls: entry["toolCalls"] || entry["tool_calls"] || entry[:tool_calls]
|
|
150
|
+
)
|
|
151
|
+
when "system"
|
|
152
|
+
types::SystemMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
|
|
153
|
+
when "developer"
|
|
154
|
+
types::DeveloperMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
|
|
155
|
+
when "tool"
|
|
156
|
+
tool_call_id = entry["toolCallId"] || entry["tool_call_id"] || entry[:tool_call_id]
|
|
157
|
+
return nil if tool_call_id.to_s.empty?
|
|
158
|
+
|
|
159
|
+
types::ToolMessage.new(
|
|
160
|
+
id: id,
|
|
161
|
+
content: (entry["content"] || entry[:content]).to_s,
|
|
162
|
+
tool_call_id: tool_call_id.to_s
|
|
163
|
+
)
|
|
164
|
+
when "activity"
|
|
165
|
+
content = entry["content"] || entry[:content]
|
|
166
|
+
return nil unless content.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
types::ActivityMessage.new(
|
|
169
|
+
id: id,
|
|
170
|
+
activity_type: (entry["activityType"] || entry["activity_type"] || "activity").to_s,
|
|
171
|
+
content: content
|
|
172
|
+
)
|
|
173
|
+
when "reasoning"
|
|
174
|
+
types::ReasoningMessage.new(id: id, content: (entry["content"] || entry[:content]).to_s)
|
|
175
|
+
end
|
|
176
|
+
rescue StandardError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param value [Object]
|
|
181
|
+
# @return [Array<AgUiProtocol::Core::Types::Tool>]
|
|
182
|
+
def self.coerce_tools(value)
|
|
183
|
+
return [] unless value.is_a?(Array)
|
|
184
|
+
|
|
185
|
+
types = AgUiProtocol::Core::Types
|
|
186
|
+
value.filter_map do |entry|
|
|
187
|
+
next unless entry.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
name = entry["name"] || entry[:name]
|
|
190
|
+
description = entry["description"] || entry[:description]
|
|
191
|
+
parameters = entry["parameters"] || entry[:parameters]
|
|
192
|
+
next if name.to_s.empty?
|
|
193
|
+
|
|
194
|
+
types::Tool.new(
|
|
195
|
+
name: name.to_s,
|
|
196
|
+
description: description.to_s,
|
|
197
|
+
parameters: parameters.nil? ? {} : parameters
|
|
198
|
+
)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @param value [Object]
|
|
205
|
+
# @return [Array<AgUiProtocol::Core::Types::Context>]
|
|
206
|
+
def self.coerce_context(value)
|
|
207
|
+
return [] unless value.is_a?(Array)
|
|
208
|
+
|
|
209
|
+
types = AgUiProtocol::Core::Types
|
|
210
|
+
value.filter_map do |entry|
|
|
211
|
+
next unless entry.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
description = entry["description"] || entry[:description]
|
|
214
|
+
content = entry["value"] || entry[:value]
|
|
215
|
+
next if description.to_s.empty? || content.nil?
|
|
216
|
+
|
|
217
|
+
types::Context.new(description: description.to_s, value: content.to_s)
|
|
218
|
+
rescue StandardError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private_class_method :coerce_message
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module AGUI
|
|
5
|
+
# Per-thread run bookkeeping backing `POST /agent/:id/connect`
|
|
6
|
+
# (replay) and `POST /agent/:id/stop/:thread_id` (cooperative cancel).
|
|
7
|
+
#
|
|
8
|
+
# The interface is duck-typed: anything implementing {#begin_run},
|
|
9
|
+
# {#record}, {#finish_run}, {#replay}, {#request_stop} and
|
|
10
|
+
# {#stop_requested?} works as a store. A future shared store (Redis,
|
|
11
|
+
# database) can be dropped in without touching the server.
|
|
12
|
+
#
|
|
13
|
+
# store.begin_run("t1", "r1")
|
|
14
|
+
# store.record("t1", "data: {...}\n\n")
|
|
15
|
+
# store.finish_run("t1")
|
|
16
|
+
# store.replay("t1") # => ["data: {...}\n\n", ...]
|
|
17
|
+
# store.request_stop("t1") # => true
|
|
18
|
+
# store.stop_requested?("t1") # => true
|
|
19
|
+
module RunStore
|
|
20
|
+
# Single-process in-memory store.
|
|
21
|
+
#
|
|
22
|
+
# NOTE: frames live in this process's memory only. Two processes
|
|
23
|
+
# (two Puma workers, two machines) cannot see each other's runs, so
|
|
24
|
+
# `/connect` replay and `/stop` only work when the client keeps
|
|
25
|
+
# hitting the same process. Back replay and stop with a shared store
|
|
26
|
+
# when running more than one process.
|
|
27
|
+
#
|
|
28
|
+
# Mutex-guarded, so threads sharing one process are safe.
|
|
29
|
+
class InMemory
|
|
30
|
+
# Build an empty store.
|
|
31
|
+
def initialize
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@frames = Hash.new { |hash, key| hash[key] = [] }
|
|
34
|
+
@stop_requested = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Mark the start of a run. Clears any stale stop request for the
|
|
38
|
+
# thread so a previous `/stop` cannot cancel the new run.
|
|
39
|
+
#
|
|
40
|
+
# @param thread_id [String]
|
|
41
|
+
# @param run_id [String]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def begin_run(thread_id, run_id)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@stop_requested.delete(thread_id.to_s)
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Append one SSE frame for later `/connect` replay.
|
|
51
|
+
#
|
|
52
|
+
# @param thread_id [String]
|
|
53
|
+
# @param frame [String] one `"data: <json>\n\n"` frame.
|
|
54
|
+
# @return [void]
|
|
55
|
+
def record(thread_id, frame)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@frames[thread_id.to_s] << frame
|
|
58
|
+
end
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Mark the run finished. Frames stay available for replay.
|
|
63
|
+
#
|
|
64
|
+
# @param thread_id [String]
|
|
65
|
+
# @return [void]
|
|
66
|
+
def finish_run(thread_id)
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Answer every frame recorded for the thread, in order.
|
|
71
|
+
#
|
|
72
|
+
# @param thread_id [String]
|
|
73
|
+
# @return [Array<String>] possibly empty when nothing was recorded.
|
|
74
|
+
def replay(thread_id)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@frames[thread_id.to_s].dup
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Flag the thread's run for cooperative cancel. The run loop
|
|
81
|
+
# checks {#stop_requested?} between events and ends the run early.
|
|
82
|
+
#
|
|
83
|
+
# @param thread_id [String]
|
|
84
|
+
# @return [true]
|
|
85
|
+
def request_stop(thread_id)
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@stop_requested[thread_id.to_s] = true
|
|
88
|
+
end
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
alias stop request_stop
|
|
93
|
+
|
|
94
|
+
# Whether {#request_stop} was called since the last {#begin_run}.
|
|
95
|
+
#
|
|
96
|
+
# @param thread_id [String]
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def stop_requested?(thread_id)
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@stop_requested.fetch(thread_id.to_s, false)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "ag_ui_protocol"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module AGUI
|
|
8
|
+
# Mountable Rack application serving the AG-UI runtime surface.
|
|
9
|
+
#
|
|
10
|
+
# This is the conventional surface third-party AG-UI clients
|
|
11
|
+
# (assistant-ui, CopilotKit) expect when pointed at the gem:
|
|
12
|
+
#
|
|
13
|
+
# * `GET /info` — the runtime envelope: the agents map (name, class,
|
|
14
|
+
# capabilities), the transport mode, and the gem version.
|
|
15
|
+
# * `POST /agent/:id/run` — parses the run input, drives one {Emitter}
|
|
16
|
+
# for the run, and streams its SSE frames.
|
|
17
|
+
# * `POST /agent/:id/connect` — replays recorded frames, or completes
|
|
18
|
+
# immediately when there is nothing to replay.
|
|
19
|
+
# * `POST /agent/:id/stop/:thread_id` — flags the run for cooperative
|
|
20
|
+
# cancel and answers a JSON acknowledgement.
|
|
21
|
+
#
|
|
22
|
+
# The host supplies the work as a block and owns the agent; the gem
|
|
23
|
+
# owns the socket and the framing and never depends on Rails or
|
|
24
|
+
# ask-agent:
|
|
25
|
+
#
|
|
26
|
+
# app = Ask::AGUI::Server.new(agent_id: "default") do |run|
|
|
27
|
+
# # run.thread_id, run.run_id, run.messages, run.tools,
|
|
28
|
+
# # run.context, run.forwarded_props — answer agent events:
|
|
29
|
+
# [TurnStart.new, TextDelta.new(content: "Hello")]
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# The block answers an enumerable of duck-typed agent events (the same
|
|
33
|
+
# vocabulary {Emitter} handles). It may answer a lazy enumerator — the
|
|
34
|
+
# response body pulls events only as it streams, so the run streams
|
|
35
|
+
# under any Rack server. Every run opens with `RUN_STARTED` and closes
|
|
36
|
+
# with `RUN_FINISHED` / `RUN_ERROR`, even when the host answers no
|
|
37
|
+
# events or raises mid-run.
|
|
38
|
+
#
|
|
39
|
+
# Matching is on trailing path segments, so the app works standalone
|
|
40
|
+
# and mounted (`map("/api/copilotkit") { run app }`, Rails `mount`).
|
|
41
|
+
class Server
|
|
42
|
+
# Response headers for the SSE streams. Matches the reference
|
|
43
|
+
# runtime: `text/event-stream`, no caching, keep-alive.
|
|
44
|
+
SSE_HEADERS = {
|
|
45
|
+
"content-type" => "text/event-stream",
|
|
46
|
+
"cache-control" => "no-cache",
|
|
47
|
+
"connection" => "keep-alive",
|
|
48
|
+
"x-accel-buffering" => "no"
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# Response headers for the JSON endpoints.
|
|
52
|
+
JSON_HEADERS = { "content-type" => "application/json" }.freeze
|
|
53
|
+
|
|
54
|
+
AGENT_RUN_ROUTE = %r{/agent/(?<agent_id>[^/]+)/run\z}
|
|
55
|
+
AGENT_CONNECT_ROUTE = %r{/agent/(?<agent_id>[^/]+)/connect\z}
|
|
56
|
+
AGENT_STOP_ROUTE = %r{/agent/(?<agent_id>[^/]+)/stop/(?<thread_id>[^/]+)\z}
|
|
57
|
+
INFO_ROUTE = %r{(?:\A|/)info\z}
|
|
58
|
+
|
|
59
|
+
# @return [String] the agent id advertised on `GET /info`.
|
|
60
|
+
attr_reader :agent_id
|
|
61
|
+
|
|
62
|
+
# Build the Rack application.
|
|
63
|
+
#
|
|
64
|
+
# @param agent_id [String] the agent name advertised on `GET /info`.
|
|
65
|
+
# @param description [String, nil] one-line agent description for discovery UIs.
|
|
66
|
+
# @param agent_class_name [String] the agent class named on `GET /info`.
|
|
67
|
+
# @param capabilities [AgUiProtocol::Core::Capabilities::AgentCapabilities, nil]
|
|
68
|
+
# capability information for `GET /info`. Defaults to what the
|
|
69
|
+
# {Emitter} actually drives: SSE transport, client-provided tools,
|
|
70
|
+
# streaming reasoning.
|
|
71
|
+
# @param store [Ask::AGUI::RunStore::InMemory, nil] run bookkeeping
|
|
72
|
+
# for `/connect` replay and `/stop`. Pass `nil` for the stateless
|
|
73
|
+
# behaviour (connect completes immediately, stop only acks).
|
|
74
|
+
# @yield [Run] the run input; answers an enumerable of agent events.
|
|
75
|
+
# @raise [ArgumentError] when no run-handler block is given.
|
|
76
|
+
def initialize(agent_id: "default", description: nil, agent_class_name: "BuiltInAgent",
|
|
77
|
+
capabilities: nil, store: RunStore::InMemory.new, &block)
|
|
78
|
+
raise ArgumentError, "Server requires a run-handler block" unless block
|
|
79
|
+
|
|
80
|
+
@agent_id = agent_id.to_s
|
|
81
|
+
@description = description
|
|
82
|
+
@agent_class_name = agent_class_name.to_s
|
|
83
|
+
@capabilities = capabilities || default_capabilities
|
|
84
|
+
@store = store
|
|
85
|
+
@handler = block
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Rack entrypoint.
|
|
89
|
+
#
|
|
90
|
+
# @param env [Hash] the Rack environment.
|
|
91
|
+
# @return [Array(Integer, Hash, Object)] the Rack response.
|
|
92
|
+
def call(env)
|
|
93
|
+
method = env["REQUEST_METHOD"].to_s
|
|
94
|
+
path = env["PATH_INFO"].to_s.chomp("/")
|
|
95
|
+
|
|
96
|
+
if method == "OPTIONS"
|
|
97
|
+
return preflight
|
|
98
|
+
elsif method == "GET" && path.match?(INFO_ROUTE)
|
|
99
|
+
return respond_info
|
|
100
|
+
elsif method == "POST" && (match = path.match(AGENT_STOP_ROUTE))
|
|
101
|
+
return respond_stop(match[:thread_id])
|
|
102
|
+
elsif method == "POST" && (match = path.match(AGENT_RUN_ROUTE))
|
|
103
|
+
return respond_run(env, match[:agent_id])
|
|
104
|
+
elsif method == "POST" && (match = path.match(AGENT_CONNECT_ROUTE))
|
|
105
|
+
return respond_connect(env)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
not_found
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def respond_info
|
|
114
|
+
[200, JSON_HEADERS.dup, [JSON.generate(info_payload)]]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The capability information is built from ag-ui-protocol's
|
|
118
|
+
# capability and identity types — never hand-rolled.
|
|
119
|
+
def info_payload
|
|
120
|
+
agent = {
|
|
121
|
+
"name" => @agent_id,
|
|
122
|
+
"className" => @agent_class_name,
|
|
123
|
+
"capabilities" => @capabilities.as_json
|
|
124
|
+
}
|
|
125
|
+
agent["description"] = @description unless @description.nil?
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
"agents" => { @agent_id => agent },
|
|
129
|
+
"mode" => "sse",
|
|
130
|
+
"version" => VERSION
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_capabilities
|
|
135
|
+
capabilities = AgUiProtocol::Core::Capabilities
|
|
136
|
+
capabilities::AgentCapabilities.new(
|
|
137
|
+
identity: capabilities::IdentityCapabilities.new(
|
|
138
|
+
name: @agent_id, description: @description, version: VERSION
|
|
139
|
+
),
|
|
140
|
+
transport: capabilities::TransportCapabilities.new(streaming: true),
|
|
141
|
+
tools: capabilities::ToolsCapabilities.new(supported: true, client_provided: true),
|
|
142
|
+
reasoning: capabilities::ReasoningCapabilities.new(supported: true, streaming: true)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def respond_run(env, agent_id)
|
|
147
|
+
body = env["rack.input"]&.read.to_s
|
|
148
|
+
run = Run.parse(agent_id, body)
|
|
149
|
+
emitter = Emitter.new(thread_id: run.thread_id, run_id: run.run_id, messages: run.messages)
|
|
150
|
+
[200, SSE_HEADERS.dup, run_body(run, emitter)]
|
|
151
|
+
rescue Run::InvalidError => e
|
|
152
|
+
bad_request(e.message)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The enumerable body pulls the host's events lazily, translates
|
|
156
|
+
# each through the emitter, and yields ready-to-write SSE frames —
|
|
157
|
+
# plain Rack 3 streaming with no async requirement.
|
|
158
|
+
def run_body(run, emitter)
|
|
159
|
+
server = self
|
|
160
|
+
Enumerator.new do |yielder|
|
|
161
|
+
server.send(:stream_run, run, emitter, yielder)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def stream_run(run, emitter, yielder)
|
|
166
|
+
@store&.begin_run(run.thread_id, run.run_id)
|
|
167
|
+
emit = lambda do |frames|
|
|
168
|
+
frames.each do |frame|
|
|
169
|
+
@store&.record(run.thread_id, frame)
|
|
170
|
+
yielder << frame
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
emit.call(emitter.start)
|
|
175
|
+
|
|
176
|
+
events = @handler.call(run)
|
|
177
|
+
events = [] if events.nil?
|
|
178
|
+
events = [events] unless events.respond_to?(:each)
|
|
179
|
+
events.each do |event|
|
|
180
|
+
break if @store&.stop_requested?(run.thread_id)
|
|
181
|
+
|
|
182
|
+
emit.call(emitter.handle(event))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
emit.call(emitter.finish)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
begin
|
|
188
|
+
emit.call(emitter.fail(e))
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
ensure
|
|
193
|
+
@store&.finish_run(run.thread_id)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Resume/reattach: replay everything recorded for the thread, then
|
|
197
|
+
# close. Unknown thread (or no store) answers 200 SSE with zero
|
|
198
|
+
# events — the client's reconnect stays well behaved.
|
|
199
|
+
def respond_connect(env)
|
|
200
|
+
thread_id = extract_thread_id(env["rack.input"]&.read.to_s)
|
|
201
|
+
frames = (thread_id && @store) ? @store.replay(thread_id) : []
|
|
202
|
+
[200, SSE_HEADERS.dup, frames]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Flag the thread's run for cooperative cancel and ack. The run loop
|
|
206
|
+
# checks the flag between events and ends the run early.
|
|
207
|
+
def respond_stop(thread_id)
|
|
208
|
+
stopped = @store ? @store.request_stop(thread_id.to_s) : false
|
|
209
|
+
[200, JSON_HEADERS.dup, [JSON.generate({ "stopped" => stopped })]]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# `/connect` bodies carry the run input plus an optional replay
|
|
213
|
+
# cursor — parsed opportunistically, never fatal.
|
|
214
|
+
def extract_thread_id(body)
|
|
215
|
+
return nil if body.empty?
|
|
216
|
+
|
|
217
|
+
raw = JSON.parse(body)
|
|
218
|
+
return nil unless raw.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
thread_id = raw["threadId"] || raw["thread_id"]
|
|
221
|
+
thread_id.is_a?(String) && !thread_id.empty? ? thread_id : nil
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def preflight
|
|
227
|
+
[204, {
|
|
228
|
+
"access-control-allow-origin" => "*",
|
|
229
|
+
"access-control-allow-methods" => "GET, POST, OPTIONS",
|
|
230
|
+
"access-control-allow-headers" => "*"
|
|
231
|
+
}, []]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def bad_request(details)
|
|
235
|
+
[400, JSON_HEADERS.dup,
|
|
236
|
+
[JSON.generate({ "error" => "Invalid request body", "details" => details })]]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def not_found
|
|
240
|
+
[404, JSON_HEADERS.dup, [JSON.generate({ "error" => "Not found" })]]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
data/lib/ask/ag_ui.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ag_ui/version"
|
|
4
|
+
require_relative "ag_ui/emitter"
|
|
5
|
+
require_relative "ag_ui/run_store"
|
|
6
|
+
require_relative "ag_ui/run"
|
|
7
|
+
require_relative "ag_ui/server"
|
|
8
|
+
|
|
9
|
+
# Namespace for the AG-UI (Agent-User Interaction) protocol server.
|
|
10
|
+
#
|
|
11
|
+
# ask-ag-ui is the runtime surface that assistant-ui and CopilotKit
|
|
12
|
+
# frontends expect: SSE event streaming over the AG-UI protocol, backed by
|
|
13
|
+
# the ask-rb ecosystem.
|
|
14
|
+
#
|
|
15
|
+
# The public seam is Ask::AGUI::Emitter — see that class for the contract.
|
|
16
|
+
module Ask
|
|
17
|
+
# AG-UI protocol surface for ask-rb.
|
|
18
|
+
module AGUI
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/ask-ag-ui.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-ag-ui
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ag-ui-protocol
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: json_schemer
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.5'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.5'
|
|
82
|
+
description: The AG-UI (Agent-User Interaction) protocol server for the ask-rb ecosystem
|
|
83
|
+
— SSE event streaming and the runtime surface that assistant-ui and CopilotKit frontends
|
|
84
|
+
expect.
|
|
85
|
+
email:
|
|
86
|
+
- kaka@myrrlabs.com
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- CHANGELOG.md
|
|
92
|
+
- LICENSE
|
|
93
|
+
- README.md
|
|
94
|
+
- lib/ask-ag-ui.rb
|
|
95
|
+
- lib/ask/ag_ui.rb
|
|
96
|
+
- lib/ask/ag_ui/emitter.rb
|
|
97
|
+
- lib/ask/ag_ui/run.rb
|
|
98
|
+
- lib/ask/ag_ui/run_store.rb
|
|
99
|
+
- lib/ask/ag_ui/server.rb
|
|
100
|
+
- lib/ask/ag_ui/version.rb
|
|
101
|
+
homepage: https://github.com/ask-rb/ask-ag-ui
|
|
102
|
+
licenses:
|
|
103
|
+
- MIT
|
|
104
|
+
metadata:
|
|
105
|
+
homepage_uri: https://github.com/ask-rb/ask-ag-ui
|
|
106
|
+
source_code_uri: https://github.com/ask-rb/ask-ag-ui
|
|
107
|
+
changelog_uri: https://github.com/ask-rb/ask-ag-ui/blob/master/CHANGELOG.md
|
|
108
|
+
rdoc_options: []
|
|
109
|
+
require_paths:
|
|
110
|
+
- lib
|
|
111
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '3.2'
|
|
116
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: '0'
|
|
121
|
+
requirements: []
|
|
122
|
+
rubygems_version: 4.0.18
|
|
123
|
+
specification_version: 4
|
|
124
|
+
summary: AG-UI protocol server for the ask-rb ecosystem
|
|
125
|
+
test_files: []
|