Files
gnoma/internal/provider/subprocess/provider.go
T
vikingowl 44d0bdc032 feat(provider): subprocess CLI provider for claude, gemini, vibe
Adds internal/provider/subprocess — a provider.Provider that spawns CLI
agents (claude, gemini, vibe) as subprocesses and streams their output.

- FormatParser interface + three parsers for claude-stream-json,
  gemini-stream-json, and vibe-streaming formats; fixtures captured from
  real binaries
- subprocessStream: pull-based stream.Stream over subprocess stdout with
  bounded stderr capture (8KB) and guarded reap() to prevent double-Wait
- DiscoverCLIAgents: parallel PATH scan with 10s timeout, stable ordering
- Provider: only the last user message is passed as --prompt; all other
  request fields (history, tools, system prompt) are intentionally ignored
  (see package doc)
- main.go: discover and register CLI arms at startup; TODO(P0c) for
  tier-based routing to enforce preference order explicitly
2026-05-07 14:29:34 +02:00

86 lines
2.8 KiB
Go

// Package subprocess provides a provider.Provider that delegates to CLI agents
// (claude, gemini, vibe) by spawning them as subprocesses.
//
// 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.
// Internal tool calls executed by the CLI are surfaced as EventTextDelta (opaque).
package subprocess
import (
"context"
"fmt"
"os/exec"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// Provider wraps a single DiscoveredAgent and implements provider.Provider.
type Provider struct {
agent DiscoveredAgent
}
// New creates a Provider for the given discovered CLI agent.
func New(agent DiscoveredAgent) *Provider {
return &Provider{agent: agent}
}
// Name returns "subprocess" — all CLI agents share this provider namespace.
func (p *Provider) Name() string { return "subprocess" }
// DefaultModel returns the CLI binary name (e.g., "claude", "gemini", "vibe").
func (p *Provider) DefaultModel() string { return p.agent.Name }
// Models returns a single ModelInfo describing this CLI agent.
func (p *Provider) Models(_ context.Context) ([]provider.ModelInfo, error) {
return []provider.ModelInfo{
{
ID: p.agent.Name,
Name: p.agent.DisplayName,
Provider: "subprocess",
Capabilities: p.agent.Capabilities,
},
}, nil
}
// Stream spawns the CLI agent with the latest user message as a prompt and
// 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)
args := p.agent.PromptArgs(prompt)
cmd := exec.CommandContext(ctx, p.agent.Path, args...)
parser := newParser(p.agent.Format)
if parser == nil {
return nil, fmt.Errorf("subprocess: unknown format %q for agent %q", p.agent.Format, p.agent.Name)
}
s, err := newSubprocessStream(ctx, cmd, parser)
if err != nil {
return nil, fmt.Errorf("subprocess %q: %w", p.agent.Name, err)
}
return s, nil
}
// 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 {
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m.Role != message.RoleUser {
continue
}
for _, c := range m.Content {
if c.Type == message.ContentText && c.Text != "" {
return c.Text
}
}
}
return ""
}