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.
@@ -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