@intentic/sandbox-contract 1.294.0 โ 1.296.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 +309 -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 +17 -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 +16 -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 +357 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/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 +44 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +27 -0
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +8 -0
- package/dist/schemas/automations.d.ts.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/dist/text/emoji.d.ts +3 -0
- package/dist/text/emoji.d.ts.map +1 -0
- package/dist/text/emoji.js +11 -0
- package/dist/text/emoji.js.map +1 -0
- 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 +3 -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/agent.ts +7 -0
- package/src/schemas/agents.ts +42 -0
- package/src/schemas/model-route.ts +43 -0
- package/src/schemas/providers/usage.ts +4 -0
- package/src/schemas/settings.ts +6 -2
- package/src/text/emoji.test.ts +29 -0
- package/src/text/emoji.ts +24 -0
|
@@ -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/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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ForkedFromSchema } from "./agent.js";
|
|
4
4
|
import { LoopStateSchema } from "./loops.js";
|
|
5
|
+
import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
|
|
5
6
|
// A fleet agent is any conversation with a registry entry, keyed by conversationId. Isolated ones own a git worktree
|
|
6
7
|
// (branch agent/<id>); workspace conversations have none, but both share one status/activity/cost lifecycle.
|
|
7
8
|
|
|
@@ -153,6 +154,26 @@ export type LandedMessageDraft = z.infer<typeof LandedMessageDraftSchema>;
|
|
|
153
154
|
// offer the press that clears a refusal without knowing whose refusal it is.
|
|
154
155
|
export const LandConflictReasonSchema = z.enum(["workspace", "diverged", "binary"]);
|
|
155
156
|
export type LandConflictReason = z.infer<typeof LandConflictReasonSchema>;
|
|
157
|
+
// One person behind one mark. The instant rides along so the group can be ordered by who was first, which is also the
|
|
158
|
+
// order the names read in.
|
|
159
|
+
export const ReactorSchema = z.object({
|
|
160
|
+
email: z.string().describe("Who it was, as the sandbox verified them."),
|
|
161
|
+
name: z.string().optional().describe("What to call them, when their sign-in carried a name. Absent leaves the address to stand for them."),
|
|
162
|
+
at: z.number().describe("When they marked it, in milliseconds."),
|
|
163
|
+
});
|
|
164
|
+
export type Reactor = z.infer<typeof ReactorSchema>;
|
|
165
|
+
// Grouped by emoji on the wire, not as a flat list of presses: every surface draws one chip per emoji, and grouping
|
|
166
|
+
// here is what stops three of them each grouping it differently.
|
|
167
|
+
export const AgentReactionSchema = z.object({
|
|
168
|
+
emoji: z.string().describe("The mark itself, one emoji, carried as the character rather than as a name that would need a table on both sides."),
|
|
169
|
+
by: z
|
|
170
|
+
.array(ReactorSchema)
|
|
171
|
+
.min(1)
|
|
172
|
+
.describe(
|
|
173
|
+
"Everyone wearing this mark, oldest first, each by name. Carried whole rather than as a count, because a chip reading 3 that cannot say whose is a number nobody can answer. Never empty: the last person taking theirs back takes the whole chip with it.",
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
export type AgentReaction = z.infer<typeof AgentReactionSchema>;
|
|
156
177
|
export const AgentSummarySchema = z.object({
|
|
157
178
|
id: z.string().describe("The conversation id, which is how every other call addresses it."),
|
|
158
179
|
sessionId: z.string().optional().describe("The provider session behind the last turn. It is retired whenever the model or account changes."),
|
|
@@ -252,6 +273,15 @@ export const AgentSummarySchema = z.object({
|
|
|
252
273
|
.describe(
|
|
253
274
|
"A collaborator has asked a maintainer to merge this work. Cleared by whichever merge or discard answers it. Absent means nobody is waiting.",
|
|
254
275
|
),
|
|
276
|
+
// Beside `landRequested` because both are marks people left on the card rather than anything the agent did. Ordered
|
|
277
|
+
// by when each emoji was first used, so a chip never jumps out from under the cursor when a second person presses
|
|
278
|
+
// the same one.
|
|
279
|
+
reactions: z
|
|
280
|
+
.array(AgentReactionSchema)
|
|
281
|
+
.optional()
|
|
282
|
+
.describe(
|
|
283
|
+
"What people have marked this conversation with, one entry per emoji, in the order the emoji were first used. Absent means nobody has marked it, which is most conversations.",
|
|
284
|
+
),
|
|
255
285
|
// The card's provenance line when an outside message opened the conversation; absent means a person started it.
|
|
256
286
|
origin: AgentOriginSchema.optional().describe(
|
|
257
287
|
"Where the conversation came from when nobody typed it: a chat mention, a visitor's message, a webhook. Absent means a person started it.",
|
|
@@ -534,6 +564,18 @@ export const AgentRenameSchema = z.object({
|
|
|
534
564
|
id: z.string().min(1).describe("Which conversation."),
|
|
535
565
|
title: z.string().trim().min(1).max(80).describe("What to call it from now on."),
|
|
536
566
|
});
|
|
567
|
+
// `on` states the intent rather than flipping whatever is stored: a double press, a retried request and two windows
|
|
568
|
+
// racing must all settle the same way, which a toggle cannot promise.
|
|
569
|
+
export const AgentReactSchema = z.object({
|
|
570
|
+
id: z.string().min(1).describe("Which conversation."),
|
|
571
|
+
emoji: z
|
|
572
|
+
.string()
|
|
573
|
+
.trim()
|
|
574
|
+
.max(EMOJI_MAX_LENGTH)
|
|
575
|
+
.refine(isSingleEmoji, { message: "not a single emoji" })
|
|
576
|
+
.describe("The mark to leave, as the emoji character itself. Exactly one: a chip has room for one mark, and a press is one press."),
|
|
577
|
+
on: z.boolean().describe("Whether to add your mark or take it back. Saying what you want rather than flipping what is there, so pressing twice lands where pressing once did."),
|
|
578
|
+
});
|
|
537
579
|
// Bounded just above the handoff's per-message render cap, so a line too long to carry whole doesn't reach the agent
|
|
538
580
|
// truncated.
|
|
539
581
|
export const AgentPlaceSchema = z.object({
|
|
@@ -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.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isSingleEmoji } from "./emoji.js";
|
|
3
|
+
|
|
4
|
+
describe("isSingleEmoji", () => {
|
|
5
|
+
it("takes one emoji however many code points it is made of", () => {
|
|
6
|
+
expect(isSingleEmoji(`๐`)).toBe(true);
|
|
7
|
+
// A skin tone, a ZWJ family and a flag are 2, 7 and 2 code points that each draw as one mark.
|
|
8
|
+
expect(isSingleEmoji(`๐๐ฝ`)).toBe(true);
|
|
9
|
+
expect(isSingleEmoji(`๐จโ๐ฉโ๐งโ๐ฆ`)).toBe(true);
|
|
10
|
+
expect(isSingleEmoji(`๐ต๐ฑ`)).toBe(true);
|
|
11
|
+
expect(isSingleEmoji(`๐ด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ`)).toBe(true);
|
|
12
|
+
// A keycap leads with an ASCII digit, which is why the pictographic property alone would refuse it.
|
|
13
|
+
expect(isSingleEmoji(`1๏ธโฃ`)).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("refuses everything that is not exactly one mark", () => {
|
|
17
|
+
expect(isSingleEmoji(``)).toBe(false);
|
|
18
|
+
expect(isSingleEmoji(`๐๐`)).toBe(false);
|
|
19
|
+
expect(isSingleEmoji(`a`)).toBe(false);
|
|
20
|
+
expect(isSingleEmoji(`lgtm`)).toBe(false);
|
|
21
|
+
// The one a length check alone would let through: an emoji with a word stuck to it.
|
|
22
|
+
expect(isSingleEmoji(`๐!`)).toBe(false);
|
|
23
|
+
expect(isSingleEmoji(` `)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("refuses a string too long to be one grapheme without segmenting it", () => {
|
|
27
|
+
expect(isSingleEmoji(`๐`.repeat(40))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// What counts as one emoji, for anywhere a person picks one rather than types a sentence (a conversation's reactions).
|
|
2
|
+
// Shared so the field that accepts the press and the route that stores it cannot disagree about what was pressed.
|
|
3
|
+
|
|
4
|
+
// The unit is the grapheme, not the code point: a flag, a skin-toned wave and a seven-code-point family all render as
|
|
5
|
+
// one mark, and one mark is what a chip draws and a person presses.
|
|
6
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: `grapheme` });
|
|
7
|
+
|
|
8
|
+
// Two escapes ride alongside the pictographic property rather than under it, because neither is one: a keycap (1๏ธโฃ) is a
|
|
9
|
+
// plain ASCII digit plus U+20E3, and a country flag (๐ต๐ฑ) is a pair of regional indicators.
|
|
10
|
+
const PICTOGRAPHIC = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u{20E3}]/u;
|
|
11
|
+
|
|
12
|
+
// A single grapheme is long enough to matter โ a subdivision flag is 28 UTF-16 units โ so this bounds the string before
|
|
13
|
+
// segmenting it, and never stands in for the grapheme count itself.
|
|
14
|
+
export const EMOJI_MAX_LENGTH = 64;
|
|
15
|
+
|
|
16
|
+
// True for exactly one emoji grapheme. False for two of them, for a letter, and for an empty string: a reaction is one
|
|
17
|
+
// press, and a chip has room for one mark.
|
|
18
|
+
export const isSingleEmoji = (text: string): boolean => {
|
|
19
|
+
if (text.length === 0 || text.length > EMOJI_MAX_LENGTH || !PICTOGRAPHIC.test(text)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const segments = graphemes.segment(text)[Symbol.iterator]();
|
|
23
|
+
return segments.next().done !== true && segments.next().done === true;
|
|
24
|
+
};
|