@intentic/sandbox-contract 1.224.0 → 1.226.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.
Files changed (109) hide show
  1. package/README.md +14 -14
  2. package/dist/agent-catalog.d.ts +2 -0
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +19 -3
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/chores/chores.js +42 -42
  7. package/dist/chores/chores.js.map +1 -1
  8. package/dist/chores/extension-update.js +2 -2
  9. package/dist/chores/extension-update.js.map +1 -1
  10. package/dist/chores/fix-deps.js +1 -1
  11. package/dist/chores/fix-deps.js.map +1 -1
  12. package/dist/chores/probes.js +1 -1
  13. package/dist/chores/probes.js.map +1 -1
  14. package/dist/chores/verdict.js +2 -2
  15. package/dist/chores/verdict.js.map +1 -1
  16. package/dist/contracts/capabilities.contract.d.ts +41 -0
  17. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  18. package/dist/contracts/exit.contract.d.ts +80 -0
  19. package/dist/contracts/exit.contract.d.ts.map +1 -0
  20. package/dist/contracts/exit.contract.js +13 -0
  21. package/dist/contracts/exit.contract.js.map +1 -0
  22. package/dist/contracts/settings.contract.d.ts +12 -0
  23. package/dist/contracts/settings.contract.d.ts.map +1 -1
  24. package/dist/events.d.ts +4 -4
  25. package/dist/events.d.ts.map +1 -1
  26. package/dist/events.js +5 -5
  27. package/dist/events.js.map +1 -1
  28. package/dist/fast-tier.d.ts +9 -0
  29. package/dist/fast-tier.d.ts.map +1 -0
  30. package/dist/fast-tier.js +19 -0
  31. package/dist/fast-tier.js.map +1 -0
  32. package/dist/history-state.js +3 -3
  33. package/dist/history-state.js.map +1 -1
  34. package/dist/index.d.ts +203 -68
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/model-order.d.ts +1 -0
  39. package/dist/model-order.d.ts.map +1 -1
  40. package/dist/model-order.js +5 -0
  41. package/dist/model-order.js.map +1 -1
  42. package/dist/output-fields.d.ts.map +1 -1
  43. package/dist/output-fields.js +2 -2
  44. package/dist/output-fields.js.map +1 -1
  45. package/dist/prompt-complexity.d.ts +19 -0
  46. package/dist/prompt-complexity.d.ts.map +1 -0
  47. package/dist/prompt-complexity.js +83 -0
  48. package/dist/prompt-complexity.js.map +1 -0
  49. package/dist/publish-drafts.js +2 -2
  50. package/dist/publish-drafts.js.map +1 -1
  51. package/dist/schemas.d.ts +214 -0
  52. package/dist/schemas.d.ts.map +1 -1
  53. package/dist/schemas.js +113 -2
  54. package/dist/schemas.js.map +1 -1
  55. package/dist/workflow-faults.js +3 -3
  56. package/dist/workflow-faults.js.map +1 -1
  57. package/dist/workspace-state.d.ts +20 -20
  58. package/dist/workspace-state.d.ts.map +1 -1
  59. package/dist/workspace-state.js +20 -20
  60. package/dist/workspace-state.js.map +1 -1
  61. package/package.json +5 -5
  62. package/src/agent-catalog.test.ts +72 -27
  63. package/src/agent-catalog.ts +91 -3
  64. package/src/agent-run-model.test.ts +3 -3
  65. package/src/capability-ledger.test.ts +34 -15
  66. package/src/chores/chores.test.ts +1 -1
  67. package/src/chores/chores.ts +42 -42
  68. package/src/chores/digest.test.ts +1 -1
  69. package/src/chores/extension-update.ts +2 -2
  70. package/src/chores/fix-deps.ts +1 -1
  71. package/src/chores/probes.test.ts +6 -6
  72. package/src/chores/probes.ts +1 -1
  73. package/src/chores/stack.test.ts +3 -3
  74. package/src/chores/verdict.test.ts +20 -20
  75. package/src/chores/verdict.ts +2 -2
  76. package/src/contract-lock.test.ts +1 -1
  77. package/src/contracts/exit.contract.ts +42 -0
  78. package/src/contracts/providers.contract.ts +1 -1
  79. package/src/conversation-ids.ts +1 -1
  80. package/src/events.test.ts +3 -3
  81. package/src/events.ts +6 -6
  82. package/src/fast-tier.test.ts +88 -0
  83. package/src/fast-tier.ts +72 -0
  84. package/src/history-state.ts +3 -3
  85. package/src/hostnames.test.ts +1 -1
  86. package/src/index.ts +5 -0
  87. package/src/model-order.test.ts +11 -11
  88. package/src/model-order.ts +22 -0
  89. package/src/output-fields.ts +2 -2
  90. package/src/path-refs.test.ts +4 -4
  91. package/src/prompt-complexity.test.ts +160 -0
  92. package/src/prompt-complexity.ts +271 -0
  93. package/src/publish-drafts.ts +2 -2
  94. package/src/quick-model.test.ts +11 -11
  95. package/src/routes.test.ts +11 -5
  96. package/src/runtime-state.test.ts +1 -1
  97. package/src/schemas.test.ts +8 -8
  98. package/src/schemas.ts +311 -6
  99. package/src/search-globs.test.ts +2 -2
  100. package/src/share-paths.test.ts +1 -1
  101. package/src/title.test.ts +9 -9
  102. package/src/title.ts +1 -1
  103. package/src/tunnel-ids.test.ts +3 -3
  104. package/src/versions.test.ts +3 -3
  105. package/src/versions.ts +1 -1
  106. package/src/workflow-faults.test.ts +6 -6
  107. package/src/workflow-faults.ts +3 -3
  108. package/src/workspace-state.test.ts +32 -32
  109. package/src/workspace-state.ts +20 -20
