Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ Features adopted from open-source agent projects. All are off by default unless
| Live agent status | `GET /v1/agent/status` (daemon) | Machine-readable working/idle/stale per session |
| X/Twitter search | `SearchX` tool | Live X search by forwarding a query to an xAI endpoint with server-side search; returns a cited summary. Requires `XAI_API_KEY` (or `GROK_API_KEY`) |
| Desktop computer-use | `ComputerUse` tool | snapshot/click/type/scroll/press/screenshot via a pluggable `tool.SetComputerBackend` seam (host wires a native macOS accessibility backend) |
| Token-cheaper file views | `Read` tool `--minify` | Read-only, comment-stripped, whitespace-dense file view (Go via `go/parser`; other languages string-aware; never touches disk) — fewer tokens per read |
| Classified provider hints | `internal/errhint` | Buckets provider errors (Auth/RateLimit/Connectivity/ModelNotFound/ContextOverflow) into a one-line fixable next step |
| Atomic install transactions | `internal/installtxn` | Cross-process staged install/remove with rollback (plugin/skill install paths) |
| Stale-lock reclaim | `internal/lockutil` | Race-correct atomic reclaim of O_EXCL lock files with live-restore |
| Test command discovery | `internal/testrunner` | Auto-detect test/verify commands (Go/npm/bun/pnpm/yarn/pytest/cargo) and parse runner output into structured results |

## Usage

Expand Down
160 changes: 160 additions & 0 deletions internal/errhint/errhint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Package errhint classifies provider/model failures into a few user-actionable
// categories and turns them into a one-line "next step" hint.
//
// Provider errors already arrive with a classified string prefix from the
// provider layer ("auth error:", "rate limit error:", …); lower-level failures
// (DNS, TLS, timeouts, context-length) arrive as raw driver or library messages.
// Classify matches both so an interactive error row and a headless exec
// provider-error path can append one concrete next step instead of dumping an
// identical red blob for every failure mode.
//
// Adopted from Zero (internal/errhint) — classified, fixable provider error hints.
package errhint

import "strings"

// Category buckets a provider/model failure into a small set of classes that each
// map to a distinct recovery action.
type Category int

const (
// Unknown means the error didn't match any known signature; callers should
// emit no hint rather than guess.
Unknown Category = iota
Auth
RateLimit
Connectivity
ModelNotFound
ContextOverflow
)

// providerMarkers are the prefixes the provider layer attaches to every
// provider-originated failure. A UI surface's error can also be a *local* failure
// (a tool's "permission denied", a "file does not exist", a config error), so
// Classify only proceeds past this gate for messages that are recognizably from
// the provider — otherwise a broad substring like "does not exist" would attach a
// bogus model hint to an unrelated local error.
var providerMarkers = []string{
"auth error:",
"rate limit error:",
"provider error:",
"provider request error:",
"provider stream error:",
}

// Classify buckets err by scanning its message for known signatures. It is a
// deliberately conservative string heuristic. It first gates on a provider-origin
// marker (see providerMarkers) so local failures never draw a provider hint, then
// sub-classifies. Order matters: more specific signatures are tested before
// broader ones (e.g. "context length" as overflow before the generic "timeout" as
// connectivity).
func Classify(err error) Category {
if err == nil {
return Unknown
}
m := strings.ToLower(err.Error())
if !containsAny(m, providerMarkers...) {
return Unknown
}
switch {
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
return Auth
case containsAny(m, "rate limit", "rate_limit", "too many requests", "quota",
"resource_exhausted", "overloaded") || containsStatusCode(m, "429", "529"):
return RateLimit
case containsAny(m, "context length", "context window", "maximum context", "context_length_exceeded",
"too many tokens", "prompt is too long", "reduce the length", "maximum context length"):
return ContextOverflow
case containsAny(m, "model not found", "model_not_found", "does not exist", "unknown model",
"no such model", "unsupported model", "invalid model", "model is not"):
return ModelNotFound
case containsAny(m, "dial tcp", "no such host", "connection refused", "network is unreachable",
"i/o timeout", "context deadline exceeded", "tls handshake", "connection reset",
"unexpected eof", "lookup ", "timeout"):
return Connectivity
default:
return Unknown
}
}

// TUIHint returns a one-line hint referencing interactive slash commands, or ""
// when the category is Unknown. Meant to sit under the raw error in the live
// error row.
func TUIHint(err error) string {
switch Classify(err) {
case Auth:
return "API key rejected — run /provider to re-check your credentials"
case RateLimit:
return "Rate limited — wait a moment, or switch model with /model"
case Connectivity:
return "Can't reach the provider — run /doctor --connectivity"
case ModelNotFound:
return "Model unavailable — pick another with /model"
case ContextOverflow:
return "Context window full — run /compact to free space"
default:
return ""
}
}

