Files
multica/server/pkg/agent/antigravity.go
Bohan Jiang 1ddf89a8f2 feat(daemon): enable Antigravity (agy) per-agent model selection (MUL-3125) (#3894)
* feat(daemon): wire agy --model and model discovery for Antigravity

agy 1.0.6 added a --model flag and an `agy models` catalog command, which
were the #1 blocker in the earlier agy-backend review (MUL-3125). The
antigravity backend already shipped but deliberately dropped opts.Model
because agy 1.0.1 had no way to select a model.

- buildAntigravityArgs now passes --model <display name> when opts.Model is
  set; the value is the exact `agy models` display string (spaces + parens),
  passed as a single exec arg so no shell quoting is needed.
- Block --model in custom_args so it can't override the managed value.
- ListModels("antigravity") enumerates via `agy models` (no static fallback:
  agy silently no-ops on unrecognised models, so a stale guess would turn a
  typo into a successful empty run).
- ModelSelectionSupported now returns true for every built-in provider; the
  hook stays for any future model-less runtime.
- Daemon probe reads MULTICA_ANTIGRAVITY_MODEL for the daemon-wide default.

Co-authored-by: multica-agent <github@multica.ai>

* docs(providers): mark Antigravity model selection as supported

Antigravity gained --model in agy 1.0.6 (MUL-3125). Update the provider
matrix + prose (en/zh/ja/ko) from "managed internally / no --model" to
dynamic discovery via `agy models`, and refresh the now-stale picker
comments. Flag the display-string (not slug) shape and agy's silent no-op
on unrecognised values.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): reject unknown Antigravity model at spawn (MUL-3125)

agy exits 0 with empty output on an unrecognised --model, so a stale/typo'd
value would surface as a 'completed' but empty task. Validate opts.Model
against the `agy models` catalog in Execute before spawning: a non-empty
model the CLI does not advertise fails fast with an actionable error listing
the real choices. opts.Model is the single funnel for agent.model and the
MULTICA_ANTIGRAVITY_MODEL default, so this one check covers every source
(UI free-text, API, persisted value, env) — addressing Elon's review that a
UI-only guard is bypassable.

Validation is fail-OPEN: if the catalog can't be discovered we pass the
value through and let agy resolve it, so a discovery hiccup never blocks a
run. Pure antigravityModelError() is unit-tested (valid / unknown / near-miss
/ empty-model / empty-catalog); verified live against real agy 1.0.6.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 15:32:53 +08:00

304 lines
11 KiB
Go

