@columenlabs/lumen-ai 0.1.186 → 0.1.188

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.
@@ -1,17 +1,8 @@
1
1
  /**
2
- * The assistant: one turn loop for any app, ported from a production assistant.
3
- *
4
- * A turn streams the model's words and runs its tool calls until it answers (at most `MAX_ROUNDS`
5
- * rounds). A write tool only drafts: a card the person saves or dismisses. Stop aborts the model
6
- * and any running tool at once and keeps what was said. Past `COMPACT_AT` tokens (or on
7
- * `/compact`) the history sent to the model becomes a short summary; the screen keeps every message.
8
- *
9
- * Hand off: an `escalate` tool hands one task to a higher tier with the same tools, in its own
10
- * section of the chat, and its memo goes back. Advice: Anthropic's advisor tool lets a higher tier
11
- * read the chat and send a plan (it runs nothing), at most `ADVICE_MAX` an answer (Claude only).
12
- *
13
- * Providers: a tier runs a Claude model (Anthropic's Messages API) or an OpenAI model (the
14
- * Responses API), each on its payer's key of that provider. A hand-off may cross providers.
2
+ * The assistant: what every chat shares, for any app. Its tiers, tasks and models, who pays and
3
+ * their caps, keys and the ChatGPT sign-in, tool groups and their access, the allowance, background
4
+ * answers (`complete`) and the plan each agent turn runs on (`agentTurn`). The chat itself runs on
5
+ * the agent (`../agent`, LumenAgent); chats saved by the old turn loop stay readable until purged.
15
6
  *
16
7
  * Spend: every call is priced at list price as it runs; past what its payer has left this month the
17
8
  * model must answer from what it has.
@@ -26,7 +17,7 @@ import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt
26
17
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
27
18
  import { DEFAULT_CAP, State, keyDocName, keyState, planDocName, waitText, } from './state.js';
28
19
  import { Docs } from './store.js';
29
- import { ToolError, rowCount, toolName, withDeadline, } from './tools.js';
20
+ import { toolName, withDeadline } from './tools.js';
30
21
  import { scrapeqlTools, webFetchTools } from './web.js';
31
22
  const DOLLARS = { one: 'dollar', many: 'dollars', usd: 1 };
32
23
  const round2 = (n) => Math.round(n * 100) / 100;
@@ -43,28 +34,11 @@ export class AssistantError extends Error {
43
34
  }
44
35
  }
45
36
  const MAX_ROUNDS = 8;
46
- const COMPACT_AT = 150_000;
47
- /** A tool result handed to the model is cut past this (about 4,000 tokens). */
48
- const RESULT_CHARS = 16_000;
49
37
  const MEMO_CHARS = 8000;
50
38
  /** The built-in tool that runs several reads at once, as one step. */
51
39
  const BATCH = 'batch';
52
- const BATCH_MAX = 25;
53
- /** What one batch returns at most, all its results together. */
54
- const BATCH_CHARS = 48_000;
55
40
  const ADVICE_MAX = 2;
56
41
  const ADVICE_TOKENS = 2048;
57
- const ADVISOR_BETA = 'advisor-tool-2026-03-01';
58
- /**
59
- * What later turns remember of the tools' results, so a follow-up ("list them all", "which have no
60
- * milk") answers from what was read instead of calling the same tools again. Each result is kept
61
- * as the model saw it (`RAN_CHARS`), at most `RAN_TURN` a turn as stored, and the whole chat sends
62
- * at most `RAN_CHAT`: newest first, a call repeated later kept only in its latest turn, and past
63
- * the budget an older result is named but dropped, so a long chat costs no more than a short one.
64
- */
65
- const RAN_CHARS = RESULT_CHARS;
66
- const RAN_TURN = 40_000;
67
- const RAN_CHAT = 40_000;
68
42
  const CHATS_LISTED = 200;
69
43
  const WHY_TEXT = {
70
44
  asked: 'you asked',
@@ -72,110 +46,6 @@ const WHY_TEXT = {
72
46
  stuck: 'stuck',
73
47
  check_failed: 'check failed',
74
48
  };
75
- /** JSON with object keys sorted, so the same input written in another order is the same call. */
76
- function stableJson(v) {
77
- return JSON.stringify(v, (_k, x) => x && typeof x === 'object' && !Array.isArray(x)
78
- ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
79
- : x);
80
- }
81
- /** A turn's tool results as stored: each cut to what the model saw, the turn to `RAN_TURN`. */
82
- function ranOf(runs) {
83
- let left = RAN_TURN;
84
- const ran = [];
85
- for (const r of runs) {
86
- if (left <= 0)
87
- break;
88
- const result = r.result.slice(0, Math.min(RAN_CHARS, left));
89
- left -= result.length;
90
- ran.push({ ...r, result: result.length < r.result.length ? `${result} …(cut)` : result });
91
- }
92
- return ran.length ? { ran } : {};
93
- }
94
- /**
95
- * The history as sent: each answer followed by what its tools returned, within `RAN_CHAT` for the
96
- * whole chat, newest first. A call made again later is kept only there; one past the budget is
97
- * named so the model knows to call it again. Chats stored before `ran` carry it in `content`.
98
- */
99
- function toolMemory(history) {
100
- let left = RAN_CHAT;
101
- const later = new Set();
102
- const out = [];
103
- for (let i = history.length - 1; i >= 0; i--) {
104
- const t = history[i];
105
- if (!t.ran?.length) {
106
- out.push({ role: t.role, content: t.content });
107
- continue;
108
- }
109
- const lines = t.ran.map((r) => {
110
- if (r.key && later.has(r.key))
111
- return `- ${r.tool} → (called again later; see there)`;
112
- if (r.key)
113
- later.add(r.key);
114
- if (left < 200)
115
- return `- ${r.tool} → (no longer kept; call it again if you need it)`;
116
- const kept = r.result.slice(0, left);
117
- left -= kept.length;
118
- return `- ${r.tool} → ${kept}${kept.length < r.result.length ? ' …(cut)' : ''}`;
119
- });
120
- out.push({
121
- role: t.role,
122
- content: `${t.content}\n\n[Tools I used and what they returned]\n${lines.join('\n')}`,
123
- });
124
- }
125
- return out.reverse();
126
- }
127
- const NO_USAGE = { calls: 0, in: 0, read: 0, out: 0, usd: 0, parts: [] };
128
- function addUsage(u, parts) {
129
- const out = { ...u, parts: u.parts.map((p) => ({ ...p })) };
130
- for (const p of parts) {
131
- if (!p.calls)
132
- continue;
133
- out.calls += p.calls;
134
- out.in += p.in;
135
- out.read += p.read;
136
- out.out += p.out;
137
- out.usd += p.usd;
138
- const prev = out.parts.find((x) => x.label === p.label);
139
- if (prev) {
140
- prev.calls += p.calls;
141
- prev.in += p.in;
142
- prev.read += p.read;
143
- prev.out += p.out;
144
- prev.usd += p.usd;
145
- }
146
- else
147
- out.parts.push({ ...p });
148
- }
149
- return out;
150
- }
151
- function withCacheMark(m) {
152
- const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : [...m.content];
153
- const last = blocks[blocks.length - 1];
154
- if (!last || last.type === 'thinking' || last.type === 'redacted_thinking')
155
- return m;
156
- blocks[blocks.length - 1] = { ...last, cache_control: { type: 'ephemeral' } };
157
- return { ...m, content: blocks };
158
- }
159
- /** A tool's result, or Stop's abort as soon as it comes. */
160
- function untilStopped(work, signal) {
161
- if (!signal)
162
- return work;
163
- signal.throwIfAborted();
164
- return new Promise((done, fail) => {
165
- const stop = () => fail(signal.reason);
166
- signal.addEventListener('abort', stop, { once: true });
167
- work.then(done, fail).finally(() => signal.removeEventListener('abort', stop));
168
- });
169
- }
170
- const newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll('-', '').slice(0, 20)}`;
171
- /** Every tool line in a chat, those in hand-off sections included. */
172
- function stepsOf(items) {
173
- return items.flatMap((i) => i.kind === 'step' ? [i] : i.kind === 'handoff' ? stepsOf(i.items) : []);
174
- }
175
- /** Every draft in a chat, those in hand-off sections included. */
176
- function draftsOf(items) {
177
- return items.flatMap((i) => i.kind === 'draft' ? [i] : i.kind === 'handoff' ? draftsOf(i.items) : []);
178
- }
179
49
  /** A short line of what an error was, never an upstream body. */
180
50
  function shownError(err) {
181
51
  if (err instanceof AssistantError)
@@ -222,15 +92,6 @@ function openaiStreamError(code, plan) {
222
92
  return 'OpenAI declined to answer this message.';
223
93
  return `OpenAI could not answer (${code || 'no reason given'}). It is logged; try again or pick another model.`;
224
94
  }
225
- /** A tool's schema without JSON Schema's `$schema` line, which zod writes and no provider needs. */
226
- const withoutMeta = (schema) => {
227
- const { $schema: _, ...rest } = schema ?? {};
228
- return rest;
229
- };
230
- /** Whether a response's output has words or a call to show. */
231
- const shows = (output) => output.some((b) => b.type === 'function_call' ||
232
- (b.type === 'message' && b.content.some((c) => c.type === 'output_text' && c.text)));
233
- const isApiError = (err) => err instanceof Anthropic.APIError || err instanceof OpenAI.APIError;
234
95
  /**
235
96
  * The tools of a Think turn as the rules describe them. Think calls several tools in one step on
236
97
  * its own, so the kit's batch tool is not offered and its line is left out (`batch`).
@@ -244,16 +105,6 @@ function openedSummary(t) {
244
105
  web: t.info.some((i) => i.group === 'web'),
245
106
  };
246
107
  }
247
- function summarize(fns) {
248
- const all = [...fns.values()];
249
- return {
250
- size: fns.size,
251
- find: all.some((f) => f.def.find),
252
- reads: all.some((f) => !f.draft && !f.write),
253
- batch: fns.has(BATCH),
254
- web: all.some((f) => f.group === 'web'),
255
- };
256
- }
257
108
  export class Assistant {
258
109
  o;
259
110
  name;
@@ -388,6 +239,13 @@ export class Assistant {
388
239
  return apiKey ? { apiKey, ...(paying.plans[provider] ? { plan: true } : {}) } : null;
389
240
  };
390
241
  const key = keyOf(main.model.provider) ?? { apiKey: paying.apiKey };
242
+ // A ChatGPT plan used up for now: the app's first Claude (its smallest) answers instead, on
243
+ // the same payer's Anthropic key, rather than the turn ending in the limit's error.
244
+ const claude = key.plan ? this.models.find((m) => m.provider === 'anthropic') : undefined;
245
+ const claudeKey = claude ? keyOf('anthropic') : null;
246
+ const fallback = claude && claudeKey
247
+ ? { model: claude, key: claudeKey, label: `${main.tier.label} · ${claude.label}` }
248
+ : undefined;
391
249
  const p = await this.state.person(caller.id);
392
250
  const policy = p.hand_off ?? 'next';
393
251
  const advisor = await this.advisorFor(caller, p.advice, main);
@@ -423,6 +281,7 @@ export class Assistant {
423
281
  ? `The person handed this message to ${handTo.label}: call escalate {tier: "${handTo.id}", why: "asked"} now, its task the message with every fact from this chat it needs, then answer from its memo.\n\n`
424
282
  : ''}${command}`,
