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 .changeset/rate-limit-on-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@github-tools/sdk': minor
---

Object-shaped tool results now include `rateLimit` (`remaining`, `limit`, `reset`, `resource`) from the last GitHub response. The field is stripped before the model sees the output. Array-shaped list tools are unchanged. 403/429 errors include remaining/reset in the message.
34 changes: 34 additions & 0 deletions apps/chat/app/components/tool/Github.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { GithubRateLimit } from '@github-tools/sdk'
import { GITHUB_TOOL_META } from '#shared/utils/tools/github'
import type { GithubToolName, GithubUIToolInvocation } from '#shared/utils/tools/github'

Expand Down Expand Up @@ -41,13 +42,46 @@ const context = computed(() => {
if (input.path) return String(input.path)
return null
})

const rateLimit = computed(() => readRateLimit(props.invocation.output))

const rateLimitLabel = computed(() => {
const value = rateLimit.value
if (!value) return null
const counts = `${value.remaining}/${value.limit}`
return value.resource && value.resource !== 'core' ? `${value.resource} ${counts}` : counts
})

const rateLimitTitle = computed(() => {
const value = rateLimit.value
if (!value) return undefined
return `Resets ${new Date(value.reset * 1000).toISOString()}`
})

function readRateLimit(output: unknown): GithubRateLimit | null {
if (output == null || typeof output !== 'object' || Array.isArray(output)) return null
const value = (output as { rateLimit?: unknown }).rateLimit
if (value == null || typeof value !== 'object') return null
const remaining = (value as GithubRateLimit).remaining
const limit = (value as GithubRateLimit).limit
const reset = (value as GithubRateLimit).reset
if (typeof remaining !== 'number' || typeof limit !== 'number' || typeof reset !== 'number') return null
const resource = (value as GithubRateLimit).resource
return {
remaining,
limit,
reset,
...typeof resource === 'string' ? { resource } : {}
}
}
</script>

<template>
<div class="flex items-center gap-1.5 text-xs my-1">
<UIcon :name="meta.icon" class="size-3 shrink-0 text-muted" />
<span class="text-default/70 font-medium">{{ label }}</span>
<span v-if="context" class="text-muted font-mono">{{ context }}</span>
<span v-if="rateLimitLabel" class="text-muted font-mono" :title="rateLimitTitle">{{ rateLimitLabel }}</span>
<UIcon v-if="isRunning" name="i-lucide-loader-circle" class="size-3 shrink-0 text-muted animate-spin" />
<UIcon v-else-if="isDone" name="i-lucide-check" class="size-3 shrink-0 text-success/60" />
<UIcon v-else-if="isDenied" name="i-lucide-ban" class="size-3 shrink-0 text-warning/60" />
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/2.frameworks/1.eve-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export default githubExtension({

The extension registers each tool with an **authored inline** `execute` and `toModelOutput` that only close over a serializable tool `name`, then rebuilds session options from the extension config on every call via `@github-tools/sdk/eve-runtime`. Tools resolve on `step.started` so registration stays fresh across durable steps. That pattern survives multi-turn eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). Prefer this mount over the deprecated [`createGithubTools`](/deprecated/eve) / [`connectGithubTools`](/deprecated/eve) paths for Slack / multi-turn durable agents — those register tools from inside `node_modules` and are skipped on replay. Author `overrides.toModelOutput` inline in the agent; a function imported from a library will not get a durable descriptor.

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).

## Durable approval, done right

Approval **pauses the session durably** until a human responds, and policies are expressive:
Expand Down
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 @@ -156,6 +156,18 @@ Supported override properties:

Core properties (`execute`, `inputSchema`, `outputSchema`) cannot be overridden.

### Rate-limit metadata

Object-shaped tool results include a `rateLimit` field from the last GitHub response (`x-ratelimit-remaining`, `x-ratelimit-limit`, `x-ratelimit-reset`, `x-ratelimit-resource`, and `retry-after` when present). Array-shaped results (`listIssues`, `listPullRequests`, …) are unchanged. The field is stripped before the model sees the output (`toModelOutput`); hooks, channels, and the chat UI still receive it.

```ts [rate-limit.ts]
import type { GithubRateLimit } from '@github-tools/sdk'

const remaining = (result as { rateLimit?: GithubRateLimit }).rateLimit?.remaining
```

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

### Commit attribution

The `author`, `committer`, and `coAuthors` options control how commits are attributed when using `createOrUpdateFile` or `mergePullRequest`:
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/skills/github-tools-agents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Array presets merge: `preset: ['code-review', 'issue-triage']`. Start with the s

## Working context

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.
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. Object-shaped execute results include `rateLimit` (`remaining` / `limit` / `reset` / `resource`); it is stripped from the model-facing output. Array-shaped list tools do not carry it. On 403/429 the error text includes remaining/reset.

