Skip to content

Commit 6d07ef7

Browse files
authored
feat(reflexion): implement issue-driven cloud runner (#78)
Implements the WS-7 issue-driven cloud runner for the Reflexion loop. The runner allows developers to start and interact with the AI engineering loop entirely through GitHub issues and comments. - Adds `.github/workflows/reflexion-issue-runner.yml` to trigger on labeled issues, specific comments, and manual workflow dispatch. - Introduces `scripts/reflexion-issue-runner.js` to process START and RESUME paths without ever pushing code directly. - Handles artifact zip extraction using `adm-zip` and inputs using Zod schema validation (`js-yaml`). - Enforces a 10-turn cap per issue loop. - Uses `<!-- processed-comment-id:XXX -->` markers to ensure strict idempotency of webhook re-deliveries. - Adds `docs/reflexion-issue-runner.md` for workflow setup.
1 parent 5ae3ffb commit 6d07ef7

7 files changed

Lines changed: 723 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Reflexion Issue Runner
2+
3+
on:
4+
issues:
5+
types: [labeled]
6+
issue_comment:
7+
types: [created]
8+
workflow_dispatch:
9+
inputs:
10+
issue_number:
11+
description: 'Issue number to run against'
12+
required: true
13+
type: number
14+
15+
concurrency:
16+
group: reflexion-${{ github.event.issue.number || github.event.inputs.issue_number }}
17+
cancel-in-progress: false
18+
19+
permissions:
20+
issues: write
21+
actions: read
22+
contents: read
23+
24+
jobs:
25+
run-reflexion:
26+
runs-on: ubuntu-latest
27+
if: >-
28+
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'reflexion:run') ||
29+
(github.event_name == 'issue_comment' && github.event.action == 'created' && startsWith(github.event.comment.body, '/reflexion')) ||
30+
(github.event_name == 'workflow_dispatch')
31+
steps:
32+
- name: Check Secrets
33+
id: check_secrets
34+
run: |
35+
if [ -z "${{ secrets.GEMINI_API_KEY }}" ] || [ -z "${{ secrets.ANTHROPIC_API_KEY }}" ]; then
36+
echo "Missing required API keys in secrets."
37+
ISSUE_NUM="${{ github.event.issue.number || github.event.inputs.issue_number }}"
38+
if [ -n "$ISSUE_NUM" ]; then
39+
curl -s -X POST \
40+
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
41+
-H "Accept: application/vnd.github.v3+json" \
42+
https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUM/comments \
43+
-d '{"body":"### 🚨 Reflexion Loop Error\nMissing required `GEMINI_API_KEY` or `ANTHROPIC_API_KEY` in repository secrets."}'
44+
fi
45+
false
46+
fi
47+
48+
- name: Checkout Code
49+
uses: actions/checkout@v4
50+
51+
- name: Setup Node
52+
uses: actions/setup-node@v4
53+
with:
54+
node-version: '22'
55+
56+
- name: Install Dependencies
57+
run: npm ci
58+
59+
- name: Run Reflexion Loop
60+
env:
61+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
63+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
64+
REFLEXION_MAX_COST_USD: ${{ vars.REFLEXION_MAX_COST_USD || '3' }}
65+
REFLEXION_MAX_REVISIONS: ${{ vars.REFLEXION_MAX_REVISIONS || '3' }}
66+
run: |
67+
node scripts/reflexion-issue-runner.js
68+
69+
- name: Upload State Artifact
70+
uses: actions/upload-artifact@v4
71+
if: always()
72+
with:
73+
name: reflexion-state-${{ github.event.issue.number || github.event.inputs.issue_number }}
74+
path: .reflexion-out/*
75+
retention-days: 7
76+
if-no-files-found: ignore

docs/reflexion-issue-runner.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Reflexion Issue Runner
2+
3+
The Reflexion Issue Runner enables cloud-based scheduling of the AI engineering loop. It allows engineers to kick off and guide the iterative process of planning, critiquing, and refining purely through GitHub Issues—even from a mobile device.
4+
5+
## The Workflow ("Label at Night, Answer Over Coffee")
6+
7+
1. **Start the Loop:** Create an issue describing your goal. Apply the `reflexion:run` label.
8+
2. **Review Feedback:** The automated loop runs (generator & critic) and uploads an artifact with its findings, then leaves a comment on the issue. This comment includes the adjudicator verdict and interview questions.
9+
3. **Resume the Loop:** Over coffee, reply to the issue starting with `/reflexion answers`, and include your filled-in YAML block based on the provided answers template.
10+
4. **Final Approval:** Repeat until satisfied, then approve it to generate a final `ide-prompt.md`.
11+
12+
## Setup & Configuration
13+
14+
### Required Secrets
15+
16+
Ensure the repository has the following Action secrets configured:
17+
18+
- `GEMINI_API_KEY`: Used for generation (Google AI SDK).
19+
- `ANTHROPIC_API_KEY`: Used for the Critic & Adjudicator (Anthropic AI SDK).
20+
21+
### Environment Variables
22+
23+
Configure these repository environment variables (defaults are applied securely if unset):
24+
25+
- `REFLEXION_MAX_COST_USD` (Default: `3`)
26+
- `REFLEXION_MAX_REVISIONS` (Default: `3`)
27+
28+
## Security Model
29+
30+
- **No Push:** The runner is structurally prohibited from pushing code. The pipeline communicates via read-only state files and outputs solely as artifacts/comments.
31+
- **Isolated Artifacts:** Loop state and execution outputs are safely zipped into a workflow artifact named `reflexion-state-<issue#>`. The state uses standard GitHub Actions artifact retention window restrictions.
32+
- **Controlled I/O Boundaries:** LLMs only receive the parsed issue brief and YAML answers block as an isolated payload.
33+
- **Untrusted Input Processing:** Comments are defensive-parsed using deterministic regex boundary extraction avoiding direct LLM routing.
34+
35+
## Troubleshooting
36+
37+
- **Missing Keys:** If a run immediately fails with a diagnostic comment stating missing API keys, check GitHub Secrets.
38+
- **Cap Reached:** The system will log a comment terminating if max cost threshold or `10` comment loop cycles per issue turn is reached.
39+
- **Stuck State:** Due to concurrency boundaries per issue, parallel comments on the same issue will wait.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"easymde": "^2.20.0",
9191
"gray-matter": "^4.0.3",
9292
"html2canvas": "^1.4.1",
93+
"js-yaml": "^5.2.1",
9394
"langfuse-core": "^3.38.20",
9495
"langfuse-node": "^3.38.20",
9596
"lucide-react": "^0.477.0",

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const { extractRunIdMarker, extractProcessedCommentIdMarker, formatRunnerComment, extractYamlBlock, validateAnswers } = require('../reflexion-issue-runner-utils.js');
2+
3+
describe('reflexion-issue-runner-utils', () => {
4+
describe('extractRunIdMarker', () => {
5+
it('should extract a valid marker', () => {
6+
const body = "Some text\n<!-- reflexion-run:987654321 -->\nMore text";
7+
expect(extractRunIdMarker(body)).toBe("987654321");
8+
});
9+
});
10+
11+
describe('extractProcessedCommentIdMarker', () => {
12+
it('should extract a valid marker', () => {
13+
const body = "Some text\n<!-- processed-comment-id:444555 -->\nMore text";
14+
expect(extractProcessedCommentIdMarker(body)).toBe("444555");
15+
});
16+
});
17+
18+
describe('formatRunnerComment', () => {
19+
it('should format a diagnostic error message', () => {
20+
const result = formatRunnerComment({
21+
runId: "123",
22+
diagnostic: "Something went wrong"
23+
});
24+
expect(result).toContain('<!-- reflexion-run:123 -->');
25+
expect(result).toContain('### 🚨 Reflexion Loop Error');
26+
});
27+
28+
it('should include processed-comment-id marker', () => {
29+
const result = formatRunnerComment({
30+
runId: "123",
31+
triggeringCommentId: "888",
32+
diagnostic: "Something went wrong"
33+
});
34+
expect(result).toContain('<!-- processed-comment-id:888 -->');
35+
});
36+
});
37+
38+
describe('extractYamlBlock', () => {
39+
it('should extract a standard yaml block', () => {
40+
const body = "Here is the yaml:\n```yaml\nfoo: bar\n```";
41+
expect(extractYamlBlock(body).trim()).toBe("foo: bar");
42+
});
43+
});
44+
45+
describe('validateAnswers', () => {
46+
it('validates correct answers structure', () => {
47+
const valid = {
48+
runId: "987",
49+
decisions: [{ id: "q1", answer: "because" }]
50+
};
51+
const res = validateAnswers(valid);
52+
expect(res.success).toBe(true);
53+
});
54+
55+
it('validates directive only', () => {
56+
const valid = {
57+
runId: "987",
58+
directive: "approve"
59+
};
60+
const res = validateAnswers(valid);
61+
expect(res.success).toBe(true);
62+
});
63+
64+
it('fails on missing runId', () => {
65+
const invalid = {
66+
decisions: [{ id: "q1", answer: "because" }]
67+
};
68+
const res = validateAnswers(invalid);
69+
expect(res.success).toBe(false);
70+
});
71+
});
72+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const { z } = require('zod');
2+
3+
// We use the AnswersSchema definition structure from src/lib/ai/reflexion/schema.ts
4+
const AnswersSchema = z.object({
5+
runId: z.string(),
6+
decisions: z.array(z.object({ id: z.string(), answer: z.string() })).default([]),
7+
directive: z.enum(['approve', 'stop']).optional(),
8+
});
9+
10+
function extractRunIdMarker(body) {
11+
if (!body) return null;
12+
const match = body.match(/<!--\s*reflexion-run:(\d+)\s*-->/);
13+
return match ? match[1] : null;
14+
}
15+
16+
function extractProcessedCommentIdMarker(body) {
17+
if (!body) return null;
18+
const match = body.match(/<!--\s*processed-comment-id:(\d+)\s*-->/);
19+
return match ? match[1] : null;
20+
}
21+
22+
function formatRunnerComment(opts) {
23+
const parts = [];
24+
25+
parts.push(`<!-- reflexion-run:${opts.runId} -->`);
26+
if (opts.triggeringCommentId) {
27+
parts.push(`<!-- processed-comment-id:${opts.triggeringCommentId} -->`);
28+
}
29+
30+
if (opts.diagnostic) {
31+
parts.push('### 🚨 Reflexion Loop Error');
32+
parts.push(opts.diagnostic);
33+
return parts.join('\n\n');
34+
}
35+
36+
if (opts.idePrompt) {
37+
parts.push('### ✅ Reflexion Loop Approved');
38+
parts.push('The plan has been approved. The finalized prompt is below:');
39+
parts.push('<details><summary><b>ide-prompt.md</b></summary>');
40+
parts.push('');
41+
parts.push('```markdown');
42+
parts.push(opts.idePrompt);
43+
parts.push('```');
44+
parts.push('');
45+
parts.push('</details>');
46+
47+
if (opts.usageCost) {
48+
parts.push(`\n*${opts.usageCost}*`);
49+
}
50+
return parts.join('\n\n');
51+
}
52+
53+
parts.push('### 🤖 Reflexion Loop Status');
54+
55+
if (opts.scoreTable) {
56+
parts.push(opts.scoreTable);
57+
}
58+
59+
if (opts.adjudicatorVerdict) {
60+
parts.push(`**Verdict:** ${opts.adjudicatorVerdict}`);
61+
}
62+
63+
if (opts.usageCost) {
64+
parts.push(`*${opts.usageCost}*`);
65+
}
66+
67+
if (opts.interviewQuestions) {
68+
parts.push('#### Questions');
69+
parts.push(opts.interviewQuestions);
70+
}
71+
72+
if (opts.answersTemplate) {
73+
parts.push('#### Your Turn: Reply to this issue');
74+
parts.push('Copy and edit the YAML block below, and reply with it starting your comment with `/reflexion answers`:');
75+
parts.push('');
76+
parts.push(opts.answersTemplate);
77+
}
78+
79+
return parts.join('\n\n');
80+
}
81+
82+
function extractYamlBlock(text) {
83+
if (!text) return null;
84+
const match = text.match(/```yaml(?: answers:)?\s*([\s\S]*?)```/);
85+
if (match) return match[1];
86+
87+
const match2 = text.match(/```answers:\s*([\s\S]*?)```/);
88+
if (match2) return match2[1];
89+
90+
return null;
91+
}
92+
93+
function validateAnswers(yamlObj) {
94+
// We expect an object parseable by AnswersSchema
95+
return AnswersSchema.safeParse(yamlObj);
96+
}
97+
98+
module.exports = {
99+
extractRunIdMarker,
100+
extractProcessedCommentIdMarker,
101+
formatRunnerComment,
102+
extractYamlBlock,
103+
validateAnswers
104+
};

0 commit comments

Comments
 (0)