@@ -0,0 +1,160 @@
1
+ import { expect, test } from "vitest";
2
+ import { type ComplexityInput, FAST_CEILING, judgeComplexity } from "./prompt-complexity.js";
3
+
4
+ /* WHETHER A TURN COULD HAVE RUN ON THE CHEAP RUNG, judged before anything is spent.
5
+ *
6
+ * What these tests pin is the ASYMMETRY, not the accuracy: the judge can only ever route down, so every one of
7
+ * its mistakes in the "standard" direction costs a fraction of a cent and every mistake in the "fast" direction
8
+ * costs a user their turn. So the properties worth nailing down are that silence resolves to standard, that a
9
+ * gate or an escalating rule ends the question whatever else the sentence says, and that no rule ORDER can
10
+ * change an answer. The weights themselves are a hypothesis with a ledger under it (settings.autoTier
11
+ * "shadow"), and a test that froze them would be a test that made them impossible to fit. */
12
+
13
+ const turn = (prompt: string, over: Partial<ComplexityInput> = {}): ComplexityInput => ({
14
+ prompt,
15
+ attachments: 0,
16
+ hasImages: false,
17
+ editorContext: false,
18
+ unattended: false,
19
+ planMode: false,
20
+ afterHardTurn: false,
21
+ ...over,
22
+ });
23
+
24
+ const tierOf = (prompt: string, over: Partial<ComplexityInput> = {}) => judgeComplexity(turn(prompt, over)).tier;
25
+
26
+ // --- silence, and what it is read as -----------------------------------------------------------------
27
+
28
+ test("a prompt matching nothing at all stays on the model the user picked", () => {
29
+ // The single most important default in the file. An unrecognised request is MEDIUM, never simple: the
30
+ // conservative reading of silence is the only safe one when a wrong downgrade costs a retry and a
31
+ // user who stops trusting the feature.
32
+ const verdict = judgeComplexity(turn(`Have another go at the thing we were discussing yesterday afternoon`));
33
+
34
+ expect(verdict.tier).toBe(`standard`);
35
+ expect(verdict.score).toBeGreaterThan(FAST_CEILING);
36
+ // Nothing in the sentence made a positive claim in either direction, so only the two absence features
37
+ // fired, and the pair of them cannot reach the ceiling by design.
38
+ expect(verdict.rules).toEqual([`short-prompt`, `no-workspace-reference`]);
39
+ });
40
+
41
+ test("a short vague request is not read as an easy one", () => {
42
+ // Absence of complexity is not evidence of simplicity. Weighted the obvious way, "short and naming no
43
+ // file" reached the ceiling by itself and downgraded every terse request in the product.
44
+ expect(tierOf(`fix the bug`)).toBe(`standard`);
45
+ expect(tierOf(`have a look at the thing we discussed`)).toBe(`standard`);
46
+ });
47
+
48
+ test("an empty-ish prompt is not mistaken for an easy one", () => {
49
+ expect(tierOf(` `)).toBe(`standard`);
50
+ });
51
+
52
+ // --- the fast end ------------------------------------------------------------------------------------
53
+
54
+ test("a short question about nothing in the workspace is the case this feature exists for", () => {
55
+ expect(tierOf(`what is a closure?`)).toBe(`fast`);
56
+ });
57
+
58
+ test("naming a file keeps an otherwise easy question on the user's own model", () => {
59
+ // "explain" is the easiest word in the lexicon, but the turn is now about real code in this repo rather
60
+ // than about a concept, and the cheap rung's failures on real code are the silent kind.
61
+ expect(tierOf(`explain what is a closure`)).toBe(`fast`);
62
+ expect(tierOf(`explain what src/agent/turn-plan.ts does`)).toBe(`standard`);
63
+ });
64
+
65
+ test("a trivial aside inside a hard conversation still gets through", () => {
66
+ // The reason afterHardTurn is a weight and not a lock: a conversation that has been doing hard work is
67
+ // still allowed to be asked an easy question, and locking it out is a mechanism that saves nothing.
68
+ expect(tierOf(`what is a closure?`, { afterHardTurn: true })).toBe(`fast`);
69
+ });
70
+
71
+ test("a workspace-adjacent errand stops being cheap once the conversation has done hard work", () => {
72
+ // The contrast with the test above is the whole rule. A pure knowledge question survives the penalty
73
+ // because it earns every easing feature there is; an errand about this repo does not, and after hard work
74
+ // it is far likelier to be the deceptive follow-up than a genuine aside.
75
+ expect(tierOf(`list the exports`, { afterHardTurn: false })).toBe(`fast`);
76
+ expect(tierOf(`list the exports`, { afterHardTurn: true })).toBe(`standard`);
77
+ });
78
+
79
+ // --- gates: the turn's situation, whatever its words say ---------------------------------------------
80
+
81
+ test("a screenshot is never sent to the cheap rung, however simple the question about it", () => {
82
+ // The tier most likely to misread an image, on the turn least likely to notice that it did.
83
+ expect(tierOf(`what is this?`, { hasImages: true, attachments: 1 })).toBe(`standard`);
84
+ });
85
+
86
+ test("plan mode is a request to think, so it is never answered by the model that thinks least", () => {
87
+ expect(tierOf(`what is a closure?`, { planMode: true })).toBe(`standard`);
88
+ });
89
+
90
+ test("a surface-started run is never downgraded, because nobody is watching it fail", () => {
91
+ // Same call agentRunModels already makes in the other direction: a run billed whole, with a worktree in
92
+ // it, is not the place to spend a guess.
93
+ expect(tierOf(`what is a closure?`, { unattended: true })).toBe(`standard`);
94
+ });
95
+
96
+ test("a gate reports itself and scores 1, so the ledger can tell a gate from a hard sentence", () => {
97
+ const verdict = judgeComplexity(turn(`hi`, { unattended: true }));
98
+
99
+ expect(verdict.score).toBe(1);
100
+ expect(verdict.rules).toContain(`unattended`);
101
+ });
102
+
103
+ // --- escalating rules: any one ends the question ------------------------------------------------------
104
+
105
+ test.each([
106
+ [`pasted code`, "explain this\n```ts\nconst x = 1;\n```"],
107
+ [`a stack trace`, "it broke\n at Object.run (/work/x.ts:12:3)"],
108
+ [`a thrown error`, "help\nTypeError: cannot read properties of undefined"],
109
+ [`a hard word`, `why does the picker reset`],
110
+ [`another hard word`, `refactor this`],
111
+ [`a second job`, `rename it and then update the tests`],
112
+ [`a checklist`, `- rename it\n- update the tests`],
113
+ [`a cross-cutting scope`, `rename it across the codebase`],
114
+ ])("%s forces the user's own model even in an otherwise tiny prompt", (_name, prompt) => {
115
+ expect(tierOf(prompt)).toBe(`standard`);
116
+ });
117
+
118
+ test("an escalating rule beats every easing feature at once, so rule order cannot change an answer", () => {
119
+ // Short, easy-worded, no workspace reference, a bare question: every negative weight in the file, plus one
120
+ // escalating rule. Monotone escalation means the rule wins, which is what makes adding a rule tomorrow a
121
+ // safe change: it can only ever move turns UP a tier.
122
+ const verdict = judgeComplexity(turn(`what is a race condition?`));
123
+
124
+ expect(verdict.tier).toBe(`standard`);
125
+ expect(verdict.score).toBe(1);
126
+ expect(verdict.rules).toEqual([`hard-words`]);
127
+ });
128
+
129
+ test("a long brief is standard on its length alone, whatever words it happens to use", () => {
130
+ expect(tierOf(`explain `.repeat(400))).toBe(`standard`);
131
+ });
132
+
133
+ test("three files in, the job is about a shape rather than about a file", () => {
134
+ expect(tierOf(`have a look`, { attachments: 3 })).toBe(`standard`);
135
+ expect(tierOf(`have a look`, { attachments: 1 })).toBe(`standard`);
136
+ });
137
+
138
+ // --- the verdict as a record --------------------------------------------------------------------------
139
+
140
+ test("names every rule that fired, because a score alone cannot say which feature did the work", () => {
141
+ // The ledger stores these. Re-fitting the weights against real traffic needs to know WHICH feature moved a
142
+ // turn, not merely that a threshold was crossed.
143
+ const verdict = judgeComplexity(turn(`what is this?`));
144
+
145
+ expect(verdict.rules).toEqual([`short-prompt`, `easy-words`, `bare-question`, `no-workspace-reference`]);
146
+ });
147
+
148
+ test("scores are rounded, so two turns the same rules judged compare equal on the ledger", () => {
149
+ const score = judgeComplexity(turn(`what is this?`)).score;
150
+
151
+ expect(score).toBe(Number(score.toFixed(3)));
152
+ });
153
+
154
+ test("the score never leaves 0..1, so a stored row is always comparable against the ceiling", () => {
155
+ const floor = judgeComplexity(turn(`what is this?`));
156
+ const ceiling = judgeComplexity(turn(`refactor everything`));
157
+
158
+ expect(floor.score).toBeGreaterThanOrEqual(0);
159
+ expect(ceiling.score).toBeLessThanOrEqual(1);
160
+ });
@@ -0,0 +1,271 @@
1
+ /* HOW HARD A TURN LOOKS BEFORE ANYTHING HAS BEEN SPENT ON IT, the judge behind automatic tier selection.
2
+ *
3
+ * The job is narrow on purpose: decide whether this turn could have run on the cheap rung of the provider the
4
+ * user is already on. Nothing here picks a model, nothing here reads a catalog, and nothing here calls
5
+ * anything. It is a pure function over the turn's own words and shape, so the daemon and the composer can both
6
+ * ask it and get the same answer, which is the same reason quick-model.ts lives in the contract rather than in
7
+ * either of them.
8
+ *
9
+ * IT CAN ONLY EVER ROUTE DOWN. The standard tier is not a setting: it is whatever the user already picked. So
10
+ * the question this file answers is never "which of two models" but "may we substitute something cheaper for
11
+ * the one they chose", and every ambiguous answer is NO. That asymmetry is the whole safety argument, and it is
12
+ * why there is no fail-up branch to get wrong: failing up means doing exactly what was asked.
13
+ *
14
+ * WHY RULES AND NOT A MODEL. Two findings decide this. Across the routing literature nothing sophisticated
15
+ * beats a simple predictor over decent features (a tuned kNN and a linear head tie, and both beat graph and
16
+ * attention routers costing 13-14x more), and a router that spends an LLM call to save an LLM call has spent
17
+ * the saving. So the layer is deliberately narrow, transparent, and free. See docs/model-routing-design.md.
18
+ *
19
+ * WHY EVERY RULE IS NAMED. A verdict carries the rules that fired, not just a number. That is what lets a
20
+ * screen say WHY a turn was downgraded, what makes a bad call reportable rather than mysterious, and what makes
21
+ * the shadow ledger analysable later: a score alone tells you a threshold was crossed, the rules tell you which
22
+ * feature is doing the work. The interpretable-router literature (Routesplain, COLM 2026) reaches the same
23
+ * conclusion from the accuracy side, but the operational one is enough on its own. */
24
+
25
+ // The named features. A verdict lists these, the shadow ledger stores them, and a screen renders them, so they
26
+ // are a vocabulary rather than debug strings: renaming one is a breaking change to what past rows mean.
27
+ export type ComplexityRule =
28
+ // Gates. Any one of these ends the question: the turn is standard and no score is computed.
29
+ | "images"
30
+ | "plan-mode"
31
+ | "unattended"
32
+ // Escalating rules. Any one forces standard. Order between them cannot matter, which is the point.
33
+ | "code-block"
34
+ | "stack-trace"
35
+ | "hard-words"
36
+ | "multi-step"
37
+ | "cross-cutting"
38
+ | "long-prompt"
39
+ | "many-attachments"
40
+ // Graded features. These only move the score.
41
+ | "medium-prompt"
42
+ | "attachment"
43
+ | "editor-context"
44
+ | "paths"
45
+ | "many-verbs"
46
+ | "after-hard-turn"
47
+ | "short-prompt"
48
+ | "easy-words"
49
+ | "bare-question"
50
+ | "no-workspace-reference";
51
+
52
+ export type ComplexityTier = "fast" | "standard";
53
+
54
+ /* Everything the judge is allowed to know. Deliberately primitive: counts, flags and the prompt itself, no
55
+ * objects owned by either side, so the daemon's turn and the composer's draft can each build one without
56
+ * agreeing about anything else. The same "compress route state into cheap primitive fields" shape the serving
57
+ * literature converges on. */
58
+ export interface ComplexityInput {
59
+ readonly prompt: string;
60
+ // Uploaded files plus @-mentioned workspace paths; the daemon resolves both into one list, so one count.
61
+ readonly attachments: number;
62
+ // Any attachment the model will read as an image. A cheap rung reading a screenshot is the worst cell in
63
+ // the matrix: it is the tier most likely to misread it and the turn least likely to notice.
64
+ readonly hasImages: boolean;
65
+ // The opt-in editor chip: the user pointed at a file and a selection, so the turn is about real code.
66
+ readonly editorContext: boolean;
67
+ // A surface started this, not a person at a composer (AgentTurn.unattended).
68
+ readonly unattended: boolean;
69
+ // The turn opens in plan mode: it is being asked to think before it acts, which is the request itself.
70
+ readonly planMode: boolean;
71
+ /* THE TURN BEFORE THIS ONE, IN THIS CONVERSATION, WAS JUDGED STANDARD.
72
+ *
73
+ * The most important field, and the one a prompt-only judge cannot derive. "now do the same for the other
74
+ * file" is nine easy words carrying the whole weight of the task before it, and a judge reading only the
75
+ * words will downgrade it every time. The routing work closest to this product (SWE-Router, over SWE-bench)
76
+ * states the general form: prompt-only routers inherit an information-theoretic error floor because the
77
+ * difficulty lives in the trajectory rather than in the request. Their answer is a 7B value model reading
78
+ * partial trajectories; ours is one boolean, which costs nothing and catches the case that actually bites.
79
+ *
80
+ * IT RAISES THE BAR, IT DOES NOT LOCK THE DOOR, and that is a deliberate softening of the rule this was
81
+ * designed as. A hard "once standard, always standard" lock reads well and is nearly useless: opening
82
+ * messages are substantive, so almost every conversation would take standard on turn one and never be
83
+ * eligible again, which is a mechanism that saves nothing while carrying all of the risk. As a weight it
84
+ * still stops the deceptive follow-up (which scores near the base and cannot afford the penalty) while a
85
+ * genuinely trivial aside inside a hard conversation still gets through, and that aside is a real and
86
+ * common turn.
87
+ *
88
+ * The JUDGEMENT, not what ran: a turn judged fast that ran standard anyway (nothing cheaper in the catalog,
89
+ * or the feature switched off) says nothing about the difficulty of the work, and reading it as escalation
90
+ * would make the sandbox's configuration leak into its opinion about a sentence. */
91
+ readonly afterHardTurn: boolean;
92
+ }
93
+
94
+ export interface ComplexityVerdict {
95
+ readonly tier: ComplexityTier;
96
+ // 0..1, rounded to three places so ledger rows compare exactly and do not carry float noise. 1 whenever a
97
+ // gate or an escalating rule fired: those do not produce a degree of difficulty, they produce an answer.
98
+ readonly score: number;
99
+ // Every rule that fired, in declaration order. Empty is legal and means "nothing distinctive": the score is
100
+ // the base, which sits above the fast ceiling, so an unremarkable turn stays on the user's own pick.
101
+ readonly rules: readonly ComplexityRule[];
102
+ }
103
+
104
+ /* WHERE AN UNREMARKABLE TURN STARTS, and it starts ABOVE the fast ceiling on purpose: a prompt that matches no
105
+ * rule at all is medium, not simple. That is the reference keyword-router convention and it is the conservative
106
+ * reading of silence, which is the only safe one when the downside of a wrong downgrade is a retry, an
107
+ * escalation, and a user who stops trusting the feature. */
108
+ const BASE_SCORE = 0.5;
109
+
110
+ // Fast iff the score lands at or below this. Exported because the shadow ledger's whole purpose is to let this
111
+ // be re-fitted against real traffic rather than argued about, and a reader of a stored score needs the ceiling
112
+ // that score was judged against.
113
+ export const FAST_CEILING = 0.25;
114
+
115
+ // Characters, not tokens: nothing here can tokenize, and for a threshold the constant cancels. ~600 chars is
116
+ // where a request stops being a sentence and starts being a brief; ~2400 is where it is carrying pasted
117
+ // material it has not fenced.
118
+ const MEDIUM_PROMPT_CHARS = 600;
119
+ const LONG_PROMPT_CHARS = 2400;
120
+ const SHORT_PROMPT_CHARS = 140;
121
+ // Three files in is a job about a shape rather than about a file, whatever the words say.
122
+ const MANY_ATTACHMENTS = 3;
123
+ // Three or more distinct imperatives is a list of jobs wearing the grammar of one.
124
+ const MANY_VERBS = 3;
125
+
126
+ /* THE WORD LISTS. Kept short and boring, because a hand-written lexicon is the part of a router that rots: it
127
+ * is inflexible by construction and every addition is a guess about traffic nobody has measured yet. It exists
128
+ * to catch the unambiguous ends of the distribution and to hand everything else to the score.
129
+ *
130
+ * Both lists are matched on word boundaries and case-insensitively. `easy` only ever lowers a score; `hard`
131
+ * forces standard outright, which is the asymmetry the rest of this file is built on. */
132
+ const EASY_WORDS =
133
+ /\b(?:what(?:'s| is| are)|explain|describe|summari[sz]e|list|show me|where(?:'s| is| are)|rename|typo|reword|reformat|format this|tidy|define|translate|spell|abbreviat)/i;
134
+
135
+ const HARD_WORDS =
136
+ /\b(?:refactor|redesign|architect|architecture|migrat|root cause|debug|investigat|diagnos|optimi[sz]|race condition|deadlock|memory leak|regression|security|threat model|benchmark|profil|audit|design a|plan (?:a|the|out)|why (?:does|is|are|did|would|can't|cannot))/i;
137
+
138
+ // "Do this, and also that." The strongest cheap signal of a job that is several jobs, and the one an easy-
139
+ // sounding sentence hides behind most often.
140
+ const MULTI_STEP = /\b(?:and then|after that|once (?:that|you)|followed by|as well as|then also)\b/i;
141
+
142
+ // A job whose subject is the shape of the codebase rather than a place in it. A cheap rung asked to be
143
+ // consistent across twenty files is being asked the one thing it is worst at.
144
+ const CROSS_CUTTING =
145
+ /\b(?:across (?:the|all|every)|every(?: single)? (?:file|module|package|component|usage|call ?site)|all (?:the|of the) (?:files|usages|call ?sites|places)|everywhere|throughout the|codebase-wide|repo-wide)\b/i;
146
+
147
+ // Fenced code, a unified diff, or an inline patch. Pasted code is not proof of difficulty by itself, but it is
148
+ // proof the turn is about real code rather than about a word, and the cheap rung's failures there are silent.
149
+ const CODE_BLOCK = /```|^diff --git |^@@ .* @@|^[+-]{3} [ab]\//m;
150
+
151
+ // A thrown error the user has pasted in. Debugging from a trace is the canonical case where the expensive tier
152
+ // earns its price, and it is trivially detectable.
153
+ const STACK_TRACE =
154
+ /(?:^|\n)\s*(?:at [\w$.<>]+ \(|Traceback \(most recent call last\)|Caused by:|panic:|thread '.*' panicked|Unhandled|[A-Z]\w*(?:Error|Exception): )/;
155
+
156
+ // A workspace path or an @-mention in the prose. Distinguishes "explain closures" from "explain what this file
157
+ // does" without claiming either is hard.
158
+ const PATH_LIKE =
159
+ /(?:^|\s)(?:@[\w./-]+|[\w-]+\/[\w./-]+\.[a-z]{1,5}\b|\b[\w-]+\.(?:ts|tsx|js|jsx|vue|py|go|rs|java|rb|css|scss|json|ya?ml|md|sql|sh)\b)/i;
160
+
161
+ // One sentence, ending in a question mark, with no second clause. Knowledge, not work.
162
+ const BARE_QUESTION = /^[^.!?]{0,200}\?\s*$/;
163
+
164
+ // Imperatives that start a request. Counted, not matched: one is a task, three is a list.
165
+ const VERBS =
166
+ /\b(?:add|remove|delete|fix|write|create|make|update|change|move|rename|split|merge|extract|inline|wire|hook|test|check|run|build|deploy|document|implement|replace|convert|handle|support|expose|log|render|validate|parse|sort|filter|cache)\b/gi;
167
+
168
+ // Bullets and numbered steps, which is a checklist however casually it is written.
169
+ const LIST_LINES = /^\s*(?:[-*+]\s|\d+[.)]\s)/gm;
170
+
171
+ // Every rule that ends the question on its own, paired with the test that fires it. Split from the graded
172
+ // features below because the two are read differently: these are answers, those are evidence.
173
+ const forcing = (input: ComplexityInput, text: string): ComplexityRule[] => {
174
+ const rules: ComplexityRule[] = [];
175
+ // Gates first, cheapest and least arguable. They are about the turn's SITUATION rather than its words.
176
+ if (input.hasImages) {
177
+ rules.push("images");
178
+ }
179
+ if (input.planMode) {
180
+ rules.push("plan-mode");
181
+ }
182
+ /* An unattended run is billed whole and nobody is watching it fail. The settings this repo already ships
183
+ * make the same call in the other direction: agentRunModels resolves to NOTHING when empty precisely
184
+ * because "nothing here can judge whether a job is worth the frontier tier". This file does judge, but not
185
+ * for the runs where a wrong guess costs a whole session with a worktree in it. */
186
+ if (input.unattended) {
187
+ rules.push("unattended");
188
+ }
189
+ // Then the words. Every one of these is a claim that the turn is about real code doing something real.
190
+ if (CODE_BLOCK.test(text)) {
191
+ rules.push("code-block");
192
+ }
193
+ if (STACK_TRACE.test(text)) {
194
+ rules.push("stack-trace");
195
+ }
196
+ if (HARD_WORDS.test(text)) {
197
+ rules.push("hard-words");
198
+ }
199
+ if (MULTI_STEP.test(text) || (text.match(LIST_LINES)?.length ?? 0) >= 2) {
200
+ rules.push("multi-step");
201
+ }
202
+ if (CROSS_CUTTING.test(text)) {
203
+ rules.push("cross-cutting");
204
+ }
205
+ if (text.length > LONG_PROMPT_CHARS) {
206
+ rules.push("long-prompt");
207
+ }
208
+ if (input.attachments >= MANY_ATTACHMENTS) {
209
+ rules.push("many-attachments");
210
+ }
211
+ return rules;
212
+ };
213
+
214
+ /* The graded half: what nudges an otherwise ordinary request either way. Weights are a starting shape fitted to
215
+ * nothing, which is exactly why the mechanism ships in shadow first — see docs/model-routing-design.md §4. They
216
+ * are not a claim, they are a hypothesis with a ledger under it.
217
+ *
218
+ * ONE PROPERTY IS NOT A HYPOTHESIS AND MUST SURVIVE ANY REFIT: the two ABSENCE features (`short-prompt`,
219
+ * `no-workspace-reference`) are too light to reach the ceiling together. Absence of complexity is not evidence
220
+ * of simplicity — "fix the bug" is four words naming no file and is not a cheap turn — so a downgrade always
221
+ * requires something POSITIVE to have been said, which in practice means `easy-words` or `bare-question`
222
+ * carrying it. Weighted the obvious way instead, the judge downgraded every short vague request in the
223
+ * product, which is the single worst population to be wrong about. */
224
+ const GRADED: readonly { readonly rule: ComplexityRule; readonly weight: number; readonly of: (input: ComplexityInput, text: string) => boolean }[] =
225
+ [
226
+ { rule: "medium-prompt", weight: +0.2, of: (_input, text) => text.length > MEDIUM_PROMPT_CHARS },
227
+ { rule: "attachment", weight: +0.15, of: (input) => input.attachments > 0 },
228
+ { rule: "editor-context", weight: +0.1, of: (input) => input.editorContext },
229
+ // Enough on its own to hold an easy-worded question at standard: "explain what this file does" is a
230
+ // question about real code in this repo, and the cheap rung's failures on real code are the silent kind.
231
+ { rule: "paths", weight: +0.15, of: (_input, text) => PATH_LIKE.test(text) },
232
+ {
233
+ rule: "many-verbs",
234
+ weight: +0.15,
235
+ of: (_input, text) => new Set((text.match(VERBS) ?? []).map((verb) => verb.toLowerCase())).size >= MANY_VERBS,
236
+ },
237
+ // The heaviest single weight, because it is the only feature that can see past the words. See
238
+ // ComplexityInput.afterHardTurn for why it is a weight rather than the gate it was designed as.
239
+ { rule: "after-hard-turn", weight: +0.25, of: (input) => input.afterHardTurn },
240
+ // The two absence features, deliberately light; see the note above the list.
241
+ { rule: "short-prompt", weight: -0.1, of: (_input, text) => text.length <= SHORT_PROMPT_CHARS },
242
+ { rule: "easy-words", weight: -0.25, of: (_input, text) => EASY_WORDS.test(text) },
243
+ { rule: "bare-question", weight: -0.15, of: (_input, text) => BARE_QUESTION.test(text) },
244
+ {
245
+ rule: "no-workspace-reference",
246
+ weight: -0.1,
247
+ of: (input, text) => input.attachments === 0 && !input.editorContext && !PATH_LIKE.test(text),
248
+ },
249
+ ];
250
+
251
+ // Three places, so a stored score is a value rather than a float artefact and two rows written by the same
252
+ // rules compare equal.
253
+ const round3 = (value: number): number => Math.round(value * 1000) / 1000;
254
+
255
+ /* THE VERDICT. Gates and escalating rules first, and if any fired the answer is standard at score 1 — no
256
+ * partial credit, no weight to tune, and no way for rule ORDER to change the outcome. That monotone-escalation
257
+ * property is worth more than the accuracy it costs: it means adding a rule tomorrow can only ever move turns
258
+ * up a tier, never silently move a different set of turns down.
259
+ *
260
+ * Only what survives all of that gets scored, which keeps the graded layer doing the one job it is good at:
261
+ * separating "explain this" from "wire this up" among requests that look alike. */
262
+ export const judgeComplexity = (input: ComplexityInput): ComplexityVerdict => {
263
+ const text = input.prompt.trim();
264
+ const forced = forcing(input, text);
265
+ if (forced.length > 0) {
266
+ return { tier: "standard", score: 1, rules: forced };
267
+ }
268
+ const hits = GRADED.filter((feature) => feature.of(input, text));
269
+ const score = Math.min(1, Math.max(0, BASE_SCORE + hits.reduce((total, feature) => total + feature.weight, 0)));
270
+ return { tier: score <= FAST_CEILING ? "fast" : "standard", score: round3(score), rules: hits.map((feature) => feature.rule) };
271
+ };
@@ -47,7 +47,7 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
47
47
  ``,
48
48
  `Take them ONE AT A TIME, and for each:`,
49
49
  `1. Read the file. Set "status":"posting" BEFORE you act, so a turn that dies here cannot double-post.`,
50
- `2. Post exactly its "content" with its "title", "target" and "media" using that platform's skill.`,
50
+ `2. Post exactly its "content": with its "title", "target" and "media", using that platform's skill.`,
51
51
  ` A "target" that is a URL means this draft is a REPLY to whatever is at it: open that exact URL and`,
52
52
  ` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
53
53
  ` nest under that comment rather than becoming a new top-level comment on the thread.`,
@@ -55,5 +55,5 @@ export const publishTurnPrompt = (drafts: readonly { readonly id: string; readon
55
55
  ` If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the owner`,
56
56
  ` can act on, then move to the next draft instead of retrying in a loop.`,
57
57
  ``,
58
- `Never rewrite the content the owner approved these exact words. Never touch a draft not listed above.`,
58
+ `Never rewrite the content: the owner approved these exact words. Never touch a draft not listed above.`,
59
59
  ].join(`\n`);
@@ -1,8 +1,8 @@
1
1
  import { expect, test } from "vitest";
2
2
  import { type QuickModelChoice, type QuickModelSource, quickModelKey, resolveQuickModels } from "./quick-model.js";
3
3
 
4
- /* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once
5
- * the daemon walks it, the browser names its head in the settings row so what these tests pin is that a
4
+ /* Which models a small automatic helper spends, and in which order. The rule answers two surfaces at once:
5
+ * the daemon walks it, the browser names its head in the settings row, so what these tests pin is that a
6
6
  * sandbox's connections alone decide it, with no stored id to go stale, and that there is always a rung
7
7
  * underneath the first one whenever the sandbox has another account to reach for. */
8
8
 
@@ -14,7 +14,7 @@ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k
14
14
 
15
15
  const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
16
16
 
17
- // The model that answers when nothing goes wrong the head of the chain, which is what most of what follows is
17
+ // The model that answers when nothing goes wrong: the head of the chain, which is what most of what follows is
18
18
  // about and what every surface naming the spend up front reads.
19
19
  const head = (sources: readonly QuickModelSource[], pinned: readonly string[]): QuickModelChoice | undefined =>
20
20
  resolveQuickModels(sources, pinned)[0];
@@ -24,12 +24,12 @@ test("reaches for the efficient rung of the one connected provider, never its fl
24
24
  });
25
25
 
26
26
  test("spends the FREE channel over the subscription when both offer the same rung", () => {
27
- // Both publish a cheap-tier row, so nothing separates them on capability and one of them costs the user
27
+ // Both publish a cheap-tier row, so nothing separates them on capability, and one of them costs the user
28
28
  // nothing while the other eats headroom they watch. A background helper should not quietly bill the Claude plan.
29
29
  expect(head([CLAUDE, GOOGLE], [])).toEqual({ provider: `gemini`, model: `gemini-3-flash-lite` });
30
30
  });
31
31
 
32
- test("puts tier ahead of cost a free frontier model is still the wrong tool for a commit message", () => {
32
+ test("puts tier ahead of cost: a free frontier model is still the wrong tool for a commit message", () => {
33
33
  // Google connected but publishing only its Pro line. Ordering on price first would seat a flagship here,
34
34
  // which is the exact outcome the feature exists to avoid.
35
35
  const proOnly: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-pro`] };
