antigravity-sdk 0.3.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 +234 -22
- 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 +4 -0
- 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 +19 -5
- data/lib/antigravity/protocol.rb +193 -0
- data/lib/antigravity/skill.rb +53 -5
- data/lib/antigravity/skill_resolver.rb +140 -0
- data/lib/antigravity/tool.rb +33 -6
- data/lib/antigravity/tool_runner.rb +59 -0
- data/lib/antigravity.rb +8 -0
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b2a5cad1edda5eb5efaa58d7d5104ba433f8469a923bb5f862b9f1bcb4219021
|
|
4
|
+
data.tar.gz: 75c9924910ef8aab407444abfecfa0f7e989473d8be251691e4e07c9d04499aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac637d9792051869f94b4a8ebc716fd604b2db18886e19b105190a9636c7e7bce418aeece52a360b139b1c534e560605b0dc578fab10b3e06ac913dc454663eb
|
|
7
|
+
data.tar.gz: c8b6e4bee541853abd1f993d5423cc3426c830a24aba58f6790ec30e093279163b83e258a824811380ac5a603cba749dd7a099dee5ad4a2160fe25a7373c6e51
|
data/lib/antigravity/agent.rb
CHANGED
|
@@ -4,27 +4,122 @@ module Antigravity
|
|
|
4
4
|
class Agent < Base
|
|
5
5
|
|
|
6
6
|
attr_accessor :model, :system_instruction, :api_key
|
|
7
|
-
attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard
|
|
7
|
+
attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
|
|
8
|
+
:workspace, :connection, :conversation
|
|
8
9
|
|
|
9
|
-
def initialize(model: nil,
|
|
10
|
+
def initialize(model: nil, system_instruction: nil, tools: [],
|
|
11
|
+
skills: [], workspace: nil, auto_logger: true, log_file: nil, &block)
|
|
10
12
|
@model = model || Antigravity.config.default_model
|
|
11
13
|
@api_key = Antigravity.config.api_key
|
|
12
|
-
@system_instruction =
|
|
13
|
-
@
|
|
14
|
+
@system_instruction = system_instruction
|
|
15
|
+
@workspace = workspace ? File.expand_path(workspace) : nil
|
|
16
|
+
@tools = tools.dup
|
|
14
17
|
@skills = []
|
|
15
18
|
@sidecars = []
|
|
16
19
|
@hooks = Hooks.new
|
|
17
20
|
@client = Client.new
|
|
18
21
|
@logger_guard = nil
|
|
22
|
+
@connection = nil
|
|
23
|
+
@conversation = nil
|
|
24
|
+
@connected = false
|
|
25
|
+
|
|
26
|
+
# Register pre-provided tools into the tool runner
|
|
27
|
+
@tool_runner = ToolRunner.new
|
|
28
|
+
@tools.each { |t| @tool_runner.register(t) }
|
|
29
|
+
|
|
30
|
+
# Load skills provided at construction (local paths or GitHub URLs)
|
|
31
|
+
add_skills(skills) unless Array(skills).empty?
|
|
19
32
|
|
|
20
33
|
# Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
|
|
21
34
|
if auto_logger && logger_enabled?
|
|
22
|
-
attach_logger
|
|
35
|
+
attach_logger(log_file)
|
|
23
36
|
end
|
|
24
37
|
|
|
25
38
|
yield(self) if block_given?
|
|
26
39
|
end
|
|
27
40
|
|
|
41
|
+
# --- Class Methods ---
|
|
42
|
+
|
|
43
|
+
# Block form: opens connection, yields agent, auto-closes.
|
|
44
|
+
def self.open(**kwargs, &block)
|
|
45
|
+
agent = new(**kwargs)
|
|
46
|
+
agent.connect!
|
|
47
|
+
begin
|
|
48
|
+
block.call(agent)
|
|
49
|
+
ensure
|
|
50
|
+
agent.close!
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Connection Lifecycle ---
|
|
55
|
+
|
|
56
|
+
def connect!
|
|
57
|
+
return self if @connected
|
|
58
|
+
|
|
59
|
+
@connection = Connection::LocalConnection.new
|
|
60
|
+
@connection.connect!
|
|
61
|
+
|
|
62
|
+
@conversation = Conversation.new(
|
|
63
|
+
ws_client: @connection.ws_client,
|
|
64
|
+
tool_runner: @tool_runner,
|
|
65
|
+
hooks: @hooks
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
harness_config = build_harness_config
|
|
69
|
+
@conversation.initialize_session!(harness_config: harness_config)
|
|
70
|
+
@connected = true
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def connected?
|
|
75
|
+
@connected && @connection&.connected?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close!
|
|
79
|
+
@connected = false
|
|
80
|
+
@connection&.disconnect!
|
|
81
|
+
@connection = nil
|
|
82
|
+
@conversation = nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- Chat ---
|
|
86
|
+
|
|
87
|
+
def prompt(message, timeout: Antigravity.config.timeout_llm, &block)
|
|
88
|
+
emit_sidecar_event(:prompt_started, prompt: message)
|
|
89
|
+
hooks.run_pre_prompt(message)
|
|
90
|
+
|
|
91
|
+
if @connected && @conversation
|
|
92
|
+
response = @conversation.chat(message, timeout: timeout, &block)
|
|
93
|
+
else
|
|
94
|
+
# Legacy mock-client path (unit tests, pre-connection)
|
|
95
|
+
response = client.send_turn(self, message, &block)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
hooks.run_post_response(response)
|
|
99
|
+
emit_sidecar_event(:turn_completed, response: response.content, model: model)
|
|
100
|
+
|
|
101
|
+
response
|
|
102
|
+
end
|
|
103
|
+
alias ask prompt
|
|
104
|
+
|
|
105
|
+
# --- Metadata Accessors (mirrors Python SDK) ---
|
|
106
|
+
|
|
107
|
+
def conversation_id
|
|
108
|
+
@conversation&.conversation_id
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def turn_count
|
|
112
|
+
@conversation&.turn_count || 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def session_summary
|
|
116
|
+
return {} unless @conversation
|
|
117
|
+
|
|
118
|
+
@conversation.session_summary(model: @model)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Tool Registration ---
|
|
122
|
+
|
|
28
123
|
def register_tool(tool_or_name = nil, description: "", &block)
|
|
29
124
|
if block_given? && tool_or_name
|
|
30
125
|
tool = Tool::Dynamic.new(tool_or_name, description: description, &block)
|
|
@@ -34,6 +129,7 @@ module Antigravity
|
|
|
34
129
|
raise ArgumentError, "Invalid tool definition"
|
|
35
130
|
end
|
|
36
131
|
@tools << tool
|
|
132
|
+
@tool_runner.register(tool) if @tool_runner
|
|
37
133
|
tool
|
|
38
134
|
end
|
|
39
135
|
|
|
@@ -42,18 +138,61 @@ module Antigravity
|
|
|
42
138
|
sidecar
|
|
43
139
|
end
|
|
44
140
|
|
|
45
|
-
def attach_logger(log_target = nil, level:
|
|
141
|
+
def attach_logger(log_target = nil, level: :info, silent_notice: false)
|
|
46
142
|
@logger_guard = Guards::AgentLogger.new(log_target, level: level, silent_notice: silent_notice)
|
|
47
143
|
@logger_guard.attach_to(self)
|
|
48
144
|
@logger_guard
|
|
49
145
|
end
|
|
50
146
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
147
|
+
# Add a single skill by path or GitHub URL.
|
|
148
|
+
# Raises if the path resolves to multiple skills (use add_skills instead).
|
|
149
|
+
# @param path_or_url [String] local path or GitHub URL
|
|
150
|
+
# @param skill_name [String, nil] optional specific skill name within a repo
|
|
151
|
+
# @return [Skill] the loaded skill
|
|
152
|
+
def add_skill(path_or_url, skill_name: nil)
|
|
153
|
+
target = skill_name ? "#{path_or_url.to_s.chomp('/')}/#{skill_name}" : path_or_url.to_s
|
|
154
|
+
paths = SkillResolver.resolve(target)
|
|
155
|
+
if paths.size > 1
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
"add_skill resolved to #{paths.size} skills. Use add_skills instead, " \
|
|
158
|
+
"or specify skill_name: to pick one."
|
|
159
|
+
end
|
|
160
|
+
raise ArgumentError, "No skill found at #{target}" if paths.empty?
|
|
161
|
+
|
|
162
|
+
load_single_skill(paths.first)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Add one or more skills by path or GitHub URL.
|
|
166
|
+
# Accepts a single string or an array. Each entry is resolved (may expand to multiple).
|
|
167
|
+
# @param paths_or_urls [String, Array<String>] local paths or GitHub URLs
|
|
168
|
+
# @return [Array<Skill>] all loaded skills
|
|
169
|
+
def add_skills(paths_or_urls)
|
|
170
|
+
Array(paths_or_urls).flat_map do |p|
|
|
171
|
+
SkillResolver.resolve(p).map { |skill_path| load_single_skill(skill_path) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Create and add an inline skill (no file needed).
|
|
176
|
+
# @param name [String] skill name
|
|
177
|
+
# @param description [String] what the skill does
|
|
178
|
+
# @param instructions [String] the skill body (markdown)
|
|
179
|
+
# @return [Skill] the inline skill
|
|
180
|
+
def add_inline_skill(name:, description:, instructions:)
|
|
181
|
+
skill = Skill.inline(name: name, description: description, instructions: instructions)
|
|
182
|
+
@skills << skill unless @skills.any? { |s| s.name == skill.name }
|
|
54
183
|
skill
|
|
55
184
|
end
|
|
56
185
|
|
|
186
|
+
# List discovered skills in a container path (without loading them).
|
|
187
|
+
# @param path_or_url [String] local path or GitHub URL
|
|
188
|
+
# @return [Array<String>] skill directory paths
|
|
189
|
+
def self.list_skills(path_or_url)
|
|
190
|
+
SkillResolver.resolve(path_or_url)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Legacy alias
|
|
194
|
+
alias_method :load_skill, :add_skill
|
|
195
|
+
|
|
57
196
|
def before_prompt(&block)
|
|
58
197
|
hooks.before_prompt(&block)
|
|
59
198
|
end
|
|
@@ -75,19 +214,6 @@ module Antigravity
|
|
|
75
214
|
@sidecars.each { |sidecar| sidecar.emit(event_type, payload) }
|
|
76
215
|
end
|
|
77
216
|
|
|
78
|
-
def prompt(message, &block)
|
|
79
|
-
emit_sidecar_event(:prompt_started, prompt: message)
|
|
80
|
-
hooks.run_pre_prompt(message)
|
|
81
|
-
|
|
82
|
-
response = client.send_turn(self, message, &block)
|
|
83
|
-
|
|
84
|
-
hooks.run_post_response(response)
|
|
85
|
-
emit_sidecar_event(:turn_completed, response: response.content, model: model)
|
|
86
|
-
|
|
87
|
-
response
|
|
88
|
-
end
|
|
89
|
-
alias ask prompt
|
|
90
|
-
|
|
91
217
|
private
|
|
92
218
|
|
|
93
219
|
def logger_enabled?
|
|
@@ -96,5 +222,91 @@ module Antigravity
|
|
|
96
222
|
|
|
97
223
|
true
|
|
98
224
|
end
|
|
225
|
+
|
|
226
|
+
def build_harness_config
|
|
227
|
+
api_key = ENV.fetch('GEMINI_API_KEY') {
|
|
228
|
+
raise ConfigError, 'GEMINI_API_KEY environment variable is required'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
config = {
|
|
232
|
+
config: {
|
|
233
|
+
models: [
|
|
234
|
+
{
|
|
235
|
+
name: @model,
|
|
236
|
+
geminiApiEndpoint: {
|
|
237
|
+
apiKey: api_key
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# Add workspaces if specified
|
|
245
|
+
if @workspace
|
|
246
|
+
expanded = File.expand_path(@workspace)
|
|
247
|
+
$stderr.puts "\u231B Indexing workspace: #{expanded} — this may take a moment..."
|
|
248
|
+
config[:config][:workspaces] = [
|
|
249
|
+
{
|
|
250
|
+
filesystemWorkspace: {
|
|
251
|
+
directory: expanded
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Add system instructions if specified (protobuf: SystemInstructions.custom.part[])
|
|
258
|
+
effective_instructions = @system_instruction || ''
|
|
259
|
+
|
|
260
|
+
# Auto-append workspace tool hints — models (esp. flash-lite) won't use tools unless told
|
|
261
|
+
if @workspace && !effective_instructions.match?(/list_dir|view_file|tools/i)
|
|
262
|
+
tool_hint = 'You have access to the workspace filesystem. Use the available tools (list_dir, view_file, grep_search) to explore it.'
|
|
263
|
+
effective_instructions = effective_instructions.empty? ? tool_hint : "#{effective_instructions}\n#{tool_hint}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
unless effective_instructions.empty?
|
|
267
|
+
config[:config][:systemInstructions] = {
|
|
268
|
+
custom: {
|
|
269
|
+
part: [{ text: effective_instructions }]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Add custom tools
|
|
275
|
+
if @tool_runner && !@tool_runner.empty?
|
|
276
|
+
config[:config][:tools] = @tool_runner.to_harness_tools
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Enable harness-side built-in tools by default (list_dir, view_file, grep_search, etc.)
|
|
280
|
+
config[:config][:harnessSideTools] = {
|
|
281
|
+
listDir: { enabled: true },
|
|
282
|
+
viewFile: { enabled: true },
|
|
283
|
+
grepSearch: { enabled: true },
|
|
284
|
+
find: { enabled: true },
|
|
285
|
+
writeToFile: { enabled: true },
|
|
286
|
+
fileEdit: { enabled: true },
|
|
287
|
+
readUrlContent: { enabled: true },
|
|
288
|
+
searchWeb: { enabled: true }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Wire skills paths for harness (proto field: skills_paths)
|
|
292
|
+
unless @skills.empty?
|
|
293
|
+
skill_paths = @skills.select(&:path).map(&:path)
|
|
294
|
+
config[:config][:skillsPaths] = skill_paths unless skill_paths.empty?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# App data dir
|
|
298
|
+
config[:config][:appDataDir] = File.expand_path('~/.gemini/antigravity')
|
|
299
|
+
|
|
300
|
+
config
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def load_single_skill(skill_path)
|
|
304
|
+
# Dedup by path
|
|
305
|
+
return @skills.find { |s| s.path == skill_path } if @skills.any? { |s| s.path == skill_path }
|
|
306
|
+
|
|
307
|
+
skill = Skill.load(skill_path)
|
|
308
|
+
@skills << skill
|
|
309
|
+
skill
|
|
310
|
+
end
|
|
99
311
|
end
|
|
100
312
|
end
|
data/lib/antigravity/config.rb
CHANGED
|
@@ -4,11 +4,22 @@ module Antigravity
|
|
|
4
4
|
class Config
|
|
5
5
|
attr_accessor :api_key, :default_model, :harness_path, :log_level
|
|
6
6
|
|
|
7
|
+
# Timeouts (seconds) — aggressive for dev, override via ENV for prod.
|
|
8
|
+
# ANTIGRAVITY_TIMEOUT_LLM — per-message wait for LLM response (default: 20s)
|
|
9
|
+
# ANTIGRAVITY_TIMEOUT_WS — WebSocket connect/handshake (default: 3s)
|
|
10
|
+
# ANTIGRAVITY_TIMEOUT_HANDSHAKE — stdio binary handshake (default: 5s)
|
|
11
|
+
attr_accessor :timeout_llm, :timeout_ws, :timeout_handshake
|
|
12
|
+
|
|
7
13
|
def initialize
|
|
8
14
|
@api_key = ENV["GEMINI_API_KEY"]
|
|
9
|
-
@default_model = "gemini-flash
|
|
15
|
+
@default_model = ENV["GEMINI_MODEL"] || ENV["ANTIGRAVITY_MODEL"] || "gemini-3.6-flash"
|
|
10
16
|
@harness_path = ENV["ANTIGRAVITY_HARNESS_PATH"] || File.expand_path("~/.antigravity/bin/localharness")
|
|
11
17
|
@log_level = :info
|
|
18
|
+
|
|
19
|
+
# Timeouts: aggressive for dev, relax via ENV for production
|
|
20
|
+
@timeout_llm = (ENV["ANTIGRAVITY_TIMEOUT_LLM"] || 40).to_i
|
|
21
|
+
@timeout_ws = (ENV["ANTIGRAVITY_TIMEOUT_WS"] || 3).to_i
|
|
22
|
+
@timeout_handshake = (ENV["ANTIGRAVITY_TIMEOUT_HANDSHAKE"] || 5).to_i
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
def api_key?
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'net/http'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module Antigravity
|
|
11
|
+
module Connection
|
|
12
|
+
# Downloads and extracts the localharness binary from the official
|
|
13
|
+
# google-antigravity PyPI wheel. Used when the binary isn't already
|
|
14
|
+
# installed via Antigravity.app or a manual setup.
|
|
15
|
+
module BinaryFetcher
|
|
16
|
+
PYPI_PACKAGE = 'google-antigravity'
|
|
17
|
+
INSTALL_DIR = File.expand_path('~/.antigravity/bin')
|
|
18
|
+
BINARY_NAME = 'localharness'
|
|
19
|
+
|
|
20
|
+
# Platform mapping: Ruby's RUBY_PLATFORM -> PyPI wheel platform tag
|
|
21
|
+
PLATFORM_MAP = {
|
|
22
|
+
/darwin.*arm/i => 'macosx_11_0_arm64',
|
|
23
|
+
/darwin.*x86/i => 'macosx_10_15_x86_64',
|
|
24
|
+
/darwin/i => 'macosx_11_0_arm64', # default macOS = ARM
|
|
25
|
+
/linux.*x86_64/i => 'manylinux2014_x86_64',
|
|
26
|
+
/linux.*aarch/i => 'manylinux2014_aarch64',
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Fetch and install the binary. Returns the installed path.
|
|
31
|
+
# @param quiet [Boolean] suppress progress output
|
|
32
|
+
# @return [String] path to the installed binary
|
|
33
|
+
def fetch!(quiet: false)
|
|
34
|
+
platform = detect_platform
|
|
35
|
+
say("🔍 Detecting platform: #{platform}", quiet: quiet)
|
|
36
|
+
|
|
37
|
+
# Step 1: Find the wheel URL from PyPI
|
|
38
|
+
say("📡 Querying PyPI for #{PYPI_PACKAGE}...", quiet: quiet)
|
|
39
|
+
wheel_url = find_wheel_url(platform)
|
|
40
|
+
|
|
41
|
+
# Step 2: Download the wheel
|
|
42
|
+
say("⏳ Downloading binary... this will take some time (~50MB wheel)", quiet: quiet)
|
|
43
|
+
wheel_path = download_wheel(wheel_url, quiet: quiet)
|
|
44
|
+
|
|
45
|
+
# Step 3: Extract the binary
|
|
46
|
+
say("📦 Extracting localharness binary...", quiet: quiet)
|
|
47
|
+
binary_path = extract_binary(wheel_path)
|
|
48
|
+
|
|
49
|
+
# Step 4: Make executable
|
|
50
|
+
FileUtils.chmod(0o755, binary_path)
|
|
51
|
+
say("✅ Installed localharness at #{binary_path}", quiet: quiet)
|
|
52
|
+
|
|
53
|
+
binary_path
|
|
54
|
+
ensure
|
|
55
|
+
# Clean up temp wheel
|
|
56
|
+
FileUtils.rm_f(wheel_path) if wheel_path && File.exist?(wheel_path.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if the binary is already installed via fetch
|
|
60
|
+
def installed?
|
|
61
|
+
path = File.join(INSTALL_DIR, BINARY_NAME)
|
|
62
|
+
File.executable?(path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def installed_path
|
|
66
|
+
File.join(INSTALL_DIR, BINARY_NAME)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def detect_platform
|
|
72
|
+
PLATFORM_MAP.each do |pattern, tag|
|
|
73
|
+
return tag if RUBY_PLATFORM.match?(pattern)
|
|
74
|
+
end
|
|
75
|
+
raise HarnessNotFoundError,
|
|
76
|
+
"Unsupported platform: #{RUBY_PLATFORM}. Cannot auto-download localharness."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def find_wheel_url(platform)
|
|
80
|
+
uri = URI("https://pypi.org/pypi/#{PYPI_PACKAGE}/json")
|
|
81
|
+
response = Net::HTTP.get(uri)
|
|
82
|
+
data = JSON.parse(response)
|
|
83
|
+
|
|
84
|
+
# Find latest version's wheel for our platform
|
|
85
|
+
urls = data['urls'] || []
|
|
86
|
+
wheel = urls.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
|
|
87
|
+
|
|
88
|
+
unless wheel
|
|
89
|
+
# Try all versions for the platform
|
|
90
|
+
versions = data['releases']&.keys&.sort_by { |v| Gem::Version.new(v) rescue v }&.reverse
|
|
91
|
+
versions&.each do |ver|
|
|
92
|
+
files = data.dig('releases', ver) || []
|
|
93
|
+
wheel = files.find { |u| u['filename']&.include?(platform) && u['filename']&.end_with?('.whl') }
|
|
94
|
+
break if wheel
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
raise HarnessNotFoundError,
|
|
99
|
+
"No wheel found for platform #{platform} on PyPI. Install Antigravity.app manually." unless wheel
|
|
100
|
+
|
|
101
|
+
wheel['url']
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def download_wheel(url, quiet: false)
|
|
105
|
+
dest = File.join(Dir.tmpdir, "antigravity-wheel-#{$$}.whl")
|
|
106
|
+
uri = URI(url)
|
|
107
|
+
|
|
108
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
109
|
+
request = Net::HTTP::Get.new(uri)
|
|
110
|
+
http.request(request) do |response|
|
|
111
|
+
total = response['content-length']&.to_i
|
|
112
|
+
downloaded = 0
|
|
113
|
+
|
|
114
|
+
File.open(dest, 'wb') do |f|
|
|
115
|
+
response.read_body do |chunk|
|
|
116
|
+
f.write(chunk)
|
|
117
|
+
downloaded += chunk.bytesize
|
|
118
|
+
if total && total > 0 && !quiet
|
|
119
|
+
pct = (downloaded * 100.0 / total).round(1)
|
|
120
|
+
print "\r ⏳ #{pct}% (#{(downloaded / 1024.0 / 1024).round(1)} MB / #{(total / 1024.0 / 1024).round(1)} MB)"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
puts unless quiet
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
dest
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def extract_binary(wheel_path)
|
|
132
|
+
FileUtils.mkdir_p(INSTALL_DIR)
|
|
133
|
+
|
|
134
|
+
# Wheels are just ZIP files. Extract the binary.
|
|
135
|
+
# Look for: google/antigravity/bin/localharness (or language_server)
|
|
136
|
+
extract_dir = Dir.mktmpdir('agy-extract-')
|
|
137
|
+
system('unzip', '-q', '-o', wheel_path, '-d', extract_dir)
|
|
138
|
+
|
|
139
|
+
# Search for the binary inside (prefer localharness over language_server)
|
|
140
|
+
candidates = Dir.glob("#{extract_dir}/**/localharness") +
|
|
141
|
+
Dir.glob("#{extract_dir}/**/language_server")
|
|
142
|
+
|
|
143
|
+
binary = candidates.find { |f| File.file?(f) && !File.directory?(f) }
|
|
144
|
+
|
|
145
|
+
unless binary
|
|
146
|
+
FileUtils.rm_rf(extract_dir)
|
|
147
|
+
raise HarnessNotFoundError,
|
|
148
|
+
"Could not find localharness binary inside the wheel. Contents: #{Dir.glob("#{extract_dir}/**/*").join(', ')}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
dest_path = File.join(INSTALL_DIR, BINARY_NAME)
|
|
152
|
+
FileUtils.cp(binary, dest_path)
|
|
153
|
+
FileUtils.rm_rf(extract_dir)
|
|
154
|
+
dest_path
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def say(msg, quiet: false)
|
|
158
|
+
$stderr.puts msg unless quiet
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -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
|