karajan-code 1.3.0 → 1.5.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 +18 -2
- package/docs/README.es.md +5 -2
- package/package.json +1 -1
- package/src/cli.js +4 -1
- package/src/config.js +10 -1
- package/src/mcp/run-kj.js +2 -0
- package/src/mcp/tools.js +1 -0
- package/src/orchestrator/agent-fallback.js +83 -0
- package/src/orchestrator/iteration-stages.js +113 -3
- package/src/orchestrator/pre-loop-stages.js +26 -0
- package/src/orchestrator.js +11 -2
- package/src/utils/model-selector.js +107 -0
- package/src/utils/rate-limit-detector.js +43 -0
package/README.md
CHANGED
|
@@ -36,7 +36,10 @@ Instead of running one AI agent and manually reviewing its output, `kj` chains a
|
|
|
36
36
|
- **Review profiles** — standard, strict, relaxed, paranoid
|
|
37
37
|
- **Budget tracking** — per-session token and cost monitoring with `--trace`
|
|
38
38
|
- **Git automation** — auto-commit, auto-push, auto-PR after approval
|
|
39
|
-
- **Session management** — pause/resume with fail-fast detection
|
|
39
|
+
- **Session management** — pause/resume with fail-fast detection and automatic cleanup of expired sessions
|
|
40
|
+
- **Plugin system** — extend with custom agents via `.karajan/plugins/`
|
|
41
|
+
- **Smart model selection** — auto-selects optimal model per role based on triage complexity (lighter models for trivial tasks, powerful models for complex ones)
|
|
42
|
+
- **Retry with backoff** — automatic recovery from transient API errors (429, 5xx) with exponential backoff and jitter
|
|
40
43
|
- **Planning Game integration** — optionally pair with [Planning Game](https://github.com/AgenteIA-Geniova/planning-game) for agile project management (tasks, sprints, estimation) — like Jira, but open-source and XP-native
|
|
41
44
|
|
|
42
45
|
> **Best with MCP** — Karajan Code is designed to be used as an MCP server inside your AI agent (Claude, Codex, etc.). The agent sends tasks to `kj_run`, gets real-time progress notifications, and receives structured results — no copy-pasting needed.
|
|
@@ -193,6 +196,8 @@ kj run "Fix the login bug" [options]
|
|
|
193
196
|
| `--auto-pr` | Create PR after push |
|
|
194
197
|
| `--no-auto-rebase` | Disable auto-rebase before push |
|
|
195
198
|
| `--branch-prefix <prefix>` | Branch naming prefix (default: `feat/`) |
|
|
199
|
+
| `--smart-models` | Enable smart model selection based on triage complexity |
|
|
200
|
+
| `--no-smart-models` | Disable smart model selection |
|
|
196
201
|
| `--no-sonar` | Skip SonarQube analysis |
|
|
197
202
|
| `--pg-task <cardId>` | Planning Game card ID for task context |
|
|
198
203
|
| `--pg-project <projectId>` | Planning Game project ID |
|
|
@@ -352,6 +357,16 @@ budget:
|
|
|
352
357
|
currency: usd # usd | eur
|
|
353
358
|
exchange_rate_eur: 0.92
|
|
354
359
|
|
|
360
|
+
# Smart model selection (requires --enable-triage)
|
|
361
|
+
model_selection:
|
|
362
|
+
enabled: true # Auto-select models based on triage complexity
|
|
363
|
+
tiers: # Override default tier map per provider
|
|
364
|
+
claude:
|
|
365
|
+
simple: claude/sonnet # Use sonnet even for simple tasks
|
|
366
|
+
role_overrides: # Override level mapping per role
|
|
367
|
+
reviewer:
|
|
368
|
+
trivial: medium # Reviewer always at least medium tier
|
|
369
|
+
|
|
355
370
|
# Output
|
|
356
371
|
output:
|
|
357
372
|
report_dir: ./.reviews
|
|
@@ -428,7 +443,7 @@ Use `kj roles show <role>` to inspect any template. Create a project override to
|
|
|
428
443
|
git clone https://github.com/manufosela/karajan-code.git
|
|
429
444
|
cd karajan-code
|
|
430
445
|
npm install
|
|
431
|
-
npm test # Run
|
|
446
|
+
npm test # Run 964+ tests with Vitest
|
|
432
447
|
npm run test:watch # Watch mode
|
|
433
448
|
npm run validate # Lint + test
|
|
434
449
|
```
|
|
@@ -439,6 +454,7 @@ npm run validate # Lint + test
|
|
|
439
454
|
|
|
440
455
|
## Links
|
|
441
456
|
|
|
457
|
+
- [Website](https://karajancode.com) (also [kj-code.com](https://kj-code.com))
|
|
442
458
|
- [Changelog](CHANGELOG.md)
|
|
443
459
|
- [Security Policy](SECURITY.md)
|
|
444
460
|
- [License (AGPL-3.0)](LICENSE)
|
package/docs/README.es.md
CHANGED
|
@@ -36,7 +36,9 @@ En lugar de ejecutar un agente de IA y revisar manualmente su output, `kj` encad
|
|
|
36
36
|
- **Perfiles de revision** — standard, strict, relaxed, paranoid
|
|
37
37
|
- **Tracking de presupuesto** — monitorizacion de tokens y costes por sesion con `--trace`
|
|
38
38
|
- **Automatizacion Git** — auto-commit, auto-push, auto-PR tras aprobacion
|
|
39
|
-
- **Gestion de sesiones** — pausa/reanudacion con deteccion fail-fast
|
|
39
|
+
- **Gestion de sesiones** — pausa/reanudacion con deteccion fail-fast y limpieza automatica de sesiones expiradas
|
|
40
|
+
- **Sistema de plugins** — extiende con agentes custom via `.karajan/plugins/`
|
|
41
|
+
- **Retry con backoff** — recuperacion automatica ante errores transitorios de API (429, 5xx) con backoff exponencial y jitter
|
|
40
42
|
- **Integracion con Planning Game** — combina opcionalmente con [Planning Game](https://github.com/AgenteIA-Geniova/planning-game) para gestion agil de proyectos (tareas, sprints, estimacion) — como Jira, pero open-source y nativo XP
|
|
41
43
|
|
|
42
44
|
> **Mejor con MCP** — Karajan Code esta disenado para usarse como servidor MCP dentro de tu agente de IA (Claude, Codex, etc.). El agente envia tareas a `kj_run`, recibe notificaciones de progreso en tiempo real, y obtiene resultados estructurados — sin copiar y pegar.
|
|
@@ -227,7 +229,7 @@ Usa `kj roles show <rol>` para inspeccionar cualquier template. Crea un override
|
|
|
227
229
|
git clone https://github.com/manufosela/karajan-code.git
|
|
228
230
|
cd karajan-code
|
|
229
231
|
npm install
|
|
230
|
-
npm test # Ejecutar
|
|
232
|
+
npm test # Ejecutar 899+ tests con Vitest
|
|
231
233
|
npm run test:watch # Modo watch
|
|
232
234
|
npm run validate # Lint + test
|
|
233
235
|
```
|
|
@@ -238,6 +240,7 @@ npm run validate # Lint + test
|
|
|
238
240
|
|
|
239
241
|
## Enlaces
|
|
240
242
|
|
|
243
|
+
- [Web](https://karajancode.com) (tambien [kj-code.com](https://kj-code.com))
|
|
241
244
|
- [Changelog](../CHANGELOG.md)
|
|
242
245
|
- [Politica de seguridad](../SECURITY.md)
|
|
243
246
|
- [Licencia (AGPL-3.0)](../LICENSE)
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ async function withConfig(commandName, flags, fn) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const program = new Command();
|
|
27
|
-
program.name("kj").description("Karajan Code CLI").version("1.
|
|
27
|
+
program.name("kj").description("Karajan Code CLI").version("1.5.0");
|
|
28
28
|
|
|
29
29
|
program
|
|
30
30
|
.command("init")
|
|
@@ -71,6 +71,7 @@ program
|
|
|
71
71
|
.option("--max-total-minutes <n>")
|
|
72
72
|
.option("--base-branch <name>")
|
|
73
73
|
.option("--base-ref <ref>")
|
|
74
|
+
.option("--coder-fallback <name>")
|
|
74
75
|
.option("--reviewer-fallback <name>")
|
|
75
76
|
.option("--reviewer-retries <n>")
|
|
76
77
|
.option("--auto-commit")
|
|
@@ -82,6 +83,8 @@ program
|
|
|
82
83
|
.option("--no-sonar")
|
|
83
84
|
.option("--pg-task <cardId>", "Planning Game card ID (e.g., KJC-TSK-0042)")
|
|
84
85
|
.option("--pg-project <projectId>", "Planning Game project ID")
|
|
86
|
+
.option("--smart-models", "Enable smart model selection based on triage complexity")
|
|
87
|
+
.option("--no-smart-models", "Disable smart model selection")
|
|
85
88
|
.option("--dry-run", "Show what would be executed without running anything")
|
|
86
89
|
.option("--json", "Output JSON only (no styled display)")
|
|
87
90
|
.action(async (task, flags) => {
|
package/src/config.js
CHANGED
|
@@ -33,7 +33,7 @@ const DEFAULTS = {
|
|
|
33
33
|
review_rules: "./review-rules.md",
|
|
34
34
|
coder_rules: "./coder-rules.md",
|
|
35
35
|
base_branch: "main",
|
|
36
|
-
coder_options: { model: null, auto_approve: true },
|
|
36
|
+
coder_options: { model: null, auto_approve: true, fallback_coder: null },
|
|
37
37
|
reviewer_options: {
|
|
38
38
|
output_format: "json",
|
|
39
39
|
require_schema: true,
|
|
@@ -106,6 +106,11 @@ const DEFAULTS = {
|
|
|
106
106
|
currency: "usd",
|
|
107
107
|
exchange_rate_eur: 0.92
|
|
108
108
|
},
|
|
109
|
+
model_selection: {
|
|
110
|
+
enabled: true,
|
|
111
|
+
tiers: {},
|
|
112
|
+
role_overrides: {}
|
|
113
|
+
},
|
|
109
114
|
session: {
|
|
110
115
|
max_iteration_minutes: 15,
|
|
111
116
|
max_total_minutes: 120,
|
|
@@ -240,6 +245,7 @@ export function applyRunOverrides(config, flags) {
|
|
|
240
245
|
if (flags.maxIterationMinutes) out.session.max_iteration_minutes = Number(flags.maxIterationMinutes);
|
|
241
246
|
if (flags.maxTotalMinutes) out.session.max_total_minutes = Number(flags.maxTotalMinutes);
|
|
242
247
|
if (flags.baseBranch) out.base_branch = flags.baseBranch;
|
|
248
|
+
if (flags.coderFallback) out.coder_options.fallback_coder = flags.coderFallback;
|
|
243
249
|
if (flags.reviewerFallback) out.reviewer_options.fallback_reviewer = flags.reviewerFallback;
|
|
244
250
|
if (flags.reviewerRetries !== undefined) out.reviewer_options.retries = Number(flags.reviewerRetries);
|
|
245
251
|
if (flags.autoCommit !== undefined) out.git.auto_commit = Boolean(flags.autoCommit);
|
|
@@ -259,6 +265,9 @@ export function applyRunOverrides(config, flags) {
|
|
|
259
265
|
out.planning_game = out.planning_game || {};
|
|
260
266
|
if (flags.pgTask) out.planning_game.enabled = true;
|
|
261
267
|
if (flags.pgProject) out.planning_game.project_id = flags.pgProject;
|
|
268
|
+
out.model_selection = out.model_selection || { enabled: true, tiers: {}, role_overrides: {} };
|
|
269
|
+
if (flags.smartModels === true) out.model_selection.enabled = true;
|
|
270
|
+
if (flags.smartModels === false || flags.noSmartModels === true) out.model_selection.enabled = false;
|
|
262
271
|
return out;
|
|
263
272
|
}
|
|
264
273
|
|
package/src/mcp/run-kj.js
CHANGED
|
@@ -49,6 +49,8 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
|
|
|
49
49
|
normalizeBoolFlag(options.autoPr, "--auto-pr", args);
|
|
50
50
|
if (options.autoRebase === false) args.push("--no-auto-rebase");
|
|
51
51
|
normalizeBoolFlag(options.noSonar, "--no-sonar", args);
|
|
52
|
+
if (options.smartModels === true) args.push("--smart-models");
|
|
53
|
+
if (options.smartModels === false) args.push("--no-smart-models");
|
|
52
54
|
addOptionalValue(args, "--pg-task", options.pgTask);
|
|
53
55
|
addOptionalValue(args, "--pg-project", options.pgProject);
|
|
54
56
|
|
package/src/mcp/tools.js
CHANGED
|
@@ -85,6 +85,7 @@ export const tools = [
|
|
|
85
85
|
autoPr: { type: "boolean" },
|
|
86
86
|
autoRebase: { type: "boolean" },
|
|
87
87
|
branchPrefix: { type: "string" },
|
|
88
|
+
smartModels: { type: "boolean", description: "Enable/disable smart model selection based on triage complexity" },
|
|
88
89
|
noSonar: { type: "boolean" },
|
|
89
90
|
kjHome: { type: "string" },
|
|
90
91
|
sonarToken: { type: "string" },
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createAgent } from "../agents/index.js";
|
|
2
|
+
import { addCheckpoint } from "../session-store.js";
|
|
3
|
+
import { detectRateLimit } from "../utils/rate-limit-detector.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run a coder-like role with fallback on rate limit.
|
|
7
|
+
* Tries the primary agent first. If it fails with a rate limit,
|
|
8
|
+
* switches to the fallback agent (if configured).
|
|
9
|
+
* Non-rate-limit failures stop immediately (no fallback).
|
|
10
|
+
*
|
|
11
|
+
* Returns { execResult, attempts, allRateLimited }
|
|
12
|
+
*/
|
|
13
|
+
export async function runCoderWithFallback({
|
|
14
|
+
coderName,
|
|
15
|
+
fallbackCoder,
|
|
16
|
+
config,
|
|
17
|
+
logger,
|
|
18
|
+
emitter,
|
|
19
|
+
RoleClass,
|
|
20
|
+
roleInput,
|
|
21
|
+
session,
|
|
22
|
+
iteration,
|
|
23
|
+
onAttemptResult
|
|
24
|
+
}) {
|
|
25
|
+
const candidates = [coderName];
|
|
26
|
+
if (fallbackCoder && fallbackCoder !== coderName) {
|
|
27
|
+
candidates.push(fallbackCoder);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const attempts = [];
|
|
31
|
+
let allRateLimited = true;
|
|
32
|
+
|
|
33
|
+
for (const name of candidates) {
|
|
34
|
+
const agentConfig = {
|
|
35
|
+
...config,
|
|
36
|
+
roles: { ...config.roles, coder: { ...config.roles?.coder, provider: name } }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const role = new RoleClass({ config: agentConfig, logger, emitter, createAgentFn: createAgent });
|
|
40
|
+
await role.init();
|
|
41
|
+
|
|
42
|
+
const execResult = await role.execute(roleInput);
|
|
43
|
+
|
|
44
|
+
if (onAttemptResult) {
|
|
45
|
+
await onAttemptResult({ coder: name, result: execResult.result });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rateLimited = !execResult.ok && detectRateLimit({
|
|
49
|
+
stderr: execResult.result?.error || "",
|
|
50
|
+
stdout: execResult.result?.output || ""
|
|
51
|
+
}).isRateLimit;
|
|
52
|
+
|
|
53
|
+
attempts.push({
|
|
54
|
+
coder: name,
|
|
55
|
+
ok: execResult.ok,
|
|
56
|
+
rateLimited,
|
|
57
|
+
result: execResult.result,
|
|
58
|
+
execResult
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await addCheckpoint(session, {
|
|
62
|
+
stage: "coder-attempt",
|
|
63
|
+
iteration,
|
|
64
|
+
coder: name,
|
|
65
|
+
ok: execResult.ok,
|
|
66
|
+
rateLimited
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (execResult.ok) {
|
|
70
|
+
return { execResult, attempts, allRateLimited: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only fallback on rate limit errors
|
|
74
|
+
if (!rateLimited) {
|
|
75
|
+
allRateLimited = false;
|
|
76
|
+
return { execResult: null, attempts, allRateLimited: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger.warn(`Agent ${name} hit rate limit, trying fallback...`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { execResult: null, attempts, allRateLimited };
|
|
83
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createAgent } from "../agents/index.js";
|
|
2
|
+
import { CoderRole } from "../roles/coder-role.js";
|
|
2
3
|
import { RefactorerRole } from "../roles/refactorer-role.js";
|
|
3
4
|
import { SonarRole } from "../roles/sonar-role.js";
|
|
4
5
|
import { addCheckpoint, markSessionStatus, saveSession, pauseSession } from "../session-store.js";
|
|
@@ -7,7 +8,9 @@ import { evaluateTddPolicy } from "../review/tdd-policy.js";
|
|
|
7
8
|
import { validateReviewResult } from "../review/schema.js";
|
|
8
9
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
9
10
|
import { runReviewerWithFallback } from "./reviewer-fallback.js";
|
|
11
|
+
import { runCoderWithFallback } from "./agent-fallback.js";
|
|
10
12
|
import { invokeSolomon } from "./solomon-escalation.js";
|
|
13
|
+
import { detectRateLimit } from "../utils/rate-limit-detector.js";
|
|
11
14
|
|
|
12
15
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
|
|
13
16
|
logger.setContext({ iteration, stage: "coder" });
|
|
@@ -35,8 +38,70 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
35
38
|
trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
|
|
36
39
|
|
|
37
40
|
if (!coderExecResult.ok) {
|
|
38
|
-
await markSessionStatus(session, "failed");
|
|
39
41
|
const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
|
|
42
|
+
const rateLimitCheck = detectRateLimit({
|
|
43
|
+
stderr: coderExecResult.result?.error || "",
|
|
44
|
+
stdout: coderExecResult.result?.output || ""
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (rateLimitCheck.isRateLimit) {
|
|
48
|
+
// Try fallback agent if configured
|
|
49
|
+
const fallbackCoder = config.coder_options?.fallback_coder;
|
|
50
|
+
if (fallbackCoder && fallbackCoder !== coderRole.provider) {
|
|
51
|
+
logger.warn(`Coder ${coderRole.provider} hit rate limit, falling back to ${fallbackCoder}`);
|
|
52
|
+
emitProgress(
|
|
53
|
+
emitter,
|
|
54
|
+
makeEvent("coder:fallback", { ...eventBase, stage: "coder" }, {
|
|
55
|
+
message: `Coder ${coderRole.provider} rate-limited, switching to ${fallbackCoder}`,
|
|
56
|
+
detail: { primary: coderRole.provider, fallback: fallbackCoder }
|
|
57
|
+
})
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const fallbackResult = await runCoderWithFallback({
|
|
61
|
+
coderName: fallbackCoder,
|
|
62
|
+
fallbackCoder: null,
|
|
63
|
+
config,
|
|
64
|
+
logger,
|
|
65
|
+
emitter,
|
|
66
|
+
RoleClass: CoderRole,
|
|
67
|
+
roleInput: { task: plannedTask, reviewerFeedback: session.last_reviewer_feedback, sonarSummary: session.last_sonar_summary, onOutput: coderOnOutput },
|
|
68
|
+
session,
|
|
69
|
+
iteration,
|
|
70
|
+
onAttemptResult: ({ coder, result }) => {
|
|
71
|
+
trackBudget({ role: "coder", provider: coder, model: coderRole.model, result, duration_ms: Date.now() - coderStart });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (fallbackResult.execResult?.ok) {
|
|
76
|
+
await addCheckpoint(session, { stage: "coder", iteration, note: `Coder completed via fallback (${fallbackCoder})` });
|
|
77
|
+
emitProgress(
|
|
78
|
+
emitter,
|
|
79
|
+
makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
|
|
80
|
+
message: `Coder completed (fallback: ${fallbackCoder})`
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// No fallback or fallback also failed — pause
|
|
88
|
+
const question = `Agent ${coderRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
|
|
89
|
+
await pauseSession(session, {
|
|
90
|
+
question,
|
|
91
|
+
context: { iteration, stage: "coder", reason: "rate_limit", agent: coderRole.provider, detail: rateLimitCheck.message }
|
|
92
|
+
});
|
|
93
|
+
emitProgress(
|
|
94
|
+
emitter,
|
|
95
|
+
makeEvent("coder:rate_limit", { ...eventBase, stage: "coder" }, {
|
|
96
|
+
status: "paused",
|
|
97
|
+
message: question,
|
|
98
|
+
detail: { agent: coderRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await markSessionStatus(session, "failed");
|
|
40
105
|
emitProgress(
|
|
41
106
|
emitter,
|
|
42
107
|
makeEvent("coder:end", { ...eventBase, stage: "coder" }, {
|
|
@@ -71,8 +136,30 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
|
|
|
71
136
|
const refResult = await refRole.execute(plannedTask);
|
|
72
137
|
trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
|
|
73
138
|
if (!refResult.ok) {
|
|
74
|
-
await markSessionStatus(session, "failed");
|
|
75
139
|
const details = refResult.result?.error || refResult.summary || "unknown error";
|
|
140
|
+
const rateLimitCheck = detectRateLimit({
|
|
141
|
+
stderr: refResult.result?.error || "",
|
|
142
|
+
stdout: refResult.result?.output || ""
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (rateLimitCheck.isRateLimit) {
|
|
146
|
+
const question = `Agent ${refactorerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
|
|
147
|
+
await pauseSession(session, {
|
|
148
|
+
question,
|
|
149
|
+
context: { iteration, stage: "refactorer", reason: "rate_limit", agent: refactorerRole.provider, detail: rateLimitCheck.message }
|
|
150
|
+
});
|
|
151
|
+
emitProgress(
|
|
152
|
+
emitter,
|
|
153
|
+
makeEvent("refactorer:rate_limit", { ...eventBase, stage: "refactorer" }, {
|
|
154
|
+
status: "paused",
|
|
155
|
+
message: question,
|
|
156
|
+
detail: { agent: refactorerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await markSessionStatus(session, "failed");
|
|
76
163
|
emitProgress(
|
|
77
164
|
emitter,
|
|
78
165
|
makeEvent("refactorer:end", { ...eventBase, stage: "refactorer" }, {
|
|
@@ -318,12 +405,35 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
|
|
|
318
405
|
});
|
|
319
406
|
|
|
320
407
|
if (!reviewerExec.execResult || !reviewerExec.execResult.ok) {
|
|
321
|
-
await markSessionStatus(session, "failed");
|
|
322
408
|
const lastAttempt = reviewerExec.attempts.at(-1);
|
|
323
409
|
const details =
|
|
324
410
|
lastAttempt?.result?.error ||
|
|
325
411
|
lastAttempt?.execResult?.summary ||
|
|
326
412
|
`reviewer=${lastAttempt?.reviewer || "unknown"}`;
|
|
413
|
+
|
|
414
|
+
const rateLimitCheck = detectRateLimit({
|
|
415
|
+
stderr: lastAttempt?.result?.error || "",
|
|
416
|
+
stdout: lastAttempt?.result?.output || ""
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (rateLimitCheck.isRateLimit) {
|
|
420
|
+
const question = `Reviewer ${reviewerRole.provider} hit a rate limit: ${rateLimitCheck.message}. Session paused until the token window resets.`;
|
|
421
|
+
await pauseSession(session, {
|
|
422
|
+
question,
|
|
423
|
+
context: { iteration, stage: "reviewer", reason: "rate_limit", agent: reviewerRole.provider, detail: rateLimitCheck.message }
|
|
424
|
+
});
|
|
425
|
+
emitProgress(
|
|
426
|
+
emitter,
|
|
427
|
+
makeEvent("reviewer:rate_limit", { ...eventBase, stage: "reviewer" }, {
|
|
428
|
+
status: "paused",
|
|
429
|
+
message: question,
|
|
430
|
+
detail: { agent: reviewerRole.provider, rateLimitMessage: rateLimitCheck.message, sessionId: session.id }
|
|
431
|
+
})
|
|
432
|
+
);
|
|
433
|
+
return { action: "pause", result: { paused: true, sessionId: session.id, question, context: "rate_limit" } };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await markSessionStatus(session, "failed");
|
|
327
437
|
emitProgress(
|
|
328
438
|
emitter,
|
|
329
439
|
makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
|
|
@@ -5,6 +5,7 @@ import { createAgent } from "../agents/index.js";
|
|
|
5
5
|
import { addCheckpoint, markSessionStatus } from "../session-store.js";
|
|
6
6
|
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
7
7
|
import { parsePlannerOutput } from "../prompts/planner.js";
|
|
8
|
+
import { selectModelsForRoles } from "../utils/model-selector.js";
|
|
8
9
|
|
|
9
10
|
export async function runTriageStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
|
|
10
11
|
logger.setContext({ iteration: 0, stage: "triage" });
|
|
@@ -47,6 +48,31 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
|
|
|
47
48
|
reasoning: triageOutput.result?.reasoning || null
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
let modelSelection = null;
|
|
52
|
+
if (triageOutput.ok && config?.model_selection?.enabled) {
|
|
53
|
+
const level = triageOutput.result?.level;
|
|
54
|
+
if (level) {
|
|
55
|
+
const { modelOverrides, reasoning } = selectModelsForRoles({ level, config });
|
|
56
|
+
for (const [role, model] of Object.entries(modelOverrides)) {
|
|
57
|
+
if (config.roles?.[role] && !config.roles[role].model) {
|
|
58
|
+
config.roles[role].model = model;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
modelSelection = { modelOverrides, reasoning };
|
|
62
|
+
emitProgress(
|
|
63
|
+
emitter,
|
|
64
|
+
makeEvent("model-selection:applied", { ...eventBase, stage: "triage" }, {
|
|
65
|
+
message: "Smart model selection applied",
|
|
66
|
+
detail: modelSelection
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (modelSelection) {
|
|
73
|
+
stageResult.modelSelection = modelSelection;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
emitProgress(
|
|
51
77
|
emitter,
|
|
52
78
|
makeEvent("triage:end", { ...eventBase, stage: "triage" }, {
|
package/src/orchestrator.js
CHANGED
|
@@ -255,11 +255,17 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
255
255
|
logger.info(`Iteration ${i}/${config.max_iterations}`);
|
|
256
256
|
|
|
257
257
|
// --- Coder ---
|
|
258
|
-
await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
|
|
258
|
+
const coderResult = await runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
|
|
259
|
+
if (coderResult?.action === "pause") {
|
|
260
|
+
return coderResult.result;
|
|
261
|
+
}
|
|
259
262
|
|
|
260
263
|
// --- Refactorer ---
|
|
261
264
|
if (refactorerEnabled) {
|
|
262
|
-
await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
|
|
265
|
+
const refResult = await runRefactorerStage({ refactorerRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration: i });
|
|
266
|
+
if (refResult?.action === "pause") {
|
|
267
|
+
return refResult.result;
|
|
268
|
+
}
|
|
263
269
|
}
|
|
264
270
|
|
|
265
271
|
// --- TDD Policy ---
|
|
@@ -302,6 +308,9 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
|
|
|
302
308
|
reviewerRole, config, logger, emitter, eventBase, session, trackBudget,
|
|
303
309
|
iteration: i, reviewRules, task, repeatDetector, budgetSummary
|
|
304
310
|
});
|
|
311
|
+
if (reviewerResult.action === "pause") {
|
|
312
|
+
return reviewerResult.result;
|
|
313
|
+
}
|
|
305
314
|
review = reviewerResult.review;
|
|
306
315
|
if (reviewerResult.stalled) {
|
|
307
316
|
return reviewerResult.stalledResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const DEFAULT_MODEL_TIERS = {
|
|
2
|
+
claude: { trivial: "claude/haiku", simple: "claude/haiku", medium: "claude/sonnet", complex: "claude/opus" },
|
|
3
|
+
codex: { trivial: "codex/o4-mini", simple: "codex/o4-mini", medium: "codex/o4-mini", complex: "codex/o3" },
|
|
4
|
+
gemini: { trivial: "gemini/flash", simple: "gemini/flash", medium: "gemini/pro", complex: "gemini/pro" },
|
|
5
|
+
aider: { trivial: null, simple: null, medium: null, complex: null }
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ROLE_OVERRIDES = {
|
|
9
|
+
reviewer: { trivial: "medium", simple: "medium" },
|
|
10
|
+
triage: { medium: "simple", complex: "simple" }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
|
|
14
|
+
|
|
15
|
+
export function getDefaultModelTiers() {
|
|
16
|
+
return JSON.parse(JSON.stringify(DEFAULT_MODEL_TIERS));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDefaultRoleOverrides() {
|
|
20
|
+
return JSON.parse(JSON.stringify(DEFAULT_ROLE_OVERRIDES));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveModelForRole({ role, provider, level, tierMap, roleOverrides }) {
|
|
24
|
+
if (!provider || !level || !VALID_LEVELS.has(level)) return null;
|
|
25
|
+
|
|
26
|
+
const tiers = tierMap || DEFAULT_MODEL_TIERS;
|
|
27
|
+
const providerTiers = tiers[provider];
|
|
28
|
+
if (!providerTiers) return null;
|
|
29
|
+
|
|
30
|
+
const overrides = roleOverrides || DEFAULT_ROLE_OVERRIDES;
|
|
31
|
+
const roleOvr = overrides[role];
|
|
32
|
+
|
|
33
|
+
let effectiveLevel = level;
|
|
34
|
+
if (roleOvr && roleOvr[level]) {
|
|
35
|
+
const mappedLevel = roleOvr[level];
|
|
36
|
+
if (VALID_LEVELS.has(mappedLevel)) {
|
|
37
|
+
effectiveLevel = mappedLevel;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return providerTiers[effectiveLevel] || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function selectModelsForRoles({ level, config, roles }) {
|
|
45
|
+
if (!level || !VALID_LEVELS.has(level)) {
|
|
46
|
+
return { modelOverrides: {}, reasoning: "No valid triage level provided" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const modelSelection = config?.model_selection || {};
|
|
50
|
+
const userTiers = modelSelection.tiers || {};
|
|
51
|
+
const userRoleOverrides = modelSelection.role_overrides || {};
|
|
52
|
+
|
|
53
|
+
const mergedTiers = { ...getDefaultModelTiers() };
|
|
54
|
+
for (const [provider, levels] of Object.entries(userTiers)) {
|
|
55
|
+
mergedTiers[provider] = { ...(mergedTiers[provider] || {}), ...levels };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const mergedRoleOverrides = { ...getDefaultRoleOverrides() };
|
|
59
|
+
for (const [role, levels] of Object.entries(userRoleOverrides)) {
|
|
60
|
+
mergedRoleOverrides[role] = { ...(mergedRoleOverrides[role] || {}), ...levels };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const allRoles = roles || Object.keys(config?.roles || {});
|
|
64
|
+
const modelOverrides = {};
|
|
65
|
+
const details = [];
|
|
66
|
+
|
|
67
|
+
for (const role of allRoles) {
|
|
68
|
+
const roleConfig = config?.roles?.[role];
|
|
69
|
+
if (!roleConfig) continue;
|
|
70
|
+
|
|
71
|
+
if (roleConfig.model) {
|
|
72
|
+
details.push(`${role}: skipped (explicit model "${roleConfig.model}")`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (roleConfig.disabled) {
|
|
77
|
+
details.push(`${role}: skipped (disabled)`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const provider = roleConfig.provider;
|
|
82
|
+
if (!provider) {
|
|
83
|
+
details.push(`${role}: skipped (no provider)`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const model = resolveModelForRole({
|
|
88
|
+
role,
|
|
89
|
+
provider,
|
|
90
|
+
level,
|
|
91
|
+
tierMap: mergedTiers,
|
|
92
|
+
roleOverrides: mergedRoleOverrides
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (model) {
|
|
96
|
+
modelOverrides[role] = model;
|
|
97
|
+
details.push(`${role}: ${model} (level=${level}, provider=${provider})`);
|
|
98
|
+
} else {
|
|
99
|
+
details.push(`${role}: no model for provider "${provider}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
modelOverrides,
|
|
105
|
+
reasoning: `Smart model selection (level=${level}): ${details.join("; ")}`
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects rate limit / usage cap messages from CLI agent output.
|
|
3
|
+
* Returns { isRateLimit, agent, message } where agent is the best guess
|
|
4
|
+
* of which CLI triggered it (or "unknown").
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const RATE_LIMIT_PATTERNS = [
|
|
8
|
+
// Claude CLI
|
|
9
|
+
{ pattern: /usage limit/i, agent: "claude" },
|
|
10
|
+
{ pattern: /plan's usage limit/i, agent: "claude" },
|
|
11
|
+
{ pattern: /Claude Pro usage limit/i, agent: "claude" },
|
|
12
|
+
|
|
13
|
+
// OpenAI / Codex CLI
|
|
14
|
+
{ pattern: /exceeded your current quota/i, agent: "codex" },
|
|
15
|
+
|
|
16
|
+
// Gemini CLI
|
|
17
|
+
{ pattern: /resource exhausted/i, agent: "gemini" },
|
|
18
|
+
{ pattern: /quota exceeded/i, agent: "gemini" },
|
|
19
|
+
|
|
20
|
+
// Generic (match any agent)
|
|
21
|
+
{ pattern: /rate limit/i, agent: "unknown" },
|
|
22
|
+
{ pattern: /token limit reached/i, agent: "unknown" },
|
|
23
|
+
{ pattern: /\b429\b/, agent: "unknown" },
|
|
24
|
+
{ pattern: /too many requests/i, agent: "unknown" },
|
|
25
|
+
{ pattern: /throttl/i, agent: "unknown" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export function detectRateLimit({ stderr = "", stdout = "" }) {
|
|
29
|
+
const combined = `${stderr}\n${stdout}`;
|
|
30
|
+
|
|
31
|
+
for (const { pattern, agent } of RATE_LIMIT_PATTERNS) {
|
|
32
|
+
if (pattern.test(combined)) {
|
|
33
|
+
const matchedLine = combined.split("\n").find((l) => pattern.test(l)) || combined.trim();
|
|
34
|
+
return {
|
|
35
|
+
isRateLimit: true,
|
|
36
|
+
agent,
|
|
37
|
+
message: matchedLine.trim()
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { isRateLimit: false, agent: "", message: "" };
|
|
43
|
+
}
|