// CLIHint returns a one-line hint referencing CLI subcommands, or "" when the
// category is Unknown. Meant for the non-interactive exec error path, where slash
// commands don't apply.
func CLIHint(err error) string {
switch Classify(err) {
case Auth:
return "API key rejected — set the provider's API key or re-run provider setup"
case RateLimit:
return "Rate limited — wait a moment, or switch model with --model"
case Connectivity:
return "Can't reach the provider — run `hawk doctor`"
case ModelNotFound:
return "Model unavailable — run `hawk doctor` or pick another with --model"
case ContextOverflow:
return "Context window full — shorten the prompt or start a fresh session"
default:
return ""
}
}

func containsAny(haystack string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(haystack, n) {
return true
}
}
return false
}

func containsStatusCode(haystack string, codes ...string) bool {
return HasStatusCode(haystack, codes...)
}

// HasStatusCode reports whether haystack contains any of the given HTTP status
// codes as a standalone number — not embedded in a longer digit run like
// "completed in 4290ms" or "request id 14015". Exported so other packages can
// gate on a status code without re-implementing the digit-boundary check.
func HasStatusCode(haystack string, codes ...string) bool {
for _, code := range codes {
for from := 0; ; {
rel := strings.Index(haystack[from:], code)
if rel < 0 {
break
}
pos := from + rel
beforeOK := pos == 0 || !isASCIIDigit(haystack[pos-1])
end := pos + len(code)
afterOK := end >= len(haystack) || !isASCIIDigit(haystack[end])
if beforeOK && afterOK {
return true
}
from = pos + 1
}
}
return false
}

func isASCIIDigit(b byte) bool {
return b >= '0' && b <= '9'
}
79 changes: 79 additions & 0 deletions internal/errhint/errhint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package errhint

import (
"errors"
"testing"
)

func TestClassifyGatesOnProviderMarker(t *testing.T) {
// A local error must not draw a provider hint even if it contains a keyword.
if got := Classify(errors.New("permission denied")); got != Unknown {
t.Fatalf("local permission denied classified as %v, want Unknown", got)
}
if got := Classify(errors.New("provider error: 401 unauthorized")); got != Auth {
t.Fatalf("classified = %v, want Auth", got)
}
}

func TestClassifyCategories(t *testing.T) {
cases := []struct {
msg string
want Category
}{
{"provider error: invalid_api_key", Auth},
{"auth error: 403 forbidden", Auth},
{"rate limit error: too many requests", RateLimit},
{"provider request error: 429", RateLimit},
{"provider error: 529", RateLimit},
{"provider error: context length exceeded", ContextOverflow},
{"provider error: prompt is too long", ContextOverflow},
{"provider error: model not found", ModelNotFound},
{"provider error: unsupported model", ModelNotFound},
{"provider stream error: dial tcp 10.0.0.1:443: i/o timeout", Connectivity},
{"provider error: connection refused", Connectivity},
{"some unrelated thing", Unknown},
{"", Unknown},
}
for _, tc := range cases {
got := Classify(errors.New(tc.msg))
if got != tc.want {
t.Errorf("Classify(%q) = %v, want %v", tc.msg, got, tc.want)
}
}
}

func TestClassifyNil(t *testing.T) {
if got := Classify(nil); got != Unknown {
t.Fatalf("Classify(nil) = %v, want Unknown", got)
}
}

func TestHints(t *testing.T) {
if TUIHint(errors.New("provider error: invalid api key")) == "" {
t.Fatal("expected a TUI hint for auth")
}
if CLIHint(errors.New("provider error: invalid api key")) == "" {
t.Fatal("expected a CLI hint for auth")
}
if TUIHint(errors.New("local file error")) != "" {
t.Fatal("expected no hint for local error")
}
if CLIHint(errors.New("local file error")) != "" {
t.Fatal("expected no hint for local error")
}
}

func TestHasStatusCode(t *testing.T) {
if !HasStatusCode("provider error: 401", "401") {
t.Fatal("expected standalone 401 to match")
}
if HasStatusCode("completed in 4290ms", "429") {
t.Fatal("429 embedded in 4290 must not match")
}
if HasStatusCode("request id 14015", "401") {
t.Fatal("401 embedded in 14015 must not match")
}
if !HasStatusCode("provider: 429 too many", "429") {
t.Fatal("expected standalone 429 to match")
}
}
Loading
Loading