@@ -90,7 +90,7 @@ test("skips a connected provider whose catalog has not loaded yet", () => {
90
90
  expect(head([unloaded], [])).toBeUndefined();
91
91
  });
92
92
 
93
- /* THE CHAIN what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
93
+ /* THE CHAIN: what the daemon walks when the model at the top of it refuses. A spent allowance is the ordinary
94
94
  * case, not the exotic one: the account a helper shares with the chat runs out mid-afternoon, and the whole
95
95
  * point of the list is that the click still lands on the next rung down. */
96
96
 
@@ -111,7 +111,7 @@ test("drops a pin whose provider went away and keeps the rest of the order intac
111
111
 
112
112
  test("stops at the end of a pinned list rather than reaching for an account the user left out", () => {
113
113
  // Google and Kimi are connected and cheaper. The user wrote down one model, so one model is what this may
114
- // spend a pin exists precisely to keep a helper off the accounts it does not name.
114
+ // spend: a pin exists precisely to keep a helper off the accounts it does not name.
115
115
  expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [`claude:claude-haiku-4-5`])).toEqual([{ provider: `claude`, model: `claude-haiku-4-5` }]);
116
116
  });
117
117
 
@@ -122,7 +122,7 @@ test("names each model once, however many times the list repeats it", () => {
122
122
  ]);
