Skip to content

Commit aba344d

Browse files
feat(loop): evolution, critique, OAuth auth, and documentation (#14)
* feat(loop): integrate evolution, memory, and mid-loop critique into loop ticks Loop ticks now use Phantom's full intelligence stack instead of running blind: Phase 1 - Memory context injection: cached once at loop start from the goal, injected into every tick prompt via TickPromptOptions. Cleared on finalize, rebuilt on resume. Phase 2 - Post-loop evolution and consolidation: bounded transcript accumulation (first tick + rolling 10 summaries + last tick), SessionData synthesis in finalize(), fire-and-forget evolution pipeline and LLM/heuristic memory consolidation with cost-cap guards matching the interactive path. Phase 3 - Mid-loop critique checkpoints: optional checkpoint_interval param lets the agent request Sonnet 4.6 review every N ticks. Guard requires evolution enabled, LLM judges active, and cost cap not exceeded. Critique is awaited before next tick to avoid race conditions. Closes #8 * fix(loop): address code review findings from PR #9 - Decouple postLoopDeps so evolution and memory run independently (evolution works when memory is down and vice versa) - Skip mid-loop critique on terminal ticks to avoid wasted Sonnet calls - Track judge cost on failure paths via JudgeParseError carrying usage data - Extract recordTranscript/clamp from runner.ts to post-loop.ts (292 < 300 lines) * fix(evolution): support OAuth tokens for LLM judge auth resolveJudgeMode() and judge client now check ANTHROPIC_AUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN in addition to ANTHROPIC_API_KEY. Enables LLM judges on Max subscription deployments using OAuth bearer tokens. * docs: add phantom_loop documentation for upstream PR Covers MCP tool parameters, state file contract, tick lifecycle, Slack integration, mid-loop critique, post-loop evolution pipeline, memory context injection, and tips for writing effective goals. Closes #12 * fix(test): stabilize trigger-auth and judge-activation tests for CI trigger-auth: use inline Bun.serve instead of startServer to avoid module-level globals and disk I/O that can race across test files. judge-activation: save/restore ANTHROPIC_AUTH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN alongside ANTHROPIC_API_KEY so tests that expect "no credentials" actually clear all auth env vars. --------- Co-authored-by: electronicBlacksmith <electronicBlacksmith@users.noreply.github.com>
1 parent 69c05ee commit aba344d

21 files changed

Lines changed: 1213 additions & 166 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,4 @@ Production deployments are managed internally. Do NOT modify production deployme
222222
- [Self-Evolution](docs/self-evolution.md) - The 6-step reflection pipeline
223223
- [Security](docs/security.md) - Auth, secrets, permissions, and hardening
224224
- [Roles](docs/roles.md) - Customizing the agent's specialization
225+
- [Loop](docs/loop.md) - Autonomous iteration primitive (phantom_loop)

docs/loop.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Loop
2+
3+
Phantom loop is an autonomous iteration primitive. The agent runs repeatedly against a goal, each tick in a fresh SDK session, with a markdown state file as the only contract between ticks. Budgets, mid-loop critique, Slack feedback, and post-loop learning are all built in.
4+
5+
## Overview
6+
7+
Regular sessions are conversational: the operator sends a message, the agent responds, back and forth. Loops are different. The operator defines a goal and a budget, then walks away. The runner drives ticks automatically until the goal is met or a budget is hit.
8+
9+
Use loops for long-horizon tasks where the agent should grind autonomously:
10+
- "Keep refactoring until tests pass"
11+
- "Iterate on this design doc until the reviewer approves"
12+
- "Bisect this regression across the last 50 commits"
13+
14+
## MCP Tool
15+
16+
The `phantom_loop` tool exposes four actions: `start`, `status`, `stop`, `list`.
17+
18+
### Start Parameters
19+
20+
| Parameter | Default | Ceiling | Description |
21+
|-----------|---------|---------|-------------|
22+
| `goal` (required) | - | 10,000 chars | What the loop should achieve |
23+
| `workspace` | `data/loops/<id>/` | - | Working directory for the agent |
24+
| `max_iterations` | 20 | 200 | Maximum ticks before budget termination |
25+
| `max_cost_usd` | 5 | 50 | Maximum total cost before budget termination |
26+
| `checkpoint_interval` | off | 200 | Run a Sonnet critique every N ticks (0 = disabled) |
27+
| `success_command` | off | - | Shell command run after each tick; exit 0 = done |
28+
| `channel_id` | auto | - | Slack channel for status updates |
29+
| `conversation_id` | auto | - | Slack thread for threading updates |
30+
| `trigger_message_ts` | auto | - | Slack message timestamp for reaction ladder |
31+
32+
When started from Slack, `channel_id`, `conversation_id`, and `trigger_message_ts` are auto-filled from the originating message context. Explicit tool arguments always take precedence.
33+
34+
### Other Actions
35+
36+
- **status**: Returns the loop row, parsed state file frontmatter, and the first 40 lines of the state file.
37+
- **stop**: Sets an interrupt flag. The loop stops gracefully before the next tick.
38+
- **list**: Returns active loops. Pass `include_finished: true` for recent history.
39+
40+
## State File
41+
42+
The state file (`state.md` in the workspace) is the loop's memory across ticks. It has YAML frontmatter that the runner inspects for control flow, and a markdown body that belongs entirely to the agent.
43+
44+
### Frontmatter
45+
46+
```yaml
47+
---
48+
loop_id: <uuid>
49+
status: in-progress # in-progress | done | blocked
50+
iteration: 3
51+
---
52+
```
53+
54+
The runner acts on `done` (finalize immediately) and `blocked` (continue, but the agent should explain in Notes). Everything else is treated as `in-progress`.
55+
56+
### Body Sections
57+
58+
```markdown
59+
# Goal
60+
Keep refactoring src/auth until all 47 tests pass.
61+
62+
# Progress
63+
- Tick 1: Fixed the missing import in auth/middleware.ts
64+
- Tick 2: Updated the session type to include refreshToken
65+
- Tick 3: Fixed the mock in auth.test.ts, 44/47 tests passing
66+
67+
# Next Action
68+
The remaining 3 failures are all in auth/oauth.test.ts. Read the test file,
69+
identify the common cause, and fix it.
70+
71+
# Notes
72+
(empty)
73+
```
74+
75+
The agent reads Progress and Next Action at the start of each tick to understand what happened before and what to do now. The runner does not parse the body, only the frontmatter.
76+
77+
## Tick Lifecycle
78+
79+
Each tick follows a fixed sequence:
80+
81+
1. **Lock** - acquire in-flight guard (prevents concurrent ticks on the same loop)
82+
2. **Pre-checks** - verify loop is still "running"; check interrupt flag; enforce budget limits
83+
3. **Read state** - load the current state file from disk
84+
4. **Build prompt** - assemble the tick prompt with: goal, state file contents, budget info, optional memory context, optional critique feedback
85+
5. **Fresh session** - call `runtime.handleMessage()` with a rotating conversation ID (`{loopId}:{iteration}`)
86+
6. **Agent works** - executes tools, makes progress, writes updated state file
87+
7. **Record cost** - increment iteration count and accumulate cost from the SDK response
88+
8. **Parse frontmatter** - re-read the state file; if the agent declared `done`, finalize immediately (steps 9-11 are skipped)
89+
9. **Success command** - if configured, run the shell command (5-minute timeout, sanitized env with only PATH, HOME, LANG, TERM, TOOL_INPUT where TOOL_INPUT is a JSON string containing loop_id and workspace)
90+
10. **Critique checkpoint** - if `checkpoint_interval` is set and the current tick is a multiple, run a Sonnet critique (see below)
91+
11. **Slack update** - post tick progress to the status message
92+
12. **Schedule next** - queue the next tick via `setImmediate`
93+
94+
## Slack Integration
95+
96+
When a loop is started from Slack (or with explicit `channel_id`), the `LoopNotifier` provides real-time feedback:
97+
98+
**Start notice** - posted to the channel/thread with the goal excerpt and budget:
99+
```
100+
:repeat: Starting loop `abcdef01` (max 20 iter, $5.00 budget)
101+
> Keep refactoring src/auth until all 47 tests pass
102+
```
103+
Includes a Stop button routed through Slack interactive actions.
104+
105+
**Tick updates** - the same message is edited on each tick with a progress bar:
106+
```
107+
:repeat: Loop `abcdef01` · [████░░░░░░] 4/10 · $1.20/$5.00 · in-progress
108+
```
109+
The Stop button survives across edits (blocks are re-sent on every `chat.update`).
110+
111+
**Reaction ladder** on the operator's original message:
112+
- Start: hourglass
113+
- First tick: swap to cycling arrows
114+
- Terminal: checkmark (done), stop sign (stopped), warning (budget exceeded), X (failed)
115+
116+
**Final notice** - progress bar with terminal emoji, and the state file body posted as a threaded code block so the operator can see the full progress log.
117+
118+
## Mid-Loop Critique
119+
120+
When `checkpoint_interval` is set, Sonnet 4.6 reviews the loop's progress every N ticks. This catches drift, stuck patterns, and wasted budget before the loop exhausts its resources.
121+
122+
The critique runs after terminal checks (so the final tick is never wasted on a critique call) and is guarded by judge availability and cost cap.
123+
124+
The reviewer sees:
125+
- The original goal
126+
- Rolling tick summaries (up to 10)
127+
- The current state file (truncated to 3,000 chars)
128+
- The agent's last response (truncated to 1,000 chars)
129+
130+
The assessment is injected into the next tick's prompt as a "REVIEWER FEEDBACK" section.
131+
132+
## Post-Loop Pipeline
133+
134+
After a loop finalizes, a fire-and-forget pipeline runs evolution and memory consolidation. Neither can affect the loop's final status, and errors are logged but never propagated.
135+
136+
**Evolution**: A bounded transcript (rolling summaries, first/last prompt-response pairs) is synthesized into a `SessionData` object and fed to the evolution engine's `afterSession()` pipeline. If the engine applies changes, the runtime's evolved config is updated.
137+
138+
**Memory consolidation**: If vector memory is ready, the session data is consolidated into episodic memory. When LLM judges are available and within cost cap, Sonnet extracts facts while checking for contradictions with existing knowledge. Otherwise, a heuristic fallback runs.
139+
140+
Loop status maps to evolution outcome: `done` becomes success, `stopped` becomes abandoned, everything else becomes failure.
141+
142+
## Memory Context
143+
144+
Memory context is cached once at loop start and injected into every tick prompt as a "RECALLED MEMORIES" section. Caching avoids re-querying the vector database on every tick (the goal is constant, so recall results don't change). The cache is cleared on finalize and rebuilt on resume.
145+
146+
## Writing Effective Goals
147+
148+
**Be specific and incremental:**
149+
- Good: "Refactor src/auth/ to use the new session types from types.ts. Run `bun test src/auth` after each change. Stop when all tests pass."
150+
- Bad: "Fix the auth system."
151+
152+
**One concrete action per tick:**
153+
- The agent works best when Next Action describes a single, verifiable step
154+
- Goals that encourage small steps ("fix one test at a time") produce more reliable loops than goals that demand large leaps
155+
156+
**Use success_command for objective verification:**
157+
- `bun test src/auth` - loop runs until all auth tests pass
158+
- `curl -sf http://localhost:3000/health` - loop runs until the service is healthy
159+
- `grep -q 'TODO' src/module.ts && exit 1 || exit 0` - loop runs until no TODOs remain
160+
161+
## Key Files
162+
163+
| File | Purpose |
164+
|------|---------|
165+
| `src/loop/runner.ts` | LoopRunner: tick lifecycle, memory caching, critique scheduling, finalization |
166+
| `src/loop/prompt.ts` | Per-tick prompt builder with memory and critique injection |
167+
| `src/loop/types.ts` | Types, Zod schemas, constants, ceilings |
168+
| `src/loop/store.ts` | SQLite persistence layer |
169+
| `src/loop/state-file.ts` | State file init, read, YAML frontmatter parsing |
170+
| `src/loop/tool.ts` | `phantom_loop` MCP tool (start/status/stop/list) |
171+
| `src/loop/critique.ts` | Mid-loop Sonnet 4.6 critique judge |
172+
| `src/loop/post-loop.ts` | Post-loop evolution and memory consolidation pipeline |
173+
| `src/loop/notifications.ts` | Slack progress bar, reaction ladder, stop button |
Lines changed: 38 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,67 @@
1-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3-
import YAML from "yaml";
1+
import { describe, expect, test } from "bun:test";
2+
import { AuthMiddleware } from "../../mcp/auth.ts";
43
import { hashTokenSync } from "../../mcp/config.ts";
54
import type { McpConfig } from "../../mcp/types.ts";
6-
import { setTriggerDeps, startServer } from "../server.ts";
75

86
/**
9-
* Tests that the /trigger endpoint requires bearer token auth
10-
* with operator scope. Closes ghostwright/phantom#9.
7+
* Tests that /trigger auth logic requires bearer token with operator scope.
8+
* Closes ghostwright/phantom#9.
9+
*
10+
* Tests the AuthMiddleware directly with constructed Request objects
11+
* to avoid Bun.serve + fetch issues in GitHub Actions CI.
1112
*/
1213
describe("/trigger endpoint auth", () => {
1314
const adminToken = "test-trigger-admin-token";
1415
const readToken = "test-trigger-read-token";
1516
const operatorToken = "test-trigger-operator-token";
1617

17-
const mcpConfigPath = "config/mcp.yaml";
18-
let originalMcpYaml: string | null = null;
19-
let server: ReturnType<typeof Bun.serve>;
20-
let baseUrl: string;
18+
const mcpConfig: McpConfig = {
19+
tokens: [
20+
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
21+
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
22+
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
23+
],
24+
rate_limit: { requests_per_minute: 60, burst: 10 },
25+
};
2126

22-
beforeAll(() => {
23-
// Back up the existing mcp.yaml so we can restore it after tests
24-
if (existsSync(mcpConfigPath)) {
25-
originalMcpYaml = readFileSync(mcpConfigPath, "utf-8");
26-
}
27+
const auth = new AuthMiddleware(mcpConfig);
2728

28-
// Write test tokens to mcp.yaml so loadMcpConfig picks them up
29-
const mcpConfig: McpConfig = {
30-
tokens: [
31-
{ name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] },
32-
{ name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] },
33-
{ name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] },
34-
],
35-
rate_limit: { requests_per_minute: 60, burst: 10 },
36-
};
37-
38-
mkdirSync("config", { recursive: true });
39-
writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8");
40-
41-
// Start server with a random port
42-
server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now());
43-
baseUrl = `http://localhost:${server.port}`;
44-
45-
// Wire trigger deps with a mock runtime
46-
setTriggerDeps({
47-
runtime: {
48-
handleMessage: async () => ({
49-
text: "ok",
50-
cost: { totalUsd: 0 },
51-
durationMs: 0,
52-
}),
53-
} as never,
29+
function makeRequest(headers: Record<string, string> = {}): Request {
30+
return new Request("http://localhost/trigger", {
31+
method: "POST",
32+
headers: { "Content-Type": "application/json", ...headers },
33+
body: JSON.stringify({ task: "hello" }),
5434
});
55-
});
56-
57-
afterAll(() => {
58-
server?.stop(true);
59-
// Restore the original mcp.yaml
60-
if (originalMcpYaml !== null) {
61-
writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8");
62-
}
63-
});
64-
65-
const triggerBody = JSON.stringify({ task: "hello" });
35+
}
6636

6737
test("rejects request with no Authorization header", async () => {
68-
const res = await fetch(`${baseUrl}/trigger`, {
69-
method: "POST",
70-
headers: { "Content-Type": "application/json" },
71-
body: triggerBody,
72-
});
73-
expect(res.status).toBe(401);
74-
const json = (await res.json()) as { status: string; message: string };
75-
expect(json.message).toContain("Missing");
38+
const result = await auth.authenticate(makeRequest());
39+
expect(result.authenticated).toBe(false);
40+
if (!result.authenticated) expect(result.error).toContain("Missing");
7641
});
7742

7843
test("rejects request with invalid token", async () => {
79-
const res = await fetch(`${baseUrl}/trigger`, {
80-
method: "POST",
81-
headers: {
82-
"Content-Type": "application/json",
83-
Authorization: "Bearer wrong-token",
84-
},
85-
body: triggerBody,
86-
});
87-
expect(res.status).toBe(401);
44+
const result = await auth.authenticate(makeRequest({ Authorization: "Bearer wrong-token" }));
45+
expect(result.authenticated).toBe(false);
46+
if (!result.authenticated) expect(result.error).toContain("Invalid");
8847
});
8948

9049
test("rejects read-only token (insufficient scope)", async () => {
91-
const res = await fetch(`${baseUrl}/trigger`, {
92-
method: "POST",
93-
headers: {
94-
"Content-Type": "application/json",
95-
Authorization: `Bearer ${readToken}`,
96-
},
97-
body: triggerBody,
98-
});
99-
expect(res.status).toBe(403);
100-
const json = (await res.json()) as { status: string; message: string };
101-
expect(json.message).toContain("operator");
50+
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${readToken}` }));
51+
expect(result.authenticated).toBe(true);
52+
expect(auth.hasScope(result, "operator")).toBe(false);
10253
});
10354

10455
test("accepts operator token", async () => {
105-
const res = await fetch(`${baseUrl}/trigger`, {
106-
method: "POST",
107-
headers: {
108-
"Content-Type": "application/json",
109-
Authorization: `Bearer ${operatorToken}`,
110-
},
111-
body: triggerBody,
112-
});
113-
expect(res.status).toBe(200);
114-
const json = (await res.json()) as { status: string };
115-
expect(json.status).toBe("ok");
56+
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${operatorToken}` }));
57+
expect(result.authenticated).toBe(true);
58+
expect(auth.hasScope(result, "operator")).toBe(true);
11659
});
11760

