Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
adcb563
feat: add copilot CLI steering hooks for time and run budgets
Copilot May 6, 2026
8d19d75
chore: polish copilot steering hook integration
Copilot May 6, 2026
506da7b
chore: refine copilot steering hook logging
Copilot May 6, 2026
f8bdd5d
chore: clarify steering hook config internals
Copilot May 6, 2026
c8dcc9b
chore: harden steering state path uniqueness
Copilot May 6, 2026
5a0f35f
chore: improve steering hook robustness and tests
Copilot May 6, 2026
044efbf
chore: organize steering state files per run
Copilot May 6, 2026
8699e63
fix: harden copilot steering state load and save
Copilot May 6, 2026
9a583dd
Add changeset
github-actions[bot] May 6, 2026
ef60b87
fix: resolve js typecheck for steering state guard
Copilot May 6, 2026
62cc12e
fix: clean up steering hook jsdoc typing
Copilot May 6, 2026
8c6cbdf
fix: align copilot steering hooks with documented lifecycle events
Copilot May 6, 2026
9f2fd5a
fix: normalize sessionEnd steering state return
Copilot May 6, 2026
378cd5b
fix: persist steering hook load diagnostics in stderr and artifacts
Copilot May 6, 2026
557e0e5
fix: align steering hook load check fallback and test cleanup
Copilot May 6, 2026
23aa210
fix: align steering hook default artifact log path
Copilot May 6, 2026
4c62720
test: cover custom steering hook log path and tidy diagnostics
Copilot May 6, 2026
55b570d
Merge branch 'main' into copilot/update-copilot-harness-js
pelikhan May 6, 2026
fd8060a
fix: enable repo hooks in copilot prompt mode
Copilot May 6, 2026
cf52407
Merge remote-tracking branch 'origin/main' into copilot/update-copilo…
Copilot May 7, 2026
3597233
fix: enable prompt-mode copilot extensions in harness
Copilot May 7, 2026
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
126 changes: 126 additions & 0 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const MAX_SCHEDULED_EXIT2_RETRIES = 1;
// If prompt files are larger than this threshold, avoid inlining into argv.

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.

Me like new constants! DEFAULT_MAX_AUTOPILOT_RUNS = 1 make sense as safe default. Clear naming. Me approve!

const PROMPT_FILE_INLINE_THRESHOLD_BYTES = 100 * 1024;
const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB";

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.

Oog! Me see DEFAULT_MAX_AUTOPILOT_RUNS = 1. Maybe add comment explain why 1 is good default? Cave brain want know.

const STEERING_HOOK_CONFIG_FILENAME = "gh-aw-steering.json";
const DEFAULT_STEERING_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json";

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.

💡 Consider using a more descriptive constant name, e.g., STEERING_HOOK_CONFIG_FILE for clarity. Also, moving these constants near the top with other configuration constants could improve readability.

const DEFAULT_MAX_AUTOPILOT_RUNS = 1;

// Pattern to detect transient CAPIError 400 in copilot output
const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/;
Expand Down Expand Up @@ -295,6 +298,123 @@ function resolvePromptFileArgs(args) {
return resolvedArgs;

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.

Me see steering hook install. Good idea! Hook run before agent start. But me wonder: what happen if hook script path wrong? Error logged but agent still run — maybe worth adding metrics counter here too.

}

