Add Antigravity (agy) as a third agent backend for gemini-* models
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"g.fc.run/ai/byre/internal/antigravity"
|
||||
)
|
||||
|
||||
type agySteps struct {
|
||||
mu sync.Mutex
|
||||
text map[int]*strings.Builder
|
||||
tools map[string]bool
|
||||
}
|
||||
|
||||
func newAgySteps() *agySteps {
|
||||
return &agySteps{text: map[int]*strings.Builder{}, tools: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (m *Manager) handleAntigravity(ctx context.Context, l *live, p antigravityProcess, ev antigravity.Event) error {
|
||||
switch ev.Kind {
|
||||
case "init":
|
||||
return nil
|
||||
|
||||
case "step_update":
|
||||
su, err := ev.StepUpdate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.agyStep(ctx, l, p, ev, su)
|
||||
|
||||
case "result":
|
||||
res, err := ev.Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.agyTurnEnded(ctx, l, p, res)
|
||||
}
|
||||
|
||||
raw := map[string]any{}
|
||||
err := json.Unmarshal(ev.Raw, &raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
|
||||
}
|
||||
|
||||
func (m *Manager) agyStep(ctx context.Context, l *live, p antigravityProcess, ev antigravity.Event, su antigravity.StepUpdate) error {
|
||||
switch su.StepType {
|
||||
case "user_input":
|
||||
return nil
|
||||
|
||||
case "agent_response":
|
||||
p.steps.mu.Lock()
|
||||
b := p.steps.text[su.StepIndex]
|
||||
if b == nil {
|
||||
b = &strings.Builder{}
|
||||
p.steps.text[su.StepIndex] = b
|
||||
}
|
||||
b.WriteString(su.TextDelta)
|
||||
done := su.State != "ACTIVE"
|
||||
if done {
|
||||
delete(p.steps.text, su.StepIndex)
|
||||
}
|
||||
p.steps.mu.Unlock()
|
||||
|
||||
if su.TextDelta != "" {
|
||||
m.store.Broadcast(m.event(l, agentActor, "agent.delta", map[string]any{"text": su.TextDelta}))
|
||||
}
|
||||
text := strings.TrimSpace(b.String())
|
||||
if !done || text == "" {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
l.lastText = text
|
||||
m.mu.Unlock()
|
||||
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": text})
|
||||
|
||||
case "tool":
|
||||
id := fmt.Sprintf("%s/%d", su.ConversationID, su.StepIndex)
|
||||
info := antigravity.ToolInfo{Name: su.ToolName}
|
||||
if su.ToolInfo != nil {
|
||||
info = *su.ToolInfo
|
||||
}
|
||||
p.steps.mu.Lock()
|
||||
called := p.steps.tools[id]
|
||||
p.steps.tools[id] = su.State == "ACTIVE"
|
||||
if su.State != "ACTIVE" {
|
||||
delete(p.steps.tools, id)
|
||||
}
|
||||
p.steps.mu.Unlock()
|
||||
|
||||
if !called {
|
||||
name, input, err := agyToolCall(su.ToolName, info.Parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.emit(ctx, l, agentActor, "agent.tool_call", map[string]any{"id": id, "name": name, "input": input})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if su.State == "ACTIVE" {
|
||||
return nil
|
||||
}
|
||||
content := info.Output
|
||||
if info.Error != "" {
|
||||
content = info.Error
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.tool_result", map[string]any{"tool_use_id": id, "is_error": su.State != "DONE" || info.Error != "", "content": content})
|
||||
}
|
||||
|
||||
raw := map[string]any{}
|
||||
err := json.Unmarshal(ev.Raw, &raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.emit(ctx, l, agentActor, "agent.vendor", raw)
|
||||
}
|
||||
|
||||
func agyToolCall(name string, params json.RawMessage) (string, any, error) {
|
||||
switch name {
|
||||
case "run_command":
|
||||
p := struct {
|
||||
CommandLine string
|
||||
}{}
|
||||
err := json.Unmarshal(params, &p)
|
||||
return "Bash", map[string]any{"command": p.CommandLine}, err
|
||||
case "call_mcp_tool":
|
||||
p := struct {
|
||||
ServerName string
|
||||
ToolName string
|
||||
Arguments json.RawMessage
|
||||
}{}
|
||||
err := json.Unmarshal(params, &p)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
args, err := decode(p.Arguments)
|
||||
return "mcp__" + strings.TrimPrefix(p.ServerName, "byre_") + "__" + p.ToolName, args, err
|
||||
}
|
||||
input, err := decode(params)
|
||||
return name, input, err
|
||||
}
|
||||
|
||||
func (m *Manager) agyTurnEnded(ctx context.Context, l *live, p antigravityProcess, res antigravity.Result) error {
|
||||
m.mu.Lock()
|
||||
final := l.lastText
|
||||
m.mu.Unlock()
|
||||
|
||||
success := res.Status == "SUCCESS"
|
||||
bounce := false
|
||||
if success && l.implementer() {
|
||||
var err error
|
||||
bounce, err = m.takeBounce(ctx, l, final)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if bounce {
|
||||
err := m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true, "bounce": true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
l.lastText = ""
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
err := p.s.Send(proseQuestionNudge)
|
||||
if err != nil {
|
||||
slog.Error("send prose question nudge", "session", l.id, "err", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
r := endResult{
|
||||
subtype: strings.ToLower(res.Status),
|
||||
isError: !success,
|
||||
result: final,
|
||||
}
|
||||
if !success {
|
||||
r.result = res.Error
|
||||
}
|
||||
r.payload = map[string]any{
|
||||
"subtype": r.subtype,
|
||||
"is_error": r.isError,
|
||||
"result": r.result,
|
||||
}
|
||||
return m.turnEnded(ctx, l, r)
|
||||
}
|
||||
|
||||
func (m *Manager) agyBeforeModel(l *live) antigravity.HookFunc {
|
||||
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
|
||||
in := struct {
|
||||
InvocationNum int `json:"invocationNum"`
|
||||
}{}
|
||||
err := json.Unmarshal(input, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.InvocationNum == 0 || l.reviewID != "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
mt := m.deliverMidTurn(ctx, l)
|
||||
if mt.text == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return map[string]any{"injectSteps": []map[string]any{{"userMessage": mt.text}}}, nil
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
if srcSess.Vendor == api.Antigravity {
|
||||
return api.Task{}, fmt.Errorf("%w: snapshot %d of task %s is from an Antigravity session, and agy can't fork a conversation", ErrConflict, n, src.Id)
|
||||
}
|
||||
events, err := m.store.TaskEvents(ctx, src.Id, 0, -1)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
|
||||
+43
-39
@@ -35,15 +35,16 @@ const agentActor = "agent"
|
||||
const restartNotice = "byre restarted while you were mid-turn, so your last turn was cut off. Re-check the working tree (git status and the files you were editing) before continuing where you left off."
|
||||
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
worktreeRoot string
|
||||
leadRoot string
|
||||
handsRoot string
|
||||
codexHome string
|
||||
status func(context.Context) (api.Status, error)
|
||||
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
|
||||
clients *clients.Registry
|
||||
terminals *terminal.Terminals
|
||||
store *store.Store
|
||||
worktreeRoot string
|
||||
leadRoot string
|
||||
handsRoot string
|
||||
codexHome string
|
||||
antigravityRoot string
|
||||
status func(context.Context) (api.Status, error)
|
||||
addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error)
|
||||
clients *clients.Registry
|
||||
terminals *terminal.Terminals
|
||||
|
||||
mu sync.Mutex
|
||||
live map[string]*live
|
||||
@@ -125,37 +126,38 @@ func (l *live) implementer() bool {
|
||||
return l.owner == "" && l.reviewID == ""
|
||||
}
|
||||
|
||||
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
|
||||
func New(st *store.Store, worktreeRoot, leadRoot, handsRoot, codexHome, antigravityRoot string, status func(context.Context) (api.Status, error), addProject func(ctx context.Context, name, repoPath, createdBy string) (api.Project, error), clientRegistry *clients.Registry, terminals *terminal.Terminals) *Manager {
|
||||
m := &Manager{
|
||||
store: st,
|
||||
worktreeRoot: worktreeRoot,
|
||||
leadRoot: leadRoot,
|
||||
handsRoot: handsRoot,
|
||||
codexHome: codexHome,
|
||||
status: status,
|
||||
addProject: addProject,
|
||||
clients: clientRegistry,
|
||||
terminals: terminals,
|
||||
live: map[string]*live{},
|
||||
starting: map[string]*start{},
|
||||
deliverLocks: map[string]*sessionLock{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
leadAnswered: map[string]bool{},
|
||||
paneFocus: map[string]api.PaneFocus{},
|
||||
paneReported: map[string]time.Time{},
|
||||
paneMoved: map[string]time.Time{},
|
||||
humanMoved: map[string]time.Time{},
|
||||
moveRefused: map[string]bool{},
|
||||
startedTodo: map[string]time.Time{},
|
||||
startRefused: map[string]bool{},
|
||||
finishing: map[string]bool{},
|
||||
handoffModel: map[string]string{},
|
||||
mergeTurns: map[string]mergeTurn{},
|
||||
signals: make(chan []api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
merges: map[string]*projectMerge{},
|
||||
bad: newBadLines(),
|
||||
watchStop: make(chan struct{}),
|
||||
store: st,
|
||||
worktreeRoot: worktreeRoot,
|
||||
leadRoot: leadRoot,
|
||||
handsRoot: handsRoot,
|
||||
codexHome: codexHome,
|
||||
antigravityRoot: antigravityRoot,
|
||||
status: status,
|
||||
addProject: addProject,
|
||||
clients: clientRegistry,
|
||||
terminals: terminals,
|
||||
live: map[string]*live{},
|
||||
starting: map[string]*start{},
|
||||
deliverLocks: map[string]*sessionLock{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
leadAnswered: map[string]bool{},
|
||||
paneFocus: map[string]api.PaneFocus{},
|
||||
paneReported: map[string]time.Time{},
|
||||
paneMoved: map[string]time.Time{},
|
||||
humanMoved: map[string]time.Time{},
|
||||
moveRefused: map[string]bool{},
|
||||
startedTodo: map[string]time.Time{},
|
||||
startRefused: map[string]bool{},
|
||||
finishing: map[string]bool{},
|
||||
handoffModel: map[string]string{},
|
||||
mergeTurns: map[string]mergeTurn{},
|
||||
signals: make(chan []api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
merges: map[string]*projectMerge{},
|
||||
bad: newBadLines(),
|
||||
watchStop: make(chan struct{}),
|
||||
}
|
||||
go m.signalLoop()
|
||||
go m.readCodexRateLimit()
|
||||
@@ -814,6 +816,8 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
|
||||
switch l.vendor {
|
||||
case api.Codex:
|
||||
l.sess, err = m.startCodex(sctx, sess, l, g, servers, stdio, env)
|
||||
case api.Antigravity:
|
||||
l.sess, err = m.startAntigravity(sctx, sess, l, g, servers, stdio, env)
|
||||
default:
|
||||
l.sess, err = m.startClaude(sctx, sess, l, g, servers, stdio, env)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/ai/byre/internal/antigravity"
|
||||
"g.fc.run/ai/byre/internal/api"
|
||||
"g.fc.run/ai/byre/internal/claude"
|
||||
"g.fc.run/ai/byre/internal/codex"
|
||||
@@ -32,7 +35,7 @@ type agentProcess interface {
|
||||
}
|
||||
|
||||
func initialVendorSessionID(vendor api.AgentSessionVendor, id string) string {
|
||||
if vendor == api.Codex {
|
||||
if vendor != api.Claude {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
@@ -238,3 +241,86 @@ func (p codexProcess) drive(m *Manager, l *live) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type antigravityProcess struct {
|
||||
s *antigravity.Session
|
||||
steps *agySteps
|
||||
}
|
||||
|
||||
func (m *Manager) startAntigravity(ctx context.Context, sess api.AgentSession, l *live, g string, servers []*mcp.Server, stdio []mcp.Stdio, env []string) (agentProcess, error) {
|
||||
opts := antigravity.Options{
|
||||
Dir: filepath.Join(m.antigravityRoot, sess.Id),
|
||||
Cwd: l.dir,
|
||||
Model: sess.Model,
|
||||
Resume: sess.VendorSessionId,
|
||||
GitName: l.gitName,
|
||||
GitEmail: l.gitEmail,
|
||||
Env: env,
|
||||
Guidance: g,
|
||||
Notes: strings.TrimSpace(l.notes),
|
||||
Reminder: g,
|
||||
MCPServers: servers,
|
||||
StdioMCP: stdio,
|
||||
Hooks: map[string]antigravity.HookFunc{"PreInvocation": m.agyBeforeModel(l)},
|
||||
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
|
||||
}
|
||||
|
||||
s, err := antigravity.Start(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start agy: %w", err)
|
||||
}
|
||||
if sess.VendorSessionId == "" {
|
||||
err = m.store.SetVendorSessionID(ctx, sess.Id, s.ID())
|
||||
if err != nil {
|
||||
s.Kill()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return antigravityProcess{s: s, steps: newAgySteps()}, nil
|
||||
}
|
||||
|
||||
func (p antigravityProcess) send(ctx context.Context, text string, images []image) error {
|
||||
imgs := []antigravity.Image{}
|
||||
for _, img := range images {
|
||||
imgs = append(imgs, antigravity.Image{MediaType: img.mediaType, Data: img.data})
|
||||
}
|
||||
return p.s.Send(text, imgs...)
|
||||
}
|
||||
|
||||
func (p antigravityProcess) interrupt(ctx context.Context) error {
|
||||
return p.s.Interrupt()
|
||||
}
|
||||
|
||||
func (p antigravityProcess) setModel(ctx context.Context, model string) error {
|
||||
return p.s.SetModel(model)
|
||||
}
|
||||
|
||||
func (p antigravityProcess) kill() { p.s.Kill() }
|
||||
func (p antigravityProcess) pid() int { return p.s.Pid() }
|
||||
func (p antigravityProcess) lastLine() time.Time { return p.s.LastLine() }
|
||||
func (p antigravityProcess) exited() bool { return p.s.Exited() }
|
||||
|
||||
func (p antigravityProcess) discard() {
|
||||
for range p.s.Events() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p antigravityProcess) drive(m *Manager, l *live) {
|
||||
ctx := context.Background()
|
||||
for ev := range p.s.Events() {
|
||||
start := time.Now()
|
||||
if wait := start.Sub(ev.Read); wait > 200*time.Millisecond {
|
||||
slog.Info("agent event waited", "session", l.id, "event", ev.Kind, "wait", wait)
|
||||
}
|
||||
err := m.handleAntigravity(ctx, l, p, ev)
|
||||
if err != nil {
|
||||
slog.Error("record agent event", "session", l.id, "event", ev.Kind, "err", err)
|
||||
}
|
||||
if isDecodeError(err) {
|
||||
m.recordBadLine(l, ev.Raw, fmt.Sprintf("decode %s: %s", ev.Kind, err))
|
||||
}
|
||||
if took := time.Since(start); took > 200*time.Millisecond {
|
||||
slog.Info("agent event slow", "session", l.id, "event", ev.Kind, "took", took)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (m *Manager) subtaskTools(l *live) []mcp.Tool {
|
||||
`{"type": "object", "properties": {
|
||||
"title": {"type": "string", "description": "Short task title, shown in the task list."},
|
||||
"prompt": {"type": "string", "description": "The new agent's first message: a complete, self-contained brief."},
|
||||
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, every other model on Claude Code. Defaults to this session's model."}
|
||||
"model": {"type": "string", "description": "Model for the new agent: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code. Defaults to this session's model."}
|
||||
}, "required": ["title", "prompt"]}`,
|
||||
func(ctx context.Context, creator api.User, raw json.RawMessage) (string, error) {
|
||||
args := struct {
|
||||
|
||||
@@ -652,7 +652,7 @@ const workflowFileHelp = "A workflow's file is YAML: an optional description, th
|
||||
"after (the names of the stages whose outputs it gets; it starts once all of them have finished; absent means the previous stage in the list, and for the first stage none; [] means it starts from the brief), " +
|
||||
"base (parent, the default: branch from the commit its one after stage finished at, or for a stage with none, from where the workflow was started; main: branch from the default branch; " +
|
||||
"refused on a stage with more than one after stage, which always branches from the default branch and is told the branches it can merge), " +
|
||||
"model (which also picks the vendor: gpt-* models run on Codex, every other model on Claude Code) and prompt (the stage agent's instructions). " +
|
||||
"model (which also picks the vendor: gpt-* models run on Codex, gemini-* models on Antigravity, every other model on Claude Code) and prompt (the stage agent's instructions). " +
|
||||
"In place of model, agents (a list of at least two, each with name, model and prompt) runs the stage as a team: those agents work in the stage's one task and worktree and talk through a shared board; " +
|
||||
"each gets the stage's prompt and then its own; the first agent finishes the stage. " +
|
||||
"Every stage runs as its own task. Exactly one stage is final (no other stage comes after it), and cycles are refused. " +
|
||||
|
||||
Reference in New Issue
Block a user