@cruxy/cli 0.16.0 → 0.17.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/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +12 -2
- package/dist/agent/session.d.ts +6 -0
- package/dist/agent/session.js +9 -1
- package/dist/brand/index.d.ts +1 -1
- package/dist/brand/index.js +1 -1
- package/dist/brand/voice.d.ts +20 -0
- package/dist/brand/voice.js +54 -0
- package/dist/cli/commands/pr.js +9 -1
- package/dist/cli/session-factory.js +9 -0
- package/dist/config/schema.d.ts +58 -16
- package/dist/config/schema.js +20 -0
- package/dist/errors/constructors.d.ts +9 -0
- package/dist/errors/constructors.js +41 -6
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +4 -0
- package/dist/plan/service.d.ts +6 -0
- package/dist/plan/service.js +4 -0
- package/dist/render/state.js +4 -1
- package/dist/render/types.d.ts +7 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +5 -0
- package/dist/routing/resolve.d.ts +17 -0
- package/dist/routing/resolve.js +18 -0
- package/dist/routing/router.d.ts +47 -0
- package/dist/routing/router.js +84 -0
- package/dist/routing/types.d.ts +42 -0
- package/dist/routing/types.js +27 -0
- package/dist/subagent/orchestrator.d.ts +6 -0
- package/dist/subagent/orchestrator.js +2 -0
- package/dist/subagent/types.d.ts +6 -0
- package/dist/vcs/generate.d.ts +3 -1
- package/dist/vcs/generate.js +4 -1
- package/package.json +2 -2
package/dist/agent/loop.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { HookEvent } from "../hooks/index.js";
|
|
4
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import { type Router, type TaskClass } from "../routing/index.js";
|
|
5
6
|
import type { ToolContext } from "../tools/index.js";
|
|
6
7
|
import { ToolRegistry } from "../tools/index.js";
|
|
7
8
|
/** The lifecycle-hook firing seam (C.19). Structural so the loop stays
|
|
@@ -57,6 +58,17 @@ export interface RunAgentArgs {
|
|
|
57
58
|
* no-hooks path, so their tools never fire hooks.
|
|
58
59
|
*/
|
|
59
60
|
hooks?: LifecycleHookRunner;
|
|
61
|
+
/**
|
|
62
|
+
* Multi-model routing (C.30): when set, the tier for `taskClass` is resolved
|
|
63
|
+
* ONCE up front (fail loud before any model turn) and its wire model overrides
|
|
64
|
+
* the provider default for every turn in this run; the tier is surfaced on the
|
|
65
|
+
* live line. Omitted → no override, the provider's default model is used
|
|
66
|
+
* (unchanged behavior).
|
|
67
|
+
*/
|
|
68
|
+
router?: Router;
|
|
69
|
+
/** The declared task class for routing; defaults to `main-turn`. Ignored
|
|
70
|
+
* unless `router` is set. */
|
|
71
|
+
taskClass?: TaskClass;
|
|
60
72
|
}
|
|
61
73
|
/**
|
|
62
74
|
* The budget seam for {@link runAgent}: implementations track their own caps
|
package/dist/agent/loop.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CruxyError, providerUnsupported } from "../errors/index.js";
|
|
2
|
+
import { resolveTaskModel, } from "../routing/index.js";
|
|
2
3
|
import { buildSystemPrompt } from "./prompts.js";
|
|
3
4
|
/** Tools whose successful call is a file change (drives the `on-file-change`
|
|
4
5
|
* hook). Kept in sync with the file-mutating tool set. */
|
|
@@ -17,9 +18,16 @@ export async function runAgent(args) {
|
|
|
17
18
|
if (!provider.supportsTools) {
|
|
18
19
|
throw providerUnsupported(config.model.provider);
|
|
19
20
|
}
|
|
21
|
+
// Resolve the routing tier ONCE, before any model turn (C.30). A tier the
|
|
22
|
+
// gateway does not offer throws CRUXY_E_ROUTING_TIER_UNAVAILABLE here — so a
|
|
23
|
+
// misrouted run never reaches provider.stream (no request sent with the wrong
|
|
24
|
+
// tier), and never silently falls back to a different one.
|
|
25
|
+
const routed = args.router
|
|
26
|
+
? resolveTaskModel(args.router, args.taskClass ?? "main-turn")
|
|
27
|
+
: null;
|
|
20
28
|
renderer?.beginTurn();
|
|
21
29
|
try {
|
|
22
|
-
return await driveLoop(args, renderer);
|
|
30
|
+
return await driveLoop(args, renderer, routed);
|
|
23
31
|
}
|
|
24
32
|
finally {
|
|
25
33
|
// Always leave the terminal clean: no orphaned status line, no held text —
|
|
@@ -28,7 +36,7 @@ export async function runAgent(args) {
|
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
31
|
-
async function driveLoop(args, renderer) {
|
|
39
|
+
async function driveLoop(args, renderer, routed) {
|
|
32
40
|
const { provider, registry, config, ctx } = args;
|
|
33
41
|
const { logger } = ctx;
|
|
34
42
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
@@ -80,11 +88,13 @@ async function driveLoop(args, renderer) {
|
|
|
80
88
|
tokens: usage.input_tokens + usage.output_tokens > 0
|
|
81
89
|
? { input: usage.input_tokens, output: usage.output_tokens }
|
|
82
90
|
: undefined,
|
|
91
|
+
tier: routed?.tier,
|
|
83
92
|
});
|
|
84
93
|
for await (const ev of provider.stream({
|
|
85
94
|
system,
|
|
86
95
|
messages,
|
|
87
96
|
tools,
|
|
97
|
+
...(routed ? { model: routed.model } : {}),
|
|
88
98
|
})) {
|
|
89
99
|
switch (ev.type) {
|
|
90
100
|
case "text_delta":
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { StreamRenderer } from "../render/index.js";
|
|
4
|
+
import { type Router } from "../routing/index.js";
|
|
4
5
|
import type { ToolContext } from "../tools/index.js";
|
|
5
6
|
import type { ToolRegistry } from "../tools/index.js";
|
|
6
7
|
import { type AgentResult, type LifecycleHookRunner } from "./loop.js";
|
|
@@ -42,6 +43,11 @@ export interface SessionArgs {
|
|
|
42
43
|
* `before-tool`/`after-tool`/`on-file-change`.
|
|
43
44
|
*/
|
|
44
45
|
hooks?: LifecycleHookRunner;
|
|
46
|
+
/**
|
|
47
|
+
* Multi-model routing (C.30). When set, main turns route on `main-turn` and
|
|
48
|
+
* context compaction on `summarize`; omitted → the provider default (unchanged).
|
|
49
|
+
*/
|
|
50
|
+
router?: Router;
|
|
45
51
|
}
|
|
46
52
|
/**
|
|
47
53
|
* Estimate the token footprint of a message list with a cheap chars/4 heuristic
|
package/dist/agent/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadProjectInstructions } from "../config/index.js";
|
|
2
|
+
import { resolveTaskModel } from "../routing/index.js";
|
|
2
3
|
import { runAgent, } from "./loop.js";
|
|
3
4
|
import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
|
|
4
5
|
/**
|
|
@@ -102,7 +103,8 @@ export class Session {
|
|
|
102
103
|
})
|
|
103
104
|
: await runAgent({
|
|
104
105
|
messages: this.messages,
|
|
105
|
-
...this.args,
|
|
106
|
+
...this.args, // carries `router` through to the loop
|
|
107
|
+
taskClass: "main-turn",
|
|
106
108
|
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
107
109
|
projectInstructions: this.projectInstructions,
|
|
108
110
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
@@ -226,9 +228,15 @@ export class Session {
|
|
|
226
228
|
const transcript = renderTranscript(prefix);
|
|
227
229
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
228
230
|
let text = "";
|
|
231
|
+
// Compaction is mechanical work — route it on `summarize` (C.30) when routing
|
|
232
|
+
// is active; omitted → the provider default, unchanged.
|
|
233
|
+
const routed = this.args.router
|
|
234
|
+
? resolveTaskModel(this.args.router, "summarize")
|
|
235
|
+
: null;
|
|
229
236
|
for await (const ev of this.args.provider.stream({
|
|
230
237
|
system: SUMMARY_SYSTEM,
|
|
231
238
|
messages: [{ role: "user", content: transcript }],
|
|
239
|
+
...(routed ? { model: routed.model } : {}),
|
|
232
240
|
})) {
|
|
233
241
|
switch (ev.type) {
|
|
234
242
|
case "text_delta":
|
package/dist/brand/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, type ForbiddenTerm, type LexiconViolation, } from "./voice.js";
|
package/dist/brand/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, } from "./voice.js";
|
|
1
|
+
export { CANONICAL_TERMS, FORBIDDEN_MODEL_NAMES, FORBIDDEN_TERMS, MODEL_NAME_PLACEHOLDER, MODEL_TIERS, PRODUCT_MASTHEAD, PRODUCT_NAME, PRODUCT_TAGLINE, scanForbidden, scrubModelNames, } from "./voice.js";
|
package/dist/brand/voice.d.ts
CHANGED
|
@@ -59,6 +59,26 @@ export declare const FORBIDDEN_TERMS: readonly ForbiddenTerm[];
|
|
|
59
59
|
* absent: they are real bring-your-own-provider config values, not model names.
|
|
60
60
|
*/
|
|
61
61
|
export declare const FORBIDDEN_MODEL_NAMES: RegExp;
|
|
62
|
+
/** The neutral token an upstream model name is replaced with in user-facing copy. */
|
|
63
|
+
export declare const MODEL_NAME_PLACEHOLDER = "\u00ABmodel\u00BB";
|
|
64
|
+
/**
|
|
65
|
+
* Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
|
|
66
|
+
* string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
|
|
67
|
+
* run to a single token.
|
|
68
|
+
*
|
|
69
|
+
* The U.8 gag is structural *inside the process* (the routing package can't
|
|
70
|
+
* produce an upstream id — see `resolveModelId`), but a gateway/provider error
|
|
71
|
+
* body is an arbitrary external string that bypasses that guarantee. This is the
|
|
72
|
+
* scrub applied at the error-construction boundary so no upstream id reaches a
|
|
73
|
+
* user-facing `CruxyError.cause`.
|
|
74
|
+
*
|
|
75
|
+
* Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
|
|
76
|
+
* inside an unrelated word/path — so legitimate output (`/home/claude`, the
|
|
77
|
+
* `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
|
|
78
|
+
* Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
|
|
79
|
+
* provider config values survive too.
|
|
80
|
+
*/
|
|
81
|
+
export declare function scrubModelNames(text: string): string;
|
|
62
82
|
/** One lexicon violation found in a scanned string. */
|
|
63
83
|
export interface LexiconViolation {
|
|
64
84
|
/** The forbidden term (or "model-name" for a gag violation). */
|
package/dist/brand/voice.js
CHANGED
|
@@ -55,6 +55,60 @@ export const FORBIDDEN_TERMS = [
|
|
|
55
55
|
* absent: they are real bring-your-own-provider config values, not model names.
|
|
56
56
|
*/
|
|
57
57
|
export const FORBIDDEN_MODEL_NAMES = /\b(claude|sonnet|opus|haiku|gpt-?\d|gemini|llama|mistral)\b/i;
|
|
58
|
+
/** The neutral token an upstream model name is replaced with in user-facing copy. */
|
|
59
|
+
export const MODEL_NAME_PLACEHOLDER = "«model»";
|
|
60
|
+
/**
|
|
61
|
+
* A model-id-SHAPED run in free text: a self-identifying `gpt-4`/`gpt-4o`, or a
|
|
62
|
+
* model word carrying a hyphen/dot tail (`claude-sonnet-4-6`, `gemini-1.5-pro`).
|
|
63
|
+
* Deliberately GENEROUS — it also matches bare words and non-model hyphenated
|
|
64
|
+
* tokens; {@link isModelId} does the real filtering in the scrub callback, and
|
|
65
|
+
* matching the whole run (not each word) is what collapses `claude-sonnet-4-6`
|
|
66
|
+
* to ONE placeholder instead of `«model»-«model»-4-6`. The leading
|
|
67
|
+
* `(?<![/\\\w])` stops it firing inside a longer word (`gptools`) or on a path
|
|
68
|
+
* segment (`/home/claude/…`) — both of which the bare `\b` form corrupts.
|
|
69
|
+
*/
|
|
70
|
+
const MODEL_ID_CANDIDATE = /(?<![/\\\w])(?:gpt-?\d[a-z0-9]*(?:[-.][a-z0-9]+)*|(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)(?:[-.][a-z0-9]+)*)/gi;
|
|
71
|
+
/** The bare model words, for the digitless-compound test in {@link isModelId}. */
|
|
72
|
+
const MODEL_WORD = /(?:claude|sonnet|opus|haiku|gemini|llama|mistral|gpt)/gi;
|
|
73
|
+
/**
|
|
74
|
+
* Is a {@link MODEL_ID_CANDIDATE} run an actual upstream model reference (scrub),
|
|
75
|
+
* or a legitimate token that merely contains a model word (keep)? A reference is:
|
|
76
|
+
* a self-identifying `gpt-<n>`; OR a hyphen/dot run carrying a version digit
|
|
77
|
+
* (`claude-sonnet-4-6`, `gemini-1.5-pro`); OR a digitless compound of ≥2 distinct
|
|
78
|
+
* model words (`claude-sonnet`). A bare word (`opus`, `haiku`), a path segment
|
|
79
|
+
* (`/home/claude`), or a plain hyphenated token (`claude-bot`) is none of these,
|
|
80
|
+
* so it survives verbatim — the gag scrubs model IDS, never legitimate output.
|
|
81
|
+
*/
|
|
82
|
+
function isModelId(run) {
|
|
83
|
+
if (/^gpt-?\d/i.test(run))
|
|
84
|
+
return true; // gpt-4, gpt4, gpt-4o
|
|
85
|
+
if (!/[-.]/.test(run))
|
|
86
|
+
return false; // bare word — never a model reference here
|
|
87
|
+
if (/\d/.test(run))
|
|
88
|
+
return true; // version digit ⇒ an id
|
|
89
|
+
const words = run.match(MODEL_WORD) ?? [];
|
|
90
|
+
return new Set(words.map((w) => w.toLowerCase())).size >= 2; // claude-sonnet
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Replace every upstream model IDENTIFIER in an arbitrary, externally-sourced
|
|
94
|
+
* string with {@link MODEL_NAME_PLACEHOLDER}, collapsing a whole `name-name-vers`
|
|
95
|
+
* run to a single token.
|
|
96
|
+
*
|
|
97
|
+
* The U.8 gag is structural *inside the process* (the routing package can't
|
|
98
|
+
* produce an upstream id — see `resolveModelId`), but a gateway/provider error
|
|
99
|
+
* body is an arbitrary external string that bypasses that guarantee. This is the
|
|
100
|
+
* scrub applied at the error-construction boundary so no upstream id reaches a
|
|
101
|
+
* user-facing `CruxyError.cause`.
|
|
102
|
+
*
|
|
103
|
+
* Scrubs model ids as whole TOKENS, never a bare vendor word or a substring
|
|
104
|
+
* inside an unrelated word/path — so legitimate output (`/home/claude`, the
|
|
105
|
+
* `opus` codec, a `claude-bot` username) is left intact (see {@link isModelId}).
|
|
106
|
+
* Provider names (`anthropic`/`openai`) are absent from the set, so bring-your-own
|
|
107
|
+
* provider config values survive too.
|
|
108
|
+
*/
|
|
109
|
+
export function scrubModelNames(text) {
|
|
110
|
+
return text.replace(MODEL_ID_CANDIDATE, (run) => isModelId(run) ? MODEL_NAME_PLACEHOLDER : run);
|
|
111
|
+
}
|
|
58
112
|
/**
|
|
59
113
|
* Scan one user-facing string for lexicon violations — deprecated synonyms and
|
|
60
114
|
* upstream model names. Returns every violation (empty ⇒ on-voice). Pure; the
|
package/dist/cli/commands/pr.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
|
5
5
|
import { authMissingKey, shouldUseColor } from "../../errors/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { ApprovalService } from "../../approval/index.js";
|
|
8
|
+
import { resolveTaskModel, routerForConfig } from "../../routing/index.js";
|
|
8
9
|
import { createForgeProvider, createPrService, generateWithLlm, loadCommitGuidance, resolveForgeToken, } from "../../vcs/index.js";
|
|
9
10
|
/**
|
|
10
11
|
* `cruxy pr` — turn the current changes into a pull request (C.15). Generates the
|
|
@@ -37,6 +38,13 @@ export function prCommand() {
|
|
|
37
38
|
temperature: config.model.temperature,
|
|
38
39
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
39
40
|
});
|
|
41
|
+
// Multi-model routing (C.30): PR/commit text is `commit-msg` work. Null
|
|
42
|
+
// unless routing is configured → the provider default, unchanged. A
|
|
43
|
+
// misrouted tier fails loud here before any request.
|
|
44
|
+
const router = routerForConfig(config);
|
|
45
|
+
const genModel = router
|
|
46
|
+
? resolveTaskModel(router, "commit-msg").model
|
|
47
|
+
: undefined;
|
|
40
48
|
// resolveForgeToken throws CRUXY_E_FORGE_AUTH (exit 4) if none is found.
|
|
41
49
|
const forge = createForgeProvider(resolveForgeToken());
|
|
42
50
|
const guidance = await loadCommitGuidance(cwd);
|
|
@@ -53,7 +61,7 @@ export function prCommand() {
|
|
|
53
61
|
...i,
|
|
54
62
|
scopes: guidance.scopes,
|
|
55
63
|
skillBody: guidance.skillBody,
|
|
56
|
-
}),
|
|
64
|
+
}, { model: genModel }),
|
|
57
65
|
});
|
|
58
66
|
logger.info(t.muted("generating pull request content…"));
|
|
59
67
|
const outcome = await service.openPullRequest({
|
|
@@ -7,6 +7,7 @@ import { shouldUseColor } from "../errors/index.js";
|
|
|
7
7
|
import { buildDefaultRegistry } from "../tools/index.js";
|
|
8
8
|
import { Session, } from "../agent/index.js";
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
|
+
import { routerForConfig } from "../routing/index.js";
|
|
10
11
|
import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
|
|
11
12
|
/**
|
|
12
13
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
@@ -96,6 +97,10 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
96
97
|
temperature: config.model.temperature,
|
|
97
98
|
gatewayUrl: config.cruxy.gatewayUrl,
|
|
98
99
|
});
|
|
100
|
+
// Multi-model routing (C.30): null unless routing is configured on a cruxy
|
|
101
|
+
// session, so the default path threads `undefined` and behaves exactly as
|
|
102
|
+
// before. One router is shared by the main loop, subagents, and plan mode.
|
|
103
|
+
const router = routerForConfig(config) ?? undefined;
|
|
99
104
|
const execRegistry = buildDefaultRegistry();
|
|
100
105
|
const git = getGitInfo(cwd);
|
|
101
106
|
const projectInstructions = loadProjectInstructions(cwd);
|
|
@@ -114,6 +119,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
114
119
|
const orchestrator = new SubagentOrchestrator({
|
|
115
120
|
provider,
|
|
116
121
|
config,
|
|
122
|
+
router,
|
|
117
123
|
parentRegistry: execRegistry,
|
|
118
124
|
cwd,
|
|
119
125
|
logger,
|
|
@@ -156,6 +162,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
156
162
|
git,
|
|
157
163
|
projectInstructions,
|
|
158
164
|
renderer: turnRenderer,
|
|
165
|
+
router,
|
|
159
166
|
});
|
|
160
167
|
return new Session({
|
|
161
168
|
provider,
|
|
@@ -167,6 +174,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
167
174
|
planMode: true,
|
|
168
175
|
planRunner,
|
|
169
176
|
hooks,
|
|
177
|
+
router,
|
|
170
178
|
});
|
|
171
179
|
}
|
|
172
180
|
const approval = new ApprovalService({
|
|
@@ -183,5 +191,6 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
183
191
|
git,
|
|
184
192
|
projectInstructions,
|
|
185
193
|
hooks,
|
|
194
|
+
router,
|
|
186
195
|
});
|
|
187
196
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -365,6 +365,27 @@ export declare const HooksConfigSchema: z.ZodObject<{
|
|
|
365
365
|
trustPrompt?: boolean | undefined;
|
|
366
366
|
}>;
|
|
367
367
|
export type HooksConfig = z.infer<typeof HooksConfigSchema>;
|
|
368
|
+
/**
|
|
369
|
+
* Multi-model routing (C.30): map declared task classes to tiers so mechanical
|
|
370
|
+
* work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
|
|
371
|
+
* with an empty `map` and no `default`, every task class routes to the session's
|
|
372
|
+
* single tier and behavior is unchanged (routing stays inert until configured).
|
|
373
|
+
* Only tier names appear here; upstream model names never do (U.8). Keys are the
|
|
374
|
+
* fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
|
|
375
|
+
*/
|
|
376
|
+
export declare const RoutingConfigSchema: z.ZodObject<{
|
|
377
|
+
/** Tier for any task class not in `map`. Unset → the tier implied by
|
|
378
|
+
* `model.model` (a real tier, or the auto-fallback tier). */
|
|
379
|
+
default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
|
|
380
|
+
/** Per-task-class tier overrides; anything omitted takes `default`. */
|
|
381
|
+
map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
|
|
382
|
+
}, "strict", z.ZodTypeAny, {
|
|
383
|
+
map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
|
|
384
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
385
|
+
}, {
|
|
386
|
+
map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
|
|
387
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
388
|
+
}>;
|
|
368
389
|
/** MCP server entry — stdio or URL transport (wired up in a later phase). */
|
|
369
390
|
export declare const McpServerSchema: z.ZodObject<{
|
|
370
391
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -697,6 +718,19 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
697
718
|
enabled?: boolean | undefined;
|
|
698
719
|
trustPrompt?: boolean | undefined;
|
|
699
720
|
}>>;
|
|
721
|
+
routing: z.ZodDefault<z.ZodObject<{
|
|
722
|
+
/** Tier for any task class not in `map`. Unset → the tier implied by
|
|
723
|
+
* `model.model` (a real tier, or the auto-fallback tier). */
|
|
724
|
+
default: z.ZodOptional<z.ZodEnum<["kavi", "vaani", "mira"]>>;
|
|
725
|
+
/** Per-task-class tier overrides; anything omitted takes `default`. */
|
|
726
|
+
map: z.ZodDefault<z.ZodRecord<z.ZodEnum<["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"]>, z.ZodEnum<["kavi", "vaani", "mira"]>>>;
|
|
727
|
+
}, "strict", z.ZodTypeAny, {
|
|
728
|
+
map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
|
|
729
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
730
|
+
}, {
|
|
731
|
+
map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
|
|
732
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
733
|
+
}>>;
|
|
700
734
|
mcpServers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
701
735
|
command: z.ZodOptional<z.ZodString>;
|
|
702
736
|
args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
@@ -732,6 +766,14 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
732
766
|
approval: {
|
|
733
767
|
mode: "prompt";
|
|
734
768
|
};
|
|
769
|
+
subagent: {
|
|
770
|
+
maxDepth: number;
|
|
771
|
+
defaultBudget: {
|
|
772
|
+
maxTokens: number;
|
|
773
|
+
maxIterations: number;
|
|
774
|
+
timeoutMs?: number | undefined;
|
|
775
|
+
};
|
|
776
|
+
};
|
|
735
777
|
model: {
|
|
736
778
|
provider: "cruxy" | "anthropic" | "openai" | "custom";
|
|
737
779
|
model: string;
|
|
@@ -777,14 +819,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
777
819
|
overlapLines: number;
|
|
778
820
|
};
|
|
779
821
|
};
|
|
780
|
-
subagent: {
|
|
781
|
-
maxDepth: number;
|
|
782
|
-
defaultBudget: {
|
|
783
|
-
maxTokens: number;
|
|
784
|
-
maxIterations: number;
|
|
785
|
-
timeoutMs?: number | undefined;
|
|
786
|
-
};
|
|
787
|
-
};
|
|
788
822
|
test: {
|
|
789
823
|
maxIterations: number;
|
|
790
824
|
captureBytes: number;
|
|
@@ -794,6 +828,10 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
794
828
|
enabled: boolean;
|
|
795
829
|
trustPrompt: boolean;
|
|
796
830
|
};
|
|
831
|
+
routing: {
|
|
832
|
+
map: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">>;
|
|
833
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
834
|
+
};
|
|
797
835
|
mcpServers: Record<string, {
|
|
798
836
|
command?: string | undefined;
|
|
799
837
|
args?: string[] | undefined;
|
|
@@ -821,6 +859,14 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
821
859
|
approval?: {
|
|
822
860
|
mode?: "prompt" | undefined;
|
|
823
861
|
} | undefined;
|
|
862
|
+
subagent?: {
|
|
863
|
+
maxDepth?: number | undefined;
|
|
864
|
+
defaultBudget?: {
|
|
865
|
+
maxTokens?: number | undefined;
|
|
866
|
+
maxIterations?: number | undefined;
|
|
867
|
+
timeoutMs?: number | undefined;
|
|
868
|
+
} | undefined;
|
|
869
|
+
} | undefined;
|
|
824
870
|
model?: {
|
|
825
871
|
provider?: "cruxy" | "anthropic" | "openai" | "custom" | undefined;
|
|
826
872
|
model?: string | undefined;
|
|
@@ -866,14 +912,6 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
866
912
|
overlapLines?: number | undefined;
|
|
867
913
|
} | undefined;
|
|
868
914
|
} | undefined;
|
|
869
|
-
subagent?: {
|
|
870
|
-
maxDepth?: number | undefined;
|
|
871
|
-
defaultBudget?: {
|
|
872
|
-
maxTokens?: number | undefined;
|
|
873
|
-
maxIterations?: number | undefined;
|
|
874
|
-
timeoutMs?: number | undefined;
|
|
875
|
-
} | undefined;
|
|
876
|
-
} | undefined;
|
|
877
915
|
test?: {
|
|
878
916
|
maxIterations?: number | undefined;
|
|
879
917
|
command?: string | undefined;
|
|
@@ -883,6 +921,10 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
883
921
|
enabled?: boolean | undefined;
|
|
884
922
|
trustPrompt?: boolean | undefined;
|
|
885
923
|
} | undefined;
|
|
924
|
+
routing?: {
|
|
925
|
+
map?: Partial<Record<"main-turn" | "subagent" | "plan" | "commit-msg" | "classify" | "summarize", "kavi" | "vaani" | "mira">> | undefined;
|
|
926
|
+
default?: "kavi" | "vaani" | "mira" | undefined;
|
|
927
|
+
} | undefined;
|
|
886
928
|
mcpServers?: Record<string, {
|
|
887
929
|
command?: string | undefined;
|
|
888
930
|
args?: string[] | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { LOG_LEVELS } from "../utils/logger.js";
|
|
3
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
4
|
+
import { TASK_CLASSES } from "../routing/types.js";
|
|
3
5
|
export const ProviderSchema = z.enum([
|
|
4
6
|
"cruxy",
|
|
5
7
|
"anthropic",
|
|
@@ -254,6 +256,23 @@ export const HooksConfigSchema = z
|
|
|
254
256
|
trustPrompt: z.boolean().default(true),
|
|
255
257
|
})
|
|
256
258
|
.strict();
|
|
259
|
+
/**
|
|
260
|
+
* Multi-model routing (C.30): map declared task classes to tiers so mechanical
|
|
261
|
+
* work runs on a cheap tier and hard reasoning on a strong one. Fully opt-in —
|
|
262
|
+
* with an empty `map` and no `default`, every task class routes to the session's
|
|
263
|
+
* single tier and behavior is unchanged (routing stays inert until configured).
|
|
264
|
+
* Only tier names appear here; upstream model names never do (U.8). Keys are the
|
|
265
|
+
* fixed {@link TASK_CLASSES}, so a mistyped class is rejected at config load.
|
|
266
|
+
*/
|
|
267
|
+
export const RoutingConfigSchema = z
|
|
268
|
+
.object({
|
|
269
|
+
/** Tier for any task class not in `map`. Unset → the tier implied by
|
|
270
|
+
* `model.model` (a real tier, or the auto-fallback tier). */
|
|
271
|
+
default: z.enum(MODEL_TIERS).optional(),
|
|
272
|
+
/** Per-task-class tier overrides; anything omitted takes `default`. */
|
|
273
|
+
map: z.record(z.enum(TASK_CLASSES), z.enum(MODEL_TIERS)).default({}),
|
|
274
|
+
})
|
|
275
|
+
.strict();
|
|
257
276
|
/** MCP server entry — stdio or URL transport (wired up in a later phase). */
|
|
258
277
|
export const McpServerSchema = z
|
|
259
278
|
.object({
|
|
@@ -278,6 +297,7 @@ export const CruxyConfigSchema = z
|
|
|
278
297
|
test: TestConfigSchema.default({}),
|
|
279
298
|
sandbox: SandboxConfigSchema.default({}),
|
|
280
299
|
hooks: HooksConfigSchema.default({}),
|
|
300
|
+
routing: RoutingConfigSchema.default({}),
|
|
281
301
|
mcpServers: z.record(z.string(), McpServerSchema).default({}),
|
|
282
302
|
logLevel: z.enum(LOG_LEVELS).default("info"),
|
|
283
303
|
})
|
|
@@ -11,6 +11,15 @@ export declare function usageError(title: string, nextSteps?: string[]): CruxyEr
|
|
|
11
11
|
export declare function interactiveRequired(what: string, alternatives?: string[]): CruxyError;
|
|
12
12
|
export declare function configKeyUnknown(key: string): CruxyError;
|
|
13
13
|
export declare function providerUnsupported(provider: string): CruxyError;
|
|
14
|
+
/**
|
|
15
|
+
* A task class is routed (C.30) to a tier the gateway does not offer. A usage
|
|
16
|
+
* error the user fixes in config — cruxy fails loud here rather than silently
|
|
17
|
+
* substituting a different tier (which would hand a user a model they never
|
|
18
|
+
* asked for). Distinct from runtime unavailability (overload/budget), which
|
|
19
|
+
* stays on the U.5 api codes. Params are tier/class NAMES only — never an
|
|
20
|
+
* upstream model id — so the message is gag-safe by construction (U.8).
|
|
21
|
+
*/
|
|
22
|
+
export declare function routingTierUnavailable(tier: string, taskClass: string, offered: string[]): CruxyError;
|
|
14
23
|
export declare function configParse(path: string, underlying?: unknown): CruxyError;
|
|
15
24
|
export declare function configInvalid(issues: string, path?: string): CruxyError;
|
|
16
25
|
export declare function authMissingKey(provider: string, envVar: string): CruxyError;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ApiError, AuthError, NetworkError, OverloadedError, RateLimitError, } from "@cruxy/sdk";
|
|
2
|
+
import { scrubModelNames } from "../brand/index.js";
|
|
2
3
|
import { CruxyError, ErrorCode } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* Helper constructors for {@link CruxyError}. Each encodes the title, the human
|
|
@@ -15,6 +16,18 @@ export function messageOf(underlying) {
|
|
|
15
16
|
return undefined;
|
|
16
17
|
return String(underlying);
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* {@link messageOf}, gag-scrubbed (U.8) for a gateway/provider-originated error.
|
|
21
|
+
* A gateway error body is an arbitrary EXTERNAL string, so it bypasses the
|
|
22
|
+
* structural tier gag — this is the boundary that scrubs any upstream model name
|
|
23
|
+
* out of it before it can reach a user-facing `cause`. Used by the provider-error
|
|
24
|
+
* constructors below (everything `classifyProviderError` routes through). The raw
|
|
25
|
+
* message stays on `underlying`, shown verbatim only under `--verbose`.
|
|
26
|
+
*/
|
|
27
|
+
function scrubbedMessageOf(underlying) {
|
|
28
|
+
const msg = messageOf(underlying);
|
|
29
|
+
return msg === undefined ? undefined : scrubModelNames(msg);
|
|
30
|
+
}
|
|
18
31
|
// ── usage (exit 2) ────────────────────────────────────────────────────────────
|
|
19
32
|
export function usageError(title, nextSteps) {
|
|
20
33
|
return new CruxyError({
|
|
@@ -57,6 +70,28 @@ export function providerUnsupported(provider) {
|
|
|
57
70
|
meta: { provider },
|
|
58
71
|
});
|
|
59
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A task class is routed (C.30) to a tier the gateway does not offer. A usage
|
|
75
|
+
* error the user fixes in config — cruxy fails loud here rather than silently
|
|
76
|
+
* substituting a different tier (which would hand a user a model they never
|
|
77
|
+
* asked for). Distinct from runtime unavailability (overload/budget), which
|
|
78
|
+
* stays on the U.5 api codes. Params are tier/class NAMES only — never an
|
|
79
|
+
* upstream model id — so the message is gag-safe by construction (U.8).
|
|
80
|
+
*/
|
|
81
|
+
export function routingTierUnavailable(tier, taskClass, offered) {
|
|
82
|
+
return new CruxyError({
|
|
83
|
+
code: ErrorCode.RoutingTierUnavailable,
|
|
84
|
+
title: `the "${tier}" tier is not available for the "${taskClass}" task`,
|
|
85
|
+
cause: `routing maps "${taskClass}" to the "${tier}" tier, which this gateway does not currently offer`,
|
|
86
|
+
nextSteps: [
|
|
87
|
+
`route it to an available tier, e.g. \`cruxy config set routing.map.${taskClass} ${offered[0] ?? "vaani"}\``,
|
|
88
|
+
offered.length
|
|
89
|
+
? `available tiers: ${offered.join(", ")}`
|
|
90
|
+
: "no tiers are currently available",
|
|
91
|
+
],
|
|
92
|
+
meta: { tier, taskClass, offered },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
60
95
|
// ── config (exit 3) ───────────────────────────────────────────────────────────
|
|
61
96
|
export function configParse(path, underlying) {
|
|
62
97
|
return new CruxyError({
|
|
@@ -99,7 +134,7 @@ export function authInvalid(underlying) {
|
|
|
99
134
|
return new CruxyError({
|
|
100
135
|
code: ErrorCode.AuthInvalid,
|
|
101
136
|
title: "the provider rejected your credentials",
|
|
102
|
-
cause:
|
|
137
|
+
cause: scrubbedMessageOf(underlying),
|
|
103
138
|
nextSteps: [
|
|
104
139
|
"verify your API key is correct and active",
|
|
105
140
|
"re-export the key and try again",
|
|
@@ -112,7 +147,7 @@ export function gatewayUnreachable(underlying) {
|
|
|
112
147
|
return new CruxyError({
|
|
113
148
|
code: ErrorCode.GatewayUnreachable,
|
|
114
149
|
title: "could not reach the model gateway",
|
|
115
|
-
cause:
|
|
150
|
+
cause: scrubbedMessageOf(underlying),
|
|
116
151
|
nextSteps: [
|
|
117
152
|
"check your internet connection",
|
|
118
153
|
"verify the gateway URL with `cruxy config get cruxy.gatewayUrl`",
|
|
@@ -129,7 +164,7 @@ export function apiError(underlying) {
|
|
|
129
164
|
title: status
|
|
130
165
|
? `the model provider returned an error (HTTP ${status})`
|
|
131
166
|
: "the model provider returned an error",
|
|
132
|
-
cause:
|
|
167
|
+
cause: scrubbedMessageOf(underlying),
|
|
133
168
|
nextSteps: [
|
|
134
169
|
"retry in a moment; if it persists, check the provider's status",
|
|
135
170
|
],
|
|
@@ -142,7 +177,7 @@ export function apiRateLimit(underlying) {
|
|
|
142
177
|
return new CruxyError({
|
|
143
178
|
code: ErrorCode.ApiRateLimit,
|
|
144
179
|
title: "rate limited by the model provider",
|
|
145
|
-
cause:
|
|
180
|
+
cause: scrubbedMessageOf(underlying),
|
|
146
181
|
nextSteps: [
|
|
147
182
|
retryAfterMs
|
|
148
183
|
? `wait ~${Math.ceil(retryAfterMs / 1000)}s and retry`
|
|
@@ -156,7 +191,7 @@ export function apiOverloaded(underlying) {
|
|
|
156
191
|
return new CruxyError({
|
|
157
192
|
code: ErrorCode.ApiOverloaded,
|
|
158
193
|
title: "the model provider is overloaded",
|
|
159
|
-
cause:
|
|
194
|
+
cause: scrubbedMessageOf(underlying),
|
|
160
195
|
nextSteps: ["retry in a few moments"],
|
|
161
196
|
underlying,
|
|
162
197
|
});
|
|
@@ -165,7 +200,7 @@ export function budgetExhausted(underlying) {
|
|
|
165
200
|
return new CruxyError({
|
|
166
201
|
code: ErrorCode.BudgetExhausted,
|
|
167
202
|
title: "your Cruxy budget is exhausted",
|
|
168
|
-
cause:
|
|
203
|
+
cause: scrubbedMessageOf(underlying),
|
|
169
204
|
nextSteps: ["top up or raise your budget, then retry"],
|
|
170
205
|
underlying,
|
|
171
206
|
});
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export declare const ErrorCode: {
|
|
|
21
21
|
readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
|
|
22
22
|
readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
|
|
23
23
|
readonly CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND";
|
|
24
|
+
/** Routing (C.30): a task class is mapped to a tier the gateway does not
|
|
25
|
+
* offer — fix the config, NEVER a silent substitution to another tier. */
|
|
26
|
+
readonly RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE";
|
|
24
27
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
25
28
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
26
29
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
package/dist/errors/types.js
CHANGED
|
@@ -23,6 +23,9 @@ export const ErrorCode = {
|
|
|
23
23
|
PlanInvalid: "CRUXY_E_PLAN_INVALID",
|
|
24
24
|
PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
|
|
25
25
|
CheckpointNotFound: "CRUXY_E_CHECKPOINT_NOT_FOUND",
|
|
26
|
+
/** Routing (C.30): a task class is mapped to a tier the gateway does not
|
|
27
|
+
* offer — fix the config, NEVER a silent substitution to another tier. */
|
|
28
|
+
RoutingTierUnavailable: "CRUXY_E_ROUTING_TIER_UNAVAILABLE",
|
|
26
29
|
// config (exit 3)
|
|
27
30
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
28
31
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
@@ -97,6 +100,7 @@ const EXIT_CODES = {
|
|
|
97
100
|
[ErrorCode.PlanInvalid]: 2,
|
|
98
101
|
[ErrorCode.PlanRevisionLimit]: 2,
|
|
99
102
|
[ErrorCode.CheckpointNotFound]: 2,
|
|
103
|
+
[ErrorCode.RoutingTierUnavailable]: 2,
|
|
100
104
|
[ErrorCode.ConfigParse]: 3,
|
|
101
105
|
[ErrorCode.ConfigInvalid]: 3,
|
|
102
106
|
[ErrorCode.AuthMissingKey]: 4,
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { PromptIO } from "../approval/index.js";
|
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
5
5
|
import { type AgentResult } from "../agent/loop.js";
|
|
6
6
|
import type { StreamRenderer } from "../render/index.js";
|
|
7
|
+
import type { Router } from "../routing/index.js";
|
|
7
8
|
import { PlanExecutionPolicy } from "./policy.js";
|
|
8
9
|
/**
|
|
9
10
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -36,5 +37,10 @@ export interface PlanSessionArgs {
|
|
|
36
37
|
renderer?: StreamRenderer;
|
|
37
38
|
/** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
|
|
38
39
|
maxRevisions?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Multi-model routing (C.30). When set, the propose phase routes on `plan`
|
|
42
|
+
* and step execution on `main-turn`; omitted → the provider default.
|
|
43
|
+
*/
|
|
44
|
+
router?: Router;
|
|
39
45
|
}
|
|
40
46
|
export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
|
package/dist/plan/service.js
CHANGED
|
@@ -75,6 +75,8 @@ export async function runPlanSession(args) {
|
|
|
75
75
|
projectInstructions: args.projectInstructions,
|
|
76
76
|
renderer: args.renderer,
|
|
77
77
|
planMode: true,
|
|
78
|
+
router: args.router,
|
|
79
|
+
taskClass: "plan",
|
|
78
80
|
}));
|
|
79
81
|
if (!holder.plan) {
|
|
80
82
|
throw planInvalid("the model ended its turn without calling submit_plan");
|
|
@@ -106,6 +108,8 @@ export async function runPlanSession(args) {
|
|
|
106
108
|
git: args.git,
|
|
107
109
|
projectInstructions: args.projectInstructions,
|
|
108
110
|
renderer: args.renderer,
|
|
111
|
+
router: args.router,
|
|
112
|
+
taskClass: "main-turn",
|
|
109
113
|
}));
|
|
110
114
|
};
|
|
111
115
|
await executePlan(plan, {
|
package/dist/render/state.js
CHANGED
|
@@ -39,9 +39,12 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
|
|
|
39
39
|
case "thinking": {
|
|
40
40
|
const t = phase.tokens;
|
|
41
41
|
// Honest numbers only: no usage yet → no figure at all.
|
|
42
|
-
|
|
42
|
+
const base = t && t.input + t.output > 0
|
|
43
43
|
? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
|
|
44
44
|
: `thinking${e}`;
|
|
45
|
+
// The real routing tier (C.30), appended honestly when present — same
|
|
46
|
+
// ` · ` joiner as the token counts.
|
|
47
|
+
return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
|
|
45
48
|
}
|
|
46
49
|
case "calling-tool":
|
|
47
50
|
return `${phase.label}${e}`;
|
package/dist/render/types.d.ts
CHANGED
|
@@ -53,10 +53,16 @@ export interface TokenUsage {
|
|
|
53
53
|
* One phase is live at a time — it is a register, not a queue.
|
|
54
54
|
*/
|
|
55
55
|
export type RenderPhase =
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Waiting on the model. `tokens` = usage accumulated so far, omitted at 0.
|
|
58
|
+
* `tier` = the routing tier this turn runs on (C.30), shown only when routing
|
|
59
|
+
* is active — an honest signal of the real tier, never a fabricated one, and
|
|
60
|
+
* always a tier name (never an upstream model id, U.8).
|
|
61
|
+
*/
|
|
57
62
|
{
|
|
58
63
|
kind: "thinking";
|
|
59
64
|
tokens?: TokenUsage;
|
|
65
|
+
tier?: string;
|
|
60
66
|
}
|
|
61
67
|
/** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
|
|
62
68
|
| {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
|
|
3
|
+
// `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
|
|
4
|
+
// mapping is internal to routing, so it can never be reached from a user-facing
|
|
5
|
+
// render path (the internal-mapping-isolation guarantee, U.8).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Tier } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
|
|
4
|
+
* it is INTERNAL to the routing package (deliberately not re-exported from the
|
|
5
|
+
* barrel) so it can never be called from a user-facing render path.
|
|
6
|
+
*
|
|
7
|
+
* For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
|
|
8
|
+
* to a concrete upstream model SERVER-SIDE. So this returns the tier name
|
|
9
|
+
* unchanged: the output is always a tier, and no upstream model name can
|
|
10
|
+
* originate here. That property is what keeps the U.8 gag structural rather than
|
|
11
|
+
* a filter — there is no upstream id in the process to leak (see the tier-gag
|
|
12
|
+
* test, which asserts this output is always a MODEL_TIERS member).
|
|
13
|
+
*
|
|
14
|
+
* The seam exists for a future provider whose tiers map to distinct wire ids;
|
|
15
|
+
* that mapping would live here and here only.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveModelId(tier: Tier): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
|
|
3
|
+
* it is INTERNAL to the routing package (deliberately not re-exported from the
|
|
4
|
+
* barrel) so it can never be called from a user-facing render path.
|
|
5
|
+
*
|
|
6
|
+
* For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
|
|
7
|
+
* to a concrete upstream model SERVER-SIDE. So this returns the tier name
|
|
8
|
+
* unchanged: the output is always a tier, and no upstream model name can
|
|
9
|
+
* originate here. That property is what keeps the U.8 gag structural rather than
|
|
10
|
+
* a filter — there is no upstream id in the process to leak (see the tier-gag
|
|
11
|
+
* test, which asserts this output is always a MODEL_TIERS member).
|
|
12
|
+
*
|
|
13
|
+
* The seam exists for a future provider whose tiers map to distinct wire ids;
|
|
14
|
+
* that mapping would live here and here only.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveModelId(tier) {
|
|
17
|
+
return tier;
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { Router, RoutingConfig, TaskClass, Tier } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
5
|
+
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
6
|
+
* cruxy session lands on exactly the tier it does today.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_TIER: Tier;
|
|
9
|
+
/**
|
|
10
|
+
* The config-driven {@link Router}: maps a declared task class to a tier from
|
|
11
|
+
* `{ default, map }`, and fails loud when the resolved tier is not offered. It
|
|
12
|
+
* NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class ConfigRouter implements Router {
|
|
15
|
+
private readonly cfg;
|
|
16
|
+
private readonly offered;
|
|
17
|
+
/**
|
|
18
|
+
* @param cfg the resolved routing table (default tier + per-task map)
|
|
19
|
+
* @param offered the tiers this gateway/plan actually provides; a resolved
|
|
20
|
+
* tier outside this set fails loud. Defaults to all tiers — the
|
|
21
|
+
* seam a future entitlement check narrows (never a silent
|
|
22
|
+
* downgrade).
|
|
23
|
+
*/
|
|
24
|
+
constructor(cfg: RoutingConfig, offered?: Iterable<Tier>);
|
|
25
|
+
select(taskClass: TaskClass): Tier;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a router from resolved config, or `null` when routing should stay
|
|
29
|
+
* inert. Routing is:
|
|
30
|
+
*
|
|
31
|
+
* - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
|
|
32
|
+
* providers get `null` (no override, their `model.model` is used unchanged);
|
|
33
|
+
* - opt-in — with no `routing.default` and an empty `routing.map`, this returns
|
|
34
|
+
* `null` so behavior (and the wire body, and the state line) is byte-identical
|
|
35
|
+
* to today. Multi-tier routing activates only once the user configures it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function routerForConfig(config: CruxyConfig): Router | null;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a declared task class to `{ tier, model }`: the tier for honest
|
|
40
|
+
* surfacing (the U.4 state line), the wire model id for the request. The model
|
|
41
|
+
* id comes from the internal {@link resolveModelId} — callers never touch that
|
|
42
|
+
* mapping directly, so it stays the single source of truth.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveTaskModel(router: Router, taskClass: TaskClass): {
|
|
45
|
+
tier: Tier;
|
|
46
|
+
model: string;
|
|
47
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
import { routingTierUnavailable } from "../errors/index.js";
|
|
3
|
+
import { resolveModelId } from "./resolve.js";
|
|
4
|
+
/**
|
|
5
|
+
* The tier a config resolves to when nothing else pins one down — mirrors the
|
|
6
|
+
* gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
|
|
7
|
+
* cruxy session lands on exactly the tier it does today.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_TIER = "vaani";
|
|
10
|
+
/**
|
|
11
|
+
* The config-driven {@link Router}: maps a declared task class to a tier from
|
|
12
|
+
* `{ default, map }`, and fails loud when the resolved tier is not offered. It
|
|
13
|
+
* NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
|
|
14
|
+
*/
|
|
15
|
+
export class ConfigRouter {
|
|
16
|
+
cfg;
|
|
17
|
+
offered;
|
|
18
|
+
/**
|
|
19
|
+
* @param cfg the resolved routing table (default tier + per-task map)
|
|
20
|
+
* @param offered the tiers this gateway/plan actually provides; a resolved
|
|
21
|
+
* tier outside this set fails loud. Defaults to all tiers — the
|
|
22
|
+
* seam a future entitlement check narrows (never a silent
|
|
23
|
+
* downgrade).
|
|
24
|
+
*/
|
|
25
|
+
constructor(cfg, offered = MODEL_TIERS) {
|
|
26
|
+
this.cfg = cfg;
|
|
27
|
+
this.offered = new Set(offered);
|
|
28
|
+
}
|
|
29
|
+
select(taskClass) {
|
|
30
|
+
// Explicit override, else the default — an unmapped/unknown class is not an
|
|
31
|
+
// error, it just takes the default (never a crash, never the cheapest).
|
|
32
|
+
const tier = this.cfg.map[taskClass] ?? this.cfg.default;
|
|
33
|
+
// Fail loud: a configured tier the gateway does not offer is a usage error
|
|
34
|
+
// to fix, NOT a silent substitution to some other tier (a user who asked for
|
|
35
|
+
// mira reasoning must never be quietly handed kavi).
|
|
36
|
+
if (!this.offered.has(tier)) {
|
|
37
|
+
throw routingTierUnavailable(tier, taskClass, [...this.offered]);
|
|
38
|
+
}
|
|
39
|
+
return tier;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The base tier implied by the session's `model.model`: a real tier passes
|
|
44
|
+
* through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
|
|
45
|
+
* Used so that when a user has pinned a single tier, an opt-in routing table
|
|
46
|
+
* that omits `routing.default` still defaults to THEIR tier, not a fixed one.
|
|
47
|
+
*/
|
|
48
|
+
function baseTierFromModel(model) {
|
|
49
|
+
return MODEL_TIERS.includes(model)
|
|
50
|
+
? model
|
|
51
|
+
: DEFAULT_TIER;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build a router from resolved config, or `null` when routing should stay
|
|
55
|
+
* inert. Routing is:
|
|
56
|
+
*
|
|
57
|
+
* - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
|
|
58
|
+
* providers get `null` (no override, their `model.model` is used unchanged);
|
|
59
|
+
* - opt-in — with no `routing.default` and an empty `routing.map`, this returns
|
|
60
|
+
* `null` so behavior (and the wire body, and the state line) is byte-identical
|
|
61
|
+
* to today. Multi-tier routing activates only once the user configures it.
|
|
62
|
+
*/
|
|
63
|
+
export function routerForConfig(config) {
|
|
64
|
+
if (config.model.provider !== "cruxy")
|
|
65
|
+
return null;
|
|
66
|
+
const { default: def, map } = config.routing;
|
|
67
|
+
const configured = def !== undefined || Object.keys(map).length > 0;
|
|
68
|
+
if (!configured)
|
|
69
|
+
return null;
|
|
70
|
+
return new ConfigRouter({
|
|
71
|
+
default: def ?? baseTierFromModel(config.model.model),
|
|
72
|
+
map,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a declared task class to `{ tier, model }`: the tier for honest
|
|
77
|
+
* surfacing (the U.4 state line), the wire model id for the request. The model
|
|
78
|
+
* id comes from the internal {@link resolveModelId} — callers never touch that
|
|
79
|
+
* mapping directly, so it stays the single source of truth.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveTaskModel(router, taskClass) {
|
|
82
|
+
const tier = router.select(taskClass);
|
|
83
|
+
return { tier, model: resolveModelId(tier) };
|
|
84
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { MODEL_TIERS } from "../brand/voice.js";
|
|
2
|
+
/**
|
|
3
|
+
* Multi-model routing (C.30): route each unit of work to the right tier instead
|
|
4
|
+
* of running one model for everything. The caller DECLARES a {@link TaskClass}
|
|
5
|
+
* at the call site; a {@link Router} maps that class to a {@link Tier} via config
|
|
6
|
+
* — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
|
|
7
|
+
* mapping is internal (see `resolve.ts`); only tier names ever appear in config,
|
|
8
|
+
* logs, errors, or the state line (the U.8 tier gag).
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The unit-of-work classes a caller can declare. Each is an explicit intent —
|
|
12
|
+
* NOT a difficulty the router infers. Unknown/unset resolves to the router's
|
|
13
|
+
* default tier, never a crash.
|
|
14
|
+
*/
|
|
15
|
+
export declare const TASK_CLASSES: readonly ["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"];
|
|
16
|
+
export type TaskClass = (typeof TASK_CLASSES)[number];
|
|
17
|
+
/** A routing tier — the ONLY model vocabulary the user ever sees (U.8). */
|
|
18
|
+
export type Tier = (typeof MODEL_TIERS)[number];
|
|
19
|
+
/**
|
|
20
|
+
* Selects a tier for a declared task class. Deliberately one method: the caller
|
|
21
|
+
* passes intent, the router returns a tier from its configured mapping. No
|
|
22
|
+
* prompt-content inspection, ever — difficulty detection is explicitly out of
|
|
23
|
+
* scope (a caller declares; the router does not guess).
|
|
24
|
+
*/
|
|
25
|
+
export interface Router {
|
|
26
|
+
/**
|
|
27
|
+
* Map a task class to its tier. Falls back to the configured default when the
|
|
28
|
+
* class has no explicit mapping; throws `CRUXY_E_ROUTING_TIER_UNAVAILABLE`
|
|
29
|
+
* (fail loud, never a silent substitution) when the resolved tier is not
|
|
30
|
+
* offered.
|
|
31
|
+
*/
|
|
32
|
+
select(taskClass: TaskClass): Tier;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The declarative routing table: a `default` tier plus per-task overrides. Lives
|
|
36
|
+
* in config (`routing.default`, `routing.map`). An empty map means every class
|
|
37
|
+
* resolves to `default` — a single tier, no traffic splitting.
|
|
38
|
+
*/
|
|
39
|
+
export interface RoutingConfig {
|
|
40
|
+
default: Tier;
|
|
41
|
+
map: Partial<Record<TaskClass, Tier>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-model routing (C.30): route each unit of work to the right tier instead
|
|
3
|
+
* of running one model for everything. The caller DECLARES a {@link TaskClass}
|
|
4
|
+
* at the call site; a {@link Router} maps that class to a {@link Tier} via config
|
|
5
|
+
* — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
|
|
6
|
+
* mapping is internal (see `resolve.ts`); only tier names ever appear in config,
|
|
7
|
+
* logs, errors, or the state line (the U.8 tier gag).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The unit-of-work classes a caller can declare. Each is an explicit intent —
|
|
11
|
+
* NOT a difficulty the router infers. Unknown/unset resolves to the router's
|
|
12
|
+
* default tier, never a crash.
|
|
13
|
+
*/
|
|
14
|
+
export const TASK_CLASSES = [
|
|
15
|
+
/** An interactive main-agent turn. */
|
|
16
|
+
"main-turn",
|
|
17
|
+
/** A spawned subagent task (C.14). */
|
|
18
|
+
"subagent",
|
|
19
|
+
/** Plan proposal / revision (C.31). */
|
|
20
|
+
"plan",
|
|
21
|
+
/** Commit / pull-request text generation. */
|
|
22
|
+
"commit-msg",
|
|
23
|
+
/** A cheap one-shot classification. */
|
|
24
|
+
"classify",
|
|
25
|
+
/** Context compaction / summarization. */
|
|
26
|
+
"summarize",
|
|
27
|
+
];
|
|
@@ -2,6 +2,7 @@ import type { Provider } from "@cruxy/sdk";
|
|
|
2
2
|
import type { ApprovalDecision } from "../approval/types.js";
|
|
3
3
|
import type { CruxyConfig } from "../config/index.js";
|
|
4
4
|
import type { StreamRenderer } from "../render/index.js";
|
|
5
|
+
import type { Router } from "../routing/index.js";
|
|
5
6
|
import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
|
|
6
7
|
import type { SandboxService } from "../sandbox/index.js";
|
|
7
8
|
import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
@@ -15,6 +16,11 @@ import type { SubagentResult, SubagentSpec } from "./types.js";
|
|
|
15
16
|
export interface SubagentOrchestratorDeps {
|
|
16
17
|
provider: Provider;
|
|
17
18
|
config: CruxyConfig;
|
|
19
|
+
/**
|
|
20
|
+
* Multi-model routing (C.30). When set, child runs route on their spec's task
|
|
21
|
+
* class (default `subagent`); omitted → the provider default (unchanged).
|
|
22
|
+
*/
|
|
23
|
+
router?: Router;
|
|
18
24
|
/** The parent's registry — the ceiling every child scope derives from. */
|
|
19
25
|
parentRegistry: ToolRegistry;
|
|
20
26
|
cwd: string;
|
package/dist/subagent/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { TaskClass } from "../routing/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Types for subagent orchestration (C.14): the main agent delegates a bounded
|
|
4
5
|
* subtask to a child agent that runs the SAME loop with its own fresh history,
|
|
@@ -35,6 +36,11 @@ export interface SubagentSpec {
|
|
|
35
36
|
* spawn can narrow its budget, never raise it past the configured ceilings.
|
|
36
37
|
*/
|
|
37
38
|
budget?: Partial<BudgetLimits>;
|
|
39
|
+
/**
|
|
40
|
+
* Routing task class for this spawn (C.30); defaults to `subagent`. A
|
|
41
|
+
* declaration at the spawn call site — not something the router guesses.
|
|
42
|
+
*/
|
|
43
|
+
taskClass?: TaskClass;
|
|
38
44
|
}
|
|
39
45
|
/**
|
|
40
46
|
* What the parent gets back — compact structured data, never the transcript.
|
package/dist/vcs/generate.d.ts
CHANGED
|
@@ -59,7 +59,9 @@ export declare function fillContent(input: GenerateInput): GeneratedContent;
|
|
|
59
59
|
* rules, then normalize + redact. Resilient: if the model's reply isn't the
|
|
60
60
|
* expected JSON, the first line becomes the subject and the rest the body.
|
|
61
61
|
*/
|
|
62
|
-
export declare function generateWithLlm(provider: Provider, input: GenerateInput
|
|
62
|
+
export declare function generateWithLlm(provider: Provider, input: GenerateInput, opts?: {
|
|
63
|
+
model?: string;
|
|
64
|
+
}): Promise<GeneratedContent>;
|
|
63
65
|
interface ParsedGenerated {
|
|
64
66
|
branchName?: string;
|
|
65
67
|
commitSubject?: string;
|
package/dist/vcs/generate.js
CHANGED
|
@@ -154,7 +154,7 @@ export function fillContent(input) {
|
|
|
154
154
|
* rules, then normalize + redact. Resilient: if the model's reply isn't the
|
|
155
155
|
* expected JSON, the first line becomes the subject and the rest the body.
|
|
156
156
|
*/
|
|
157
|
-
export async function generateWithLlm(provider, input) {
|
|
157
|
+
export async function generateWithLlm(provider, input, opts = {}) {
|
|
158
158
|
const redactedDiff = redactSecrets(input.diff);
|
|
159
159
|
const system = buildSystemPrompt(input);
|
|
160
160
|
const user = buildUserPrompt({ ...input, diff: redactedDiff });
|
|
@@ -162,6 +162,9 @@ export async function generateWithLlm(provider, input) {
|
|
|
162
162
|
for await (const ev of provider.stream({
|
|
163
163
|
system,
|
|
164
164
|
messages: [{ role: "user", content: user }],
|
|
165
|
+
// The commit-msg tier's wire model (C.30), resolved by the caller; omitted →
|
|
166
|
+
// the provider default.
|
|
167
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
165
168
|
})) {
|
|
166
169
|
if (ev.type === "text_delta")
|
|
167
170
|
text += ev.text;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"tinyglobby": "^0.2.10",
|
|
37
37
|
"zod": "^3.23.8",
|
|
38
38
|
"zod-to-json-schema": "^3.23.5",
|
|
39
|
-
"@cruxy/sdk": "0.
|
|
39
|
+
"@cruxy/sdk": "0.2.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/better-sqlite3": "^7.6.13",
|