package agent
import (
"bufio"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// antigravityBackend implements Backend by spawning Google's Antigravity CLI
// (`agy -p <prompt>`) in non-interactive print mode. Unlike Claude / Codex /
// Cursor / Gemini, the Antigravity CLI does not expose a structured event
// stream — stdout is plain assistant text (intermediate "I will run X" lines
// and the final reply, all interleaved). The backend therefore streams stdout
// line-by-line as `MessageText` events and accumulates the same text as the
// final `Result.Output`.
//
// Session resumption uses `--conversation <id>`. The conversation id is not
// emitted on stdout; we capture it by routing `--log-file` to a temp file and
// scanning its glog-formatted lines for the `conversation=<uuid>` token that
// printmode.go logs at message-send time.
type antigravityBackend struct {
cfg Config
}
func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "agy"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("agy executable not found at %q: %w", execPath, err)
}
// Guard against agy's silent no-op on an unrecognised --model: it exits 0
// with empty output, which would otherwise surface as a "completed" but
// empty task. opts.Model is the single funnel for both agent.model and the
// daemon-wide MULTICA_ANTIGRAVITY_MODEL default (resolved in daemon.go), so
// validating it here covers every source — UI free-text, API, a persisted
// value, and the env default alike. Reject a non-empty model the installed
// CLI definitively does not advertise, with an actionable error. Validation
// is fail-OPEN: if the `agy models` catalog can't be discovered we let agy
// resolve the value itself rather than blocking the run on a discovery
// hiccup (see antigravityModelError).
if opts.Model != "" {
catalog, _ := ListModels(ctx, "antigravity", execPath)
if err := antigravityModelError(opts.Model, catalog); err != nil {
return nil, err
}
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
logFile, err := os.CreateTemp("", "multica-agy-log-*.log")
if err != nil {
cancel()
return nil, fmt.Errorf("create agy log file: %w", err)
}
logPath := logFile.Name()
_ = logFile.Close()
args := buildAntigravityArgs(prompt, logPath, timeout, opts, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
_ = os.Remove(logPath)
return nil, fmt.Errorf("agy stdout pipe: %w", err)
}
stderrBuf := newStderrTail(newLogWriter(b.cfg.Logger, "[agy:stderr] "), agentStderrTailBytes)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
cancel()
_ = os.Remove(logPath)
return nil, fmt.Errorf("start agy: %w", err)
}
b.cfg.Logger.Info("agy started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
defer os.Remove(logPath)
startTime := time.Now()
var output strings.Builder
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
for scanner.Scan() {
line := scanner.Text()
if output.Len() > 0 {
output.WriteByte('\n')
}
output.WriteString(line)
if strings.TrimSpace(line) != "" {
trySend(msgCh, Message{Type: MessageText, Content: line})
}
}
if err := scanner.Err(); err != nil {
b.cfg.Logger.Warn("agy stdout scanner error", "err", err)
}
waitErr := cmd.Wait()
duration := time.Since(startTime)
sessionID := readAntigravityConversationID(logPath)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("agy timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if waitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("agy exited with error: %v", waitErr)
}
if finalError != "" {
finalError = withAgentStderr(finalError, "agy", stderrBuf.Tail())
}
b.cfg.Logger.Info("agy finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
// The Antigravity CLI doesn't surface per-turn token usage today;
// leave Usage empty rather than report misleading zeros under a
// guessed model name.
Usage: map[string]TokenUsage{},
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// antigravityConversationIDRe matches the glog line printmode.go writes when
// the CLI dispatches the user's message — the only place in the log that
// reliably surfaces the conversation UUID for both fresh and resumed turns.
//
// Example: `I0528 13:36:23.318877 73304 printmode.go:130] Print mode:
// conversation=b8b263a4-4b2f-4339-acc9-78b248e2b606, sending message`
var antigravityConversationIDRe = regexp.MustCompile(
`conversation=([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})`,
)
// readAntigravityConversationID scans the per-run log file for the
// conversation UUID. Best-effort: returns "" if the log file is missing, the
// CLI exited before dispatching, or the format changes upstream.
func readAntigravityConversationID(logPath string) string {
if logPath == "" {
return ""
}
data, err := os.ReadFile(logPath)
if err != nil {
return ""
}
matches := antigravityConversationIDRe.FindAllSubmatch(data, -1)
if len(matches) == 0 {
return ""
}
// The CLI logs the conversation id repeatedly during a turn (one entry
// per dispatched message, plus stream-update lines). Any non-empty UUID
// in the file resolves to the same conversation, so the last match wins
// — that's what `--conversation` should be pinned to next turn.
return string(matches[len(matches)-1][1])
}
// antigravityBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args. Overriding these would break
// non-interactive operation or the daemon's session-resume bookkeeping.
var antigravityBlockedArgs = map[string]blockedArgMode{
"-p": blockedWithValue,
"--print": blockedWithValue,
"--prompt": blockedWithValue,
"-i": blockedStandalone, // interactive mode would block the daemon
"--prompt-interactive": blockedStandalone,
"-c": blockedStandalone, // resume via --conversation, not --continue
"--continue": blockedStandalone,
"--conversation": blockedWithValue, // managed via ExecOptions.ResumeSessionID
"--model": blockedWithValue, // managed via ExecOptions.Model / agent.model
"--print-timeout": blockedWithValue,
"--dangerously-skip-permissions": blockedStandalone, // always-on in daemon mode
"--log-file": blockedWithValue, // daemon needs it for session capture
}
// buildAntigravityArgs assembles the argv for a one-shot agy invocation.
//
// agy -p <prompt> --dangerously-skip-permissions [--model <display name>]
// --print-timeout <duration> --log-file <tmp>
// [--conversation <id>] [--add-dir <cwd>]
//
// agy 1.0.6 added a `--model` flag (MUL-3125), so opts.Model is now wired
// through when set. The value is the exact human display string `agy models`
// prints (e.g. "Claude Opus 4.6 (Thinking)"), NOT a provider/model slug —
// it's passed verbatim as a single exec arg, so spaces and parens need no
// shell quoting. agy still exposes no --system-prompt; runtime instructions
// are delivered via AGENTS.md in the task workdir.
//
// agy silently no-ops on a model string it doesn't recognise (empty output,
// exit 0), so Execute validates opts.Model against the `agy models` catalog
// and rejects an unrecognised value up front (see antigravityModelError) —
// by the time we build argv the value is either empty or known-good. When
// opts.Model is empty we omit the flag and agy resolves its own default.
func buildAntigravityArgs(prompt, logPath string, timeout time.Duration, opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p", prompt,
"--dangerously-skip-permissions",
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
// Only pass --print-timeout when a positive wall-clock cap is configured.
// timeout <= 0 means "no cap" (MUL-3064): agy then runs without its own
// print-timeout guillotine, matching every other backend's runContext
// semantics. Passing antigravityFormatTimeout(0) would clamp to 1s and kill
// the run almost immediately — the opposite of "no cap".
if timeout > 0 {
args = append(args, "--print-timeout", antigravityFormatTimeout(timeout))
}
args = append(args, "--log-file", logPath)
if opts.ResumeSessionID != "" {
args = append(args, "--conversation", opts.ResumeSessionID)
}
if opts.Cwd != "" {
args = append(args, "--add-dir", filepath.Clean(opts.Cwd))
}
args = append(args, filterCustomArgs(opts.ExtraArgs, antigravityBlockedArgs, logger)...)
args = append(args, filterCustomArgs(opts.CustomArgs, antigravityBlockedArgs, logger)...)
return args
}
// antigravityModelError returns an actionable error when `model` is non-empty
// and definitively absent from `available` (the `agy models` catalog); it
// returns nil otherwise. An empty `available` means discovery couldn't produce
// a catalog (agy missing, transient failure) — we fail OPEN there and let agy
// resolve the value, so a discovery hiccup never blocks a run. The match is
// exact because agy's --model wants the precise display string; a near-miss
// (extra space, dropped suffix) is correctly rejected since agy would silently
// no-op on it anyway.
func antigravityModelError(model string, available []Model) error {
if model == "" || len(available) == 0 {
return nil
}
ids := make([]string, 0, len(available))
for _, m := range available {
if m.ID == model {
return nil
}
ids = append(ids, m.ID)
}
return fmt.Errorf(
"antigravity model %q is not available from `agy models`; pick one of: %s",
model, strings.Join(ids, ", "),
)
}
// antigravityFormatTimeout renders a Go duration in the `<n>m<n>s` shape the
// agy CLI accepts (e.g. 20m0s). Sub-second timeouts round up to 1s so the CLI
// doesn't reject the flag.
func antigravityFormatTimeout(d time.Duration) string {
if d < time.Second {
d = time.Second
}
// time.Duration.String() already produces shapes like "20m0s" / "1h30m0s"
// that agy parses via Go's stdlib flag.Duration on the receiving side.
return d.String()
}