@intentic/sandbox-contract 1.223.0 → 1.225.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/dist/agent-catalog.js +3 -3
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.js +43 -43
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/extension-update.js +2 -2
- package/dist/chores/extension-update.js.map +1 -1
- package/dist/chores/fix-deps.js +1 -1
- package/dist/chores/fix-deps.js.map +1 -1
- package/dist/chores/probes.js +1 -1
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/prompt.d.ts.map +1 -1
- package/dist/chores/prompt.js +3 -3
- package/dist/chores/prompt.js.map +1 -1
- package/dist/chores/verdict.js +2 -2
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +41 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/exit.contract.d.ts +80 -0
- package/dist/contracts/exit.contract.d.ts.map +1 -0
- package/dist/contracts/exit.contract.js +13 -0
- package/dist/contracts/exit.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +11 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/events.d.ts +4 -4
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +5 -5
- package/dist/events.js.map +1 -1
- package/dist/fast-tier.d.ts +9 -0
- package/dist/fast-tier.d.ts.map +1 -0
- package/dist/fast-tier.js +19 -0
- package/dist/fast-tier.js.map +1 -0
- package/dist/history-state.js +3 -3
- package/dist/history-state.js.map +1 -1
- package/dist/index.d.ts +214 -68
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +5 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts.map +1 -1
- package/dist/output-fields.js +2 -2
- package/dist/output-fields.js.map +1 -1
- package/dist/prompt-complexity.d.ts +19 -0
- package/dist/prompt-complexity.d.ts.map +1 -0
- package/dist/prompt-complexity.js +83 -0
- package/dist/prompt-complexity.js.map +1 -0
- package/dist/publish-drafts.js +2 -2
- package/dist/publish-drafts.js.map +1 -1
- package/dist/schemas.d.ts +236 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +116 -2
- package/dist/schemas.js.map +1 -1
- package/dist/workflow-faults.js +3 -3
- package/dist/workflow-faults.js.map +1 -1
- package/dist/workspace-state.d.ts +20 -20
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +20 -20
- package/dist/workspace-state.js.map +1 -1
- package/package.json +5 -5
- package/src/agent-catalog.test.ts +25 -25
- package/src/agent-catalog.ts +81 -81
- package/src/agent-run-model.test.ts +3 -3
- package/src/agent-run-model.ts +8 -8
- package/src/capability-env.ts +3 -3
- package/src/capability-ledger.test.ts +13 -13
- package/src/capability-secrets.ts +5 -5
- package/src/chores/chores.test.ts +1 -1
- package/src/chores/chores.ts +109 -109
- package/src/chores/digest.test.ts +1 -1
- package/src/chores/digest.ts +3 -3
- package/src/chores/extension-update.ts +5 -5
- package/src/chores/fix-deps.ts +4 -4
- package/src/chores/probes.test.ts +6 -6
- package/src/chores/probes.ts +17 -17
- package/src/chores/prompt.ts +9 -9
- package/src/chores/stack.test.ts +3 -3
- package/src/chores/stack.ts +17 -17
- package/src/chores/verdict.test.ts +20 -20
- package/src/chores/verdict.ts +25 -25
- package/src/contract-lock.test.ts +1 -1
- package/src/contract-lock.ts +7 -7
- package/src/contracts/activity.contract.ts +1 -1
- package/src/contracts/agent.contract.ts +2 -2
- package/src/contracts/agents.contract.ts +11 -11
- package/src/contracts/automations.contract.ts +5 -5
- package/src/contracts/capabilities.contract.ts +7 -7
- package/src/contracts/chores.contract.ts +3 -3
- package/src/contracts/ci.contract.ts +2 -2
- package/src/contracts/claude.contract.ts +3 -3
- package/src/contracts/drafts.contract.ts +1 -1
- package/src/contracts/endpoints.contract.ts +2 -2
- package/src/contracts/exit.contract.ts +42 -0
- package/src/contracts/extensions.contract.ts +8 -8
- package/src/contracts/git.contract.ts +6 -6
- package/src/contracts/grok.contract.ts +4 -4
- package/src/contracts/host.contract.ts +5 -5
- package/src/contracts/intentic.contract.ts +2 -2
- package/src/contracts/logs.contract.ts +1 -1
- package/src/contracts/loops.contract.ts +8 -8
- package/src/contracts/personas.contract.ts +8 -8
- package/src/contracts/ports.contract.ts +1 -1
- package/src/contracts/prepush.contract.ts +2 -2
- package/src/contracts/providers.contract.ts +4 -4
- package/src/contracts/public.contract.ts +1 -1
- package/src/contracts/push.contract.ts +2 -2
- package/src/contracts/secrets.contract.ts +2 -2
- package/src/contracts/sessions.contract.ts +2 -2
- package/src/contracts/settings.contract.ts +3 -3
- package/src/contracts/share.contract.ts +1 -1
- package/src/contracts/skills.contract.ts +2 -2
- package/src/contracts/system.contract.ts +9 -9
- package/src/contracts/usage.contract.ts +1 -1
- package/src/contracts/vpn.contract.ts +5 -5
- package/src/contracts/workflows.contract.ts +12 -12
- package/src/contracts/workspace.contract.ts +13 -13
- package/src/conversation-ids.ts +8 -8
- package/src/events.test.ts +3 -3
- package/src/events.ts +142 -142
- package/src/fast-tier.test.ts +88 -0
- package/src/fast-tier.ts +72 -0
- package/src/history-state.ts +15 -15
- package/src/host-protocol.ts +7 -7
- package/src/hostnames.test.ts +1 -1
- package/src/hostnames.ts +15 -15
- package/src/index.ts +13 -8
- package/src/listener-protocol.ts +12 -12
- package/src/model-order.test.ts +11 -11
- package/src/model-order.ts +52 -30
- package/src/output-fields.ts +11 -11
- package/src/path-refs.test.ts +4 -4
- package/src/path-refs.ts +6 -6
- package/src/prompt-complexity.test.ts +160 -0
- package/src/prompt-complexity.ts +271 -0
- package/src/publish-drafts.ts +8 -8
- package/src/quick-model.test.ts +11 -11
- package/src/quick-model.ts +15 -15
- package/src/routes.test.ts +11 -5
- package/src/routes.ts +19 -19
- package/src/runtime-state.test.ts +1 -1
- package/src/runtime-state.ts +22 -22
- package/src/schemas.test.ts +8 -8
- package/src/schemas.ts +1238 -921
- package/src/search-globs.test.ts +2 -2
- package/src/search-globs.ts +6 -6
- package/src/session-names.ts +5 -5
- package/src/share-paths.test.ts +1 -1
- package/src/share-paths.ts +7 -7
- package/src/sse.ts +1 -1
- package/src/state-portability.ts +8 -8
- package/src/terminal-protocol.ts +3 -3
- package/src/title.test.ts +9 -9
- package/src/title.ts +21 -21
- package/src/tunnel-ids.test.ts +3 -3
- package/src/tunnel-ids.ts +7 -7
- package/src/versions.test.ts +3 -3
- package/src/versions.ts +6 -6
- package/src/workflow-faults.test.ts +6 -6
- package/src/workflow-faults.ts +14 -14
- package/src/workspace-state.test.ts +32 -32
- package/src/workspace-state.ts +139 -139
|
@@ -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
|
+
};
|
package/src/publish-drafts.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
/* HOW AN APPROVED POST GETS SENT
|
|
1
|
+
/* HOW AN APPROVED POST GETS SENT, the shared half, written once because the daemon acts on it, the app draws
|
|
2
2
|
* it, and the two have to agree on the same seconds.
|
|
3
3
|
*
|
|
4
4
|
* THERE IS NO PUBLISHER AUTOMATION ANY MORE, and its absence is the design. Publishing used to be a scheduled
|
|
5
5
|
* automation: a cron waking every few minutes, running a shell guard over the drafts directory, almost always
|
|
6
|
-
* finding nothing
|
|
6
|
+
* finding nothing, a job whose entire job was to ask "yet?" forever. It also made the approve button
|
|
7
7
|
* conditional on a row in a list nobody had asked for: delete the automation and approvals silently went
|
|
8
8
|
* nowhere, with the button still there and still saying yes. The daemon owns publishing now
|
|
9
9
|
* (drafts-publisher.ts). It knows the moment a draft comes due because it is the process that wrote the draft,
|
|
10
10
|
* so it sleeps until exactly then and costs nothing in between.
|
|
11
11
|
*
|
|
12
12
|
* APPROVAL IS NOT "SEND NOW", IT IS "SEND UNLESS I STOP YOU". A post is public and permanent the instant it
|
|
13
|
-
* lands, and the gap between realising and clicking is about two seconds
|
|
13
|
+
* lands, and the gap between realising and clicking is about two seconds, so an approved draft carrying no
|
|
14
14
|
* date of its own is dated HOLD into the future, and the queue counts it down in the open. The hold is stored
|
|
15
15
|
* as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it survives a restart,
|
|
16
16
|
* it reads as one number in the same place a scheduled post's date is already read, and calling it off is the
|
|
@@ -21,20 +21,20 @@
|
|
|
21
21
|
export const APPROVAL_HOLD_MS = 60_000;
|
|
22
22
|
|
|
23
23
|
/* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
|
|
24
|
-
* the daemon can make itself
|
|
24
|
+
* the daemon can make itself, no model, no browser, no turn, and it either got a 200 or it did not. A
|
|
25
25
|
* connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
|
|
26
26
|
* page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
|
|
27
27
|
* precisely the work an agent turn exists to absorb.
|
|
28
28
|
*
|
|
29
29
|
* So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
|
|
30
|
-
* here rather than guessed at a call site
|
|
30
|
+
* here rather than guessed at a call site, because being wrong in the optimistic direction means a post that
|
|
31
31
|
* silently never goes out. A platform absent from this set is published by an agent turn, which always works
|
|
32
32
|
* and merely costs more. */
|
|
33
33
|
export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
|
|
34
34
|
|
|
35
35
|
/* WHAT THE PUBLISH TURN IS TOLD, for the drafts no API can carry. It NAMES the drafts rather than saying "go
|
|
36
36
|
* and look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
|
|
37
|
-
* with it
|
|
37
|
+
* with it, sending something the owner pulled back a second ago, or skipping something it judged not ready.
|
|
38
38
|
* The turn's job is the part only it can do, which is working the platform's own UI.
|
|
39
39
|
*
|
|
40
40
|
* It still writes the outcome back into the file, because the file is where the queue reads it, and a post
|
|
@@ -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"
|
|
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
|
|
58
|
+
`Never rewrite the content: the owner approved these exact words. Never touch a draft not listed above.`,
|
|
59
59
|
].join(`\n`);
|
package/src/quick-model.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
});
|
package/src/quick-model.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { ACCESS_COST, accessFor, modelsFor, PROVIDERS } from "./agent-catalog.js
|
|
|
2
2
|
import { compareCheapestFirst, familyOf, tierRankOf } from "./model-order.js";
|
|
3
3
|
import type { AgentProvider } from "./schemas.js";
|
|
4
4
|
|
|
5
|
-
/* THE QUICK MODEL
|
|
5
|
+
/* THE QUICK MODEL, the cheap, fast model a small automatic job spends instead of the frontier model the chat
|
|
6
6
|
* runs on. Today that is the commit message written when an agent's work lands; anything else of that shape (a
|
|
7
7
|
* branch name, a PR description) reads the same answer, which is the reason this is a `quickModel` setting
|
|
8
8
|
* rather than a commit-message one.
|
|
@@ -11,11 +11,11 @@ import type { AgentProvider } from "./schemas.js";
|
|
|
11
11
|
* failure: the account it names spends its allowance on the chat all morning, and every job for the rest of the
|
|
12
12
|
* day fails on a limit while three other connected providers sit idle. So the setting is a LIST
|
|
13
13
|
* read top to bottom, the resolver hands back the whole ladder, and the daemon walks it until one answers.
|
|
14
|
-
* Nothing here decides WHICH failures are worth stepping over
|
|
15
|
-
* the call
|
|
14
|
+
* Nothing here decides WHICH failures are worth stepping over, that is the daemon's, since only it has run
|
|
15
|
+
* the call, this side only says what the running order is.
|
|
16
16
|
*
|
|
17
17
|
* The rule lives in the contract because BOTH sides need the same answer for different jobs: the daemon runs
|
|
18
|
-
* the model, and the browser has to NAME it
|
|
18
|
+
* the model, and the browser has to NAME it, in the settings row's "Auto (…)" label, before anything has been
|
|
19
19
|
* run. Two implementations would drift precisely where it matters most, since a label promising Haiku while the
|
|
20
20
|
* daemon bills Opus is worse than no label.
|
|
21
21
|
*
|
|
@@ -27,14 +27,14 @@ import type { AgentProvider } from "./schemas.js";
|
|
|
27
27
|
|
|
28
28
|
/* One provider's standing in the decision: whether a turn on it can be sent at all, and what its catalog holds.
|
|
29
29
|
*
|
|
30
|
-
* ACP agents are deliberately not expressible here
|
|
30
|
+
* ACP agents are deliberately not expressible here, an ACP row's model id is empty because the agent owns its
|
|
31
31
|
* own model, so there is no cheap rung to point it at. `endpoint/<id>` providers ARE, and have to be: their
|
|
32
32
|
* models appear in the same picker the settings row builds its options from, so a pin naming one has to hold
|
|
33
33
|
* rather than fall silently back to Auto and spend an account the user was deliberately steering away from. */
|
|
34
34
|
export interface QuickModelSource {
|
|
35
35
|
// AgentProvider, not NativeProvider: an endpoint's id is user-created and cannot be in a fixed union. Auto's
|
|
36
|
-
// ranking degrades gracefully for one
|
|
37
|
-
// UNRANKED, which is genuine last place
|
|
36
|
+
// ranking degrades gracefully for one, costOf falls to the metered rung and an id with no tier word is
|
|
37
|
+
// UNRANKED, which is genuine last place, so an endpoint effectively only wins Auto when nothing else is
|
|
38
38
|
// connected, while a PIN on one holds. Both are the right answers: what a turn on someone's own model server
|
|
39
39
|
// costs is not a fact this repo can know, so it is not one Auto should be asserting.
|
|
40
40
|
readonly provider: AgentProvider;
|
|
@@ -67,13 +67,13 @@ export const parsePinned = (pinned: string): QuickModelChoice | undefined => {
|
|
|
67
67
|
|
|
68
68
|
/* A pin as a person reads it: the catalog's own label for the id, or the id itself for one the static catalog
|
|
69
69
|
* has not caught up with (the picker offers a custom-id escape hatch, so this is a real case rather than a
|
|
70
|
-
* defensive branch). Beside parsePinned because the two are always wanted together
|
|
70
|
+
* defensive branch). Beside parsePinned because the two are always wanted together, by any surface that has to
|
|
71
71
|
* name what a click is about to spend BEFORE it spends it, and the two loudest of those are extensions that
|
|
72
72
|
* share no other code with each other. */
|
|
73
73
|
export const pinnedModelLabel = (choice: QuickModelChoice): string =>
|
|
74
74
|
modelsFor(choice.provider).find((option) => option.value === choice.model)?.label ?? choice.model;
|
|
75
75
|
|
|
76
|
-
// The cheapest row a provider publishes
|
|
76
|
+
// The cheapest row a provider publishes, its whole catalog read from the cheap end. Undefined for a catalog
|
|
77
77
|
// that hasn't loaded yet, which is a real state: every provider serves a floor, but only once something has
|
|
78
78
|
// asked it.
|
|
79
79
|
const cheapestOf = (source: QuickModelSource): string | undefined => source.models.toSorted(compareCheapestFirst)[0];
|
|
@@ -83,28 +83,28 @@ const cheapestOf = (source: QuickModelSource): string | undefined => source.mode
|
|
|
83
83
|
// so the row is the provider's base line rather than its budget one.
|
|
84
84
|
const tierOf = (model: string): number => tierRankOf(familyOf(model));
|
|
85
85
|
|
|
86
|
-
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read
|
|
86
|
+
// PROVIDERS order, as the final tiebreak. Arbitrary, but the SAME arbitrary answer on every read, the property
|
|
87
87
|
// compareUnrankedModelIds exists to guarantee, and the one a default actually needs. An endpoint is in no fixed
|
|
88
88
|
// list, so it reads -1 and leads the tiebreak; unreachable in practice, since it can never tie on cost.
|
|
89
89
|
const providerOrder = (provider: AgentProvider): number => PROVIDERS.findIndex((entry) => entry.value === provider);
|
|
90
90
|
|
|
91
91
|
// How much a call on this provider costs at the margin. Every native provider declares an access kind; an
|
|
92
|
-
// endpoint declares none, and takes the metered rung
|
|
92
|
+
// endpoint declares none, and takes the metered rung, the conservative reading of a model API whose bill this
|
|
93
93
|
// repo cannot see, which keeps Auto from reaching for someone's paid gateway on its own initiative.
|
|
94
94
|
const costOf = (provider: AgentProvider): number => {
|
|
95
95
|
const access = accessFor(provider);
|
|
96
96
|
return access === undefined ? ACCESS_COST.key : ACCESS_COST[access.kind];
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
/* AUTO
|
|
99
|
+
/* AUTO, every connected provider's cheapest row, best-first, as a ladder rather than a winner.
|
|
100
100
|
*
|
|
101
101
|
* Ranked on TIER FIRST, then cost. That order is the point of the feature: the helper exists to not be the
|
|
102
102
|
* frontier model, so a free flagship is still the wrong tool, while a free Haiku-class row and a subscription
|
|
103
103
|
* Haiku-class row differ only in whose quota they spend. Cost then breaks that tie towards the channel the user
|
|
104
|
-
* is not paying per token for
|
|
104
|
+
* is not paying per token for, and against the one they are.
|
|
105
105
|
*
|
|
106
106
|
* The whole ladder, not just its head, because the same ranking that picks the best answer also states the best
|
|
107
|
-
* SECOND answer
|
|
107
|
+
* SECOND answer, and a sandbox with three accounts connected should not lose its commit messages for six hours
|
|
108
108
|
* because one of them is spent. */
|
|
109
109
|
const autoLadder = (sources: readonly QuickModelSource[]): readonly QuickModelChoice[] =>
|
|
110
110
|
sources
|
|
@@ -144,7 +144,7 @@ export const resolveQuickModels = (sources: readonly QuickModelSource[], pinned:
|
|
|
144
144
|
const choice = parsePinned(key);
|
|
145
145
|
return choice === undefined || !ready.has(choice.provider) ? [] : [choice];
|
|
146
146
|
});
|
|
147
|
-
// The same model twice would spend two attempts proving the same account is out
|
|
147
|
+
// The same model twice would spend two attempts proving the same account is out, a real state, since the
|
|
148
148
|
// list is edited by hand and Auto's ladder can rank a provider the user has also pinned.
|
|
149
149
|
const chain = [...new Map(requested.map((choice) => [quickModelKey(choice), choice])).values()];
|
|
150
150
|
return chain.length > 0 ? chain : autoLadder(sources);
|
package/src/routes.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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`, () => {
|