425
283
  sources: await this.sourcesOf(caller, p),
284
+ ...(fallback ? { fallback } : {}),
426
285
  left: paying.left,
427
286
  maxSteps: MAX_ROUNDS,
428
287
  ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
@@ -846,9 +705,6 @@ export class Assistant {
846
705
  purge() {
847
706
  return this.o.store.purge('chat:', Date.now() - (this.o.keepDays ?? 30) * 86_400_000);
848
707
  }
849
- writeChat(caller, id, doc) {
850
- return this.state.docs.write(caller.id, `chat:${id}`, { ...doc, at: Date.now() }, doc.title);
851
- }
852
708
  // ---------------------------------------------------------------- tools
853
709
  /** Opens the tools the person allows: each group's, then the web's. */
854
710
  /** The tool groups (and the web) at the person's access, not opened yet. */
@@ -880,354 +736,7 @@ export class Assistant {
880
736
  }
881
737
  return sources;
882
738
  }
883
- async openTools(caller, p) {
884
- const opened = [];
885
- const sources = await this.sourcesOf(caller, p);
886
- const results = await Promise.allSettled(sources.map((s) => s.source()));
887
- results.forEach((r, i) => {
888
- const s = sources[i];
889
- if (r.status === 'fulfilled' && s)
890
- opened.push({ group: s.group, access: s.access, open: r.value });
891
- });
892
- const fns = new Map();
893
- for (const o of opened)
894
- for (const def of o.open.tools) {
895
- if (!def.read && o.access !== 'draft' && o.access !== 'write')
896
- continue;
897
- // Read + write on a group that offers no writes drafts.
898
- const write = !def.read && o.access === 'write' && !!this.groupById(o.group)?.writes;
899
- fns.set(toolName(o.group, def.name), {
900
- group: o.group,
901
- def,
902
- draft: !def.read && !write,
903
- write,
904
- open: o.open,
905
- });
906
- }
907
- return {
908
- fns,
909
- failed: results.filter((r) => r.status === 'rejected').length,
910
- close: () => Promise.all(opened.map((o) => o.open.close().catch(() => undefined))),
911
- };
912
- }
913
- toolsFor(fns) {
914
- const batch = this.batchTool(fns);
915
- return [
916
- ...[...fns.entries()].map(([name, f]) => ({
917
- name,
918
- description: `${f.draft ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. " : f.write ? 'WRITES AT ONCE: this call makes the change now; the person can undo it. ' : ''}${f.def.title ? `${f.def.title}. ` : ''}${f.def.description ?? ''}`.slice(0, 4000),
919
- input_schema: {
920
- type: 'object',
921
- ...withoutMeta(f.def.inputSchema),
922
- },
923
- })),
924
- ...(batch ? [batch] : []),
925
- ];
926
- }
927
- /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
928
- batchTool(fns) {
929
- if (fns.has(BATCH))
930
- return null;
931
- const reads = [...fns.entries()].filter(([, f]) => !f.draft && !f.write).map(([n]) => n);
932
- if (!reads.length)
933
- return null;
934
- return {
935
- name: BATCH,
936
- description: `Runs up to ${BATCH_MAX} read calls at once and returns each result, numbered in order. Use it to read several records (one tool with several inputs) or several tools that do not depend on each other, in one step instead of one call at a time. Reads only.`,
937
- input_schema: {
938
- type: 'object',
939
- properties: {
940
- calls: {
941
- type: 'array',
942
- minItems: 1,
943
- maxItems: BATCH_MAX,
944
- items: {
945
- type: 'object',
946
- properties: {
947
- tool: { type: 'string', enum: reads },
948
- input: { type: 'object', description: "The tool's own input." },
949
- },
950
- required: ['tool', 'input'],
951
- },
952
- },
953
- },
954
- required: ['calls'],
955
- },
956
- };
957
- }
958
739
  // ---------------------------------------------------------------- the turn