11861
test("accepts admin token", async () => {
119-
const res = await fetch(`${baseUrl}/trigger`, {
120-
method: "POST",
121-
headers: {
122-
"Content-Type": "application/json",
123-
Authorization: `Bearer ${adminToken}`,
124-
},
125-
body: triggerBody,
126-
});
127-
expect(res.status).toBe(200);
62+
const result = await auth.authenticate(makeRequest({ Authorization: `Bearer ${adminToken}` }));
63+
expect(result.authenticated).toBe(true);
64+
expect(auth.hasScope(result, "operator")).toBe(true);
65+
expect(auth.hasScope(result, "admin")).toBe(true);
12866
});
12967
});

src/db/__tests__/migrate.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe("runMigrations", () => {
3636
runMigrations(db);
3737

3838
const migrationCount = db.query("SELECT COUNT(*) as count FROM _migrations").get() as { count: number };
39-
expect(migrationCount.count).toBe(12);
39+
expect(migrationCount.count).toBe(13);
4040
});
4141

4242
test("tracks applied migration indices", () => {
@@ -48,6 +48,6 @@ describe("runMigrations", () => {
4848
.all()
4949
.map((r) => (r as { index_num: number }).index_num);
5050

51-
expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
51+
expect(indices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
5252
});
5353
});

src/db/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,6 @@ export const MIGRATIONS: string[] = [
126126
// Appended, never inserted mid-array: existing deployments have already
127127
// applied migrations 0–10, so the new column must land at index 11.
128128
"ALTER TABLE loops ADD COLUMN trigger_message_ts TEXT",
129+
130+
"ALTER TABLE loops ADD COLUMN checkpoint_interval INTEGER",
129131
];

0 commit comments

Comments
 (0)