antigravity-sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Antigravity
4
+ # Base error for all Antigravity SDK errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the localharness binary cannot be found
8
+ class HarnessNotFoundError < Error; end
9
+
10
+ # Raised when the stdio handshake with localharness fails
11
+ class HarnessHandshakeError < Error; end
12
+
13
+ # Raised when protobuf encoding/decoding fails
14
+ class ProtocolError < Error; end
15
+
16
+ # Raised when a tool callback fails
17
+ class ToolError < Error; end
18
+
19
+ # Raised when trying to execute an unregistered tool
20
+ class ToolNotFoundError < ToolError; end
21
+
22
+ # Raised when WebSocket connection fails
23
+ class ConnectionError < Error; end
24
+
25
+ # Raised when required configuration (e.g., GEMINI_API_KEY) is missing
26
+ class ConfigError < Error; end
27
+ end
@@ -1,31 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "logger"
3
+ require "json"
4
4
  require "fileutils"
5
5
 
6
6
  module Antigravity
7
7
  module Guards
8
- # Configurable opt-in logger guard supporting file loggers and Rails.logger
8
+ # Dual-output logger guard:
9
+ # 1. JSONL (log/antigravity.jsonl) — structured, machine-parseable, full data
10
+ # 2. Compact log (log/antigravity.log) — human-readable one-liners with byte sizes
11
+ # Falls back to Rails.logger for both if available.
9
12
  class AgentLogger
10
- attr_reader :logger, :target_description
13
+ attr_reader :target_description
11
14
 
12
- def initialize(log_target = nil, level: Logger::INFO, silent_notice: false)
13
- resolved_target = resolve_log_target(log_target)
15
+ def initialize(log_target = nil, level: :info, silent_notice: false)
16
+ resolved = resolve_log_target(log_target)
14
17
 
15
- if resolved_target.is_a?(String)
16
- dir = File.dirname(resolved_target)
18
+ if resolved.is_a?(String)
19
+ dir = File.dirname(resolved)
17
20
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
18
- @logger = ::Logger.new(resolved_target)
19
- @target_description = resolved_target
20
- elsif resolved_target.respond_to?(:info)
21
- @logger = resolved_target
21
+
22
+ # Fat JSONL log
23
+ @jsonl = File.open(resolved, 'a')
24
+ @jsonl.sync = true
25
+
26
+ # Skinny compact log (same dir, .log extension)
27
+ compact_path = resolved.sub(/\.jsonl$/, '.log')
28
+ @compact = File.open(compact_path, 'a')
29
+ @compact.sync = true
30
+
31
+ @rails_logger = nil
32
+ @target_description = resolved
33
+ elsif resolved.respond_to?(:info)
34
+ @jsonl = nil
35
+ @compact = nil
36
+ @rails_logger = resolved
22
37
  @target_description = "Rails.logger"
23
38
  else
24
- @logger = ::Logger.new($stdout)
39
+ @jsonl = $stdout
40
+ @compact = nil
41
+ @rails_logger = nil
25
42
  @target_description = "$stdout"
26
43
  end
27
44
 
28
- @logger.level = level if @logger.respond_to?(:level=)
45
+ @level = level
29
46
 
30
47
  unless silent_notice
31
48
  puts "#{Antigravity.emoji(:logger)} Logging to #{@target_description}"
@@ -33,25 +50,49 @@ module Antigravity
33
50
  end
34
51
 
35
52
  def before_prompt(prompt_text)
36
- @logger.info("#{Antigravity.emoji(:prompt)} [Antigravity::Prompt] User: '#{prompt_text}'")
53
+ size = prompt_text.to_s.bytesize
54
+ log_jsonl('prompt', { user_input: prompt_text })
55
+ log_compact("#{Antigravity.emoji(:prompt)} PROMPT #{size}B | #{prompt_text.to_s[0, 80]}")
37
56
  end
38
57
 
39
58
  def after_response(response)