/**
* Parse --max-autopilot-continues from copilot CLI args.

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.

Ugh! parseMaxAutopilotContinues good function. Me think return 0 when not found - maybe return DEFAULT_MAX_AUTOPILOT_RUNS instead for consistent behavior? Just caveman thought.

* @param {string[]} args
* @returns {number}
*/
function parseMaxAutopilotContinues(args) {
const index = args.indexOf("--max-autopilot-continues");
if (index < 0 || index + 1 >= args.length) {
return 0;
}
const parsed = parseInt(args[index + 1], 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}

/**
* Compute the maximum number of autonomous runs (initial run + autopilot continuations).
* @param {string[]} args
* @returns {number}
*/
function computeMaxAutopilotRuns(args) {
if (!args.includes("--autopilot")) {
return DEFAULT_MAX_AUTOPILOT_RUNS;
}
const maxContinues = parseMaxAutopilotContinues(args);
if (maxContinues <= 0) {
return DEFAULT_MAX_AUTOPILOT_RUNS;
}
return maxContinues + 1;
}

/**
* Build Copilot CLI hook config for gh-aw steering messages.
* @param {string} hookScriptPath
* @param {string} nodeExecPath
* @returns {{ version: number, hooks: Record<string, Array<{ type: string, bash: string, timeoutSec: number }>> }}
*/
function buildSteeringHookConfig(hookScriptPath, nodeExecPath) {
// JSON-encode paths so they are safely quoted when embedded into bash hook command strings.
const quotedNodePath = JSON.stringify(nodeExecPath);
const quotedHookScriptPath = JSON.stringify(hookScriptPath);
return {
version: 1,
hooks: {
sessionStart: [
{
type: "command",
bash: `${quotedNodePath} ${quotedHookScriptPath} sessionStart`,
timeoutSec: 10,
},
],
agentStop: [
{
type: "command",
bash: `${quotedNodePath} ${quotedHookScriptPath} agentStop`,
timeoutSec: 10,
},
],
},
};
}

/**
* Install Copilot CLI steering hooks in the workspace and export hook env vars.
* @param {string[]} resolvedArgs
*/
function installCopilotSteeringHooks(resolvedArgs) {
try {
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const hooksDir = path.join(workspace, ".github", "hooks");
const hookConfigPath = path.join(hooksDir, STEERING_HOOK_CONFIG_FILENAME);
const hookScriptPath = path.join(__dirname, "copilot_steering_hook.cjs");

if (!fs.existsSync(hookScriptPath)) {
log(`warning: steering hook script missing at ${hookScriptPath}; this may indicate setup action copy/deploy drift, so Copilot steering hooks will be skipped`);
return;
}

// Include run ID, PID, and timestamp to avoid collisions across concurrent/serial runner jobs.
// This installer runs once per harness process, so exactly one state path is expected per run.
const runID = process.env.GITHUB_RUN_ID || "local";
const stateDir = path.join(path.dirname(DEFAULT_STEERING_STATE_PATH), "steering-hooks");
fs.mkdirSync(stateDir, { recursive: true });
const processStatePath = path.join(stateDir, `copilot-steering-${runID}-${process.pid}-${Date.now()}.json`);
process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath;
process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs));
process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30";
process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = process.env.GH_AW_STEERING_TIME_WARNING_MINUTES || "5";
process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES || "2";
process.env.GH_AW_STEERING_RUN_WARNING_REMAINING = process.env.GH_AW_STEERING_RUN_WARNING_REMAINING || "2";
process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING = process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING || "1";

fs.mkdirSync(hooksDir, { recursive: true });
const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath);
fs.writeFileSync(hookConfigPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8");
log(`installed steering hook config: ${hookConfigPath}`);
} catch (error) {
const err = /** @type {Error} */ error;
log(`warning: failed to install steering hook config: ${err.message}`);
}
}

function cleanupCopilotSteeringState() {
const statePath = process.env.GH_AW_COPILOT_STEERING_STATE_PATH || "";
if (!statePath) {
return;
}
try {
if (fs.existsSync(statePath)) {
fs.unlinkSync(statePath);
log(`removed steering hook state file: ${statePath}`);
}
} catch (error) {
const err = /** @type {Error} */ error;
log(`warning: failed to remove steering hook state file ${statePath}: ${err.message}`);
}
}

