feat: add agy CLI provider and support structured output via prompt augmentation

This commit is contained in:
2026-05-20 00:21:03 +02:00
parent d5958203cb
commit 17d83f2e2a
5 changed files with 163 additions and 6 deletions
+20 -1
View File
@@ -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
}
+9 -2
View File
@@ -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)
}
+92
View File
@@ -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")
}
}
+25
View File
@@ -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 }
+17 -3
View File
@@ -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 {