959
- /** One answer as server-sent events. Abort `signal` to stop it. */
960
- turnStream(caller, req, signal) {
961
- const enc = new TextEncoder();
962
- let ctl;
963
- let closed = false;
964
- const stream = new ReadableStream({
965
- start: (c) => {
966
- ctl = c;
967
- },
968
- cancel: () => {
969
- closed = true;
970
- },
971
- });
972
- const emit = (event, data) => {
973
- if (closed)
974
- return;
975
- try {
976
- ctl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
977
- }
978
- catch {
979
- closed = true;
980
- }
981
- };
982
- void this.turn(caller, req, emit, signal).finally(() => {
983
- if (closed)
984
- return;
985
- closed = true;
986
- try {
987
- ctl.close();
988
- }
989
- catch { }
990
- });
991
- return stream;
992
- }
993
- async turn(caller, req, emit, signal) {
994
- const t0 = performance.now();
995
- const message = req.message.trim();
996
- const chatId = req.chat_id ?? newId('ac');
997
- const prev = req.chat_id
998
- ? await this.state.docs.read(caller.id, `chat:${req.chat_id}`)
999
- : null;
1000
- if (req.chat_id && !prev) {
1001
- emit('error', { message: 'That chat is gone.', code: 'not_found' });
1002
- return;
1003
- }
1004
- const doc = prev ?? {
1005
- title: message.replace(/\s+/g, ' ').slice(0, 60),
1006
- items: [],
1007
- history: [],
1008
- };
1009
- const compactOnly = message === '/compact';
1010
- const asked = await this.expand(caller, message);
1011
- if (!compactOnly)
1012
- doc.items.push({
1013
- kind: 'user',
1014
- text: message,
1015
- at: Date.now(),
1016
- ...(req.context?.label ? { context: req.context.label } : {}),
1017
- ...(req.refs?.length ? { refs: req.refs } : {}),
1018
- ...(req.tier ? { tier: req.tier } : {}),
1019
- });
1020
- const start = doc.items.length;
1021
- emit('chat', { id: chatId, title: doc.title });
1022
- // `tier` on a message: a higher one takes it as a task in its own section (handTo below); the
1023
- // same or a lower one answers it as the main tier, for this message only.
1024
- const picked = await this.mainTier(caller);
1025
- const lower = req.tier && !this.above(picked).some((t) => t.id === req.tier)
1026
- ? this.tierById(req.tier)
1027
- : undefined;
1028
- const main = await this.session(caller, lower ?? picked);
1029
- const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
1030
- if (!paying) {
1031
- const code = wait ?? 'not_connected';
1032
- const text = waitText(code, main.model.provider, !!this.o.allowance);
1033
- doc.items.push({ kind: 'error', text, code });
1034
- await this.writeChat(caller, chatId, doc);
1035
- emit('error', { message: text, code });
1036
- emit('done', { id: chatId, usage: doc.usage ?? NO_USAGE });
1037
- return;
1038
- }
1039
- const p = await this.state.person(caller.id);
1040
- const policy = p.hand_off ?? 'next';
1041
- const advisor = await this.advisorFor(caller, p.advice, main);
1042
- const clients = this.clients(paying);
1043
- const live = [main.meter, ...(advisor ? [advisor.session.meter] : [])];
1044
- const before = doc.usage ?? NO_USAGE;
1045
- let context = before.context;
1046
- const spent = () => ({
1047
- ...addUsage(before, live.map((m) => m.part())),
1048
- ...(context ? { context } : {}),
1049
- });
1050
- let limited = false;
1051
- const over = () => {
1052
- if (live.reduce((a, m) => a + m.usd, 0) < paying.left)
1053
- return null;
1054
- limited = true;
1055
- return 'The spend cap for this month is reached: answer now from what you have, in a sentence or two. You can call no more tools.';
1056
- };
1057
- const handTo = req.tier ? this.above(main.tier).find((t) => t.id === req.tier) : undefined;
1058
- const tools = await this.openTools(caller, p);
1059
- const runs = [];
1060
- doc.writes ??= {};
1061
- const counts = {
1062
- steps: 0,
1063
- handoffs: 0,
1064
- advice: 0,
1065
- end: main.tier,
1066
- writes: doc.writes,
1067
- seen: new Map(),
1068
- };
1069
- let outcome = 'ok';
1070
- try {
1071
- const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
1072
- const kitRules = this.rules(main, policy, advisor, summarize(tools.fns), tools.failed);
1073
- const system = [
1074
- { type: 'text', text: appSystem },
1075
- { type: 'text', text: kitRules, cache_control: { type: 'ephemeral' } },
1076
- ];
1077
- const subSystem = [
1078
- { type: 'text', text: appSystem },
1079
- {
1080
- type: 'text',
1081
- text: this.rules(null, policy, null, summarize(tools.fns), tools.failed),
1082
- cache_control: { type: 'ephemeral' },
1083
- },
1084
- ];
1085
- if (compactOnly || (before.context ?? 0) > COMPACT_AT) {
1086
- const did = await this.compact(clients, main, doc, emit);
1087
- if (did)
1088
- context = undefined;
1089
- if (compactOnly) {
1090
- if (!did) {
1091
- const text = 'Nothing to compact yet.';
1092
- doc.items.push({ kind: 'text', text });
1093
- emit('text', { delta: text });
1094
- emit('text_end', {});
1095
- }
1096
- doc.usage = spent();
1097
- await this.writeChat(caller, chatId, doc);
1098
- await this.state.addSpend(caller.id, paying.payer, live.map((m) => m.part()));
1099
- emit('done', { id: chatId, usage: doc.usage });
1100
- return;
1101
- }
1102
- }
1103
- const told = await bearings({
1104
- timeZone: req.time_zone,
1105
- fallbackZone: this.o.timeZone,
1106
- lastAt: prev?.at,
1107
- app: this.o.app,
1108
- ...(this.o.bearings ? { extras: (b) => this.o.bearings?.(b, caller) } : {}),
1109
- });
1110
- const page = told +
1111
- this.cardNews(doc) +
1112
- (req.refs?.length
1113
- ? `The person points at these parts: they are what "this" means, and when the person asks for a change they are the only parts to change. Read anything else you need (the rest of the page, other records, your tools) for context, but leave it as it is unless they ask.\n${JSON.stringify(req.refs).slice(0, 16000)}\n\n`
1114
- : '') +
1115
- (req.context
1116
- ? `Page: ${req.context.label}${req.context.note === undefined ? '' : `\n${JSON.stringify(req.context.note).slice(0, 4000)}`}\n\n`
1117
- : '');
1118
- const lead = handTo
1119
- ? `The person handed this message to ${handTo.label}: call escalate {tier: "${handTo.id}", why: "asked"} now, its task the message with every fact from this chat it needs, then answer from its memo.\n\n`
1120
- : '';
1121
- const messages = [
1122
- ...toolMemory(doc.history),
1123
- { role: 'user', content: `${page}${lead}${asked}` },
1124
- ];
1125
- const escalateTool = this.escalateTool(main.tier);
1126
- let answer = await this.loop({
1127
- clients,
1128
- session: main,
1129
- system,
1130
- tools: [...this.toolsFor(tools.fns), ...(escalateTool ? [escalateTool] : [])],
1131
- messages,
1132
- sink: doc.items,
1133
- emit,
1134
- signal,
1135
- over,
1136
- ...(handTo && escalateTool ? { first: { type: 'tool', name: 'escalate' } } : {}),
1137
- ...(advisor ? { advisor } : {}),
1138
- context: (n) => {
1139
- context = n;
1140
- },
1141
- called: () => emit('usage', spent()),
1142
- tool: (use) => use.name === 'escalate'
1143
- ? this.handOff(use, {
1144
- caller,
1145
- clients,
1146
- message: asked,
1147
- handTo,
1148
- page,
1149
- policy,
1150
- main,
1151
- live,
1152
- sink: doc.items,
1153
- emit,
1154
- spent,
1155
- system: subSystem,
1156
- tools: tools.fns,
1157
- runs,
1158
- counts,
1159
- signal,
1160
- over,
1161
- })
1162
- : this.runTool(use, tools.fns, doc.items, emit, runs, counts, main, false, signal),
1163
- });
1164
- if (limited) {
1165
- doc.items.push({ kind: 'stopped', reason: 'cap' });
1166
- emit('stopped', { reason: 'cap' });
1167
- }
1168
- if (!answer) {
1169
- answer = 'I could not finish that. Try a narrower question.';
1170
- doc.items.push({ kind: 'text', text: answer });
1171
- emit('text', { delta: answer });
1172
- emit('text_end', {});
1173
- }
1174
- doc.history.push({ role: 'user', content: `${page}${asked}` });
1175
- doc.history.push({ role: 'assistant', content: answer, ...ranOf(runs) });
1176
- }
1177
- catch (err) {
1178
- if (signal?.aborted) {
1179
- outcome = 'stopped';
1180
- doc.items.push({ kind: 'stopped' });
1181
- const said = doc.items
1182
- .slice(start)
1183
- .filter((i) => i.kind === 'text')
1184
- .map((i) => i.text)
1185
- .join('\n\n');
1186
- if (!compactOnly) {
1187
- doc.history.push({ role: 'user', content: asked });
1188
- doc.history.push({ role: 'assistant', content: said || '(stopped before answering)' });
1189
- }
1190
- }
1191
- else {
1192
- outcome = 'error';
1193
- const shown = shownError(err);
1194
- if (!(err instanceof AssistantError) && !isApiError(err))
1195
- console.error(JSON.stringify({
1196
- event: 'assistant_error',
1197
- error: String(err?.stack ?? err).slice(0, 2000),
1198
- }));
1199
- doc.items.push({ kind: 'error', text: shown.message, code: shown.code });
1200
- emit('error', shown);
1201
- }
1202
- }
1203
- finally {
1204
- await tools.close();
1205
- }
1206
- doc.usage = spent();
1207
- await this.writeChat(caller, chatId, doc).catch(() => undefined);
1208
- await this.state
1209
- .addSpend(caller.id, paying.payer, live.map((m) => m.part()))
1210
- .catch(() => undefined);
1211
- if (outcome !== 'stopped')
1212
- emit('done', { id: chatId, usage: doc.usage });
1213
- const total = live.reduce((a, m) => ({
1214
- calls: a.calls + m.calls,
1215
- in: a.in + m.in,
1216
- out: a.out + m.out,
1217
- usd: a.usd + m.usd,
1218
- }), { calls: 0, in: 0, out: 0, usd: 0 });
1219
- await Promise.resolve(this.o.onStep?.({
1220
- kind: 'turn',
1221
- outcome,
1222
- ms: Math.round(performance.now() - t0),
1223
- tier: main.tier.id,
1224
- end_tier: counts.end.id,
1225
- handoffs: counts.handoffs,
1226
- advice: counts.advice,
1227
- steps: counts.steps,
1228
- ...total,
1229
- })).catch(() => undefined);
1230
- }
1231
740
  /** What the kit adds to the app's instructions: tools, drafts, hand off, advice, limits. */
