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
838 lines
25 KiB
Go
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 }
|