Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .github/agents/registry.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: 1

default_agent: codex

agents:
codex:
runner_workflow: .github/workflows/reusable-codex-run.yml
required_secrets:
- CODEX_AUTH_JSON
branch_prefix: codex/issue-
ui_mentions_allowed: false
capabilities:
pr_keepalive: true
pr_autofix: true
belt: true
verifier_checkbox: true
216 changes: 216 additions & 0 deletions .github/scripts/agent_registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
'use strict';

const fs = require('node:fs');

function stripTrailingComment(rawLine) {
const line = String(rawLine ?? '');
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
return '';
}

// Keep this intentionally simple: our registry YAML should not rely on inline comments.
const match = line.match(/^(.*?)(\s+#.*)?$/);
return (match?.[1] ?? line).replace(/\s+$/, '');
}

function parseScalar(value) {
const raw = String(value ?? '').trim();
if (!raw) {
return '';
}

if (raw === 'true') {
return true;
}
if (raw === 'false') {
return false;
}

if (/^-?\d+$/.test(raw)) {
return Number(raw);
}

const quoted = raw.match(/^(['"])(.*)\1$/);
if (quoted) {
return quoted[2];
}

return raw;
}

function countIndent(line) {
// Match all leading horizontal whitespace (spaces and tabs).
const match = String(line).match(/^([ \t]*)/);
const indentPrefix = match?.[1] ?? '';
if (indentPrefix.includes('\t')) {
throw new Error('Registry YAML must use spaces only (tabs are not allowed)');
}
if (indentPrefix.length % 2 !== 0) {
throw new Error(
`Registry YAML indentation must be multiples of 2 spaces (got ${indentPrefix.length})`,
);
}
return indentPrefix.length;
}

function findNextMeaningfulLine(lines, startIndex) {
for (let index = startIndex; index < lines.length; index += 1) {
const stripped = stripTrailingComment(lines[index]);
if (!stripped.trim()) {
continue;
}
return {
index,
indent: countIndent(stripped),
trimmed: stripped.trim(),
};
}
return null;
}

// Minimal YAML parser for the registry file.
// Supported features:
// - nested mappings via indentation (2 spaces)
// - scalar values (strings, booleans, integers)
// - sequences using "- item" lines (scalar items only)
// Unsupported (intentionally): anchors, multiline strings, flow maps, complex quoting.
function parseRegistryYaml(text) {
const rawLines = String(text ?? '').split(/\r?\n/);
const lines = rawLines.map(stripTrailingComment);

const root = {};
const stack = [{ indent: -1, container: root }];

for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const rawLine = lines[lineIndex];
if (!rawLine.trim()) {
continue;
}

const indent = countIndent(rawLine);
const trimmed = rawLine.trim();

while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
stack.pop();
}

const parent = stack[stack.length - 1].container;

if (trimmed.startsWith('- ')) {
if (!Array.isArray(parent)) {
throw new Error(`Unexpected list item at line ${lineIndex + 1}; parent is not a list`);
}
parent.push(parseScalar(trimmed.slice(2)));
continue;
}

const sepIndex = trimmed.indexOf(':');
if (sepIndex <= 0) {
throw new Error(`Invalid registry YAML line ${lineIndex + 1}: expected "key: value"`);
}

const key = trimmed.slice(0, sepIndex).trim();
const rest = trimmed.slice(sepIndex + 1).trim();

if (!key) {
throw new Error(`Invalid registry YAML line ${lineIndex + 1}: empty key`);
}
if (typeof parent !== 'object' || parent === null || Array.isArray(parent)) {
throw new Error(`Invalid registry YAML line ${lineIndex + 1}: cannot assign key under a list`);
}

if (rest) {
parent[key] = parseScalar(rest);
continue;
}

const next = findNextMeaningfulLine(lines, lineIndex + 1);
const shouldBeList = Boolean(next && next.indent > indent && next.trimmed.startsWith('- '));
const child = shouldBeList ? [] : {};
parent[key] = child;
stack.push({ indent, container: child });
}

return root;
}

function loadAgentRegistry({ registryPath } = {}) {
const path = registryPath || '.github/agents/registry.yml';
const raw = fs.readFileSync(path, 'utf8');
const registry = parseRegistryYaml(raw);
if (!registry || typeof registry !== 'object') {
throw new Error('Agent registry did not parse into an object');
}
if (!registry.agents || typeof registry.agents !== 'object') {
throw new Error('Agent registry missing required "agents" mapping');
}
if (!registry.default_agent || typeof registry.default_agent !== 'string') {
throw new Error('Agent registry missing required "default_agent" string');
}
return registry;
}

function normalizeLabel(label) {
if (!label) {
return '';
}
if (typeof label === 'string') {
return label.trim().toLowerCase();
}
if (typeof label === 'object' && typeof label.name === 'string') {
return label.name.trim().toLowerCase();
}
return '';
}

function resolveAgentFromLabels(labels, { registryPath } = {}) {
const registry = loadAgentRegistry({ registryPath });
const labelList = Array.isArray(labels) ? labels : [];
const agentLabels = labelList
.map(normalizeLabel)
.filter(Boolean)
.filter((value) => value.startsWith('agent:'));

const uniqueAgents = new Set(agentLabels.map((value) => value.slice('agent:'.length)));

if (uniqueAgents.size > 1) {
throw new Error(`Multiple agent labels present: ${Array.from(uniqueAgents).join(', ')}`);
}

const explicit = Array.from(uniqueAgents)[0];
const agentKey = explicit || registry.default_agent;
if (!registry.agents[agentKey]) {
const known = Object.keys(registry.agents).sort();
throw new Error(`Unknown agent key: ${agentKey}. Known agents: ${known.join(', ') || '(none)'}`);
}
return agentKey;
}

function getAgentConfig(agentKey, { registryPath } = {}) {
const registry = loadAgentRegistry({ registryPath });
const key = String(agentKey || '').trim() || registry.default_agent;
const config = registry.agents[key];
if (!config) {
const known = Object.keys(registry.agents).sort();
throw new Error(`Unknown agent key: ${key}. Known agents: ${known.join(', ') || '(none)'}`);
}
return config;
}

function getRunnerWorkflow(agentKey, { registryPath } = {}) {
const config = getAgentConfig(agentKey, { registryPath });
const workflow = String(config.runner_workflow || '').trim();
if (!workflow) {
throw new Error(`Agent config missing runner_workflow for agent: ${agentKey}`);
}
return workflow;
}

module.exports = {
getAgentConfig,
getRunnerWorkflow,
loadAgentRegistry,
parseRegistryYaml,
resolveAgentFromLabels,
};
24 changes: 18 additions & 6 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -1870,12 +1870,24 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload
let gateRateLimit = false;

const config = parseConfig(pr.body || '');
const labels = Array.isArray(pr.labels) ? pr.labels.map((label) => normalise(label.name).toLowerCase()) : [];

// Extract agent type from agent:* labels (supports agent:codex, agent:claude, etc.)
const agentLabel = labels.find((label) => label.startsWith('agent:'));
const agentType = agentLabel ? agentLabel.replace('agent:', '') : '';
const hasAgentLabel = Boolean(agentType);
const labels = Array.isArray(pr.labels)
? pr.labels.map((label) => normalise(label.name).toLowerCase())
: [];

// Phase 2: Resolve agent via registry helper when an explicit agent:* label is present.
// Keepalive stays opt-in: no agent label => keepalive disabled.
const explicitAgentLabel = labels.find((label) => label.startsWith('agent:'));
let agentType = '';
let hasAgentLabel = false;
if (explicitAgentLabel) {
hasAgentLabel = true;
try {
const { resolveAgentFromLabels } = require('./agent_registry.js');
agentType = resolveAgentFromLabels(pr.labels);
} catch (error) {
agentType = explicitAgentLabel.replace('agent:', '');
}
}
const hasHighPrivilege = labels.includes('agent-high-privilege');
const keepaliveEnabled = config.keepalive_enabled && hasAgentLabel;

Expand Down
40 changes: 36 additions & 4 deletions .github/workflows/agents-71-codex-belt-dispatcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ name: Agents 71 Codex Belt Dispatcher
on:
workflow_call:
inputs:
agent_key:
description: 'Agent key to dispatch (default: codex)'
required: false
default: 'codex'
type: string
force_issue:
description: 'Optional issue number to dispatch immediately'
required: false
Expand All @@ -23,6 +28,9 @@ on:
WORKFLOWS_APP_PRIVATE_KEY:
required: false
outputs:
agent_key:
description: 'Agent key used for dispatch'
value: ${{ jobs.dispatch.outputs.agent_key }}
issue:
description: 'Issue selected for dispatch'
value: ${{ jobs.dispatch.outputs.issue }}
Expand All @@ -40,6 +48,11 @@ on:
value: ${{ jobs.dispatch.outputs.dry_run }}
workflow_dispatch:
inputs:
agent_key:
description: 'Agent key to dispatch (default: codex)'
required: false
default: 'codex'
type: string
force_issue:
description: 'Optional issue number to dispatch immediately'
required: false
Expand All @@ -65,6 +78,7 @@ jobs:
name: Select next Codex issue
runs-on: ubuntu-latest
outputs:
agent_key: ${{ steps.pick.outputs.agent_key || '' }}
issue: ${{ steps.pick.outputs.issue || '' }}
branch: ${{ steps.pick.outputs.branch || '' }}
base: ${{ steps.pick.outputs.base || '' }}
Expand Down Expand Up @@ -190,6 +204,8 @@ jobs:
ref: ${{ steps.workflows_ref.outputs.ref }}
sparse-checkout: |
.github/actions/setup-api-client
.github/agents/registry.yml
.github/scripts/agent_registry.js
.github/scripts/github-api-with-retry.js
.github/scripts/token_load_balancer.js
sparse-checkout-cone-mode: false
Expand All @@ -207,10 +223,11 @@ jobs:
task: 'codex-belt-dispatcher-pick',
});
const forced = '${{ inputs.force_issue }}';
const agentKey = String('${{ inputs.agent_key }}' || 'codex').trim().toLowerCase() || 'codex';
const { owner, repo } = context.repo;