## Write safety

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default githubExtension({
})
```

Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+.
Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. That callback 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+.

## Approval

Expand Down
2 changes: 1 addition & 1 deletion packages/github-tools-eve-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default githubExtension({

> `code-review` pairs cleanly with a Connect `connector`. `maintainer` and `repo-explorer` include gist tools, and GitHub only grants gist access to user access tokens, never the installation tokens Connect mints, so gist calls 403 over Connect. Write tools already require approval via `always()` by default, so a plain `{ someTool: true }` is a no-op, use a predicate (as above) when you actually want to narrow or loosen the default.

Tools are registered with **inline** `execute` and `toModelOutput` handlers in the extension package so they survive multi-turn durable eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). Do not use the deprecated `@github-tools/sdk/connect/eve` one-liner for durable Slack/multi-turn agents.
Tools are registered with **inline** `execute` and `toModelOutput` handlers in the extension package so they survive multi-turn durable eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). `toModelOutput` strips `rateLimit` from the model-facing payload; the execute result still carries it for hooks and channels. Do not use the deprecated `@github-tools/sdk/connect/eve` one-liner for durable Slack/multi-turn agents.

`connector` also accepts a `() => string | Promise<string>` resolver, so the same config can pick a connector dynamically (e.g. by environment):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { connectGithubToken } from '@github-tools/sdk/connect'
import {
executeGithubEveTool,
formatGithubEveToolOutput,
hasGithubEveToolModelOutput,
isEveApprovalDisabled,
listEveToolDescriptors,
mapEveApprovalValue,
Expand Down Expand Up @@ -113,9 +112,7 @@ export default defineDynamic({
}),
...(override?.toModelOutput !== undefined
? { toModelOutput: override.toModelOutput }
: hasGithubEveToolModelOutput(name)
? { toModelOutput: (output: unknown) => formatGithubEveToolOutput(name, output) }
: {}),
: { toModelOutput: (output: unknown) => formatGithubEveToolOutput(name, output) }),
...(override?.outputSchema !== undefined && {
outputSchema: override.outputSchema,
}),
Expand Down
12 changes: 12 additions & 0 deletions packages/github-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ Supported override properties:

Core properties (`execute`, `inputSchema`, `outputSchema`) cannot be overridden.

## Rate-limit metadata

Object-shaped tool results include a `rateLimit` field from the last GitHub response. Array-shaped results are unchanged. The field is stripped before the model sees the output; hooks, channels, and UIs still receive it.

```ts
import type { GithubRateLimit } from '@github-tools/sdk'

