Skip to content
6 changes: 5 additions & 1 deletion actions/setup/js/github_rate_limit_logger.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ function logRateLimitFromResponse(response, operation, credentialSource) {
*
* @param {any} github - The github object injected by actions/github-script
* @param {string} [operation="fetch"] - Label recorded in each log entry
* @param {boolean} [visible=false] - Also print core, code_search, and search limits to the Actions log
* @returns {Promise<{remaining:number,limit:number,used:number,reset:string}|null>}
* Core rate-limit data, or null if the call fails or the core resource is absent.
*/
async function fetchAndLogRateLimit(github, operation = "fetch") {
async function fetchAndLogRateLimit(github, operation = "fetch", visible = false) {
try {
const response = await github.rest.rateLimit.get();
const resources = response?.data?.resources;
Expand All @@ -169,6 +170,9 @@ async function fetchAndLogRateLimit(github, operation = "fetch") {
reset: data.reset ? new Date(data.reset * 1000).toISOString() : null,
};
appendEntry(entry);
if (visible && ["core", "code_search", "search"].includes(resource)) {
core.info(`${operation}: ${resource} limit=${entry.limit} remaining=${entry.remaining} reset=${entry.reset}`);
}
}

const coreData = resources.core;
Expand Down
47 changes: 47 additions & 0 deletions actions/setup/js/github_rate_limit_logger.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,53 @@ describe("fetchAndLogRateLimit", () => {

expect(searchEntry).toBeDefined();
expect(searchEntry.limit).toBe(30);
expect(mockCore.info).not.toHaveBeenCalled();
});

it.each(["agent_start", "agent_end"])("prints the requested resource limits for %s while retaining every JSONL resource", async operation => {
const resources = {
core: { limit: 5000, remaining: 4900, used: 100, reset: 1700000000 },
code_search: { limit: 10, remaining: 0, used: 10, reset: 1700000060 },
search: { limit: 30, remaining: 28, used: 2, reset: 1700000120 },
graphql: { limit: 5000, remaining: 4999, used: 1, reset: 1700000000 },
};
const mockGithub = { rest: { rateLimit: { get: vi.fn().mockResolvedValue({ data: { resources } }) } } };

const snapshot = await fetchAndLogRateLimit(mockGithub, operation, true);

expect(mockGithub.rest.rateLimit.get).toHaveBeenCalledOnce();
expect(appendSpy).toHaveBeenCalledTimes(4);
expect(mockCore.info).toHaveBeenCalledTimes(3);
for (const resource of ["core", "code_search", "search"]) {
const data = resources[resource];
expect(mockCore.info).toHaveBeenCalledWith(`${operation}: ${resource} limit=${data.limit} remaining=${data.remaining} reset=${new Date(data.reset * 1000).toISOString()}`);
}
expect(appendSpy.mock.calls.map(([, content]) => JSON.parse(content))).toEqual(
Object.entries(resources).map(([resource, data]) => expect.objectContaining({ operation, resource, limit: data.limit, remaining: data.remaining, reset: new Date(data.reset * 1000).toISOString() }))
);
expect(snapshot.remaining).toBe(4900);
});

it("prints a visible snapshot even when the JSONL file cannot be written", async () => {
appendSpy.mockImplementation(() => {
throw new Error("disk full");
});
const mockGithub = {
rest: { rateLimit: { get: vi.fn().mockResolvedValue({ data: { resources: { search: { limit: 30, remaining: 0, reset: 1700000000 } } } }) } },
};

await expect(fetchAndLogRateLimit(mockGithub, "agent_end", true)).resolves.toBeNull();
expect(mockCore.warning).toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith("agent_end: search limit=30 remaining=0 reset=2023-11-14T22:13:20.000Z");
});

it("keeps visible snapshot API failures nonfatal", async () => {
const mockGithub = { rest: { rateLimit: { get: vi.fn().mockRejectedValue(new Error("API unavailable")) } } };

await expect(fetchAndLogRateLimit(mockGithub, "agent_end", true)).resolves.toBeNull();
expect(mockCore.warning).toHaveBeenCalled();
expect(mockCore.info).not.toHaveBeenCalled();
expect(appendSpy).not.toHaveBeenCalled();
});

it("emits a warning and does not throw when the API call fails", async () => {
Expand Down
89 changes: 89 additions & 0 deletions actions/setup/js/parse_mcp_gateway_log.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -787,12 +787,86 @@ function formatRpcInlineCodeLabel(value) {
return `<code>${escapeHtml(normalizeRpcSummaryLabel(value))}</code>`;
}

/**
* Extract only the status and retry duration, never query text or URLs.
* @param {string} message
* @returns {string|null}
*/
function getMcpRateLimitHint(message) {
const status = Array.from(message.matchAll(/(?:^|\bHTTP(?:\/[\d.]+)?\s+|\bstatus(?:\s+code)?\s*[:=]?\s*|:\s+)([45]\d{2})\b/gi))
Comment thread
pelikhan marked this conversation as resolved.
Outdated
.filter(match => !/\bretry-after\s*$/i.test(message.slice(0, match.index)))
.at(-1);
Comment thread
pelikhan marked this conversation as resolved.
Outdated
if (!status || status[1] !== "429") return null;
const diagnostic = message.slice(status.index);
const duration = diagnostic.match(/\btry again in\s+((?:\d+(?:\.\d+)?(?:ns|µs|μs|us|ms|s|m|h))+)/i);
if (duration) return `try again in ${duration[1]}`;
const retryAfter = diagnostic.match(/\bretry-after\s*[:=]\s*(\d+(?:\.\d+)?)\b/i);
return retryAfter ? `Retry-After: ${retryAfter[1]}s` : "No retry hint provided";
Comment thread
Copilot marked this conversation as resolved.
Outdated
}

/**
* @param {Object} entry
* @returns {string|null}
*/
function getRpcRateLimitHint(entry) {
const payload = entry.payload;
if (payload?.error && typeof payload.error.message === "string") {
return getMcpRateLimitHint(payload.error.message);
}
if (payload?.result?.isError === true && Array.isArray(payload.result.content)) {
for (const block of payload.result.content) {
if (block?.type === "text" && typeof block.text === "string") {
const hint = getMcpRateLimitHint(block.text);
if (hint !== null) return hint;
}
}
}
return null;
}

/**
* Surface failed MCP calls separately from successful content that quotes 429s.
* @param {string} jsonlContent
* @returns {string}
*/
function generateMcpRateLimitSummary(jsonlContent) {
const { requests, responses, other } = parseRpcMessagesJsonl(jsonlContent);
/** @param {Object} entry */
const requestKey = entry => JSON.stringify([entry.server_id, entry.payload?.id]);
const tools = new Map(requests.filter(entry => entry.payload?.method === "tools/call" && entry.payload?.id != null).map(entry => [requestKey(entry), getRpcRequestLabel(entry)]));
const rows = [];
for (const response of responses) {
const hint = getRpcRateLimitHint(response);
if (hint !== null) {
rows.push(buildRpcSummaryRow([formatRpcInlineCodeLabel(response.server_id), formatRpcInlineCodeLabel(tools.get(requestKey(response)) || "unknown"), hint]));
}
}
for (const entry of other) {
if (entry.type !== "tool_call" || typeof entry.error !== "string") continue;
const hint = getMcpRateLimitHint(entry.error);
if (hint !== null) rows.push(buildRpcSummaryRow([formatRpcInlineCodeLabel(entry.server_name), formatRpcInlineCodeLabel(entry.tool_name), hint]));
}
if (rows.length === 0) return "";
return [
"### MCP rate limits (HTTP 429)",
Comment thread
pelikhan marked this conversation as resolved.
Outdated
"",
"| Server | Tool | Retry hint |",
"|--------|------|------------|",
...new Set(rows),
"",
"Retry hints are reported by the server, not a guarantee that the next request will succeed.",
"",
Comment thread
pelikhan marked this conversation as resolved.
].join("\n");
}

/**
* Summarizes an MCP RESPONSE entry for table rendering.
* @param {Object} entry
* @returns {{status: string, details: string}}
*/
function summarizeRpcResponseEntry(entry) {
const rateLimitHint = getRpcRateLimitHint(entry);
if (rateLimitHint !== null) return { status: "error", details: `HTTP 429: ${rateLimitHint}` };
const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
const error = payload.error && typeof payload.error === "object" ? payload.error : null;
if (error) {
Expand All @@ -806,6 +880,7 @@ function summarizeRpcResponseEntry(entry) {

const result = payload.result;
if (result && typeof result === "object") {
if (result.isError === true) return { status: "error", details: "Tool returned an error" };
if (Array.isArray(result.tools)) {
return {
status: "ok",
Expand Down Expand Up @@ -1019,8 +1094,10 @@ async function main() {
let modelAliasResolutionEvents = [];
/** @type {any} */
let rpcMessagesContent = null;
let gatewayJsonlContent = "";
if (fs.existsSync(gatewayJsonlPath)) {
const jsonlContent = fs.readFileSync(gatewayJsonlPath, "utf8");
gatewayJsonlContent = jsonlContent;
core.info(`Found gateway.jsonl (${jsonlContent.length} bytes)`);
difcFilteredEvents = parseGatewayJsonlForDifcFiltered(jsonlContent);
tokenSteeringEvents = parseGatewayJsonlForTokenSteering(jsonlContent);
Expand Down Expand Up @@ -1062,6 +1139,16 @@ async function main() {
core.info(`No gateway.jsonl or rpc-messages.jsonl found for steering or DIFC_FILTERED scanning`);
}

// RPC responses remain useful even when gateway.md or gateway.jsonl is present.
if (rpcMessagesContent === null && fs.existsSync(rpcMessagesPath)) {
rpcMessagesContent = fs.readFileSync(rpcMessagesPath, "utf8");
}
const rateLimitSummary = generateMcpRateLimitSummary(`${gatewayJsonlContent}\n${rpcMessagesContent || ""}`);
if (rateLimitSummary) {
core.warning("MCP tools returned HTTP 429; see the MCP rate limits summary for retry hints.");
core.summary.addRaw(rateLimitSummary);
}

// Always scan authoritative text logs for AI credits signals before selecting
// which format to render in the step summary.
let gatewayLogContent = "";
Expand Down Expand Up @@ -1187,6 +1274,7 @@ async function main() {
}
setAICreditsRateLimitOutput(core, aiCreditsRateLimitError);
setUnknownModelAICreditsOutput(core, unknownModelAICredits);
await writeStepSummaryWithTokenUsage(core);
} catch (error) {
core.setFailed(`${ERR_PARSE}: ${getErrorMessage(error)}`);
}
Expand Down Expand Up @@ -1311,6 +1399,7 @@ if (typeof module !== "undefined" && module.exports) {
getRpcMessageType,
getRpcRequestLabel,
generateRpcMessagesSummary,
generateMcpRateLimitSummary,
printAllGatewayFiles,
parseTokenUsageJsonl,
generateTokenUsageSummary,
Expand Down
113 changes: 113 additions & 0 deletions actions/setup/js/parse_mcp_gateway_log.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
getRpcMessageType,
getRpcRequestLabel,
generateRpcMessagesSummary,
generateMcpRateLimitSummary,
printAllGatewayFiles,
parseTokenUsageJsonl,
generateTokenUsageSummary,
Expand All @@ -26,6 +27,118 @@ const {
} = require("./parse_mcp_gateway_log.cjs");

describe("parse_mcp_gateway_log", () => {
describe("MCP rate-limit diagnostics", () => {
const request = (server_id = "github", name = "search_code", id = 1) => ({
event: "rpc_request",
server_id,
payload: { id, method: "tools/call", params: { name, arguments: { query: "private-query" } } },
});
const response = (message, server_id = "github", id = 1) => ({
event: "rpc_response",
server_id,
payload: { id, result: { isError: true, content: [{ type: "text", text: message }] } },
});
const jsonl = entries => entries.map(entry => JSON.stringify(entry)).join("\n");

test.each(["216.007769ms", "719.5s", "10.2s", "1m30s"])("preserves the server retry hint %s without query text", duration => {
const entries = [request(), response(`failed to search code with query '${"private-query".repeat(20)}': GET https://api.github.com/search/code?q=private-query: 429 try again in ${duration}`)];
const summary = generateMcpRateLimitSummary(jsonl(entries));
expect(summary).toContain("MCP rate limits (HTTP 429)");
expect(summary).toContain("<code>search_code</code>");
expect(summary).toContain(`try again in ${duration}`);
expect(summary).not.toContain("private-query");
expect(summary).not.toContain("https://");
const rpcSummary = generateRpcMessagesSummary(parseRpcMessagesJsonl(jsonl(entries)), []);
expect(rpcSummary).toContain(`error | HTTP 429: try again in ${duration}`);
});

test("handles JSON-RPC errors, absent hints, and Retry-After", () => {
const entries = [request(), { event: "rpc_response", server_id: "github", payload: { id: 1, error: { code: -32603, message: "HTTP 429 Too Many Requests; Retry-After: 60" } } }, response("HTTP 429 Too Many Requests")];
const summary = generateMcpRateLimitSummary(jsonl(entries));
expect(summary).toContain("Retry-After: 60s");
expect(summary).toContain("No retry hint provided");
});

test.each([400, 429, 480, 599])("does not mistake Retry-After: %s for an HTTP status", seconds => {
const entries = [request(), response(`HTTP 429 Too Many Requests; Retry-After: ${seconds}`)];
expect(generateMcpRateLimitSummary(jsonl(entries))).toContain(`Retry-After: ${seconds}s`);
expect(generateRpcMessagesSummary(parseRpcMessagesJsonl(jsonl(entries)), [])).toContain(`error | HTTP 429: Retry-After: ${seconds}s`);
});

test("matches request IDs per server and escapes tool labels", () => {
const entries = [request(), request("other", "<img src=x>|tool"), response("429 try again in 2s", "other")];
const summary = generateMcpRateLimitSummary(jsonl(entries));
expect(summary).toContain("&lt;img src=x&gt;\\|tool");
expect(summary).not.toContain("<img");
expect(summary).not.toContain("search_code");
});

test("ignores successful content, request arguments, and non-429 errors", () => {
const success = response("documentation about HTTP 429 try again in 719s");
success.payload.result.isError = false;
const entries = [
request(),
success,
response("HTTP 403 Forbidden"),
response("failed to search code with query '429': HTTP 422"),
response("failed to search code with query 'HTTP 429 try again in 7s': GET https://api.github.com/search/code?q=429: 422 Validation Failed"),
{ event: "tool_call", tool_name: "search_code", message: "429 try again in 2s", status: "success" },
];
expect(generateMcpRateLimitSummary(jsonl(entries) + "\n{broken\n")).toBe("");
expect(generateMcpRateLimitSummary("")).toBe("");
});

test("handles legacy gateway tool errors", () => {
const entry = { event: "tool_call", server_name: "github", tool_name: "search_code", error: "429 try again in 719.5s" };
expect(generateMcpRateLimitSummary(jsonl([entry]))).toContain("<code>search_code</code> | try again in 719.5s");
});

test.each(["gateway.jsonl", "rpc-messages.jsonl", "both", "legacy"])("surfaces 429s with %s logs", async filename => {
const prefix = "/tmp/gh-aw/mcp-logs/";
const files = {
[prefix + "gateway.md"]: "# Gateway Summary\n",
[prefix + filename]: jsonl([request(), response("429 try again in 216.007769ms")]),
};
if (filename === "both") {
files[prefix + "gateway.jsonl"] = jsonl([{ event: "tool_call", server_name: "github", tool_name: "search_code", error: "429 try again in 216.007769ms" }]);
files[prefix + "rpc-messages.jsonl"] = jsonl([request()]);
} else if (filename === "legacy") {
delete files[prefix + "gateway.md"];
files[prefix + "gateway.jsonl"] = jsonl([{ event: "tool_call", server_name: "github", tool_name: "search_code", error: "429 try again in 216.007769ms" }]);
files[prefix + "gateway.log"] = "Gateway started";
}
const existsSync = fs.existsSync;
const readFileSync = fs.readFileSync;
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation(file => (String(file).startsWith("/tmp/gh-aw/") ? Object.hasOwn(files, String(file)) : existsSync(file)));
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((file, options) => files[String(file)] ?? readFileSync(file, options));
const previousCore = global.core;
const mockCore = {
info: vi.fn(),
debug: vi.fn(),
startGroup: vi.fn(),
endGroup: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
summary: { addRaw: vi.fn(), write: vi.fn().mockResolvedValue(undefined) },
};
global.core = mockCore;
try {
await require("./parse_mcp_gateway_log.cjs").main();
expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("try again in 216.007769ms"));
if (filename !== "legacy") expect(mockCore.summary.addRaw).toHaveBeenCalledWith("# Gateway Summary\n");
expect(mockCore.summary.write).toHaveBeenCalledOnce();
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("MCP tools returned HTTP 429"));
expect(mockCore.setOutput).toHaveBeenCalledWith("ai_credits_rate_limit_error", "false");
} finally {
existsSpy.mockRestore();
readSpy.mockRestore();
global.core = previousCore;
}
});
});

test("flushes gateway diagnostics without appending a duplicate unified timeline", async () => {
const existsSync = fs.existsSync;
const readFileSync = fs.readFileSync;
Expand Down
25 changes: 25 additions & 0 deletions docs/src/content/docs/reference/github-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ Common toolsets include `context` (user and team info), `repos` (repository oper

Some toolsets require [additional authentication](#additional-authentication-for-github-tools).

### Code Search Rate Limits

The GitHub MCP `search_code` tool uses a separate, much smaller budget than ordinary GitHub API reads. GitHub's [search rate limits](https://docs.github.com/en/rest/search/search#rate-limit) allow 10 authenticated code-search requests per minute, compared with 30 per minute for other search endpoints. An available `core` quota does not imply that code search is available.

Agent start/end rate-limit snapshots log `core`, `code_search`, and `search` with their limit, remaining requests, and reset time. MCP HTTP 429 failures appear in the agent job summary with the server's retry hint, when provided. These snapshots reflect the configured GitHub tools credential; they do not prove that concurrent runs share an identity or explain every secondary limit or MCP-server throttle. A retry hint is not a guarantee that waiting will make the next request succeed.

For inspecting code in another repository, the recommended alternative is [`checkout:` with `sparse-checkout`](/gh-aw/reference/checkout/), then local `grep` or `rg` searches. This checkout configuration is supported in v0.89.17:

```aw wrap
permissions:
contents: read
checkout:
- repository: owner/target-repo
ref: main
path: target-repo
sparse-checkout: |
src
docs
github-token: ${{ secrets.TARGET_REPO_READ_TOKEN }}
tools:
bash: ["grep", "rg"]
```

Use a token or GitHub App with read access to the target repository; the workflow's `GITHUB_TOKEN` is scoped to its own repository. The checkout credential is separate from `tools.github` authentication. Search the checked-out paths locally rather than fetching missing files during agent execution. For occasional API code searches, keep a small per-run [`max-calls` limit](#restricting-tools-toolsgithuballowed); it does not reserve quota or prevent throttling.

## Restricting Tools (`tools.github.allowed`)

Use `tools.github.allowed` to restrict which GitHub MCP tools the agent can call. Each entry is either a string tool name or an object with a per-tool call limit:
Expand Down
Loading
Loading