Files
wehub-resource-sync 26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:16 +08:00

838 lines
25 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/kunchenguid/no-mistakes/internal/types"
)
// Agent is the interface for running AI agent tasks.
type Agent interface {
Name() string
Run(ctx context.Context, opts RunOpts) (*Result, error)
Close() error
}
// RunOpts configures a single agent invocation.
type RunOpts struct {
Prompt string
CWD string
JSONSchema json.RawMessage // structured output schema (optional)
OnChunk func(text string) // streaming text callback (optional)
OnLifecycle func(LifecycleEvent) // native agent lifecycle callback (optional)
// Session, when non-nil, asks a session-capable adapter (see
// SessionResumer) to start or resume a durable native session. Adapters
// without session support ignore it and run cold; the caller detects the
// fallback via an empty Result.SessionID.
Session *SessionRef
// SessionFallback marks this invocation as the fresh-session retry after
// a failed resume. Instrumentation only; adapters ignore it.
SessionFallback bool
// Purpose labels the pipeline duty this invocation serves (review,
// review-fix, test-evidence, ...). Instrumentation only; adapters
// ignore it.
Purpose string
// SessionFallbackReason is the low-cardinality reason a failed resume forced
// this fresh-session retry (see db.FallbackReason*). Set only when
// SessionFallback is true. Instrumentation only; adapters ignore it.
SessionFallbackReason string
// Workload, when non-nil, records the bounded size of the change this
// invocation is working over (files and net lines), so review/fix telemetry
// can be normalized without external git archaeology. Instrumentation only;
// adapters ignore it.
Workload *InvocationWorkload
// OnAttempt receives each concrete adapter attempt, including retries and
// fallback-provider attempts, after it completes. It is instrumentation
// only and must not change invocation behavior.
OnAttempt func(Attempt)
}
// Attempt describes one completed concrete adapter attempt for an agent
// invocation. An Agent may make several attempts when it retries transient
// failures or moves to a fallback provider.
type Attempt struct {
Agent string
Result *Result
Err error
StartedAt time.Time
CompletedAt time.Time
Session *SessionRef
SessionFallback bool
}
// SessionRef identifies a durable adapter-native session for RunOpts.Session.
type SessionRef struct {
// ID is the adapter-native session identity to resume. Empty starts a
// new resumable session whose identity is reported via Result.SessionID.
ID string
Agent string
}
// SessionResumer is the optional adapter capability for durable native
// session resume across invocations. Decorators must forward it; callers use
// SupportsSessionResume so wrapping never hides the capability.
type SessionResumer interface {
SupportsSessionResume() bool
}
// SupportsSessionResume reports whether a (possibly wrapped) agent can start
// and resume durable native sessions.
func SupportsSessionResume(a Agent) bool {
r, ok := a.(SessionResumer)
return ok && r.SupportsSessionResume()
}
// SessionProviderMatcher reports whether an agent can resume sessions minted
// by a particular provider. Fallback wrappers implement it so callers do not
// mistake the wrapper's name for the provider that owns a session identity.
type SessionProviderMatcher interface {
SupportsSessionProvider(string) bool
}
// SupportsSessionProvider reports whether a (possibly wrapped) agent can
// resume a session minted by provider.
func SupportsSessionProvider(a Agent, provider string) bool {
if provider == "" {
return false
}
if matcher, ok := a.(SessionProviderMatcher); ok {
return matcher.SupportsSessionProvider(provider)
}
return a != nil && a.Name() == provider && SupportsSessionResume(a)
}
// AttemptReporter is the optional adapter capability for reporting every
// concrete attempt, including internal retries and fallback providers.
type AttemptReporter interface {
ReportsAgentAttempts() bool
}
// ReportsAgentAttempts reports whether a (possibly wrapped) agent emits an
// Attempt callback for each concrete adapter attempt.
func ReportsAgentAttempts(a Agent) bool {
r, ok := a.(AttemptReporter)
return ok && r.ReportsAgentAttempts()
}
// GateInstructionNeutralizer is the optional adapter capability that reports the
// adapter neutralizes the target repository's project agent-instruction files
// (AGENTS.md/CLAUDE.md) for this invocation, so they cannot install a governing
// identity on the gate agent.
//
// A gate agent runs with cmd.Dir set to the target checkout and a free shell. If
// the checkout is itself an agent-orchestration harness (for example firstmate),
// its AGENTS.md can otherwise convince the gate agent it is the fleet captain and
// drive it to spawn a crew and reset the branch it is validating (the
// ambient-authority incident). Only adapters whose suppression knob is
// empirically verified implement this and return true, and only while that knob
// is actually in effect for the invocation (an operator override that defeats the
// knob must report false so the gate fails closed rather than launching
// unneutralized).
type GateInstructionNeutralizer interface {
NeutralizesGateInstructions() bool
}
// NeutralizesGateInstructions reports whether a (possibly wrapped) agent
// neutralizes the target repo's project agent-instruction files during a gate
// run. It fails closed: an adapter that does not implement the capability, a
// nil agent, or a wrapper any member of which does not neutralize, is reported
// as NOT neutralized.
func NeutralizesGateInstructions(a Agent) bool {
if a == nil {
return false
}
n, ok := a.(GateInstructionNeutralizer)
return ok && n.NeutralizesGateInstructions()
}
// EnsureGateNeutralized fails closed when the agent that will run gate steps in
// the target checkout does not neutralize that checkout's project
// agent-instruction files. Callers must invoke it before launching any gate
// agent so an unverified harness is refused with a clear error rather than run
// unneutralized in the target checkout. Only codex and claude have a verified
// neutralization knob today.
func EnsureGateNeutralized(a Agent) error {
if a == nil {
return fmt.Errorf("no gate agent configured")
}
if NeutralizesGateInstructions(a) {
return nil
}
return fmt.Errorf("gate agent %q does not neutralize the target repository's project "+
"agent-instruction files (AGENTS.md/CLAUDE.md); refusing to launch it in the target "+
"checkout. Only codex and claude have a verified neutralization knob (and only when it "+
"is not overridden by agent_args_override); set 'agent' to codex or claude in "+
"~/.no-mistakes/config.yaml", a.Name())
}
// LifecycleEvent describes process-level activity for an agent invocation.
// The pipeline records these as step log lines and active-step heartbeats.
type LifecycleEvent struct {
Agent string
Phase string
PID int
Message string
}
// Result holds the output of an agent invocation.
type Result struct {
// Output is structured JSON returned by the agent. Text-parsed fallback
// results are validated before return, and optional fields may be
// nullable there.
Output json.RawMessage
// Text is the raw text output.
Text string
// Usage tracks token consumption for the invocation.
Usage TokenUsage
UsageReported bool
// SessionID is the adapter-native session identity of this invocation
// when the adapter reports one. Callers persist it to resume later.
SessionID string
// Resumed reports whether this invocation resumed opts.Session.ID.
Resumed bool
// Model is the model the adapter reported serving this invocation, when
// available. Instrumentation only.
Model string
// ModelProvider is the provider that served the model (e.g. "openai",
// "anthropic"), when the adapter can report it. Instrumentation only.
ModelProvider string
// Provider is the adapter provider that served this invocation. It lets
// fallback wrappers persist a session against the provider that minted it.
Provider string
// Metrics is the bounded per-invocation activity evidence the adapter
// extracted from its event stream (round-trips, tool calls + categories,
// subprocess wait time). Nil means the adapter reported nothing, which is
// recorded as unknown (NULL) rather than a fabricated zero.
Metrics *InvocationMetrics
// CacheCreationReported reports whether Usage.CacheCreationTokens is a
// meaningful value. Adapters whose provider does not surface cache-creation
// cost (codex) leave it false so the field is recorded as unknown instead of
// a fabricated zero.
CacheCreationReported bool
// SessionUsageCumulative reports that Usage accumulates across a resumed
// durable session, so round N's counters include rounds 1..N-1. The pipeline
// uses it to record correct per-round token deltas (see PerRoundTokens).
SessionUsageCumulative bool
}
// TokenUsage tracks token consumption for an agent invocation.
type TokenUsage struct {
InputTokens int
OutputTokens int
CacheReadTokens int
CacheCreationTokens int
// ReasoningTokens is the output tokens the model spent on hidden reasoning,
// when the provider reports it separately. Zero when not reported.
ReasoningTokens int
Reported bool
CacheCreationReported bool
}
// InvocationWorkload is the bounded size of the change an invocation works
// over: changed files and net changed lines. It carries no paths or content.
type InvocationWorkload struct {
Files int
Lines int
}
// Options configures backend-specific agent construction behavior.
// ACPRegistryOverrides maps acpx target names to raw ACP agent commands.
type Options struct {
ACPRegistryOverrides map[string]string
// DisableProjectSettings, when true, asks a supported adapter (codex,
// claude) to launch with the target repo's project-level agent
// settings/instructions suppressed. It is the resolved, trusted-only opt-out
// from config.Config; adapters without a verified suppression knob ignore it
// and are refused separately by EnsureGateNeutralized when the opt-out is on.
DisableProjectSettings bool
}
func finalizeTextResult(agentName, text string, schema json.RawMessage, usage TokenUsage) (*Result, error) {
if text == "" {
return nil, fmt.Errorf("%s returned no text output", agentName)
}
if len(schema) == 0 {
return &Result{Text: text, Usage: usage, UsageReported: usage.Reported, CacheCreationReported: usage.CacheCreationReported}, nil
}
output, err := parseStructuredTextOutput(text, schema)
if err != nil {
return nil, fmt.Errorf("%s output parse: %w (output snippet: %q)", agentName, err, outputSnippet(text))
}
return &Result{Output: output, Text: text, Usage: usage, UsageReported: usage.Reported, CacheCreationReported: usage.CacheCreationReported}, nil
}
// outputSnippet returns a trimmed, length-capped excerpt of agent output for
// inclusion in parse-failure errors. Without it, errors like "invalid
// character 'N'" are undiagnosable without separately capturing agent stdout.
func outputSnippet(text string) string {
const max = 200
trimmed := strings.TrimSpace(text)
runes := []rune(trimmed)
if len(runes) > max {
return string(runes[:max]) + "…"
}
return trimmed
}
func parseStructuredTextOutput(text string, schema json.RawMessage) (json.RawMessage, error) {
validationSchema, err := textValidationSchema(schema)
if err != nil {
return nil, err
}
output, rawErr := parseStructuredCandidate([]byte(text), validationSchema)
if rawErr == nil {
return output, nil
}
candidates := fencedJSONCandidates(text)
var parsed []json.RawMessage
var candidateErr error
for _, candidate := range candidates {
fenced, err := parseStructuredCandidate([]byte(candidate), validationSchema)
if err == nil {
parsed = append(parsed, fenced)
continue
}
if candidateErr == nil {
candidateErr = err
}
}
switch len(parsed) {
case 0:
case 1:
return parsed[0], nil
default:
return nil, fmt.Errorf("multiple JSON code fences found in output")
}
if bare, err := lastBareJSONObject(text, validationSchema); err == nil && bare != nil {
return bare, nil
} else if candidateErr == nil && err != nil {
candidateErr = err
}
if candidateErr != nil {
return nil, candidateErr
}
return nil, rawErr
}
func textValidationSchema(schema json.RawMessage) (json.RawMessage, error) {
if len(schema) == 0 {
return nil, nil
}
var value any
if err := json.Unmarshal(schema, &value); err != nil {
return nil, err
}
allowOptionalSchemaNulls(value)
return json.Marshal(value)
}
// fencedJSONCandidates extracts JSON bodies from ```json ... ``` fences.
// Fence markers may appear anywhere in the text, including glued to the end
// of a preceding line (e.g. "...behavior.```json"), which is a shape real
// codex/GPT-5 output regularly produces.
func fencedJSONCandidates(text string) []string {
var candidates []string
rest := text
for {
start := indexJSONFenceOpen(rest)
if start < 0 {
return candidates
}
body := rest[start:]
end, next := indexJSONFenceClose(body)
if end < 0 {
return candidates
}
candidates = append(candidates, body[:end])
rest = body[next:]
}
}
// indexJSONFenceOpen returns the byte offset of the content immediately
// following an opening ```json fence (the char after the info line's
// newline), or -1 if no opener exists.
func indexJSONFenceOpen(text string) int {
for searchStart := 0; searchStart < len(text); {
i := strings.Index(text[searchStart:], "```")
if i < 0 {
return -1
}
i += searchStart
contentStart, info := fenceContentStart(text, i)
if strings.EqualFold(strings.TrimSpace(info), "json") {
return contentStart
}
next := skipFenceBlock(text[contentStart:])
if next < 0 {
return -1
}
searchStart = contentStart + next
}
return -1
}
func fenceContentStart(text string, fenceStart int) (int, string) {
after := text[fenceStart+3:]
lineEnd := strings.IndexByte(after, '\n')
if lineEnd < 0 {
return fenceStart + 3 + len(after), after
}
return fenceStart + 3 + lineEnd + 1, after[:lineEnd]
}
func skipFenceBlock(text string) int {
depth := 1
for lineStart := 0; lineStart < len(text); {
lineEnd := strings.IndexByte(text[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(text)
} else {
lineEnd += lineStart
}
line := text[lineStart:lineEnd]
trimmed := strings.TrimLeft(line, " \t")
if strings.HasPrefix(trimmed, "```") {
if strings.TrimSpace(trimmed[3:]) == "" {
depth--
if depth == 0 {
return lineStart + (len(line) - len(trimmed)) + 3
}
} else {
depth++
}
}
if lineEnd == len(text) {
break
}
lineStart = lineEnd + 1
}
return -1
}
func indexJSONFenceClose(text string) (int, int) {
for lineStart := 0; lineStart < len(text); {
lineEnd := strings.IndexByte(text[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(text)
} else {
lineEnd += lineStart
}
line := text[lineStart:lineEnd]
trimmed := strings.TrimLeft(line, " \t")
if strings.HasPrefix(trimmed, "```") {
indent := len(line) - len(trimmed)
return lineStart, lineStart + indent + 3
}
if lineEnd == len(text) {
break
}
lineStart = lineEnd + 1
}
trimmed := strings.TrimRight(text, " \t\r\n")
if strings.HasSuffix(trimmed, "```") {
return len(trimmed) - 3, len(trimmed)
}
return -1, -1
}
// lastBareJSONObject scans text for balanced {...} substrings that parse
// as JSON and returns the last one found. This handles models that emit
// reasoning prose followed by a raw JSON answer, with no code fence.
func lastBareJSONObject(text string, schema json.RawMessage) (json.RawMessage, error) {
var last json.RawMessage
var lastErr error
for i := 0; i < len(text); i++ {
if strings.HasPrefix(text[i:], "```") {
contentStart, _ := fenceContentStart(text, i)
next := skipFenceBlock(text[contentStart:])
if next < 0 {
break
}
i = contentStart + next - 1
continue
}
if text[i] != '{' {
continue
}
end, ok := scanBalancedObject(text, i)
if !ok {
continue
}
candidate := text[i:end]
obj, err := parseStructuredCandidate([]byte(candidate), schema)
if err == nil {
last = obj
lastErr = nil
} else if lastErr == nil {
lastErr = err
}
i = end - 1
}
if last != nil {
return last, nil
}
return nil, lastErr
}
// scanBalancedObject returns the exclusive end index of a brace-balanced
// substring starting at text[start] == '{', or (0, false) if no balanced
// closing brace exists. It respects JSON string literals so braces inside
// strings do not affect the depth count.
func scanBalancedObject(text string, start int) (int, bool) {
depth := 0
inString := false
escape := false
for i := start; i < len(text); i++ {
c := text[i]
if inString {
if escape {
escape = false
continue
}
switch c {
case '\\':
escape = true
case '"':
inString = false
}
continue
}
switch c {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i + 1, true
}
}
}
return 0, false
}
func parseStructuredCandidate(candidate, schema []byte) (json.RawMessage, error) {
var output json.RawMessage
if err := json.Unmarshal(candidate, &output); err != nil {
return nil, err
}
if err := validateStructuredOutput(output, schema); err != nil {
return nil, err
}
return output, nil
}
func validateStructuredOutput(output, schema json.RawMessage) error {
if len(schema) == 0 {
return nil
}
var parsedSchema any
if err := json.Unmarshal(schema, &parsedSchema); err != nil {
return err
}
value, err := decodeJSONValue(output)
if err != nil {
return err
}
if err := validateJSONValue(value, parsedSchema, ""); err != nil {
return fmt.Errorf("JSON output %w", err)
}
return nil
}
func allowOptionalSchemaNulls(value any) {
schema, ok := value.(map[string]any)
if !ok {
return
}
required := requiredSet(schema)
if properties, ok := schema["properties"].(map[string]any); ok {
for name, property := range properties {
allowOptionalSchemaNulls(property)
if !required[name] {
allowSchemaNull(property)
}
}
}
if items, ok := schema["items"]; ok {
allowOptionalSchemaNulls(items)
}
}
func decodeJSONValue(raw []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var value any
if err := dec.Decode(&value); err != nil {
return nil, err
}
if err := dec.Decode(&struct{}{}); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return value, nil
}
func validateJSONValue(value, schema any, path string) error {
schemaMap, ok := schema.(map[string]any)
if !ok {
return nil
}
if enum, ok := schemaMap["enum"].([]any); ok && !matchesEnum(value, enum) {
return fmt.Errorf("%smust match one of the allowed values", formatJSONPath(path))
}
if types, ok := schemaTypes(schemaMap); ok && !matchesAnyType(value, types) {
return fmt.Errorf("%smust be %s", formatJSONPath(path), strings.Join(types, " or "))
}
if object, ok := value.(map[string]any); ok {
if err := validateJSONObject(object, schemaMap, path); err != nil {
return err
}
}
if array, ok := value.([]any); ok {
if err := validateJSONArray(array, schemaMap, path); err != nil {
return err
}
}
return nil
}
func validateJSONObject(object map[string]any, schema map[string]any, path string) error {
required := stringSlice(schema["required"])
for _, key := range required {
if _, ok := object[key]; !ok {
return fmt.Errorf("%smissing required field %q", formatJSONPath(path), key)
}
}
properties, _ := schema["properties"].(map[string]any)
if additional, ok := schema["additionalProperties"].(bool); ok && !additional {
for key := range object {
if _, ok := properties[key]; !ok {
return fmt.Errorf("%scontains unknown field %q", formatJSONPath(path), key)
}
}
}
for key, propSchema := range properties {
child, ok := object[key]
if !ok {
continue
}
if err := validateJSONValue(child, propSchema, joinJSONPath(path, key)); err != nil {
return err
}
}
return nil
}
func validateJSONArray(array []any, schema map[string]any, path string) error {
itemsSchema, ok := schema["items"]
if !ok {
return nil
}
for i, item := range array {
if err := validateJSONValue(item, itemsSchema, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
return nil
}
func schemaTypes(schema map[string]any) ([]string, bool) {
switch raw := schema["type"].(type) {
case string:
return []string{raw}, true
case []any:
types := stringSlice(raw)
return types, len(types) > 0
default:
return nil, false
}
}
func stringSlice(raw any) []string {
items, ok := raw.([]any)
if !ok {
if single, ok := raw.([]string); ok {
return single
}
return nil
}
out := make([]string, 0, len(items))
for _, item := range items {
str, ok := item.(string)
if !ok {
continue
}
out = append(out, str)
}
return out
}
func matchesAnyType(value any, types []string) bool {
for _, typ := range types {
if matchesType(value, typ) {
return true
}
}
return false
}
func matchesType(value any, typ string) bool {
switch typ {
case "object":
_, ok := value.(map[string]any)
return ok
case "array":
_, ok := value.([]any)
return ok
case "string":
_, ok := value.(string)
return ok
case "integer":
number, ok := value.(json.Number)
return ok && isJSONInteger(number)
case "number":
_, ok := value.(json.Number)
return ok
case "boolean":
_, ok := value.(bool)
return ok
case "null":
return value == nil
default:
return true
}
}
func isJSONInteger(number json.Number) bool {
_, err := number.Int64()
return err == nil
}
func matchesEnum(value any, allowed []any) bool {
valueJSON, err := json.Marshal(value)
if err != nil {
return false
}
for _, candidate := range allowed {
candidateJSON, err := json.Marshal(candidate)
if err != nil {
continue
}
if bytes.Equal(valueJSON, candidateJSON) {
return true
}
}
return false
}
func joinJSONPath(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}
func formatJSONPath(path string) string {
if path == "" {
return ""
}
return path + " "
}
// Total returns input + output tokens (the billing-relevant total).
func (u TokenUsage) Total() int {
return u.InputTokens + u.OutputTokens
}
// Add accumulates another usage into this one.
func (u *TokenUsage) Add(other TokenUsage) {
u.InputTokens += other.InputTokens
u.OutputTokens += other.OutputTokens
u.CacheReadTokens += other.CacheReadTokens
u.CacheCreationTokens += other.CacheCreationTokens
u.ReasoningTokens += other.ReasoningTokens
u.Reported = u.Reported || other.Reported
u.CacheCreationReported = u.CacheCreationReported || other.CacheCreationReported
}
// New creates an agent by name with the given binary path.
// For native agents, extraArgs are user CLI flags from agent_args_override that
// are injected into the underlying tool's argv ahead of no-mistakes' managed flags.
// ACP agents ignore extraArgs; use NewWithOptions to provide registry overrides.
func New(name types.AgentName, bin string, extraArgs []string) (Agent, error) {
return NewWithOptions(name, bin, extraArgs, Options{})
}
// NewWithOptions creates an agent by name with additional backend-specific options.
func NewWithOptions(name types.AgentName, bin string, extraArgs []string, opts Options) (Agent, error) {
if target, ok := acpTarget(name); ok {
rawCommand := ""
if opts.ACPRegistryOverrides != nil {
rawCommand = opts.ACPRegistryOverrides[target]
}
return &acpxAgent{bin: bin, target: target, rawCommand: rawCommand}, nil
}
switch name {
case types.AgentClaude:
return &claudeAgent{bin: bin, extraArgs: extraArgs, disableProjectSettings: opts.DisableProjectSettings}, nil
case types.AgentCodex:
return &codexAgent{bin: bin, extraArgs: extraArgs, disableProjectSettings: opts.DisableProjectSettings}, nil
case types.AgentRovoDev:
return &rovodevAgent{bin: bin, extraArgs: extraArgs}, nil
case types.AgentOpenCode:
return &opencodeAgent{bin: bin, extraArgs: extraArgs}, nil
case types.AgentPi:
return &piAgent{bin: bin, extraArgs: extraArgs}, nil
case types.AgentCopilot:
return &copilotAgent{bin: bin, extraArgs: extraArgs}, nil
default:
return nil, fmt.Errorf("unknown agent %q; valid options: auto, claude, codex, rovodev, opencode, pi, copilot, acp:<target> (set 'agent' in ~/.no-mistakes/config.yaml)", name)
}
}
func acpTarget(name types.AgentName) (string, bool) {
value := string(name)
if !strings.HasPrefix(value, "acp:") {
return "", false
}
target := strings.TrimPrefix(value, "acp:")
if target == "" || strings.ContainsAny(target, " \t\r\n") {
return "", false
}
return target, true
}
// NewNoop returns an agent that does nothing. Used for demo mode where
// mock steps handle all logic without calling a real agent.
func NewNoop() Agent { return &noopAgent{} }
type noopAgent struct{}
func (n *noopAgent) Name() string { return "noop" }
func (n *noopAgent) Run(_ context.Context, _ RunOpts) (*Result, error) { return &Result{}, nil }
func (n *noopAgent) Close() error { return nil }