result.rateLimit?.remaining
```

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

## Commit Attribution

Control how commits are attributed when using `createOrUpdateFile` or `mergePullRequest`:
Expand Down
62 changes: 62 additions & 0 deletions packages/github-tools/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { GITHUB_API_VERSION, createOctokit } from './client'
import { finishGithubResult, peekGithubRateLimit } from './core/rate-limit'

describe('createOctokit', () => {
it('sets X-GitHub-Api-Version on REST requests', async () => {
Expand Down Expand Up @@ -27,4 +28,65 @@ describe('createOctokit', () => {

expect(seen.at(-1)).toBe(GITHUB_API_VERSION)
})

it('records rate-limit headers on a successful request', async () => {
const octokit = createOctokit('ghp_test')
octokit.request = octokit.request.defaults({
request: {
fetch: async () => new Response(JSON.stringify({
resources: { core: { limit: 5000, remaining: 38, reset: 1774800000, used: 0 } },
rate: { limit: 5000, remaining: 38, reset: 1774800000, used: 0 },
}), {
status: 200,
headers: {
'content-type': 'application/json',
'x-ratelimit-limit': '5000',
'x-ratelimit-remaining': '38',
'x-ratelimit-reset': '1774800000',
'x-ratelimit-resource': 'core',
},
}),
},
})

await octokit.rest.rateLimit.get()

expect(peekGithubRateLimit(octokit)).toEqual({
remaining: 38,
limit: 5000,
reset: 1774800000,
resource: 'core',
})
expect(finishGithubResult(octokit, { ok: true })).toEqual({
ok: true,
rateLimit: {
remaining: 38,
limit: 5000,
reset: 1774800000,
resource: 'core',
},
})
})

it('appends rate-limit state on a 403', async () => {
const octokit = createOctokit('ghp_test')
octokit.request = octokit.request.defaults({
request: {
fetch: async () => new Response(JSON.stringify({ message: 'API rate limit exceeded' }), {
status: 403,
headers: {
'content-type': 'application/json',
'x-ratelimit-limit': '30',
'x-ratelimit-remaining': '0',
'x-ratelimit-reset': '1774800000',
'x-ratelimit-resource': 'search',
},
}),
},
})

await expect(octokit.rest.search.code({ q: 'test' })).rejects.toThrow(
'GitHub rate limit search: 0/30 remaining, resets at 1774800000',
)
})
})
29 changes: 29 additions & 0 deletions packages/github-tools/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { Octokit } from 'octokit'
import {
enrichGithubRateLimitError,
finishGithubResult,
parseGithubRateLimit,
recordGithubRateLimit,
} from './core/rate-limit'

/** @see https://docs.github.com/en/rest/about-the-rest-api/api-versions */
export const GITHUB_API_VERSION = '2026-03-10'

function errorResponseHeaders(error: unknown): Record<string, unknown> | undefined {
if (error == null || typeof error !== 'object' || !('response' in error)) return undefined
const response = (error as { response?: { headers?: Record<string, unknown> } }).response
return response?.headers
}

export function createOctokit(token: string): Octokit {
const octokit = new Octokit({ auth: token })

Expand All @@ -13,5 +25,22 @@ export function createOctokit(token: string): Octokit {
}
})

octokit.hook.after('request', (response) => {
recordGithubRateLimit(octokit, response.headers)
})

octokit.hook.error('request', (error) => {
const rateLimit = parseGithubRateLimit(errorResponseHeaders(error))
if (rateLimit) recordGithubRateLimit(octokit, errorResponseHeaders(error))
throw enrichGithubRateLimitError(error, rateLimit)
})

return octokit
}

/** Run a GitHub `*Core` body and attach `rateLimit` from this Octokit instance. */
export async function withOctokit<T>(token: string, fn: (octokit: Octokit) => Promise<T>): Promise<T> {
const octokit = createOctokit(token)
const result = await fn(octokit)
return finishGithubResult(octokit, result)
}
17 changes: 9 additions & 8 deletions packages/github-tools/src/core/bundles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { withComposedRateLimit } from './rate-limit'
import { getCombinedStatusCore, listCheckRunsCore } from './checks'
import { compareCommitsCore } from './commits'
import { detailSchema, type DetailLevel } from './detail'
Expand Down Expand Up @@ -77,12 +78,12 @@ export async function getPullRequestContextCore({
: Promise.resolve(undefined),
])

return {
return withComposedRateLimit({
pullRequest,
...files !== undefined ? { files } : {},
...reviews !== undefined ? { reviews } : {},
...checks !== undefined ? { checks } : {},
}
})
}

export const getIssueContextInputSchema = z.object({
Expand Down Expand Up @@ -139,12 +140,12 @@ export async function getIssueContextCore({
: Promise.resolve(undefined),
])

return {
return withComposedRateLimit({
issue,
// Names only — full label objects (color/description) dominate triage payloads on large repos
...labels !== undefined ? { labelNames: labels.map(label => label.name) } : {},
...comments !== undefined ? { comments } : {},
}
})
}

export const getReleaseContextInputSchema = z.object({
Expand Down Expand Up @@ -204,11 +205,11 @@ export async function getReleaseContextCore({
})
}

return {
return withComposedRateLimit({
release,
...previous !== undefined ? { previousRelease: previous } : {},
...comparison !== undefined ? { comparison } : {},
}
})
}

export const getCiFailureContextInputSchema = z.object({
Expand Down Expand Up @@ -311,12 +312,12 @@ export async function getCiFailureContextCore({
latestFailure = { run: latestFailedRun, jobs: failedJobs }
}

return {
return withComposedRateLimit({
ref,
combinedStatus,
failedCheckRuns,
checkRunTotalCount: checkRunsResult.totalCount,
recentFailedRuns: failedRuns.runs,
...latestFailure !== undefined ? { latestFailure } : {},
}
})
}
8 changes: 5 additions & 3 deletions packages/github-tools/src/core/checks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { createOctokit } from '../client'
import { withOctokit } from '../client'
import { fetchAllPages, maxPagesSchema } from './pagination'

export const listCheckRunsInputSchema = z.object({
Expand All @@ -13,7 +13,7 @@ export const listCheckRunsInputSchema = z.object({
export const listCheckRunsDescription = 'List check runs (Checks API — GitHub Actions and other CI providers) for a commit, branch, or tag'

export async function listCheckRunsCore({ token, owner, repo, ref, perPage, maxPages }: { token: string, owner: string, repo: string, ref: string, perPage: number, maxPages?: number }) {
const octokit = createOctokit(token)
return withOctokit(token, async (octokit) => {
let totalCount = 0
const checkRuns = await fetchAllPages(async page => {
const { data } = await octokit.rest.checks.listForRef({ owner, repo, ref, per_page: perPage, page })
Expand All @@ -32,6 +32,7 @@ export async function listCheckRunsCore({ token, owner, repo, ref, perPage, maxP
completedAt: run.completed_at,
})),
}
})
}

export const getCombinedStatusInputSchema = z.object({
Expand All @@ -43,7 +44,7 @@ export const getCombinedStatusInputSchema = z.object({
export const getCombinedStatusDescription = 'Get the combined commit status (Statuses API — legacy CI integrations) for a commit, branch, or tag'

export async function getCombinedStatusCore({ token, owner, repo, ref }: { token: string, owner: string, repo: string, ref: string }) {
const octokit = createOctokit(token)
return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref })
return {
state: data.state,
Expand All @@ -55,4 +56,5 @@ export async function getCombinedStatusCore({ token, owner, repo, ref }: { token
url: status.target_url,
})),
}
})
}
Loading
Loading