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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbeafe860899a22961b7c19a418337cb456594d683708d49b06b293711429c57
4
- data.tar.gz: 7e0caad86e9aa6dd49e2f541ddb041ee9fc0518a21575bfe634b43cb3e879e94
3
+ metadata.gz: b2a5cad1edda5eb5efaa58d7d5104ba433f8469a923bb5f862b9f1bcb4219021
4
+ data.tar.gz: 75c9924910ef8aab407444abfecfa0f7e989473d8be251691e4e07c9d04499aa
5
5
  SHA512:
6
- metadata.gz: 624c1c92b64d248113fd1ef0bd02367c84097666a873b0cecda4063107719406533861faa4cd9baf401afe91ae91e7f2fe185d811027adc7521a09e4e51fa1ae
7
- data.tar.gz: def792e62ad5f0c2b0c7198022453c5d28ccdf7b9c70668ce79207b81589e778310cd0779a58d13301902663b6c0b59e2db15ef57fcb99cc92105b41c505dbb8
6
+ metadata.gz: ac637d9792051869f94b4a8ebc716fd604b2db18886e19b105190a9636c7e7bce418aeece52a360b139b1c534e560605b0dc578fab10b3e06ac913dc454663eb
7
+ data.tar.gz: c8b6e4bee541853abd1f993d5423cc3426c830a24aba58f6790ec30e093279163b83e258a824811380ac5a603cba749dd7a099dee5ad4a2160fe25a7373c6e51
@@ -1,31 +1,125 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Antigravity
4
- class Agent
5
- include Emojifiable
4
+ class Agent < Base
6
5
 
7
6
  attr_accessor :model, :system_instruction, :api_key
8
- attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard
7
+ attr_reader :tools, :skills, :hooks, :sidecars, :client, :logger_guard,
8
+ :workspace, :connection, :conversation
9
9
 
10
- def initialize(model: nil, auto_logger: true, &block)
10
+ def initialize(model: nil, system_instruction: nil, tools: [],
11
+ skills: [], workspace: nil, auto_logger: true, log_file: nil, &block)
11
12
  @model = model || Antigravity.config.default_model
12
13
  @api_key = Antigravity.config.api_key
13
- @system_instruction = nil
14
- @tools = []
14
+ @system_instruction = system_instruction
15
+ @workspace = workspace ? File.expand_path(workspace) : nil
16
+ @tools = tools.dup
15
17
  @skills = []
16
18
  @sidecars = []
17
19
  @hooks = Hooks.new
18
20
  @client = Client.new
19
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?
20
32
 
21
33
  # Automagic Logger attachment unless disabled via ENV["ANTIGRAVITY_LOGGER"]=false or auto_logger: false
22
34
  if auto_logger && logger_enabled?
23
- attach_logger
35
+ attach_logger(log_file)
24
36
  end
25
37
 
26
38
  yield(self) if block_given?
27
39
  end
28
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
+
29
123
  def register_tool(tool_or_name = nil, description: "", &block)
30
124
  if block_given? && tool_or_name
31
125
  tool = Tool::Dynamic.new(tool_or_name, description: description, &block)
@@ -35,6 +129,7 @@ module Antigravity
35
129
  raise ArgumentError, "Invalid tool definition"
36
130
  end
37
131
  @tools << tool
132
+ @tool_runner.register(tool) if @tool_runner
38
133
  tool
39
134
  end
40
135
 
@@ -43,18 +138,61 @@ module Antigravity
43
138
  sidecar
44
139
  end
45
140
 
46
- def attach_logger(log_target = nil, level: ::Logger::INFO, silent_notice: false)
141
+ def attach_logger(log_target = nil, level: :info, silent_notice: false)
47
142
  @logger_guard = Guards::AgentLogger.new(log_target, level: level, silent_notice: silent_notice)
48
143
  @logger_guard.attach_to(self)
49
144
  @logger_guard
50
145
  end
51
146
 
52
- def load_skill(skill_path)
53
- skill = Skill.load(skill_path)
54
- @skills << skill
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 }
55
183
  skill
56
184
  end
57
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
+
58
196
  def before_prompt(&block)
59
197
  hooks.before_prompt(&block)
60
198
  end
@@ -76,19 +214,6 @@ module Antigravity
76
214
  @sidecars.each { |sidecar| sidecar.emit(event_type, payload) }
77
215
  end
78
216
 
79
- def prompt(message, &block)
80
- emit_sidecar_event(:prompt_started, prompt: message)
81
- hooks.run_pre_prompt(message)
82
-
83
- response = client.send_turn(self, message, &block)
84
-
85
- hooks.run_post_response(response)
86
- emit_sidecar_event(:turn_completed, response: response.content, model: model)
87
-
88
- response
89
- end
90
- alias ask prompt
91
-
92
217
  private
93
218
 
94
219
  def logger_enabled?
@@ -97,5 +222,91 @@ module Antigravity
97
222
 
98
223
  true
99
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
100
311
  end
101
312
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antigravity
4
+ # Base class for all Antigravity domain objects.
5
+ # Subclasses automagically get .emoji / #emoji via Emojifiable.
6
+ #
7
+ # ⚠️ Keep this class SUPER thin!
8
+ # Every SDK class inherits from it, so any weight here
9
+ # is carried by Agent, Tool, Skill, Message, Sidecar::Runner, etc.
10
+ class Base
11
+ include Emojifiable
12
+
13
+ def self.inherited(subclass)
14
+ super
15
+ subclass.include(Emojifiable) unless subclass < Emojifiable
16
+ end
17
+ end
18
+ end
@@ -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-latest"
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