@intentic/sandbox-contract 1.226.1 → 1.227.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/agent.contract.d.ts +19 -2
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +58 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +1 -0
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +1 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/logs.contract.d.ts +17 -0
- package/dist/contracts/logs.contract.d.ts.map +1 -1
- package/dist/contracts/logs.contract.js +10 -1
- package/dist/contracts/logs.contract.js.map +1 -1
- package/dist/contracts/providers.contract.d.ts +1 -0
- package/dist/contracts/providers.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +3 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +19 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +7 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events.d.ts +46 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +14 -0
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +196 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prompt-complexity.d.ts +9 -1
- package/dist/prompt-complexity.d.ts.map +1 -1
- package/dist/prompt-complexity.js +13 -5
- package/dist/prompt-complexity.js.map +1 -1
- package/dist/request-id.d.ts +3 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-id.js +3 -0
- package/dist/request-id.js.map +1 -0
- package/dist/schemas.d.ts +88 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +56 -0
- package/dist/schemas.js.map +1 -1
- package/dist/starter.d.ts +3 -0
- package/dist/starter.d.ts.map +1 -0
- package/dist/starter.js +3 -0
- package/dist/starter.js.map +1 -0
- package/package.json +5 -4
- package/src/contract-lock.test.ts +8 -2
- package/src/contracts/logs.contract.ts +23 -2
- package/src/events.ts +52 -1
- package/src/index.ts +2 -0
- package/src/prompt-complexity.test.ts +69 -0
- package/src/prompt-complexity.ts +102 -39
- package/src/request-id.ts +41 -0
- package/src/schemas.ts +194 -0
- package/src/starter.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"starter.d.ts","sourceRoot":"","sources":["../src/starter.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,YAAY,SAAS,CAAC;AACnC,eAAO,MAAM,WAAW,YAAY,CAAC"}
|
package/dist/starter.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"starter.js","sourceRoot":"","sources":["../src/starter.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC;AACnC,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/sandbox-contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.227.0",
|
|
4
4
|
"description": "oRPC wire contract for the intentic sandbox daemon, shared by the daemon and its browser client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -89,14 +89,15 @@
|
|
|
89
89
|
"@orpc/contract": "1.14.13",
|
|
90
90
|
"tslib": "2.8.1",
|
|
91
91
|
"zod": "4.4.3",
|
|
92
|
-
"@intentic/constants": "1.
|
|
93
|
-
"@intentic/
|
|
94
|
-
"@intentic/
|
|
92
|
+
"@intentic/constants": "1.227.0",
|
|
93
|
+
"@intentic/registry": "1.227.0",
|
|
94
|
+
"@intentic/extension-manifest": "1.227.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
97
|
"@types/node": "24.13.2",
|
|
98
98
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
99
99
|
"vitest": "4.1.10",
|
|
100
|
+
"@intentic/testing": "0.0.0",
|
|
100
101
|
"@intentic/tsconfig": "0.0.0"
|
|
101
102
|
},
|
|
102
103
|
"scripts": {
|
|
@@ -5,8 +5,14 @@ import { currentLock } from "./contract-lock.js";
|
|
|
5
5
|
/* The committed lock and the code must say the same thing: see contract-lock.ts for what the pair buys.
|
|
6
6
|
*
|
|
7
7
|
* This is the HALF that runs everywhere the tests run; the other half (a shrunk lock needs a declared break)
|
|
8
|
-
* lives in prepass.mjs, which has git and this suite does not.
|
|
9
|
-
|
|
8
|
+
* lives in prepass.mjs, which has git and this suite does not.
|
|
9
|
+
*
|
|
10
|
+
* ITS OWN BUDGET, because the default one is a HANG DETECTOR and this test does real work: it serializes every
|
|
11
|
+
* schema this package exports, ~500 of them, to JSON Schema. That is ~100ms with the machine to itself and it
|
|
12
|
+
* measured 8.8s on a runner running every package's suite at once, so vitest's 5s default failed it as a hang
|
|
13
|
+
* over a contract that had not moved: green on a box, red on a busy runner, the trap _tools/testing/src/vitest
|
|
14
|
+
* .ts is written against. 30s is well clear of the work and still reports a genuine hang inside half a minute. */
|
|
15
|
+
test("contract.lock.json matches the schemas this package exports", { timeout: 30_000 }, () => {
|
|
10
16
|
const committed: unknown = JSON.parse(readFileSync(new URL("../contract.lock.json", import.meta.url), "utf8"));
|
|
11
17
|
expect(
|
|
12
18
|
currentLock(),
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { oc } from "@orpc/contract";
|
|
2
|
-
import { LogReadQuerySchema, LogReadSchema, LogsListSchema } from "../schemas.js";
|
|
2
|
+
import { ClientDiagnosticsAcceptedSchema, ClientDiagnosticsReportSchema, LogReadQuerySchema, LogReadSchema, LogsListSchema } from "../schemas.js";
|
|
3
3
|
|
|
4
4
|
// Daemon-owned debug logs (historyRoot/logs): terminal pipe-pane captures, intentic CLI run logs, daemon.log.
|
|
5
|
-
//
|
|
5
|
+
// Reads are read-only by design, the files are written by the daemon/tmux only, so the record stays
|
|
6
|
+
// trustworthy. `report` is the one write, and the exception that proves the rule: the browser is the only
|
|
7
|
+
// witness to its own crashes, and a record it cannot reach is a record that has nothing to say about them.
|
|
6
8
|
export const logsContract = {
|
|
7
9
|
list: oc
|
|
8
10
|
.route({
|
|
@@ -22,4 +24,23 @@ export const logsContract = {
|
|
|
22
24
|
})
|
|
23
25
|
.input(LogReadQuerySchema)
|
|
24
26
|
.output(LogReadSchema),
|
|
27
|
+
/* What the browser saw. The counterpart to every other route here: those serve what the daemon wrote down,
|
|
28
|
+
* this accepts what only the browser could have known.
|
|
29
|
+
*
|
|
30
|
+
* A WRITE from the client, which every other log route deliberately is not, and the trust argument is the
|
|
31
|
+
* inverse of theirs: the reads are trustworthy because only the daemon writes them, and this is trustworthy
|
|
32
|
+
* only about ITSELF. So it lands in its own file, never daemon.log, and its lines say plainly that a browser
|
|
33
|
+
* said them. It floors at viewer rather than the maintainer the logs prefix takes, because a viewer whose
|
|
34
|
+
* page just white-screened is exactly who needs to be able to report it, and cannot raise their own role to
|
|
35
|
+
* do it. */
|
|
36
|
+
report: oc
|
|
37
|
+
.route({
|
|
38
|
+
method: "POST",
|
|
39
|
+
path: "/logs/client",
|
|
40
|
+
summary: "Report what the browser saw",
|
|
41
|
+
description:
|
|
42
|
+
"Errors the app caught, stalls it measured, and recoveries it performed, written to a log of their own. The browser is the only witness to these, so without it a bug someone hit in their own browser leaves no record at all.",
|
|
43
|
+
})
|
|
44
|
+
.input(ClientDiagnosticsReportSchema)
|
|
45
|
+
.output(ClientDiagnosticsAcceptedSchema),
|
|
25
46
|
};
|
package/src/events.ts
CHANGED
|
@@ -387,6 +387,19 @@ export const RestoredMessageSchema = z.object({
|
|
|
387
387
|
.describe(
|
|
388
388
|
"A person wrote this in the agent's voice, with no turn behind it. Marked for the human re-reading the conversation months later, so their own words do not pass as the agent's. The agent itself never sees the mark.",
|
|
389
389
|
),
|
|
390
|
+
/* THE ONE-PRESS OFFER A RECORDED NOTICE CARRIES (notice rows only), named rather than inferred from its
|
|
391
|
+
* words. Only `tierHold` today: the line saying this turn ran on a cheaper model, whose offer is "keep this
|
|
392
|
+
* chat on my pick".
|
|
393
|
+
*
|
|
394
|
+
* It is on the wire because the offer has to survive a reopen, and a reopened tab has only the record. The
|
|
395
|
+
* chat's other one-press notices are drawn live and never recorded, so they never needed this; a routed turn
|
|
396
|
+
* is different precisely because the whole point of recording it is that somebody reads it LATER. A KIND, not
|
|
397
|
+
* a callback, exactly as the live ones are: the reader decides what the press does and whether the offer
|
|
398
|
+
* still stands (a chat already holding its pick shows a settled sentence, not a stale button). */
|
|
399
|
+
noticeAction: z
|
|
400
|
+
.enum(["tierHold"])
|
|
401
|
+
.optional()
|
|
402
|
+
.describe("A one-press follow-up this recorded notice offers, by name. The chat decides what it does and whether it still applies."),
|
|
390
403
|
});
|
|
391
404
|
export type RestoredMessage = z.infer<typeof RestoredMessageSchema>;
|
|
392
405
|
|
|
@@ -667,6 +680,33 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
667
680
|
// wasn't asked for.
|
|
668
681
|
reason: z.string().optional(),
|
|
669
682
|
}),
|
|
683
|
+
/* WHAT THE COMPLEXITY JUDGE MADE OF THIS TURN, emitted once at turn start on every judged turn (that is,
|
|
684
|
+
* whenever settings.autoTier is not "off"), for the same reason fast_mode exists: a mechanism that can
|
|
685
|
+
* change what a turn runs on fails silently unless the daemon says what it decided. One tiny frame per
|
|
686
|
+
* turn, deliberately on the standard verdicts too, because the client's composer preview needs the
|
|
687
|
+
* conversation's LAST verdict (prompt-complexity.ts `afterHardTurn`) and a frame only on the interesting
|
|
688
|
+
* turns would leave it guessing on the common ones.
|
|
689
|
+
*
|
|
690
|
+
* `tier`/`score`/`rules` are the verdict verbatim (judgeComplexity): the rules are the named-feature
|
|
691
|
+
* vocabulary of ComplexityRule, carried as strings so a frame from a build with a rule this client hasn't
|
|
692
|
+
* heard of still parses. `model` is present only when a substitution actually applies to THIS turn, which
|
|
693
|
+
* is `routed` (mode on, verdict fast, something cheaper published) or `held` (the same turn the user pinned
|
|
694
|
+
* to their pick, see AgentTurn.tierHold): measure mode never names one because naming it would cost the
|
|
695
|
+
* catalog read shadow mode exists to avoid.
|
|
696
|
+
*
|
|
697
|
+
* `routed` is what HAPPENED, never implied by the verdict: a fast verdict in measure mode, under a hold, or
|
|
698
|
+
* with nothing cheaper published all run the user's own pick and say `routed: false`. */
|
|
699
|
+
z.object({
|
|
700
|
+
kind: z.literal("tier"),
|
|
701
|
+
tier: z.enum(["fast", "standard"]),
|
|
702
|
+
score: z.number(),
|
|
703
|
+
rules: z.array(z.string()),
|
|
704
|
+
// The cheaper model this turn ran on (routed) or would have run on (held). Absent otherwise.
|
|
705
|
+
model: z.string().optional(),
|
|
706
|
+
routed: z.boolean(),
|
|
707
|
+
// The user pinned this turn to their pick (AgentTurn.tierHold), so a fast verdict moved nothing.
|
|
708
|
+
held: z.boolean().optional(),
|
|
709
|
+
}),
|
|
670
710
|
/* The turn is alive but WAITING on the provider: a request failed transiently (5xx, 529, a dropped socket)
|
|
671
711
|
* and the harness is retrying it inside this same turn. A status, not a failure, nothing has been lost and
|
|
672
712
|
* the turn may still finish normally, so the client renders it where "thinking" goes rather than in the
|
|
@@ -677,7 +717,10 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
677
717
|
* a hang. The one action a user takes against an apparent hang is Stop, which is the only action that
|
|
678
718
|
* actually loses the work, so the wait has to be visible, with its own next-attempt clock.
|
|
679
719
|
*
|
|
680
|
-
* `attempt
|
|
720
|
+
* `attempt` is the harness's own counter and `maxAttempts` is the bound that will actually be honoured,
|
|
721
|
+
* which on the Claude path is the daemon's own cap on how deep a storm may get rather than the harness's
|
|
722
|
+
* far longer budget (MAX_IN_TURN_RETRIES in sdk-stream.ts, which ends the turn at the cap and hands the
|
|
723
|
+
* waiting to the outage breaker). `nextAttemptAt` (epoch ms) is when it will try
|
|
681
724
|
* again, so the readout counts down instead of freezing on a number nobody can interpret. BOTH are optional
|
|
682
725
|
* for the same reason, which is that each runtime publishes a different half of the wait and none of them
|
|
683
726
|
* publishes all of it: Claude's harness reports the delay and the bound, Codex says which attempt it is on
|
|
@@ -855,6 +898,14 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
855
898
|
"unknown-command",
|
|
856
899
|
"grok-model-invalid",
|
|
857
900
|
"codex-model-invalid",
|
|
901
|
+
/* THE MODEL CANNOT HOLD A TURN OF THIS AGENT LOOP, so the daemon refused before sending
|
|
902
|
+
* (agent/context-budget.ts). Its own code because none of the neighbours describes it: nothing is
|
|
903
|
+
* disconnected, nothing is spent, nothing comes back on a clock, and re-sending the same request
|
|
904
|
+
* at the same model fails identically forever. What changes the outcome is the model or the
|
|
905
|
+
* server's context flag, so the message names both and the client HOLDS the words: they never
|
|
906
|
+
* reached anything, and losing them to a configuration fact would be the one part of this that
|
|
907
|
+
* was our fault. */
|
|
908
|
+
"context-window-too-small",
|
|
858
909
|
"subscription-required",
|
|
859
910
|
"agent-busy",
|
|
860
911
|
])
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,7 @@ export { hostContract } from "./contracts/host.contract.js";
|
|
|
60
60
|
export { intenticContract } from "./contracts/intentic.contract.js";
|
|
61
61
|
export { inventoryContract } from "./contracts/inventory.contract.js";
|
|
62
62
|
export { logsContract } from "./contracts/logs.contract.js";
|
|
63
|
+
export { REQUEST_ID_EVIDENCE_ROUTE, REQUEST_ID_HEADER } from "./request-id.js";
|
|
63
64
|
export { loopsContract } from "./contracts/loops.contract.js";
|
|
64
65
|
export { panelsContract } from "./contracts/panels.contract.js";
|
|
65
66
|
export { portsContract } from "./contracts/ports.contract.js";
|
|
@@ -113,6 +114,7 @@ export * from "./output-fields.js";
|
|
|
113
114
|
export * from "./publish-drafts.js";
|
|
114
115
|
export * from "./schemas.js";
|
|
115
116
|
export * from "./search-globs.js";
|
|
117
|
+
export * from "./starter.js";
|
|
116
118
|
export * from "./terminal-protocol.js";
|
|
117
119
|
export * from "./title.js";
|
|
118
120
|
export * from "./versions.js";
|
|
@@ -158,3 +158,72 @@ test("the score never leaves 0..1, so a stored row is always comparable against
|
|
|
158
158
|
expect(floor.score).toBeGreaterThanOrEqual(0);
|
|
159
159
|
expect(ceiling.score).toBeLessThanOrEqual(1);
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
// --- the one dial, and the property it may not move ------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/* The owner can move the cutoff (settings.autoTierEagerness) because "err toward my model or toward the cheap
|
|
165
|
+
* one" is a preference nobody else can hold for them. What these pin is that the dial moves the cutoff and
|
|
166
|
+
* NOTHING else, in particular not the rule that a downgrade needs something positively easy to have been said,
|
|
167
|
+
* which was the property the old ceiling held only by arithmetic coincidence. */
|
|
168
|
+
|
|
169
|
+
test("the dial widens what counts as simple, in the direction it says", () => {
|
|
170
|
+
// A question about a real file: eased by its words, held back by naming a path. The middle stop keeps it on
|
|
171
|
+
// the user's pick, and the eager stop is precisely the choice to let it through.
|
|
172
|
+
const aboutAFile = `explain what src/app.ts does`;
|
|
173
|
+
|
|
174
|
+
expect(tierOf(aboutAFile, { eagerness: `balanced` })).toBe(`standard`);
|
|
175
|
+
expect(tierOf(aboutAFile, { eagerness: `eager` })).toBe(`fast`);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the cautious stop wants every easing signal at once, not merely an easy word", () => {
|
|
179
|
+
// Its whole content: leave no room for doubt. A statement in easy words still qualifies at the default,
|
|
180
|
+
// and does not here; only a short bare question naming no file survives every stop.
|
|
181
|
+
expect(tierOf(`explain closures`, { eagerness: `balanced` })).toBe(`fast`);
|
|
182
|
+
expect(tierOf(`explain closures`, { eagerness: `cautious` })).toBe(`standard`);
|
|
183
|
+
expect(tierOf(`what is a closure?`, { eagerness: `cautious` })).toBe(`fast`);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("an absent dial is the balanced stop, so every row recorded before it existed still compares", () => {
|
|
187
|
+
const bare = judgeComplexity(turn(`what is this?`));
|
|
188
|
+
|
|
189
|
+
expect(bare.ceiling).toBe(FAST_CEILING);
|
|
190
|
+
expect(bare.tier).toBe(judgeComplexity(turn(`what is this?`, { eagerness: `balanced` })).tier);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("no setting of the dial can downgrade a short vague request", () => {
|
|
194
|
+
// The whole safety argument for offering an eager stop at all. Absence of complexity is not evidence of
|
|
195
|
+
// simplicity at ANY cutoff, so this is a rule in the judge rather than a sum that happens to clear it.
|
|
196
|
+
for (const eagerness of [`cautious`, `balanced`, `eager`] as const) {
|
|
197
|
+
expect(tierOf(`fix the bug`, { eagerness })).toBe(`standard`);
|
|
198
|
+
expect(tierOf(`have a look at the thing we discussed`, { eagerness })).toBe(`standard`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("the deceptive follow-up is standard at every stop, because it never says anything easy", () => {
|
|
203
|
+
/* "now do the same for the other file" is the case the whole afterHardTurn signal was built for, and the
|
|
204
|
+
* easing rule turns out to answer it more strongly than any cutoff can: those words make no positive claim
|
|
205
|
+
* of ease, so no setting of the dial reaches them. The weight still does its own job one test down. */
|
|
206
|
+
for (const eagerness of [`cautious`, `balanced`, `eager`] as const) {
|
|
207
|
+
expect(tierOf(`now do the same for the other file`, { eagerness, afterHardTurn: true })).toBe(`standard`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a turn following hard work has to clear a higher bar, and at the default an eased one no longer does", () => {
|
|
212
|
+
// It raises the bar rather than locking the door (see ComplexityInput.afterHardTurn), so this is a shift of
|
|
213
|
+
// one stop's worth, not a gate: the same words that route in a fresh conversation stay put in a hard one.
|
|
214
|
+
expect(tierOf(`explain closures`)).toBe(`fast`);
|
|
215
|
+
expect(tierOf(`explain closures`, { afterHardTurn: true })).toBe(`standard`);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("the verdict carries the cutoff it was judged against, because a score alone stopped being an answer", () => {
|
|
219
|
+
// With the cutoff an owner setting, the same 0.35 is standard on one stop and fast on another. A ledger of
|
|
220
|
+
// bare scores could not tell those two rows apart; the ceiling beside each is what keeps a refit honest.
|
|
221
|
+
const cautious = judgeComplexity(turn(`explain closures`, { eagerness: `cautious` }));
|
|
222
|
+
const eager = judgeComplexity(turn(`explain closures`, { eagerness: `eager` }));
|
|
223
|
+
|
|
224
|
+
expect(cautious.ceiling).toBeLessThan(FAST_CEILING);
|
|
225
|
+
expect(eager.ceiling).toBeGreaterThan(FAST_CEILING);
|
|
226
|
+
// Same words, same score, opposite verdicts: the pair that a column of bare scores could not have told apart.
|
|
227
|
+
expect(cautious.score).toBe(eager.score);
|
|
228
|
+
expect([cautious.tier, eager.tier]).toEqual([`standard`, `fast`]);
|
|
229
|
+
});
|
package/src/prompt-complexity.ts
CHANGED
|
@@ -89,6 +89,10 @@ export interface ComplexityInput {
|
|
|
89
89
|
* or the feature switched off) says nothing about the difficulty of the work, and reading it as escalation
|
|
90
90
|
* would make the sandbox's configuration leak into its opinion about a sentence. */
|
|
91
91
|
readonly afterHardTurn: boolean;
|
|
92
|
+
/* HOW EAGER THE OWNER ASKED THIS TO BE (settings.autoTierEagerness), the one preference the judge takes.
|
|
93
|
+
* Absent ⇒ `balanced`, which is the stop every verdict recorded before the knob existed was judged
|
|
94
|
+
* against, so an absent value and an old row mean the same thing. See FAST_CEILINGS. */
|
|
95
|
+
readonly eagerness?: TierEagerness;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
export interface ComplexityVerdict {
|
|
@@ -99,6 +103,10 @@ export interface ComplexityVerdict {
|
|
|
99
103
|
// Every rule that fired, in declaration order. Empty is legal and means "nothing distinctive": the score is
|
|
100
104
|
// the base, which sits above the fast ceiling, so an unremarkable turn stays on the user's own pick.
|
|
101
105
|
readonly rules: readonly ComplexityRule[];
|
|
106
|
+
/* The cutoff this score was judged against, carried out so the caller can record it beside the score.
|
|
107
|
+
* A score is only half a verdict once the ceiling is a setting: 0.35 was standard yesterday and is fast
|
|
108
|
+
* today, and a refit reading a column of bare scores could not tell those rows apart. */
|
|
109
|
+
readonly ceiling: number;
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
/* WHERE AN UNREMARKABLE TURN STARTS, and it starts ABOVE the fast ceiling on purpose: a prompt that matches no
|
|
@@ -107,10 +115,35 @@ export interface ComplexityVerdict {
|
|
|
107
115
|
* escalation, and a user who stops trusting the feature. */
|
|
108
116
|
const BASE_SCORE = 0.5;
|
|
109
117
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
/* HOW EAGER THE JUDGE IS, the one knob this feature exposes, and deliberately the only one.
|
|
119
|
+
*
|
|
120
|
+
* The routing literature's own answer to "how do you tune a router in public" is a single aggressiveness
|
|
121
|
+
* threshold (RouteLLM ships it in the model name, `router-mf-0.3` against `router-mf-0.7`); everything else it
|
|
122
|
+
* learns stays inside. Same here: the weights below are a hypothesis with a ledger under them and are nobody's
|
|
123
|
+
* business, while "should this err toward my model or toward the cheap one" is a preference only the owner can
|
|
124
|
+
* hold, and the shadow numbers are useless without a way to act on what they say.
|
|
125
|
+
*
|
|
126
|
+
* THREE NAMED STOPS, not a slider, because the page has no slider idiom and, more to the point, a continuous
|
|
127
|
+
* control here invites fiddling with a number whose meaning nobody can feel. Each stop is a sentence about
|
|
128
|
+
* which turns move:
|
|
129
|
+
* cautious — every easing signal at once and nothing pulling the other way: a short bare question, in easy
|
|
130
|
+
* words, naming no file. "what is a closure?" and very little else. The zero is not a disabled
|
|
131
|
+
* state, it is the floor the score clamps to, so it means exactly "leave no room for doubt".
|
|
132
|
+
* balanced — the shipped default, and what every stored verdict before this knob existed was judged against.
|
|
133
|
+
* Easy words carry a turn on their own; naming a file still holds it back.
|
|
134
|
+
* eager — an easy-worded question about real code goes too ("explain what this file does").
|
|
135
|
+
*
|
|
136
|
+
* NONE OF THEM CAN REACH THE ABSENCE FEATURES, at any setting, because that property is enforced structurally
|
|
137
|
+
* now rather than by the weights happening to sum above the ceiling (see `easing` below). That is what makes an
|
|
138
|
+
* eager stop safe to offer at all: raising a bare number would, at 0.3, have started downgrading every short
|
|
139
|
+
* vague request in the product, which is the single worst population to be wrong about. */
|
|
140
|
+
export const FAST_CEILINGS = { cautious: 0, balanced: 0.25, eager: 0.4 } as const;
|
|
141
|
+
export type TierEagerness = keyof typeof FAST_CEILINGS;
|
|
142
|
+
|
|
143
|
+
// The stop a turn is judged against when nobody has chosen one, and the one every verdict recorded before the
|
|
144
|
+
// knob existed was judged against. Exported because a reader of a stored score needs the ceiling behind it, and
|
|
145
|
+
// a row written before ceilings were recorded was written against exactly this.
|
|
146
|
+
export const FAST_CEILING = FAST_CEILINGS.balanced;
|
|
114
147
|
|
|
115
148
|
// Characters, not tokens: nothing here can tokenize, and for a threshold the constant cancels. ~600 chars is
|
|
116
149
|
// where a request stops being a sentence and starts being a brief; ~2400 is where it is carrying pasted
|
|
@@ -215,38 +248,56 @@ const forcing = (input: ComplexityInput, text: string): ComplexityRule[] => {
|
|
|
215
248
|
* nothing, which is exactly why the mechanism ships in shadow first — see docs/model-routing-design.md §4. They
|
|
216
249
|
* are not a claim, they are a hypothesis with a ledger under it.
|
|
217
250
|
*
|
|
218
|
-
* ONE PROPERTY IS NOT A HYPOTHESIS AND MUST SURVIVE ANY REFIT
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
* ONE PROPERTY IS NOT A HYPOTHESIS AND MUST SURVIVE ANY REFIT, and it is a RULE here rather than an accident of
|
|
252
|
+
* arithmetic: absence of complexity is not evidence of simplicity. "fix the bug" is four words naming no file
|
|
253
|
+
* and is not a cheap turn, so a downgrade always requires something POSITIVE to have been said — an easy word,
|
|
254
|
+
* or a bare question. Those two are marked `easing`, and `judgeComplexity` refuses a fast verdict without one
|
|
255
|
+
* whatever the score says.
|
|
256
|
+
*
|
|
257
|
+
* It used to hold only because the two ABSENCE features (`short-prompt`, `no-workspace-reference`) summed to
|
|
258
|
+
* 0.3 against a ceiling of 0.25, which is a coincidence of two numbers rather than a property, and the moment
|
|
259
|
+
* the ceiling became a setting it was one click from being false. Weighted the obvious way, the judge
|
|
260
|
+
* downgraded every short vague request in the product, which is the single worst population to be wrong
|
|
261
|
+
* about. */
|
|
262
|
+
interface GradedFeature {
|
|
263
|
+
readonly rule: ComplexityRule;
|
|
264
|
+
readonly weight: number;
|
|
265
|
+
readonly of: (input: ComplexityInput, text: string) => boolean;
|
|
266
|
+
// A POSITIVE reason to think this is easy, as opposed to the mere absence of reasons to think it is hard.
|
|
267
|
+
// At least one has to fire before any turn is called fast; see the note above.
|
|
268
|
+
readonly easing?: true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const GRADED: readonly GradedFeature[] = [
|
|
272
|
+
{ rule: "medium-prompt", weight: +0.2, of: (_input, text) => text.length > MEDIUM_PROMPT_CHARS },
|
|
273
|
+
{ rule: "attachment", weight: +0.15, of: (input) => input.attachments > 0 },
|
|
274
|
+
{ rule: "editor-context", weight: +0.1, of: (input) => input.editorContext },
|
|
275
|
+
// Enough on its own to hold an easy-worded question at standard on the middle stop: "explain what this file
|
|
276
|
+
// does" is a question about real code in this repo, and the cheap rung's failures on real code are the
|
|
277
|
+
// silent kind. The `eager` stop is precisely the choice to let that one through.
|
|
278
|
+
{ rule: "paths", weight: +0.15, of: (_input, text) => PATH_LIKE.test(text) },
|
|
279
|
+
{
|
|
280
|
+
rule: "many-verbs",
|
|
281
|
+
weight: +0.15,
|
|
282
|
+
of: (_input, text) => new Set((text.match(VERBS) ?? []).map((verb) => verb.toLowerCase())).size >= MANY_VERBS,
|
|
283
|
+
},
|
|
284
|
+
// The heaviest single weight, because it is the only feature that can see past the words. See
|
|
285
|
+
// ComplexityInput.afterHardTurn for why it is a weight rather than the gate it was designed as. Heavy enough
|
|
286
|
+
// that even the eager stop cannot route a follow-up to hard work on easy words alone.
|
|
287
|
+
{ rule: "after-hard-turn", weight: +0.25, of: (input) => input.afterHardTurn },
|
|
288
|
+
/* The easing half, in the order it has always been declared, because that order is the order a verdict
|
|
289
|
+
* lists its rules in and the ledger has rows written against it. `easing` marks the two POSITIVE ones, one
|
|
290
|
+
* of which every fast verdict must carry; the other two are the absence features, deliberately light and,
|
|
291
|
+
* by the rule above, never enough by themselves. */
|
|
292
|
+
{ rule: "short-prompt", weight: -0.1, of: (_input, text) => text.length <= SHORT_PROMPT_CHARS },
|
|
293
|
+
{ rule: "easy-words", weight: -0.25, easing: true, of: (_input, text) => EASY_WORDS.test(text) },
|
|
294
|
+
{ rule: "bare-question", weight: -0.15, easing: true, of: (_input, text) => BARE_QUESTION.test(text) },
|
|
295
|
+
{
|
|
296
|
+
rule: "no-workspace-reference",
|
|
297
|
+
weight: -0.1,
|
|
298
|
+
of: (input, text) => input.attachments === 0 && !input.editorContext && !PATH_LIKE.test(text),
|
|
299
|
+
},
|
|
300
|
+
];
|
|
250
301
|
|
|
251
302
|
// Three places, so a stored score is a value rather than a float artefact and two rows written by the same
|
|
252
303
|
// rules compare equal.
|
|
@@ -258,14 +309,26 @@ const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
|
|
258
309
|
* up a tier, never silently move a different set of turns down.
|
|
259
310
|
*
|
|
260
311
|
* 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.
|
|
312
|
+
* separating "explain this" from "wire this up" among requests that look alike.
|
|
313
|
+
*
|
|
314
|
+
* TWO CONDITIONS FOR FAST, not one, and the second is the one that does not move: the score has to clear the
|
|
315
|
+
* owner's chosen ceiling AND something POSITIVE has to have been said (a `easing` feature). The ceiling is a
|
|
316
|
+
* preference and belongs to whoever pays the bill; "we downgraded it because you didn't say much" is not a
|
|
317
|
+
* preference, it is a bug, and it stays impossible at every stop of the knob. */
|
|
262
318
|
export const judgeComplexity = (input: ComplexityInput): ComplexityVerdict => {
|
|
319
|
+
const ceiling = FAST_CEILINGS[input.eagerness ?? "balanced"];
|
|
263
320
|
const text = input.prompt.trim();
|
|
264
321
|
const forced = forcing(input, text);
|
|
265
322
|
if (forced.length > 0) {
|
|
266
|
-
return { tier: "standard", score: 1, rules: forced };
|
|
323
|
+
return { tier: "standard", score: 1, rules: forced, ceiling };
|
|
267
324
|
}
|
|
268
325
|
const hits = GRADED.filter((feature) => feature.of(input, text));
|
|
269
326
|
const score = Math.min(1, Math.max(0, BASE_SCORE + hits.reduce((total, feature) => total + feature.weight, 0)));
|
|
270
|
-
|
|
327
|
+
const eased = hits.some((feature) => feature.easing === true);
|
|
328
|
+
return {
|
|
329
|
+
tier: eased && score <= ceiling ? "fast" : "standard",
|
|
330
|
+
score: round3(score),
|
|
331
|
+
rules: hits.map((feature) => feature.rule),
|
|
332
|
+
ceiling,
|
|
333
|
+
};
|
|
271
334
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* THE ONE HEADER THAT JOINS A BROWSER CALL TO THE DAEMON LINE THAT SERVED IT.
|
|
2
|
+
*
|
|
3
|
+
* Both halves of a slow interaction were already measured and could not be put together. The browser times
|
|
4
|
+
* `rpc.request`, what the user actually waited for; the daemon times `http.request`, what it served; and on a
|
|
5
|
+
* sandbox answering several calls a second the only way to pair them was by timestamp and hope. So "the panel
|
|
6
|
+
* stuttered" stayed unattributable even with both numbers in hand, which is the gap the daemon's own perf module
|
|
7
|
+
* says it exists to close and could only close on its own side of the wire.
|
|
8
|
+
*
|
|
9
|
+
* Here rather than as a literal on each side, because a header name that disagrees across the wire fails
|
|
10
|
+
* SILENTLY: the browser sends a field nobody reads and the daemon logs nothing, which looks exactly like a quiet
|
|
11
|
+
* system. Both sides import this.
|
|
12
|
+
*
|
|
13
|
+
* The NAME only. The browser mints the value with the app's own `composables/uuid.ts`, which already solves the
|
|
14
|
+
* one hard part (`crypto.randomUUID` is a secure-context api and a self-hosted instance on a LAN address does
|
|
15
|
+
* not have it), and the daemon only ever reads what it is given. A generator here would be a second answer to a
|
|
16
|
+
* question that already has a better one. The value is a correlation token and never a security boundary: the
|
|
17
|
+
* daemon records it and decides nothing on it, so a caller repeating one confuses a log and nothing else. */
|
|
18
|
+
export const REQUEST_ID_HEADER = "x-intentic-request-id";
|
|
19
|
+
|
|
20
|
+
/* WHEN A BROWSER MAY SEND IT, and this is not the additive change it looks like.
|
|
21
|
+
*
|
|
22
|
+
* A custom request header is the one wire addition that is NOT backward compatible, because the browser does not
|
|
23
|
+
* get to decide it: a header outside the CORS safelist forces a preflight, and a daemon whose `allowHeaders`
|
|
24
|
+
* predates this name answers a preflight that omits it. The browser then fails the whole request rather than
|
|
25
|
+
* dropping the header. Sent unconditionally, this turns "one field the daemon never logs" into EVERY typed call
|
|
26
|
+
* to that daemon failing at the transport, `system.events` included, so the stream never opens, the connection
|
|
27
|
+
* never reaches `online`, and the app settles on "Busy, catching up" forever against a sandbox that is up and
|
|
28
|
+
* healthy and answering `/health` in a millisecond.
|
|
29
|
+
*
|
|
30
|
+
* That state is not exotic. A browser newer than its daemon is the NORMAL case and a supported one, both in
|
|
31
|
+
* production (every user's sandbox runs whatever image they last chose to pull, and COMPATIBILITY.md's second
|
|
32
|
+
* promise is that no update is ever forced) and in development (the web app runs from the working tree, the
|
|
33
|
+
* daemon is baked into the last `pnpm build:sandbox`). See the web app's useDaemonRoutes.ts, which exists to say
|
|
34
|
+
* exactly this.
|
|
35
|
+
*
|
|
36
|
+
* So the header ships gated on POSITIVE evidence, and this names the evidence: `logs.report` is the route that
|
|
37
|
+
* landed in the same commit as the `allowHeaders` entry, so a daemon advertising it on its hello frame is a
|
|
38
|
+
* daemon whose CORS accepts the header. Note the polarity is the opposite of `supportsRoute`'s: there, no
|
|
39
|
+
* evidence means assume-supported, because hiding a working feature is the greater harm; here, no evidence must
|
|
40
|
+
* mean DO NOT SEND, because sending costs the entire connection and the header buys a log field. */
|
|
41
|
+
export const REQUEST_ID_EVIDENCE_ROUTE = "logs.report";
|