antigravity-sdk 0.2.0 → 0.4.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 +4 -4
- data/lib/antigravity/agent.rb +235 -24
- data/lib/antigravity/base.rb +18 -0
- data/lib/antigravity/config.rb +12 -1
- data/lib/antigravity/connection/binary_fetcher.rb +163 -0
- data/lib/antigravity/connection/local_connection.rb +184 -0
- data/lib/antigravity/connection/websocket_client.rb +159 -0
- data/lib/antigravity/conversation.rb +302 -0
- data/lib/antigravity/emojis.rb +46 -17
- data/lib/antigravity/errors.rb +27 -0
- data/lib/antigravity/guards.rb +82 -22
- data/lib/antigravity/hooks.rb +12 -0
- data/lib/antigravity/message.rb +20 -7
- data/lib/antigravity/protocol.rb +193 -0
- data/lib/antigravity/sidecar.rb +7 -5
- data/lib/antigravity/skill.rb +54 -7
- data/lib/antigravity/skill_resolver.rb +140 -0
- data/lib/antigravity/tool.rb +35 -11
- data/lib/antigravity/tool_runner.rb +59 -0
- data/lib/antigravity.rb +9 -0
- metadata +13 -4
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module Antigravity
|
|
8
|
+
module Connection
|
|
9
|
+
# Manages the full lifecycle of communicating with the localharness Go binary.
|
|
10
|
+
#
|
|
11
|
+
# Lifecycle: discover → spawn → stdio handshake → WebSocket → events → shutdown
|
|
12
|
+
#
|
|
13
|
+
# The localharness binary is the core Go engine that powers Antigravity.
|
|
14
|
+
# It handles model calls, tool orchestration, and agent logic.
|
|
15
|
+
# This class is the Ruby adapter that speaks its protocol.
|
|
16
|
+
class LocalConnection < Base
|
|
17
|
+
# Known locations for the localharness binary (checked in order).
|
|
18
|
+
# IMPORTANT: The standalone `localharness` binary (from PyPI wheel) uses
|
|
19
|
+
# the stdin/stdout protobuf handshake. The `language_server` binary
|
|
20
|
+
# (Antigravity.app) does NOT — it's the IDE server mode.
|
|
21
|
+
BINARY_SEARCH_PATHS = [
|
|
22
|
+
File.expand_path('~/.antigravity/bin/localharness'),
|
|
23
|
+
File.expand_path('~/.local/bin/localharness'),
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
HARNESS_SUBCOMMAND = 'localharness'
|
|
27
|
+
|
|
28
|
+
attr_reader :port, :api_key, :pid
|
|
29
|
+
|
|
30
|
+
def initialize(binary_path: nil)
|
|
31
|
+
@binary_path = binary_path || self.class.find_binary!
|
|
32
|
+
@port = nil
|
|
33
|
+
@api_key = nil
|
|
34
|
+
@pid = nil
|
|
35
|
+
@stdin = nil
|
|
36
|
+
@stdout = nil
|
|
37
|
+
@stderr = nil
|
|
38
|
+
@connected = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# --- Binary Discovery ---
|
|
42
|
+
|
|
43
|
+
# Finds the localharness binary. Search order:
|
|
44
|
+
# 1. ANTIGRAVITY_HARNESS_PATH env var
|
|
45
|
+
# 2. Antigravity.app (macOS)
|
|
46
|
+
# 3. ~/.antigravity/bin/
|
|
47
|
+
# 4. PATH lookup via `which`
|
|
48
|
+
# 5. Auto-download from PyPI (unless ANTIGRAVITY_AUTO_DOWNLOAD=false)
|
|
49
|
+
#
|
|
50
|
+
# @return [String] absolute path to the binary
|
|
51
|
+
# @raise [HarnessNotFoundError] if not found anywhere
|
|
52
|
+
def self.find_binary!
|
|
53
|
+
# 1. Env var override
|
|
54
|
+
if (env_path = ENV['ANTIGRAVITY_HARNESS_PATH'])
|
|
55
|
+
return env_path if File.executable?(env_path)
|
|
56
|
+
|
|
57
|
+
raise HarnessNotFoundError,
|
|
58
|
+
"ANTIGRAVITY_HARNESS_PATH=#{env_path} is not executable"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 2. Known paths
|
|
62
|
+
BINARY_SEARCH_PATHS.each do |path|
|
|
63
|
+
return path if File.executable?(path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 3. PATH lookup
|
|
67
|
+
which_result = `which localharness 2>/dev/null`.strip
|
|
68
|
+
return which_result unless which_result.empty?
|
|
69
|
+
|
|
70
|
+
# 4. Auto-download from PyPI wheel (opt-out with ANTIGRAVITY_AUTO_DOWNLOAD=false)
|
|
71
|
+
auto_dl = ENV.fetch('ANTIGRAVITY_AUTO_DOWNLOAD', 'true').downcase
|
|
72
|
+
unless %w[false 0 no].include?(auto_dl)
|
|
73
|
+
$stderr.puts "⏳ localharness not found locally. Downloading from PyPI... this may take a minute."
|
|
74
|
+
return BinaryFetcher.fetch!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise HarnessNotFoundError,
|
|
78
|
+
"Could not find localharness binary. Install Antigravity.app, set ANTIGRAVITY_HARNESS_PATH, or allow auto-download."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- Connection Lifecycle ---
|
|
82
|
+
|
|
83
|
+
def connect!
|
|
84
|
+
spawn_process!
|
|
85
|
+
perform_handshake!
|
|
86
|
+
connect_websocket!
|
|
87
|
+
@connected = true
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def connected?
|
|
92
|
+
@connected && process_alive? && @ws_client&.connected?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def disconnect!
|
|
96
|
+
@connected = false
|
|
97
|
+
@ws_client&.close
|
|
98
|
+
kill_process!
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Expose the WebSocket client for Conversation to use
|
|
102
|
+
def ws_client
|
|
103
|
+
@ws_client
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def spawn_process!
|
|
109
|
+
# Only pass 'localharness' subcommand when binary is language_server
|
|
110
|
+
# (Antigravity.app). The standalone localharness binary needs no subcommand.
|
|
111
|
+
cmd = if @binary_path.end_with?('language_server')
|
|
112
|
+
[@binary_path, HARNESS_SUBCOMMAND]
|
|
113
|
+
else
|
|
114
|
+
[@binary_path]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*cmd)
|
|
118
|
+
@pid = @wait_thread.pid
|
|
119
|
+
rescue Errno::ENOENT => e
|
|
120
|
+
raise HarnessNotFoundError, "Failed to spawn localharness: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def perform_handshake!
|
|
124
|
+
storage_dir = File.join(Dir.tmpdir, "antigravity-ruby-#{$$}")
|
|
125
|
+
FileUtils.mkdir_p(storage_dir)
|
|
126
|
+
|
|
127
|
+
# Send InputConfig via stdin
|
|
128
|
+
input_config = Protocol.encode_input_config(
|
|
129
|
+
storage_directory: storage_dir,
|
|
130
|
+
bind_address: 'localhost'
|
|
131
|
+
)
|
|
132
|
+
@stdin.write(input_config)
|
|
133
|
+
@stdin.flush
|
|
134
|
+
|
|
135
|
+
# Read OutputConfig from stdout
|
|
136
|
+
frame = Protocol.read_length_prefixed(@stdout, timeout: Antigravity.config.timeout_handshake)
|
|
137
|
+
output = Protocol.decode_output_config(frame)
|
|
138
|
+
@port = output[:port]
|
|
139
|
+
@api_key = output[:api_key]
|
|
140
|
+
|
|
141
|
+
raise HarnessHandshakeError, "Harness returned port=0" if @port == 0
|
|
142
|
+
rescue IOError, Errno::EPIPE, ProtocolError => e
|
|
143
|
+
# Capture stderr for diagnostics
|
|
144
|
+
stderr_output = begin
|
|
145
|
+
@stderr&.read_nonblock(4096)
|
|
146
|
+
rescue EOFError, IOError, Errno::EAGAIN
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
kill_process!
|
|
150
|
+
detail = stderr_output ? " Stderr: #{stderr_output.strip}" : ''
|
|
151
|
+
raise HarnessHandshakeError, "Stdio handshake failed: #{e.message}#{detail}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def connect_websocket!
|
|
155
|
+
@ws_client = WebSocketClient.new(port: @port, api_key: @api_key)
|
|
156
|
+
@ws_client.connect!
|
|
157
|
+
rescue => e
|
|
158
|
+
kill_process!
|
|
159
|
+
raise ProtocolError, "WebSocket connection failed: #{e.message}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def process_alive?
|
|
163
|
+
return false unless @pid
|
|
164
|
+
|
|
165
|
+
Process.kill(0, @pid)
|
|
166
|
+
true
|
|
167
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def kill_process!
|
|
172
|
+
[@stdin, @stdout, @stderr].each { |io| io&.close rescue nil }
|
|
173
|
+
Process.kill('TERM', @pid) if @pid && process_alive?
|
|
174
|
+
@wait_thread&.join(5)
|
|
175
|
+
Process.kill('KILL', @pid) if @pid && process_alive?
|
|
176
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
177
|
+
# Already dead, fine
|
|
178
|
+
ensure
|
|
179
|
+
@pid = nil
|
|
180
|
+
@stdin = @stdout = @stderr = nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'websocket'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
|
|
8
|
+
module Antigravity
|
|
9
|
+
module Connection
|
|
10
|
+
# Lightweight WebSocket client for the localharness.
|
|
11
|
+
# Uses the `websocket` gem for frame encoding/decoding over raw TCPSocket.
|
|
12
|
+
# No EventMachine, no threads-by-default — just blocking IO.
|
|
13
|
+
class WebSocketClient
|
|
14
|
+
attr_reader :port, :api_key, :connected
|
|
15
|
+
|
|
16
|
+
def initialize(port:, api_key:)
|
|
17
|
+
@port = port
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@socket = nil
|
|
20
|
+
@handshake = nil
|
|
21
|
+
@connected = false
|
|
22
|
+
@frame_buffer = WebSocket::Frame::Incoming::Client.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Open the WebSocket connection to ws://localhost:<port>/
|
|
26
|
+
def connect!
|
|
27
|
+
@socket = TCPSocket.new('127.0.0.1', @port)
|
|
28
|
+
|
|
29
|
+
# Build and send the HTTP upgrade handshake
|
|
30
|
+
@handshake = WebSocket::Handshake::Client.new(
|
|
31
|
+
url: "ws://127.0.0.1:#{@port}/",
|
|
32
|
+
headers: { 'x-goog-api-key' => @api_key }
|
|
33
|
+
)
|
|
34
|
+
@socket.write(@handshake.to_s)
|
|
35
|
+
@socket.flush
|
|
36
|
+
|
|
37
|
+
# Read the server's handshake response
|
|
38
|
+
loop do
|
|
39
|
+
line = @socket.gets
|
|
40
|
+
raise ProtocolError, 'EOF during WebSocket handshake' unless line
|
|
41
|
+
|
|
42
|
+
@handshake << line
|
|
43
|
+
break if @handshake.finished?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
unless @handshake.valid?
|
|
47
|
+
raise ProtocolError, "WebSocket handshake rejected: #{@handshake.error}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@connected = true
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def connected?
|
|
55
|
+
@connected && @socket && !@socket.closed?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Send a JSON message as a WebSocket text frame
|
|
59
|
+
def send_json(data)
|
|
60
|
+
json = data.is_a?(String) ? data : JSON.generate(data)
|
|
61
|
+
frame = WebSocket::Frame::Outgoing::Client.new(
|
|
62
|
+
data: json,
|
|
63
|
+
type: :text,
|
|
64
|
+
version: @handshake.version
|
|
65
|
+
)
|
|
66
|
+
@socket.write(frame.to_s)
|
|
67
|
+
@socket.flush
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Read the next JSON message. Blocks until a text frame arrives.
|
|
71
|
+
# Yields each message if a block is given (for streaming).
|
|
72
|
+
# Returns nil on connection close or idle_timeout.
|
|
73
|
+
def receive_json(timeout: Antigravity.config.timeout_llm, idle_timeout: nil)
|
|
74
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
75
|
+
last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
|
|
77
|
+
loop do
|
|
78
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
79
|
+
remaining = deadline - now
|
|
80
|
+
raise ProtocolError, 'WebSocket read timeout' if remaining <= 0
|
|
81
|
+
|
|
82
|
+
if idle_timeout && (now - last_activity) >= idle_timeout
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
select_time = [remaining, 0.5].min
|
|
87
|
+
if idle_timeout
|
|
88
|
+
idle_rem = idle_timeout - (now - last_activity)
|
|
89
|
+
select_time = [select_time, idle_rem].min if idle_rem > 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
ready = IO.select([@socket], nil, nil, [select_time, 0.05].max)
|
|
93
|
+
next unless ready
|
|
94
|
+
|
|
95
|
+
data = @socket.read_nonblock(16384, exception: false)
|
|
96
|
+
case data
|
|
97
|
+
when :wait_readable then next
|
|
98
|
+
when nil
|
|
99
|
+
@connected = false
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
|
+
@frame_buffer << data
|
|
105
|
+
|
|
106
|
+
while (frame = @frame_buffer.next)
|
|
107
|
+
case frame.type
|
|
108
|
+
when :text
|
|
109
|
+
return JSON.parse(frame.data, symbolize_names: true)
|
|
110
|
+
when :ping
|
|
111
|
+
pong = WebSocket::Frame::Outgoing::Client.new(
|
|
112
|
+
data: frame.data, type: :pong, version: @handshake.version
|
|
113
|
+
)
|
|
114
|
+
@socket.write(pong.to_s)
|
|
115
|
+
when :close
|
|
116
|
+
@connected = false
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Read messages in a loop, yielding each parsed JSON.
|
|
124
|
+
# Stops when block returns :stop, connection closes, or timeout.
|
|
125
|
+
def each_message(timeout: Antigravity.config.timeout_llm, &block)
|
|
126
|
+
loop do
|
|
127
|
+
# Block can return [:stop] or [:idle_timeout, seconds]
|
|
128
|
+
msg = receive_json(timeout: timeout, idle_timeout: @current_idle_timeout)
|
|
129
|
+
break unless msg
|
|
130
|
+
|
|
131
|
+
result = block.call(msg)
|
|
132
|
+
if result.is_a?(Array) && result.first == :idle_timeout
|
|
133
|
+
@current_idle_timeout = result.last
|
|
134
|
+
elsif result == :stop
|
|
135
|
+
@current_idle_timeout = nil
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
ensure
|
|
140
|
+
@current_idle_timeout = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def close
|
|
144
|
+
return unless @socket && !@socket.closed?
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
close_frame = WebSocket::Frame::Outgoing::Client.new(
|
|
148
|
+
data: '', type: :close, version: @handshake&.version || 13
|
|
149
|
+
)
|
|
150
|
+
@socket.write(close_frame.to_s)
|
|
151
|
+
rescue IOError, Errno::EPIPE
|
|
152
|
+
# Already closed
|
|
153
|
+
end
|
|
154
|
+
@socket.close rescue nil
|
|
155
|
+
@connected = false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Antigravity
|
|
7
|
+
# Manages a multi-turn conversation with the harness over WebSocket.
|
|
8
|
+
# Handles the event loop: send InputEvent, receive OutputEvents, dispatch tools.
|
|
9
|
+
#
|
|
10
|
+
# Mirrors Python SDK's Conversation class:
|
|
11
|
+
# - history, turn_count, conversation_id
|
|
12
|
+
# - last_turn_usage, total_usage
|
|
13
|
+
class Conversation
|
|
14
|
+
attr_reader :conversation_id, :history, :turn_count, :total_usage, :last_turn_usage
|
|
15
|
+
|
|
16
|
+
def initialize(ws_client:, tool_runner: nil, hooks: nil)
|
|
17
|
+
@ws = ws_client
|
|
18
|
+
@tool_runner = tool_runner || ToolRunner.new
|
|
19
|
+
@hooks = hooks
|
|
20
|
+
@conversation_id = nil
|
|
21
|
+
@history = []
|
|
22
|
+
@turn_count = 0
|
|
23
|
+
@total_usage = empty_usage
|
|
24
|
+
@last_turn_usage = nil
|
|
25
|
+
@initialized = false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Initialize the conversation session with the harness.
|
|
29
|
+
# Sends InitializeConversationEvent (protobuf JSON format), receives response.
|
|
30
|
+
def initialize_session!(harness_config:)
|
|
31
|
+
# harness_config IS the InitializeConversationEvent JSON:
|
|
32
|
+
# { config: { models: [...], workspaces: [...], ... } }
|
|
33
|
+
@ws.send_json(harness_config)
|
|
34
|
+
|
|
35
|
+
# Wait for InitializeConversationResponse
|
|
36
|
+
msg = @ws.receive_json(timeout: 30)
|
|
37
|
+
raise ProtocolError, 'No response to InitializeConversationEvent' unless msg
|
|
38
|
+
|
|
39
|
+
if (init_resp = msg[:initializeConversationResponse])
|
|
40
|
+
@conversation_id = init_resp[:cascadeId]
|
|
41
|
+
@initialized = true
|
|
42
|
+
else
|
|
43
|
+
# Some harness versions may wrap differently
|
|
44
|
+
@conversation_id = msg[:cascadeId] || msg.dig(:config, :cascadeId) || SecureRandom.uuid
|
|
45
|
+
@initialized = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Send a user message and collect the full response.
|
|
52
|
+
# Yields streaming chunks if a block is given.
|
|
53
|
+
#
|
|
54
|
+
# @param prompt [String] user message
|
|
55
|
+
# @return [Message] the complete response
|
|
56
|
+
def chat(prompt, timeout: Antigravity.config.timeout_llm, &block)
|
|
57
|
+
raise ProtocolError, 'Session not initialized' unless @initialized
|
|
58
|
+
|
|
59
|
+
@turn_count += 1
|
|
60
|
+
@last_turn_usage = empty_usage
|
|
61
|
+
|
|
62
|
+
# Send user input (protobuf InputEvent with user_input string field)
|
|
63
|
+
input_event = {
|
|
64
|
+
userInput: prompt
|
|
65
|
+
}
|
|
66
|
+
@ws.send_json(input_event)
|
|
67
|
+
|
|
68
|
+
# Collect response
|
|
69
|
+
collect_response(timeout: timeout, &block)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialized?
|
|
73
|
+
@initialized
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Generate a session summary hash (mirrors Python's metadata)
|
|
77
|
+
def session_summary(model: nil)
|
|
78
|
+
{
|
|
79
|
+
conversation_id: @conversation_id,
|
|
80
|
+
turn_count: @turn_count,
|
|
81
|
+
total_tokens: @total_usage[:total_token_count],
|
|
82
|
+
prompt_tokens: @total_usage[:prompt_token_count],
|
|
83
|
+
candidates_tokens: @total_usage[:candidates_token_count],
|
|
84
|
+
model: model
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def collect_response(timeout: Antigravity.config.timeout_llm, &block)
|
|
91
|
+
text_parts = []
|
|
92
|
+
thinking_parts = []
|
|
93
|
+
steps = []
|
|
94
|
+
tool_calls_count = 0
|
|
95
|
+
finished = false
|
|
96
|
+
finished_at = nil
|
|
97
|
+
|
|
98
|
+
@ws.each_message(timeout: timeout) do |msg|
|
|
99
|
+
@hooks&.emit(:ws_message, msg)
|
|
100
|
+
|
|
101
|
+
if (step = msg[:stepUpdate])
|
|
102
|
+
step_record = parse_step(step)
|
|
103
|
+
steps << step_record
|
|
104
|
+
|
|
105
|
+
# Text delta — stream it (only from model aimed at user, not tool descriptions or error steps)
|
|
106
|
+
is_model_step = step[:source].to_s =~ /MODEL|model|3/
|
|
107
|
+
is_target_user = step[:target].to_s =~ /USER|user|1/
|
|
108
|
+
is_error_step = step[:state].to_s =~ /ERROR|error|4/ || step[:errorMessage]
|
|
109
|
+
|
|
110
|
+
if step[:textDelta] && !step[:textDelta].empty? && is_model_step && is_target_user && !is_error_step
|
|
111
|
+
text_parts << step[:textDelta]
|
|
112
|
+
chunk = Message.new(
|
|
113
|
+
content: step[:textDelta],
|
|
114
|
+
role: :assistant,
|
|
115
|
+
delta: true
|
|
116
|
+
)
|
|
117
|
+
block&.call(chunk)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Thinking delta
|
|
121
|
+
if step[:thinkingDelta] && !step[:thinkingDelta].empty?
|
|
122
|
+
thinking_parts << step[:thinkingDelta]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Custom tool action
|
|
126
|
+
if step[:customTool]
|
|
127
|
+
tool_calls_count += 1
|
|
128
|
+
handle_custom_tool(step)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Harness built-in tool actions count
|
|
132
|
+
if step_record[:target] == :environment && step_record[:source] == :model
|
|
133
|
+
tool_calls_count += 1 unless step[:customTool]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Finished? Model response to user with DONE state AND text collected.
|
|
137
|
+
# NOTE: DONE can arrive before text deltas, so we require text here.
|
|
138
|
+
# For tool-only turns (no text), FULLY_IDLE below is the authoritative stop.
|
|
139
|
+
if is_model_step && is_target_user && !is_error_step && step[:state] && step[:state].to_s =~ /DONE|done|2/ && !text_parts.empty?
|
|
140
|
+
finished = true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Top-level tool call (custom tools are sent as separate messages, not in stepUpdate)
|
|
145
|
+
if (tool_call = msg[:toolCall])
|
|
146
|
+
tool_calls_count += 1
|
|
147
|
+
handle_tool_call(tool_call)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Usage update
|
|
151
|
+
if (usage = msg[:usageUpdate])
|
|
152
|
+
update_usage(usage)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Trajectory state: STATE_FULLY_IDLE / STATE_CANCELLED = turn complete (authoritative signal from harness)
|
|
156
|
+
# This is the MOST authoritative signal — always stop, even with empty text.
|
|
157
|
+
if (traj = msg[:trajectoryStateUpdate])
|
|
158
|
+
if traj[:state].to_s =~ /FULLY_IDLE|CANCELLED/
|
|
159
|
+
finished = true
|
|
160
|
+
finished_at = Time.now
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Stop conditions (in priority order):
|
|
165
|
+
# 1. Session end — always stop
|
|
166
|
+
# 2. Model response DONE or trajectory FULLY_IDLE / CANCELLED — turn complete
|
|
167
|
+
if msg.key?(:sessionEndResponse) || finished
|
|
168
|
+
:stop
|
|
169
|
+
elsif !text_parts.empty?
|
|
170
|
+
# If assistant has sent text response, allow 3s idle timeout for trailing metadata
|
|
171
|
+
[:idle_timeout, 3.0]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Build final message
|
|
176
|
+
Message.new(
|
|
177
|
+
content: text_parts.join,
|
|
178
|
+
role: :assistant,
|
|
179
|
+
thinking: thinking_parts.join,
|
|
180
|
+
steps: steps,
|
|
181
|
+
tool_calls_count: tool_calls_count,
|
|
182
|
+
usage: @last_turn_usage.dup
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def handle_custom_tool(step)
|
|
187
|
+
tool_data = step[:customTool]
|
|
188
|
+
tool_call = tool_data[:toolCall] || tool_data
|
|
189
|
+
handle_tool_call(tool_call)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Handle a top-level toolCall message from the harness
|
|
193
|
+
# Format: {id: "...", name: "tool_name", argumentsJson: "{...}"}
|
|
194
|
+
def handle_tool_call(tool_call)
|
|
195
|
+
tool_id = tool_call[:id]
|
|
196
|
+
tool_name = tool_call[:name]
|
|
197
|
+
|
|
198
|
+
# Parse arguments from JSON string
|
|
199
|
+
args_json = tool_call[:argumentsJson] || tool_call[:arguments_json]
|
|
200
|
+
args = if args_json.is_a?(String) && !args_json.empty?
|
|
201
|
+
JSON.parse(args_json, symbolize_names: true)
|
|
202
|
+
elsif tool_call[:arguments].is_a?(Hash)
|
|
203
|
+
tool_call[:arguments]
|
|
204
|
+
else
|
|
205
|
+
{}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Symbolize keys for Ruby kwargs
|
|
209
|
+
kwargs = args.transform_keys(&:to_sym)
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
result = @tool_runner.execute(tool_name, **kwargs)
|
|
213
|
+
rescue ToolNotFoundError => e
|
|
214
|
+
result = { error: e.message }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Send tool response back (protobuf InputEvent.tool_response format)
|
|
218
|
+
# The harness expects responseJson to be a JSON object (Python SDK wraps in {"result": ...})
|
|
219
|
+
result_dict = result.is_a?(Hash) ? result : { result: result.to_s }
|
|
220
|
+
tool_response = {
|
|
221
|
+
toolResponse: {
|
|
222
|
+
id: tool_id,
|
|
223
|
+
responseJson: JSON.generate(result_dict)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
@ws.send_json(tool_response)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def parse_step(step)
|
|
230
|
+
{
|
|
231
|
+
step_index: step[:stepIndex],
|
|
232
|
+
state: parse_state(step[:state]),
|
|
233
|
+
source: parse_source(step[:source]),
|
|
234
|
+
target: parse_target(step[:target]),
|
|
235
|
+
text_delta: step[:textDelta],
|
|
236
|
+
text: step[:text],
|
|
237
|
+
thinking_delta: step[:thinkingDelta],
|
|
238
|
+
error: step[:errorMessage],
|
|
239
|
+
cascade_id: step[:cascadeId],
|
|
240
|
+
trajectory_id: step[:trajectoryId]
|
|
241
|
+
}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_state(val)
|
|
245
|
+
case val.to_s
|
|
246
|
+
when /ACTIVE|1/ then :active
|
|
247
|
+
when /DONE|2/ then :done
|
|
248
|
+
when /WAITING|3/ then :waiting
|
|
249
|
+
when /ERROR|4/ then :error
|
|
250
|
+
else :unknown
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse_source(val)
|
|
255
|
+
case val.to_s
|
|
256
|
+
when /SYSTEM|1/ then :system
|
|
257
|
+
when /USER|2/ then :user
|
|
258
|
+
when /MODEL|3/ then :model
|
|
259
|
+
else :unknown
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def parse_target(val)
|
|
264
|
+
case val.to_s
|
|
265
|
+
when /USER|1/ then :user
|
|
266
|
+
when /MODEL|2/ then :model
|
|
267
|
+
when /ENVIRONMENT|3/ then :environment
|
|
268
|
+
else :unknown
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def update_usage(usage)
|
|
273
|
+
# The harness sends: { total: { promptTokenCount: "3855", ... }, agents: [...] }
|
|
274
|
+
# Fall back to cumulativeUsage (legacy) or flat usage hash
|
|
275
|
+
meta = usage[:total] || usage[:cumulativeUsage] || usage
|
|
276
|
+
@last_turn_usage = {
|
|
277
|
+
prompt_token_count: meta[:promptTokenCount].to_i,
|
|
278
|
+
candidates_token_count: meta[:candidatesTokenCount].to_i,
|
|
279
|
+
thoughts_token_count: meta[:thoughtsTokenCount].to_i,
|
|
280
|
+
total_token_count: meta[:totalTokenCount].to_i,
|
|
281
|
+
cached_content_token_count: meta[:cachedContentTokenCount].to_i
|
|
282
|
+
}
|
|
283
|
+
# Accumulate into total
|
|
284
|
+
@last_turn_usage.each { |k, v| @total_usage[k] += v }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def empty_usage
|
|
288
|
+
{
|
|
289
|
+
prompt_token_count: 0,
|
|
290
|
+
candidates_token_count: 0,
|
|
291
|
+
thoughts_token_count: 0,
|
|
292
|
+
total_token_count: 0,
|
|
293
|
+
cached_content_token_count: 0
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def next_seq
|
|
298
|
+
@seq_counter ||= 0
|
|
299
|
+
@seq_counter += 1
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|