1232
741
  rules(main, policy, advisor, fns, failed) {
1233
742
  const lines = [
@@ -1280,27 +789,6 @@ export class Assistant {
1280
789
  session.meter = new Meter(session.model, `${session.tier.label} · advice`);
1281
790
  return { session, asked: 0 };
1282
791
  }
1283
- escalateTool(main) {
1284
- const reach = this.above(main);
1285
- if (!reach.length)
1286
- return null;
1287
- return {
1288
- name: 'escalate',
1289
- description: 'Hands one task to a higher tier with the same tools. It works in its own section and returns a memo of at most 200 words.',
1290
- input_schema: {
1291
- type: 'object',
1292
- properties: {
1293
- tier: { type: 'string', enum: reach.map((t) => t.id) },
1294
- why: { type: 'string', enum: [...HAND_OFF_WHYS] },
1295
- task: {
1296
- type: 'string',
1297
- description: 'The task, with every fact from the chat it needs.',
1298
- },
1299
- },
1300
- required: ['tier', 'why', 'task'],
1301
- },
1302
- };
1303
- }
1304
792
  /** What the model is asked: an app command's prompt, else the message as typed. */
1305
793
  async expand(caller, message) {
1306
794
  const m = /^\/([\w-]+)(?:\s+([\s\S]*))?$/.exec(message);
@@ -1323,654 +811,6 @@ export class Assistant {
1323
811
  asked.add(reach[0].id);
1324
812
  return asked;
1325
813
  }
1326
- async handOff(use, c) {
1327
- const reply = (content, is_error = false) => ({
1328
- type: 'tool_result',
1329
- tool_use_id: use.id,
1330
- content,
1331
- is_error,
1332
- });
1333
- const input = (use.input ?? {});
1334
- const reach = this.above(c.main.tier);
1335
- const tier = reach.find((t) => t.id === input.tier);
1336
- const why = HAND_OFF_WHYS.find((w) => w === input.why);
1337
- const task = typeof input.task === 'string' ? input.task.trim() : '';
1338
- if (!tier || !why || !task)
1339
- return reply(`Give {tier: ${reach.map((t) => `"${t.id}"`).join(' | ')}, why: ${HAND_OFF_WHYS.map((w) => `"${w}"`).join(' | ')}, task}.`, true);
1340
- const asked = this.askedFor(c.message, c.main.tier);
1341
- if (c.handTo)
1342
- asked.add(c.handTo.id);
1343
- const own = c.policy === 'any' ? reach : c.policy === 'next' ? reach.slice(0, 1) : [];
1344
- const may = new Set([...own.map((t) => t.id), ...asked]);
1345
- if (!may.has(tier.id))
1346
- return reply(may.size
1347
- ? `Not ${tier.id} now: you may call ${[...may].join(' or ')}.`
1348
- : 'The person has not asked for a higher tier: answer yourself.', true);
1349
- if (c.over())
1350
- return reply('No money left for a higher tier this answer: answer yourself.', true);
1351
- const sub = await this.session(c.caller, tier);
1352
- if (!c.clients[sub.model.provider])
1353
- return reply(`${tier.label} runs on ${PROVIDERS[sub.model.provider].label} and no ${PROVIDERS[sub.model.provider].label} key is set for whoever pays this answer: answer yourself.`, true);
1354
- c.live.push(sub.meter);
1355
- c.counts.handoffs += 1;
1356
- if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
1357
- c.counts.end = tier;
1358
- const item = {
1359
- kind: 'handoff',
1360
- tier: tier.id,
1361
- label: sub.meter.label,
1362
- task: task.replace(/\s+/g, ' ').slice(0, 200),
1363
- why: asked.has(tier.id) ? 'asked' : why,
1364
- items: [],
1365
- };
1366
- c.sink.push(item);
1367
- c.emit('handoff', {
1368
- kind: 'handoff',
1369
- tier: item.tier,
1370
- label: item.label,
1371
- task: item.task,
1372
- why: item.why,
1373
- });
1374
- const inside = (event, data) => c.emit(event, { ...data, handoff: true });
1375
- let memo = '';
1376
- let failed;
1377
- try {
1378
- memo = await this.loop({
1379
- clients: c.clients,
1380
- session: sub,
1381
- system: c.system,
1382
- tools: this.toolsFor(c.tools),
1383
- messages: [
1384
- {
1385
- role: 'user',
1386
- content: `${c.page}The person asked: ${c.message}\n\n${this.name} handed you this task (${WHY_TEXT[item.why]}): ${task}\n\nWork it out with the tools, then end with a memo for ${this.name} of at most 200 words: the answer and the facts it rests on. ${this.name} answers the person.`,
1387
- },
1388
- ],
1389
- sink: item.items,
1390
- emit: inside,
1391
- signal: c.signal,
1392
- over: c.over,
1393
- called: () => c.emit('usage', c.spent()),
1394
- tool: (u) => this.runTool(u, c.tools, item.items, inside, c.runs, c.counts, sub, true, c.signal),
1395
- });
1396
- }
1397
- catch (err) {
1398
- if (c.signal?.aborted)
1399
- throw err;
1400
- const shown = shownError(err);
1401
- failed = shown.message;
1402
- item.items.push({ kind: 'error', text: failed, code: shown.code });
1403
- inside('error', shown);
1404
- }
1405
- finally {
1406
- item.usage = sub.meter.part();
1407
- c.emit('handoff_done', { usage: item.usage });
1408
- }
1409
- return failed
1410
- ? reply(`${item.label} failed: ${failed}`, true)
1411
- : reply(`Memo from ${item.label}:\n${memo.slice(0, MEMO_CHARS)}`);
1412
- }
1413
- /** One tool call: a draft card for a write, else the call itself, timed. */
1414
- async runTool(use, fns, sink, emit, runs, counts, session, handoff, signal,
1415
- /** Part of a batch: runs and is remembered, but draws no step of its own. */
1416
- quiet = false) {
1417
- const reply = (content, is_error = false) => ({
1418
- type: 'tool_result',
1419
- tool_use_id: use.id,
1420
- content,
1421
- is_error,
1422
- });
1423
- if (use.name === BATCH && !fns.has(BATCH) && !quiet)
1424
- return this.runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal);
1425
- const f = fns.get(use.name);
1426
- if (!f)
1427
- return reply(`No tool ${use.name}.`, true);
1428
- const title = f.def.title ?? f.def.name;
1429
- if (f.draft) {
1430
- const item = {
1431
- kind: 'draft',
1432
- id: newId('d'),
1433
- tool: use.name,
1434
- title,
1435
- args: use.input,
1436
- status: 'open',
1437
- };
1438
- sink.push(item);
1439
- emit('draft', item);
1440
- await Promise.resolve(this.o.onStep?.({ kind: 'draft', tool: use.name, tier: session.tier.id, handoff })).catch(() => undefined);
1441
- return reply(`Drafted: a "${title}" card is on the person's screen. Nothing changed until they press Save.`);
1442
- }
1443
- // The same read with the same input in one answer gives back what it gave, without running it
1444
- // again or showing a second step: a model that lost track of a result reads it here, not twice.
1445
- const key = f.write ? '' : `${use.name}\u0000${stableJson(use.input)}`;
1446
- const again = key ? counts.seen.get(key) : undefined;
1447
- if (again)
1448
- return reply(`(Same call as earlier in this answer; its result again.)\n${await again}`);
1449
- let settle = () => undefined;
1450
- if (key)
1451
- counts.seen.set(key, new Promise((done) => {
1452
- settle = done;
1453
- }));
1454
- const id = newId('s');
1455
- if (!quiet)
1456
- emit('step_start', { id, tool: use.name, title });
1457
- const t0 = performance.now();
1458
- let out;
1459
- let ok = true;
1460
- let error;
1461
- let rows;
1462
- try {
1463
- const result = await untilStopped(withDeadline(title, 60_000, f.open.call(f.def.name, use.input)), signal);
1464
- if (f.write && counts.writes)
1465
- counts.writes[id] = { group: f.group, tool: f.def.name, args: use.input, result };
1466
- rows = rowCount(result);
1467
- out = typeof result === 'string' ? result : JSON.stringify(result);
1468
- }
1469
- catch (err) {
1470
- if (signal?.aborted) {
1471
- settle('(stopped)');
1472
- throw err;
1473
- }
1474
- ok = false;
1475
- error =
1476
- err instanceof ToolError || err instanceof Error ? err.message.slice(0, 300) : 'failed';
1477
- out = error;
1478
- }
1479
- const ms = Math.round(performance.now() - t0);
1480
- const item = {
1481
- kind: 'step',
1482
- id,
1483
- tool: use.name,
1484
- title,
1485
- ok,
1486
- ms,
1487
- ...(rows !== undefined ? { rows } : {}),
1488
- ...(error ? { error } : {}),
1489
- ...(f.write && ok
1490
- ? { write: { undo: !!this.groupById(f.group)?.undo, status: 'done' } }
1491
- : {}),
1492
- };
1493
- if (!quiet) {
1494
- sink.push(item);
1495
- emit('step', item);
1496
- counts.steps += 1;
1497
- }
1498
- // A change made at once can make an earlier read stale: reads after it run again.
1499
- if (f.write)
1500
- counts.seen.clear();
1501
- runs.push({ tool: use.name, key, result: out });
1502
- await Promise.resolve(this.o.onStep?.({
1503
- kind: 'tool',
1504
- tool: use.name,
1505
- ok,
1506
- ms,
1507
- ...(rows !== undefined ? { rows } : {}),
1508
- ...(error ? { error } : {}),
1509
- tier: session.tier.id,
1510
- handoff,
1511
- })).catch(() => undefined);
1512
- const cut = out.length > RESULT_CHARS
1513
- ? `${out.slice(0, RESULT_CHARS)} …(cut at ${RESULT_CHARS.toLocaleString('en-US')} of ${out.length.toLocaleString('en-US')} characters: the rest was not read; ask again for less)`
1514
- : out;
1515
- if (key) {
1516
- settle(cut);
1517
- if (!ok)
1518
- counts.seen.delete(key);
1519
- }
1520
- return reply(cut, !ok);
1521
- }
1522
- /** A batch of reads: each runs as its own call (the same read twice runs once), in parallel, shown as one step. */
1523
- async runBatch(use, fns, sink, emit, runs, counts, session, handoff, signal) {
1524
- const reply = (content, is_error = false) => ({
1525
- type: 'tool_result',
1526
- tool_use_id: use.id,
1527
- content,
1528
- is_error,
1529
- });
1530
- const raw = use.input?.calls;
1531
- const calls = (Array.isArray(raw) ? raw : []).map((c) => {
1532
- const o = (c && typeof c === 'object' ? c : {});
1533
- return {
1534
- tool: typeof o.tool === 'string' ? o.tool : '',
1535
- input: o.input && typeof o.input === 'object' ? o.input : {},
1536
- };
1537
- });
1538
- if (!calls.length)
1539
- return reply(`Give {calls: [{tool, input}]}: 1 to ${BATCH_MAX} reads.`, true);
1540
- if (calls.length > BATCH_MAX)
1541
- return reply(`At most ${BATCH_MAX} calls a batch: split them into batches.`, true);
1542
- const titles = [...new Set(calls.map((c) => fns.get(c.tool)?.def.title ?? c.tool))];
1543
- const title = titles.length === 1 ? `${titles[0]} ×${calls.length}` : `${calls.length} reads`;
1544
- const id = newId('s');
1545
- emit('step_start', { id, tool: BATCH, title });
1546
- const t0 = performance.now();
1547
- const done = await Promise.all(calls.map(async (c, i) => {
1548
- const f = fns.get(c.tool);
1549
- if (!f)
1550
- return { ...c, ok: false, text: `No tool ${c.tool}.` };
1551
- if (f.draft || f.write)
1552
- return { ...c, ok: false, text: 'Only reads go in a batch: call this tool on its own.' };
1553
- const r = await this.runTool({
1554
- type: 'tool_use',
1555
- id: `${use.id}_${i}`,
1556
- name: c.tool,
1557
- input: c.input,
1558
- }, fns, sink, emit, runs, counts, session, handoff, signal, true);
1559
- return { ...c, ok: !r.is_error, text: typeof r.content === 'string' ? r.content : '' };
1560
- }));
1561
- const failed = done.filter((d) => !d.ok).length;
1562
- const item = {
1563
- kind: 'step',
1564
- id,
1565
- tool: BATCH,
1566
- title,
1567
- ok: failed < done.length,
1568
- ms: Math.round(performance.now() - t0),
1569
- ...(failed ? { error: `${failed} of ${done.length} failed` } : {}),
1570
- };
1571
- sink.push(item);
1572
- emit('step', item);
1573
- counts.steps += 1;
1574
- let left = BATCH_CHARS;
1575
- const parts = done.map((d, i) => {
1576
- const fits = d.text.length <= left;
1577
- left -= fits ? d.text.length : 0;
1578
- return `[${i + 1}] ${d.tool} ${stableJson(d.input)}${d.ok ? '' : ' (failed)'}\n${fits
1579
- ? d.text
1580
- : '(left out: the batch passed its size; read this one again in a smaller batch)'}`;
1581
- });
1582
- return reply(parts.join('\n\n'), failed === done.length);
1583
- }
1584
- /** Model calls and tool rounds until the model answers; returns its words. */
1585
- loop(o) {
1586
- const { provider } = o.session.model;
1587
- const label = PROVIDERS[provider].label;
1588
- if (provider === 'openai') {
1589
- if (!o.clients.openai)
1590
- throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
1591
- return this.loopOpenAI({ ...o, client: o.clients.openai, plan: !!o.clients.openaiPlan });
1592
- }
1593
- if (!o.clients.anthropic)
1594
- throw new AssistantError(`No ${label} key is set for this answer.`, 'not_connected');
1595
- return this.loopClaude({ ...o, client: o.clients.anthropic });
1596
- }
1597
- /** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
1598
- async loopOpenAI(o) {
1599
- const { client, session, tools, sink, emit, signal, plan } = o;
1600
- const instructions = o.system.map((b) => b.text).join('\n\n');
1601
- const fnTools = tools.map((t) => ({
1602
- type: 'function',
1603
- name: t.name,
1604
- description: t.description ?? null,
1605
- parameters: t.input_schema,
1606
- strict: false,
1607
- }));
1608
- // The chat so far is plain text turns; this answer's calls are appended as OpenAI items.
1609
- // An earlier answer that said nothing (only a cut mark) is left out: an empty turn is refused.
1610
- const input = o.messages
1611
- .map((m) => ({
1612
- role: m.role,
1613
- content: typeof m.content === 'string'
1614
- ? m.content
1615
- : m.content
1616
- .map((b) => (b.type === 'text' ? b.text : ''))
1617
- .filter(Boolean)
1618
- .join('\n'),
1619
- }))
1620
- .filter((m) => m.role !== 'assistant' || m.content.replace('…(cut)', '').trim() !== '');
1621
- let answer = '';
1622
- let force = o.first?.type === 'tool' ? { type: 'function', name: o.first.name } : undefined;
1623
- // A plan sign-in takes function tools only inside a namespace, and no output cap
1624
- // (`max_output_tokens` is unsupported there: developers.openai.com/siwc, Preview limitations).
1625
- const sent = (only) => {
1626
- const fns = only ? fnTools.filter((t) => t.name === only) : fnTools;
1627
- const out = plan ? {} : { max_output_tokens: session.tier.out ?? DEFAULT_OUT };
1628
- if (!plan)
1629
- return { tools: fns, ...out };
1630
- return {
1631
- ...out,
1632
- tools: [
1633
- {
1634
- type: 'namespace',
1635
- name: 'app',
1636
- description: `${this.name}'s tools`,
1637
- tools: fns.map(({ strict: _, ...t }) => t),
1638
- },
1639
- ],
1640
- };
1641
- };
1642
- for (let round = 0; round < MAX_ROUNDS; round++) {
1643
- signal?.throwIfAborted();
1644
- const effort = session.effort === 'default' ? undefined : session.effort;
1645
- // On a plan a forced call is the one tool offered, with any call required.
1646
- const only = plan && typeof force === 'object' ? force.name : undefined;
1647
- const choice = only ? 'required' : force;
1648
- // The raw event stream, not the SDK's accumulator: a plan sign-in's backend sends event types
1649
- // the SDK does not know and its accumulator throws on them. The final response rides on the
1650
- // completed, incomplete or failed event; every other event but text deltas is skipped.
1651
- let partial = '';
1652
- // Event types seen and the items finished one by one: a plan sign-in's final event may carry
1653
- // an empty `output`, its items having come only as `response.output_item.done`.
1654
- let seen = {};
1655
- let items = [];
1656
- // An error OpenAI sends inside the stream: logged in full, shown as a plain sentence.
1657
- const streamFailure = (er) => {
1658
- if (partial)
1659
- sink.push({ kind: 'text', text: partial });
1660
- console.error(JSON.stringify({
1661
- event: 'assistant_openai_error',
1662
- model: session.model.id,
1663
- plan,
1664
- code: er.code ?? null,
1665
- type: er.type ?? null,
1666
- param: er.param ?? null,
1667
- message: er.message?.slice(0, 500) ?? null,
1668
- events: seen,
1669
- }));
1670
- return new AssistantError(openaiStreamError(er.code || er.type || '', plan), 'upstream');
1671
- };
1672
- // One call of this round; asked again (fresh stream state) when it spends its whole output
1673
- // on reasoning.
1674
- const ask = (eff) => {
1675
- partial = '';
1676
- seen = {};
1677
- items = [];
1678
- return askOnce(eff);
1679
- };
1680
- const askOnce = (eff) => (async () => {
1681
- const stream = await client.responses.create({
1682
- model: session.model.id,
1683
- instructions,
1684
- input,
1685
- ...sent(only),
1686
- store: false,
1687
- stream: true,
1688
- include: ['reasoning.encrypted_content'],
1689
- ...(eff ? { reasoning: { effort: eff } } : {}),
1690
- ...(choice ? { tool_choice: choice } : {}),
1691
- }, signal ? { signal } : {});
1692
- let done;
1693
- for await (const e of stream) {
1694
- seen[e.type] = (seen[e.type] ?? 0) + 1;
1695
- if (e.type === 'response.output_item.done' && e.item)
1696
- items.push(e.item);
1697
- if (e.type === 'response.output_text.delta' && typeof e.delta === 'string') {
1698
- partial += e.delta;
1699
- emit('text', { delta: e.delta });
1700
- }
1701
- else if (e.type === 'response.completed' ||
1702
- e.type === 'response.incomplete' ||
1703
- e.type === 'response.failed') {
1704
- done = e.response;
1705
- }
1706
- else if (e.type === 'error') {
1707
- const er = (e.error ?? e);
1708
- throw streamFailure(er);
1709
- }
1710
- }
1711
- if (!done)
1712
- throw new AssistantError('OpenAI ended the answer early.', 'upstream');
1713
- return done;
1714
- })().catch((err) => {
1715
- if (signal?.aborted && partial)
1716
- sink.push({ kind: 'text', text: partial });
1717
- // The SDK throws a stream's `error` event as an APIError with no HTTP status.
1718
- if (err instanceof OpenAI.APIError && err.status === undefined) {
1719
- const er = (err.error ?? {});
1720
- throw streamFailure({
1721
- ...er,
1722
- code: er.code ?? err.code ?? undefined,
1723
- message: er.message ?? err.message,
1724
- });
1725
- }
1726
- throw err;
1727
- });
1728
- let res = await ask(effort);
1729
- // A reasoning model can spend the whole output on thinking and say nothing: ask once more
1730
- // with less thinking before giving up. One that stopped before writing anything is not asked
1731
- // again: less thinking cannot help it.
1732
- if (res.status === 'incomplete' &&
1733
- res.incomplete_details?.reason === 'max_output_tokens' &&
1734
- (res.usage?.output_tokens ?? 0) > 0 &&
1735
- !partial &&
1736
- !shows(res.output?.length ? res.output : items) &&
1737
- effort !== 'low') {
1738
- console.error(JSON.stringify({
1739
- event: 'assistant_openai_retry',
1740
- model: session.model.id,
1741
- plan,
1742
- why: 'no_words',
1743
- usage: res.usage ?? null,
1744
- events: seen,
1745
- }));
1746
- res = await ask('low');
1747
- }
1748
- if (res.status === 'failed') {
1749
- if (partial)
1750
- sink.push({ kind: 'text', text: partial });
1751
- const code = res.error?.code ?? '';
1752
- console.error(JSON.stringify({
1753
- event: 'assistant_openai_failed',
1754
- model: session.model.id,
1755
- plan,
1756
- code,
1757
- message: res.error?.message?.slice(0, 300) ?? null,
1758
- events: seen,
1759
- }));
1760
- throw new AssistantError(openaiStreamError(code, plan), 'upstream');
1761
- }
1762
- const u = res.usage;
1763
- if (u) {
1764
- const read = u.input_tokens_details?.cached_tokens ?? 0;
1765
- const written = u.input_tokens_details?.cache_write_tokens ?? 0;
1766
- session.meter.add({
1767
- input_tokens: Math.max(0, u.input_tokens - read - written),
1768
- output_tokens: u.output_tokens,
1769
- cache_read_input_tokens: read,
1770
- cache_creation_input_tokens: written,
1771
- }, plan);
1772
- o.context?.(u.input_tokens + u.output_tokens);
1773
- }
1774
- o.called();
1775
- const output = (res.output?.length ? res.output : items);
1776
- res.output = output;
1777
- // Raw events carry no `output_text`: the words are the output messages' text parts.
1778
- const words = output
1779
- .flatMap((b) => (b.type === 'message' ? b.content : []))
1780
- .map((c) => (c.type === 'output_text' ? c.text : ''))
1781
- .join('') || partial;
1782
- const hasCall = output.some((b) => b.type === 'function_call');
1783
- if (!words && !hasCall) {
1784
- // Nothing to show: say why instead of an empty or bare "cut" answer, and log the shape.
1785
- console.error(JSON.stringify({
1786
- event: 'assistant_openai_empty',
1787
- model: session.model.id,
1788
- plan,
1789
- status: res.status,
1790
- incomplete: res.incomplete_details ?? null,
1791
- usage: res.usage ?? null,
1792
- events: seen,
1793
- output: output.map((b) => b.type),
1794
- parts: output.flatMap((b) => b.type === 'message' ? b.content.map((c) => c.type) : []),
1795
- }));
1796
- if (!answer)
1797
- throw new AssistantError(res.incomplete_details?.reason === 'max_output_tokens' && !res.usage?.output_tokens
1798
- ? 'OpenAI stopped before reading the message. Try again, or pick another model for this tier.'
1799
- : res.incomplete_details?.reason === 'max_output_tokens'
1800
- ? 'OpenAI spent the whole answer thinking and wrote nothing. Try again, or pick a smaller tier.'
1801
- : res.incomplete_details?.reason
1802
- ? `OpenAI stopped before answering (${res.incomplete_details.reason}).`
1803
- : 'OpenAI sent no answer.', 'upstream');
1804
- }
1805
- const cut = res.incomplete_details?.reason === 'max_output_tokens' && (words || hasCall)
1806
- ? ' …(cut)'
1807
- : '';
1808
- if (cut)
1809
- emit('text', { delta: cut });
1810
- const text = words + cut;
1811
- if (text) {
1812
- answer += (answer ? '\n\n' : '') + text;
1813
- sink.push({ kind: 'text', text });
1814
- emit('text_end', {});
1815
- }
1816
- // Reasoning, words and calls go back as they came, so the model keeps its own thread.
1817
- input.push(...output);
1818
- const calls = output.filter((b) => b.type === 'function_call');
1819
- if (calls.length === 0)
1820
- break;
1821
- const results = await Promise.all(calls.map((c) => {
1822
- let args = {};
1823
- try {
1824
- args = c.arguments ? JSON.parse(c.arguments) : {};
1825
- }
1826
- catch { }
1827
- const use = {
1828
- type: 'tool_use',
1829
- id: c.call_id,
1830
- name: c.name,
1831
- input: args,
1832
- };
1833
- return untilStopped(o.tool(use), signal);
1834
- }));
1835
- for (const r of results) {
1836
- const body = typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? '');
1837
- input.push({
1838
- type: 'function_call_output',
1839
- call_id: r.tool_use_id,
1840
- output: r.is_error ? `Error: ${body}` : body,
1841
- });
1842
- }
1843
- force = undefined;
1844
- const limit = o.over();
1845
- const last = round === MAX_ROUNDS - 2;
1846
- if (limit || last) {
1847
- input.push({
1848
- role: 'user',
1849
- content: limit ?? 'No more tool calls: answer now from what you have.',
1850
- });
1851
- force = 'none';
1852
- }
1853
- }
1854
- return answer;
1855
- }
1856
- /** The loop on Anthropic's Messages API, with the advisor and prompt caching. */
1857
- async loopClaude(o) {
1858
- const { client, session, system, tools, messages, sink, emit, signal } = o;
1859
- let answer = '';
1860
- let force = o.first;
1861
- for (let round = 0; round < MAX_ROUNDS; round++) {
1862
- signal?.throwIfAborted();
1863
- // The system prompt and tools are one cached prefix; the chat so far a second breakpoint.
1864
- const sent = messages.map((m, i) => (i === messages.length - 1 ? withCacheMark(m) : m));
1865
- const params = {
1866
- model: session.model.id,
1867
- max_tokens: session.tier.out ?? DEFAULT_OUT,
1868
- ...effortParams(session.effort),
1869
- system,
1870
- tools,
1871
- messages: sent,
1872
- ...(force ? { tool_choice: force } : {}),
1873
- };
1874
- const adv = o.advisor;
1875
- // A forced tool choice may not come with the advisor; the beta header stays while it is on.
1876
- const advise = adv && adv.asked < ADVICE_MAX && force?.type !== 'tool' && force?.type !== 'any';
1877
- const stream = adv
1878
- ? client.beta.messages.stream({
1879
- ...params,
1880
- tools: [
1881
- ...tools,
1882
- ...(advise
1883
- ? [
1884
- {
1885
- type: 'advisor_20260301',
1886
- name: 'advisor',
1887
- model: adv.session.model.id,
1888
- max_tokens: ADVICE_TOKENS,
1889
- caching: { type: 'ephemeral', ttl: '5m' },
1890
- },
1891
- ]
1892
- : []),
1893
- ],
1894
- betas: [ADVISOR_BETA],
1895
- }, { signal })
1896
- : client.messages.stream(params, signal ? { signal } : {});
1897
- let partial = '';
1898
- stream.on('text', (delta) => {
1899
- partial += delta;
1900
- emit('text', { delta });
1901
- });
1902
- if (adv)
1903
- stream.on('streamEvent', (e) => {
1904
- if (e.type !== 'content_block_start')
1905
- return;
1906
- const b = e.content_block;
1907
- const label = adv.session.meter.label;
1908
- if (b.type === 'server_tool_use' && b.name === 'advisor') {
1909
- adv.asked += 1;
1910
- emit('advice', { tier: adv.session.tier.id, label });
1911
- }
1912
- else if (b.type === 'advisor_tool_result') {
1913
- const error = b.content?.type === 'advisor_tool_result_error'
1914
- ? (b.content.error_code ?? 'error')
1915
- : undefined;
1916
- sink.push({
1917
- kind: 'advice',
1918
- tier: adv.session.tier.id,
1919
- label,
1920
- ...(error ? { error } : {}),
1921
- });
1922
- emit('advice_done', error ? { error } : {});
1923
- }
1924
- });
1925
- const msg = await stream.finalMessage().catch((err) => {
1926
- if (signal?.aborted && partial)
1927
- sink.push({ kind: 'text', text: partial });
1928
- throw err;
1929
- });
1930
- session.meter.add(msg.usage);
1931
- if (adv)
1932
- for (const it of msg.usage.iterations ?? [])
1933
- if (it.type === 'advisor_message')
1934
- adv.session.meter.add(it);
1935
- o.context?.(msg.usage.input_tokens +
1936
- (msg.usage.cache_read_input_tokens ?? 0) +
1937
- (msg.usage.cache_creation_input_tokens ?? 0) +
1938
- msg.usage.output_tokens);
1939
- o.called();
1940
- const cut = msg.stop_reason === 'max_tokens' ? ' …(cut)' : '';
1941
- if (cut)
1942
- emit('text', { delta: cut });
1943
- const text = msg.content
1944
- .filter((b) => b.type === 'text')
1945
- .map((b) => b.text)
1946
- .join('') + cut;
1947
- if (text) {
1948
- answer += (answer ? '\n\n' : '') + text;
1949
- sink.push({ kind: 'text', text });
1950
- emit('text_end', {});
1951
- }
1952
- messages.push({ role: 'assistant', content: msg.content });
1953
- if (msg.stop_reason === 'pause_turn')
1954
- continue;
1955
- const uses = msg.content.filter((b) => b.type === 'tool_use');
1956
- if (msg.stop_reason !== 'tool_use' || uses.length === 0)
1957
- break;
1958
- const results = await Promise.all(uses.map((use) => untilStopped(o.tool(use), signal)));
1959
- if (force === o.first)
1960
- force = undefined;
1961
- const limit = o.over();
1962
- if (limit) {
1963
- results.push({ type: 'text', text: limit });
1964
- force = { type: 'none' };
1965
- }
1966
- else if (round === MAX_ROUNDS - 2) {
1967
- results.push({ type: 'text', text: 'No more tool calls: answer now from what you have.' });
1968
- force = { type: 'none' };
1969
- }
1970
- messages.push({ role: 'user', content: results });
1971
- }
1972
- return answer;
1973
- }
1974
814
  /** Swaps the history for a short summary. False with nothing to compact. */
1975
815
  /**
1976
816
  * One plain answer from OpenAI, metered on `s`: no tools, no chat. Streamed and read from the raw
@@ -2040,40 +880,6 @@ export class Assistant {
2040
880
  }
2041
881
  return text || done?.output_text || '';
2042
882
  }
2043
- async compact(clients, s, doc, emit) {
2044
- if (doc.history.length < 2)
2045
- return false;
2046
- const system = `Summarise this chat between a person and ${this.name}, their assistant, so ${this.name} can carry on from the summary alone. Keep what the person wants, the facts and numbers found, decisions made and cards drafted or saved, and anything still open. Plain bullets, at most 400 words. No preamble.`;
2047
- const chat = toolMemory(doc.history)
2048
- .map((t) => `${t.role === 'user' ? 'Person' : this.name}: ${t.content}`)
2049
- .join('\n\n');
2050
- let summary = '';
2051
- if (s.model.provider === 'openai' && clients.openai) {
2052
- summary = await this.openaiOnce(clients, s, system, chat, 1500);
2053
- }
2054
- else if (clients.anthropic) {
2055
- const msg = await clients.anthropic.messages.create({
2056
- model: s.model.id,
2057
- max_tokens: 1500,
2058
- system,
2059
- messages: [{ role: 'user', content: chat }],
2060
- });
2061
- s.meter.add(msg.usage);
2062
- summary = msg.content
2063
- .filter((b) => b.type === 'text')
2064
- .map((b) => b.text)
2065
- .join('');
2066
- }
2067
- else
2068
- return false;
2069
- doc.history = [
2070
- { role: 'user', content: `Summary of our chat so far (compacted):\n${summary}` },
2071
- { role: 'assistant', content: 'Noted. I carry on from this summary.' },
2072
- ];
2073
- doc.items.push({ kind: 'compacted' });
2074
- emit('compacted', {});
2075
- return true;
2076
- }
2077
883
  // ---------------------------------------------------------------- one-shot work
2078
884
  /**
2079
885
  * One prompt answered on a task's tier as the person: no chat, no tools. It is for background
@@ -2139,23 +945,6 @@ export class Assistant {
2139
945
  }
2140
946
  return { text, usd: s.meter.usd, model: s.model.id };
2141
947
  }
2142
- /** How the person's cards ended since the last answer, once each. */
2143
- cardNews(doc) {
2144
- const told = new Set(doc.told ?? []);
2145
- const news = draftsOf(doc.items).filter((d) => d.status !== 'open' && !told.has(d.id));
2146
- const undone = stepsOf(doc.items).filter((x) => x.write?.status === 'undone' && !told.has(x.id));
2147
- if (!news.length && !undone.length)
2148
- return '';
2149
- doc.told = [...told, ...news.map((d) => d.id), ...undone.map((x) => x.id)];
2150
- const back = undone.length
2151
- ? `The person undid these changes you made: ${undone.map((x) => `${x.title} ${JSON.stringify(doc.writes?.[x.id]?.args ?? {}).slice(0, 300)}`).join('; ')}. Do not make them again unless asked.\n\n`
2152
- : '';
2153
- if (!news.length)
2154
- return back;
2155
- return `${back}Cards since your last answer:\n${news
2156
- .map((d) => `- ${d.title} ${JSON.stringify(d.args).slice(0, 300)}: ${d.status}${d.error ? ` (${d.error})` : ''}${d.result !== undefined ? ` → ${JSON.stringify(d.result).slice(0, 300)}` : ''}`)
2157
- .join('\n')}\n\n`;
2158
- }
2159
948
  // ---------------------------------------------------------------- keys
2160
949
  /** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
2161
950
  async startSignin(caller, provider) {
@@ -2268,70 +1057,6 @@ export class Assistant {
2268
1057
  };
2269
1058
  }
2270
1059
  }
2271
- // ---------------------------------------------------------------- drafts
2272
- /** Saves (runs, as the person) or dismisses a drafted card. Access is checked again first. */
2273
- async settleDraft(caller, chatId, draftId, action) {
2274
- const doc = await this.state.docs.read(caller.id, `chat:${chatId}`);
2275
- if (!doc)
2276
- throw new AssistantError('That chat is gone.', 'not_found', 404);
2277
- const d = draftsOf(doc.items).find((x) => x.id === draftId);
2278
- if (!d)
2279
- throw new AssistantError('That card is gone.', 'not_found', 404);
2280
- if (d.status !== 'open')
2281
- throw new AssistantError(`That card is already ${d.status}.`, 'settled', 409);
2282
- if (action === 'dismiss')
2283
- d.status = 'dismissed';
2284
- else {
2285
- const p = await this.state.person(caller.id);
2286
- const tools = await this.openTools(caller, p);
2287
- try {
2288
- const f = tools.fns.get(d.tool);
2289
- if (!f || f.def.read) {
2290
- d.status = 'failed';
2291
- d.error = 'Its tools are no longer on Read + draft or Read + write in Settings ▸ Tools.';
2292
- }
2293
- else {
2294
- try {
2295
- d.result = await withDeadline(d.title, 60_000, f.open.call(f.def.name, d.args));
2296
- d.status = 'saved';
2297
- }
2298
- catch (err) {
2299
- d.status = 'failed';
2300
- d.error = err instanceof Error ? err.message.slice(0, 300) : 'failed';
2301
- }
2302
- }
2303
- }
2304
- finally {
2305
- await tools.close();
2306
- }
2307
- }
2308
- await this.writeChat(caller, chatId, doc);
2309
- return d;
2310
- }
2311
- /** Takes back one change a write made at once, through its group's `undo`. */
2312
- async undoWrite(caller, chatId, stepId) {
2313
- const doc = await this.state.docs.read(caller.id, `chat:${chatId}`);
2314
- if (!doc)
2315
- throw new AssistantError('That chat is gone.', 'not_found', 404);
2316
- const step = stepsOf(doc.items).find((x) => x.id === stepId);
2317
- const w = doc.writes?.[stepId];
2318
- if (!step?.write || !w)
2319
- throw new AssistantError('That change is gone.', 'not_found', 404);
2320
- if (step.write.status === 'undone')
2321
- throw new AssistantError('That change is already undone.', 'settled', 409);
2322
- const g = this.groupById(w.group);
2323
- if (!g?.undo)
2324
- throw new AssistantError('That change cannot be undone here.', 'no_undo', 400);
2325
- try {
2326
- await withDeadline(`Undo ${step.title}`, 60_000, g.undo(caller, w));
2327
- step.write = { ...step.write, status: 'undone' };
2328
- }
2329
- catch (err) {
2330
- throw new AssistantError(err instanceof Error ? err.message.slice(0, 300) : 'Undo failed.', 'undo_failed', 502);
2331
- }
2332
- await this.writeChat(caller, chatId, doc);
2333
- return step;
2334
- }
2335
1060
  }
2336
1061
  export function createAssistant(o) {
2337
1062
  return new Assistant(o);