123
123
  });
124
124
 
125
- test("Auto is a ladder too every connected provider's cheap rung, best first", () => {
125
+ test("Auto is a ladder too, every connected provider's cheap rung, best first", () => {
126
126
  expect(resolveQuickModels([CLAUDE, GOOGLE, KIMI], [])).toEqual([
127
127
  { provider: `gemini`, model: `gemini-3-flash-lite` },
128
128
  { provider: `claude`, model: `claude-haiku-4-5-20251001` },
@@ -135,11 +135,11 @@ test("Auto is a ladder too — every connected provider's cheap rung, best first
135
135
  * dropped would print one model's name in the settings row and spend a different account entirely. */
136
136
  const OLLAMA: QuickModelSource = { provider: `endpoint/ollama`, ready: true, models: [`qwen3-coder`, `gemma3-27b`] };
137
137
 
138
- test("honours a pin on a configured endpoint the whole id, not the half before its slash", () => {
138
+ test("honours a pin on a configured endpoint: the whole id, not the half before its slash", () => {
139
139
  expect(head([CLAUDE, OLLAMA], [`endpoint/ollama:qwen3-coder`])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
140
140
  // And it round-trips through the key shape the picker mints, which is where the slash-not-colon rule earns
141
141
  // itself: parsePinned splits on the FIRST colon, so an `endpoint:ollama` id would have parsed the provider
142
- // as "endpoint" and the model as "ollama:qwen3-coder" a pin that silently resolves to nothing.
142
+ // as "endpoint" and the model as "ollama:qwen3-coder": a pin that silently resolves to nothing.
143
143
  expect(quickModelKey({ provider: `endpoint/ollama`, model: `qwen3-coder` })).toBe(`endpoint/ollama:qwen3-coder`);
144
144
  });
145
145
 
@@ -152,7 +152,7 @@ test("leaves Auto to the providers whose price is known, rather than reaching fo
152
152
 
153
153
  test("still answers from an endpoint when it is the only thing configured", () => {
154
154
  // No tier word in either id, so the shared id-derived ordering decides between them exactly as it does for
155
- // Kimi above the point here is that a sandbox whose only model API is its owner's still gets an answer
155
+ // Kimi above: the point here is that a sandbox whose only model API is its owner's still gets an answer
156
156
  // rather than the disabled "nothing connected" button.
157
157
  expect(head([offline(CLAUDE), OLLAMA], [])).toEqual({ provider: `endpoint/ollama`, model: `qwen3-coder` });
158
158
  });
@@ -87,14 +87,14 @@ describe(`routeShapes`, () => {
87
87
  expect(after).not.toBe(before);
88
88
  });
89
89
 
90
- it(`is blind to the order fields are declared in a reordered object is not a wire change`, () => {
90
+ it(`is blind to the order fields are declared in: a reordered object is not a wire change`, () => {
91
91
  const one = routeShapes(shaped(z.object({ a: z.string(), b: z.number() })))[`vpn.list`];
92
92
  const other = routeShapes(shaped(z.object({ b: z.number(), a: z.string() })))[`vpn.list`];
93
93
  expect(other).toBe(one);
94
94
  });
95
95
 
96
96
  it(`reads a defaulted field differently on the way in than on the way out`, () => {
97
- // `.default()` makes a field optional going in and required coming out the same declaration, two
97
+ // `.default()` makes a field optional going in and required coming out: the same declaration, two
98
98
  // wire shapes. Reading both directions the same way would call them identical.
99
99
  const one = z.object({ a: z.string().default(`x`) });
100
100
  const asOutput = routeShapes(shaped(one))[`vpn.list`];
@@ -114,7 +114,7 @@ describe(`routeShapes`, () => {
114
114
 
115
115
  it(`omits a route whose shape cannot be expressed rather than failing the walk`, () => {
116
116
  // An oRPC event iterator wraps its output in an opaque type with no schema underneath. The route keeps
117
- // its NAME (it is still advertised as existing) and simply carries no shape which reads downstream as
117
+ // its NAME (it is still advertised as existing) and simply carries no shape, which reads downstream as
118
118
  // "assume compatible". The two neighbours still get fingerprinted, which is the part that matters: one
119
119
  // unexpressable route must not cost the whole walk.
120
120
  const withStream = {
@@ -135,12 +135,18 @@ describe(`routeShapes`, () => {
135
135
  describe(`the real sandbox contract`, () => {
136
136
  it(`fingerprints all but the streaming routes`, () => {
137
137
  const unshaped = SANDBOX_ROUTE_NAMES.filter((name) => !(name in SANDBOX_ROUTE_SHAPES));
138
- // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these seven
138
+ // oRPC wraps an event iterator's output in an opaque type with no schema under it, so these ten
139
139
  // cannot be fingerprinted and are assumed compatible. Named rather than counted: a NEW entry here is
140
140
  // a route that quietly lost its shape check, which is worth failing a test over.
141
141
  expect(unshaped.toSorted()).toEqual([
142
142
  `agent.attach`,
143
143
  `capabilities.add`,
144
+ // The three geo-exit moves, streaming for the same reason vpn.connect does: bringing an exit up
145
+ // pulls a catalog, dials, and then verifies the address it landed on, which is tens of seconds on
146
+ // the free providers and can fail with something the user has to read at each step.
147
+ `exit.rotate`,
148
+ `exit.start`,
149
+ `exit.use`,
144
150
  `intentic.applyEvents`,
145
151
  `intentic.run`,
146
152
  `system.events`,
@@ -151,7 +157,7 @@ describe(`the real sandbox contract`, () => {
151
157
 
152
158
  it(`fingerprints every other route exactly once`, () => {
153
159
  expect(Object.keys(SANDBOX_ROUTE_SHAPES).every((name) => SANDBOX_ROUTE_NAMES.includes(name))).toBe(true);
154
- expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 7);
160
+ expect(Object.keys(SANDBOX_ROUTE_SHAPES).length).toBe(SANDBOX_ROUTE_NAMES.length - 10);
155
161
  });
156
162
 
157
163
  it(`derives a route table with no duplicate names`, () => {
@@ -39,7 +39,7 @@ describe(`runtimeBoundQueryKeys`, () => {
39
39
  expect(joined(runtimeBoundQueryKeys())).toEqual(joined(RUNTIME_DOMAIN_BINDINGS.flatMap((binding) => binding.invalidates)));
40
40
  });
41
41
 
42
- it(`leaves no domain declaring no keys a domain nothing renders has no reason to be pushed`, () => {
42
+ it(`leaves no domain declaring no keys: a domain nothing renders has no reason to be pushed`, () => {
43
43
  expect(RUNTIME_DOMAIN_BINDINGS.filter((binding) => binding.invalidates.length === 0)).toEqual([]);
44
44
  });
45
45
  });