@calltelemetry/openclaw-linear 0.8.8 → 0.9.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 +194 -91
- package/index.ts +36 -4
- package/package.json +1 -1
- package/src/__test__/webhook-scenarios.test.ts +1 -1
- package/src/gateway/dispatch-methods.test.ts +9 -9
- package/src/infra/commands.test.ts +5 -5
- package/src/infra/config-paths.test.ts +246 -0
- package/src/infra/doctor.ts +45 -36
- package/src/infra/notify.test.ts +49 -0
- package/src/infra/notify.ts +7 -2
- package/src/infra/observability.ts +1 -0
- package/src/infra/shared-profiles.test.ts +262 -0
- package/src/infra/shared-profiles.ts +116 -0
- package/src/infra/template.test.ts +86 -0
- package/src/infra/template.ts +18 -0
- package/src/infra/validation.test.ts +175 -0
- package/src/infra/validation.ts +52 -0
- package/src/pipeline/active-session.test.ts +2 -2
- package/src/pipeline/agent-end-hook.test.ts +305 -0
- package/src/pipeline/artifacts.test.ts +3 -3
- package/src/pipeline/dispatch-state.test.ts +111 -8
- package/src/pipeline/dispatch-state.ts +48 -13
- package/src/pipeline/e2e-dispatch.test.ts +2 -2
- package/src/pipeline/intent-classify.test.ts +20 -2
- package/src/pipeline/intent-classify.ts +14 -24
- package/src/pipeline/pipeline.ts +28 -11
- package/src/pipeline/planner.ts +1 -8
- package/src/pipeline/planning-state.ts +9 -0
- package/src/pipeline/tier-assess.test.ts +39 -39
- package/src/pipeline/tier-assess.ts +15 -33
- package/src/pipeline/webhook.test.ts +149 -1
- package/src/pipeline/webhook.ts +90 -62
- package/src/tools/dispatch-history-tool.test.ts +21 -20
- package/src/tools/dispatch-history-tool.ts +1 -1
- package/src/tools/linear-issues-tool.test.ts +115 -0
- package/src/tools/linear-issues-tool.ts +25 -0
|
@@ -20,7 +20,7 @@ import { homedir } from "node:os";
|
|
|
20
20
|
// Types
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
|
|
23
|
-
export type Tier = "
|
|
23
|
+
export type Tier = "small" | "medium" | "high";
|
|
24
24
|
|
|
25
25
|
export type DispatchStatus =
|
|
26
26
|
| "dispatched"
|
|
@@ -79,6 +79,8 @@ export interface SessionMapping {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
export interface DispatchState {
|
|
82
|
+
/** Schema version — used by migrateState() for forward-compatible upgrades */
|
|
83
|
+
version: 2;
|
|
82
84
|
dispatches: {
|
|
83
85
|
active: Record<string, ActiveDispatch>;
|
|
84
86
|
completed: Record<string, CompletedDispatch>;
|
|
@@ -114,26 +116,38 @@ import { acquireLock, releaseLock } from "../infra/file-lock.js";
|
|
|
114
116
|
|
|
115
117
|
function emptyState(): DispatchState {
|
|
116
118
|
return {
|
|
119
|
+
version: 2,
|
|
117
120
|
dispatches: { active: {}, completed: {} },
|
|
118
121
|
sessionMap: {},
|
|
119
122
|
processedEvents: [],
|
|
120
123
|
};
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
/** Migrate
|
|
126
|
+
/** Migrate state from any known version to the current version (2). */
|
|
124
127
|
function migrateState(raw: any): DispatchState {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
const version = raw?.version ?? 1;
|
|
129
|
+
switch (version) {
|
|
130
|
+
case 1: {
|
|
131
|
+
// v1 → v2: add sessionMap, processedEvents, attempt defaults, status rename
|
|
132
|
+
const state = raw as DispatchState;
|
|
133
|
+
if (!state.sessionMap) state.sessionMap = {};
|
|
134
|
+
if (!state.processedEvents) state.processedEvents = [];
|
|
135
|
+
// Ensure all active dispatches have attempt field
|
|
136
|
+
for (const d of Object.values(state.dispatches.active)) {
|
|
137
|
+
if ((d as any).attempt === undefined) (d as any).attempt = 0;
|
|
138
|
+
}
|
|
139
|
+
// Migrate old status "running" → "working"
|
|
140
|
+
for (const d of Object.values(state.dispatches.active)) {
|
|
141
|
+
if ((d as any).status === "running") (d as any).status = "working";
|
|
142
|
+
}
|
|
143
|
+
state.version = 2;
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
case 2:
|
|
147
|
+
return raw as DispatchState;
|
|
148
|
+
default:
|
|
149
|
+
throw new Error(`Unknown dispatch state version: ${version}`);
|
|
135
150
|
}
|
|
136
|
-
return state;
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
export async function readDispatchState(configPath?: string): Promise<DispatchState> {
|
|
@@ -143,6 +157,15 @@ export async function readDispatchState(configPath?: string): Promise<DispatchSt
|
|
|
143
157
|
return migrateState(JSON.parse(raw));
|
|
144
158
|
} catch (err: any) {
|
|
145
159
|
if (err.code === "ENOENT") return emptyState();
|
|
160
|
+
if (err instanceof SyntaxError) {
|
|
161
|
+
// State file corrupted — log and recover
|
|
162
|
+
console.error(`Dispatch state corrupted at ${filePath}: ${err.message}. Starting fresh.`);
|
|
163
|
+
// Rename corrupted file for forensics
|
|
164
|
+
try {
|
|
165
|
+
await fs.rename(filePath, `${filePath}.corrupted.${Date.now()}`);
|
|
166
|
+
} catch { /* best-effort */ }
|
|
167
|
+
return emptyState();
|
|
168
|
+
}
|
|
146
169
|
throw err;
|
|
147
170
|
}
|
|
148
171
|
}
|
|
@@ -434,6 +457,18 @@ export async function pruneCompleted(
|
|
|
434
457
|
}
|
|
435
458
|
}
|
|
436
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Garbage-collect completed dispatches older than maxAgeMs.
|
|
462
|
+
* Convenience wrapper with a 7-day default.
|
|
463
|
+
* Returns the count of pruned entries.
|
|
464
|
+
*/
|
|
465
|
+
export async function pruneCompletedDispatches(
|
|
466
|
+
maxAgeMs: number = 7 * 24 * 60 * 60_000,
|
|
467
|
+
configPath?: string,
|
|
468
|
+
): Promise<number> {
|
|
469
|
+
return pruneCompleted(maxAgeMs, configPath);
|
|
470
|
+
}
|
|
471
|
+
|
|
437
472
|
/**
|
|
438
473
|
* Remove an active dispatch (e.g. when worktree is gone and branch is gone).
|
|
439
474
|
*/
|
|
@@ -100,7 +100,7 @@ function makeDispatch(worktreePath: string, overrides?: Partial<ActiveDispatch>)
|
|
|
100
100
|
issueIdentifier: "ENG-100",
|
|
101
101
|
worktreePath,
|
|
102
102
|
branch: "codex/ENG-100",
|
|
103
|
-
tier: "
|
|
103
|
+
tier: "small" as const,
|
|
104
104
|
model: "test-model",
|
|
105
105
|
status: "dispatched",
|
|
106
106
|
dispatchedAt: new Date().toISOString(),
|
|
@@ -468,7 +468,7 @@ describe("E2E dispatch pipeline", () => {
|
|
|
468
468
|
writeManifest(worktree, {
|
|
469
469
|
issueIdentifier: "ENG-100",
|
|
470
470
|
issueId: "issue-1",
|
|
471
|
-
tier: "
|
|
471
|
+
tier: "small",
|
|
472
472
|
status: "dispatched",
|
|
473
473
|
attempts: 0,
|
|
474
474
|
dispatchedAt: new Date().toISOString(),
|
|
@@ -151,16 +151,34 @@ describe("classifyIntent", () => {
|
|
|
151
151
|
expect(result.fromFallback).toBe(true);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
it("uses
|
|
154
|
+
it("uses 10s timeout for classification", async () => {
|
|
155
155
|
await classifyIntent(createApi(), createCtx());
|
|
156
156
|
|
|
157
157
|
expect(runAgentMock).toHaveBeenCalledWith(
|
|
158
158
|
expect.objectContaining({
|
|
159
|
-
timeoutMs:
|
|
159
|
+
timeoutMs: 10_000,
|
|
160
160
|
}),
|
|
161
161
|
);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
it("falls back to regex when classification times out via Promise.race", async () => {
|
|
165
|
+
// Simulate runAgent hanging beyond the 10s timeout
|
|
166
|
+
runAgentMock.mockImplementationOnce(
|
|
167
|
+
() => new Promise((resolve) => setTimeout(resolve, 60_000)),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Use fake timers to avoid actually waiting
|
|
171
|
+
vi.useFakeTimers();
|
|
172
|
+
const promise = classifyIntent(createApi(), createCtx({ commentBody: "fix the bug" }));
|
|
173
|
+
// Advance past the 10s timeout
|
|
174
|
+
await vi.advanceTimersByTimeAsync(10_001);
|
|
175
|
+
const result = await promise;
|
|
176
|
+
vi.useRealTimers();
|
|
177
|
+
|
|
178
|
+
expect(result.fromFallback).toBe(true);
|
|
179
|
+
expect(result.intent).toBe("general"); // regex fallback for "fix the bug" with no agent names matched
|
|
180
|
+
});
|
|
181
|
+
|
|
164
182
|
it("includes context in the prompt", async () => {
|
|
165
183
|
await classifyIntent(createApi(), createCtx({
|
|
166
184
|
commentBody: "what can I do?",
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Cost: one short agent turn (~300 tokens). Latency: ~2-5s.
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
10
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
11
|
+
import { resolveDefaultAgent } from "../infra/shared-profiles.js";
|
|
13
12
|
|
|
14
13
|
// ---------------------------------------------------------------------------
|
|
15
14
|
// Types
|
|
@@ -115,13 +114,19 @@ export async function classifyIntent(
|
|
|
115
114
|
try {
|
|
116
115
|
const { runAgent } = await import("../agent/agent.js");
|
|
117
116
|
const classifierAgent = resolveClassifierAgent(api, pluginConfig);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
const CLASSIFY_TIMEOUT_MS = 10_000;
|
|
118
|
+
const result = await Promise.race([
|
|
119
|
+
runAgent({
|
|
120
|
+
api,
|
|
121
|
+
agentId: classifierAgent,
|
|
122
|
+
sessionId: `intent-classify-${Date.now()}`,
|
|
123
|
+
message,
|
|
124
|
+
timeoutMs: CLASSIFY_TIMEOUT_MS,
|
|
125
|
+
}),
|
|
126
|
+
new Promise<never>((_, reject) =>
|
|
127
|
+
setTimeout(() => reject(new Error("Intent classification timed out")), CLASSIFY_TIMEOUT_MS)
|
|
128
|
+
),
|
|
129
|
+
]);
|
|
125
130
|
|
|
126
131
|
if (result.output) {
|
|
127
132
|
const parsed = parseIntentResponse(result.output, ctx);
|
|
@@ -252,18 +257,3 @@ function resolveClassifierAgent(api: OpenClawPluginApi, pluginConfig?: Record<st
|
|
|
252
257
|
// 2. Fall back to default agent
|
|
253
258
|
return resolveDefaultAgent(api);
|
|
254
259
|
}
|
|
255
|
-
|
|
256
|
-
function resolveDefaultAgent(api: OpenClawPluginApi): string {
|
|
257
|
-
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
258
|
-
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
262
|
-
const raw = readFileSync(profilesPath, "utf8");
|
|
263
|
-
const profiles = JSON.parse(raw).agents ?? {};
|
|
264
|
-
const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
|
|
265
|
-
if (defaultAgent) return defaultAgent[0];
|
|
266
|
-
} catch { /* fall through */ }
|
|
267
|
-
|
|
268
|
-
return "default";
|
|
269
|
-
}
|
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
} from "./artifacts.js";
|
|
49
49
|
import { resolveWatchdogConfig } from "../agent/watchdog.js";
|
|
50
50
|
import { emitDiagnostic } from "../infra/observability.js";
|
|
51
|
+
import { renderTemplate } from "../infra/template.js";
|
|
51
52
|
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Prompt loading
|
|
@@ -166,14 +167,6 @@ export function clearPromptCache(): void {
|
|
|
166
167
|
_projectPromptCache.clear();
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
170
|
-
let result = template;
|
|
171
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
172
|
-
result = result.replaceAll(`{{${key}}}`, value);
|
|
173
|
-
}
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
170
|
// ---------------------------------------------------------------------------
|
|
178
171
|
// Task builders
|
|
179
172
|
// ---------------------------------------------------------------------------
|
|
@@ -568,7 +561,15 @@ async function handleAuditPass(
|
|
|
568
561
|
).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
|
|
569
562
|
|
|
570
563
|
api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
|
|
571
|
-
emitDiagnostic(api, {
|
|
564
|
+
emitDiagnostic(api, {
|
|
565
|
+
event: "verdict_processed",
|
|
566
|
+
identifier: dispatch.issueIdentifier,
|
|
567
|
+
issueId: dispatch.issueId,
|
|
568
|
+
phase: "done",
|
|
569
|
+
attempt: dispatch.attempt,
|
|
570
|
+
tier: dispatch.tier,
|
|
571
|
+
agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
|
|
572
|
+
});
|
|
572
573
|
|
|
573
574
|
await notify("audit_pass", {
|
|
574
575
|
identifier: dispatch.issueIdentifier,
|
|
@@ -644,7 +645,15 @@ async function handleAuditFail(
|
|
|
644
645
|
).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
|
|
645
646
|
|
|
646
647
|
api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
|
|
647
|
-
emitDiagnostic(api, {
|
|
648
|
+
emitDiagnostic(api, {
|
|
649
|
+
event: "verdict_processed",
|
|
650
|
+
identifier: dispatch.issueIdentifier,
|
|
651
|
+
issueId: dispatch.issueId,
|
|
652
|
+
phase: "stuck",
|
|
653
|
+
attempt: nextAttempt,
|
|
654
|
+
tier: dispatch.tier,
|
|
655
|
+
agentId: (pluginConfig?.defaultAgentId as string) ?? "default",
|
|
656
|
+
});
|
|
648
657
|
|
|
649
658
|
await notify("escalation", {
|
|
650
659
|
identifier: dispatch.issueIdentifier,
|
|
@@ -820,7 +829,15 @@ export async function spawnWorker(
|
|
|
820
829
|
const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
|
|
821
830
|
|
|
822
831
|
api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
|
|
823
|
-
emitDiagnostic(api, {
|
|
832
|
+
emitDiagnostic(api, {
|
|
833
|
+
event: "watchdog_kill",
|
|
834
|
+
identifier: dispatch.issueIdentifier,
|
|
835
|
+
issueId: dispatch.issueId,
|
|
836
|
+
attempt: dispatch.attempt,
|
|
837
|
+
tier: dispatch.tier,
|
|
838
|
+
durationMs: workerElapsed,
|
|
839
|
+
agentId,
|
|
840
|
+
});
|
|
824
841
|
|
|
825
842
|
try {
|
|
826
843
|
appendLog(dispatch.worktreePath, {
|
package/src/pipeline/planner.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { runClaude } from "../tools/claude-tool.js";
|
|
27
27
|
import { runCodex } from "../tools/codex-tool.js";
|
|
28
28
|
import { runGemini } from "../tools/gemini-tool.js";
|
|
29
|
+
import { renderTemplate } from "../infra/template.js";
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
// Types
|
|
@@ -72,14 +73,6 @@ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerProm
|
|
|
72
73
|
return defaults;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
76
|
-
let result = template;
|
|
77
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
78
|
-
result = result.replaceAll(`{{${key}}}`, value);
|
|
79
|
-
}
|
|
80
|
-
return result;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
76
|
// ---------------------------------------------------------------------------
|
|
84
77
|
// Planning initiation
|
|
85
78
|
// ---------------------------------------------------------------------------
|
|
@@ -71,6 +71,15 @@ export async function readPlanningState(configPath?: string): Promise<PlanningSt
|
|
|
71
71
|
return parsed;
|
|
72
72
|
} catch (err: any) {
|
|
73
73
|
if (err.code === "ENOENT") return emptyState();
|
|
74
|
+
if (err instanceof SyntaxError) {
|
|
75
|
+
// State file corrupted — log and recover
|
|
76
|
+
console.error(`Planning state corrupted at ${filePath}: ${err.message}. Starting fresh.`);
|
|
77
|
+
// Rename corrupted file for forensics
|
|
78
|
+
try {
|
|
79
|
+
await fs.rename(filePath, `${filePath}.corrupted.${Date.now()}`);
|
|
80
|
+
} catch { /* best-effort */ }
|
|
81
|
+
return emptyState();
|
|
82
|
+
}
|
|
74
83
|
throw err;
|
|
75
84
|
}
|
|
76
85
|
}
|
|
@@ -44,10 +44,10 @@ function makeIssue(overrides?: Partial<IssueContext>): IssueContext {
|
|
|
44
44
|
// ---------------------------------------------------------------------------
|
|
45
45
|
|
|
46
46
|
describe("TIER_MODELS", () => {
|
|
47
|
-
it("maps
|
|
48
|
-
expect(TIER_MODELS.
|
|
49
|
-
expect(TIER_MODELS.
|
|
50
|
-
expect(TIER_MODELS.
|
|
47
|
+
it("maps small to haiku, medium to sonnet, high to opus", () => {
|
|
48
|
+
expect(TIER_MODELS.small).toBe("anthropic/claude-haiku-4-5");
|
|
49
|
+
expect(TIER_MODELS.medium).toBe("anthropic/claude-sonnet-4-6");
|
|
50
|
+
expect(TIER_MODELS.high).toBe("anthropic/claude-opus-4-6");
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -59,19 +59,19 @@ describe("assessTier", () => {
|
|
|
59
59
|
it("returns parsed tier from agent response", async () => {
|
|
60
60
|
mockRunAgent.mockResolvedValue({
|
|
61
61
|
success: true,
|
|
62
|
-
output: '{"tier":"
|
|
62
|
+
output: '{"tier":"high","reasoning":"Multi-service architecture change"}',
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
66
66
|
const result = await assessTier(api, makeIssue());
|
|
67
67
|
|
|
68
|
-
expect(result.tier).toBe("
|
|
69
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
68
|
+
expect(result.tier).toBe("high");
|
|
69
|
+
expect(result.model).toBe(TIER_MODELS.high);
|
|
70
70
|
expect(result.reasoning).toBe("Multi-service architecture change");
|
|
71
71
|
expect(api.logger.info).toHaveBeenCalled();
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("falls back to
|
|
74
|
+
it("falls back to medium when agent fails (success: false) with no parseable JSON", async () => {
|
|
75
75
|
mockRunAgent.mockResolvedValue({
|
|
76
76
|
success: false,
|
|
77
77
|
output: "Agent process exited with code 1",
|
|
@@ -80,13 +80,13 @@ describe("assessTier", () => {
|
|
|
80
80
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
81
81
|
const result = await assessTier(api, makeIssue());
|
|
82
82
|
|
|
83
|
-
expect(result.tier).toBe("
|
|
84
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
85
|
-
expect(result.reasoning).toBe("Assessment failed — defaulting to
|
|
83
|
+
expect(result.tier).toBe("medium");
|
|
84
|
+
expect(result.model).toBe(TIER_MODELS.medium);
|
|
85
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medium");
|
|
86
86
|
expect(api.logger.warn).toHaveBeenCalled();
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it("falls back to
|
|
89
|
+
it("falls back to medium when output has no JSON", async () => {
|
|
90
90
|
mockRunAgent.mockResolvedValue({
|
|
91
91
|
success: true,
|
|
92
92
|
output: "I think this is a medium complexity issue because it involves multiple files.",
|
|
@@ -95,12 +95,12 @@ describe("assessTier", () => {
|
|
|
95
95
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
96
96
|
const result = await assessTier(api, makeIssue());
|
|
97
97
|
|
|
98
|
-
expect(result.tier).toBe("
|
|
99
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
100
|
-
expect(result.reasoning).toBe("Assessment failed — defaulting to
|
|
98
|
+
expect(result.tier).toBe("medium");
|
|
99
|
+
expect(result.model).toBe(TIER_MODELS.medium);
|
|
100
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medium");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
it("falls back to
|
|
103
|
+
it("falls back to medium when JSON has invalid tier", async () => {
|
|
104
104
|
mockRunAgent.mockResolvedValue({
|
|
105
105
|
success: true,
|
|
106
106
|
output: '{"tier":"expert","reasoning":"Very hard problem"}',
|
|
@@ -109,8 +109,8 @@ describe("assessTier", () => {
|
|
|
109
109
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
110
110
|
const result = await assessTier(api, makeIssue());
|
|
111
111
|
|
|
112
|
-
expect(result.tier).toBe("
|
|
113
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
112
|
+
expect(result.tier).toBe("medium");
|
|
113
|
+
expect(result.model).toBe(TIER_MODELS.medium);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it("handles agent throwing an error", async () => {
|
|
@@ -119,9 +119,9 @@ describe("assessTier", () => {
|
|
|
119
119
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
120
120
|
const result = await assessTier(api, makeIssue());
|
|
121
121
|
|
|
122
|
-
expect(result.tier).toBe("
|
|
123
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
124
|
-
expect(result.reasoning).toBe("Assessment failed — defaulting to
|
|
122
|
+
expect(result.tier).toBe("medium");
|
|
123
|
+
expect(result.model).toBe(TIER_MODELS.medium);
|
|
124
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medium");
|
|
125
125
|
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
126
126
|
expect.stringContaining("Tier assessment error for CT-123"),
|
|
127
127
|
);
|
|
@@ -131,7 +131,7 @@ describe("assessTier", () => {
|
|
|
131
131
|
const longDescription = "A".repeat(3000);
|
|
132
132
|
mockRunAgent.mockResolvedValue({
|
|
133
133
|
success: true,
|
|
134
|
-
output: '{"tier":"
|
|
134
|
+
output: '{"tier":"small","reasoning":"Simple copy change"}',
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
@@ -149,7 +149,7 @@ describe("assessTier", () => {
|
|
|
149
149
|
it("uses configured agentId when provided", async () => {
|
|
150
150
|
mockRunAgent.mockResolvedValue({
|
|
151
151
|
success: true,
|
|
152
|
-
output: '{"tier":"
|
|
152
|
+
output: '{"tier":"small","reasoning":"Typo fix"}',
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
@@ -162,42 +162,42 @@ describe("assessTier", () => {
|
|
|
162
162
|
it("parses JSON even when wrapped in markdown fences", async () => {
|
|
163
163
|
mockRunAgent.mockResolvedValue({
|
|
164
164
|
success: true,
|
|
165
|
-
output: '```json\n{"tier":"
|
|
165
|
+
output: '```json\n{"tier":"small","reasoning":"Config tweak"}\n```',
|
|
166
166
|
});
|
|
167
167
|
|
|
168
168
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
169
169
|
const result = await assessTier(api, makeIssue());
|
|
170
170
|
|
|
171
|
-
expect(result.tier).toBe("
|
|
172
|
-
expect(result.model).toBe(TIER_MODELS.
|
|
171
|
+
expect(result.tier).toBe("small");
|
|
172
|
+
expect(result.model).toBe(TIER_MODELS.small);
|
|
173
173
|
expect(result.reasoning).toBe("Config tweak");
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
it("handles null description gracefully", async () => {
|
|
177
177
|
mockRunAgent.mockResolvedValue({
|
|
178
178
|
success: true,
|
|
179
|
-
output: '{"tier":"
|
|
179
|
+
output: '{"tier":"small","reasoning":"Trivial"}',
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
183
183
|
const result = await assessTier(api, makeIssue({ description: null }));
|
|
184
184
|
|
|
185
|
-
expect(result.tier).toBe("
|
|
185
|
+
expect(result.tier).toBe("small");
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
it("handles empty labels and no comments", async () => {
|
|
189
189
|
mockRunAgent.mockResolvedValue({
|
|
190
190
|
success: true,
|
|
191
|
-
output: '{"tier":"
|
|
191
|
+
output: '{"tier":"medium","reasoning":"Standard feature"}',
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
195
195
|
const result = await assessTier(api, makeIssue({ labels: [], commentCount: undefined }));
|
|
196
196
|
|
|
197
|
-
expect(result.tier).toBe("
|
|
197
|
+
expect(result.tier).toBe("medium");
|
|
198
198
|
});
|
|
199
199
|
|
|
200
|
-
it("falls back to
|
|
200
|
+
it("falls back to medium on malformed JSON (half JSON)", async () => {
|
|
201
201
|
mockRunAgent.mockResolvedValue({
|
|
202
202
|
success: true,
|
|
203
203
|
output: '{"tier":"seni',
|
|
@@ -206,40 +206,40 @@ describe("assessTier", () => {
|
|
|
206
206
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
207
207
|
const result = await assessTier(api, makeIssue());
|
|
208
208
|
|
|
209
|
-
expect(result.tier).toBe("
|
|
210
|
-
expect(result.reasoning).toBe("Assessment failed — defaulting to
|
|
209
|
+
expect(result.tier).toBe("medium");
|
|
210
|
+
expect(result.reasoning).toBe("Assessment failed — defaulting to medium");
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
it("provides default reasoning when missing from response", async () => {
|
|
214
214
|
mockRunAgent.mockResolvedValue({
|
|
215
215
|
success: true,
|
|
216
|
-
output: '{"tier":"
|
|
216
|
+
output: '{"tier":"high"}',
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
220
220
|
const result = await assessTier(api, makeIssue());
|
|
221
221
|
|
|
222
|
-
expect(result.tier).toBe("
|
|
222
|
+
expect(result.tier).toBe("high");
|
|
223
223
|
expect(result.reasoning).toBe("no reasoning provided");
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
it("extracts JSON from output with success=false but valid JSON", async () => {
|
|
227
227
|
mockRunAgent.mockResolvedValue({
|
|
228
228
|
success: false,
|
|
229
|
-
output: 'Agent exited early but: {"tier":"
|
|
229
|
+
output: 'Agent exited early but: {"tier":"small","reasoning":"Simple fix"}',
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
233
233
|
const result = await assessTier(api, makeIssue());
|
|
234
234
|
|
|
235
|
-
expect(result.tier).toBe("
|
|
235
|
+
expect(result.tier).toBe("small");
|
|
236
236
|
expect(result.reasoning).toBe("Simple fix");
|
|
237
237
|
});
|
|
238
238
|
|
|
239
239
|
it("defaults agentId from pluginConfig when not passed", async () => {
|
|
240
240
|
mockRunAgent.mockResolvedValue({
|
|
241
241
|
success: true,
|
|
242
|
-
output: '{"tier":"
|
|
242
|
+
output: '{"tier":"medium","reasoning":"Normal"}',
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
const api = makeApi({ defaultAgentId: "zoe" });
|
|
@@ -252,7 +252,7 @@ describe("assessTier", () => {
|
|
|
252
252
|
it("uses 30s timeout for assessment", async () => {
|
|
253
253
|
mockRunAgent.mockResolvedValue({
|
|
254
254
|
success: true,
|
|
255
|
-
output: '{"tier":"
|
|
255
|
+
output: '{"tier":"medium","reasoning":"Normal"}',
|
|
256
256
|
});
|
|
257
257
|
|
|
258
258
|
const api = makeApi({ defaultAgentId: "mal" });
|
|
@@ -7,19 +7,18 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Cost: one short agent turn (~500 tokens). Latency: ~2-5s.
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
10
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
13
11
|
import type { Tier } from "./dispatch-state.js";
|
|
12
|
+
import { resolveDefaultAgent } from "../infra/shared-profiles.js";
|
|
14
13
|
|
|
15
14
|
// ---------------------------------------------------------------------------
|
|
16
15
|
// Tier → Model mapping
|
|
17
16
|
// ---------------------------------------------------------------------------
|
|
18
17
|
|
|
19
18
|
export const TIER_MODELS: Record<Tier, string> = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
small: "anthropic/claude-haiku-4-5",
|
|
20
|
+
medium: "anthropic/claude-sonnet-4-6",
|
|
21
|
+
high: "anthropic/claude-opus-4-6",
|
|
23
22
|
};
|
|
24
23
|
|
|
25
24
|
export interface TierAssessment {
|
|
@@ -43,9 +42,9 @@ export interface IssueContext {
|
|
|
43
42
|
const ASSESS_PROMPT = `You are a complexity assessor. Assess this issue and respond ONLY with JSON.
|
|
44
43
|
|
|
45
44
|
Tiers:
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
45
|
+
- small: typos, copy changes, config tweaks, simple CSS, env var additions
|
|
46
|
+
- medium: features, bugfixes, moderate refactoring, adding tests, API changes
|
|
47
|
+
- high: architecture changes, database migrations, security fixes, multi-service coordination
|
|
49
48
|
|
|
50
49
|
Consider:
|
|
51
50
|
1. How many files/services are likely affected?
|
|
@@ -53,12 +52,12 @@ Consider:
|
|
|
53
52
|
3. Is the description clear and actionable?
|
|
54
53
|
4. Are there dependencies or unknowns?
|
|
55
54
|
|
|
56
|
-
Respond ONLY with: {"tier":"
|
|
55
|
+
Respond ONLY with: {"tier":"small|medium|high","reasoning":"one sentence"}`;
|
|
57
56
|
|
|
58
57
|
/**
|
|
59
58
|
* Assess issue complexity using the agent's configured model.
|
|
60
59
|
*
|
|
61
|
-
* Falls back to "
|
|
60
|
+
* Falls back to "medium" if the agent call fails or returns invalid JSON.
|
|
62
61
|
*/
|
|
63
62
|
export async function assessTier(
|
|
64
63
|
api: OpenClawPluginApi,
|
|
@@ -105,13 +104,13 @@ export async function assessTier(
|
|
|
105
104
|
api.logger.warn(`Tier assessment error for ${issue.identifier}: ${err}`);
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
// Fallback:
|
|
107
|
+
// Fallback: medium is the safest default
|
|
109
108
|
const fallback: TierAssessment = {
|
|
110
|
-
tier: "
|
|
111
|
-
model: TIER_MODELS.
|
|
112
|
-
reasoning: "Assessment failed — defaulting to
|
|
109
|
+
tier: "medium",
|
|
110
|
+
model: TIER_MODELS.medium,
|
|
111
|
+
reasoning: "Assessment failed — defaulting to medium",
|
|
113
112
|
};
|
|
114
|
-
api.logger.info(`Tier assessment fallback for ${issue.identifier}:
|
|
113
|
+
api.logger.info(`Tier assessment fallback for ${issue.identifier}: medium`);
|
|
115
114
|
return fallback;
|
|
116
115
|
}
|
|
117
116
|
|
|
@@ -119,23 +118,6 @@ export async function assessTier(
|
|
|
119
118
|
// Helpers
|
|
120
119
|
// ---------------------------------------------------------------------------
|
|
121
120
|
|
|
122
|
-
function resolveDefaultAgent(api: OpenClawPluginApi): string {
|
|
123
|
-
// Use the plugin's configured default agent (same one that runs the pipeline)
|
|
124
|
-
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
125
|
-
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
126
|
-
|
|
127
|
-
// Fall back to isDefault in agent profiles
|
|
128
|
-
try {
|
|
129
|
-
const profilesPath = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
130
|
-
const raw = readFileSync(profilesPath, "utf8");
|
|
131
|
-
const profiles = JSON.parse(raw).agents ?? {};
|
|
132
|
-
const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
|
|
133
|
-
if (defaultAgent) return defaultAgent[0];
|
|
134
|
-
} catch { /* fall through */ }
|
|
135
|
-
|
|
136
|
-
return "default";
|
|
137
|
-
}
|
|
138
|
-
|
|
139
121
|
function parseAssessment(raw: string): TierAssessment | null {
|
|
140
122
|
// Extract JSON from the response (may have markdown wrapping)
|
|
141
123
|
const jsonMatch = raw.match(/\{[^}]+\}/);
|
|
@@ -144,7 +126,7 @@ function parseAssessment(raw: string): TierAssessment | null {
|
|
|
144
126
|
try {
|
|
145
127
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
146
128
|
const tier = parsed.tier as string;
|
|
147
|
-
if (tier !== "
|
|
129
|
+
if (tier !== "small" && tier !== "medium" && tier !== "high") return null;
|
|
148
130
|
|
|
149
131
|
return {
|
|
150
132
|
tier: tier as Tier,
|