@saadnvd1/agent-os 0.3.723 → 0.3.728
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/api/loops/route.ts +17 -0
- package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
- package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
- package/app/api/tasks/[id]/loop/next/route.ts +25 -0
- package/app/api/tasks/[id]/loop/route.ts +72 -0
- package/app/api/tasks/[id]/loop/run/route.ts +12 -0
- package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
- package/app/api/tasks/[id]/mode/route.ts +29 -0
- package/app/api/tasks/[id]/report/draft/route.ts +54 -0
- package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
- package/app/api/tasks/[id]/report/route.ts +39 -0
- package/app/api/workspaces/[id]/mode/route.ts +23 -0
- package/app/api/workspaces/[id]/reports/route.ts +17 -0
- package/app/page.tsx +8 -0
- package/components/Analyst/PairChip.tsx +131 -0
- package/components/Analyst/PairSwitch.tsx +76 -0
- package/components/Analyst/ReviewCard.tsx +272 -0
- package/components/Analyst/SignalChips.tsx +39 -0
- package/components/Analyst/StartAnalystSheet.tsx +109 -0
- package/components/Analyst/cardState.test.ts +109 -0
- package/components/Analyst/cardState.ts +95 -0
- package/components/Analyst/pair.ts +81 -0
- package/components/Chat/Code.tsx +3 -0
- package/components/Chat/Messages.tsx +6 -1
- package/components/Chat/itemContext.ts +14 -0
- package/components/Draft/DraftChips.tsx +10 -0
- package/components/Draft/DraftPanel.tsx +7 -0
- package/components/Pane/DesktopTabBar.tsx +6 -0
- package/components/Pane/MobileTabBar.tsx +17 -0
- package/components/Pane/MobileViewPicker.tsx +4 -0
- package/components/Pane/PaneMenu.test.ts +21 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/Pane/PaneViewToggle.tsx +12 -0
- package/components/Pane/index.tsx +19 -0
- package/components/Report/DraftCard.tsx +89 -0
- package/components/Report/EstimateBar.tsx +51 -0
- package/components/Report/EvidenceSheet.tsx +98 -0
- package/components/Report/ModeChooser.tsx +43 -0
- package/components/Report/ReportView.tsx +289 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Sessions/RolePill.tsx +7 -0
- package/components/Tasks/Loop/LoopButton.tsx +74 -0
- package/components/Tasks/Loop/LoopSetup.tsx +146 -0
- package/components/Tasks/Loop/LoopView.tsx +350 -0
- package/components/Tasks/Loop/labels.ts +17 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/components/ui/segmented.tsx +55 -0
- package/components/ui/status-pill.tsx +3 -0
- package/contexts/PaneContext.tsx +21 -1
- package/data/loops/index.ts +3 -0
- package/data/loops/keys.ts +4 -0
- package/data/loops/mutations.ts +68 -0
- package/data/loops/queries.ts +40 -0
- package/data/push/topics.ts +6 -0
- package/data/reports/index.ts +115 -0
- package/lib/analyst/forward.ts +1 -1
- package/lib/db/changes.ts +7 -0
- package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
- package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +2 -0
- package/lib/loops/analyse.ts +46 -0
- package/lib/loops/analyst-prompt.ts +203 -0
- package/lib/loops/checkCriteria.ts +114 -0
- package/lib/loops/compactEvidence.ts +140 -0
- package/lib/loops/controls.ts +317 -0
- package/lib/loops/decideStop.ts +113 -0
- package/lib/loops/deps.ts +128 -0
- package/lib/loops/diffMap.ts +39 -0
- package/lib/loops/draftCriteria.ts +71 -0
- package/lib/loops/gatherEvidence.ts +265 -0
- package/lib/loops/hooks.ts +40 -0
- package/lib/loops/index.ts +7 -0
- package/lib/loops/route.ts +83 -0
- package/lib/loops/runCycle.ts +323 -0
- package/lib/loops/screenInstruction.ts +59 -0
- package/lib/loops/sendInstruction.ts +83 -0
- package/lib/loops/snapshot.ts +38 -0
- package/lib/loops/stopLoop.ts +86 -0
- package/lib/loops/store.ts +481 -0
- package/lib/loops/summary.ts +27 -0
- package/lib/loops/tickLoops.ts +141 -0
- package/lib/loops/types.ts +223 -0
- package/lib/loops/world.test-helpers.ts +223 -0
- package/lib/models/plan-step.ts +4 -1
- package/lib/orchestrator/digest.ts +2 -1
- package/lib/orchestrator/loop-guard.ts +5 -2
- package/lib/orchestrator/pr-wake.ts +4 -0
- package/lib/orchestrator/watcher.ts +8 -1
- package/lib/panes.ts +52 -0
- package/lib/plans/acceptance.ts +23 -0
- package/lib/reports/drafts.ts +87 -0
- package/lib/reports/end-hook.ts +17 -0
- package/lib/reports/estimate.ts +107 -0
- package/lib/reports/evidence/build.ts +193 -0
- package/lib/reports/evidence/gather.ts +229 -0
- package/lib/reports/fixtures.ts +17 -0
- package/lib/reports/generate.ts +159 -0
- package/lib/reports/hooks.ts +30 -0
- package/lib/reports/mode.ts +65 -0
- package/lib/reports/model.ts +115 -0
- package/lib/reports/read.ts +26 -0
- package/lib/reports/store.ts +180 -0
- package/lib/reports/template.ts +117 -0
- package/lib/reports/types.ts +135 -0
- package/lib/reports/validate.ts +142 -0
- package/lib/sidebar/row-menu.ts +9 -0
- package/lib/tasks/contract/routes.ts +5 -0
- package/lib/tasks/contract/types.ts +3 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/package.json +2 -2
- package/stores/analystUi.ts +19 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Session } from "../db";
|
|
2
|
+
import type { SessionFacts } from "../orchestrator/facts";
|
|
3
|
+
import { checkCriteria } from "./checkCriteria";
|
|
4
|
+
import { evidenceText, scoreOf } from "./compactEvidence";
|
|
5
|
+
import { decideStop, type Stop } from "./decideStop";
|
|
6
|
+
import type { LoopDeps } from "./deps";
|
|
7
|
+
import { callHook } from "./hooks";
|
|
8
|
+
import { instructionText, mirrorToPairing } from "./sendInstruction";
|
|
9
|
+
import { screenInstruction } from "./screenInstruction";
|
|
10
|
+
import {
|
|
11
|
+
claimCycle,
|
|
12
|
+
criteriaOf,
|
|
13
|
+
cycleCount,
|
|
14
|
+
cyclesOf,
|
|
15
|
+
getCycle,
|
|
16
|
+
getLoop,
|
|
17
|
+
markCriterion,
|
|
18
|
+
moveCycle,
|
|
19
|
+
patchCycle,
|
|
20
|
+
touchLoop,
|
|
21
|
+
} from "./store";
|
|
22
|
+
import { finishLoop } from "./stopLoop";
|
|
23
|
+
import type { CycleRow, CycleTrigger, EvidenceSummary, LoopRow } from "./types";
|
|
24
|
+
|
|
25
|
+
// A turn that ends with nothing changed: nudge, don't spend a model run.
|
|
26
|
+
export const NO_CHANGE_NUDGE =
|
|
27
|
+
"Your last turn changed nothing in the worktree. Take the first open acceptance criterion and make a concrete change for it, then run the checks. If something is stopping you, say so on a line starting with BLOCKED:.";
|
|
28
|
+
|
|
29
|
+
const inflight = new Set<string>();
|
|
30
|
+
export const cycleInFlight = (loopId: string) => inflight.has(loopId);
|
|
31
|
+
|
|
32
|
+
const BLOCKED_LINE = /^\s*BLOCKED:\s*(.+)$/m;
|
|
33
|
+
const RETRY_AFTER_ERROR_MS = 60_000;
|
|
34
|
+
const SEND_RETRY_MS = 15_000;
|
|
35
|
+
|
|
36
|
+
interface Ctx {
|
|
37
|
+
loop: LoopRow;
|
|
38
|
+
task: Session;
|
|
39
|
+
facts: SessionFacts | undefined;
|
|
40
|
+
deps: LoopDeps;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nameOf = (task: Session) => task.name;
|
|
44
|
+
|
|
45
|
+
function stop(ctx: Ctx, s: Stop): void {
|
|
46
|
+
finishLoop(ctx.loop, s, { taskName: nameOf(ctx.task), now: ctx.deps.now() });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Opens a cycle and runs it to the point where an instruction waits to be
|
|
50
|
+
// sent (or the loop stopped). One at a time per loop.
|
|
51
|
+
export async function startCycle(
|
|
52
|
+
loopId: string,
|
|
53
|
+
trigger: CycleTrigger,
|
|
54
|
+
facts: SessionFacts | undefined,
|
|
55
|
+
deps: LoopDeps
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
if (inflight.has(loopId)) return;
|
|
58
|
+
const loop = getLoop(loopId);
|
|
59
|
+
const task = loop && deps.task(loop.sessionId);
|
|
60
|
+
if (!loop || loop.state !== "running" || !task) return;
|
|
61
|
+
const cycle = claimCycle(loop.id, trigger, deps.now());
|
|
62
|
+
if (!cycle) return;
|
|
63
|
+
await processCycle({ loop, task, facts, deps }, cycle);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Runs a claimed cycle; after a restart it picks the same row up again.
|
|
67
|
+
export async function processCycle(ctx: Ctx, cycle: CycleRow): Promise<void> {
|
|
68
|
+
if (inflight.has(ctx.loop.id)) return;
|
|
69
|
+
inflight.add(ctx.loop.id);
|
|
70
|
+
try {
|
|
71
|
+
await analyseCycle(ctx, cycle);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
74
|
+
patchCycle(ctx.loop.id, cycle.n, {
|
|
75
|
+
state: "error",
|
|
76
|
+
error: why.slice(0, 500),
|
|
77
|
+
endedAt: ctx.deps.now(),
|
|
78
|
+
});
|
|
79
|
+
const recent = cyclesOf(ctx.loop.id, { limit: 2 });
|
|
80
|
+
if (recent.length === 2 && recent.every((c) => c.state === "error"))
|
|
81
|
+
stop(ctx, {
|
|
82
|
+
reason: "blocked",
|
|
83
|
+
detail: `The cycle failed twice: ${why.slice(0, 200)}`,
|
|
84
|
+
});
|
|
85
|
+
} finally {
|
|
86
|
+
inflight.delete(ctx.loop.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function analyseCycle(ctx: Ctx, cycle: CycleRow): Promise<void> {
|
|
91
|
+
const { loop, task, deps } = ctx;
|
|
92
|
+
const previous = cyclesOf(loop.id, { limit: 10 }).filter(
|
|
93
|
+
(c) => c.n < cycle.n
|
|
94
|
+
);
|
|
95
|
+
const lastSnap = previous.find((c) => c.snapshotSha);
|
|
96
|
+
const prepared = await deps.prepare(task);
|
|
97
|
+
const snap = await deps.snapshot(prepared.dir);
|
|
98
|
+
const changed = !lastSnap?.tree || lastSnap.tree !== snap.tree;
|
|
99
|
+
const reply = deps.coderReply(task);
|
|
100
|
+
|
|
101
|
+
let evidence: EvidenceSummary;
|
|
102
|
+
let signature: string | null;
|
|
103
|
+
let analysis: CycleRow["analysis"] = null;
|
|
104
|
+
let costUsd = 0;
|
|
105
|
+
let marksMet: number;
|
|
106
|
+
|
|
107
|
+
if (!changed && lastSnap?.evidence) {
|
|
108
|
+
// Nothing to inspect that the last cycle didn't: its evidence stands.
|
|
109
|
+
evidence = lastSnap.evidence;
|
|
110
|
+
signature = lastSnap.signature;
|
|
111
|
+
marksMet = criteriaOf(loop.id).filter(
|
|
112
|
+
(c) => c.status === "met" || c.status === "waived"
|
|
113
|
+
).length;
|
|
114
|
+
patchCycle(loop.id, cycle.n, {
|
|
115
|
+
snapshotSha: snap.sha,
|
|
116
|
+
tree: snap.tree,
|
|
117
|
+
changed: false,
|
|
118
|
+
evidence,
|
|
119
|
+
signature,
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
const commands = await deps.commands(prepared.repo, prepared.base);
|
|
123
|
+
const testFiles = await deps.testFiles(
|
|
124
|
+
prepared.dir,
|
|
125
|
+
prepared.base,
|
|
126
|
+
snap.sha
|
|
127
|
+
);
|
|
128
|
+
const gathered = await deps.gather({
|
|
129
|
+
taskId: task.id,
|
|
130
|
+
n: cycle.n,
|
|
131
|
+
dir: prepared.dir,
|
|
132
|
+
commands,
|
|
133
|
+
testFiles,
|
|
134
|
+
});
|
|
135
|
+
const pr = ctx.facts?.task?.pr;
|
|
136
|
+
const ci = pr
|
|
137
|
+
? [
|
|
138
|
+
{
|
|
139
|
+
name: pr.failing ?? "checks",
|
|
140
|
+
state: pr.checks === "pass" ? "success" : String(pr.checks),
|
|
141
|
+
sha: pr.head ?? null,
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
: [];
|
|
145
|
+
evidence = { ...gathered.summary, ci };
|
|
146
|
+
signature = gathered.signature;
|
|
147
|
+
patchCycle(loop.id, cycle.n, {
|
|
148
|
+
snapshotSha: snap.sha,
|
|
149
|
+
tree: snap.tree,
|
|
150
|
+
changed: true,
|
|
151
|
+
evidence,
|
|
152
|
+
logPath: gathered.logPath,
|
|
153
|
+
signature,
|
|
154
|
+
});
|
|
155
|
+
const diff = await deps.diff(
|
|
156
|
+
prepared.dir,
|
|
157
|
+
prepared.base,
|
|
158
|
+
snap.sha,
|
|
159
|
+
lastSnap?.snapshotSha ?? null
|
|
160
|
+
);
|
|
161
|
+
const wake =
|
|
162
|
+
pr && pr.checks === "fail" && pr.failing
|
|
163
|
+
? `CI check "${pr.failing}" is failing on the PR's head.`
|
|
164
|
+
: null;
|
|
165
|
+
const result = await deps.analyse({
|
|
166
|
+
repo: prepared.repo,
|
|
167
|
+
sha: snap.sha,
|
|
168
|
+
workspaceId: loop.workspaceId,
|
|
169
|
+
prompt: {
|
|
170
|
+
goal: task.task_prompt || loop.goal,
|
|
171
|
+
amendments: deps.amendments(task.id),
|
|
172
|
+
criteria: criteriaOf(loop.id),
|
|
173
|
+
evidence: evidenceText(evidence.commands, ci),
|
|
174
|
+
diffMap: diff.map,
|
|
175
|
+
recent: previous.slice(0, 2).reverse(),
|
|
176
|
+
coderReply: reply,
|
|
177
|
+
wake,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
analysis = result.analysis;
|
|
181
|
+
costUsd = result.costUsd;
|
|
182
|
+
const marks = checkCriteria(criteriaOf(loop.id), analysis, {
|
|
183
|
+
green: evidence.green,
|
|
184
|
+
commands: evidence.commands,
|
|
185
|
+
hunks: diff.hunks,
|
|
186
|
+
ci,
|
|
187
|
+
snapshotSha: snap.sha,
|
|
188
|
+
});
|
|
189
|
+
for (const m of marks)
|
|
190
|
+
markCriterion(m.id, m.status, m.evidence, m.verified, cycle.n);
|
|
191
|
+
marksMet = marks.filter(
|
|
192
|
+
(m) => m.status === "met" || m.status === "waived"
|
|
193
|
+
).length;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const score = scoreOf(evidence.commands, marksMet);
|
|
197
|
+
patchCycle(loop.id, cycle.n, {
|
|
198
|
+
analysis,
|
|
199
|
+
score,
|
|
200
|
+
costUsd,
|
|
201
|
+
input: reply ? reply.slice(0, 2000) : null,
|
|
202
|
+
});
|
|
203
|
+
callHook("onCycleSettled", {
|
|
204
|
+
taskId: task.id,
|
|
205
|
+
cycleId: cycle.id,
|
|
206
|
+
analystSummary: analysis?.summary ?? null,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// The user typed to the coder meanwhile: their message wins.
|
|
210
|
+
if (await deps.coderRunning(task)) {
|
|
211
|
+
moveCycle(loop.id, cycle.n, ["claimed"], {
|
|
212
|
+
state: "superseded",
|
|
213
|
+
endedAt: deps.now(),
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const criteria = criteriaOf(loop.id);
|
|
219
|
+
const finished = cyclesOf(loop.id, { limit: 40 })
|
|
220
|
+
.filter((c) => c.score)
|
|
221
|
+
.reverse();
|
|
222
|
+
const blocked =
|
|
223
|
+
analysis?.blocker?.text ??
|
|
224
|
+
BLOCKED_LINE.exec(reply ?? "")?.[1]?.trim() ??
|
|
225
|
+
(ctx.facts?.needsInput
|
|
226
|
+
? "the coding agent is waiting for an answer"
|
|
227
|
+
: null) ??
|
|
228
|
+
deps.waitingOnPerson(task);
|
|
229
|
+
const fresh = getLoop(loop.id)!;
|
|
230
|
+
const draft = changed
|
|
231
|
+
? (analysis?.next?.instruction ?? null)
|
|
232
|
+
: NO_CHANGE_NUDGE;
|
|
233
|
+
const decided = decideStop({
|
|
234
|
+
limits: fresh,
|
|
235
|
+
startedAt: fresh.startedAt,
|
|
236
|
+
cycles: finished,
|
|
237
|
+
cycleCount: cycleCount(loop.id),
|
|
238
|
+
criteria,
|
|
239
|
+
green: evidence.green,
|
|
240
|
+
spentUsd: deps.spent(fresh),
|
|
241
|
+
now: deps.now(),
|
|
242
|
+
blocked,
|
|
243
|
+
});
|
|
244
|
+
if (decided) {
|
|
245
|
+
patchCycle(loop.id, cycle.n, {
|
|
246
|
+
instruction: decided.reason === "done" ? null : draft,
|
|
247
|
+
});
|
|
248
|
+
stop(ctx, decided);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (!draft) {
|
|
252
|
+
patchCycle(loop.id, cycle.n, {});
|
|
253
|
+
stop(ctx, {
|
|
254
|
+
reason: "blocked",
|
|
255
|
+
detail: "The analyst has no next step, but criteria are still open",
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const refused = screenInstruction(draft);
|
|
260
|
+
if (refused) {
|
|
261
|
+
patchCycle(loop.id, cycle.n, { instruction: draft });
|
|
262
|
+
stop(ctx, {
|
|
263
|
+
reason: "blocked",
|
|
264
|
+
detail: `The next instruction was held back: ${refused.why}`,
|
|
265
|
+
askKind: refused.kind,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const grace = fresh.graceSeconds;
|
|
270
|
+
patchCycle(loop.id, cycle.n, {
|
|
271
|
+
instruction: draft,
|
|
272
|
+
sender: "analyst",
|
|
273
|
+
sendAt: deps.now() + grace * 1000,
|
|
274
|
+
});
|
|
275
|
+
moveCycle(loop.id, cycle.n, ["claimed"], { state: "counting" });
|
|
276
|
+
touchLoop(loop.id, deps.now());
|
|
277
|
+
if (grace === 0) await deliverCycle({ ...ctx, loop: fresh }, cycle.n);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Sends a cycle's instruction once (the state move claims it). A failed
|
|
281
|
+
// send goes back to counting; a second failure stops the loop.
|
|
282
|
+
export async function deliverCycle(ctx: Ctx, n: number): Promise<boolean> {
|
|
283
|
+
const { loop, task, deps } = ctx;
|
|
284
|
+
const before = getCycle(loop.id, n);
|
|
285
|
+
if (!before || !before.instruction) return false;
|
|
286
|
+
if (!moveCycle(loop.id, n, ["counting", "decided"], { state: "sending" }))
|
|
287
|
+
return false;
|
|
288
|
+
const open = criteriaOf(loop.id).filter(
|
|
289
|
+
(c) => c.status !== "met" && c.status !== "waived"
|
|
290
|
+
);
|
|
291
|
+
const text =
|
|
292
|
+
before.instructionSent ?? instructionText(before.instruction, open);
|
|
293
|
+
try {
|
|
294
|
+
const delivery = await deps.send(task, loop.id, n, text);
|
|
295
|
+
patchCycle(loop.id, n, {
|
|
296
|
+
state: "sent",
|
|
297
|
+
delivery,
|
|
298
|
+
instructionSent: text,
|
|
299
|
+
sender: before.sender ?? "analyst",
|
|
300
|
+
error: null,
|
|
301
|
+
endedAt: deps.now(),
|
|
302
|
+
});
|
|
303
|
+
touchLoop(loop.id, deps.now());
|
|
304
|
+
mirrorToPairing(task.id, loop.id, n, text, before.analysis);
|
|
305
|
+
return true;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
308
|
+
patchCycle(loop.id, n, {
|
|
309
|
+
state: "counting",
|
|
310
|
+
sendAt: deps.now() + SEND_RETRY_MS,
|
|
311
|
+
error: why.slice(0, 300),
|
|
312
|
+
});
|
|
313
|
+
if (before.error) {
|
|
314
|
+
stop(ctx, {
|
|
315
|
+
reason: "blocked",
|
|
316
|
+
detail: `The instruction couldn't be delivered: ${why.slice(0, 200)}`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const RETRY_MS = RETRY_AFTER_ERROR_MS;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AskKind } from "../orchestrator/ask-view";
|
|
2
|
+
|
|
3
|
+
export interface Refusal {
|
|
4
|
+
kind: AskKind;
|
|
5
|
+
why: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Command-shaped on purpose: the broad ask classifier would trip on
|
|
9
|
+
// "token" in ordinary code, and an instruction about a token fixture is
|
|
10
|
+
// fine. These are the things the loop must never tell the coder to do.
|
|
11
|
+
const RULES: { kind: AskKind; why: string; re: RegExp }[] = [
|
|
12
|
+
{
|
|
13
|
+
kind: "irreversible",
|
|
14
|
+
why: "it tells the coder to merge",
|
|
15
|
+
re: /\b(?:gh\s+pr\s+merge|git\s+merge\b[^.\n]*\b(?:main|master)\b|merge\s+(?:the\s+|this\s+)?(?:pr|pull request)\b|(?:squash|rebase)[- ]and[- ]merge)/i,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
kind: "irreversible",
|
|
19
|
+
why: "it tells the coder to push to the default branch or force-push",
|
|
20
|
+
re: /\bgit\s+push\b[^.\n]*(?:--force\b|--force-with-lease|\s-f\b|\b(?:origin\s+)?(?:main|master)\b)|\bforce[- ]push\b/i,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
kind: "public",
|
|
24
|
+
why: "it tells the coder to publish or release",
|
|
25
|
+
re: /\b(?:npm|pnpm|yarn|bun)\s+publish\b|\bcargo\s+publish\b|\btwine\s+upload\b|\bgh\s+release\s+create\b|\bpublish\s+(?:the\s+|this\s+)?(?:package|release|build|version)\b|\bcut\s+a\s+release\b|\bgit\s+tag\b[^.\n]*\bpush\b/i,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: "public",
|
|
29
|
+
why: "it tells the coder to deploy",
|
|
30
|
+
re: /\bdeploy\s+(?:it\s+|this\s+|the\s+[\w -]{0,30})?(?:to|on)\s+(?:prod|production|staging|the\s+(?:server|box|cluster))\b|\b(?:kubectl\s+apply|terraform\s+apply|vercel\s+--prod|fly\s+deploy|helm\s+upgrade)\b/i,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: "public",
|
|
34
|
+
why: "it tells the coder to post or send something outbound",
|
|
35
|
+
re: /\b(?:send|post|tweet|publish)\s+(?:an?\s+|the\s+)?(?:email|e-mail|message|tweet|post|announcement|slack message)\s+to\b|\bgh\s+(?:issue|pr)\s+comment\b|\bcurl\b[^\n]*\s-X\s*(?:POST|PUT|DELETE)\b/i,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: "credentials",
|
|
39
|
+
why: "it tells the coder to read or change credentials",
|
|
40
|
+
re: /\b(?:print|cat|echo|dump|exfiltrate|send|upload)\b[^.\n]{0,40}(?:\.env\b|\bapi[_ -]?key|private key|ssh key|keychain|secrets?\b(?!\s+fixture))|\brotate\s+(?:the\s+)?(?:production|prod|live)\s+(?:key|token|secret|password)/i,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
kind: "irreversible",
|
|
44
|
+
why: "it tells the coder to delete data outside the worktree",
|
|
45
|
+
re: /\brm\s+-[a-z]*r[a-z]*f?\s+(?:~|\/(?!tmp\b)|\$HOME)|\bdrop\s+(?:table|database)\b|\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f)\b[^.\n]*\borigin\b|\bdelete\s+(?:the\s+)?(?:remote\s+)?branch\b/i,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: "money",
|
|
49
|
+
why: "it tells the coder to spend money",
|
|
50
|
+
re: /\b(?:purchase|buy|subscribe to|upgrade (?:the )?(?:plan|subscription))\b[^.\n]{0,40}\b(?:license|plan|subscription|credits|domain)\b|\b(?:charge|bill)\s+(?:the\s+)?(?:card|customer)/i,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The first rule the text breaks, or null.
|
|
55
|
+
export function screenInstruction(text: string): Refusal | null {
|
|
56
|
+
for (const r of RULES)
|
|
57
|
+
if (r.re.test(text)) return { kind: r.kind, why: r.why };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { deliver } from "../bus";
|
|
2
|
+
import { forwardToCoder } from "../analyst/forward";
|
|
3
|
+
import {
|
|
4
|
+
insertRecommendation,
|
|
5
|
+
decideRecommendation,
|
|
6
|
+
liveLinkFor,
|
|
7
|
+
} from "../analyst/store";
|
|
8
|
+
import type { Session } from "../db";
|
|
9
|
+
import { statusDetector } from "../status-detector";
|
|
10
|
+
import type { Analysis } from "./types";
|
|
11
|
+
|
|
12
|
+
export const LOOP_LABEL = "Analyst loop";
|
|
13
|
+
|
|
14
|
+
// The id the chat keeps the message by, so a resend after a restart is a
|
|
15
|
+
// no-op.
|
|
16
|
+
export const instructionId = (loopId: string, n: number) =>
|
|
17
|
+
`loop-${loopId}-${n}`;
|
|
18
|
+
|
|
19
|
+
// The instruction as the coder reads it: the instruction, then which
|
|
20
|
+
// criteria are still open.
|
|
21
|
+
export function instructionText(
|
|
22
|
+
instruction: string,
|
|
23
|
+
open: { id: number; text: string }[]
|
|
24
|
+
): string {
|
|
25
|
+
const list = open.length
|
|
26
|
+
? `\n\nStill open (acceptance criteria):\n${open.map((c) => `${c.id}. ${c.text}`).join("\n")}`
|
|
27
|
+
: "";
|
|
28
|
+
return `${instruction.trim()}${list}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Hands the instruction to the task's agent: a chat's next prompt (the
|
|
32
|
+
// analyst-forward path, by id) or typed into a terminal. Throws when it
|
|
33
|
+
// could not be delivered. Returns how it went.
|
|
34
|
+
export async function sendInstruction(
|
|
35
|
+
task: Session,
|
|
36
|
+
loopId: string,
|
|
37
|
+
n: number,
|
|
38
|
+
text: string
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
if (task.view === "chat") {
|
|
41
|
+
await forwardToCoder(task.id, {
|
|
42
|
+
text,
|
|
43
|
+
id: instructionId(loopId, n),
|
|
44
|
+
label: LOOP_LABEL,
|
|
45
|
+
});
|
|
46
|
+
return "sent";
|
|
47
|
+
}
|
|
48
|
+
await statusDetector.refreshCache();
|
|
49
|
+
const delivery = await deliver(task, text, { name: LOOP_LABEL });
|
|
50
|
+
if (delivery.state === "failed") throw new Error(delivery.why);
|
|
51
|
+
return delivery.state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A paired analyst's split view shows the same history: the instruction as
|
|
55
|
+
// a sent card. Best effort; the loop never depends on it.
|
|
56
|
+
export function mirrorToPairing(
|
|
57
|
+
taskId: string,
|
|
58
|
+
loopId: string,
|
|
59
|
+
n: number,
|
|
60
|
+
text: string,
|
|
61
|
+
analysis: Analysis | null
|
|
62
|
+
): void {
|
|
63
|
+
try {
|
|
64
|
+
const link = liveLinkFor(taskId);
|
|
65
|
+
if (link?.coderSessionId !== taskId) return;
|
|
66
|
+
const itemId = instructionId(loopId, n);
|
|
67
|
+
const rec = insertRecommendation({
|
|
68
|
+
linkId: link.id,
|
|
69
|
+
analystItemId: itemId,
|
|
70
|
+
coderThrough: null,
|
|
71
|
+
signals: null,
|
|
72
|
+
verdict: "continue",
|
|
73
|
+
summary: analysis?.summary ?? "",
|
|
74
|
+
issues: [],
|
|
75
|
+
prompt: text,
|
|
76
|
+
state: "open",
|
|
77
|
+
});
|
|
78
|
+
if (rec)
|
|
79
|
+
decideRecommendation(rec.id, "sent", { text, itemId, edited: false });
|
|
80
|
+
} catch {
|
|
81
|
+
// The loop goes on.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { git } from "../git-exec";
|
|
5
|
+
|
|
6
|
+
// A commit of the worktree as it is, uncommitted work included, made with a
|
|
7
|
+
// throwaway index so the real index, the stash and the branch don't move.
|
|
8
|
+
// A clean worktree is HEAD itself. Untracked files that .gitignore hides stay
|
|
9
|
+
// out, as they do for any commit.
|
|
10
|
+
export async function snapshotWorktree(dir: string): Promise<string> {
|
|
11
|
+
const head = (await git(dir, ["rev-parse", "HEAD"])).trim();
|
|
12
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aos-loop-idx-"));
|
|
13
|
+
const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
|
|
14
|
+
try {
|
|
15
|
+
await git(dir, ["read-tree", "HEAD"], { env });
|
|
16
|
+
await git(dir, ["add", "-A"], { env });
|
|
17
|
+
const tree = (await git(dir, ["write-tree"], { env })).trim();
|
|
18
|
+
const headTree = (await git(dir, ["rev-parse", "HEAD^{tree}"])).trim();
|
|
19
|
+
if (tree === headTree) return head;
|
|
20
|
+
return (
|
|
21
|
+
await git(
|
|
22
|
+
dir,
|
|
23
|
+
["commit-tree", tree, "-p", head, "-m", "AgentOS loop snapshot"],
|
|
24
|
+
{
|
|
25
|
+
env: {
|
|
26
|
+
...env,
|
|
27
|
+
GIT_AUTHOR_NAME: "AgentOS",
|
|
28
|
+
GIT_AUTHOR_EMAIL: "agentos@localhost",
|
|
29
|
+
GIT_COMMITTER_NAME: "AgentOS",
|
|
30
|
+
GIT_COMMITTER_EMAIL: "agentos@localhost",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
).trim();
|
|
35
|
+
} finally {
|
|
36
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { raiseAsk, AskRefused, taskSubject } from "../orchestrator/asks";
|
|
2
|
+
import { queueEventOnce } from "../orchestrator/events";
|
|
3
|
+
import { addNote } from "../orchestrator/notes";
|
|
4
|
+
import type { Stop } from "./decideStop";
|
|
5
|
+
import { callHook } from "./hooks";
|
|
6
|
+
import { getLoop, cyclesOf, moveCycle, stopLoopRow } from "./store";
|
|
7
|
+
import type { LoopRow, StopReason } from "./types";
|
|
8
|
+
|
|
9
|
+
export const REASON_LABEL: Record<StopReason, string> = {
|
|
10
|
+
stopped: "stopped by you",
|
|
11
|
+
blocked: "needs you",
|
|
12
|
+
done: "all criteria met",
|
|
13
|
+
repeat: "the same failure kept coming back",
|
|
14
|
+
no_progress: "no progress",
|
|
15
|
+
"limit:cycles": "reached its cycle limit",
|
|
16
|
+
"limit:cost": "reached its cost cap",
|
|
17
|
+
"limit:time": "reached its time limit",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// These raise an ask: the loop stopped short of done and someone has to
|
|
21
|
+
// decide. Done and the user's own stop don't.
|
|
22
|
+
const ASKS: StopReason[] = [
|
|
23
|
+
"blocked",
|
|
24
|
+
"repeat",
|
|
25
|
+
"no_progress",
|
|
26
|
+
"limit:cycles",
|
|
27
|
+
"limit:cost",
|
|
28
|
+
"limit:time",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// Ends a running loop once. The cycle in flight is closed, its undelivered
|
|
32
|
+
// instruction kept as the draft, the orchestrator told, an ask raised when
|
|
33
|
+
// someone has to decide, and the operating mode handed back.
|
|
34
|
+
export function finishLoop(
|
|
35
|
+
loop: LoopRow,
|
|
36
|
+
stop: Stop,
|
|
37
|
+
opts: { taskName: string; now: number }
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!stopLoopRow(loop.id, stop.reason, stop.detail, opts.now)) return false;
|
|
40
|
+
const last = cyclesOf(loop.id, { limit: 1 })[0];
|
|
41
|
+
let pending: string | null = null;
|
|
42
|
+
if (last) {
|
|
43
|
+
pending = last.instruction;
|
|
44
|
+
const unsent = ["claimed", "decided", "counting", "sending"] as const;
|
|
45
|
+
if (unsent.includes(last.state as (typeof unsent)[number]))
|
|
46
|
+
moveCycle(loop.id, last.n, [...unsent], {
|
|
47
|
+
state: "stopped",
|
|
48
|
+
endedAt: opts.now,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const label = REASON_LABEL[stop.reason];
|
|
52
|
+
const text = `Loop on ${opts.taskName}: ${label}${stop.detail && stop.reason !== "stopped" ? ` (${stop.detail})` : ""}.`;
|
|
53
|
+
if (loop.workspaceId) {
|
|
54
|
+
try {
|
|
55
|
+
addNote(loop.workspaceId, text);
|
|
56
|
+
} catch {}
|
|
57
|
+
try {
|
|
58
|
+
queueEventOnce(
|
|
59
|
+
loop.workspaceId,
|
|
60
|
+
`loop:${loop.sessionId}:${loop.id}:${stop.reason}`,
|
|
61
|
+
loop.sessionId,
|
|
62
|
+
text
|
|
63
|
+
);
|
|
64
|
+
} catch {}
|
|
65
|
+
if (ASKS.includes(stop.reason)) {
|
|
66
|
+
try {
|
|
67
|
+
raiseAsk({
|
|
68
|
+
workspaceId: loop.workspaceId,
|
|
69
|
+
subject: taskSubject(loop.sessionId),
|
|
70
|
+
kind: stop.askKind ?? "decision",
|
|
71
|
+
title: `Loop on ${opts.taskName} ${label}`,
|
|
72
|
+
detail: [
|
|
73
|
+
stop.detail,
|
|
74
|
+
pending ? `The next instruction was not sent:\n${pending}` : "",
|
|
75
|
+
]
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join("\n\n"),
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (!(error instanceof AskRefused)) console.error("Loop ask:", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
callHook("onStop", getLoop(loop.id) ?? loop, stop, pending);
|
|
85
|
+
return true;
|
|
86
|
+
}
|