const summary = core.summary;
summary.addHeading('Codex Belt Dispatcher');
summary.addHeading(`Belt Dispatcher (agent: ${agentKey})`);

let issueNumber = null;
let reason = '';
Expand All @@ -225,7 +242,7 @@ jobs:
owner,
repo,
state: 'open',
labels: 'agent:codex,status:ready',
labels: `agent:${agentKey},status:ready`,
sort: 'created',
direction: 'asc',
per_page: 30,
Expand All @@ -238,7 +255,11 @@ jobs:
}

if (!issueNumber) {
summary.addRaw('No open issues with labels `agent:codex` and `status:ready` were found.').write();
summary
.addRaw(
`No open issues with labels \`agent:${agentKey}\` and \`status:ready\` were found.`
)
.write();
core.setOutput('issue', '');
core.setOutput('reason', 'empty');
return;
Expand All @@ -261,8 +282,19 @@ jobs:
core.setFailed('Repository default branch not available');
return;
}
const branch = `codex/issue-${issueNumber}`;

let branchPrefix = 'codex/issue-';
try {
const { getAgentConfig } = require('./.github/scripts/agent_registry.js');
const cfg = getAgentConfig(agentKey);
branchPrefix = String(cfg.branch_prefix || branchPrefix);
} catch (error) {
core.warning(`Could not load agent registry; defaulting branch prefix: ${error.message}`);
}

const branch = `${branchPrefix}${issueNumber}`;

core.setOutput('agent_key', agentKey);
core.setOutput('issue', String(issueNumber));
core.setOutput('branch', branch);
core.setOutput('base', base);
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/agents-72-codex-belt-worker-dispatch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ name: Agents 72 Codex Belt Worker Dispatch
on:
workflow_dispatch:
inputs:
agent_key:
description: 'Agent key for this belt run (default: codex)'
required: false
default: 'codex'
type: string
issue:
description: 'Issue number'
required: true
Expand Down Expand Up @@ -54,6 +59,7 @@ jobs:
name: Run Codex belt worker
uses: ./.github/workflows/agents-72-codex-belt-worker.yml
with:
agent_key: ${{ inputs.agent_key }}
issue: ${{ inputs.issue }}
branch: ${{ inputs.branch }}
base: ${{ inputs.base }}
Expand Down
Loading
Loading