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
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ runs:
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare-validator.ts
env:
GITHUB_TOKEN: ${{ steps.prepare.outputs.github_token }}
FACTORY_API_KEY: ${{ inputs.factory_api_key }}
REVIEW_VALIDATED_PATH: ${{ inputs.review_validated_path }}
REVIEW_CANDIDATES_PATH: ${{ inputs.review_candidates_path }}
DROID_COMMENT_ID: ${{ steps.prepare.outputs.droid_comment_id }}
Expand Down Expand Up @@ -349,6 +350,9 @@ runs:
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
DROID_ERROR_MESSAGE: ${{ steps.droid.outputs.error_message || '' }}
DROID_VALIDATOR_ERROR_MESSAGE: ${{ steps.droid_validator.outputs.error_message || '' }}
MODEL_FALLBACK_NOTE: ${{ steps.prepare.outputs.model_fallback_note || steps.prepare_validator.outputs.model_fallback_note || steps.droid.outputs.model_fallback_note || steps.droid_validator.outputs.model_fallback_note || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
TRACK_PROGRESS: ${{ inputs.track_progress }}
AUTOMATIC_REVIEW: ${{ inputs.automatic_review }}
Expand Down
95 changes: 93 additions & 2 deletions base-action/src/run-droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { promisify } from "util";
import { stat } from "fs/promises";
import { parse as parseShellArgs } from "shell-quote";
import { retryWithBackoff } from "./utils/retry";
import {
condenseInvalidModelError,
isInvalidModelError,
isModelPolicyError,
stripModelArgs,
} from "./utils/model-policy-error";

const execAsync = promisify(exec);

Expand Down Expand Up @@ -256,16 +262,41 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
// retryWithBackoff so backoff timing lives in one place (3 total attempts,
// 5s then 10s delays).
let lastExitCode = 1;
let currentDroidArgs = config.droidArgs;
let modelArgsStripped = false;
type ResultEvent = { is_error?: boolean; result?: string };
let lastResultEvent: ResultEvent | null = null;
// Fast client-side failures (e.g. an invalid --model value) print to
// stderr and exit before any stream-json result event is emitted, so keep
// a bounded tail of stderr as an error-message fallback.
const STDERR_TAIL_LIMIT = 1500;
let stderrTail = "";
// Indirection defeats TS control-flow narrowing: the variables are only
// assigned inside stream handler closures, so direct reads after the
// retry loop would otherwise be narrowed to their initial values.
const getLastResultEvent = (): ResultEvent | null => lastResultEvent;
const getStderrTail = (): string => stderrTail;

const runDroidOnce = (): Promise<number> => {
const droidProcess = spawn(droidExecutable, config.droidArgs, {
stdio: ["ignore", "pipe", "inherit"],
stderrTail = "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reset lastResultEvent between retry attempts

lastResultEvent is not cleared per attempt, so a later retry that exits before emitting a type: "result" event can cause stale result data from a previous attempt to drive the retry decision and the final error_message. Reset it at the start of runDroidOnce so each attempt’s diagnostics reflect only that attempt.

Suggested change
stderrTail = "";
stderrTail = "";
lastResultEvent = null;

const droidProcess = spawn(droidExecutable, currentDroidArgs, {
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...config.env,
},
});

droidProcess.stderr.on("data", (data) => {
const text = data.toString();
process.stderr.write(text);
stderrTail = (stderrTail + text).slice(-STDERR_TAIL_LIMIT);
});

droidProcess.stderr.on("error", (error) => {
console.error("Error reading Droid stderr:", error);
});

// Handle Droid process errors
droidProcess.on("error", (error) => {
console.error("Error spawning Droid process:", error);
Expand Down Expand Up @@ -294,6 +325,17 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
console.log(`Detected Droid session: ${sessionId}`);
}
}
if (
typeof parsed === "object" &&
parsed !== null &&
parsed.type === "result"
) {
lastResultEvent = {
is_error: parsed.is_error === true,
result:
typeof parsed.result === "string" ? parsed.result : undefined,
};
}
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);

if (sanitizedOutput) {
Expand Down Expand Up @@ -340,6 +382,38 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
lastExitCode = await runDroidOnce();
if (lastExitCode !== 0) {
console.log(`Droid Exec exited with code ${lastExitCode}`);
// If the failure was caused by the requested model being rejected
// (blocked by the org's model policy, or not a recognized model
// id), retry without --model so droid exec falls back to the
// organization's default model.
const resultEvent = getLastResultEvent();
const policyBlocked =
resultEvent?.is_error === true &&
isModelPolicyError(resultEvent.result);
const invalidModel = isInvalidModelError(getStderrTail());
if (
!modelArgsStripped &&
(policyBlocked || invalidModel) &&
currentDroidArgs.some(
(arg) => arg === "--model" || arg.startsWith("--model="),
)
) {
modelArgsStripped = true;
currentDroidArgs = stripModelArgs(currentDroidArgs);
const reason = policyBlocked
? "is not allowed by your organization's model policy"
: "is not a recognized model id";
console.warn(
`The requested model ${reason}; retrying with the organization's default model`,
);
core.setOutput(
"model_fallback_note",
`The requested model ${reason}, so Droid retried with your organization's default model. ` +
"Set the model input (e.g. `review_model`) to an " +
"[available model](https://docs.factory.ai/models) approved " +
"by your organization to control which model is used.",
);
}
throw new Error(`Droid Exec exited with code ${lastExitCode}`);
}
},
Expand All @@ -352,6 +426,23 @@ export async function runDroid(promptPath: string, options: DroidOptions) {
console.error(
`Droid Exec failed after 3 total attempts (exit code: ${lastExitCode})`,
);
const finalResultEvent = getLastResultEvent();
let finalStderrTail = getStderrTail().trim();
if (isInvalidModelError(finalStderrTail)) {
finalStderrTail = condenseInvalidModelError(finalStderrTail);
}
const rawErrorMessage =
finalResultEvent?.is_error && finalResultEvent.result?.trim()
? finalResultEvent.result.trim()
: finalStderrTail
? `Droid Exec exited with code ${lastExitCode}:\n${finalStderrTail}`
: `Droid Exec exited with code ${lastExitCode}`;
const errorMessage =
rawErrorMessage.length > 2000
? `${rawErrorMessage.slice(0, 2000)}…`
: rawErrorMessage;
console.error(`Droid Exec failed: ${errorMessage}`);
core.setOutput("error_message", errorMessage);
core.setOutput("conclusion", "failure");
process.exit(lastExitCode);
}
Expand Down
77 changes: 77 additions & 0 deletions base-action/src/utils/model-policy-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Matches the 403 errors returned by the Factory API when a request uses a
* model that the organization's model policy does not allow, including the
* explicit opt-in variant ("This model requires explicit organization
* opt-in by an admin.").
*/
const MODEL_POLICY_ERROR_PATTERNS = [
/not available due to your organization['’]s security settings/i,
/requires explicit organization opt-in/i,
];

export function isModelPolicyError(text: string | undefined | null): boolean {
if (!text) {
return false;
}
return MODEL_POLICY_ERROR_PATTERNS.some((pattern) => pattern.test(text));
}

/**
* Matches the fast client-side failure droid exec prints to stderr when the
* --model value is not a recognized model id.
*/
export function isInvalidModelError(text: string | undefined | null): boolean {
if (!text) {
return false;
}
return /Invalid model:/i.test(text);
}

/**
* An invalid --model value makes droid exec dump the full list of available
* models (twice, with one line per dump that is hundreds of characters
* wide). Condense that output down to the meaningful lines so it can be
* embedded in a PR comment without sideways scrolling.
*/
export function condenseInvalidModelError(text: string): string {
const kept: string[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") continue;
if (/^Available built-in models:$/i.test(trimmed)) continue;
if (/^No custom models configured/i.test(trimmed)) continue;
// Drop raw model-list dumps (long comma-separated lines)
if (trimmed.split(", ").length >= 5) continue;
if (kept[kept.length - 1] === trimmed) continue;
kept.push(trimmed);
}
const condensed = kept.join("\n");
return condensed || text;
}

/**
* Remove `--model <value>` and `--reasoning-effort <value>` (including
* `--flag=value` forms) from an argv array so droid exec falls back to the
* organization's default model.
*/
export function stripModelArgs(args: string[]): string[] {
const stripped: string[] = [];
let skipNext = false;

for (const arg of args) {
if (skipNext) {
skipNext = false;
continue;
}
if (arg === "--model" || arg === "--reasoning-effort") {
skipNext = true;
continue;
}
if (arg.startsWith("--model=") || arg.startsWith("--reasoning-effort=")) {
continue;
}
stripped.push(arg);
}

return stripped;
}
120 changes: 120 additions & 0 deletions base-action/test/model-policy-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from "bun:test";
import {
condenseInvalidModelError,
isInvalidModelError,
isModelPolicyError,
stripModelArgs,
} from "../src/utils/model-policy-error";

describe("isModelPolicyError", () => {
it("matches the model policy 403 message", () => {
expect(
isModelPolicyError(
`403 {"detail":"This model is not available due to your organization's security settings.","status":403}`,
),
).toBe(true);
});

it("matches with a curly apostrophe", () => {
expect(
isModelPolicyError(
"This model is not available due to your organization’s security settings.",
),
).toBe(true);
});

it("matches the explicit opt-in 403 message", () => {
expect(
isModelPolicyError(
"403 This model requires explicit organization opt-in by an admin.",
),
).toBe(true);
});

it("does not match unrelated errors", () => {
expect(isModelPolicyError("429 Too Many Requests")).toBe(false);
expect(isModelPolicyError(undefined)).toBe(false);
});
});

describe("isInvalidModelError", () => {
it("matches the CLI invalid-model stderr output", () => {
expect(isInvalidModelError("Invalid model: gpt-image-1")).toBe(true);
});

it("does not match unrelated output", () => {
expect(isInvalidModelError("500 Internal Server Error")).toBe(false);
expect(isInvalidModelError(undefined)).toBe(false);
});
});

describe("condenseInvalidModelError", () => {
it("drops the model-list dump and dedupes repeated lines", () => {
const dump = [
"claude-opus-5, claude-sonnet-5, gpt-5.4, gpt-5.2, kimi-k3, glm-5.2",
"",
"No custom models configured. Add them to ~/.factory/settings.json",
"Invalid model: gpt-image-1",
"",
"Available built-in models:",
" auto, claude-opus-5, claude-sonnet-5, gpt-5.4, gpt-5.2, kimi-k3",
"",
"No custom models configured. Add them to ~/.factory/settings.json",
"Invalid model: gpt-image-1",
].join("\n");

expect(condenseInvalidModelError(dump)).toBe("Invalid model: gpt-image-1");
});

it("returns the original text when nothing would remain", () => {
const listOnly = "a, b, c, d, e, f";
expect(condenseInvalidModelError(listOnly)).toBe(listOnly);
});
});

describe("stripModelArgs", () => {
it("removes --model and its value", () => {
expect(
stripModelArgs(["exec", "--model", "gpt-5.2", "-f", "prompt.txt"]),
).toEqual(["exec", "-f", "prompt.txt"]);
});

it("removes --reasoning-effort and its value", () => {
expect(
stripModelArgs(["exec", "--reasoning-effort", "high", "-f", "p.txt"]),
).toEqual(["exec", "-f", "p.txt"]);
});

it("removes --flag=value forms", () => {
expect(
stripModelArgs(["exec", "--model=gpt-5.2", "--reasoning-effort=high"]),
).toEqual(["exec"]);
});

it("removes both flags while preserving other args", () => {
expect(
stripModelArgs([
"exec",
"--output-format",
"stream-json",
"--model",
"kimi-k2.6",
"--reasoning-effort",
"high",
"--tag",
"code-review",
]),
).toEqual([
"exec",
"--output-format",
"stream-json",
"--tag",
"code-review",
]);
});

it("returns args unchanged when no model flags are present", () => {
const args = ["exec", "--output-format", "stream-json", "-f", "p.txt"];
expect(stripModelArgs(args)).toEqual(args);
});
});
8 changes: 4 additions & 4 deletions gitlab/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ your-project/
└── droid-review.yml # self-contained droid-review config
```

| File | Where it lives in your project | Purpose |
| ----------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `factory/droid-review.yml` | drop verbatim | Self-contained config: includes the remote Component, sets inputs, wires CI/CD variables. |
| `.gitlab-ci.yml` | append one `include:` line if the file exists | Project-root entry point. Just needs to include `factory/droid-review.yml`. |
| File | Where it lives in your project | Purpose |
| -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `factory/droid-review.yml` | drop verbatim | Self-contained config: includes the remote Component, sets inputs, wires CI/CD variables. |
| `.gitlab-ci.yml` | append one `include:` line if the file exists | Project-root entry point. Just needs to include `factory/droid-review.yml`. |

The two required CI/CD variables (`FACTORY_API_KEY`, `GITLAB_TOKEN`) are set
in the GitLab UI under **Project → Settings → CI/CD → Variables** (or at
Expand Down
5 changes: 5 additions & 0 deletions review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ runs:
bun run ${{ github.action_path }}/../src/entrypoints/generate-review-prompt.ts
env:
GITHUB_TOKEN: ${{ steps.token.outputs.github_token }}
FACTORY_API_KEY: ${{ inputs.factory_api_key }}
DROID_COMMENT_ID: ${{ inputs.tracking_comment_id }}
REVIEW_DEPTH: ${{ inputs.review_depth }}
REVIEW_MODEL: ${{ inputs.review_model }}
Expand Down Expand Up @@ -123,6 +124,7 @@ runs:
bun run ${{ github.action_path }}/../src/entrypoints/prepare-validator.ts
env:
GITHUB_TOKEN: ${{ steps.token.outputs.github_token }}
FACTORY_API_KEY: ${{ inputs.factory_api_key }}
REVIEW_VALIDATED_PATH: ${{ inputs.review_validated_path }}
REVIEW_CANDIDATES_PATH: ${{ inputs.review_candidates_path }}
REVIEW_DEPTH: ${{ inputs.review_depth }}
Expand Down Expand Up @@ -158,3 +160,6 @@ runs:
TRIGGER_USERNAME: ${{ github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: "true"
DROID_SUCCESS: ${{ steps.validator.outcome == 'success' }}
DROID_ERROR_MESSAGE: ${{ steps.review.outputs.error_message || '' }}
DROID_VALIDATOR_ERROR_MESSAGE: ${{ steps.validator.outputs.error_message || '' }}
MODEL_FALLBACK_NOTE: ${{ steps.prompt.outputs.model_fallback_note || steps.prepare_validator.outputs.model_fallback_note || steps.review.outputs.model_fallback_note || steps.validator.outputs.model_fallback_note || '' }}
Loading
Loading