40
- @logger.info("#{Antigravity.emoji(:response)} [Antigravity::Response] Assistant (#{response.model_id}): #{response.content.strip}")
59
+ content = response.content&.strip || ''
60
+ log_jsonl('response', {
61
+ model: response.model_id,
62
+ content: content,
63
+ tokens: response.usage[:total_token_count],
64
+ tool_calls: response.tool_calls_count,
65
+ steps: response.steps&.length
66
+ })
67
+ log_compact("#{Antigravity.emoji(:response)} RESPONSE #{content.bytesize}B | " \
68
+ "tokens=#{response.usage[:total_token_count]} " \
69
+ "tools=#{response.tool_calls_count} " \
70
+ "steps=#{response.steps&.length} " \
71
+ "model=#{response.model_id}")
41
72
  end
42
73
 
43
74
  def before_tool_call(tool_name, params)
44
- @logger.info("#{Antigravity.emoji(:tool)} [Antigravity::Tool] Executing '#{tool_name}' with params: #{params.inspect}")
75
+ params_size = params.to_s.bytesize
76
+ log_jsonl('tool_call', { tool: tool_name, params: params })
77
+ log_compact("#{Antigravity.emoji(:tool)} TOOL_CALL #{tool_name} params=#{params_size}B")
45
78
  end
46
79
 
47
80
  def after_tool_call(tool_name, params, result)
48
- prefix = result.to_s.include?("TOOL BLOCKED") ? Antigravity.emoji(:tool_blocked) : Antigravity.emoji(:tool_result)
49
- @logger.info("#{prefix} [Antigravity::Tool] Result for '#{tool_name}': #{result}")
81
+ blocked = result.to_s.include?("TOOL BLOCKED")
82
+ result_size = result.to_s.bytesize
83
+ log_jsonl('tool_result', {
84
+ tool: tool_name,
85
+ result: result.to_s[0, 500],
86
+ blocked: blocked
87
+ })
88
+ status = blocked ? 'BLOCKED' : 'OK'
89
+ log_compact("#{blocked ? Antigravity.emoji(:tool_blocked) : Antigravity.emoji(:tool_result)} TOOL_RESULT #{tool_name} #{status} result=#{result_size}B")
50
90
  result
51
91
  end
52
92
 
53
93
  def sidecar_event(event_type, payload)
54
- @logger.info("#{Antigravity.emoji(:sidecar)} [Antigravity::Sidecar] Event :#{event_type} payload: #{payload.inspect}")
94
+ log_jsonl('sidecar', { type: event_type.to_s, payload: payload })
95
+ log_compact("#{Antigravity.emoji(:sidecar)} SIDECAR :#{event_type}")
55
96
  end
56
97
 
57
98
  def attach_to(agent)
@@ -64,17 +105,36 @@ module Antigravity
64
105
 
65
106
  private
66
107
 
108
+ def ts
109
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
110
+ end
111
+
112
+ def log_jsonl(event, data)
113
+ if @rails_logger
114
+ @rails_logger.info("[Antigravity] #{event}: #{data.inspect}")
115
+ elsif @jsonl
116
+ entry = { ts: ts, event: event, pid: Process.pid }.merge(data.compact)
117
+ @jsonl.puts(JSON.generate(entry))
118
+ end
119
+ end
120
+
121
+ def log_compact(line)
122
+ if @compact
123
+ @compact.puts("#{ts} #{line}")
124
+ end
125
+ end
126
+
67
127
  def resolve_log_target(target)
68
128
  return target if target
69
129
 
70
130
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
71
131
  Rails.logger
72
132
  elsif ENV["RAILS_ENV"] && !ENV["RAILS_ENV"].empty?
73
- "log/#{ENV['RAILS_ENV']}.log"
133
+ "log/#{ENV['RAILS_ENV']}.jsonl"
74
134
  elsif ENV["RACK_ENV"] && !ENV["RACK_ENV"].empty?
