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 +4 -4
- data/lib/antigravity/agent.rb +235 -24
- data/lib/antigravity/base.rb +18 -0
- 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 +46 -17
- 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 +20 -7
- data/lib/antigravity/protocol.rb +193 -0
- data/lib/antigravity/sidecar.rb +7 -5
- data/lib/antigravity/skill.rb +54 -7
- data/lib/antigravity/skill_resolver.rb +140 -0
- data/lib/antigravity/tool.rb +35 -11
- data/lib/antigravity/tool_runner.rb +59 -0
- data/lib/antigravity.rb +9 -0
- metadata +13 -4
data/lib/antigravity/emojis.rb
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Antigravity
|
|
4
4
|
EMOJIS = {
|
|
5
|
+
antigravity: "๐ฐ๏ธ",
|
|
5
6
|
gem: "๐",
|
|
6
|
-
agent: "
|
|
7
|
+
agent: "๐ต๏ธโโ๏ธ",
|
|
7
8
|
prompt: "๐ฌ",
|
|
8
9
|
response: "๐ค",
|
|
9
10
|
thinking: "๐ค",
|
|
@@ -13,35 +14,63 @@ module Antigravity
|
|
|
13
14
|
sidecar: "๐",
|
|
14
15
|
logger: "๐ชต",
|
|
15
16
|
skill: "๐",
|
|
17
|
+
check: "โ
",
|
|
18
|
+
magnifying: "๐",
|
|
19
|
+
workspace: "๐",
|
|
20
|
+
message: "๐ฌ",
|
|
16
21
|
guard: "๐ก๏ธ",
|
|
17
22
|
test: "๐งช",
|
|
18
|
-
success: "โ
"
|
|
23
|
+
success: "โ
",
|
|
24
|
+
unknown: "๐คท"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Name fragments we recognise in the class hierarchy
|
|
28
|
+
EMOJI_CLASS_MAP = {
|
|
29
|
+
"Agent" => :agent,
|
|
30
|
+
"Tool" => :tool,
|
|
31
|
+
"Sidecar" => :sidecar,
|
|
32
|
+
"Runner" => :sidecar,
|
|
33
|
+
"Skill" => :skill,
|
|
34
|
+
"Message" => :message,
|
|
35
|
+
"Chunk" => :message,
|
|
36
|
+
"Guard" => :guard,
|
|
37
|
+
"Logger" => :logger
|
|
19
38
|
}.freeze
|
|
20
39
|
|
|
21
40
|
class << self
|
|
22
41
|
def emoji(key)
|
|
23
|
-
EMOJIS[key.to_sym] ||
|
|
42
|
+
EMOJIS[key.to_sym] || EMOJIS[:unknown]
|
|
24
43
|
end
|
|
25
44
|
|
|
26
45
|
def emoji_for(target)
|
|
27
|
-
key =
|
|
28
|
-
when Message, Chunk
|
|
29
|
-
target.respond_to?(:role) && target.role == :user ? :prompt : :response
|
|
30
|
-
when Symbol, String
|
|
31
|
-
target
|
|
32
|
-
else
|
|
33
|
-
klass = target.is_a?(Class) || target.is_a?(Module) ? target : target.class
|
|
34
|
-
matched_part = klass.name&.split("::")&.reverse&.find do |part|
|
|
35
|
-
%w[Agent Tool Sidecar Skill Message Guard Logger].include?(part)
|
|
36
|
-
end
|
|
37
|
-
matched_part ? matched_part.downcase.to_sym : :gem
|
|
38
|
-
end
|
|
39
|
-
|
|
46
|
+
key = resolve_emoji_key(target)
|
|
40
47
|
emoji(key)
|
|
41
48
|
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def resolve_emoji_key(target)
|
|
53
|
+
# Message/Chunk: role-aware
|
|
54
|
+
if target.is_a?(Message) || (defined?(Chunk) && target.is_a?(Chunk))
|
|
55
|
+
return target.respond_to?(:role) && target.role == :user ? :prompt : :response
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Direct symbol/string lookup
|
|
59
|
+
return target if target.is_a?(Symbol) || target.is_a?(String)
|
|
60
|
+
|
|
61
|
+
# Class hierarchy reflection
|
|
62
|
+
klass = target.is_a?(Class) || target.is_a?(Module) ? target : target.class
|
|
63
|
+
parts = klass.name&.split("::") || []
|
|
64
|
+
parts.reverse_each do |part|
|
|
65
|
+
return EMOJI_CLASS_MAP[part] if EMOJI_CLASS_MAP.key?(part)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
:unknown
|
|
69
|
+
end
|
|
42
70
|
end
|
|
43
71
|
|
|
44
|
-
# Mixin providing polymorphic .emoji class and instance methods
|
|
72
|
+
# Mixin providing polymorphic .emoji class and #emoji instance methods.
|
|
73
|
+
# Auto-included by Antigravity::Base via inherited hook.
|
|
45
74
|
module Emojifiable
|
|
46
75
|
def emoji
|
|
47
76
|
Antigravity.emoji_for(self)
|
|
@@ -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
|
data/lib/antigravity/message.rb
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Antigravity
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Represents a response message from the agent.
|
|
5
|
+
# Mirrors Python SDK's ChatResponse with metadata.
|
|
6
|
+
class Message < Base
|
|
7
|
+
attr_accessor :role, :content, :thinking, :tool_calls, :model_id, :tokens,
|
|
8
|
+
:steps, :tool_calls_count, :usage, :delta
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
def initialize(role: :assistant, content: '', thinking: '', tool_calls: [],
|
|
11
|
+
model_id: nil, steps: [], tool_calls_count: 0, usage: nil,
|
|
12
|
+
delta: false)
|
|
10
13
|
@role = role
|
|
11
14
|
@content = content
|
|
12
15
|
@thinking = thinking
|
|
13
16
|
@tool_calls = tool_calls
|
|
14
17
|
@model_id = model_id
|
|
15
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
|
|
16
28
|
end
|
|
17
29
|
end
|
|
18
30
|
|
|
31
|
+
# Backward-compatible alias for streaming chunks
|
|
19
32
|
class Chunk < Message
|
|
20
|
-
def initialize(
|
|
21
|
-
super(
|
|
33
|
+
def initialize(**kwargs)
|
|
34
|
+
super(**kwargs, delta: true)
|
|
22
35
|
end
|
|
23
36
|
end
|
|
24
37
|
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'version'
|
|
4
|
+
|
|
5
|
+
module Antigravity
|
|
6
|
+
# Hand-rolled protobuf encoding/decoding for the 2 stdio handshake messages:
|
|
7
|
+
# InputConfig (SDK โ localharness via stdin)
|
|
8
|
+
# OutputConfig (localharness โ SDK via stdout)
|
|
9
|
+
#
|
|
10
|
+
# Uses raw protobuf wire format to avoid the google-protobuf gem dependency.
|
|
11
|
+
# See: https://github.com/palladius/antigravity-ruby-sdk/issues/7
|
|
12
|
+
#
|
|
13
|
+
# Wire format reference: https://protobuf.dev/programming-guides/encoding/
|
|
14
|
+
# Varint: wire type 0, tag = (field_number << 3) | 0
|
|
15
|
+
# Length-delimited: wire type 2, tag = (field_number << 3) | 2
|
|
16
|
+
class Protocol
|
|
17
|
+
# --- Encoding: InputConfig ---
|
|
18
|
+
# message InputConfig {
|
|
19
|
+
# string storage_directory = 1;
|
|
20
|
+
# uint32 port = 2; # optional, we leave 0
|
|
21
|
+
# string bind_address = 3; # default "localhost"
|
|
22
|
+
# ClientInfo client_info = 4;
|
|
23
|
+
# map<string, string> env = 5;
|
|
24
|
+
# }
|
|
25
|
+
# message ClientInfo {
|
|
26
|
+
# string language = 1;
|
|
27
|
+
# string version = 2;
|
|
28
|
+
# string language_version = 3;
|
|
29
|
+
# string os = 4;
|
|
30
|
+
# string os_version = 5;
|
|
31
|
+
# }
|
|
32
|
+
def self.encode_input_config(storage_directory:, bind_address: 'localhost', env: {})
|
|
33
|
+
buf = ''.b
|
|
34
|
+
|
|
35
|
+
# field 1: storage_directory (string)
|
|
36
|
+
buf << encode_string_field(1, storage_directory)
|
|
37
|
+
|
|
38
|
+
# field 3: bind_address (string, default "localhost")
|
|
39
|
+
buf << encode_string_field(3, bind_address)
|
|
40
|
+
|
|
41
|
+
# field 4: client_info (embedded message)
|
|
42
|
+
client_info = encode_client_info
|
|
43
|
+
buf << encode_bytes_field(4, client_info)
|
|
44
|
+
|
|
45
|
+
# field 5: env map entries (each is an embedded message with key=1, value=2)
|
|
46
|
+
env.each do |key, value|
|
|
47
|
+
entry = encode_string_field(1, key.to_s) + encode_string_field(2, value.to_s)
|
|
48
|
+
buf << encode_bytes_field(5, entry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Length-prefix: 4-byte little-endian uint32
|
|
52
|
+
[buf.bytesize].pack('V') + buf
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Decoding: OutputConfig ---
|
|
56
|
+
# message OutputConfig {
|
|
57
|
+
# int32 port = 1;
|
|
58
|
+
# string api_key = 2;
|
|
59
|
+
# }
|
|
60
|
+
def self.decode_output_config(data)
|
|
61
|
+
raise ProtocolError, 'Data too short for length prefix' if data.bytesize < 4
|
|
62
|
+
|
|
63
|
+
declared_len = data[0..3].unpack1('V')
|
|
64
|
+
payload = data[4..]
|
|
65
|
+
|
|
66
|
+
raise ProtocolError, "Truncated payload: expected #{declared_len}, got #{payload&.bytesize || 0}" if payload.nil? || payload.bytesize < declared_len
|
|
67
|
+
|
|
68
|
+
result = { port: 0, api_key: '' }
|
|
69
|
+
pos = 0
|
|
70
|
+
|
|
71
|
+
while pos < declared_len
|
|
72
|
+
tag_byte, new_pos = decode_varint(payload, pos)
|
|
73
|
+
pos = new_pos
|
|
74
|
+
field_number = tag_byte >> 3
|
|
75
|
+
wire_type = tag_byte & 0x07
|
|
76
|
+
|
|
77
|
+
case wire_type
|
|
78
|
+
when 0 # varint
|
|
79
|
+
value, pos = decode_varint(payload, pos)
|
|
80
|
+
result[:port] = value if field_number == 1
|
|
81
|
+
when 2 # length-delimited
|
|
82
|
+
length, pos = decode_varint(payload, pos)
|
|
83
|
+
value = payload[pos, length]
|
|
84
|
+
pos += length
|
|
85
|
+
result[:api_key] = value.force_encoding('UTF-8') if field_number == 2
|
|
86
|
+
else
|
|
87
|
+
raise ProtocolError, "Unsupported wire type #{wire_type} at position #{pos}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Read a length-prefixed frame from an IO (blocking).
|
|
95
|
+
# Returns the raw payload bytes.
|
|
96
|
+
def self.read_length_prefixed(io, timeout: 10)
|
|
97
|
+
len_bytes = read_exactly(io, 4, timeout: timeout)
|
|
98
|
+
raise ProtocolError, 'EOF reading length prefix' unless len_bytes&.bytesize == 4
|
|
99
|
+
|
|
100
|
+
payload_len = len_bytes.unpack1('V')
|
|
101
|
+
raise ProtocolError, "Unreasonable payload length: #{payload_len}" if payload_len > 1_000_000
|
|
102
|
+
|
|
103
|
+
frame = [payload_len].pack('V') + read_exactly(io, payload_len, timeout: timeout)
|
|
104
|
+
frame
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class << self
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def encode_client_info
|
|
111
|
+
buf = ''.b
|
|
112
|
+
buf << encode_string_field(1, 'ruby')
|
|
113
|
+
buf << encode_string_field(2, Antigravity::VERSION)
|
|
114
|
+
buf << encode_string_field(3, RUBY_VERSION)
|
|
115
|
+
buf << encode_string_field(4, ruby_platform_os)
|
|
116
|
+
buf << encode_string_field(5, RUBY_PLATFORM)
|
|
117
|
+
buf
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ruby_platform_os
|
|
121
|
+
case RUBY_PLATFORM
|
|
122
|
+
when /darwin/i then 'macos'
|
|
123
|
+
when /linux/i then 'linux'
|
|
124
|
+
when /win/i then 'windows'
|
|
125
|
+
else RUBY_PLATFORM
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Encode a string field: tag + varint length + UTF-8 bytes
|
|
130
|
+
def encode_string_field(field_number, value)
|
|
131
|
+
encode_bytes_field(field_number, value.to_s.encode('UTF-8').b)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Encode a length-delimited field: tag + varint length + raw bytes
|
|
135
|
+
def encode_bytes_field(field_number, bytes)
|
|
136
|
+
tag = (field_number << 3) | 2 # wire type 2 = length-delimited
|
|
137
|
+
encode_varint_bytes(tag) + encode_varint_bytes(bytes.bytesize) + bytes
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Encode an integer as varint bytes
|
|
141
|
+
def encode_varint_bytes(value)
|
|
142
|
+
bytes = []
|
|
143
|
+
loop do
|
|
144
|
+
byte = value & 0x7F
|
|
145
|
+
value >>= 7
|
|
146
|
+
byte |= 0x80 if value > 0
|
|
147
|
+
bytes << byte
|
|
148
|
+
break if value == 0
|
|
149
|
+
end
|
|
150
|
+
bytes.pack('C*')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Decode a varint starting at position, returns [value, new_position]
|
|
154
|
+
def decode_varint(data, pos)
|
|
155
|
+
result = 0
|
|
156
|
+
shift = 0
|
|
157
|
+
loop do
|
|
158
|
+
raise ProtocolError, "Varint extends past end of data at pos #{pos}" if pos >= data.bytesize
|
|
159
|
+
|
|
160
|
+
byte = data.getbyte(pos)
|
|
161
|
+
pos += 1
|
|
162
|
+
result |= (byte & 0x7F) << shift
|
|
163
|
+
break if (byte & 0x80) == 0
|
|
164
|
+
|
|
165
|
+
shift += 7
|
|
166
|
+
raise ProtocolError, 'Varint too long' if shift >= 64
|
|
167
|
+
end
|
|
168
|
+
[result, pos]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read exactly n bytes from IO with timeout
|
|
172
|
+
def read_exactly(io, n, timeout: 10)
|
|
173
|
+
buf = ''.b
|
|
174
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
175
|
+
while buf.bytesize < n
|
|
176
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
177
|
+
raise ProtocolError, "Timeout reading #{n} bytes from harness" if remaining <= 0
|
|
178
|
+
|
|
179
|
+
ready = IO.select([io], nil, nil, [remaining, 0.1].min)
|
|
180
|
+
if ready
|
|
181
|
+
chunk = io.read_nonblock(n - buf.bytesize, exception: false)
|
|
182
|
+
case chunk
|
|
183
|
+
when String then buf << chunk
|
|
184
|
+
when :wait_readable then next
|
|
185
|
+
when nil then raise ProtocolError, 'EOF while reading from harness'
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
buf
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/antigravity/sidecar.rb
CHANGED
|
@@ -6,9 +6,9 @@ require "fileutils"
|
|
|
6
6
|
|
|
7
7
|
module Antigravity
|
|
8
8
|
module Sidecar
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
# Abstract runner for non-blocking agent sidecars.
|
|
10
|
+
# Subclass and implement #process_event.
|
|
11
|
+
class Runner < Antigravity::Base
|
|
12
12
|
|
|
13
13
|
attr_reader :name, :queue, :worker_thread
|
|
14
14
|
|
|
@@ -52,7 +52,7 @@ module Antigravity
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Async Audit Logger Sidecar
|
|
55
|
-
class AuditLogger <
|
|
55
|
+
class AuditLogger < Runner
|
|
56
56
|
attr_reader :log_file
|
|
57
57
|
|
|
58
58
|
def initialize(log_file = "log/agent_audit.jsonl")
|
|
@@ -72,7 +72,7 @@ module Antigravity
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# Async Vulnerability & Code Quality Scanner Sidecar
|
|
75
|
-
class VulnerabilityScanner <
|
|
75
|
+
class VulnerabilityScanner < Runner
|
|
76
76
|
attr_reader :scanned_events
|
|
77
77
|
|
|
78
78
|
def initialize
|
|
@@ -90,5 +90,7 @@ module Antigravity
|
|
|
90
90
|
@scanned_events << { tool: tool_name, params: params, verified: true }
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
|
+
# Backward-compatible alias
|
|
94
|
+
Base = Runner
|
|
93
95
|
end
|
|
94
96
|
end
|
data/lib/antigravity/skill.rb
CHANGED
|
@@ -3,31 +3,78 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
|
|
5
5
|
module Antigravity
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# Represents an Agent Skill loaded from a SKILL.md file or defined inline.
|
|
7
|
+
# Spec: https://agentskills.io/specification
|
|
8
|
+
#
|
|
9
|
+
# A skill directory must contain a SKILL.md with YAML frontmatter:
|
|
10
|
+
# ---
|
|
11
|
+
# name: my-skill
|
|
12
|
+
# description: What this skill does
|
|
13
|
+
# ---
|
|
14
|
+
# # Instructions in markdown...
|
|
15
|
+
class Skill < Base
|
|
8
16
|
|
|
9
|
-
attr_reader :name, :description, :instructions, :path
|
|
17
|
+
attr_reader :name, :description, :instructions, :path, :metadata
|
|
10
18
|
|
|
19
|
+
# Load a skill from a directory containing SKILL.md.
|
|
20
|
+
# @param path [String] path to skill directory
|
|
11
21
|
def initialize(path)
|
|
12
22
|
@path = File.expand_path(path)
|
|
23
|
+
@metadata = {}
|
|
13
24
|
parse_skill_file
|
|
14
25
|
end
|
|
15
26
|
|
|
27
|
+
# Factory: load from directory path.
|
|
16
28
|
def self.load(path)
|
|
17
29
|
new(path)
|
|
18
30
|
end
|
|
19
31
|
|
|
32
|
+
# Factory: create an inline skill (no file needed).
|
|
33
|
+
# @param name [String] skill name (lowercase, hyphenated)
|
|
34
|
+
# @param description [String] what the skill does
|
|
35
|
+
# @param instructions [String] the skill instructions (markdown)
|
|
36
|
+
# @return [Skill] an inline skill instance
|
|
37
|
+
def self.inline(name:, description:, instructions:)
|
|
38
|
+
skill = allocate
|
|
39
|
+
skill.send(:init_inline, name: name, description: description, instructions: instructions)
|
|
40
|
+
skill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Check if a directory is a valid skill (contains SKILL.md).
|
|
44
|
+
# @param path [String] directory path to check
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def self.skill_dir?(path)
|
|
47
|
+
SkillResolver.skill_dir?(path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
"#<Skill name=#{@name.inspect} path=#{@path.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inspect
|
|
55
|
+
"#<Antigravity::Skill name=#{@name.inspect} description=#{@description.inspect} path=#{@path.inspect}>"
|
|
56
|
+
end
|
|
57
|
+
|
|
20
58
|
private
|
|
21
59
|
|
|
60
|
+
def init_inline(name:, description:, instructions:)
|
|
61
|
+
@name = name
|
|
62
|
+
@description = description
|
|
63
|
+
@instructions = instructions
|
|
64
|
+
@path = nil # inline skills have no path
|
|
65
|
+
@metadata = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
22
68
|
def parse_skill_file
|
|
23
69
|
skill_file = File.join(@path, "SKILL.md")
|
|
24
70
|
raise ArgumentError, "SKILL.md not found at #{@path}" unless File.exist?(skill_file)
|
|
25
71
|
|
|
26
|
-
content = File.read(skill_file)
|
|
72
|
+
content = File.read(skill_file, encoding: 'UTF-8')
|
|
27
73
|
if content =~ /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m
|
|
28
|
-
front_matter = YAML.safe_load(Regexp.last_match(1))
|
|
29
|
-
@name = front_matter["name"]
|
|
30
|
-
@description = front_matter["description"]
|
|
74
|
+
front_matter = YAML.safe_load(Regexp.last_match(1)) || {}
|
|
75
|
+
@name = front_matter["name"] || File.basename(@path)
|
|
76
|
+
@description = front_matter["description"] || ""
|
|
77
|
+
@metadata = front_matter.fetch("metadata", {})
|
|
31
78
|
@instructions = Regexp.last_match.post_match.strip
|
|
32
79
|
else
|
|
33
80
|
@name = File.basename(@path)
|