@gethmy/harness 1.0.0
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 +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The motor's only door to Harmony: read the one card it was invoked for, read
|
|
3
|
+
* that card's pinned stage definition, fetch a held oracle, record gate
|
|
4
|
+
* evidence. Nothing else — the motor does not read the board, does not advance
|
|
5
|
+
* a stage, and does not decide what runs next.
|
|
6
|
+
*
|
|
7
|
+
* The card read is deliberately NARROW: `fetchStageCard` returns exactly
|
|
8
|
+
* `current_stage`, `playbook_id` and `playbook_version` and drops every other
|
|
9
|
+
* field the API sends, so no board content (title, description, comments) can
|
|
10
|
+
* flow onward from here — not into a prompt, not into a log. Those three fields
|
|
11
|
+
* exist because `resolveStageGate` (gate-collectors.ts) takes a card, not a
|
|
12
|
+
* request: it needs the PIN to read the frozen stage definition the gate is
|
|
13
|
+
* collected against. Passing `playbook_id` / `playbook_version` as CLI flags
|
|
14
|
+
* instead would move the read into the driver and make the pin forgeable by the
|
|
15
|
+
* caller.
|
|
16
|
+
*
|
|
17
|
+
* `request` is the generic seam `resolveStageGate` consumes
|
|
18
|
+
* (`(method, path) => Promise<{ version: … }>`). It THROWS on a non-ok status.
|
|
19
|
+
* `fetchOracle` deliberately does not: a denied or missing oracle returns null
|
|
20
|
+
* so the collector reports `blocked`. A gate with no signal must never read as
|
|
21
|
+
* a pass, and after ruling 27 a 403 also means "this caller does not own the
|
|
22
|
+
* run", which is exactly the signal worth logging loudly.
|
|
23
|
+
*
|
|
24
|
+
* Auth is the `X-API-Key` header, which is what harmony-api reads for an
|
|
25
|
+
* agent/API key (`supabase/functions/harmony-api/index.ts`; the same header
|
|
26
|
+
* also carries an OAuth access token when it starts with `hmy_at_`). An
|
|
27
|
+
* `authorization: Bearer` header would fall through to the Supabase-JWT path
|
|
28
|
+
* and 401. `packages/mcp-server/src/api-client.ts` sends the same header, and
|
|
29
|
+
* this client mirrors its URL shape too: `HARMONY_API_URL` is the API BASE
|
|
30
|
+
* (e.g. `https://app.gethmy.com/api`) and `/v1` is appended per request.
|
|
31
|
+
*/
|
|
32
|
+
import type { Card, StageGateEvidenceInsert } from "@harmony/shared";
|
|
33
|
+
import { log } from "./log.js";
|
|
34
|
+
import type { HeldOracle } from "./oracle.js";
|
|
35
|
+
|
|
36
|
+
const TAG = "harmony-client";
|
|
37
|
+
|
|
38
|
+
export interface HarmonyClientConfig {
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
apiKey: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The three card fields the motor reads, and the only ones it keeps. Typed off
|
|
45
|
+
* `Card` so a rename in the shared model breaks this compile rather than
|
|
46
|
+
* silently producing an unresolvable pin.
|
|
47
|
+
*/
|
|
48
|
+
export type StageCardPin = Pick<
|
|
49
|
+
Card,
|
|
50
|
+
"current_stage" | "playbook_id" | "playbook_version"
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
/** The oracle payload `POST /stage-oracle/fetch` returns (camelCase, edge-side). */
|
|
54
|
+
interface OracleResponse {
|
|
55
|
+
path: string;
|
|
56
|
+
content: string;
|
|
57
|
+
runnerHint?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readClientConfig(
|
|
61
|
+
env: Record<string, string | undefined>,
|
|
62
|
+
): HarmonyClientConfig {
|
|
63
|
+
const apiUrl = env.HARMONY_API_URL?.trim();
|
|
64
|
+
const apiKey = env.HARMONY_API_KEY?.trim();
|
|
65
|
+
if (!apiUrl) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"HARMONY_API_URL is not set — the motor needs the Harmony API base URL (e.g. https://app.gethmy.com/api)",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!apiKey) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"HARMONY_API_KEY is not set — the motor needs a Harmony API key or OAuth access token",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// Trim trailing slashes so the built path has exactly one separator. A blank
|
|
76
|
+
// value is refused above rather than sent: an empty credential would 401 at
|
|
77
|
+
// the edge with no hint about which end is misconfigured.
|
|
78
|
+
return { apiUrl: apiUrl.replace(/\/+$/, ""), apiKey };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class HarmonyClient {
|
|
82
|
+
constructor(private readonly config: HarmonyClientConfig) {}
|
|
83
|
+
|
|
84
|
+
/** One raw call. Returns the Response so a caller can judge the status itself. */
|
|
85
|
+
private async send(
|
|
86
|
+
method: string,
|
|
87
|
+
path: string,
|
|
88
|
+
body?: unknown,
|
|
89
|
+
): Promise<Response> {
|
|
90
|
+
return await fetch(`${this.config.apiUrl}/v1${path}`, {
|
|
91
|
+
method,
|
|
92
|
+
headers: {
|
|
93
|
+
"X-API-Key": this.config.apiKey,
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
accept: "application/json",
|
|
96
|
+
},
|
|
97
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The generic request seam. `resolveStageGate` reads the pinned
|
|
103
|
+
* `playbook_versions` snapshot through this; `fetchStageCard` uses it too.
|
|
104
|
+
* Throws on a non-ok status — the caller that must not throw (`fetchOracle`)
|
|
105
|
+
* uses {@link send} directly instead.
|
|
106
|
+
*/
|
|
107
|
+
async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
108
|
+
const response = await this.send(method, path, body);
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${method} ${path} failed with ${response.status}${await detail(response)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return (await response.json()) as T;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read the card's stage pin. Only the three pinned fields survive this
|
|
119
|
+
* function — see the module doc comment for why the read exists at all and
|
|
120
|
+
* why it stops there.
|
|
121
|
+
*/
|
|
122
|
+
async fetchStageCard(cardId: string): Promise<StageCardPin> {
|
|
123
|
+
const { card } = await this.request<{ card: StageCardPin }>(
|
|
124
|
+
"GET",
|
|
125
|
+
`/cards/${encodeURIComponent(cardId)}`,
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
current_stage: card.current_stage ?? null,
|
|
129
|
+
playbook_id: card.playbook_id ?? null,
|
|
130
|
+
playbook_version: card.playbook_version ?? null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetch the held oracle for `stageId`. The session id travels in the body
|
|
136
|
+
* beside `purpose: "gate_evaluation"` — the read route binds the caller to the
|
|
137
|
+
* card's ACTIVE agent session (ruling 27), so an ended or foreign session is a
|
|
138
|
+
* 403.
|
|
139
|
+
*
|
|
140
|
+
* ANY non-ok status returns null, not just 403/404: the collector turns null
|
|
141
|
+
* into `blocked`, which is the honest outcome for "no oracle was read" whatever
|
|
142
|
+
* the reason. The status itself is logged here, because the collector's
|
|
143
|
+
* `blocked` reason cannot distinguish a refusal from an absence.
|
|
144
|
+
*/
|
|
145
|
+
async fetchOracle(
|
|
146
|
+
cardId: string,
|
|
147
|
+
stageId: string,
|
|
148
|
+
sessionId: string,
|
|
149
|
+
): Promise<HeldOracle | null> {
|
|
150
|
+
const response = await this.send("POST", "/stage-oracle/fetch", {
|
|
151
|
+
cardId,
|
|
152
|
+
stageId,
|
|
153
|
+
sessionId,
|
|
154
|
+
purpose: "gate_evaluation",
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
log.warn(
|
|
158
|
+
TAG,
|
|
159
|
+
`Oracle fetch for stage ${stageId} returned ${response.status} — no oracle read, the gate will report blocked`,
|
|
160
|
+
);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const body = (await response.json()) as OracleResponse;
|
|
164
|
+
return {
|
|
165
|
+
path: body.path,
|
|
166
|
+
content: body.content,
|
|
167
|
+
runnerHint: body.runnerHint ?? null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Persist one evidence row. The card id in the insert IS the id in the route's
|
|
173
|
+
* path (`POST /cards/:cardId/stage-gate-evidence`) — the same shape
|
|
174
|
+
* `packages/mcp-server/src/api-client.ts` uses. Throws on a refused write:
|
|
175
|
+
* the stored row is what the human-advance edge re-evaluates, so a lost row
|
|
176
|
+
* must never read as a recorded one.
|
|
177
|
+
*/
|
|
178
|
+
async recordStageGateEvidence(
|
|
179
|
+
insert: StageGateEvidenceInsert,
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
await this.request(
|
|
182
|
+
"POST",
|
|
183
|
+
`/cards/${encodeURIComponent(insert.card_id)}/stage-gate-evidence`,
|
|
184
|
+
insert,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Best-effort error detail for a failed request. Never throws. */
|
|
190
|
+
async function detail(response: Response): Promise<string> {
|
|
191
|
+
try {
|
|
192
|
+
const text = await response.text();
|
|
193
|
+
return text ? `: ${text.slice(0, 500)}` : "";
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the Harmony execution motor.
|
|
3
|
+
*
|
|
4
|
+
* The motor runs exactly ONE playbook stage per invocation and exits. It owns the
|
|
5
|
+
* worktree, dispatches the subagent for the stage's role, places and removes the
|
|
6
|
+
* held oracle, and collects gate evidence. It deliberately does NOT decide which
|
|
7
|
+
* stage runs next (Harmony does), does NOT form a gate verdict (`gateEvaluate` in
|
|
8
|
+
* @harmony/shared does), and does NOT push or merge.
|
|
9
|
+
*
|
|
10
|
+
* Dependency direction is agent → harness → shared. Importing from @gethmy/agent
|
|
11
|
+
* here would create a cycle and is never correct.
|
|
12
|
+
*/
|
|
13
|
+
export const MOTOR_NAME = "harmony-harness";
|
|
14
|
+
|
|
15
|
+
export * from "./artifact-judge.js";
|
|
16
|
+
export * from "./command-metric.js";
|
|
17
|
+
export * from "./error-classifier.js";
|
|
18
|
+
export * from "./exec-types.js";
|
|
19
|
+
export * from "./gate-collectors.js";
|
|
20
|
+
export * from "./gate-config-error.js";
|
|
21
|
+
export * from "./git-diff-stat.js";
|
|
22
|
+
export * from "./git-pr.js";
|
|
23
|
+
export * from "./harmony-client.js";
|
|
24
|
+
export * from "./log.js";
|
|
25
|
+
export * from "./model-tier.js";
|
|
26
|
+
export * from "./oracle.js";
|
|
27
|
+
export * from "./oracle-collector.js";
|
|
28
|
+
export * from "./pm.js";
|
|
29
|
+
export * from "./process-group.js";
|
|
30
|
+
export * from "./project-type.js";
|
|
31
|
+
export * from "./revert-guard.js";
|
|
32
|
+
export * from "./review-types.js";
|
|
33
|
+
export * from "./runner.js";
|
|
34
|
+
export * from "./sdk-agent-runner.js";
|
|
35
|
+
export * from "./stage-run.js";
|
|
36
|
+
export * from "./verification.js";
|
|
37
|
+
export * from "./worktree.js";
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for the agent daemon.
|
|
3
|
+
*
|
|
4
|
+
* When stderr is a TTY, output is ANSI-coloured single-line text — the
|
|
5
|
+
* readable default for humans running the daemon interactively. When
|
|
6
|
+
* stderr is piped/redirected (files, logshippers, systemd), output
|
|
7
|
+
* falls back to one JSON object per line — trivially `jq`-able,
|
|
8
|
+
* indexable, and machine-readable.
|
|
9
|
+
*
|
|
10
|
+
* Overrides: `--pretty` / HARMONY_AGENT_PRETTY=1 force pretty;
|
|
11
|
+
* `--json` / HARMONY_AGENT_JSON=1 force JSON. Explicit flags beat
|
|
12
|
+
* TTY detection either way.
|
|
13
|
+
*
|
|
14
|
+
* Stdout is left clean so consumers can pipe structured data if they
|
|
15
|
+
* want to (future use).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type Level = "debug" | "info" | "warn" | "error";
|
|
19
|
+
|
|
20
|
+
export interface LogContext {
|
|
21
|
+
event?: string;
|
|
22
|
+
runId?: string;
|
|
23
|
+
cardId?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LogRecord extends LogContext {
|
|
28
|
+
ts: string;
|
|
29
|
+
level: Level;
|
|
30
|
+
tag: string;
|
|
31
|
+
msg: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const COLORS = {
|
|
35
|
+
reset: "\x1b[0m",
|
|
36
|
+
dim: "\x1b[2m",
|
|
37
|
+
red: "\x1b[31m",
|
|
38
|
+
green: "\x1b[32m",
|
|
39
|
+
yellow: "\x1b[33m",
|
|
40
|
+
blue: "\x1b[34m",
|
|
41
|
+
cyan: "\x1b[36m",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const LEVEL_COLOR: Record<Level, string> = {
|
|
45
|
+
debug: COLORS.dim,
|
|
46
|
+
info: COLORS.green,
|
|
47
|
+
warn: COLORS.yellow,
|
|
48
|
+
error: COLORS.red,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function pretty(): boolean {
|
|
52
|
+
if (
|
|
53
|
+
process.env.HARMONY_AGENT_JSON === "1" ||
|
|
54
|
+
process.argv.includes("--json")
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (process.env.HARMONY_AGENT_PRETTY === "1") return true;
|
|
59
|
+
if (process.argv.includes("--pretty")) return true;
|
|
60
|
+
return Boolean(process.stderr.isTTY);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function shortTime(iso: string): string {
|
|
64
|
+
return iso.slice(11, 23);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emit(rec: LogRecord): void {
|
|
68
|
+
if (rec.level === "debug" && !process.env.DEBUG) return;
|
|
69
|
+
|
|
70
|
+
if (pretty()) {
|
|
71
|
+
const color = LEVEL_COLOR[rec.level];
|
|
72
|
+
const label = rec.level.toUpperCase().padEnd(5, " ");
|
|
73
|
+
const ctx: string[] = [];
|
|
74
|
+
if (rec.event) ctx.push(`event=${rec.event}`);
|
|
75
|
+
if (rec.runId) ctx.push(`run=${rec.runId}`);
|
|
76
|
+
if (rec.cardId) ctx.push(`card=${rec.cardId}`);
|
|
77
|
+
const tail = ctx.length
|
|
78
|
+
? ` ${COLORS.dim}(${ctx.join(" ")})${COLORS.reset}`
|
|
79
|
+
: "";
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`${COLORS.dim}${shortTime(rec.ts)}${COLORS.reset} ${color}${label}${COLORS.reset} ${COLORS.cyan}[${rec.tag}]${COLORS.reset} ${rec.msg}${tail}\n`,
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process.stderr.write(`${JSON.stringify(rec)}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function record(
|
|
90
|
+
level: Level,
|
|
91
|
+
tag: string,
|
|
92
|
+
msg: string,
|
|
93
|
+
ctx?: LogContext,
|
|
94
|
+
): void {
|
|
95
|
+
const rec: LogRecord = {
|
|
96
|
+
ts: new Date().toISOString(),
|
|
97
|
+
level,
|
|
98
|
+
tag,
|
|
99
|
+
msg,
|
|
100
|
+
...(ctx ?? {}),
|
|
101
|
+
};
|
|
102
|
+
emit(rec);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const log = {
|
|
106
|
+
info(tag: string, msg: string, ctx?: LogContext) {
|
|
107
|
+
record("info", tag, msg, ctx);
|
|
108
|
+
},
|
|
109
|
+
warn(tag: string, msg: string, ctx?: LogContext) {
|
|
110
|
+
record("warn", tag, msg, ctx);
|
|
111
|
+
},
|
|
112
|
+
error(tag: string, msg: string, ctx?: LogContext) {
|
|
113
|
+
record("error", tag, msg, ctx);
|
|
114
|
+
},
|
|
115
|
+
debug(tag: string, msg: string, ctx?: LogContext) {
|
|
116
|
+
record("debug", tag, msg, ctx);
|
|
117
|
+
},
|
|
118
|
+
/**
|
|
119
|
+
* Emit a named event. Semantically the same as `info` but signals to
|
|
120
|
+
* downstream tooling that this is a structured event worth indexing.
|
|
121
|
+
*/
|
|
122
|
+
event(tag: string, event: string, ctx?: LogContext) {
|
|
123
|
+
record("info", tag, event, { ...ctx, event });
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function isPretty(): boolean {
|
|
128
|
+
return pretty();
|
|
129
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Card } from "@harmony/shared";
|
|
2
|
+
import { escalateTier, tierFromScore } from "@harmony/shared";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
chooseImplementModel,
|
|
6
|
+
MAX_IMPLEMENT_MODEL,
|
|
7
|
+
type ModelTierConfig,
|
|
8
|
+
} from "./model-tier.js";
|
|
9
|
+
|
|
10
|
+
const claude: ModelTierConfig = {
|
|
11
|
+
model: "opus",
|
|
12
|
+
escalateModel: "claude-opus-4-8",
|
|
13
|
+
escalateAfterAttempts: 2,
|
|
14
|
+
tiers: { simple: "haiku", advanced: "sonnet", research: "opus" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function card(p: Partial<Card>): Card {
|
|
18
|
+
return {
|
|
19
|
+
priority: "medium",
|
|
20
|
+
model_tier: null,
|
|
21
|
+
model_override: null,
|
|
22
|
+
...p,
|
|
23
|
+
} as Card;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("tierFromScore", () => {
|
|
27
|
+
it("maps score ranges to tiers and clamps", () => {
|
|
28
|
+
expect(tierFromScore(0)).toBe("simple");
|
|
29
|
+
expect(tierFromScore(2)).toBe("simple");
|
|
30
|
+
expect(tierFromScore(3)).toBe("advanced");
|
|
31
|
+
expect(tierFromScore(6)).toBe("advanced");
|
|
32
|
+
expect(tierFromScore(7)).toBe("research");
|
|
33
|
+
expect(tierFromScore(99)).toBe("research");
|
|
34
|
+
expect(tierFromScore(-5)).toBe("simple");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("escalateTier", () => {
|
|
39
|
+
it("bumps one level, capping at research", () => {
|
|
40
|
+
expect(escalateTier("simple")).toBe("advanced");
|
|
41
|
+
expect(escalateTier("advanced")).toBe("research");
|
|
42
|
+
expect(escalateTier("research")).toBe("research");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("chooseImplementModel", () => {
|
|
47
|
+
it("honors a pinned override above everything (no escalation)", () => {
|
|
48
|
+
const r = chooseImplementModel(
|
|
49
|
+
claude,
|
|
50
|
+
card({
|
|
51
|
+
model_override: "claude-opus-4-8",
|
|
52
|
+
model_tier: "simple",
|
|
53
|
+
priority: "urgent",
|
|
54
|
+
}),
|
|
55
|
+
5,
|
|
56
|
+
);
|
|
57
|
+
expect(r).toEqual({
|
|
58
|
+
model: "claude-opus-4-8",
|
|
59
|
+
escalated: false,
|
|
60
|
+
source: "override",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("resolves a tier to its configured model on the first attempt", () => {
|
|
65
|
+
const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 1);
|
|
66
|
+
expect(r).toEqual({ model: "haiku", escalated: false, source: "tier" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("escalates the tier up one level on a retry", () => {
|
|
70
|
+
const r = chooseImplementModel(claude, card({ model_tier: "simple" }), 2);
|
|
71
|
+
expect(r).toEqual({ model: "sonnet", escalated: true, source: "tier" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to global policy when no tier/override is set", () => {
|
|
75
|
+
expect(chooseImplementModel(claude, card({}), 1)).toEqual({
|
|
76
|
+
model: "opus",
|
|
77
|
+
escalated: false,
|
|
78
|
+
source: "policy",
|
|
79
|
+
});
|
|
80
|
+
expect(chooseImplementModel(claude, card({}), 2)).toEqual({
|
|
81
|
+
model: "claude-opus-4-8",
|
|
82
|
+
escalated: true,
|
|
83
|
+
source: "policy",
|
|
84
|
+
});
|
|
85
|
+
expect(chooseImplementModel(claude, card({ priority: "high" }), 1)).toEqual(
|
|
86
|
+
{ model: "claude-opus-4-8", escalated: true, source: "policy" },
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("spawns Fable 5 unclamped now that it is available again (#747)", () => {
|
|
91
|
+
// Pinned override.
|
|
92
|
+
expect(
|
|
93
|
+
chooseImplementModel(
|
|
94
|
+
claude,
|
|
95
|
+
card({ model_override: "claude-fable-5" }),
|
|
96
|
+
1,
|
|
97
|
+
),
|
|
98
|
+
).toEqual({
|
|
99
|
+
model: "claude-fable-5",
|
|
100
|
+
escalated: false,
|
|
101
|
+
source: "override",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Global policy escalation (the shipped default).
|
|
105
|
+
const fableEscalate = { ...claude, escalateModel: "claude-fable-5" };
|
|
106
|
+
expect(chooseImplementModel(fableEscalate, card({}), 2)).toEqual({
|
|
107
|
+
model: "claude-fable-5",
|
|
108
|
+
escalated: true,
|
|
109
|
+
source: "policy",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Tier mapping.
|
|
113
|
+
const fableTier = {
|
|
114
|
+
...claude,
|
|
115
|
+
tiers: {
|
|
116
|
+
simple: "haiku",
|
|
117
|
+
advanced: "sonnet",
|
|
118
|
+
research: "claude-fable-5",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
expect(
|
|
122
|
+
chooseImplementModel(fableTier, card({ model_tier: "research" }), 1)
|
|
123
|
+
.model,
|
|
124
|
+
).toBe("claude-fable-5");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("clamps a retired model id up to the ceiling from any source", () => {
|
|
128
|
+
// Pinned override naming a retired model.
|
|
129
|
+
expect(
|
|
130
|
+
chooseImplementModel(
|
|
131
|
+
claude,
|
|
132
|
+
card({ model_override: "claude-3-5-sonnet-20241022" }),
|
|
133
|
+
1,
|
|
134
|
+
),
|
|
135
|
+
).toEqual({
|
|
136
|
+
model: MAX_IMPLEMENT_MODEL,
|
|
137
|
+
escalated: false,
|
|
138
|
+
source: "override",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Stale config still pointing escalateModel at a retired model.
|
|
142
|
+
const staleConfig = { ...claude, escalateModel: "claude-3-opus-20240229" };
|
|
143
|
+
expect(chooseImplementModel(staleConfig, card({}), 2)).toEqual({
|
|
144
|
+
model: MAX_IMPLEMENT_MODEL,
|
|
145
|
+
escalated: true,
|
|
146
|
+
source: "policy",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Tier mapping pointing at a retired model.
|
|
150
|
+
const retiredTier = {
|
|
151
|
+
...claude,
|
|
152
|
+
tiers: { simple: "claude-2.1", advanced: "sonnet", research: "opus" },
|
|
153
|
+
};
|
|
154
|
+
expect(
|
|
155
|
+
chooseImplementModel(retiredTier, card({ model_tier: "simple" }), 1)
|
|
156
|
+
.model,
|
|
157
|
+
).toBe(MAX_IMPLEMENT_MODEL);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("falls back to base model if a tier has no configured mapping", () => {
|
|
161
|
+
const r = chooseImplementModel(
|
|
162
|
+
{ ...claude, tiers: { simple: "", advanced: "", research: "" } },
|
|
163
|
+
card({ model_tier: "research" }),
|
|
164
|
+
1,
|
|
165
|
+
);
|
|
166
|
+
expect(r.model).toBe("opus");
|
|
167
|
+
expect(r.source).toBe("tier");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Card } from "@harmony/shared";
|
|
2
|
+
import { escalateTier, isModelTier } from "@harmony/shared";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The narrow slice of `AgentConfig["claude"]` that {@link chooseImplementModel}
|
|
6
|
+
* actually reads. The daemon's `AgentConfig` carries fields this module never
|
|
7
|
+
* touches (`reviewModel`, `maxTurns`, `leanSettingSources`, …); importing the
|
|
8
|
+
* whole daemon type would pull `harness → agent`, which the dependency
|
|
9
|
+
* direction (agent → harness → shared) forbids. `VerificationConfig.claude` in
|
|
10
|
+
* `exec-types.ts` is a *different* narrow slice of the same daemon field — two
|
|
11
|
+
* narrow types over the same source, each declaring only what its consumer
|
|
12
|
+
* reads.
|
|
13
|
+
*/
|
|
14
|
+
export interface ModelTierConfig {
|
|
15
|
+
model: string;
|
|
16
|
+
escalateModel: string;
|
|
17
|
+
escalateAfterAttempts: number;
|
|
18
|
+
tiers: { simple: string; advanced: string; research: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hard ceiling for any implement run: Fable 5, the most capable model the
|
|
23
|
+
* daemon may spawn — nothing escalates past it. Also the substitute a retired
|
|
24
|
+
* model id is clamped to (see {@link clampWithdrawn}).
|
|
25
|
+
*
|
|
26
|
+
* Fable 5 was previously withdrawn from availability, which is why this used to
|
|
27
|
+
* point at Opus 4.8 and the clamp below matched `/fable/`. It is generally
|
|
28
|
+
* available again (#747), so it is once more the top of the ladder.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_IMPLEMENT_MODEL = "claude-fable-5";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Retired model ids that must never reach the Claude CLI. Every `claude-2.x`
|
|
34
|
+
* and `claude-3.x` id has now passed its retirement date (the last one,
|
|
35
|
+
* `claude-3-haiku-20240307`, retired 2026-04-19), so the whole 2/3 generation
|
|
36
|
+
* 404s on the API. The 4.x family is still served and is deliberately NOT
|
|
37
|
+
* matched — an operator pinning Opus 4.8 gets Opus 4.8.
|
|
38
|
+
*/
|
|
39
|
+
const RETIRED_MODEL = /^claude-[23][.-]/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clamp a requested model up to {@link MAX_IMPLEMENT_MODEL} if it names a
|
|
43
|
+
* retired model. Applied to every resolution path (pinned override, tier
|
|
44
|
+
* mapping, global policy) so a stale `config.json` or a manually-pinned card
|
|
45
|
+
* can't spawn a model that no longer exists.
|
|
46
|
+
*
|
|
47
|
+
* Exported so the #517 artifact judge clamps its lean tier through the same gate
|
|
48
|
+
* (a stale judge model id can't reach the SDK either).
|
|
49
|
+
*/
|
|
50
|
+
export function clampWithdrawn(model: string): string {
|
|
51
|
+
return RETIRED_MODEL.test(model) ? MAX_IMPLEMENT_MODEL : model;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pick the model for an implement run.
|
|
56
|
+
*
|
|
57
|
+
* Precedence (#354):
|
|
58
|
+
* 1. `card.model_override` — a user-pinned concrete model id. Always wins,
|
|
59
|
+
* never auto-escalates (the user chose it deliberately).
|
|
60
|
+
* 2. `card.model_tier` — the classifier's suggested tier, resolved through
|
|
61
|
+
* `claude.tiers`. On a retry (attempts >= escalateAfterAttempts) the tier
|
|
62
|
+
* bumps up one level before resolving.
|
|
63
|
+
* 3. Global policy fallback — the original #348 behaviour: escalate to
|
|
64
|
+
* `escalateModel` on high/urgent priority or on a retry, else `model`.
|
|
65
|
+
*
|
|
66
|
+
* Whichever path wins, the result is clamped to {@link MAX_IMPLEMENT_MODEL}:
|
|
67
|
+
* a retired model id is never spawned regardless of its source.
|
|
68
|
+
*
|
|
69
|
+
* Pure so the policy is unit-testable without spawning a worker.
|
|
70
|
+
*/
|
|
71
|
+
export function chooseImplementModel(
|
|
72
|
+
claude: ModelTierConfig,
|
|
73
|
+
card: Pick<Card, "priority" | "model_tier" | "model_override">,
|
|
74
|
+
attempts: number,
|
|
75
|
+
): {
|
|
76
|
+
model: string;
|
|
77
|
+
escalated: boolean;
|
|
78
|
+
source: "override" | "tier" | "policy";
|
|
79
|
+
} {
|
|
80
|
+
if (card.model_override) {
|
|
81
|
+
return {
|
|
82
|
+
model: clampWithdrawn(card.model_override),
|
|
83
|
+
escalated: false,
|
|
84
|
+
source: "override",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isModelTier(card.model_tier)) {
|
|
89
|
+
const retry = attempts >= claude.escalateAfterAttempts;
|
|
90
|
+
const tier = retry ? escalateTier(card.model_tier) : card.model_tier;
|
|
91
|
+
const mapped = claude.tiers?.[tier];
|
|
92
|
+
return {
|
|
93
|
+
model: clampWithdrawn(
|
|
94
|
+
mapped && mapped.length > 0 ? mapped : claude.model,
|
|
95
|
+
),
|
|
96
|
+
escalated: retry,
|
|
97
|
+
source: "tier",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const highPriority = card.priority === "high" || card.priority === "urgent";
|
|
102
|
+
const escalated = highPriority || attempts >= claude.escalateAfterAttempts;
|
|
103
|
+
return {
|
|
104
|
+
model: clampWithdrawn(escalated ? claude.escalateModel : claude.model),
|
|
105
|
+
escalated,
|
|
106
|
+
source: "policy",
|
|
107
|
+
};
|
|
108
|
+
}
|