From 17d83f2e2abf77f2b2795f8bb0902ddb15ad7e83 Mon Sep 17 00:00:00 2001 From: vikingowl <26+vikingowl@noreply.somegit.dev> Date: Wed, 20 May 2026 00:21:03 +0200 Subject: [PATCH] feat: add agy CLI provider and support structured output via prompt augmentation --- internal/provider/subprocess/agent.go | 21 ++++- internal/provider/subprocess/agent_test.go | 11 ++- internal/provider/subprocess/agy_test.go | 92 ++++++++++++++++++++++ internal/provider/subprocess/format.go | 25 ++++++ internal/provider/subprocess/provider.go | 20 ++++- 5 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 internal/provider/subprocess/agy_test.go diff --git a/internal/provider/subprocess/agent.go b/internal/provider/subprocess/agent.go index af9fa1a..94c5afd 100644 --- a/internal/provider/subprocess/agent.go +++ b/internal/provider/subprocess/agent.go @@ -24,6 +24,7 @@ const ( FormatClaudeStreamJSON StreamFormat = "claude-stream-json" FormatGeminiStreamJSON StreamFormat = "gemini-stream-json" FormatVibeStreaming StreamFormat = "vibe-streaming" + FormatAgyText StreamFormat = "agy-text" ) // CLIAgent describes a known CLI agent binary. @@ -90,10 +91,26 @@ var knownAgents = []CLIAgent{ ContextWindow: 128000, }, }, + { + Name: "agy", + DisplayName: "Antigravity", + ProbeArgs: []string{"--version"}, + PromptArgs: func(p string) []string { + return []string{"-p", p} + }, + Format: FormatAgyText, + Capabilities: provider.Capabilities{ + ToolUse: true, + JSONOutput: true, + Vision: true, + // Agy is a full agent, context window is effectively huge + ContextWindow: 200000, + }, + }, } // newParser returns a FormatParser for the given format. -func newParser(f StreamFormat) FormatParser { +func newParser(f StreamFormat, rf *provider.ResponseFormat) FormatParser { switch f { case FormatClaudeStreamJSON: return newClaudeParser() @@ -101,6 +118,8 @@ func newParser(f StreamFormat) FormatParser { return newGeminiParser() case FormatVibeStreaming: return newVibeParser() + case FormatAgyText: + return newAgyParser(rf) default: return nil } diff --git a/internal/provider/subprocess/agent_test.go b/internal/provider/subprocess/agent_test.go index ba2b172..9b7ad92 100644 --- a/internal/provider/subprocess/agent_test.go +++ b/internal/provider/subprocess/agent_test.go @@ -53,6 +53,7 @@ func TestKnownAgents_ValidFormats(t *testing.T) { FormatClaudeStreamJSON: true, FormatGeminiStreamJSON: true, FormatVibeStreaming: true, + FormatAgyText: true, } for _, a := range knownAgents { if !valid[a.Format] { @@ -79,8 +80,14 @@ func TestKnownAgents_PromptArgsIncludePrompt(t *testing.T) { } func TestNewParser_ReturnsParserForKnownFormats(t *testing.T) { - for _, f := range []StreamFormat{FormatClaudeStreamJSON, FormatGeminiStreamJSON, FormatVibeStreaming} { - p := newParser(f) + formats := []StreamFormat{ + FormatClaudeStreamJSON, + FormatGeminiStreamJSON, + FormatVibeStreaming, + FormatAgyText, + } + for _, f := range formats { + p := newParser(f, nil) if p == nil { t.Errorf("newParser(%q) returned nil", f) } diff --git a/internal/provider/subprocess/agy_test.go b/internal/provider/subprocess/agy_test.go new file mode 100644 index 0000000..82f73b6 --- /dev/null +++ b/internal/provider/subprocess/agy_test.go @@ -0,0 +1,92 @@ +package subprocess + +import ( + "encoding/json" + "strings" + "testing" + + "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/provider" + "somegit.dev/Owlibou/gnoma/internal/stream" +) + +func TestAgyProvider_StreamAugmentation(t *testing.T) { + agent := CLIAgent{ + Name: "agy", + PromptArgs: func(p string) []string { + return []string{"-p", p} + }, + Format: FormatAgyText, + } + _ = New(DiscoveredAgent{CLIAgent: agent, Path: "agy"}) + + schema := json.RawMessage(`{"type": "object", "properties": {"foo": {"type": "string"}}}`) + req := provider.Request{ + Messages: []message.Message{ + message.NewUserText("Hello"), + }, + ResponseFormat: &provider.ResponseFormat{ + Type: provider.ResponseJSON, + JSONSchema: &provider.JSONSchema{ + Schema: schema, + }, + }, + } + + // We can't easily run the subprocess in a unit test without mocking exec.Command. + // But we can check the prompt augmentation logic if we refactor Stream or test it indirectly. + // For now, let's just verify the agyParser emits text deltas. + + parser := newParser(FormatAgyText, req.ResponseFormat) + lines := [][]byte{ + []byte("Thinking..."), + []byte(`{"foo": "bar"}`), + } + + var allEvents []stream.Event + for _, line := range lines { + evts, err := parser.ParseLine(line) + if err != nil { + t.Fatalf("ParseLine failed: %v", err) + } + allEvents = append(allEvents, evts...) + } + + var sb strings.Builder + for _, ev := range allEvents { + if ev.Type == stream.EventTextDelta { + sb.WriteString(ev.Text) + } + } + + want := "Thinking...\n{\"foo\": \"bar\"}\n" + if sb.String() != want { + t.Errorf("output = %q, want %q", sb.String(), want) + } +} + +func TestAgyProvider_BuildPrompt(t *testing.T) { + agent := CLIAgent{Name: "agy"} + p := New(DiscoveredAgent{CLIAgent: agent}) + + schema := json.RawMessage(`{"type": "object"}`) + req := provider.Request{ + Messages: []message.Message{ + message.NewUserText("Hello"), + }, + ResponseFormat: &provider.ResponseFormat{ + Type: provider.ResponseJSON, + JSONSchema: &provider.JSONSchema{ + Schema: schema, + }, + }, + } + + prompt := p.buildPrompt(req) + if !strings.Contains(prompt, "IMPORTANT: You MUST respond with a valid JSON object") { + t.Error("prompt missing JSON instructions") + } + if !strings.Contains(prompt, `{"type": "object"}`) { + t.Error("prompt missing schema") + } +} diff --git a/internal/provider/subprocess/format.go b/internal/provider/subprocess/format.go index 521bc4e..c617db2 100644 --- a/internal/provider/subprocess/format.go +++ b/internal/provider/subprocess/format.go @@ -5,6 +5,7 @@ import ( "fmt" "somegit.dev/Owlibou/gnoma/internal/message" + "somegit.dev/Owlibou/gnoma/internal/provider" "somegit.dev/Owlibou/gnoma/internal/stream" ) @@ -224,3 +225,27 @@ func (p *vibeParser) ParseLine(line []byte) ([]stream.Event, error) { } func (p *vibeParser) Done() []stream.Event { return nil } + +// --- agy-text --- +// Format emitted by: agy -p "..." +// +// agy emits plain text to stdout. Each line is emitted as an EventTextDelta. +// If ResponseFormat is JSON, the prompt was augmented to request JSON; +// we still emit everything as text so the user sees progress. + +type agyParser struct { + rf *provider.ResponseFormat +} + +func newAgyParser(rf *provider.ResponseFormat) FormatParser { + return &agyParser{rf: rf} +} + +func (p *agyParser) ParseLine(line []byte) ([]stream.Event, error) { + return []stream.Event{{ + Type: stream.EventTextDelta, + Text: string(line) + "\n", + }}, nil +} + +func (p *agyParser) Done() []stream.Event { return nil } diff --git a/internal/provider/subprocess/provider.go b/internal/provider/subprocess/provider.go index 3f3087e..03070b6 100644 --- a/internal/provider/subprocess/provider.go +++ b/internal/provider/subprocess/provider.go @@ -4,7 +4,8 @@ // Impedance mismatch: these CLI agents are full agentic loops, not LLM endpoints. // Only the latest user message is passed as a prompt. The following provider.Request // fields are intentionally ignored: Tools, SystemPrompt, Messages (history), -// Temperature, TopP, TopK, Thinking, ResponseFormat, ToolChoice, MaxTokens. +// Temperature, TopP, TopK, Thinking, ToolChoice, MaxTokens. +// ResponseFormat is partially supported via prompt augmentation for agy. // Internal tool calls executed by the CLI are surfaced as EventTextDelta (opaque). package subprocess @@ -50,12 +51,12 @@ func (p *Provider) Models(_ context.Context) ([]provider.ModelInfo, error) { // returns an event stream. All fields in req except the last user message are // ignored — see package doc for rationale. func (p *Provider) Stream(ctx context.Context, req provider.Request) (stream.Stream, error) { - prompt := extractLastUserMessage(req.Messages) + prompt := p.buildPrompt(req) args := p.agent.PromptArgs(prompt) cmd := exec.CommandContext(ctx, p.agent.Path, args...) - parser := newParser(p.agent.Format) + parser := newParser(p.agent.Format, req.ResponseFormat) if parser == nil { return nil, fmt.Errorf("subprocess: unknown format %q for agent %q", p.agent.Format, p.agent.Name) } @@ -67,6 +68,19 @@ func (p *Provider) Stream(ctx context.Context, req provider.Request) (stream.Str return s, nil } +func (p *Provider) buildPrompt(req provider.Request) string { + prompt := extractLastUserMessage(req.Messages) + + // Support ResponseJSON via prompt augmentation for agy (plain text agent) + if req.ResponseFormat != nil && req.ResponseFormat.Type == provider.ResponseJSON && p.agent.Name == "agy" { + prompt += "\n\nIMPORTANT: You MUST respond with a valid JSON object matching this schema:\n" + prompt += string(req.ResponseFormat.JSONSchema.Schema) + prompt += "\nRespond with JSON only." + } + + return prompt +} + // extractLastUserMessage returns the content of the last user-role message in msgs. // Returns an empty string if there are no user messages. func extractLastUserMessage(msgs []message.Message) string {