@intentic/sandbox-contract 1.295.0 → 1.297.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 +23 -6
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +10 -0
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +276 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +10 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/runner.contract.d.ts +95 -95
- package/dist/contracts/sessions.contract.d.ts +1 -0
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +4 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +11 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts +3 -0
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/system-events.d.ts +10 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/transcript.d.ts +6 -0
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +1 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/index.d.ts +317 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/models/model-offer.d.ts +32 -0
- package/dist/models/model-offer.d.ts.map +1 -0
- package/dist/models/model-offer.js +81 -0
- package/dist/models/model-offer.js.map +1 -0
- package/dist/models/model-roles.d.ts +7 -0
- package/dist/models/model-roles.d.ts.map +1 -1
- package/dist/models/model-roles.js +7 -0
- package/dist/models/model-roles.js.map +1 -1
- package/dist/schemas/activity.d.ts.map +1 -1
- package/dist/schemas/activity.js +1 -1
- package/dist/schemas/activity.js.map +1 -1
- package/dist/schemas/agent.d.ts +2 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +4 -0
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/agents.d.ts +26 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +14 -1
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +5 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/devices.d.ts +1 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +1 -0
- package/dist/schemas/devices.js.map +1 -1
- package/dist/schemas/model-route.d.ts +27 -0
- package/dist/schemas/model-route.d.ts.map +1 -0
- package/dist/schemas/model-route.js +30 -0
- package/dist/schemas/model-route.js.map +1 -0
- package/dist/schemas/providers/usage.d.ts +1 -0
- package/dist/schemas/providers/usage.d.ts.map +1 -1
- package/dist/schemas/providers/usage.js +1 -0
- package/dist/schemas/providers/usage.js.map +1 -1
- package/dist/schemas/settings.d.ts +2 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +2 -2
- package/dist/schemas/settings.js.map +1 -1
- package/dist/state/definition.d.ts +8 -8
- package/package.json +5 -5
- package/src/contracts/agent.contract.ts +12 -0
- package/src/contracts/agents.contract.ts +13 -0
- package/src/events/transcript.ts +1 -1
- package/src/index.ts +2 -0
- package/src/models/model-offer.test.ts +88 -0
- package/src/models/model-offer.ts +151 -0
- package/src/models/model-roles.ts +9 -0
- package/src/schemas/activity.ts +3 -1
- package/src/schemas/agent.ts +7 -0
- package/src/schemas/agents.ts +25 -1
- package/src/schemas/devices.ts +4 -0
- package/src/schemas/model-route.ts +43 -0
- package/src/schemas/providers/usage.ts +4 -0
- package/src/schemas/settings.ts +6 -2
|
@@ -3,6 +3,7 @@ import { AgentCommandsQuerySchema, AgentCommandsSchema } from "../events/cards.j
|
|
|
3
3
|
import { AttachFrameSchema } from "../events/agent-events.js";
|
|
4
4
|
import { AgentTurnSchema, AttachTurnSchema, StartedTurnSchema } from "../schemas/agent.js";
|
|
5
5
|
import { RewindResultSchema, RewindTurnSchema } from "../schemas/history.js";
|
|
6
|
+
import { ModelRouteAskSchema, ModelRouteSchema } from "../schemas/model-route.js";
|
|
6
7
|
import { AgentReplySchema, ProviderRefusalsSchema, ResumeTurnSchema, SteerSchema, StopTurnSchema } from "../schemas/providers/plan-limits.js";
|
|
7
8
|
import { OkSchema } from "../schemas/shared.js";
|
|
8
9
|
|
|
@@ -91,6 +92,17 @@ export const agentContract = {
|
|
|
91
92
|
})
|
|
92
93
|
.input(AgentCommandsQuerySchema)
|
|
93
94
|
.output(AgentCommandsSchema),
|
|
95
|
+
// Never throws: no offer, no model, or a deadline are all "nothing chosen" with a reason; the composer is waiting.
|
|
96
|
+
routeModel: oc
|
|
97
|
+
.route({
|
|
98
|
+
method: "POST",
|
|
99
|
+
path: "/agent/route-model",
|
|
100
|
+
summary: "Choose the model a new chat runs on",
|
|
101
|
+
description:
|
|
102
|
+
"Reads a new chat's opening message and picks the model, effort and account that conversation should run on, from what is connected and still has allowance left. Asked once per chat, on the message actually sent, and never again: every turn after it runs on the pick the chat is wearing, which you are free to change. Answers with nothing, and a reason, whenever it cannot choose — a chat is never held up by this.",
|
|
103
|
+
})
|
|
104
|
+
.input(ModelRouteAskSchema)
|
|
105
|
+
.output(ModelRouteSchema),
|
|
94
106
|
refusals: oc
|
|
95
107
|
.route({
|
|
96
108
|
method: "GET",
|
|
@@ -2,6 +2,7 @@ import { oc } from "@orpc/contract";
|
|
|
2
2
|
import { AgentTranscriptSchema } from "../events/transcript.js";
|
|
3
3
|
import {
|
|
4
4
|
AgentArchiveSchema,
|
|
5
|
+
AgentAssignSchema,
|
|
5
6
|
AgentAutoLandSchema,
|
|
6
7
|
AgentFileDiffQuerySchema,
|
|
7
8
|
AgentIdSchema,
|
|
@@ -226,6 +227,18 @@ export const agentsContract = {
|
|
|
226
227
|
})
|
|
227
228
|
.input(AgentIdSchema)
|
|
228
229
|
.output(AgentSummarySchema),
|
|
230
|
+
// Sets `AgentSummarySchema.owner`. Who may: the current owner (handing over), a maintainer or the sandbox owner
|
|
231
|
+
// (taking over), and anyone at the driving tier when nobody owns it yet (claiming).
|
|
232
|
+
assign: oc
|
|
233
|
+
.route({
|
|
234
|
+
method: "POST",
|
|
235
|
+
path: "/agents/{id}/assign",
|
|
236
|
+
summary: "Make a member answerable for this conversation",
|
|
237
|
+
description:
|
|
238
|
+
"Hands a conversation to a member: its owner is who answers its questions and who a reviewer asks about its work. Its owner may hand it to anyone; a maintainer may reassign any conversation; one nobody owns may be claimed by anyone allowed to drive agents. Refused for an address that is not a member's. Nothing about the conversation's own work changes.",
|
|
239
|
+
})
|
|
240
|
+
.input(AgentAssignSchema)
|
|
241
|
+
.output(AgentSummarySchema),
|
|
229
242
|
// Floored at viewer (auth/role-floor.ts), unlike every other write here: marking a conversation is expression, not
|
|
230
243
|
// operating authority.
|
|
231
244
|
react: oc
|
package/src/events/transcript.ts
CHANGED
|
@@ -213,7 +213,7 @@ export const TranscriptRowSchema = z.object({
|
|
|
213
213
|
.describe("A one-press follow-up this notice offers, by name. The chat decides what it does and whether it still applies."),
|
|
214
214
|
// An unfinished wait this notice describes, by name; whether it's still running is live state, not stored here.
|
|
215
215
|
noticeWait: z
|
|
216
|
-
.enum(["credentialRenewal", "personaRoute", "watch"])
|
|
216
|
+
.enum(["credentialRenewal", "personaRoute", "modelRoute", "watch"])
|
|
217
217
|
.optional()
|
|
218
218
|
.describe("The wait this notice describes, by name, so a reader can say whether it is still on."),
|
|
219
219
|
// Which one, for a wait whose kind can have several in flight at once; without it two armed watches settle together.
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,7 @@ export * from "./protocol/container-requirements.js";
|
|
|
132
132
|
export * from "./ids/hostnames.js";
|
|
133
133
|
export * from "./policy/overlay-lint.js";
|
|
134
134
|
export * from "./models/model-order.js";
|
|
135
|
+
export * from "./models/model-offer.js";
|
|
135
136
|
export * from "./models/model-pins.js";
|
|
136
137
|
export * from "./models/model-roles.js";
|
|
137
138
|
export * from "./models/plan-pools.js";
|
|
@@ -169,6 +170,7 @@ export * from "./schemas/logs.js";
|
|
|
169
170
|
export * from "./schemas/loops.js";
|
|
170
171
|
export * from "./schemas/maintenance.js";
|
|
171
172
|
export * from "./schemas/marketplace.js";
|
|
173
|
+
export * from "./schemas/model-route.js";
|
|
172
174
|
export * from "./schemas/panels.js";
|
|
173
175
|
export * from "./schemas/personas.js";
|
|
174
176
|
export * from "./schemas/providers/plan-limits.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { type ModelOffer, offerLines, parseModelPick, renewsInWords } from "./model-offer.js";
|
|
3
|
+
|
|
4
|
+
/* THE OFFER the Auto judge chooses from, and the reading of what it replies. Pure both ways: no catalog, no account. */
|
|
5
|
+
|
|
6
|
+
const NOW = 1_700_000_000_000;
|
|
7
|
+
// Epoch SECONDS, as every reset instant on this wire; two hours past `NOW`.
|
|
8
|
+
const IN_TWO_HOURS = Math.floor(NOW / 1000) + 2 * 3600;
|
|
9
|
+
|
|
10
|
+
const OFFER: ModelOffer = {
|
|
11
|
+
models: [
|
|
12
|
+
{ provider: "claude", model: "claude-opus-5", label: "Opus 5", efforts: ["low", "medium", "high", "max"], note: "Deep reasoning." },
|
|
13
|
+
{ provider: "claude", model: "claude-haiku-4-5", label: "Haiku 4.5", efforts: ["low", "medium"] },
|
|
14
|
+
{ provider: "codex", model: "gpt-5", label: "GPT-5", efforts: [] },
|
|
15
|
+
],
|
|
16
|
+
accounts: {
|
|
17
|
+
claude: [
|
|
18
|
+
{ id: "work", label: "work@studio", windows: [{ short: "5h", left: 38, resetsAt: IN_TWO_HOURS }, { short: "wk", left: 61 }] },
|
|
19
|
+
{ id: "personal", windows: [] },
|
|
20
|
+
],
|
|
21
|
+
codex: [],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
test("a model line carries what it is, what it is for, and the efforts it takes", () => {
|
|
26
|
+
const lines = offerLines(OFFER, NOW);
|
|
27
|
+
expect(lines).toContain("- claude:claude-opus-5 — Opus 5. Deep reasoning. Effort: low, medium, high, max.");
|
|
28
|
+
// No note, so nothing is invented to fill the gap.
|
|
29
|
+
expect(lines).toContain("- claude:claude-haiku-4-5 — Haiku 4.5. Effort: low, medium.");
|
|
30
|
+
// A model that takes no effort setting says nothing about effort rather than offering an empty ladder.
|
|
31
|
+
expect(lines).toContain("- codex:gpt-5 — GPT-5.");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("an account line says what is LEFT, names its pool and when it renews", () => {
|
|
35
|
+
const lines = offerLines(OFFER, NOW);
|
|
36
|
+
expect(lines).toContain(" - work (work@studio) — 5h: 38% left (renews in about 2h), wk: 61% left");
|
|
37
|
+
// Never measured is its own answer: reading it as empty would bench an account that may be entirely free.
|
|
38
|
+
expect(lines).toContain(" - personal — allowance never measured");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a provider with no named account still offers its models, and says why no account is named", () => {
|
|
42
|
+
expect(offerLines(OFFER, NOW)).toContain("- codex: no named account; this provider resolves its own.");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("resets are phrased relatively, and coarsely at every scale", () => {
|
|
46
|
+
const seconds = Math.floor(NOW / 1000);
|
|
47
|
+
expect(renewsInWords(seconds + 30, NOW)).toBe("any moment");
|
|
48
|
+
expect(renewsInWords(seconds + 25 * 60, NOW)).toBe("in about 25 min");
|
|
49
|
+
expect(renewsInWords(seconds + 5 * 3600, NOW)).toBe("in about 5h");
|
|
50
|
+
expect(renewsInWords(seconds + 4 * 86_400, NOW)).toBe("in about 4 days");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the three keyed lines are read back against the offer", () => {
|
|
54
|
+
expect(parseModelPick("model: claude:claude-opus-5\neffort: high\naccount: work", OFFER).pick).toEqual({
|
|
55
|
+
provider: "claude",
|
|
56
|
+
model: "claude-opus-5",
|
|
57
|
+
effort: "high",
|
|
58
|
+
account: "work",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a reply is read through the wrappers a model reaches for anyway", () => {
|
|
63
|
+
const wrapped = "```\n- model: `claude:claude-haiku-4-5`\n- Effort: LOW\n```";
|
|
64
|
+
expect(parseModelPick(wrapped, OFFER).pick).toEqual({ provider: "claude", model: "claude-haiku-4-5", effort: "low" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the first answer wins: a later line is commentary, not a second choice", () => {
|
|
68
|
+
const twice = "model: claude:claude-haiku-4-5\nmodel: claude:claude-opus-5";
|
|
69
|
+
expect(parseModelPick(twice, OFFER).pick?.model).toBe("claude-haiku-4-5");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a model that is not on the list yields no pick at all, carrying what it said", () => {
|
|
73
|
+
// The load-bearing choice: the caller steps to the next rung rather than running an id no provider has.
|
|
74
|
+
expect(parseModelPick("model: claude:claude-sonnet-9", OFFER)).toEqual({ pick: undefined, token: "claude:claude-sonnet-9" });
|
|
75
|
+
expect(parseModelPick("Opus, obviously", OFFER)).toEqual({ pick: undefined, token: "" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("an unrecognised effort or account is dropped, and the model it came with is kept", () => {
|
|
79
|
+
// Refinements, not the choice: the turn's own defaults answer for them, and losing a whole rung over one would
|
|
80
|
+
// spend another reading to learn nothing.
|
|
81
|
+
const offEffort = parseModelPick("model: claude:claude-haiku-4-5\neffort: max\naccount: nobody", OFFER);
|
|
82
|
+
expect(offEffort.pick).toEqual({ provider: "claude", model: "claude-haiku-4-5" });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an account of another provider is not honoured under this one", () => {
|
|
86
|
+
// `work` is a Claude account; naming it under codex would spend a credential that provider has no idea about.
|
|
87
|
+
expect(parseModelPick("model: codex:gpt-5\naccount: work", OFFER).pick).toEqual({ provider: "codex", model: "gpt-5" });
|
|
88
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { modelPinKey } from "./model-pins.js";
|
|
2
|
+
import type { AgentProvider } from "../schemas/agent.js";
|
|
3
|
+
import type { ModelPick } from "../schemas/model-route.js";
|
|
4
|
+
|
|
5
|
+
// What the Auto judge is allowed to choose from, and how its reply is read back. Pure: the daemon gathers the facts
|
|
6
|
+
// (catalogs, connected accounts, headroom readings) and this turns them into the lines the model sees and the
|
|
7
|
+
// validator that reads its answer. Nothing outside the offer can be chosen, so a hallucinated model id is caught here
|
|
8
|
+
// rather than at the provider.
|
|
9
|
+
|
|
10
|
+
export interface OfferedWindow {
|
|
11
|
+
// Narrow token for the pool's length, e.g. "5h", "wk"; absent when nothing names how long the window runs.
|
|
12
|
+
readonly short?: string;
|
|
13
|
+
readonly label?: string;
|
|
14
|
+
// 0-100 REMAINING, the complement of the reading's utilization: the judge is asked about what is left, not spent.
|
|
15
|
+
readonly left: number;
|
|
16
|
+
// Epoch seconds, as every reset instant on this wire.
|
|
17
|
+
readonly resetsAt?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface OfferedAccount {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
// The owner's own name for it where there is one; the id is what must be replied with either way.
|
|
23
|
+
readonly label?: string;
|
|
24
|
+
// Empty means never measured, which is not the same as measured full and must not read as it.
|
|
25
|
+
readonly windows: readonly OfferedWindow[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OfferedModel {
|
|
29
|
+
readonly provider: AgentProvider;
|
|
30
|
+
readonly model: string;
|
|
31
|
+
readonly label: string;
|
|
32
|
+
// Weakest first, as the catalog row published them; empty means this model takes no effort setting.
|
|
33
|
+
readonly efforts: readonly string[];
|
|
34
|
+
// One short clause about what the model is for, where the catalog carries one.
|
|
35
|
+
readonly note?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ModelOffer {
|
|
39
|
+
// Already filtered: a model whose every account is at cap never reaches here.
|
|
40
|
+
readonly models: readonly OfferedModel[];
|
|
41
|
+
// Accounts that can pay, per provider id. A provider with none still offers its models: an unnamed account is a
|
|
42
|
+
// real state (a plain key, an endpoint), and the turn resolves one itself.
|
|
43
|
+
readonly accounts: Readonly<Record<string, readonly OfferedAccount[]>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Relative phrasing of a reset, since an absolute instant would need the reader's timezone; deliberately coarse at
|
|
47
|
+
// every scale. `reopensAt` is epoch seconds, `now` epoch ms.
|
|
48
|
+
export const renewsInWords = (reopensAt: number, now: number): string => {
|
|
49
|
+
const seconds = reopensAt - Math.floor(now / 1000);
|
|
50
|
+
if (seconds <= 60) {
|
|
51
|
+
return `any moment`;
|
|
52
|
+
}
|
|
53
|
+
if (seconds < 60 * 60) {
|
|
54
|
+
return `in about ${Math.round(seconds / 60)} min`;
|
|
55
|
+
}
|
|
56
|
+
if (seconds < 36 * 60 * 60) {
|
|
57
|
+
return `in about ${Math.round(seconds / 3600)}h`;
|
|
58
|
+
}
|
|
59
|
+
return `in about ${Math.round(seconds / 86_400)} days`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const windowWords = (window: OfferedWindow, now: number): string => {
|
|
63
|
+
const name = window.short ?? window.label ?? `allowance`;
|
|
64
|
+
const renews = window.resetsAt === undefined ? `` : ` (renews ${renewsInWords(window.resetsAt, now)})`;
|
|
65
|
+
return `${name}: ${Math.round(window.left)}% left${renews}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// An account with no reading says so rather than being left off: "not measured" is a fact the judge should weigh,
|
|
69
|
+
// and an account silently missing from the list would read as one that cannot pay.
|
|
70
|
+
const accountLine = (account: OfferedAccount, now: number): string => {
|
|
71
|
+
const name = account.label === undefined || account.label === account.id ? account.id : `${account.id} (${account.label})`;
|
|
72
|
+
const state = account.windows.length === 0 ? `allowance never measured` : account.windows.map((window) => windowWords(window, now)).join(`, `);
|
|
73
|
+
return `- ${name} — ${state}`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const modelLine = (model: OfferedModel): string => {
|
|
77
|
+
const efforts = model.efforts.length === 0 ? `` : ` Effort: ${model.efforts.join(`, `)}.`;
|
|
78
|
+
const note = model.note === undefined ? `` : ` ${model.note.trim().replace(/\.$/u, ``)}.`;
|
|
79
|
+
return `- ${modelPinKey(model)} — ${model.label}.${note}${efforts}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// The two blocks the prompt shows: what may be run, and who can pay for it. Providers keep the offer's own order, so a
|
|
83
|
+
// reply can be compared against the list a person would read.
|
|
84
|
+
export const offerLines = (offer: ModelOffer, now: number): readonly string[] => {
|
|
85
|
+
const providers = [...new Set(offer.models.map((model) => model.provider))];
|
|
86
|
+
return [
|
|
87
|
+
`Models you may choose:`,
|
|
88
|
+
...offer.models.map(modelLine),
|
|
89
|
+
``,
|
|
90
|
+
`Accounts that can pay, and how much allowance each has left:`,
|
|
91
|
+
...providers.flatMap((provider) => {
|
|
92
|
+
const accounts = offer.accounts[provider] ?? [];
|
|
93
|
+
return accounts.length === 0
|
|
94
|
+
? [`- ${provider}: no named account; this provider resolves its own.`]
|
|
95
|
+
: [`- ${provider}:`, ...accounts.map((account) => ` ${accountLine(account, now)}`)];
|
|
96
|
+
}),
|
|
97
|
+
];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Wrapper words a model reaches for anyway: a fence, a bullet, quotes, a trailing period.
|
|
101
|
+
const FENCE_LINE = /^```/u;
|
|
102
|
+
const BULLET_LINE = /^[-*•]\s+/u;
|
|
103
|
+
const FIELD = /^(model|effort|account)\s*:\s*(.+)$/iu;
|
|
104
|
+
|
|
105
|
+
const bare = (value: string): string => value.trim().replace(/^[`'"]+/u, ``).replace(/[.`'"]+$/u, ``).trim();
|
|
106
|
+
|
|
107
|
+
// The reply's `key: value` lines, lowercased keys, bullets and fences stripped. First wins: a model that answers twice
|
|
108
|
+
// meant its first answer, and a later line is commentary on it.
|
|
109
|
+
const fieldsOf = (reply: string): ReadonlyMap<string, string> => {
|
|
110
|
+
const fields = new Map<string, string>();
|
|
111
|
+
for (const line of reply.split(`\n`)) {
|
|
112
|
+
const trimmed = line.trim().replace(BULLET_LINE, ``);
|
|
113
|
+
const found = FENCE_LINE.test(trimmed) ? null : FIELD.exec(trimmed);
|
|
114
|
+
const key = found?.[1]?.toLowerCase();
|
|
115
|
+
if (key !== undefined && found?.[2] !== undefined && !fields.has(key)) {
|
|
116
|
+
fields.set(key, bare(found[2]));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return fields;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export interface ParsedPick {
|
|
123
|
+
readonly pick: ModelPick | undefined;
|
|
124
|
+
// The reply's literal model line, for the sentence explaining why a rung was stepped over.
|
|
125
|
+
readonly token: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Reads the keyed reply (`model:` / `effort:` / `account:`) against the offer. The MODEL is the load-bearing choice:
|
|
129
|
+
// one that names nothing offered yields no pick, and the caller steps to the next rung rather than running an id no
|
|
130
|
+
// provider has. An unrecognised effort or account is dropped instead, keeping the model it came with — those are
|
|
131
|
+
// refinements, and the turn's own defaults answer for them correctly.
|
|
132
|
+
export const parseModelPick = (reply: string, offer: ModelOffer): ParsedPick => {
|
|
133
|
+
const fields = fieldsOf(reply);
|
|
134
|
+
const token = fields.get(`model`) ?? ``;
|
|
135
|
+
const chosen = offer.models.find((model) => modelPinKey(model).toLowerCase() === token.toLowerCase());
|
|
136
|
+
if (chosen === undefined) {
|
|
137
|
+
return { pick: undefined, token };
|
|
138
|
+
}
|
|
139
|
+
const effort = fields.get(`effort`);
|
|
140
|
+
const account = fields.get(`account`);
|
|
141
|
+
const named = (offer.accounts[chosen.provider] ?? []).find((entry) => entry.id.toLowerCase() === account?.toLowerCase());
|
|
142
|
+
return {
|
|
143
|
+
pick: {
|
|
144
|
+
provider: chosen.provider,
|
|
145
|
+
model: chosen.model,
|
|
146
|
+
...(effort !== undefined && chosen.efforts.includes(effort.toLowerCase()) ? { effort: effort.toLowerCase() } : {}),
|
|
147
|
+
...(named === undefined ? {} : { account: named.id }),
|
|
148
|
+
},
|
|
149
|
+
token,
|
|
150
|
+
};
|
|
151
|
+
};
|
|
@@ -65,6 +65,15 @@ export const MODEL_ROLES = [
|
|
|
65
65
|
kind: "helper",
|
|
66
66
|
icon: "users",
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
// Picks one offered `provider:model` line, an effort and an account from a fixed list; a classification, not
|
|
70
|
+
// free text. Prompt carries live account headroom, so it must not be a model whose own allowance it is reading.
|
|
71
|
+
id: "model-router",
|
|
72
|
+
label: "Auto model choice",
|
|
73
|
+
blurb: "Which model reads a new chat's first message and picks the model, effort and account it runs on.",
|
|
74
|
+
kind: "helper",
|
|
75
|
+
icon: "sparkles",
|
|
76
|
+
},
|
|
68
77
|
{
|
|
69
78
|
id: "pipeline-fix",
|
|
70
79
|
label: "Pipeline fixes",
|
package/src/schemas/activity.ts
CHANGED
|
@@ -33,7 +33,9 @@ export const ActivityEventSchema = z.object({
|
|
|
33
33
|
actor: z
|
|
34
34
|
.string()
|
|
35
35
|
.optional()
|
|
36
|
-
.describe(
|
|
36
|
+
.describe(
|
|
37
|
+
"Who asked for the turn, as the sandbox verified it: a member's email, token:<label> for a program's control token, or agent:<conversation id> for a parent conversation's child. Absent for a wake nothing asked for.",
|
|
38
|
+
),
|
|
37
39
|
// Full message text (inbound) or sent payload content (outbound).
|
|
38
40
|
content: z.string().optional().describe("The message, in full, whichever direction it went."),
|
|
39
41
|
// HTTP method and endpoint path of an outgoing call; credentials ride headers, never the URL.
|
package/src/schemas/agent.ts
CHANGED
|
@@ -262,6 +262,13 @@ export const AgentTurnSchema = z
|
|
|
262
262
|
),
|
|
263
263
|
// Vetoes automatic tier selection for this turn; the judge still runs and records its verdict, but nothing is
|
|
264
264
|
// substituted.
|
|
265
|
+
// Set by the composer on the one turn whose model the Auto judge chose; the daemon only records it.
|
|
266
|
+
autoPicked: z
|
|
267
|
+
.boolean()
|
|
268
|
+
.optional()
|
|
269
|
+
.describe(
|
|
270
|
+
"Whether this turn's model was chosen for you by reading the conversation's opening message, rather than picked by hand. Recorded so the choice can be judged later against what you did next.",
|
|
271
|
+
),
|
|
265
272
|
tierHold: z
|
|
266
273
|
.boolean()
|
|
267
274
|
.optional()
|
package/src/schemas/agents.ts
CHANGED
|
@@ -162,6 +162,17 @@ export const ReactorSchema = z.object({
|
|
|
162
162
|
at: z.number().describe("When they marked it, in milliseconds."),
|
|
163
163
|
});
|
|
164
164
|
export type Reactor = z.infer<typeof ReactorSchema>;
|
|
165
|
+
// The member answerable for a conversation: whose questions its cards are, who a reviewer asks about its work. Derived
|
|
166
|
+
// once from the starter, moved only by `agents.assign`; never a program, which is what `startedBy` is for.
|
|
167
|
+
export const SessionOwnerSchema = z.object({
|
|
168
|
+
email: z.string().describe("Who answers for this conversation, as the sandbox verified them."),
|
|
169
|
+
name: z
|
|
170
|
+
.string()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe("What to call them, when the sign-in that made them its owner carried a name. Absent leaves the address to stand for them."),
|
|
173
|
+
since: z.number().describe("When they became its owner, in milliseconds."),
|
|
174
|
+
});
|
|
175
|
+
export type SessionOwner = z.infer<typeof SessionOwnerSchema>;
|
|
165
176
|
// Grouped by emoji on the wire, not as a flat list of presses: every surface draws one chip per emoji, and grouping
|
|
166
177
|
// here is what stops three of them each grouping it differently.
|
|
167
178
|
export const AgentReactionSchema = z.object({
|
|
@@ -291,7 +302,14 @@ export const AgentSummarySchema = z.object({
|
|
|
291
302
|
startedBy: z
|
|
292
303
|
.string()
|
|
293
304
|
.optional()
|
|
294
|
-
.describe(
|
|
305
|
+
.describe(
|
|
306
|
+
"Who asked for the first turn, as the sandbox verified it: a member's email, token:<label> for a program's control token, or agent:<conversation id> for a child another conversation spawned. Absent when nothing was verified (a wake, a loopback caller).",
|
|
307
|
+
),
|
|
308
|
+
// Responsibility, beside provenance: the starter says who asked, this says who answers for it now. Absent means
|
|
309
|
+
// nobody has claimed it, which is what a program's or an automation's conversation is until somebody does.
|
|
310
|
+
owner: SessionOwnerSchema.optional().describe(
|
|
311
|
+
"The member answerable for this conversation: set from whoever started it, inherited from the parent by a spawned child, moved by handing it over. Absent means nobody has claimed it yet.",
|
|
312
|
+
),
|
|
295
313
|
// Recorded once on the fork's first turn and never cleared; rides the summary so the link survives closing and
|
|
296
314
|
// reopening either tab.
|
|
297
315
|
forkedFrom: ForkedFromSchema.optional().describe(
|
|
@@ -564,6 +582,12 @@ export const AgentRenameSchema = z.object({
|
|
|
564
582
|
id: z.string().min(1).describe("Which conversation."),
|
|
565
583
|
title: z.string().trim().min(1).max(80).describe("What to call it from now on."),
|
|
566
584
|
});
|
|
585
|
+
// `to` is an address, not a name: authorization and membership both read emails, and a name is not unique.
|
|
586
|
+
export const AgentAssignSchema = z.object({
|
|
587
|
+
id: z.string().min(1).describe("Which conversation."),
|
|
588
|
+
to: z.string().trim().toLowerCase().email().describe("Who should answer for it from now on, by the address they sign in with. Must be the sandbox owner or a member."),
|
|
589
|
+
});
|
|
590
|
+
export type AgentAssign = z.infer<typeof AgentAssignSchema>;
|
|
567
591
|
// `on` states the intent rather than flipping whatever is stored: a double press, a retried request and two windows
|
|
568
592
|
// racing must all settle the same way, which a toggle cannot promise.
|
|
569
593
|
export const AgentReactSchema = z.object({
|
package/src/schemas/devices.ts
CHANGED
|
@@ -580,6 +580,10 @@ export const hostHoldingPath = (devices: readonly Device[], slug: string | undef
|
|
|
580
580
|
// reports. Cheap: the sidebar badge reads this and must never fan out to a device.
|
|
581
581
|
export const SyncStatusSchema = z.object({
|
|
582
582
|
enrolled: z.boolean(),
|
|
583
|
+
// Whether any machine holds this sandbox's FILES, as opposed to only mirroring its ports — the difference
|
|
584
|
+
// between "a copy of this work exists elsewhere" and "it does not". Absent means the daemon cannot say, which
|
|
585
|
+
// readers must take as "do not claim there is no copy", the same way `available` is read.
|
|
586
|
+
syncing: z.boolean().optional(),
|
|
583
587
|
// Whether this sandbox can do desktop sync at all; absent means a daemon too old to say, so don't offer it.
|
|
584
588
|
available: z.boolean().optional(),
|
|
585
589
|
machines: z.array(DeviceReportSchema).optional(),
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { AgentProviderSchema } from "./agent.js";
|
|
3
|
+
|
|
4
|
+
// Asked once per chat, on the message it opens with; answers with the model, effort and account that whole
|
|
5
|
+
// conversation runs on. Distinct from prompt-complexity.ts, which judges a turn at a time and may only ever name a
|
|
6
|
+
// cheaper rung of the provider already picked: this one chooses freely, and only before anything has run.
|
|
7
|
+
|
|
8
|
+
export const ModelRouteAskSchema = z.object({
|
|
9
|
+
prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
|
|
10
|
+
paths: z
|
|
11
|
+
.array(z.string().min(1).max(500))
|
|
12
|
+
.max(50)
|
|
13
|
+
.default([])
|
|
14
|
+
.describe("Workspace paths the message names: uploads, @-mentions, the editor's own file. How much real code the work touches."),
|
|
15
|
+
editorContext: z.boolean().optional().describe("Whether the message carries a file and selection the user pointed at, so it is about real code."),
|
|
16
|
+
planMode: z.boolean().optional().describe("Whether the chat opens in plan mode, which is a request to think before acting."),
|
|
17
|
+
});
|
|
18
|
+
export type ModelRouteAsk = z.infer<typeof ModelRouteAskSchema>;
|
|
19
|
+
|
|
20
|
+
export const ModelPickSchema = z.object({
|
|
21
|
+
provider: AgentProviderSchema.describe("Which provider serves the conversation."),
|
|
22
|
+
model: z.string().min(1).describe("Which of its models."),
|
|
23
|
+
effort: z.string().optional().describe("How hard it should think, where the model offers a choice. Absent takes the model's own default."),
|
|
24
|
+
account: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Which connected account pays, by its daemon-minted id. Absent leaves it to whichever account has the most headroom."),
|
|
28
|
+
});
|
|
29
|
+
export type ModelPick = z.infer<typeof ModelPickSchema>;
|
|
30
|
+
|
|
31
|
+
export const ModelRouteSchema = z.object({
|
|
32
|
+
// Absent is a real, safe answer: the chat runs on whatever the picker remembered, and says why nothing moved.
|
|
33
|
+
pick: ModelPickSchema.optional().describe("What the conversation should run on, or absent when nothing could be chosen and the usual pick stands."),
|
|
34
|
+
reason: z.string().describe("Why, in the one line a chat can show. Present whether or not a model was named."),
|
|
35
|
+
// Absent means nothing was spent: the role was unset, every rung refused, or the deadline passed first.
|
|
36
|
+
judge: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe(
|
|
40
|
+
"Which model answered, as `provider:model`, so the chat can name what the reading cost. Absent when no model was reached at all.",
|
|
41
|
+
),
|
|
42
|
+
});
|
|
43
|
+
export type ModelRoute = z.infer<typeof ModelRouteSchema>;
|
|
@@ -102,6 +102,10 @@ export const UsageTurnSchema = z.object({
|
|
|
102
102
|
tierCeiling: z.number().optional(),
|
|
103
103
|
// The turn carried AgentTurn.tierHold: the user vetoed a fast verdict. Absent means no veto.
|
|
104
104
|
tierDenied: z.boolean().optional(),
|
|
105
|
+
// This turn's model was chosen by the Auto judge reading the conversation's opening message, not picked by hand.
|
|
106
|
+
// Marked on that one turn only; a later row of the same conversation naming a different model is the user
|
|
107
|
+
// overruling it, which is the escalation rate this feature has to be able to answer for.
|
|
108
|
+
autoPicked: z.boolean().optional(),
|
|
105
109
|
});
|
|
106
110
|
export type UsageTurn = z.infer<typeof UsageTurnSchema>;
|
|
107
111
|
// Ledger grouped by day, provider, account, model, harness and conversation, one panel's worth of rows per active day.
|
package/src/schemas/settings.ts
CHANGED
|
@@ -317,11 +317,15 @@ export const SandboxSettingsSchema = z.object({
|
|
|
317
317
|
// off: the judge never runs.
|
|
318
318
|
// shadow (default): the judge scores every turn to the ledger; nothing is routed.
|
|
319
319
|
// on: a turn judged fast runs on the cheap rung, where the provider publishes one.
|
|
320
|
+
// judge: the deterministic scorer keeps writing shadow rows but routes nothing; the Auto picker row is offered
|
|
321
|
+
// instead, and a model reads a new chat's opening message once to choose what the whole conversation runs on.
|
|
322
|
+
// Exclusive with `on` because the two answer the same question at different moments, and a per-turn downgrade
|
|
323
|
+
// underneath a picked-by-model conversation would be overruling a choice already made about this chat.
|
|
320
324
|
autoTier: z
|
|
321
|
-
.enum(["off", "shadow", "on"])
|
|
325
|
+
.enum(["off", "shadow", "on", "judge"])
|
|
322
326
|
.default("shadow")
|
|
323
327
|
.describe(
|
|
324
|
-
"Whether an easy-looking turn may run on a cheaper model from the same provider. Three states rather than a switch, because the middle one is the only honest road to the third: it scores every turn and routes nothing, so the guess can become a measurement before it changes anything. It can only ever route down, so the worst case is one turn's quality rather than a bill nobody asked for.",
|
|
328
|
+
"Whether an easy-looking turn may run on a cheaper model from the same provider. Three states rather than a switch, because the middle one is the only honest road to the third: it scores every turn and routes nothing, so the guess can become a measurement before it changes anything. It can only ever route down, so the worst case is one turn's quality rather than a bill nobody asked for. The fourth, Auto, answers a different question: rather than downgrading turns one by one, it offers an Auto row in the model picker and has a model read a new chat's first message to choose what that whole conversation runs on.",
|
|
325
329
|
),
|
|
326
330
|
// `balanced` is what every verdict recorded before this setting existed was judged against, so shadow history stays
|
|
327
331
|
// comparable.
|