@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,114 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Analysis,
|
|
3
|
+
Criterion,
|
|
4
|
+
CommandResult,
|
|
5
|
+
CriterionStatus,
|
|
6
|
+
Evidence,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export interface Resolver {
|
|
10
|
+
// The run's checks all passed.
|
|
11
|
+
green: boolean;
|
|
12
|
+
commands: CommandResult[];
|
|
13
|
+
// Changed lines per file on the branch: [start, end] ranges in the new file.
|
|
14
|
+
hunks: Map<string, [number, number][]>;
|
|
15
|
+
ci: { name: string; state: string; sha?: string | null }[];
|
|
16
|
+
snapshotSha: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "@@ -a,b +c,d @@" per file, from `git diff -U0`.
|
|
20
|
+
export function parseHunks(diff: string): Map<string, [number, number][]> {
|
|
21
|
+
const out = new Map<string, [number, number][]>();
|
|
22
|
+
let file: string | null = null;
|
|
23
|
+
for (const line of diff.split("\n")) {
|
|
24
|
+
if (line.startsWith("+++ ")) {
|
|
25
|
+
file = line.startsWith("+++ b/") ? line.slice(6) : null;
|
|
26
|
+
if (file && !out.has(file)) out.set(file, []);
|
|
27
|
+
} else if (file && line.startsWith("@@")) {
|
|
28
|
+
const m = /\+(\d+)(?:,(\d+))?/.exec(line);
|
|
29
|
+
if (m) {
|
|
30
|
+
const start = Number(m[1]);
|
|
31
|
+
const len = m[2] === undefined ? 1 : Number(m[2]);
|
|
32
|
+
out.get(file)!.push([start, start + Math.max(len, 1) - 1]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lower = (s: string) => s.toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Does the citation point at something this run really has? The model
|
|
42
|
+
// proposes; this decides.
|
|
43
|
+
export function resolves(e: Evidence | undefined, r: Resolver): boolean {
|
|
44
|
+
if (!e || !r.green) return false;
|
|
45
|
+
const ref = e.ref.trim();
|
|
46
|
+
if (!ref) return false;
|
|
47
|
+
switch (e.kind) {
|
|
48
|
+
case "test": {
|
|
49
|
+
const tests = r.commands.filter((c) => c.name === "test");
|
|
50
|
+
if (!tests.length || tests.some((c) => c.exitCode !== 0)) return false;
|
|
51
|
+
const want = lower(ref);
|
|
52
|
+
return tests.some((c) =>
|
|
53
|
+
c.passed.some((p) => lower(p).includes(want) || want.includes(lower(p)))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
case "typecheck":
|
|
57
|
+
return r.commands.some((c) => c.name === "typecheck" && c.exitCode === 0);
|
|
58
|
+
case "diff": {
|
|
59
|
+
const m = /^(.*?)(?::(\d+))?$/.exec(ref);
|
|
60
|
+
const file = m?.[1] ?? ref;
|
|
61
|
+
const ranges = r.hunks.get(file);
|
|
62
|
+
if (!ranges) return false;
|
|
63
|
+
if (!m?.[2]) return true;
|
|
64
|
+
const line = Number(m[2]);
|
|
65
|
+
return ranges.some(([a, b]) => line >= a && line <= b);
|
|
66
|
+
}
|
|
67
|
+
case "ci":
|
|
68
|
+
return r.ci.some(
|
|
69
|
+
(c) =>
|
|
70
|
+
lower(c.name) === lower(ref) &&
|
|
71
|
+
c.state === "success" &&
|
|
72
|
+
c.sha === r.snapshotSha
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Mark {
|
|
78
|
+
id: number;
|
|
79
|
+
status: CriterionStatus;
|
|
80
|
+
evidence: Evidence | null;
|
|
81
|
+
verified: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The analyst's marks, checked. "met" with no resolving citation stays open
|
|
85
|
+
// and is shown as unverified; a criterion the analyst didn't mention goes
|
|
86
|
+
// back to open (met at one snapshot is not met at the next); waived stays.
|
|
87
|
+
export function checkCriteria(
|
|
88
|
+
criteria: Pick<Criterion, "id" | "status">[],
|
|
89
|
+
analysis: Pick<Analysis, "criteria">,
|
|
90
|
+
r: Resolver
|
|
91
|
+
): Mark[] {
|
|
92
|
+
const said = new Map(analysis.criteria.map((c) => [c.id, c]));
|
|
93
|
+
return criteria.map((c) => {
|
|
94
|
+
if (c.status === "waived")
|
|
95
|
+
return { id: c.id, status: "waived", evidence: null, verified: true };
|
|
96
|
+
const a = said.get(c.id);
|
|
97
|
+
if (!a) return { id: c.id, status: "open", evidence: null, verified: true };
|
|
98
|
+
if (a.status !== "met")
|
|
99
|
+
return {
|
|
100
|
+
id: c.id,
|
|
101
|
+
status: a.status,
|
|
102
|
+
evidence: a.evidence ?? null,
|
|
103
|
+
verified: true,
|
|
104
|
+
};
|
|
105
|
+
return resolves(a.evidence, r)
|
|
106
|
+
? { id: c.id, status: "met", evidence: a.evidence!, verified: true }
|
|
107
|
+
: {
|
|
108
|
+
id: c.id,
|
|
109
|
+
status: "open",
|
|
110
|
+
evidence: a.evidence ?? null,
|
|
111
|
+
verified: false,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { failureSignature, normalize } from "../orchestrator/loop-guard";
|
|
2
|
+
import { redact } from "../models/secrets";
|
|
3
|
+
import type { CommandResult } from "./types";
|
|
4
|
+
|
|
5
|
+
const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
6
|
+
const FAILED_TEST = /^\s*(?:FAIL\s+|[×✗]\s+|not ok \d+ - )(.+)$/;
|
|
7
|
+
const PASSED_TEST = /^\s*(?:[✓√]\s+|ok \d+ - |PASS\s+)(.+)$/;
|
|
8
|
+
// tsc: "file.ts(12,3): error TS2322: ..." or "file.ts:12:3 - error TS2322: ..."
|
|
9
|
+
const TS_ERROR = /^(.+?)(?:\((\d+),\d+\)|:(\d+):\d+)\s*[:-]\s*error\s+(TS\d+)/;
|
|
10
|
+
|
|
11
|
+
const MAX_TYPE_ERRORS = 30;
|
|
12
|
+
const EXCERPT_LINES = 30;
|
|
13
|
+
export const EVIDENCE_CHARS = 6000;
|
|
14
|
+
|
|
15
|
+
const clean = (s: string) => s.replace(ANSI, "");
|
|
16
|
+
|
|
17
|
+
// A passed test line carries its timing and count; the name is what a
|
|
18
|
+
// citation names.
|
|
19
|
+
const nameOf = (s: string) =>
|
|
20
|
+
normalize(s)
|
|
21
|
+
.replace(/\(\d+ tests?[^)]*\)/, "")
|
|
22
|
+
.trim();
|
|
23
|
+
|
|
24
|
+
export function typeErrorsOf(output: string): string[] {
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
for (const line of clean(output).split("\n")) {
|
|
27
|
+
const m = TS_ERROR.exec(line.trim());
|
|
28
|
+
if (m) seen.add(`${m[1]}:${m[2] ?? m[3]} ${m[4]}`);
|
|
29
|
+
}
|
|
30
|
+
return [...seen];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function excerptOf(lines: string[], firstFailure: number): string {
|
|
34
|
+
const at =
|
|
35
|
+
firstFailure >= 0
|
|
36
|
+
? firstFailure
|
|
37
|
+
: Math.max(lines.length - EXCERPT_LINES / 2, 0);
|
|
38
|
+
const from = Math.max(at - 5, 0);
|
|
39
|
+
return lines.slice(from, from + EXCERPT_LINES).join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// What one command did, in a few hundred tokens: exit code, the tests that
|
|
43
|
+
// passed and failed, the type errors (once each), and the lines around the
|
|
44
|
+
// first failure. No model.
|
|
45
|
+
export function compactCommand(input: {
|
|
46
|
+
name: CommandResult["name"];
|
|
47
|
+
command: string;
|
|
48
|
+
exitCode: number | null;
|
|
49
|
+
durationMs: number;
|
|
50
|
+
output: string;
|
|
51
|
+
}): CommandResult {
|
|
52
|
+
const lines = clean(input.output).split("\n");
|
|
53
|
+
const passed = new Set<string>();
|
|
54
|
+
const failed = new Set<string>();
|
|
55
|
+
let first = -1;
|
|
56
|
+
lines.forEach((l, i) => {
|
|
57
|
+
const f = FAILED_TEST.exec(l)?.[1];
|
|
58
|
+
if (f) {
|
|
59
|
+
failed.add(nameOf(f));
|
|
60
|
+
if (first < 0) first = i;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const p = PASSED_TEST.exec(l)?.[1];
|
|
64
|
+
if (p) passed.add(nameOf(p));
|
|
65
|
+
});
|
|
66
|
+
const errors = input.name === "typecheck" ? typeErrorsOf(input.output) : [];
|
|
67
|
+
if (input.name === "typecheck" && errors.length && first < 0)
|
|
68
|
+
first = lines.findIndex((l) => TS_ERROR.test(l.trim()));
|
|
69
|
+
if (first < 0 && input.exitCode !== 0)
|
|
70
|
+
first = lines.findIndex((l) => /\berror\b|\bfail/i.test(l));
|
|
71
|
+
return {
|
|
72
|
+
name: input.name,
|
|
73
|
+
command: input.command,
|
|
74
|
+
exitCode: input.exitCode,
|
|
75
|
+
durationMs: input.durationMs,
|
|
76
|
+
passed: [...passed].slice(0, 400),
|
|
77
|
+
failed: [...failed].slice(0, 100),
|
|
78
|
+
errors: errors.slice(0, MAX_TYPE_ERRORS),
|
|
79
|
+
excerpt: redact(input.exitCode === 0 ? "" : excerptOf(lines, first)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The signature a repeat is judged by: the same one the merge loop guard
|
|
84
|
+
// uses for a failing CI log, over the failing commands' output.
|
|
85
|
+
export function signatureOf(
|
|
86
|
+
results: CommandResult[],
|
|
87
|
+
output: Map<string, string>
|
|
88
|
+
): string | null {
|
|
89
|
+
const failing = results.filter((r) => r.exitCode !== 0 && !r.skipped);
|
|
90
|
+
if (!failing.length) return null;
|
|
91
|
+
const parts = failing.map(
|
|
92
|
+
(r) =>
|
|
93
|
+
failureSignature(r.name, output.get(r.name) ?? r.excerpt) ??
|
|
94
|
+
(r.errors.length
|
|
95
|
+
? `${r.name}: ${r.errors.slice(0, 5).join("; ")}`
|
|
96
|
+
: `${r.name}: exit ${r.exitCode}`)
|
|
97
|
+
);
|
|
98
|
+
return parts.join(" | ").slice(0, 500);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function scoreOf(results: CommandResult[], met: number) {
|
|
102
|
+
return {
|
|
103
|
+
met,
|
|
104
|
+
failingTests: results
|
|
105
|
+
.filter((r) => r.name === "test")
|
|
106
|
+
.reduce((n, r) => n + r.failed.length, 0),
|
|
107
|
+
typeErrors: results
|
|
108
|
+
.filter((r) => r.name === "typecheck")
|
|
109
|
+
.reduce((n, r) => n + r.errors.length, 0),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The text the analyst reads, capped.
|
|
114
|
+
export function evidenceText(
|
|
115
|
+
results: CommandResult[],
|
|
116
|
+
ci: { name: string; state: string }[]
|
|
117
|
+
): string {
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
for (const r of results) {
|
|
120
|
+
const head = r.skipped
|
|
121
|
+
? `${r.name}: skipped (${r.skipped})`
|
|
122
|
+
: `${r.name} \`${r.command}\`: ${r.exitCode === 0 ? "passed" : `FAILED (exit ${r.exitCode})`} in ${Math.round(r.durationMs / 1000)}s`;
|
|
123
|
+
out.push(head);
|
|
124
|
+
if (r.passed.length)
|
|
125
|
+
out.push(
|
|
126
|
+
` passed (${r.passed.length}): ${r.passed.slice(0, 40).join("; ")}`
|
|
127
|
+
);
|
|
128
|
+
if (r.failed.length)
|
|
129
|
+
out.push(` failed (${r.failed.length}): ${r.failed.join("; ")}`);
|
|
130
|
+
if (r.errors.length)
|
|
131
|
+
out.push(` type errors (${r.errors.length}): ${r.errors.join("; ")}`);
|
|
132
|
+
if (r.excerpt) out.push(` around the first failure:\n${r.excerpt}`);
|
|
133
|
+
}
|
|
134
|
+
if (ci.length)
|
|
135
|
+
out.push(`CI: ${ci.map((c) => `${c.name} ${c.state}`).join(", ")}`);
|
|
136
|
+
const text = out.join("\n");
|
|
137
|
+
return text.length > EVIDENCE_CHARS
|
|
138
|
+
? `${text.slice(0, EVIDENCE_CHARS)}\n… (cut)`
|
|
139
|
+
: text;
|
|
140
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { getProject } from "../projects";
|
|
2
|
+
import { queueEventOnce } from "../orchestrator/events";
|
|
3
|
+
import { resolveAsks, taskSubject } from "../orchestrator/asks";
|
|
4
|
+
import { defaultDeps, type LoopDeps } from "./deps";
|
|
5
|
+
import { callHook } from "./hooks";
|
|
6
|
+
import { cycleInFlight, deliverCycle } from "./runCycle";
|
|
7
|
+
import { screenInstruction } from "./screenInstruction";
|
|
8
|
+
import { finishLoop } from "./stopLoop";
|
|
9
|
+
import {
|
|
10
|
+
addCriteria,
|
|
11
|
+
criteriaOf,
|
|
12
|
+
cyclesOf,
|
|
13
|
+
editCriterion,
|
|
14
|
+
getCycle,
|
|
15
|
+
insertLoop,
|
|
16
|
+
latestLoopFor,
|
|
17
|
+
liveLoopFor,
|
|
18
|
+
moveCycle,
|
|
19
|
+
patchCycle,
|
|
20
|
+
removeCriterion,
|
|
21
|
+
reopenLoop,
|
|
22
|
+
setLimits,
|
|
23
|
+
setRunNow,
|
|
24
|
+
waiveCriterion,
|
|
25
|
+
} from "./store";
|
|
26
|
+
import {
|
|
27
|
+
CRITERION_SOURCES,
|
|
28
|
+
DEFAULT_LIMITS,
|
|
29
|
+
LIMIT_BOUNDS,
|
|
30
|
+
LoopError,
|
|
31
|
+
type CriterionDraft,
|
|
32
|
+
type Limits,
|
|
33
|
+
type LoopRow,
|
|
34
|
+
type LoopView,
|
|
35
|
+
} from "./types";
|
|
36
|
+
|
|
37
|
+
export const MAX_CRITERIA = 30;
|
|
38
|
+
export const CRITERION_MAX = 400;
|
|
39
|
+
export const INSTRUCTION_MAX = 8000;
|
|
40
|
+
|
|
41
|
+
export function cleanCriteria(drafts: CriterionDraft[]): CriterionDraft[] {
|
|
42
|
+
const out = drafts
|
|
43
|
+
.map((d) => ({
|
|
44
|
+
text: String(d.text ?? "")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim()
|
|
47
|
+
.slice(0, CRITERION_MAX),
|
|
48
|
+
source: CRITERION_SOURCES.includes(d.source as never) ? d.source : "user",
|
|
49
|
+
evidenceKind: d.evidenceKind ?? null,
|
|
50
|
+
}))
|
|
51
|
+
.filter((d) => d.text);
|
|
52
|
+
if (!out.length)
|
|
53
|
+
throw new LoopError("Add at least one acceptance criterion", 400);
|
|
54
|
+
if (out.length > MAX_CRITERIA)
|
|
55
|
+
throw new LoopError(`At most ${MAX_CRITERIA} criteria`, 400);
|
|
56
|
+
return out as CriterionDraft[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Limits inside their bounds; a value out of range is refused, not clamped.
|
|
60
|
+
export function cleanLimits(
|
|
61
|
+
input: Partial<Limits> = {},
|
|
62
|
+
base: Limits = DEFAULT_LIMITS
|
|
63
|
+
): Limits {
|
|
64
|
+
const out = { ...base };
|
|
65
|
+
for (const key of Object.keys(LIMIT_BOUNDS) as (keyof Limits)[]) {
|
|
66
|
+
const v = input[key];
|
|
67
|
+
if (v === undefined) continue;
|
|
68
|
+
const [lo, hi] = LIMIT_BOUNDS[key];
|
|
69
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < lo || v > hi)
|
|
70
|
+
throw new LoopError(`${key} must be between ${lo} and ${hi}`, 400);
|
|
71
|
+
out[key] = key === "maxCostUsd" ? v : Math.round(v);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const workspaceOf = (task: {
|
|
77
|
+
project_id: string | null;
|
|
78
|
+
workspace_id: string | null;
|
|
79
|
+
}) =>
|
|
80
|
+
(task.project_id && getProject(task.project_id)?.workspace_id) ||
|
|
81
|
+
task.workspace_id ||
|
|
82
|
+
null;
|
|
83
|
+
|
|
84
|
+
function runningTask(deps: LoopDeps, taskId: string) {
|
|
85
|
+
const task = deps.task(taskId);
|
|
86
|
+
if (!task) throw new LoopError("No such task", 404);
|
|
87
|
+
if (task.task_status !== "running")
|
|
88
|
+
throw new LoopError("The task isn't running", 409);
|
|
89
|
+
if (task.quick_of)
|
|
90
|
+
throw new LoopError("A quick change has no agent to loop with", 409);
|
|
91
|
+
if (task.host_id && task.host_id !== "local")
|
|
92
|
+
throw new LoopError(
|
|
93
|
+
"The task runs on another machine: start the loop there",
|
|
94
|
+
409
|
|
95
|
+
);
|
|
96
|
+
return task;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function startLoop(
|
|
100
|
+
taskId: string,
|
|
101
|
+
input: { criteria: CriterionDraft[]; limits?: Partial<Limits> },
|
|
102
|
+
deps: LoopDeps = defaultDeps
|
|
103
|
+
): Promise<LoopView> {
|
|
104
|
+
const task = runningTask(deps, taskId);
|
|
105
|
+
if (liveLoopFor(taskId))
|
|
106
|
+
throw new LoopError("A loop is already running on this task", 409);
|
|
107
|
+
const criteria = cleanCriteria(input.criteria);
|
|
108
|
+
const limits = cleanLimits(input.limits);
|
|
109
|
+
// A loop with no checks is the coder's claims again.
|
|
110
|
+
const prepared = await deps.prepare(task).catch((e: Error) => {
|
|
111
|
+
throw new LoopError(`Can't read the task's repository: ${e.message}`, 409);
|
|
112
|
+
});
|
|
113
|
+
const commands = await deps.commands(prepared.repo, prepared.base);
|
|
114
|
+
if (!commands.test && !commands.typecheck)
|
|
115
|
+
throw new LoopError(
|
|
116
|
+
"The base branch has no test command in agentos.json: add one so the loop has evidence to judge by",
|
|
117
|
+
409
|
|
118
|
+
);
|
|
119
|
+
const workspaceId = workspaceOf(task);
|
|
120
|
+
const now = deps.now();
|
|
121
|
+
const loop = insertLoop({
|
|
122
|
+
sessionId: taskId,
|
|
123
|
+
workspaceId,
|
|
124
|
+
goal: (task.task_prompt ?? "").slice(0, 20_000),
|
|
125
|
+
limits,
|
|
126
|
+
costBaseUsd: 0,
|
|
127
|
+
now,
|
|
128
|
+
});
|
|
129
|
+
addCriteria(loop.id, criteria);
|
|
130
|
+
if (workspaceId) {
|
|
131
|
+
resolveAsks(workspaceId, taskSubject(taskId), "a new loop started");
|
|
132
|
+
queueEventOnce(
|
|
133
|
+
workspaceId,
|
|
134
|
+
`loop:${taskId}:${loop.id}:started`,
|
|
135
|
+
taskId,
|
|
136
|
+
`Loop started on ${task.name}: ${criteria.length} criteria, up to ${limits.maxCycles} cycles.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
callHook("onStart", loop);
|
|
140
|
+
return loopView(taskId, deps)!;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function stopByUser(
|
|
144
|
+
taskId: string,
|
|
145
|
+
deps: LoopDeps = defaultDeps
|
|
146
|
+
): LoopView {
|
|
147
|
+
const loop = liveLoopFor(taskId);
|
|
148
|
+
if (!loop) throw new LoopError("No loop is running on this task", 409);
|
|
149
|
+
const task = deps.task(taskId);
|
|
150
|
+
finishLoop(
|
|
151
|
+
loop,
|
|
152
|
+
{ reason: "stopped", detail: "stopped by you" },
|
|
153
|
+
{ taskName: task?.name ?? "the task", now: deps.now() }
|
|
154
|
+
);
|
|
155
|
+
return loopView(taskId, deps)!;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface EditInput {
|
|
159
|
+
limits?: Partial<Limits>;
|
|
160
|
+
// Added on top of the current limits.
|
|
161
|
+
extend?: { cycles?: number; costUsd?: number; minutes?: number };
|
|
162
|
+
addCriteria?: CriterionDraft[];
|
|
163
|
+
editCriteria?: { id: number; text: string }[];
|
|
164
|
+
removeCriteria?: number[];
|
|
165
|
+
waive?: number[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Limits, extensions and criteria, on a running loop or one stopped on a
|
|
169
|
+
// limit (an extension that clears the limit runs it again).
|
|
170
|
+
export function editLoop(
|
|
171
|
+
taskId: string,
|
|
172
|
+
input: EditInput,
|
|
173
|
+
by = "you",
|
|
174
|
+
deps: LoopDeps = defaultDeps
|
|
175
|
+
): LoopView {
|
|
176
|
+
const loop = latestLoopFor(taskId);
|
|
177
|
+
if (!loop) throw new LoopError("This task has no loop", 404);
|
|
178
|
+
if (input.limits) setLimits(loop.id, cleanLimits(input.limits, loop));
|
|
179
|
+
if (input.extend) {
|
|
180
|
+
const e = input.extend;
|
|
181
|
+
const next = cleanLimits(
|
|
182
|
+
{
|
|
183
|
+
maxCycles: e.cycles ? loop.maxCycles + e.cycles : undefined,
|
|
184
|
+
maxCostUsd: e.costUsd ? loop.maxCostUsd + e.costUsd : undefined,
|
|
185
|
+
maxMinutes: e.minutes ? loop.maxMinutes + e.minutes : undefined,
|
|
186
|
+
},
|
|
187
|
+
loop
|
|
188
|
+
);
|
|
189
|
+
setLimits(loop.id, next);
|
|
190
|
+
if (loop.state === "stopped" && loop.stopReason?.startsWith("limit:"))
|
|
191
|
+
resume(loop, deps);
|
|
192
|
+
}
|
|
193
|
+
if (input.addCriteria?.length) {
|
|
194
|
+
const have = criteriaOf(loop.id).length;
|
|
195
|
+
const add = cleanCriteria(input.addCriteria);
|
|
196
|
+
if (have + add.length > MAX_CRITERIA)
|
|
197
|
+
throw new LoopError(`At most ${MAX_CRITERIA} criteria`, 400);
|
|
198
|
+
addCriteria(loop.id, add);
|
|
199
|
+
}
|
|
200
|
+
for (const c of input.editCriteria ?? []) {
|
|
201
|
+
const text = String(c.text ?? "")
|
|
202
|
+
.replace(/\s+/g, " ")
|
|
203
|
+
.trim()
|
|
204
|
+
.slice(0, CRITERION_MAX);
|
|
205
|
+
if (!text) throw new LoopError("A criterion can't be empty", 400);
|
|
206
|
+
if (!editCriterion(c.id, loop.id, text))
|
|
207
|
+
throw new LoopError("No such criterion", 404);
|
|
208
|
+
}
|
|
209
|
+
for (const id of input.removeCriteria ?? [])
|
|
210
|
+
if (!removeCriterion(id, loop.id))
|
|
211
|
+
throw new LoopError("No such criterion", 404);
|
|
212
|
+
for (const id of input.waive ?? [])
|
|
213
|
+
if (!waiveCriterion(id, loop.id, by))
|
|
214
|
+
throw new LoopError("No such criterion", 404);
|
|
215
|
+
if (input.removeCriteria?.length && !criteriaOf(loop.id).length)
|
|
216
|
+
throw new LoopError("A loop needs at least one criterion", 400);
|
|
217
|
+
return loopView(taskId, deps)!;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resume(loop: LoopRow, deps: LoopDeps): void {
|
|
221
|
+
const task = deps.task(loop.sessionId);
|
|
222
|
+
if (!task || task.task_status !== "running")
|
|
223
|
+
throw new LoopError("The task isn't running", 409);
|
|
224
|
+
if (liveLoopFor(loop.sessionId)) return;
|
|
225
|
+
if (!reopenLoop(loop.id)) return;
|
|
226
|
+
setRunNow(loop.id, true);
|
|
227
|
+
if (loop.workspaceId)
|
|
228
|
+
resolveAsks(
|
|
229
|
+
loop.workspaceId,
|
|
230
|
+
taskSubject(loop.sessionId),
|
|
231
|
+
"the loop was resumed"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Run now: a cycle at the next tick, resuming a stopped loop (after criteria
|
|
236
|
+
// were edited, say).
|
|
237
|
+
export function runNow(taskId: string, deps: LoopDeps = defaultDeps): LoopView {
|
|
238
|
+
const loop = latestLoopFor(taskId);
|
|
239
|
+
if (!loop) throw new LoopError("This task has no loop", 404);
|
|
240
|
+
if (loop.state === "stopped") {
|
|
241
|
+
if (loop.stopReason === "done")
|
|
242
|
+
throw new LoopError("The loop is done", 409);
|
|
243
|
+
resume(loop, deps);
|
|
244
|
+
} else setRunNow(loop.id, true);
|
|
245
|
+
return loopView(taskId, deps)!;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export type NextAction =
|
|
249
|
+
| { action: "send"; instruction?: string }
|
|
250
|
+
| { action: "hold" }
|
|
251
|
+
| { action: "skip" };
|
|
252
|
+
|
|
253
|
+
// What the person does with the instruction in its countdown: send it now
|
|
254
|
+
// (edited or not), hold the countdown while they edit, or skip the cycle.
|
|
255
|
+
export async function actOnNext(
|
|
256
|
+
taskId: string,
|
|
257
|
+
input: NextAction,
|
|
258
|
+
deps: LoopDeps = defaultDeps
|
|
259
|
+
): Promise<LoopView> {
|
|
260
|
+
const loop = liveLoopFor(taskId);
|
|
261
|
+
if (!loop) throw new LoopError("No loop is running on this task", 409);
|
|
262
|
+
const task = deps.task(taskId);
|
|
263
|
+
const cycle = cyclesOf(loop.id, { limit: 1 })[0];
|
|
264
|
+
if (!task || !cycle || cycle.state !== "counting")
|
|
265
|
+
throw new LoopError("No instruction is waiting to be sent", 409);
|
|
266
|
+
if (input.action === "skip") {
|
|
267
|
+
moveCycle(loop.id, cycle.n, ["counting"], {
|
|
268
|
+
state: "skipped",
|
|
269
|
+
endedAt: deps.now(),
|
|
270
|
+
});
|
|
271
|
+
} else if (input.action === "hold") {
|
|
272
|
+
patchCycle(loop.id, cycle.n, { sendAt: null });
|
|
273
|
+
} else {
|
|
274
|
+
const edited = input.instruction?.trim();
|
|
275
|
+
if (edited !== undefined && !edited)
|
|
276
|
+
throw new LoopError("The instruction is empty", 400);
|
|
277
|
+
if (edited && edited.length > INSTRUCTION_MAX)
|
|
278
|
+
throw new LoopError("The instruction is too long", 400);
|
|
279
|
+
if (edited && edited !== cycle.instruction?.trim()) {
|
|
280
|
+
// The person may tell the coder what they like, but the loop still
|
|
281
|
+
// sends nothing that crosses a hard line on its own.
|
|
282
|
+
const refused = screenInstruction(edited);
|
|
283
|
+
if (refused)
|
|
284
|
+
throw new LoopError(
|
|
285
|
+
`Not sent: ${refused.why}. Send it to the coder yourself if you mean it.`,
|
|
286
|
+
409
|
|
287
|
+
);
|
|
288
|
+
patchCycle(loop.id, cycle.n, { instruction: edited, sender: "user" });
|
|
289
|
+
}
|
|
290
|
+
if (cycleInFlight(loop.id))
|
|
291
|
+
throw new LoopError("The cycle is still being prepared", 409);
|
|
292
|
+
await deliverCycle({ loop, task, facts: undefined, deps }, cycle.n);
|
|
293
|
+
}
|
|
294
|
+
return loopView(taskId, deps)!;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function loopView(
|
|
298
|
+
taskId: string,
|
|
299
|
+
deps: LoopDeps = defaultDeps,
|
|
300
|
+
opts: { before?: number } = {}
|
|
301
|
+
): LoopView | null {
|
|
302
|
+
const loop = latestLoopFor(taskId);
|
|
303
|
+
if (!loop) return null;
|
|
304
|
+
const task = deps.task(taskId);
|
|
305
|
+
const hold = loop.state === "running" && task ? deps.hold(loop, task) : null;
|
|
306
|
+
return {
|
|
307
|
+
loop,
|
|
308
|
+
hold: hold?.kind ?? null,
|
|
309
|
+
holdReason: hold?.reason ?? null,
|
|
310
|
+
spentUsd: deps.spent(loop),
|
|
311
|
+
elapsedMs: (loop.endedAt ?? deps.now()) - loop.startedAt,
|
|
312
|
+
criteria: criteriaOf(loop.id),
|
|
313
|
+
cycles: cyclesOf(loop.id, { before: opts.before, limit: 20 }),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export { getCycle };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AskKind } from "../orchestrator/ask-view";
|
|
2
|
+
import type { Criterion, CycleRow, Limits, Score, StopReason } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface StopCheck {
|
|
5
|
+
limits: Limits;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
// How many cycles have run, errors included; the length of `cycles` by default.
|
|
8
|
+
cycleCount?: number;
|
|
9
|
+
// Oldest first; the cycle just finished is the last.
|
|
10
|
+
cycles: Pick<CycleRow, "n" | "signature" | "score">[];
|
|
11
|
+
criteria: Pick<Criterion, "status">[];
|
|
12
|
+
// The latest cycle's checks all passed.
|
|
13
|
+
green: boolean;
|
|
14
|
+
spentUsd: number;
|
|
15
|
+
now: number;
|
|
16
|
+
userStop?: boolean;
|
|
17
|
+
// The analyst's blocker, a BLOCKED: line, a coder waiting on input, an
|
|
18
|
+
// open ask or a refused instruction: the words for the reader.
|
|
19
|
+
blocked?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Stop {
|
|
23
|
+
reason: StopReason;
|
|
24
|
+
detail: string;
|
|
25
|
+
// The ask a refused instruction raises, when it crossed a hard line.
|
|
26
|
+
askKind?: AskKind;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const usd = (n: number) => `$${n.toFixed(2)}`;
|
|
30
|
+
|
|
31
|
+
// Strictly better: more criteria met, then fewer failing tests, then
|
|
32
|
+
// fewer type errors.
|
|
33
|
+
export function better(a: Score, b: Score): boolean {
|
|
34
|
+
if (a.met !== b.met) return a.met > b.met;
|
|
35
|
+
if (a.failingTests !== b.failingTests) return a.failingTests < b.failingTests;
|
|
36
|
+
return a.typeErrors < b.typeErrors;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function repeated(cycles: StopCheck["cycles"], n: number): string | null {
|
|
40
|
+
if (cycles.length < n) return null;
|
|
41
|
+
const last = cycles.slice(-n);
|
|
42
|
+
const sig = last[0].signature;
|
|
43
|
+
return sig && last.every((c) => c.signature === sig) ? sig : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// None of the last `n` cycles beat the best before it. A cycle with no
|
|
47
|
+
// change scores the same as the one before, so it counts too.
|
|
48
|
+
function stalled(cycles: StopCheck["cycles"], n: number): boolean {
|
|
49
|
+
if (cycles.length < n + 1) return false;
|
|
50
|
+
const scored = cycles.map((c) => c.score);
|
|
51
|
+
if (scored.some((s) => !s)) return false;
|
|
52
|
+
const all = scored as Score[];
|
|
53
|
+
let best = all[0];
|
|
54
|
+
for (const s of all.slice(1, all.length - n)) if (better(s, best)) best = s;
|
|
55
|
+
for (const s of all.slice(all.length - n)) {
|
|
56
|
+
if (better(s, best)) return false;
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The limits alone: checked on every tick, and they stop the next send,
|
|
62
|
+
// never a turn that is running.
|
|
63
|
+
export function limitStop(
|
|
64
|
+
c: Pick<StopCheck, "limits" | "startedAt" | "spentUsd" | "now"> & {
|
|
65
|
+
cycleCount: number;
|
|
66
|
+
}
|
|
67
|
+
): Stop | null {
|
|
68
|
+
const l = c.limits;
|
|
69
|
+
if (c.cycleCount >= l.maxCycles)
|
|
70
|
+
return {
|
|
71
|
+
reason: "limit:cycles",
|
|
72
|
+
detail: `${c.cycleCount} of ${l.maxCycles} cycles used`,
|
|
73
|
+
};
|
|
74
|
+
if (c.spentUsd >= l.maxCostUsd)
|
|
75
|
+
return {
|
|
76
|
+
reason: "limit:cost",
|
|
77
|
+
detail: `${usd(c.spentUsd)} spent of the ${usd(l.maxCostUsd)} cap`,
|
|
78
|
+
};
|
|
79
|
+
const minutes = (c.now - c.startedAt) / 60_000;
|
|
80
|
+
if (minutes >= l.maxMinutes)
|
|
81
|
+
return {
|
|
82
|
+
reason: "limit:time",
|
|
83
|
+
detail: `${Math.round(minutes)} of ${l.maxMinutes} minutes used`,
|
|
84
|
+
};
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One pure decision at a cycle boundary. First match wins: the user, a
|
|
89
|
+
// blocker, done, the same failure again, no progress, then the limits.
|
|
90
|
+
export function decideStop(c: StopCheck): Stop | null {
|
|
91
|
+
if (c.userStop) return { reason: "stopped", detail: "stopped by you" };
|
|
92
|
+
if (c.blocked) return { reason: "blocked", detail: c.blocked };
|
|
93
|
+
const open = c.criteria.filter(
|
|
94
|
+
(k) => k.status !== "met" && k.status !== "waived"
|
|
95
|
+
);
|
|
96
|
+
if (c.criteria.length && !open.length && c.green)
|
|
97
|
+
return {
|
|
98
|
+
reason: "done",
|
|
99
|
+
detail: `all ${c.criteria.length} criteria met, checks green`,
|
|
100
|
+
};
|
|
101
|
+
const sig = repeated(c.cycles, c.limits.repeatCycles);
|
|
102
|
+
if (sig)
|
|
103
|
+
return {
|
|
104
|
+
reason: "repeat",
|
|
105
|
+
detail: `the same failure on ${c.limits.repeatCycles} cycles in a row: ${sig.slice(0, 200)}`,
|
|
106
|
+
};
|
|
107
|
+
if (stalled(c.cycles, c.limits.stallCycles))
|
|
108
|
+
return {
|
|
109
|
+
reason: "no_progress",
|
|
110
|
+
detail: `no improvement in ${c.limits.stallCycles} cycles`,
|
|
111
|
+
};
|
|
112
|
+
return limitStop({ ...c, cycleCount: c.cycleCount ?? c.cycles.length });
|
|
113
|
+
}
|