Go 415 lines
// Package ai is the pluggable AI-provider bridge with eeco's shared,
// opt-in gating.
//
// A Provider runs a single Request and returns a Response. Every call
// goes through a Gate that enforces the floor invariants from PLAN.md:
//
// - consent: a pass runs only with --ai or an automation level that
// implies consent;
// - budget cap: a fixed number of gated passes per invocation (a
// tool-using pass may make several model calls but counts as one);
// - never a silent spend, never a hard failure: on no-consent, over
// budget, or provider error the prompt is parked to state/ and a
// queue item is appended, and the caller falls back to its non-AI
// path.
//
// One provider is wired: a generic CLI-based provider that shells an
// operator-chosen command (eeco no longer runs an in-binary model client;
// the AI lives in the harness eeco configures). An unconfigured setup
// yields a stub whose Run cleanly reports "not configured" (handled as a
// parked pass, not an error). Every attempt is recorded to the AI-call
// ledger (state/ai-calls.json). No provider brand appears in product copy.
package ai
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/queue"
)
// ErrNotConfigured is returned by a provider that has nothing wired. The
// Gate treats it like any other non-result: the prompt is parked, never
// surfaced as a hard failure.
var ErrNotConfigured = errors.New("ai provider not configured")
// Message is one turn in a multi-turn conversation. Role is "user" or
// "assistant"; the System block stays on Request.System. The transcript
// is folded to a single prompt string for the CLI provider (foldPrompt).
type Message struct {
Role string // "user" | "assistant"
Text string // plain text turn
}
// Request is one gated provider call. System is the deterministic,
// stable-across-calls block (cheap to recompute, the natural cache
// prefix); User is the volatile per-call instruction or input. A
// provider may ignore Model and Cache.
//
// Messages carries multi-turn history; when it is non-empty the provider
// uses it in place of folding System+User into one user turn (and User is
// ignored). Single-turn callers leave Messages nil and keep today's
// behaviour byte-identical.
type Request struct {
Label string // parking + ledger key
System string // cacheable deterministic block (stable across calls)
User string // volatile per-call instruction / input
Messages []Message // multi-turn history; non-empty overrides User
Model string // optional model override; provider may ignore
Cache bool // hint: ephemeral-cache the System block
}
// Usage reports token accounting for one provider call. Zero for
// providers that do not surface it (the CLI provider) and on every
// parked pass.
type Usage struct {
InputTokens int
CachedInputTokens int
OutputTokens int
}
// Response is the result of one provider call. Model is the model the
// provider actually resolved (may differ from Request.Model); empty when
// the provider does not resolve a model (the CLI provider leaves it
// empty).
type Response struct {
Text string
Model string
Usage Usage
}
// Provider runs a single Request and returns a Response. The
// implementation must respect ctx cancellation and must not write to the
// tracked tree.
type Provider interface {
// Name is an internal identifier for selection and logging only; it
// is never written into product copy or the tracked tree.
Name() string
Run(ctx context.Context, req Request) (Response, error)
}
// foldPrompt collapses a Request to the single prompt string the
// stdin-fed CLI provider, the parked-prompt file, and the ledger hash all
// share. With Messages set it renders a transcript (optional System block,
// then "User: …" / "Assistant: …" per turn); otherwise an empty System
// yields exactly User, so today's User-only callers feed byte-identical
// stdin to the CLI provider.
func foldPrompt(req Request) string {
if len(req.Messages) > 0 {
var b strings.Builder
if req.System != "" {
b.WriteString(req.System)
b.WriteString("\n\n")
}
for i, m := range req.Messages {
if i > 0 {
b.WriteString("\n\n")
}
role := "User"
if m.Role == "assistant" {
role = "Assistant"
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(m.Text)
}
return strings.TrimSpace(b.String())
}
if req.System == "" {
return req.User
}
return req.System + "\n\n" + req.User
}
// cliProvider shells a configured command, feeding the folded prompt on
// stdin and taking stdout as the response. The command is operator-chosen
// via `ai_command`; no specific tool is assumed or named. It ignores
// Model and Cache and reports no token usage.
type cliProvider struct{ argv []string }
func (cliProvider) Name() string { return "cli" }
func (c cliProvider) Run(ctx context.Context, req Request) (Response, error) {
if len(c.argv) == 0 {
return Response{}, ErrNotConfigured
}
cmd := exec.CommandContext(ctx, c.argv[0], c.argv[1:]...)
cmd.Stdin = strings.NewReader(foldPrompt(req))
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return Response{}, fmt.Errorf("provider call failed: %s", msg)
}
return Response{Text: strings.TrimSpace(out.String())}, nil
}
// notConfigured is the clean stub used when no provider is wired.
type notConfigured struct{}
func (notConfigured) Name() string { return "none" }
func (notConfigured) Run(context.Context, Request) (Response, error) {
return Response{}, ErrNotConfigured
}
// Select returns the provider implied by config. An explicit
// `ai_provider == "cli"` selects the CLI provider when `ai_command` is
// set, else the not-configured stub. Every other value — empty/auto, the
// legacy `anthropic` (the in-binary API provider was retired), or any
// unknown string — falls back to auto: a configured `ai_command` picks the
// CLI provider, else the not-configured stub. An unrecognised value is
// always tolerated (floor invariant — never fail on this key).
func Select(cfg *config.Config) Provider {
if cfg == nil {
return notConfigured{}
}
if cfg.AIProvider == "cli" {
if p, ok := selectCLI(cfg); ok {
return p
}
return notConfigured{}
}
// Auto (and any other value, incl. legacy "anthropic"): a configured
// command wins, else unconfigured.
if p, ok := selectCLI(cfg); ok {
return p
}
return notConfigured{}
}
// selectCLI returns the CLI provider when `ai_command` is set.
func selectCLI(cfg *config.Config) (Provider, bool) {
if len(cfg.AICommand) == 0 {
return nil, false
}
return cliProvider{argv: append([]string(nil), cfg.AICommand...)}, true
}
// Outcome reports what the Gate did with a request. Exactly one of Ran
// or Skipped is true. When Skipped is true the prompt was parked and a
// queue item was appended; Reason explains why the pass did not run.
// Usage carries the provider's token accounting on Ran (zero on Skipped).
type Outcome struct {
Text string
Ran bool
Skipped bool
Parked string // path of the parked prompt, when Skipped
Reason string
Usage Usage // the provider's token accounting on Ran (zero on Skipped)
}
// ResponseScanner inspects a provider response before the Gate hands it to the
// caller. Returns nil/empty for a clean response, else one human-readable
// description per violation. Text-only + caller-agnostic so a future tool-use
// slice can reuse it on serialized tool-call arguments. The detector lives in
// internal/workflow and is injected to keep internal/ai workflow-import-free.
type ResponseScanner func(text string) []string
// Gate wraps a Provider with consent, a budget cap, and prompt-parking.
// A Gate is single-invocation: Budget is spent across all Run calls on
// the instance.
type Gate struct {
Provider Provider
// Consent is true when --ai was passed or the automation level
// implies standing consent.
Consent bool
// Budget is the maximum number of gated passes per invocation (a
// tool-using pass may make several model calls but counts as one);
// <= 0 disables AI.
Budget int
// StateDir is <workspace>/state: parked prompts, queue.md, and the
// AI-call ledger live here.
StateDir string
// Project is a short handle (repo basename) for queue items.
Project string
// Scanner, when non-nil, runs on every successful provider response before
// it is recorded or returned. A non-empty result blocks the pass.
Scanner ResponseScanner
spent int
}
// NewGate builds the Gate for one invocation from config, the --ai flag,
// and a pre-write response scanner. Consent is the flag OR an automation
// level that implies it. The scanner is a required parameter so every call
// site names the attribution filter explicitly; pass nil only where no
// filtering is wanted (test Gates keep the nil-safe zero value).
func NewGate(cfg *config.Config, aiFlag bool, scanner ResponseScanner) *Gate {
return &Gate{
Provider: Select(cfg),
Consent: aiFlag || cfg.Automation.ImpliesAIConsent(),
Budget: cfg.AIBudget,
StateDir: filepath.Join(cfg.Workspace, "state"),
Project: filepath.Base(cfg.RepoRoot),
Scanner: scanner,
}
}
// Run executes one gated pass. It never returns a hard failure for a
// missing consent, an exhausted budget, or a provider error: in every
// such case the prompt is parked, a queue item is appended, the attempt
// is recorded to the ledger, and the returned Outcome has Skipped set so
// the caller takes its non-AI path. A non-nil error means parking itself
// failed (a real I/O fault).
func (g *Gate) Run(ctx context.Context, req Request) (Outcome, error) {
if !g.Consent {
reason := "AI pass not consented (use --ai or set automation=auto)"
g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil)
return g.park(req, reason)
}
if g.Budget <= 0 || g.spent >= g.Budget {
reason := fmt.Sprintf("AI budget exhausted (cap %d)", g.Budget)
g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil)
return g.park(req, reason)
}
g.spent++
prov := g.Provider
if prov == nil {
prov = notConfigured{}
}
resp, err := prov.Run(ctx, req)
if err != nil {
reason := "provider unavailable: " + err.Error()
g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
if strings.TrimSpace(resp.Text) == "" {
reason := "provider returned no text"
g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
// Pre-write attribution filter (Slice 3): enforce eeco's no-AI-attribution
// rule on text eeco itself initiates, before it can reach the workspace and
// be copied into the tracked tree. A flagged response is blocked like any
// other non-result — recorded (the blocked response's hash IS captured, and
// its real token cost stands; the call did happen) then parked — and the
// caller falls back to its non-AI path.
if g.Scanner != nil {
if v := g.Scanner(resp.Text); len(v) > 0 {
reason := "AI response blocked: attribution violation (" + strings.Join(v, "; ") + ")"
g.recordCall(req, prov.Name(), resp.Model, resp.Text, false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
}
g.recordCall(req, prov.Name(), resp.Model, resp.Text, true, false, "", resp.Usage, nil)
return Outcome{Text: resp.Text, Ran: true, Usage: resp.Usage}, nil
}
// providerName returns the selected provider's name for ledger records on
// paths where no provider call is attempted.
func (g *Gate) providerName() string {
if g.Provider == nil {
return notConfigured{}.Name()
}
return g.Provider.Name()
}
// park writes the folded prompt under StateDir/parked/ and appends a
// queue item so the spend is visible and recoverable. The parked file
// lives inside the gitignored workspace (write-scope floor invariant).
func (g *Gate) park(req Request, reason string) (Outcome, error) {
out := Outcome{Skipped: true, Reason: reason}
if g.StateDir == "" {
// No place to park: still never a hard failure for the caller.
return out, nil
}
dir := filepath.Join(g.StateDir, "parked")
if err := os.MkdirAll(dir, 0o755); err != nil {
return out, fmt.Errorf("park prompt: %w", err)
}
ts := time.Now().UTC()
name := sanitize(req.Label) + "-" + ts.Format("20060102T150405.000000000Z") + ".md"
path := filepath.Join(dir, name)
body := fmt.Sprintf(
"parked AI prompt\n\nlabel: %s\nreason: %s\ntime: %s\n\n----- prompt -----\n%s\n",
req.Label, reason, ts.Format(time.RFC3339), foldPrompt(req))
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return out, fmt.Errorf("park prompt: %w", err)
}
out.Parked = path
rel := path
if r, err := filepath.Rel(filepath.Dir(g.StateDir), path); err == nil {
rel = r
}
qerr := queue.Append(g.StateDir, queue.Item{
Kind: "ai-parked",
Title: "AI pass parked for " + req.Label,
Project: g.Project,
Detail: reason + "\nprompt saved: " + rel,
Date: ts,
})
if qerr != nil {
return out, fmt.Errorf("park prompt: queue: %w", qerr)
}
return out, nil
}
// ProjectDigest is the deterministic, no-spend System block for the
// background project-understanding pass: the profile plus the sorted
// top-level entry names. Reading file names is not an AI spend; only a
// gated provider call is.
func ProjectDigest(cfg *config.Config) string {
var names []string
if cfg != nil {
if ents, err := os.ReadDir(cfg.RepoRoot); err == nil {
for _, e := range ents {
if e.Name() == ".git" || e.Name() == cfg.WorkspaceName {
continue
}
names = append(names, e.Name())
}
}
}
sort.Strings(names)
prof := "generic"
if cfg != nil {
prof = string(cfg.Profile)
}
return fmt.Sprintf(
"Profile: %s\nTop-level entries: %s\n",
prof, strings.Join(names, ", "))
}
// Understand runs the background project-understanding pass through the
// Gate: it is a provider call subject to the same consent, budget, and
// parking as any other (PLAN.md §AI providers). The deterministic digest
// is the cacheable System block; the instruction is the User turn.
func Understand(ctx context.Context, g *Gate, cfg *config.Config) (Outcome, error) {
req := Request{
Label: "project-understanding",
System: ProjectDigest(cfg),
User: "Summarise this project and its likely maintenance risks. Be concrete and terse.",
Cache: true,
}
return g.Run(ctx, req)
}
// sanitize keeps a label safe as a filename component.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
return r
default:
return '-'
}
}, s)
if s == "" {
return "pass"
}
return s
}