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
|
@@ -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
|
data/lib/antigravity/emojis.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Antigravity
|
|
4
4
|
EMOJIS = {
|
|
5
|
+
antigravity: "🛰️",
|
|
5
6
|
gem: "💎",
|
|
6
7
|
agent: "🕵️♂️",
|
|
7
8
|
prompt: "💬",
|
|
@@ -13,6 +14,9 @@ module Antigravity
|
|
|
13
14
|
sidecar: "🚗",
|
|
14
15
|
logger: "🪵",
|
|
15
16
|
skill: "📁",
|
|
17
|
+
check: "✅",
|
|
18
|
+
magnifying: "🔍",
|
|
19
|
+
workspace: "📂",
|
|
16
20
|
message: "💬",
|
|
17
21
|
guard: "🛡️",
|
|
18
22
|
test: "🧪",
|
|
@@ -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
|
data/lib/antigravity/guards.rb
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Antigravity
|
|
7
7
|
module Guards
|
|
8
|
-
#
|
|
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 :
|
|
13
|
+
attr_reader :target_description
|
|
11
14
|
|
|
12
|
-
def initialize(log_target = nil, level:
|
|
13
|
-
|
|
15
|
+
def initialize(log_target = nil, level: :info, silent_notice: false)
|
|
16
|
+
resolved = resolve_log_target(log_target)
|
|
14
17
|
|
|
15
|
-
if
|
|
16
|
-
dir = File.dirname(
|
|
18
|
+
if resolved.is_a?(String)
|
|
19
|
+
dir = File.dirname(resolved)
|
|
17
20
|
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@
|
|
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
|
-
@
|
|
39
|
+
@jsonl = $stdout
|
|
40
|
+
@compact = nil
|
|
41
|
+
@rails_logger = nil
|
|
25
42
|
@target_description = "$stdout"
|
|
26
43
|
end
|
|
27
44
|
|
|
28
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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']}.
|
|
133
|
+
"log/#{ENV['RAILS_ENV']}.jsonl"
|
|
74
134
|
elsif ENV["RACK_ENV"] && !ENV["RACK_ENV"].empty?
|
|
75
|
-
"log/#{ENV['RACK_ENV']}.
|
|
135
|
+
"log/#{ENV['RACK_ENV']}.jsonl"
|
|
76
136
|
else
|
|
77
|
-
"log/antigravity.
|
|
137
|
+
"log/antigravity.jsonl"
|
|
78
138
|
end
|
|
79
139
|
end
|
|
80
140
|
end
|
data/lib/antigravity/hooks.rb
CHANGED
|
@@ -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
|