75
- "log/#{ENV['RACK_ENV']}.log"
135
+ "log/#{ENV['RACK_ENV']}.jsonl"
76
136
  else
77
- "log/antigravity.log"
137
+ "log/antigravity.jsonl"
78
138
  end
79
139
  end
80
140
  end
@@ -9,6 +9,7 @@ module Antigravity
9
9
  @post_response_hooks = []
10
10
  @pre_tool_hooks = []
11
11
  @post_tool_hooks = []
12
+ @listeners = Hash.new { |h, k| h[k] = [] }
12
13
  end
13
14
 
14
15
  def before_prompt(&block)
@@ -29,6 +30,17 @@ module Antigravity
29
30
  @post_tool_hooks << block if block_given?
30
31
  end
31
32
 
33
+ # Generic event system — subscribe to any named event.
34
+ # Usage: hooks.on(:ws_message) { |msg| puts msg.keys }
35
+ def on(event, &block)
36
+ @listeners[event.to_sym] << block if block_given?
37
+ end
38
+
39
+ # Emit a named event to all subscribers.
40
+ def emit(event, *args)
41
+ @listeners[event.to_sym].each { |cb| cb.call(*args) }
42
+ end
43
+
32
44
  def run_pre_prompt(prompt_text)
33
45
  @pre_prompt_hooks.each { |hook| hook.call(prompt_text) }
34
46
  end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Auto-attachable lifecycle logger that prints colorful, compact status lines
