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
11 changes: 11 additions & 0 deletions .changeset/evlog-error-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@github-tools/sdk": minor
"@github-tools/eve-extension": minor
---

Structured error catalog (evlog): classifiable failures now carry a stable `code`, a `why` (technical cause), and a `fix` (actionable remedy) instead of a bare message.

- GitHub API errors are mapped by status: `UNAUTHORIZED` (401), `FORBIDDEN` (403), `RATE_LIMITED` (403/429, with reset info), `NOT_FOUND` (404 — explicitly states GitHub masks no-access private resources as 404), `VALIDATION_FAILED` (422). Unmapped statuses pass through; the original Octokit error stays as `cause`.
- An expired `VERCEL_OIDC_TOKEN` now throws `OIDC_TOKEN_EXPIRED` with the exact expiry time before any Connect request, instead of surfacing as an opaque 403. `@vercel/connect` failures map to `CONNECT_NOT_AUTHORIZED`, `CONNECT_USER_NOT_CONNECTED`, and `CONNECT_INSTALLATION_REQUIRED`.
- In the eve extension, failing tools return `{ error: { code, message, why, fix, link } }` to the model; unclassified failures keep the plain message string.
- The catalog is exported as `githubToolsErrors`; use evlog's `parseError` to read the structure from thrown errors in AI SDK apps.
2 changes: 2 additions & 0 deletions apps/docs/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ export default defineAppConfig({
{ label: 'Commit Attribution', to: '/guide/commit-attribution' },
{ label: 'Tokens & Auth', to: '/guide/tokens-and-auth' },
{ label: 'Vercel Connect', to: '/guide/vercel-connect' },
{ label: 'Working Context', to: '/guide/working-context' },
{ label: 'Errors', to: '/guide/errors' },
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/2.frameworks/1.eve-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ The extension registers each tool with an **authored inline** `execute`, `toMode

Object-shaped execute results include `rateLimit` (`remaining`, `limit`, `reset`, `resource`). `toModelOutput` strips it so the model never sees the remaining count; `toolResultFrom` and channels still do. See [Rate-limit metadata](/api/reference#rate-limit-metadata).

If execute fails (token mint, GitHub 403/429, …), the tool returns `{ error }` instead of throwing so the model always receives a `tool_result`. A thrown error in eve's tool-loop can leave a `tool_use` unpaired and kill the turn.
If execute fails (token mint, GitHub 403/429, …), the tool returns `{ error }` instead of throwing so the model always receives a `tool_result`. A thrown error in eve's tool-loop can leave a `tool_use` unpaired and kill the turn. For failures the SDK can classify, `error` is a structured `{ code, message, why, fix, link }` object from the [error catalog](/guide/errors) — e.g. a 404 explains that GitHub also masks no-access private repos as 404, so the model does not hallucinate a cause. Unclassified failures keep the plain message string.

## Durable approval, done right

Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/4.guide/5.vercel-connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export default connectGithubTools('github/my-connector', {

When `VERCEL_OIDC_TOKEN` is set (`vercel env pull` for local eve/workflow), `connectGithubToken` passes it to `getToken` as `vercelToken`. That uses the pulled token instead of `@vercel/oidc` walking the filesystem for `.vercel` — which fails inside workflow snapshots that have no project root.

Pinning disables refresh, so the SDK checks the token's `exp` claim before every mint and throws [`OIDC_TOKEN_EXPIRED`](/guide/errors) with the exact expiry time when it is stale — instead of an opaque Connect 403 the model would misread as missing GitHub permissions. Run `vercel env pull` again to refresh it.

There is no dedicated starter for this path; new agents should use the [eve extension](#eve-extension) above.

## Token provider only
Expand Down
94 changes: 94 additions & 0 deletions apps/docs/content/docs/4.guide/7.errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
title: Errors
description: Structured, agent-readable errors — every failure carries a stable code, the technical cause, and an actionable fix.
navigation:
title: Errors
path: /guide/errors
links:
- label: Tokens & Auth
icon: i-lucide-key-round
to: /guide/tokens-and-auth
color: neutral
variant: subtle
- label: Vercel Connect
icon: i-lucide-unplug
to: /guide/vercel-connect
color: neutral
variant: subtle
- label: API reference
icon: i-lucide-braces
to: /api/reference
color: neutral
variant: subtle
---

Raw API failures make models hallucinate. GitHub answers 404 for private repositories the token cannot see, and an expired Vercel OIDC token surfaces as a bare "Not authorized" — both read as "the repo is private" or "permissions are missing" to a model, which then confidently reports the wrong cause.

The SDK maps every failure it can classify to a structured error from an [evlog](https://evlog.dev) catalog. Each error carries:

- `code` — stable, machine-readable identifier (`github_tools.NOT_FOUND`)
- `message` — what happened, with the GitHub or Connect detail embedded
- `why` — the technical cause, including the non-obvious ones
- `fix` — the actionable remedy
- `link` — the relevant documentation page (this site for auth/Connect setup, GitHub's REST troubleshooting docs for API errors)

## Error codes

### Authentication and Vercel Connect

| Code | When |
| --- | --- |
| `TOKEN_REQUIRED` | No token string, provider, or `GITHUB_TOKEN` env var was available |
| `OIDC_TOKEN_EXPIRED` | `VERCEL_OIDC_TOKEN` is past its `exp` claim — thrown before any request is made, with the exact expiry time. Run `vercel env pull` locally |
| `CONNECT_NOT_AUTHORIZED` | Connect rejected the calling process's identity (403). The request never reached GitHub — this is not a GitHub permission problem |
| `CONNECT_USER_NOT_CONNECTED` | A `{ type: 'user' }` subject was requested but that user has no active GitHub connection |
| `CONNECT_INSTALLATION_REQUIRED` | The connector's GitHub App is not installed on the target account |
| `SUBJECT_CONTEXT_REQUIRED` | A `connect.subject` resolver ran outside a tool execution (no eve context) |

### GitHub API

| Code | Status | When |
| --- | --- | --- |
| `UNAUTHORIZED` | 401 | Token invalid, expired, or revoked |
| `FORBIDDEN` | 403 | Missing scope, SAML enforcement, or a user-token-only API (gists, notifications) called with an installation token |
| `RATE_LIMITED` | 403/429 | Rate limit exhausted — the message includes remaining/limit and the reset timestamp |
| `NOT_FOUND` | 404 | The resource does not exist **or the token cannot see it** — GitHub returns 404 instead of 403 for private resources |
| `VALIDATION_FAILED` | 422 | Well-formed input that GitHub refused, with GitHub's message embedded |

Statuses without a catalog entry (5xx, redirects) pass through unchanged. The original Octokit `RequestError` stays reachable as `cause`; request coordinates live in `internal` and are never serialized toward the model.

## What the model sees

In the [eve extension](/frameworks/eve-extension), a failing tool returns the structure instead of a flat string:

```json
{
"error": {
"code": "github_tools.NOT_FOUND",
"message": "GitHub resource not found (404): Not Found",
"why": "Either the resource does not exist, or the token cannot see it — GitHub deliberately returns 404 instead of 403 for private resources the token has no access to.",
"fix": "Check the owner/repo/number input first. If it is correct, the token lacks access: grant the repository to the PAT or App installation, or use a Connect subject that has access.",
"link": "https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api#404-not-found-for-an-existing-resource"
}
}
```

`internal` stays off the wire (request URL, raw status). `link` is included so the model can fetch the docs page when `why`/`fix` are not enough.

## AI SDK consumers

With `generateText`/`streamText`, tool execution errors are thrown and the framework forwards `error.message` to the model. The catalog messages are written to be self-sufficient for that path. If you need the full structure (error boundaries, logging, custom `onError`), use evlog's `parseError`:

```ts
import { parseError } from 'evlog'

try {
await generateText({ model, tools, prompt })
}
catch (error) {
const { code, message, why, fix } = parseError(error)
// code autocompletes against the github_tools catalog
}
```

The catalog itself is exported as `githubToolsErrors` from `@github-tools/sdk` for consumers who want to match on specific factories or reuse the codes.
12 changes: 12 additions & 0 deletions apps/docs/content/docs/5.api/2.reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,18 @@ const octokit = createOctokit(await resolveGithubToken())
const { data } = await octokit.repos.get({ owner: 'HugoRCD', repo: 'github-tools' })
```

## `githubToolsErrors`

The [evlog](https://evlog.dev) error catalog behind every classifiable SDK failure (`github_tools.*` codes with `why`/`fix` fields). Exported from `@github-tools/sdk` and `@github-tools/sdk/eve-runtime` for consumers who want to match on specific codes or throw catalog errors from custom tools. See the [errors guide](/guide/errors) for the full code list and the model-facing shape.

```ts [error-handling.ts]
import { githubToolsErrors } from '@github-tools/sdk'
import { parseError } from 'evlog'

const { code, why, fix } = parseError(error)
if (code === 'github_tools.RATE_LIMITED') { /* back off */ }
```

## External references

- [AI SDK tool definitions](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/skills/github-tools-agents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ Array presets merge: `preset: ['code-review', 'issue-triage']`. Start with the s

Pass `context: { owner, repo, pullNumber?, issueNumber?, ref? }` to `createGithubTools` / `createGithubAgent` / `createDurableGithubAgent` to default those fields on tool inputs and inject them into the agent system prompt. Prefer composite tools (`getPullRequestContext`, `getIssueContext`, `getReleaseContext`, `getCiFailureContext`) for multi-part reads — call follow-up reads in the same step when possible. Diff patches are omitted by default — set `includePatch: true` (optionally with `filenames`) when you need specific diffs. Bodies are truncated by default (`detail: 'summary'`). `getIssueContext` returns `labelNames` (strings) rather than full label objects. Prefer `getFileContent` with `startLine`/`endLine` or `maxLines` for large files. `getWorkflowJobLogs` returns the last 200 log lines with timestamps stripped — raise `maxLines` (up to 2000) only when needed. `listPullRequestReviewThreads` returns unresolved threads only by default with truncated comment bodies. REST list tools return `{ items, hasMore, page, nextPage }` (or add those fields next to `checkRuns` / `runs`); when `hasMore`, call with `nextPage` or raise `maxPages` — never the same page. Filter `listCommits` with `path` / `author` / `since` / `until`. Prefer a `path` prefix on `getRepositoryTree` over `recursive: true`. Object-shaped execute results include `rateLimit` (`remaining` / `limit` / `reset` / `resource`); it is stripped from the model-facing output. On 403/429 the error text includes remaining/reset.

Classifiable failures are structured evlog catalog errors (`githubToolsErrors`, codes prefixed `github_tools.`) with `why`/`fix` fields: 401 `UNAUTHORIZED`, 403 `FORBIDDEN`, 403/429 `RATE_LIMITED`, 404 `NOT_FOUND` (also thrown when the token cannot see a private resource — GitHub masks no-access as 404), 422 `VALIDATION_FAILED`, plus auth/Connect codes (`TOKEN_REQUIRED`, `OIDC_TOKEN_EXPIRED`, `CONNECT_USER_NOT_CONNECTED`, …). In eve, tool failures return `{ error: { code, message, why, fix, link } }`; with `generateText`/`streamText` the message alone is forwarded and is self-sufficient — use evlog's `parseError` for the full structure.

## Write safety

- Default: writes go through **approval** (AI SDK tool approval flow) unless `requireApproval: false` or per-tool overrides.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default githubExtension({
})
```

`execute`, `toModelOutput`, and `approval` are direct `defineTool` properties whose callbacks only close over the tool name (a spread or `resolveEveApproval(...)` call is not stamped). `toModelOutput` also strips `rateLimit` from the model-facing payload. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+, and the resolver then drops every `github__*` tool. Execute failures return `{ error }` so the model still receives a `tool_result`. Requires `eve` `>=0.44`.
`execute`, `toModelOutput`, and `approval` are direct `defineTool` properties whose callbacks only close over the tool name (a spread or `resolveEveApproval(...)` call is not stamped). `toModelOutput` also strips `rateLimit` from the model-facing payload. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+, and the resolver then drops every `github__*` tool. Execute failures return `{ error }` so the model still receives a `tool_result` — a structured `{ code, message, why, fix, link }` object for catalog errors (e.g. `github_tools.NOT_FOUND` explains GitHub masks no-access private repos as 404), a plain string otherwise. Requires `eve` `>=0.44`.

## Approval

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { connectGithubToken } from '@github-tools/sdk/connect'
import {
executeGithubEveTool,
formatGithubEveToolOutput,
githubToolsErrors,
GITHUB_WRITE_TOOLS,
isEveApprovalDisabled,
listEveToolDescriptors,
Expand Down Expand Up @@ -97,7 +98,7 @@ function writeToolName(name: GithubToolName): GithubWriteToolName | undefined {

function requireToolContext(ctx: ToolContext | undefined): ToolContext {
if (!ctx) {
throw new Error('connect.subject resolver needs the tool execution context — it is only available while a tool call executes')
throw githubToolsErrors.SUBJECT_CONTEXT_REQUIRED()
}
return ctx
}
Expand Down
8 changes: 8 additions & 0 deletions packages/github-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ result.rateLimit?.remaining

`resource` is `core`, `search`, or `graphql`. On HTTP 403/429 the thrown error message also includes remaining/reset.

## Errors

Classifiable failures become structured [evlog](https://evlog.dev) catalog errors with a stable `code`, a `why` (technical cause), and a `fix` (actionable remedy) — written so a model recovers instead of hallucinating. The two causes models get wrong most often are spelled out: GitHub answers 404 for private resources the token cannot see (`NOT_FOUND`), and an expired `VERCEL_OIDC_TOKEN` is caught before any request with the exact expiry time (`OIDC_TOKEN_EXPIRED`) instead of surfacing as an opaque Connect 403.

Codes: `TOKEN_REQUIRED`, `OIDC_TOKEN_EXPIRED`, `CONNECT_NOT_AUTHORIZED`, `CONNECT_USER_NOT_CONNECTED`, `CONNECT_INSTALLATION_REQUIRED`, `SUBJECT_CONTEXT_REQUIRED`, `UNAUTHORIZED` (401), `FORBIDDEN` (403), `RATE_LIMITED` (403/429), `NOT_FOUND` (404), `VALIDATION_FAILED` (422). Unmapped statuses pass through unchanged; the original Octokit error stays reachable as `cause`.

In the eve extension, a failing tool returns `{ error: { code, message, why, fix, link } }` to the model. With `generateText`/`streamText`, the framework forwards `error.message` (self-sufficient by design); use evlog's `parseError(error)` when you need the full structure. The catalog is exported as `githubToolsErrors`. See the [errors guide](https://github-tools.com/guide/errors).

## Commit Attribution

Control how commits are attributed when using `createOrUpdateFile` or `mergePullRequest`:
Expand Down
1 change: 1 addition & 0 deletions packages/github-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
],
"license": "MIT",
"dependencies": {
"evlog": "^2.27.1",
"octokit": "^5.0.5"
},
"peerDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions packages/github-tools/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Octokit } from 'octokit'
import { toGithubToolsError } from './core/errors'
import {
enrichGithubRateLimitError,
finishGithubResult,
parseGithubRateLimit,
recordGithubRateLimit,
Expand Down Expand Up @@ -32,7 +32,7 @@ export function createOctokit(token: string): Octokit {
octokit.hook.error('request', (error) => {
const rateLimit = parseGithubRateLimit(errorResponseHeaders(error))
if (rateLimit) recordGithubRateLimit(octokit, errorResponseHeaders(error))
throw enrichGithubRateLimitError(error, rateLimit)
throw toGithubToolsError(error, rateLimit)
})

return octokit
Expand Down
91 changes: 88 additions & 3 deletions packages/github-tools/src/connect/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { getToken } = vi.hoisted(() => ({
getToken: vi.fn(async () => 'ghs_connect_token'),
}))
const { getToken, ConnectError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError } = vi.hoisted(() => {
class ConnectError extends Error {
readonly status?: number
constructor(message: string, options?: { status?: number }) {
super(message)
this.status = options?.status
}
}
class UserAuthorizationRequiredError extends ConnectError {}
class ConnectorInstallationRequiredError extends ConnectError {}
return {
getToken: vi.fn(async () => 'ghs_connect_token'),
ConnectError,
UserAuthorizationRequiredError,
ConnectorInstallationRequiredError,
}
})

vi.mock('@vercel/connect', () => ({
getToken,
ConnectError,
UserAuthorizationRequiredError,
ConnectorInstallationRequiredError,
}))

import { connectGithubToken } from './token'

function fakeJwt(expiresAt: Date): string {
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
const exp = Math.floor(expiresAt.getTime() / 1000)
return `${encode({ alg: 'none' })}.${encode({ exp })}.signature`
}

function resolveConnectToken(
connector: Parameters<typeof connectGithubToken>[0],
options?: Parameters<typeof connectGithubToken>[1],
Expand Down Expand Up @@ -189,6 +212,68 @@ describe('connectGithubToken', () => {
)
})

it('throws OIDC_TOKEN_EXPIRED for an expired VERCEL_OIDC_TOKEN without calling Connect', async () => {
const expiredAt = new Date('2026-01-01T00:00:00.000Z')
vi.stubEnv('VERCEL_OIDC_TOKEN', fakeJwt(expiredAt))
const resolve = resolveConnectToken('github/my-connector', { preset: 'repo-explorer' })

await expect(resolve()).rejects.toMatchObject({
code: 'github_tools.OIDC_TOKEN_EXPIRED',
message: expect.stringContaining('2026-01-01T00:00:00.000Z'),
fix: expect.stringContaining('vercel env pull'),
})
expect(getToken).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})

it('passes a still-valid VERCEL_OIDC_TOKEN through unchanged', async () => {
const token = fakeJwt(new Date(Date.now() + 3_600_000))
vi.stubEnv('VERCEL_OIDC_TOKEN', token)
const resolve = resolveConnectToken('github/my-connector', { preset: 'repo-explorer' })

await resolve()
expect(getToken).toHaveBeenCalledWith(
'github/my-connector',
expect.anything(),
{ vercelToken: token },
)
vi.unstubAllEnvs()
})

it('maps UserAuthorizationRequiredError to CONNECT_USER_NOT_CONNECTED with the subject id', async () => {
getToken.mockRejectedValueOnce(new UserAuthorizationRequiredError('authorization required'))
const resolve = resolveConnectToken('github/my-connector', {
preset: 'issue-triage',
params: { subject: { type: 'user', id: 'user_123' } },
})

await expect(resolve()).rejects.toMatchObject({
code: 'github_tools.CONNECT_USER_NOT_CONNECTED',
message: expect.stringContaining('user_123'),
fix: expect.stringContaining('connect their GitHub account'),
})
})

it('maps ConnectorInstallationRequiredError to CONNECT_INSTALLATION_REQUIRED', async () => {
getToken.mockRejectedValueOnce(new ConnectorInstallationRequiredError('installation required'))
const resolve = resolveConnectToken('github/my-connector', { preset: 'issue-triage' })

await expect(resolve()).rejects.toMatchObject({
code: 'github_tools.CONNECT_INSTALLATION_REQUIRED',
message: expect.stringContaining('installation required'),
})
})

it('maps a Connect 403 to CONNECT_NOT_AUTHORIZED and names the process identity', async () => {
getToken.mockRejectedValueOnce(new ConnectError('Not authorized', { status: 403 }))
const resolve = resolveConnectToken('github/my-connector', { preset: 'issue-triage' })

await expect(resolve()).rejects.toMatchObject({
code: 'github_tools.CONNECT_NOT_AUTHORIZED',
why: expect.stringContaining('never reached GitHub'),
})
})

it('re-resolves a function connector on every call', async () => {
let env = 'preview'
const connectorFn = vi.fn(() => `github/${env}-connector`)
Expand Down
Loading
Loading