feat: add agy CLI provider and support structured output via prompt augmentation
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user