acp_ruby 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 +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/lib/acp_ruby.rb +3 -0
- data/lib/agent_client_protocol/agent/connection.rb +125 -0
- data/lib/agent_client_protocol/agent/router.rb +62 -0
- data/lib/agent_client_protocol/agent.rb +49 -0
- data/lib/agent_client_protocol/client/connection.rb +112 -0
- data/lib/agent_client_protocol/client/router.rb +85 -0
- data/lib/agent_client_protocol/client.rb +53 -0
- data/lib/agent_client_protocol/connection.rb +144 -0
- data/lib/agent_client_protocol/contrib/permission_broker.rb +57 -0
- data/lib/agent_client_protocol/contrib/session_accumulator.rb +160 -0
- data/lib/agent_client_protocol/contrib/tool_call_tracker.rb +115 -0
- data/lib/agent_client_protocol/error.rb +41 -0
- data/lib/agent_client_protocol/helpers.rb +176 -0
- data/lib/agent_client_protocol/meta.rb +30 -0
- data/lib/agent_client_protocol/router.rb +108 -0
- data/lib/agent_client_protocol/schema/base_model.rb +158 -0
- data/lib/agent_client_protocol/schema/generated.rb +508 -0
- data/lib/agent_client_protocol/schema/types.rb +200 -0
- data/lib/agent_client_protocol/stdio.rb +129 -0
- data/lib/agent_client_protocol/transport.rb +70 -0
- data/lib/agent_client_protocol/version.rb +5 -0
- data/lib/agent_client_protocol.rb +54 -0
- data/schema/VERSION +1 -0
- data/schema/meta.json +24 -0
- data/schema/schema.json +3430 -0
- metadata +103 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "async"
|
|
5
|
+
|
|
6
|
+
module AgentClientProtocol
|
|
7
|
+
class Connection
|
|
8
|
+
JSONRPC_VERSION = "2.0"
|
|
9
|
+
|
|
10
|
+
attr_reader :reader, :writer
|
|
11
|
+
|
|
12
|
+
def initialize(reader:, writer:, handler:)
|
|
13
|
+
@reader = reader
|
|
14
|
+
@writer = writer
|
|
15
|
+
@handler = handler
|
|
16
|
+
@next_id = 0
|
|
17
|
+
@pending = {} # id -> Async::Promise
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@closed = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def send_request(method, params = nil)
|
|
23
|
+
id = next_id
|
|
24
|
+
variable = Async::Promise.new
|
|
25
|
+
|
|
26
|
+
@mutex.synchronize { @pending[id] = variable }
|
|
27
|
+
|
|
28
|
+
msg = {"jsonrpc" => JSONRPC_VERSION, "id" => id, "method" => method}
|
|
29
|
+
msg["params"] = params if params
|
|
30
|
+
@writer.write(msg)
|
|
31
|
+
|
|
32
|
+
result = variable.wait
|
|
33
|
+
if result.is_a?(Hash) && result.key?("__error__")
|
|
34
|
+
raise RequestError.from_hash(result["__error__"])
|
|
35
|
+
end
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def send_notification(method, params = nil)
|
|
40
|
+
msg = {"jsonrpc" => JSONRPC_VERSION, "method" => method}
|
|
41
|
+
msg["params"] = params if params
|
|
42
|
+
@writer.write(msg)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def listen
|
|
46
|
+
@reader.each do |message|
|
|
47
|
+
process_message(message)
|
|
48
|
+
end
|
|
49
|
+
rescue IOError, Errno::EPIPE
|
|
50
|
+
# Connection closed
|
|
51
|
+
ensure
|
|
52
|
+
close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def close
|
|
56
|
+
return if @closed
|
|
57
|
+
|
|
58
|
+
@closed = true
|
|
59
|
+
|
|
60
|
+
# Reject all pending requests
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@pending.each_value do |var|
|
|
63
|
+
var.resolve({"__error__" => {"code" => -32603, "message" => "Connection closed"}}) unless var.resolved?
|
|
64
|
+
end
|
|
65
|
+
@pending.clear
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@reader.close
|
|
69
|
+
@writer.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def closed?
|
|
73
|
+
@closed
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def next_id
|
|
79
|
+
@mutex.synchronize { @next_id += 1 }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def process_message(message)
|
|
83
|
+
has_method = message.key?("method")
|
|
84
|
+
has_id = message.key?("id")
|
|
85
|
+
|
|
86
|
+
if has_method && has_id
|
|
87
|
+
handle_request(message)
|
|
88
|
+
elsif has_method
|
|
89
|
+
handle_notification(message)
|
|
90
|
+
elsif has_id
|
|
91
|
+
handle_response(message)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_request(message)
|
|
96
|
+
id = message["id"]
|
|
97
|
+
method = message["method"]
|
|
98
|
+
params = message["params"]
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
result = @handler.call(method, params, false)
|
|
102
|
+
send_response(id, result: result)
|
|
103
|
+
rescue RequestError => e
|
|
104
|
+
send_response(id, error: e.to_h)
|
|
105
|
+
rescue => e
|
|
106
|
+
send_response(id, error: RequestError.internal_error(e.message).to_h)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_notification(message)
|
|
111
|
+
method = message["method"]
|
|
112
|
+
params = message["params"]
|
|
113
|
+
|
|
114
|
+
@handler.call(method, params, true)
|
|
115
|
+
rescue RequestError
|
|
116
|
+
# Notifications don't send error responses
|
|
117
|
+
rescue => e
|
|
118
|
+
# Log but don't propagate — notifications are fire-and-forget
|
|
119
|
+
$stderr.puts("ACP notification handler error: #{e.message}") if $DEBUG
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def handle_response(message)
|
|
123
|
+
id = message["id"]
|
|
124
|
+
variable = @mutex.synchronize { @pending.delete(id) }
|
|
125
|
+
return unless variable
|
|
126
|
+
|
|
127
|
+
if message.key?("error")
|
|
128
|
+
variable.resolve({"__error__" => message["error"]})
|
|
129
|
+
else
|
|
130
|
+
variable.resolve(message["result"])
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def send_response(id, result: nil, error: nil)
|
|
135
|
+
msg = {"jsonrpc" => JSONRPC_VERSION, "id" => id}
|
|
136
|
+
if error
|
|
137
|
+
msg["error"] = error
|
|
138
|
+
else
|
|
139
|
+
msg["result"] = result
|
|
140
|
+
end
|
|
141
|
+
@writer.write(msg)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentClientProtocol
|
|
4
|
+
module Contrib
|
|
5
|
+
class PermissionBroker
|
|
6
|
+
def initialize(requester:, tracker: nil)
|
|
7
|
+
@requester = requester
|
|
8
|
+
@tracker = tracker
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def request_for(external_id, session_id:, description: nil, options: nil, content: nil, tool_call: nil)
|
|
12
|
+
tc = tool_call
|
|
13
|
+
if tc.nil? && @tracker
|
|
14
|
+
tc = @tracker.tool_call_model(external_id)
|
|
15
|
+
end
|
|
16
|
+
tc ||= Schema::ToolCallUpdate.new(
|
|
17
|
+
tool_call_id: external_id,
|
|
18
|
+
title: description || external_id
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
opts = options || default_permission_options
|
|
22
|
+
|
|
23
|
+
@requester.call(
|
|
24
|
+
session_id: session_id,
|
|
25
|
+
tool_call: tc,
|
|
26
|
+
options: opts
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.default_permission_options
|
|
31
|
+
[
|
|
32
|
+
Schema::PermissionOption.new(
|
|
33
|
+
option_id: "approve",
|
|
34
|
+
name: "Approve",
|
|
35
|
+
kind: Schema::PermissionOptionKind::ALLOW_ONCE
|
|
36
|
+
),
|
|
37
|
+
Schema::PermissionOption.new(
|
|
38
|
+
option_id: "approve_session",
|
|
39
|
+
name: "Approve for session",
|
|
40
|
+
kind: Schema::PermissionOptionKind::ALLOW_ALWAYS
|
|
41
|
+
),
|
|
42
|
+
Schema::PermissionOption.new(
|
|
43
|
+
option_id: "reject",
|
|
44
|
+
name: "Reject",
|
|
45
|
+
kind: Schema::PermissionOptionKind::REJECT_ONCE
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def default_permission_options
|
|
53
|
+
self.class.default_permission_options
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentClientProtocol
|
|
4
|
+
module Contrib
|
|
5
|
+
class SessionAccumulator
|
|
6
|
+
SessionSnapshot = Struct.new(
|
|
7
|
+
:session_id, :tool_calls, :plan_entries, :current_mode_id,
|
|
8
|
+
:available_commands, :user_messages, :agent_messages, :agent_thoughts,
|
|
9
|
+
:config_options,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
def freeze
|
|
13
|
+
tool_calls&.freeze
|
|
14
|
+
plan_entries&.freeze
|
|
15
|
+
available_commands&.freeze
|
|
16
|
+
user_messages&.freeze
|
|
17
|
+
agent_messages&.freeze
|
|
18
|
+
agent_thoughts&.freeze
|
|
19
|
+
config_options&.freeze
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
reset
|
|
26
|
+
@subscribers = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def reset
|
|
30
|
+
@session_id = nil
|
|
31
|
+
@tool_calls = {}
|
|
32
|
+
@plan_entries = []
|
|
33
|
+
@current_mode_id = nil
|
|
34
|
+
@available_commands = []
|
|
35
|
+
@user_messages = []
|
|
36
|
+
@agent_messages = []
|
|
37
|
+
@agent_thoughts = []
|
|
38
|
+
@config_options = []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply(notification)
|
|
42
|
+
notif = normalize(notification)
|
|
43
|
+
session_id = notif[:session_id]
|
|
44
|
+
update = notif[:update]
|
|
45
|
+
|
|
46
|
+
# Reset on session change
|
|
47
|
+
if @session_id && @session_id != session_id
|
|
48
|
+
reset
|
|
49
|
+
end
|
|
50
|
+
@session_id = session_id
|
|
51
|
+
|
|
52
|
+
apply_update(update)
|
|
53
|
+
|
|
54
|
+
snap = snapshot
|
|
55
|
+
@subscribers.each { |cb| cb.call(snap, notification) }
|
|
56
|
+
snap
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def snapshot
|
|
60
|
+
SessionSnapshot.new(
|
|
61
|
+
session_id: @session_id,
|
|
62
|
+
tool_calls: @tool_calls.dup,
|
|
63
|
+
plan_entries: @plan_entries.dup,
|
|
64
|
+
current_mode_id: @current_mode_id,
|
|
65
|
+
available_commands: @available_commands.dup,
|
|
66
|
+
user_messages: @user_messages.dup,
|
|
67
|
+
agent_messages: @agent_messages.dup,
|
|
68
|
+
agent_thoughts: @agent_thoughts.dup,
|
|
69
|
+
config_options: @config_options.dup
|
|
70
|
+
).freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def subscribe(&block)
|
|
74
|
+
@subscribers << block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def normalize(notification)
|
|
80
|
+
case notification
|
|
81
|
+
when Schema::SessionNotification
|
|
82
|
+
{session_id: notification.session_id, update: notification.update}
|
|
83
|
+
when Hash
|
|
84
|
+
sid = notification["sessionId"] || notification[:session_id]
|
|
85
|
+
upd = notification["update"] || notification[:update]
|
|
86
|
+
{session_id: sid, update: upd}
|
|
87
|
+
else
|
|
88
|
+
{session_id: notification.session_id, update: notification.update}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_update(update)
|
|
93
|
+
return unless update
|
|
94
|
+
|
|
95
|
+
session_update_type = case update
|
|
96
|
+
when TaggedUpdate
|
|
97
|
+
update.tag
|
|
98
|
+
when Hash
|
|
99
|
+
update["sessionUpdate"] || update[:session_update]
|
|
100
|
+
when Schema::BaseModel
|
|
101
|
+
update.to_h["sessionUpdate"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
case session_update_type
|
|
105
|
+
when "user_message_chunk"
|
|
106
|
+
content = extract_content(update)
|
|
107
|
+
@user_messages << content if content
|
|
108
|
+
when "agent_message_chunk"
|
|
109
|
+
content = extract_content(update)
|
|
110
|
+
@agent_messages << content if content
|
|
111
|
+
when "agent_thought_chunk"
|
|
112
|
+
content = extract_content(update)
|
|
113
|
+
@agent_thoughts << content if content
|
|
114
|
+
when "tool_call"
|
|
115
|
+
tc_id = extract_field(update, "toolCallId", :tool_call_id)
|
|
116
|
+
@tool_calls[tc_id] = update if tc_id
|
|
117
|
+
when "tool_call_update"
|
|
118
|
+
tc_id = extract_field(update, "toolCallId", :tool_call_id)
|
|
119
|
+
@tool_calls[tc_id] = merge_tool_call(@tool_calls[tc_id], update) if tc_id
|
|
120
|
+
when "plan"
|
|
121
|
+
entries = extract_field(update, "entries", :entries)
|
|
122
|
+
@plan_entries = entries || []
|
|
123
|
+
when "available_commands_update"
|
|
124
|
+
commands = extract_field(update, "availableCommands", :available_commands)
|
|
125
|
+
@available_commands = commands || []
|
|
126
|
+
when "current_mode_update"
|
|
127
|
+
@current_mode_id = extract_field(update, "currentModeId", :current_mode_id)
|
|
128
|
+
when "config_option_update"
|
|
129
|
+
options = extract_field(update, "configOptions", :config_options)
|
|
130
|
+
@config_options = options || []
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_content(update)
|
|
135
|
+
case update
|
|
136
|
+
when Hash
|
|
137
|
+
update["content"] || update[:content]
|
|
138
|
+
when Schema::ContentChunk
|
|
139
|
+
update.content
|
|
140
|
+
else
|
|
141
|
+
update.respond_to?(:content) ? update.content : nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def extract_field(update, json_key, ruby_key)
|
|
146
|
+
case update
|
|
147
|
+
when Hash
|
|
148
|
+
update[json_key] || update[ruby_key]
|
|
149
|
+
else
|
|
150
|
+
update.respond_to?(ruby_key) ? update.send(ruby_key) : nil
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def merge_tool_call(existing, update)
|
|
155
|
+
# Just replace with the update — the update contains the latest state
|
|
156
|
+
update
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module AgentClientProtocol
|
|
6
|
+
module Contrib
|
|
7
|
+
class ToolCallTracker
|
|
8
|
+
TrackedToolCallView = Struct.new(
|
|
9
|
+
:external_id, :tool_call_id, :title, :kind, :status,
|
|
10
|
+
:content, :locations, :raw_input, :raw_output, :stream_text,
|
|
11
|
+
keyword_init: true
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def initialize(id_factory: -> { SecureRandom.hex(16) })
|
|
15
|
+
@id_factory = id_factory
|
|
16
|
+
@tracked = {} # external_id -> TrackedToolCallView
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start(external_id, title:, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
|
|
20
|
+
tool_call_id = @id_factory.call
|
|
21
|
+
view = TrackedToolCallView.new(
|
|
22
|
+
external_id: external_id,
|
|
23
|
+
tool_call_id: tool_call_id,
|
|
24
|
+
title: title,
|
|
25
|
+
kind: kind,
|
|
26
|
+
status: status || Schema::ToolCallStatus::PENDING,
|
|
27
|
+
content: content || [],
|
|
28
|
+
locations: locations || [],
|
|
29
|
+
raw_input: raw_input,
|
|
30
|
+
raw_output: raw_output,
|
|
31
|
+
stream_text: ""
|
|
32
|
+
)
|
|
33
|
+
@tracked[external_id] = view
|
|
34
|
+
|
|
35
|
+
Schema::ToolCall.new(
|
|
36
|
+
tool_call_id: tool_call_id,
|
|
37
|
+
title: title,
|
|
38
|
+
kind: kind,
|
|
39
|
+
status: status || Schema::ToolCallStatus::PENDING,
|
|
40
|
+
content: content,
|
|
41
|
+
locations: locations,
|
|
42
|
+
raw_input: raw_input,
|
|
43
|
+
raw_output: raw_output
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def progress(external_id, title: nil, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
|
|
48
|
+
view = @tracked[external_id]
|
|
49
|
+
raise ArgumentError, "Unknown tool call: #{external_id}" unless view
|
|
50
|
+
|
|
51
|
+
view.title = title if title
|
|
52
|
+
view.kind = kind if kind
|
|
53
|
+
view.status = status if status
|
|
54
|
+
view.content = content if content
|
|
55
|
+
view.locations = locations if locations
|
|
56
|
+
view.raw_input = raw_input if raw_input
|
|
57
|
+
view.raw_output = raw_output if raw_output
|
|
58
|
+
|
|
59
|
+
Schema::ToolCallUpdate.new(
|
|
60
|
+
tool_call_id: view.tool_call_id,
|
|
61
|
+
title: title,
|
|
62
|
+
kind: kind,
|
|
63
|
+
status: status,
|
|
64
|
+
content: content,
|
|
65
|
+
locations: locations,
|
|
66
|
+
raw_input: raw_input,
|
|
67
|
+
raw_output: raw_output
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def append_stream_text(external_id, text, title: nil, status: nil)
|
|
72
|
+
view = @tracked[external_id]
|
|
73
|
+
raise ArgumentError, "Unknown tool call: #{external_id}" unless view
|
|
74
|
+
|
|
75
|
+
view.stream_text = (view.stream_text || "") + text
|
|
76
|
+
view.title = title if title
|
|
77
|
+
view.status = status if status
|
|
78
|
+
|
|
79
|
+
Schema::ToolCallUpdate.new(
|
|
80
|
+
tool_call_id: view.tool_call_id,
|
|
81
|
+
title: title,
|
|
82
|
+
status: status,
|
|
83
|
+
raw_output: view.stream_text
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def view(external_id)
|
|
88
|
+
view = @tracked[external_id]
|
|
89
|
+
raise ArgumentError, "Unknown tool call: #{external_id}" unless view
|
|
90
|
+
|
|
91
|
+
TrackedToolCallView.new(**view.to_h)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def tool_call_model(external_id)
|
|
95
|
+
v = @tracked[external_id]
|
|
96
|
+
raise ArgumentError, "Unknown tool call: #{external_id}" unless v
|
|
97
|
+
|
|
98
|
+
Schema::ToolCallUpdate.new(
|
|
99
|
+
tool_call_id: v.tool_call_id,
|
|
100
|
+
title: v.title,
|
|
101
|
+
kind: v.kind,
|
|
102
|
+
status: v.status,
|
|
103
|
+
content: v.content,
|
|
104
|
+
locations: v.locations,
|
|
105
|
+
raw_input: v.raw_input,
|
|
106
|
+
raw_output: v.raw_output
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def forget(external_id)
|
|
111
|
+
@tracked.delete(external_id)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentClientProtocol
|
|
4
|
+
class RequestError < StandardError
|
|
5
|
+
attr_reader :code, :data
|
|
6
|
+
|
|
7
|
+
PARSE_ERROR = -32700
|
|
8
|
+
INVALID_REQUEST = -32600
|
|
9
|
+
METHOD_NOT_FOUND = -32601
|
|
10
|
+
INVALID_PARAMS = -32602
|
|
11
|
+
INTERNAL_ERROR = -32603
|
|
12
|
+
AUTH_REQUIRED = -32000
|
|
13
|
+
RESOURCE_NOT_FOUND = -32002
|
|
14
|
+
|
|
15
|
+
def initialize(code, message, data = nil)
|
|
16
|
+
@code = code
|
|
17
|
+
@data = data
|
|
18
|
+
super(message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
h = {"code" => @code, "message" => message}
|
|
23
|
+
h["data"] = @data if @data
|
|
24
|
+
h
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def parse_error(data = nil) = new(PARSE_ERROR, "Parse error", data)
|
|
29
|
+
def invalid_request(data = nil) = new(INVALID_REQUEST, "Invalid request", data)
|
|
30
|
+
def method_not_found(method) = new(METHOD_NOT_FOUND, "Method not found", {"method" => method})
|
|
31
|
+
def invalid_params(data = nil) = new(INVALID_PARAMS, "Invalid params", data)
|
|
32
|
+
def internal_error(data = nil) = new(INTERNAL_ERROR, "Internal error", data)
|
|
33
|
+
def auth_required(data = nil) = new(AUTH_REQUIRED, "Authentication required", data)
|
|
34
|
+
def resource_not_found(uri = nil) = new(RESOURCE_NOT_FOUND, "Resource not found", uri ? {"uri" => uri} : nil)
|
|
35
|
+
|
|
36
|
+
def from_hash(hash)
|
|
37
|
+
new(hash["code"], hash["message"], hash["data"])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentClientProtocol
|
|
4
|
+
module Helpers
|
|
5
|
+
# --- Content blocks ---
|
|
6
|
+
|
|
7
|
+
def text_block(text)
|
|
8
|
+
Schema::TextContent.new(text: text)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def image_block(data, mime_type, uri: nil)
|
|
12
|
+
Schema::ImageContent.new(data: data, mime_type: mime_type, uri: uri)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def audio_block(data, mime_type)
|
|
16
|
+
Schema::AudioContent.new(data: data, mime_type: mime_type)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def resource_link_block(name:, uri:, mime_type: nil, size: nil, description: nil, title: nil)
|
|
20
|
+
Schema::ResourceLink.new(
|
|
21
|
+
name: name, uri: uri, mime_type: mime_type,
|
|
22
|
+
size: size, description: description, title: title
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def embedded_text_resource(uri:, text:, mime_type: nil)
|
|
27
|
+
Schema::TextResourceContents.new(uri: uri, text: text, mime_type: mime_type)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def embedded_blob_resource(uri:, blob:, mime_type: nil)
|
|
31
|
+
Schema::BlobResourceContents.new(uri: uri, blob: blob, mime_type: mime_type)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resource_block(resource)
|
|
35
|
+
Schema::EmbeddedResource.new(resource: resource)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- Tool call content ---
|
|
39
|
+
|
|
40
|
+
def tool_content(block)
|
|
41
|
+
Schema::Content.new(content: block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tool_diff_content(path, new_text, old_text: nil)
|
|
45
|
+
Schema::Diff.new(path: path, new_text: new_text, old_text: old_text)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_terminal_ref(terminal_id)
|
|
49
|
+
Schema::Terminal.new(terminal_id: terminal_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Session updates ---
|
|
53
|
+
# These return TaggedUpdate objects that include the sessionUpdate discriminator
|
|
54
|
+
# when serialized, matching the wire format.
|
|
55
|
+
|
|
56
|
+
def update_agent_message(content)
|
|
57
|
+
TaggedUpdate.new("agent_message_chunk", Schema::ContentChunk.new(content: content))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_agent_message_text(text)
|
|
61
|
+
update_agent_message(text_block(text))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update_user_message(content)
|
|
65
|
+
TaggedUpdate.new("user_message_chunk", Schema::ContentChunk.new(content: content))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update_user_message_text(text)
|
|
69
|
+
update_user_message(text_block(text))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def update_agent_thought(content)
|
|
73
|
+
TaggedUpdate.new("agent_thought_chunk", Schema::ContentChunk.new(content: content))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def update_agent_thought_text(text)
|
|
77
|
+
update_agent_thought(text_block(text))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update_available_commands(commands)
|
|
81
|
+
TaggedUpdate.new("available_commands_update",
|
|
82
|
+
Schema::AvailableCommandsUpdate.new(available_commands: commands))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_current_mode(current_mode_id)
|
|
86
|
+
TaggedUpdate.new("current_mode_update",
|
|
87
|
+
Schema::CurrentModeUpdate.new(current_mode_id: current_mode_id))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update_config_options(config_options)
|
|
91
|
+
TaggedUpdate.new("config_option_update",
|
|
92
|
+
Schema::ConfigOptionUpdate.new(config_options: config_options))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- Plans ---
|
|
96
|
+
|
|
97
|
+
def plan_entry(content, priority: Schema::PlanEntryPriority::MEDIUM, status: Schema::PlanEntryStatus::PENDING)
|
|
98
|
+
Schema::PlanEntry.new(content: content, priority: priority, status: status)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def update_plan(entries)
|
|
102
|
+
TaggedUpdate.new("plan", Schema::Plan.new(entries: entries))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Tool calls ---
|
|
106
|
+
|
|
107
|
+
def start_tool_call(tool_call_id, title, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
|
|
108
|
+
TaggedUpdate.new("tool_call", Schema::ToolCall.new(
|
|
109
|
+
tool_call_id: tool_call_id,
|
|
110
|
+
title: title,
|
|
111
|
+
kind: kind,
|
|
112
|
+
status: status,
|
|
113
|
+
content: content,
|
|
114
|
+
locations: locations,
|
|
115
|
+
raw_input: raw_input,
|
|
116
|
+
raw_output: raw_output
|
|
117
|
+
))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def update_tool_call(tool_call_id, title: nil, kind: nil, status: nil, content: nil, locations: nil, raw_input: nil, raw_output: nil)
|
|
121
|
+
TaggedUpdate.new("tool_call_update", Schema::ToolCallUpdate.new(
|
|
122
|
+
tool_call_id: tool_call_id,
|
|
123
|
+
title: title,
|
|
124
|
+
kind: kind,
|
|
125
|
+
status: status,
|
|
126
|
+
content: content,
|
|
127
|
+
locations: locations,
|
|
128
|
+
raw_input: raw_input,
|
|
129
|
+
raw_output: raw_output
|
|
130
|
+
))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# --- Notification wrapper ---
|
|
134
|
+
|
|
135
|
+
def session_notification(session_id, update)
|
|
136
|
+
Schema::SessionNotification.new(session_id: session_id, update: update)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A session update tagged with its discriminator value.
|
|
141
|
+
# Serializes to include the "sessionUpdate" key in the wire format.
|
|
142
|
+
class TaggedUpdate
|
|
143
|
+
attr_reader :tag, :model
|
|
144
|
+
|
|
145
|
+
def initialize(tag, model)
|
|
146
|
+
@tag = tag
|
|
147
|
+
@model = model
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def to_h
|
|
151
|
+
h = @model.is_a?(Schema::BaseModel) ? @model.to_h : @model
|
|
152
|
+
{"sessionUpdate" => @tag}.merge(h)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_json(*)
|
|
156
|
+
JSON.generate(to_h, *)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Delegate attribute access to the underlying model
|
|
160
|
+
def respond_to_missing?(method, include_private = false)
|
|
161
|
+
@model.respond_to?(method, include_private) || super
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def method_missing(method, ...)
|
|
165
|
+
if @model.respond_to?(method)
|
|
166
|
+
@model.send(method, ...)
|
|
167
|
+
else
|
|
168
|
+
super
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def inspect
|
|
173
|
+
"#<TaggedUpdate tag=#{@tag.inspect} #{@model.inspect}>"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|