/**
* Main entry point: run copilot with retry logic for partially-executed sessions.
*/
Expand All @@ -310,6 +430,7 @@ async function main() {

await checkCommandAccessible(command);
const resolvedArgs = resolvePromptFileArgs(args);
installCopilotSteeringHooks(resolvedArgs);

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.

Smoke test review comment #2 🔍

installCopilotSteeringHooks(resolvedArgs) is called right after resolvePromptFileArgs, which is a good placement — hooks are installed before the agent run starts. The function wraps failures in try/catch and logs warnings rather than failing the whole harness, which is appropriate for a non-critical enhancement.

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.

Oog! Me caveman. Me agree with this observation. Good catch!

Warning

Firewall blocked 6 domains

The following domains were blocked by the firewall during workflow execution:

  • accounts.google.com
  • android.clients.google.com
  • clients2.google.com
  • contentautofill.googleapis.com
  • safebrowsingohttpgateway.googleapis.com
  • www.google.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "accounts.google.com"
    - "android.clients.google.com"
    - "clients2.google.com"
    - "contentautofill.googleapis.com"
    - "safebrowsingohttpgateway.googleapis.com"
    - "www.google.com"

See Network Configuration for more information.

📰 BREAKING: Report filed by Smoke Copilot · ● 17.4M

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.

Smoke test review comment #2 🔬

installCopilotSteeringHooks(resolvedArgs) is called after resolvePromptFileArgs, which is the right placement — hooks are installed before the agent run. The try/catch approach that logs warnings rather than failing is appropriate for a non-critical enhancement. ✅


// Fetch AWF API proxy reflection data before running the agent to capture initial proxy state.
// This is best-effort: failures are logged but do not affect the agent run.
Expand Down Expand Up @@ -459,6 +580,7 @@ async function main() {
// This is best-effort: failures are logged but do not affect the agent exit code.
await fetchAWFReflect({ logger: log });

cleanupCopilotSteeringState();
log(`done: exitCode=${lastExitCode} totalDuration=${formatDuration(Date.now() - driverStartTime)}`);
process.exit(lastExitCode);
}
Expand All @@ -479,13 +601,17 @@ if (typeof module !== "undefined" && module.exports) {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
buildSteeringHookConfig,
computeMaxAutopilotRuns,
resolvePromptFileArgs,
parseMaxAutopilotContinues,
};
}

if (require.main === module) {
main().catch(err => {
log(`unexpected error: ${err.message}`);
cleanupCopilotSteeringState();
process.exit(1);
});
}
38 changes: 38 additions & 0 deletions actions/setup/js/copilot_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import path from "path";
const require = createRequire(import.meta.url);
const {
appendSafeOutputLine,
buildSteeringHookConfig,
buildInfrastructureIncompletePayload,
buildPromptFileFallbackInstruction,
computeMaxAutopilotRuns,
emitInfrastructureIncomplete,
enrichReflectModels,
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
GEMINI_MODEL_NAME_PREFIX,
parseMaxAutopilotContinues,
PROMPT_FILE_INLINE_THRESHOLD_BYTES,
resolvePromptFileArgs,
} = require("./copilot_harness.cjs");
Expand Down Expand Up @@ -630,6 +633,41 @@ describe("copilot_harness.cjs", () => {
});
});

describe("steering hook setup helpers", () => {
it("parses --max-autopilot-continues when present", () => {
const value = parseMaxAutopilotContinues(["--autopilot", "--max-autopilot-continues", "7"]);
expect(value).toBe(7);
});

it("returns zero when --max-autopilot-continues is missing or invalid", () => {
expect(parseMaxAutopilotContinues(["--autopilot"])).toBe(0);
expect(parseMaxAutopilotContinues(["--max-autopilot-continues", "invalid"])).toBe(0);
});

it("computes max autopilot runs as initial run plus continuations", () => {
expect(computeMaxAutopilotRuns(["--autopilot", "--max-autopilot-continues", "3"])).toBe(4);
});

it("falls back to single-run budget when autopilot is disabled", () => {
expect(computeMaxAutopilotRuns(["--add-dir", "/tmp"])).toBe(1);
});

it("builds hook config with sessionStart and agentStop command hooks", () => {
const config = buildSteeringHookConfig("/tmp/gh-aw/actions/copilot_steering_hook.cjs", "/usr/bin/node");
expect(config.version).toBe(1);
expect(config.hooks.sessionStart).toHaveLength(1);
expect(config.hooks.agentStop).toHaveLength(1);
expect(config.hooks.sessionStart[0].bash).toContain("sessionStart");
expect(config.hooks.agentStop[0].bash).toContain("agentStop");
});

it("quotes node and hook script paths in hook commands", () => {
const config = buildSteeringHookConfig("/tmp/with space/copilot_steering_hook.cjs", "/opt/tools/node with space");
expect(config.hooks.sessionStart[0].bash).toContain('"/opt/tools/node with space"');
expect(config.hooks.sessionStart[0].bash).toContain('"/tmp/with space/copilot_steering_hook.cjs"');
});
});

describe("formatDuration", () => {
// Inline the same logic as the driver's formatDuration for unit testing
function formatDuration(ms) {
Expand Down
Loading
Loading