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
334 changes: 254 additions & 80 deletions setup/js/add_mask_redaction.cjs

Large diffs are not rendered by default.

22 changes: 16 additions & 6 deletions setup/js/agent_execution.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check

const { detectErrors, buildOutputLines, isCAPIServerError } = require("./agent_error_patterns.cjs");
const { detectNonRetryableHarnessGuard, isAuthenticationFailedError } = require("./harness_error_patterns.cjs");
const { API_PROXY_GUARD_REJECTION_PATTERNS, detectNonRetryableHarnessGuard, isAuthenticationFailedError, selectNativeErrorDiagnostic, shouldSuppressGuardCategory } = require("./harness_error_patterns.cjs");
const { crashSignalNameForExitCode } = require("./harness_crash_signals.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");
const { sessionContext } = require("./agent_session.cjs");
Expand Down Expand Up @@ -243,11 +243,15 @@ function agentErrorSummaryText(content, eventsOrSourceEngine = [], rawSourceEngi
*/
function collectNativeErrorEvidence(errors) {
const diagnostics = [];
const guardDiagnostics = [];
const codes = new Set();
const types = new Set();
for (const error of errors) {
diagnostics.push(JSON.stringify(error));
const queue = [error];
const diagnostic = selectNativeErrorDiagnostic(error);
const diagnosticText = JSON.stringify(diagnostic) ?? "";
diagnostics.push(diagnosticText);
guardDiagnostics.push(diagnosticText);
const queue = [diagnostic];
for (let visited = 0; visited < 16 && queue.length; visited++) {
const value = queue.shift();
if (typeof value === "string") {
Expand All @@ -274,10 +278,9 @@ function collectNativeErrorEvidence(errors) {
for (const key of ["error", "message", "errorMessage"]) {
if (value[key] !== undefined) queue.push(value[key]);
}
if (value.metadata?.raw !== undefined) queue.push(value.metadata.raw);
}
}
return { diagnostics, codes, types };
return { diagnostics, guardDiagnostics, codes, types };
}

/**
Expand All @@ -290,7 +293,7 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi
const categorySet = new Set(categories);
const { diagnostics, errors: rawErrors } = collectAgentErrorEvidence(content, events, rawSourceEngine);
const errors = [...events.flatMap(recordErrors), ...rawErrors];
const { diagnostics: nativeDiagnostics, codes, types } = collectNativeErrorEvidence(errors);
const { diagnostics: nativeDiagnostics, guardDiagnostics, codes, types } = collectNativeErrorEvidence(errors);
let observedExit = exitCode;
for (const data of observations) {
validateAgentExecution(data);
Expand All @@ -305,6 +308,7 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi
const terminal = line.match(/^\[(?:copilot|claude|codex)-harness\].*?\bdone: exitCode=(\d+)\b/);
if (terminal && exitCode === undefined && !observations.some(data => data.exitCode !== undefined)) observedExit = Number(terminal[1]);
}
guardDiagnostics.push(...diagnostics);
diagnostics.push(...nativeDiagnostics);
const text = diagnostics.join("\n");
for (const diagnostic of diagnostics) {
Expand All @@ -322,6 +326,12 @@ function collectAgentExecution({ content = "", events = [], categories = [], exi
if (guards.awfAPIProxyBlockingRequests) categorySet.add("awf_api_proxy_blocking_requests");
if (guards.goalAlreadyActive) categorySet.add("goal_already_active");
if (guards.apiProxyGuardRejection) categorySet.add(guards.apiProxyGuardRejection.guard);
// Observation accepts attributed diagnostic text; retry decisions require transport envelopes.
for (const diagnostic of guardDiagnostics) {
for (const { guard, pattern } of API_PROXY_GUARD_REJECTION_PATTERNS) {
if (pattern.test(diagnostic) && !shouldSuppressGuardCategory(guard, diagnostic)) categorySet.add(guard);
}
}
const crashSignal = observedExit === undefined ? null : crashSignalNameForExitCode(observedExit);
if (crashSignal) {
categorySet.add("sandbox_runtime_crash");
Expand Down
111 changes: 93 additions & 18 deletions setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ require("./shim.cjs");

const fs = require("fs");
const path = require("path");
const { parseAICreditsExceededProxyRejection } = require("./harness_error_patterns.cjs");

const MAX_AI_CREDITS_FIELDS = new Set(["max_ai_credits", "maxAiCredits"]);
const AI_CREDITS_FIELDS = new Set(["ai_credits", "aiCredits"]);
Expand Down Expand Up @@ -32,10 +33,88 @@ const API_PROXY_GUARD_REJECTION_EVENT_TYPES = [MAX_CACHE_MISSES_EXCEEDED_EVENT_T
// Counter fields carried by proxy guard rejection events, reported alongside the guard name
// so the step log states what limit was hit and at which value.
const API_PROXY_GUARD_COUNTER_FIELDS = ["consecutive_cache_misses", "max_cache_misses", "effective_tokens", "max_effective_tokens", "permission_denied_count", "max_permission_denied", "model"];
const MAX_AI_CREDITS_EXCEEDED_STDIO_RE = /maximum ai credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?/i;
// Raw stderr fallback assumes unprefixed subprocess diagnostics; only explicitly allowlisted wrappers are unwrapped below.
const MAX_AI_CREDITS_EXCEEDED_STDIO_RE =
/^(?:(?:Execution failed:\s*)?CAPIError:\s*429\s+|Failed to get response from the AI model; retried \d+ times(?: \(total retry wait time:[^)]+\))?(?: \(Request-ID [^)]+\))?\.? Last error: CAPIError:\s*429\s+|Fatal:\s*)Maximum AI credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?\.?$/i;
const MAX_AI_CREDITS_EXCEEDED_NATIVE_ERROR_RE = /^(?:(?:API Error:\s*)?403(?::\s*|\s+)(?:Forbidden[.:]?\s*)?)?Maximum AI credits exceeded(?:\s*\((\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\))?\.?$/i;
const DEFAULT_AGENT_STDIO_LOG = "/tmp/gh-aw/agent-stdio.log";
const AGENT_STDIO_LOG_MAX_TAIL = 64 * 1024; // 64 KB — sufficient for any realistic error block

/**
* Compatibility for native diagnostics alongside the pair-only harness parser.
* Never walk arbitrary JSON fields: only engine-owned error slots are eligible.
* @param {string} line
* @returns {{ aiCredits: string, maxAICredits: string } | null}
*/
function parseNativeAICreditsRejection(line) {
let diagnostic = line;
let nativeEnvelope = false;
if (line.trimStart().startsWith("{")) {
let record;
try {
record = JSON.parse(line);
} catch {
return null;
}
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
if (record.role || record.data?.role || record.error?.role || record.parentToolUseId || record.parent_tool_use_id || record.parentToolCallId || record.agentId || record.data?.parentToolCallId || record.data?.agentId) return null;
if (["warning", "info"].includes(record.data?.severity || record.severity)) return null;
if (record.message?.role && !["message_end", "turn_end", "assistant"].includes(record.type)) return null;
if (record.message?.role && record.message.role !== "assistant") return null;
const rejection = parseAICreditsExceededProxyRejection(line);
if (rejection) return { aiCredits: String(rejection.aiCredits), maxAICredits: String(rejection.maxAICredits) };
if (["message_end", "turn_end"].includes(record.type) && record.message?.role === "assistant" && record.message.stopReason === "error") {
diagnostic = record.message.errorMessage;
} else if (record.type === "error" && typeof record.error === "string" && record.message === undefined) {
diagnostic = record.error;
} else if (record.type === "session.error" && record.data) {
diagnostic = record.data.message;
} else {
return null;
}
nativeEnvelope = true;
} else {
const sdkError = /^\[(?:copilot-sdk-driver|sdk-driver)\]\s+(?:\[sdk-driver\]\s+)?(?:error|unhandled error):\s*(?:Error:\s*)?([\s\S]*)$/i.exec(line);
if (sdkError) {
diagnostic = sdkError[1];
nativeEnvelope = true;
}
// Pi strips the HTTP status from its JSON-quoted error field.
const providerError = /^\[gh-aw\/pi-provider\]\s+\S+\s+provider_error provider=.+? model=.+? api=\S+ status=403 method=\S+ url=.+? response_headers=\S+ error=("(?:[^"\\]|\\.)*")$/.exec(line);
if (providerError) {
try {
diagnostic = JSON.parse(providerError[1]);
} catch {
return null;
}
nativeEnvelope = true;
}
}
if (typeof diagnostic !== "string") return null;
let match = MAX_AI_CREDITS_EXCEEDED_STDIO_RE.exec(diagnostic);
if (!match && nativeEnvelope) {
// Goose's terminal error wraps the response and appends fixed retry advice.
diagnostic = diagnostic.replace(/^Ran into this error: Authentication error: Status: 403 Forbidden\. Response: /, "").replace(/\n\nPlease retry if you think this is a transient or recoverable error\.$/, "");
// Pi may retain the provider's JSON error body after the HTTP status.
const jsonBody = /^(?:403(?::\s*|\s+))?(\{.*\})$/.exec(diagnostic);
if (jsonBody) {
try {
const body = JSON.parse(jsonBody[1]);
if (body.role || body.error?.role || ["assistant", "user", "tool", "tool_result", "toolResponse", "message"].includes(body.type)) return null;
diagnostic = typeof body.error?.message === "string" ? body.error.message : body.message;
} catch {
return null;
}
}
match = typeof diagnostic === "string" ? MAX_AI_CREDITS_EXCEEDED_NATIVE_ERROR_RE.exec(diagnostic) : null;
}
if (!match) return null;
const aiCredits = match[1] || "";
const maxAICredits = match[2] || "";
if (aiCredits && (!Number.isFinite(Number(aiCredits)) || !Number.isFinite(Number(maxAICredits)) || Number(maxAICredits) <= 0 || Number(aiCredits) < Number(maxAICredits))) return null;
return { aiCredits, maxAICredits };
}

/**
* @param {unknown} value
* @returns {string}
Expand Down Expand Up @@ -716,25 +795,21 @@ function parseAICreditsExceededFromAgentStdio() {
fs.closeSync(fd);
}
const content = buf.toString("utf8");
const proxyHTTP403 = content.split(/\r?\n/).some(line => {
const proxyFailure = line.match(/Authentication failed with provider at (\S+) \(HTTP 403\)\.?/i);
const lines = content.split(/\r?\n/);
const proxyHTTP403 = lines.some(line => {
const proxyFailure = line.match(/^Authentication failed with provider at (\S+) \(HTTP 403\)\.?$/i);
return proxyFailure !== null && isLikelyAWFAPIProxyURL(proxyFailure[1]);
});
// Use matchAll and take the last occurrence — in retried runs the final
// entry carries the authoritative (highest) credit values.
const RE_G = new RegExp(MAX_AI_CREDITS_EXCEEDED_STDIO_RE.source, "gi");
const allMatches = [...content.matchAll(RE_G)];
const match = allMatches.at(-1);
if (!match) return { ...EMPTY_AGENT_STDIO_STATE, proxyHTTP403 };
const aiCredits = parsePositiveNumberString(match[1] || "");
const maxAICredits = parsePositiveNumberString(match[2] || "");
return {
aiCredits,
maxAICredits,
rateLimitError: true,
maxAICreditsExceeded: true,
proxyHTTP403,
};
// JSON tool/assistant records can quote rejection examples. Only inspect
// authenticated transport errors or a complete native stderr diagnostic.
let result = { ...EMPTY_AGENT_STDIO_STATE, proxyHTTP403 };
for (const line of lines) {
const rejection = parseNativeAICreditsRejection(line);
if (!rejection) continue;
const { aiCredits, maxAICredits } = rejection;
result = { aiCredits, maxAICredits, rateLimitError: true, maxAICreditsExceeded: true, proxyHTTP403 };
}
return result;
} catch {
return EMPTY_AGENT_STDIO_STATE;
}
Expand Down
4 changes: 3 additions & 1 deletion setup/js/checkout_pr_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const { detectForkPR } = require("./pr_helpers.cjs");
const { parseAllowedBots, isAllowedBot, canonicalizeBotIdentifier } = require("./check_permissions_utils.cjs");
const { ERR_API, ERR_PERMISSION } = require("./error_codes.cjs");
const TRUSTED_CHECKOUT_PERMISSIONS = ["write", "maintain", "admin"];
const TRUSTED_BOT_PR_ACTIONS = ["opened", "synchronize", "ready_for_review", "reopened"];
// Centralized command/label routing uses the repository GITHUB_TOKEN, whose
// GitHub-authenticated actor is fixed to this platform identity.
const CENTRALIZED_ROUTER_ACTOR = "github-actions[bot]";
Expand Down Expand Up @@ -295,7 +296,8 @@ async function assertTrustedCheckoutRuntime(awContext, pullRequest) {
// events require the collaborator check unless explicitly allow-listed below.
if (
(context.eventName === "pull_request" || context.eventName === "pull_request_target") &&
(context.payload.action === "opened" || context.payload.action === "synchronize") &&
typeof context.payload.action === "string" &&
TRUSTED_BOT_PR_ACTIONS.includes(context.payload.action) &&
senderType === "Bot" &&
context.payload.sender?.login === actor &&
context.payload.repository !== undefined &&
Expand Down
17 changes: 14 additions & 3 deletions setup/js/collect_ndjson_output.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { AGENT_OUTPUT_FILENAME, TMP_GH_AW_PATH } = require("./constants.cjs");
const { ERR_API, ERR_PARSE } = require("./error_codes.cjs");
const { parseIntTemplatable } = require("./templatable.cjs");
const { isProbingNoopMessage } = require("./intent_probe.cjs");
const { buildEmptyOutputOutcome } = require("./empty_output_outcome.cjs");
const { buildEmptyOutputOutcome, collectOutcomeSession, hasFailedFinalResult } = require("./empty_output_outcome.cjs");

const MENTION_AWARE_OUTPUT_TYPES = new Set(["add_comment", "close_discussion", "create_discussion", "create_issue", "create_pull_request", "create_pull_request_review_comment", "reply_to_pull_request_review_comment"]);

Expand Down Expand Up @@ -374,8 +374,19 @@ async function main() {
let collectorDriverExitCode;
let collectorRetryCount;
let collectorEngineErrorType;
if (!queueScope && !parsedItems.some(item => !["missing_tool", "missing_data"].includes(item.type))) {
const incompleteOutcome = buildEmptyOutputOutcome(errors);
const hasTerminalOutput = parsedItems.some(item => !["missing_tool", "missing_data"].includes(item.type));
let failedFinalResult = false;
let session;
if (!queueScope && hasTerminalOutput) {
try {
session = collectOutcomeSession(TMP_GH_AW_PATH);
failedFinalResult = hasFailedFinalResult(session.events);
} catch {
core.warning("Runtime diagnostics could not be collected.");
}
}
if (!queueScope && (failedFinalResult || !hasTerminalOutput)) {
const incompleteOutcome = buildEmptyOutputOutcome(errors, undefined, session);
parsedItems.push(incompleteOutcome);
collectorEmptyOutputCause = incompleteOutcome.reason;
collectorFailureCause = incompleteOutcome.failureCause;
Expand Down
7 changes: 4 additions & 3 deletions setup/js/create_prompt.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,15 @@ function renderWorkClaim(payload) {
return "";
}

return assignment.claims
const assignmentJSON = JSON.stringify({ work_queue_assignment: assignment }).replace(/</g, "\\u003c");
const claims = assignment.claims
.map(member => {
const work = JSON.stringify({ id: member.work_id, payload: member.work, result_refs: member.result_refs }).replace(/</g, "\\u003c");
const claim = JSON.stringify({ dispatch_id: assignment.dispatch_id, id: member.claim_id, work_id: member.work_id, claim_handle: member.handle }).replace(/</g, "\\u003c");
const handle = JSON.stringify(member.handle).replace(/</g, "\\u003c");
return `<work-claim>\n${work}\n${claim}\nWhen the work is complete, call work_queue_claim_finish with outcome "completed" and claim_handle ${handle} to record the finish intent. Set keep_open: true only with completed to finish this Claim while retaining the Work in the queue and leaving its backing Issue open; the Work can be claimed again only after delivery is verified. keep_open defaults to false. If you cannot complete the work, call it with outcome "cancelled" and the same claim_handle. Always include this claim_handle on safe outputs and queue-control calls. The claim is only complete after trusted reconciliation verifies it; recording intent alone does not authorize safe outputs.\n</work-claim>\n`;
return `<work-claim>\n${claim}\nWhen the work is complete, call work_queue_claim_finish with outcome "completed" and claim_handle ${handle} to record the finish intent. Set keep_open: true only with completed to finish this Claim while retaining the Work in the queue and leaving its backing Issue open; the Work can be claimed again only after delivery is verified. keep_open defaults to false. If you cannot complete the work, call it with outcome "cancelled" and the same claim_handle. Always include this claim_handle on safe outputs and queue-control calls. The claim is only complete after trusted reconciliation verifies it; recording intent alone does not authorize safe outputs.\n</work-claim>\n`;
})
.join("");
return `<work-queue-assignment>\n${assignmentJSON}\n</work-queue-assignment>\n${claims}`;
}

/**
Expand Down
Loading
Loading