4
+ # on every hook event. Inspired by Cloud Code's status bar.
5
+ #
6
+ # Usage:
7
+ # agent = Antigravity::Agent.new
8
+ # Antigravity::LifecycleLogger.attach!(agent)
9
+ #
10
+ # Or auto-attach via env:
11
+ # ANTIGRAVITY_LIFECYCLE=1
12
+ # RAILS_ENV=test (auto-attaches in test/development)
13
+ #
14
+ module Antigravity
15
+ class LifecycleLogger
16
+ C = Antigravity::Colors
17
+
18
+ # Compact status string — the "Cloud Code status bar" equivalent.
19
+ # Example: "T3 | 1.2k tok | 4 tools | 2.3s"
20
+ def self.status_line(agent)
21
+ turns = agent.turn_count rescue 0
22
+ summary = agent.session_summary rescue {}
23
+ tokens = summary.dig(:tokens, :total) || 0
24
+ tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
25
+ model = summary[:model] || agent.model || '?'
26
+ conv_id = (summary[:conversation_id] || '?')[0..7]
27
+
28
+ C.dim("T#{turns} | #{tok_str} tok | #{model} | #{conv_id}")
29
+ end
30
+
31
+ def self.attach!(agent, verbose: false)
32
+ new(verbose: verbose).attach(agent)
33
+ end
34
+
35
+ def initialize(verbose: false)
36
+ @verbose = verbose
37
+ @session_start_time = nil
38
+ @turn_start_time = nil
39
+ @turn_count = 0
40
+ end
41
+
42
+ def attach(agent)
43
+ attach_session_hooks(agent)
44
+ attach_turn_hooks(agent)
45
+ attach_tool_hooks(agent)
46
+ end
47
+
48
+ private
49
+
50
+ def attach_session_hooks(agent)
51
+ logger = self
52
+
53
+ agent.hooks.on(:session_start) do |info|
54
+ logger.instance_variable_set(:@session_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
55
+ model = info[:model] || agent.model || '?'
56
+ conv_id = (info[:conversation_id] || '?')[0..11]
57
+ puts C.gray("🟢 #{C.dim("session_start")} | model=#{C.cyan(model)} | conv=#{C.cyan(conv_id)}")
58
+ end
59
+
60
+ agent.hooks.on(:session_end) do |info|
61
+ elapsed = if logger.instance_variable_get(:@session_start_time)
62
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@session_start_time)).round(1)
63
+ else
64
+ '?'
65
+ end
66
+ turns = info[:turn_count] || agent.turn_count rescue 0
67
+ summary = agent.session_summary rescue {}
68
+ tokens = summary.dig(:tokens, :total) || 0
69
+ tok_str = tokens > 999 ? "#{(tokens / 1000.0).round(1)}k" : tokens.to_s
70
+ puts C.gray("🔴 #{C.dim("session_end")} | #{C.bold("#{turns} turns")} | #{tok_str} tok | #{elapsed}s")
71
+ end
72
+ end
73
+
74
+ def attach_turn_hooks(agent)
75
+ logger = self
76
+
77
+ agent.hooks.before_prompt do |text|
78
+ logger.instance_variable_set(:@turn_start_time, Process.clock_gettime(Process::CLOCK_MONOTONIC))
79
+ count = logger.instance_variable_get(:@turn_count) + 1
80
+ logger.instance_variable_set(:@turn_count, count)
81
+ preview = text.to_s[0..60].gsub("\n", ' ')
82
+ preview += '...' if text.to_s.length > 60
83
+ status = self.class.status_line(agent) rescue C.dim("T#{count}")
84
+ puts C.gray(" ➡️ #{C.dim("pre_turn")} T#{count} | #{C.yellow("\"#{preview}\"")} | #{status}")
85
+ end
86
+
87
+ agent.hooks.after_response do |response|
88
+ elapsed = if logger.instance_variable_get(:@turn_start_time)
89
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - logger.instance_variable_get(:@turn_start_time)).round(2)
90
+ else
91
+ '?'
92
+ end
93
+ content = response.respond_to?(:content) ? response.content.to_s : response.to_s
94
+ chars = content.length
95
+ lines = content.count("\n") + 1
96
+ preview = content[0..60]&.gsub("\n", ' ') || '(empty)'
97
+ preview += '...' if chars > 60
98
+ thinking_len = response.respond_to?(:thinking) ? (response.thinking&.length || 0) : 0
99
+ tool_count = response.respond_to?(:tool_calls_count) ? (response.tool_calls_count || 0) : 0
100
+ status = self.class.status_line(agent) rescue ''
101
+
102
+ parts = ["#{C.green("#{chars}ch")} #{C.dim("#{lines}L")}"]
103
+ parts << "#{C.magenta("#{thinking_len}ch")} think" if thinking_len > 0
104
+ parts << "#{C.cyan("#{tool_count}")} tools" if tool_count > 0
105
+ parts << "#{C.blue("#{elapsed}s")}"
106
+
107
+ puts C.gray(" ⬅️ #{C.dim("post_turn")} T#{logger.instance_variable_get(:@turn_count)} | #{parts.join(' | ')} | #{status}")
108
+ if logger.instance_variable_get(:@verbose)
109
+ puts C.dim(" \"#{preview}\"")
110
+ end
111
+ end
112
+ end
113
+
114
+ def attach_tool_hooks(agent)
115
+ logger = self
116
+
117
+ agent.hooks.on(:tool_call) do |info|
118
+ name = info[:tool_name] || info[:name] || '?'
119
+ params_preview = (info[:params] || {}).keys.join(', ')
120
+ puts C.gray(" 🔧 #{C.dim("tool_call")} | #{C.cyan(name)}(#{C.dim(params_preview)})")
121
+ end
122
+
123
+ agent.hooks.on(:tool_result) do |info|
124
+ name = info[:tool_name] || info[:name] || '?'
125
+ result_len = info[:result].to_s.length rescue 0
126
+ duration = info[:duration] ? "#{info[:duration].round(2)}s" : nil
127
+ parts = [C.cyan(name), "#{result_len}ch"]
128
+ parts << duration if duration
129
+ puts C.gray(" ✅ #{C.dim("tool_done")} | #{parts.join(' | ')}")
130
+ end
131
+
132
+ agent.hooks.on(:tool_blocked) do |info|
133
+ name = info[:tool] || '?'
134
+ reason = info[:reason] || 'policy'
135
+ puts C.red(" 🚫 #{C.dim("tool_deny")} | #{C.red(name)} — #{reason}")
136
+ end
137
+
138
+ agent.hooks.on(:tool_error) do |info|
139
+ name = info[:tool_name] || info[:name] || '?'
140
+ error = info[:error] || info[:message] || '?'
141
+ puts C.red(" 💥 #{C.dim("tool_error")} | #{C.red(name)} — #{error.to_s[0..80]}")
142
+ end
143
+ end
144
+ end
145
+ end
@@ -1,23 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Antigravity
4
+ # Represents a response message from the agent.
5
+ # Mirrors Python SDK's ChatResponse with metadata.
4
6
  class Message < Base
7
+ attr_accessor :role, :content, :thinking, :tool_calls, :model_id, :tokens,
8
+ :steps, :tool_calls_count, :usage, :delta
5
9
 
6
- attr_accessor :role, :content, :thinking, :tool_calls, :model_id, :tokens
7
-
8
- def initialize(role: :assistant, content: "", thinking: "", tool_calls: [], model_id: nil)
10
+ def initialize(role: :assistant, content: '', thinking: '', tool_calls: [],
11
+ model_id: nil, steps: [], tool_calls_count: 0, usage: nil,
12
+ delta: false)
9
13
  @role = role
10
14
  @content = content
11
15
  @thinking = thinking
12
16
  @tool_calls = tool_calls
13
17
  @model_id = model_id
14
18
  @tokens = { input: 0, output: 0 }
19
+ @steps = steps
20
+ @tool_calls_count = tool_calls_count
21
+ @usage = usage || { prompt_token_count: 0, candidates_token_count: 0, total_token_count: 0 }
22
+ @delta = delta
23
+ end
24
+
25
+ # Is this a streaming delta (partial) or a complete response?
26
+ def delta?
27
+ @delta
15
28
  end
16
29
  end
17
30
 
31
+ # Backward-compatible alias for streaming chunks
18
32
  class Chunk < Message
19
- def initialize(role: :assistant, content: "", thinking: "", tool_calls: [], model_id: nil)
20
- super(role: role, content: content, thinking: thinking, tool_calls: tool_calls, model_id: model_id)
33
+ def initialize(**kwargs)
34
+ super(**kwargs, delta: true)
21
35
  end
22
36
  end
23
37
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ==========================================================================
4
+ # Policy Constants — curated lists of commands, tools, and file patterns.
5
+ #
6
+ # 🛠️ MAINTAINER NOTES:
7
+ # - One entry per line for easy review in PRs.
8
+ # - Add new entries alphabetically within each group.
9
+ # - Multi-word commands use explicit quotes (no backslash escapes!).
10
+ # - Run `bundle exec rspec spec/antigravity/policy_spec.rb` after edits.
11
+ # ==========================================================================
12
+
13
+ module Antigravity
14
+ class Policy
15
+
16
+ # ------------------------------------------------------------------
17
+ # 💀 Catastrophic commands — hard-denied in ALL presets, no exceptions.
18
+ # ------------------------------------------------------------------
19
+ CATASTROPHIC_CMDS = [
20
+ 'dd if=/dev/urandom',
21
+ 'dd if=/dev/zero',
22
+ '> /dev/sd',
23
+ 'halt',
24
+ 'mkfs',
25
+ 'reboot',
26
+ 'rm -rf /*',
27
+ 'rm -rf /',
28
+ 'rm -rf ~',
29
+ 'shutdown',
30
+ ].freeze
31
+
32
+ # ------------------------------------------------------------------
33
+ # ⚠️ Risky commands — confirmed in :default, hard-denied in :cautious.
34
+ # Single-word or short patterns that match substring in command_line.
35
+ # ------------------------------------------------------------------
36
+ RISKY_CMDS = [
37
+ 'chmod -R 777',
38
+ 'chown -R',
39
+ 'kill -9',
40
+ 'killall',
41
+ 'pkill',
42
+ 'rm',
43
+ 'xargs',
44
+ ].freeze
45
+
46
+ # ------------------------------------------------------------------
47
+ # 🔥 Destructive git — nuke local changes, rewrite history.
48
+ # Confirmed in :default/:turbo, hard-denied in :cautious/:test.
49
+ # ------------------------------------------------------------------
50
+ DESTRUCTIVE_GIT_CMDS = [
51
+ 'git checkout .',
52
+ 'git checkout -- .',
53
+ 'git clean -fd',
54
+ 'git clean -fdx',
55
+ 'git push --force',
56
+ 'git push -f',
57
+ 'git reset --hard',
58
+ 'git stash drop',
59
+ ].freeze
60
+
61
+ # ------------------------------------------------------------------
62
+ # ✅ Safe read-only shell commands — allowed even in :cautious.
63
+ # ------------------------------------------------------------------
64
+ SAFE_CMDS = %i[
65
+ cd
66
+ date
67
+ echo
68
+ hostname
69
+ ls
70
+ md5
71
+ md5sum
72
+ pwd
73
+ uname
74
+ wc
75
+ which
76
+ whoami
77
+ ].freeze
78
+
79
+ # ⚠️ File-reading shell commands — can bypass view_file deny rules!
80
+ # Allowed in :default/:turbo/:test, but NOT in :cautious.
81
+ # If you deny `view_file` for a path, `cat` can circumvent it.
82
+ READ_CMDS = %i[
83
+ cat
84
+ head
85
+ strings
86
+ tail
87
+ ].freeze
88
+
89
+ # Safe git subcommands (read-only, no mutations)
90
+ SAFE_GIT_CMDS = [
91
+ 'git branch',
92
+ 'git diff',
93
+ 'git log',
94
+ 'git remote',
95
+ 'git status',
96
+ ].freeze
97
+
98
+ # ------------------------------------------------------------------
99
+ # 🔐 Sensitive file globs — writes to these require confirmation.
100
+ # ------------------------------------------------------------------
101
+ SENSITIVE_FILES = [
102
+ '.env',
103
+ '.env.*',
104
+ '*.key',
105
+ '*.pem',
106
+ '*.secret',
107
+ 'id_rsa*',
108
+ ].freeze
109
+
110
+ # ------------------------------------------------------------------
111
+ # 📂 Sandbox directories — always writable, even in production.
112
+ # Throwaway / output dirs where agents can freely write.
113
+ # ------------------------------------------------------------------
114
+ SANDBOX_DIRS = [
115
+ 'out/*',
116
+ 'scratch/*',
117
+ ].freeze
118
+
119
+ # ------------------------------------------------------------------
120
+ # 🔧 Tool classifications
121
+ # ------------------------------------------------------------------
122
+
123
+ # Read-only harness tools (always safe)
124
+ READONLY_TOOLS = %i[
125
+ find
126
+ grep_search
127
+ list_dir
128
+ read_url_content
129
+ search_web
130
+ view_file
131
+ ].freeze
132
+
133
+ # Write harness tools
134
+ WRITE_TOOLS = %i[
135
+ file_edit
136
+ write_to_file
137
+ ].freeze
138
+
139
+ # ------------------------------------------------------------------
140
+ # 🗺️ Environment → preset mapping (for Policy.auto)
141
+ # ------------------------------------------------------------------
142
+ PRESET_NAMES = %i[auto cautious default test turbo].freeze
143
+
144
+ ENV_MAP = {
145
+ 'dev' => :turbo,
146
+ 'development' => :turbo,
147
+ 'prod' => :cautious,
148
+ 'production' => :cautious,
149
+ 'staging' => :default,
150
+ 'test' => :test,
151
+ }.freeze
152
+
153
+ end
154
+ end