@columenlabs/lumen-ai 0.1.177 → 0.1.179

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,3 +225,21 @@ ctx.waitUntil(meaningStep({ def: notesFind, sql: d1Sql(env.DB), embed, index: en
225
225
  | `UsageMeter`, `tokenUsage(u)` | Prices each step's tokens (cache reads and writes, reasoning) into one usage part |
226
226
 
227
227
  A ChatGPT plan sign-in is free to the app (`priced` with `free`). Nothing here holds a key or a token: the app passes them per turn.
228
+
229
+ An app with an `Assistant` gets the whole plan from it: `assistant.agentTurn(caller, { message, tier?, context?, refs?, time_zone? })` returns `{ plan, settle }` with the person's tier and model on whoever pays, the tool groups at their access, the kit's rules, the bearings and the page before the message, `escalate` to a higher tier (its own usage part, inside the same cap) and Claude's advisor. `settle` adds the spend to the payer's month and reports the turn through `onStep`. It throws `AssistantError` when nobody can pay.
230
+
231
+ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agent's SQLite). A write the person's access only drafts makes an open card and changes nothing. `saveCard(id)` runs it through the app's `applyDraft`, and `dismissCard(id)` drops it. A write made at once is kept for `undoCard(id)` through the app's `undoWrite`. `cards()` lists the cards. The next answer is told how each card ended and what was undone. With an `Assistant`, `applyDraft` is `assistant.runDraft(caller, card)` and `undoWrite` is `assistant.undoChange(caller, card)`. Think compacts the chat past `COMPACT_AT` (150k tokens) on the last answer's model and key, and that spend is added to the next answer.
232
+
233
+ ```ts
234
+ class Chat extends LumenAgent<Env> {
235
+ #settle?: (end: AgentTurnEnd) => Promise<void>;
236
+ async plan(turn: TurnContext) {
237
+ const { plan, settle } = await assistant.agentTurn(caller, { message: lastText(turn), ...turn.body });
238
+ this.#settle = settle;
239
+ return plan;
240
+ }
241
+ settle(end: AgentTurnEnd) { return this.#settle?.(end) ?? Promise.resolve(); }
242
+ protected applyDraft(card: AgentCard) { return assistant.runDraft(caller, card); }
243
+ protected undoWrite(card: AgentCard) { return assistant.undoChange(caller, card); }
244
+ }
245
+ ```
@@ -1,4 +1,3 @@
1
- import { type ChatResponseResult, type PrepareStepContext, type StepConfig, type StepContext, Think, type ThinkModel, type TurnConfig, type TurnContext } from '@cloudflare/think';
2
1
  /**
3
2
  * `LumenAgent`: one chat as a Cloudflare Think agent, run on the app's tiers, keys and tool groups.
4
3
  * Think keeps the loop, the messages, streaming, resume and compaction; the app answers `plan` for
@@ -9,11 +8,24 @@ import { type ChatResponseResult, type PrepareStepContext, type StepConfig, type
9
8
  * plan(turn) { return sagePlan(this.env, this.name, turn); }
10
9
  * settle(s) { return recordSpend(this.env, this.name, s); }
11
10
  * }
11
+ *
12
+ * An `Assistant` gives both: `assistant.agentTurn(caller, input)` returns a plan and its settle.
12
13
  */
14
+ import { type ChatResponseResult, type PrepareStepContext, type Session, type StepConfig, type StepContext, Think, type ThinkModel, type TurnConfig, type TurnContext } from '@cloudflare/think';
13
15
  import type { AssistantEffort, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
16
+ import { type ModelMessage, type ToolSet } from 'ai';
14
17
  import type { ModelDef } from '../assistant/models.js';
15
18
  import { type ModelKey } from './models.js';
16
- import { type AiToolsOptions, type SourceAt } from './tools.js';
19
+ import { type AiToolsOptions, type OpenedTools, type SourceAt } from './tools.js';
20
+ import { UsageMeter } from './usage.js';
21
+ /** What a plan's extra tools get: the open tools, a meter of their own and the cap. */
22
+ export interface TurnTools {
23
+ opened: OpenedTools;
24
+ /** A new part of this answer's usage (a hand off), counted against the cap. */
25
+ meter(model: ModelDef, label: string, free?: boolean): UsageMeter;
26
+ /** Spent this answer reached what the payer has left. */
27
+ over(): boolean;
28
+ }
17
29
  /** What one turn runs on, as the app decides it. */
18
30
  export interface AgentTurnPlan {
19
31
  /** The tier's model, and the key or sign-in it runs on. */
@@ -24,10 +36,23 @@ export interface AgentTurnPlan {
24
36
  out?: number;
25
37
  /** The usage part's label: `Tier · Model`. */
26
38
  label: string;
27
- /** The app's instructions and the kit's rules, joined. */
28
- system: string;
39
+ /** The app's instructions and the kit's rules, joined; a function sees the tools opened. */
40
+ system: string | ((t: OpenedTools) => string);
41
+ /** Put before the person's newest message for the model only (bearings, the page). */
42
+ lead?: string;
29
43
  /** The tool groups at the person's access. */
30
44
  sources: SourceAt[];
45
+ /** Tools beside the groups' (hand off), made once those are open. */
46
+ more?: (t: TurnTools) => ToolSet;
47
+ /** Call this tool first (a message handed to a higher tier). */
48
+ first?: string;
49
+ /** Claude's advisor: a bigger Claude reads the chat and sends a plan. */
50
+ advisor?: {
51
+ model: ModelDef;
52
+ label: string;
53
+ maxUses: number;
54
+ maxTokens: number;
55
+ };
31
56
  /** Dollars the payer has left; at or past it the model gets no more tools. */
32
57
  left?: number;
33
58
  /** Most model calls in one answer; 8. */
@@ -39,23 +64,60 @@ export interface AgentTurnPlan {
39
64
  /** How a turn ended, with its usage, for the app to record. */
40
65
  export interface AgentTurnEnd {
41
66
  outcome: 'ok' | 'stopped' | 'error';
67
+ /** The main model's part first, then advice and hand offs; parts with no calls left out. */
42
68
  parts: UsagePart[];
43
69
  /** Tokens in the main model's last call: how full its context is. */
44
70
  context?: number;
71
+ /** Model calls of the main tier. */
72
+ steps: number;
45
73
  ms: number;
46
74
  /** The spend cap stopped tool use in this answer. */
47
75
  capped: boolean;
48
76
  }
77
+ export declare const CAP_NOTE = "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.";
78
+ /** A card in the chat: a draft the person saves or dismisses, or a write made at once (Undo). */
79
+ export interface AgentCard {
80
+ id: string;
81
+ kind: 'draft' | 'write';
82
+ group: string;
83
+ /** The tool's own name, not the `group__tool` the model calls. */
84
+ tool: string;
85
+ args: unknown;
86
+ result?: unknown;
87
+ status: 'open' | 'saved' | 'dismissed' | 'failed' | 'done' | 'undone';
88
+ error?: string;
89
+ at: number;
90
+ }
91
+ /** Past this many tokens in the chat Think summarizes its older part. */
92
+ export declare const COMPACT_AT = 150000;
93
+ /** What the model is told of cards settled and changes undone since its last answer. */
94
+ export declare function cardNews(cards: AgentCard[]): string;
95
+ /** The messages with `lead` put before the newest message the person wrote. */
96
+ export declare function withLead(messages: ModelMessage[], lead: string | undefined): ModelMessage[];
49
97
  export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
50
98
  #private;
51
- /** The turn the app runs now, or null between turns. */
99
+ /** The turn the app runs now. Throw to refuse it (no key, no money); the error is shown. */
52
100
  abstract plan(turn: TurnContext): Promise<AgentTurnPlan>;
53
101
  /** A turn ended: record its spend and how it went. Errors here are logged, never shown. */
54
102
  abstract settle(end: AgentTurnEnd): Promise<void>;
103
+ /** Saves a draft: runs its write as the person. The result is shown and told to the model. */
104
+ protected applyDraft(_card: AgentCard): Promise<unknown>;
105
+ /** Takes back a write made at once (Undo). */
106
+ protected undoWrite(_card: AgentCard): Promise<void>;
107
+ /** The chat's cards, newest last: drafts and writes made at once (at most 200). */
108
+ cards(): AgentCard[];
109
+ /** The person saved a draft: its write runs now. */
110
+ saveCard(id: string): Promise<AgentCard>;
111
+ /** The person dismissed a draft: nothing changes. */
112
+ dismissCard(id: string): AgentCard;
113
+ /** The person undid a write made at once. */
114
+ undoCard(id: string): Promise<AgentCard>;
115
+ /** Think compacts the chat past `COMPACT_AT` tokens, on the last turn's model and key. */
116
+ configureSession(session: Session): Session;
55
117
  /** Never read: `beforeTurn` gives every turn its own model. */
56
118
  getModel(): ThinkModel;
57
119
  beforeTurn(ctx: TurnContext): Promise<TurnConfig>;
58
- beforeStep(_ctx: PrepareStepContext): StepConfig | undefined;
120
+ beforeStep(ctx: PrepareStepContext): StepConfig | undefined;
59
121
  onStepFinish(ctx: StepContext): void;
60
122
  onChatResponse(result: ChatResponseResult): Promise<void>;
61
123
  onChatError(error: unknown): unknown;
@@ -1,10 +1,183 @@
1
+ /**
2
+ * `LumenAgent`: one chat as a Cloudflare Think agent, run on the app's tiers, keys and tool groups.
3
+ * Think keeps the loop, the messages, streaming, resume and compaction; the app answers `plan` for
4
+ * each turn (which model on whose key, the instructions, the tools at the person's access, what is
5
+ * left to spend) and hears `settle` when the turn ends, to record the spend.
6
+ *
7
+ * export class SageChat extends LumenAgent<Env> {
8
+ * plan(turn) { return sagePlan(this.env, this.name, turn); }
9
+ * settle(s) { return recordSpend(this.env, this.name, s); }
10
+ * }
11
+ *
12
+ * An `Assistant` gives both: `assistant.agentTurn(caller, input)` returns a plan and its settle.
13
+ */
14
+ import { anthropic } from '@ai-sdk/anthropic';
1
15
  import { Think, } from '@cloudflare/think';
16
+ import { createCompactFunction } from 'agents/sessions';
17
+ import { generateText } from 'ai';
2
18
  import { aiModel, turnSettings } from './models.js';
3
- import { openAiTools } from './tools.js';
19
+ import { openAiTools, } from './tools.js';
4
20
  import { UsageMeter } from './usage.js';
5
- const CAP_NOTE = '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.';
21
+ export const CAP_NOTE = '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.';
22
+ /** Past this many tokens in the chat Think summarizes its older part. */
23
+ export const COMPACT_AT = 150_000;
24
+ const CARD_NOTE = "A card is on the person's screen; nothing has changed yet. Say what it will do when they save it. The next message tells you how it ended.";
25
+ /** What the model is told of cards settled and changes undone since its last answer. */
26
+ export function cardNews(cards) {
27
+ const undone = cards.filter((c) => c.kind === 'write' && c.status === 'undone');
28
+ const ended = cards.filter((c) => c.kind === 'draft' && c.status !== 'open');
29
+ const line = (c) => `${c.group}/${c.tool} ${JSON.stringify(c.args ?? {}).slice(0, 300)}`;
30
+ const back = undone.length
31
+ ? `The person undid these changes you made: ${undone.map(line).join('; ')}. Do not make them again unless asked.\n\n`
32
+ : '';
33
+ if (!ended.length)
34
+ return back;
35
+ return `${back}Cards since your last answer:\n${ended
36
+ .map((c) => `- ${line(c)}: ${c.status}${c.error ? ` (${c.error})` : ''}${c.result !== undefined ? ` → ${JSON.stringify(c.result).slice(0, 300)}` : ''}`)
37
+ .join('\n')}\n\n`;
38
+ }
39
+ const cardOf = (r) => ({
40
+ id: r.id,
41
+ kind: r.kind,
42
+ group: r.grp,
43
+ tool: r.tool,
44
+ args: JSON.parse(r.args),
45
+ ...(r.result === null ? {} : { result: JSON.parse(r.result) }),
46
+ status: r.status,
47
+ ...(r.error ? { error: r.error } : {}),
48
+ at: r.at,
49
+ });
50
+ /** The messages with `lead` put before the newest message the person wrote. */
51
+ export function withLead(messages, lead) {
52
+ if (!lead)
53
+ return messages;
54
+ let i = messages.length - 1;
55
+ while (i >= 0 && messages[i]?.role !== 'user')
56
+ i -= 1;
57
+ const m = messages[i];
58
+ if (!m || m.role !== 'user')
59
+ return messages;
60
+ const content = typeof m.content === 'string'
61
+ ? `${lead}${m.content}`
62
+ : [{ type: 'text', text: lead }, ...m.content];
63
+ return [...messages.slice(0, i), { ...m, content }, ...messages.slice(i + 1)];
64
+ }
6
65
  export class LumenAgent extends Think {
66
+ /** Saves a draft: runs its write as the person. The result is shown and told to the model. */
67
+ applyDraft(_card) {
68
+ return Promise.reject(new Error('Saving a card is not set up here.'));
69
+ }
70
+ /** Takes back a write made at once (Undo). */
71
+ undoWrite(_card) {
72
+ return Promise.reject(new Error('This change cannot be undone here.'));
73
+ }
7
74
  #turn = null;
75
+ /** The last turn's model and key, for compaction between turns. */
76
+ #last = null;
77
+ /** Usage spent between turns (compaction), settled with the next turn. */
78
+ #owed = [];
79
+ #tabled = false;
80
+ #cards() {
81
+ if (!this.#tabled) {
82
+ this
83
+ .sql `CREATE TABLE IF NOT EXISTS lumen_cards (id TEXT PRIMARY KEY, kind TEXT NOT NULL, grp TEXT NOT NULL, tool TEXT NOT NULL, args TEXT NOT NULL, result TEXT, status TEXT NOT NULL, error TEXT, told INTEGER NOT NULL DEFAULT 0, at INTEGER NOT NULL)`;
84
+ this.#tabled = true;
85
+ }
86
+ }
87
+ #addCard(kind, d, result) {
88
+ this.#cards();
89
+ const id = `card_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
90
+ this
91
+ .sql `INSERT INTO lumen_cards (id, kind, grp, tool, args, result, status, at) VALUES (${id}, ${kind}, ${d.group}, ${d.tool}, ${JSON.stringify(d.args ?? null)}, ${result === undefined ? null : JSON.stringify(result).slice(0, 20_000)}, ${kind === 'draft' ? 'open' : 'done'}, ${Date.now()})`;
92
+ return id;
93
+ }
94
+ #card(id) {
95
+ this.#cards();
96
+ const [r] = this.sql `SELECT * FROM lumen_cards WHERE id = ${id} LIMIT 1`;
97
+ return r ? cardOf(r) : null;
98
+ }
99
+ #setCard(id, status, o = {}) {
100
+ this
101
+ .sql `UPDATE lumen_cards SET status = ${status}, result = COALESCE(${o.result === undefined ? null : JSON.stringify(o.result).slice(0, 20_000)}, result), error = ${o.error ?? null}, told = 0 WHERE id = ${id}`;
102
+ }
103
+ /** The chat's cards, newest last: drafts and writes made at once (at most 200). */
104
+ cards() {
105
+ this.#cards();
106
+ return this.sql `SELECT * FROM lumen_cards ORDER BY at DESC LIMIT 200`
107
+ .map(cardOf)
108
+ .reverse();
109
+ }
110
+ /** The person saved a draft: its write runs now. */
111
+ async saveCard(id) {
112
+ const c = this.#card(id);
113
+ if (!c || c.kind !== 'draft')
114
+ throw new Error('That card is gone.');
115
+ if (c.status !== 'open')
116
+ throw new Error(`That card is already ${c.status}.`);
117
+ try {
118
+ this.#setCard(id, 'saved', { result: await this.applyDraft(c) });
119
+ }
120
+ catch (err) {
121
+ this.#setCard(id, 'failed', { error: String(err?.message ?? err).slice(0, 300) });
122
+ }
123
+ return this.#card(id);
124
+ }
125
+ /** The person dismissed a draft: nothing changes. */
126
+ dismissCard(id) {
127
+ const c = this.#card(id);
128
+ if (!c || c.kind !== 'draft')
129
+ throw new Error('That card is gone.');
130
+ if (c.status !== 'open')
131
+ throw new Error(`That card is already ${c.status}.`);
132
+ this.#setCard(id, 'dismissed');
133
+ return this.#card(id);
134
+ }
135
+ /** The person undid a write made at once. */
136
+ async undoCard(id) {
137
+ const c = this.#card(id);
138
+ if (!c || c.kind !== 'write')
139
+ throw new Error('That change is gone.');
140
+ if (c.status === 'undone')
141
+ throw new Error('That change is already undone.');
142
+ await this.undoWrite(c);
143
+ this.#setCard(id, 'undone');
144
+ return this.#card(id);
145
+ }
146
+ /** Cards settled since the model last heard, as its note; marks them told. */
147
+ #news() {
148
+ this.#cards();
149
+ const rows = this
150
+ .sql `SELECT * FROM lumen_cards WHERE told = 0 AND status IN ('saved', 'dismissed', 'failed', 'undone') ORDER BY at LIMIT 50`;
151
+ if (!rows.length)
152
+ return '';
153
+ for (const r of rows)
154
+ this.sql `UPDATE lumen_cards SET told = 1 WHERE id = ${r.id}`;
155
+ return cardNews(rows.map(cardOf));
156
+ }
157
+ /** Think compacts the chat past `COMPACT_AT` tokens, on the last turn's model and key. */
158
+ configureSession(session) {
159
+ return session
160
+ .onCompaction(createCompactFunction({ summarize: (prompt) => this.#summarize(prompt) }))
161
+ .compactAfter(COMPACT_AT);
162
+ }
163
+ async #summarize(prompt) {
164
+ const last = this.#last;
165
+ if (!last)
166
+ throw new Error('No model to compact with until the next answer.');
167
+ const { plan } = last;
168
+ const meter = new UsageMeter(plan.model, `${plan.label} · compact`, !!plan.key.plan);
169
+ this.#owed.push(meter);
170
+ const r = await generateText({
171
+ model: aiModel(plan.model, plan.key, {
172
+ ...(plan.fetch ? { fetch: plan.fetch } : {}),
173
+ name: this.constructor.name,
174
+ }),
175
+ prompt,
176
+ ...turnSettings(plan.model, { effort: 'default', out: 4000, plan: !!plan.key.plan }),
177
+ });
178
+ meter.add(r.usage);
179
+ return r.text;
180
+ }
8
181
  /** Never read: `beforeTurn` gives every turn its own model. */
9
182
  getModel() {
10
183
  return '@cf/none';
@@ -12,22 +185,58 @@ export class LumenAgent extends Think {
12
185
  async beforeTurn(ctx) {
13
186
  await this.#close();
14
187
  const plan = await this.plan(ctx);
15
- const tools = await openAiTools(plan.sources, plan.tools ?? {});
188
+ this.#last = { plan };
189
+ const opened = await openAiTools(plan.sources, {
190
+ draft: async (d) => ({ card: this.#addCard('draft', d), note: CARD_NOTE }),
191
+ wrote: async (w) => this.#addCard('write', w, w.result),
192
+ ...plan.tools,
193
+ });
16
194
  const free = !!plan.key.plan;
195
+ const main = new UsageMeter(plan.model, plan.label, free);
196
+ const meters = [main, ...this.#owed];
197
+ this.#owed = [];
198
+ const advice = plan.advisor
199
+ ? new UsageMeter(plan.advisor.model, plan.advisor.label, free)
200
+ : undefined;
201
+ if (advice)
202
+ meters.push(advice);
203
+ const system = typeof plan.system === 'string' ? plan.system : plan.system(opened);
17
204
  this.#turn = {
18
205
  plan,
19
- tools,
20
- meter: new UsageMeter(plan.model, plan.label, free),
206
+ system,
207
+ tools: opened,
208
+ main,
209
+ ...(advice ? { advice } : {}),
210
+ meters,
211
+ steps: 0,
21
212
  capped: false,
22
213
  t0: Date.now(),
23
214
  };
215
+ const more = plan.more?.({
216
+ opened,
217
+ over: () => plan.left !== undefined && this.#spent() >= plan.left,
218
+ meter: (model, label, f = free) => {
219
+ const m = new UsageMeter(model, label, f);
220
+ meters.push(m);
221
+ return m;
222
+ },
223
+ });
224
+ const advisor = {};
225
+ if (plan.advisor)
226
+ advisor.advisor = anthropic.tools.advisor_20260301({
227
+ model: plan.advisor.model.id,
228
+ maxUses: plan.advisor.maxUses,
229
+ maxTokens: plan.advisor.maxTokens,
230
+ });
24
231
  return {
25
232
  model: aiModel(plan.model, plan.key, {
26
233
  ...(plan.fetch ? { fetch: plan.fetch } : {}),
27
234
  name: this.constructor.name,
28
235
  }),
29
- instructions: plan.system,
30
- tools: tools.tools,
236
+ instructions: system,
237
+ messages: withLead(ctx.messages, `${this.#news()}${plan.lead ?? ''}`),
238
+ tools: { ...opened.tools, ...more, ...advisor },
239
+ ...(plan.first ? { toolChoice: { type: 'tool', toolName: plan.first } } : {}),
31
240
  maxSteps: plan.maxSteps ?? 8,
32
241
  ...turnSettings(plan.model, {
33
242
  effort: plan.effort ?? 'default',
@@ -36,22 +245,38 @@ export class LumenAgent extends Think {
36
245
  }),
37
246
  };
38
247
  }
39
- beforeStep(_ctx) {
248
+ beforeStep(ctx) {
40
249
  const t = this.#turn;
41
- if (!t || t.plan.left === undefined)
42
- return undefined;
43
- if (t.meter.part().usd < t.plan.left)
250
+ if (!t)
44
251
  return undefined;
45
- t.capped = true;
46
- // The SDK's step result is a union that Omit narrows to shared keys; these two exist on it.
47
- return { activeTools: [], instructions: `${t.plan.system}\n\n${CAP_NOTE}` };
252
+ // The SDK's step result is a union that Omit narrows to shared keys; these exist on it.
253
+ if (t.plan.left !== undefined && this.#spent() >= t.plan.left) {
254
+ t.capped = true;
255
+ return { activeTools: [], instructions: `${t.system}\n\n${CAP_NOTE}` };
256
+ }
257
+ // A forced first call is for the first step only.
258
+ if (t.plan.first && ctx.stepNumber > 0)
259
+ return { toolChoice: 'auto' };
260
+ return undefined;
48
261
  }
49
262
  onStepFinish(ctx) {
50
263
  const t = this.#turn;
51
264
  if (!t)
52
265
  return;
53
- const p = t.meter.add(ctx.usage);
266
+ t.steps += 1;
267
+ const p = t.main.add(ctx.usage);
54
268
  t.context = p.in + p.out;
269
+ // Claude's advisor bills apart from the step's own usage: its calls are listed one by one.
270
+ const meta = ctx
271
+ .providerMetadata;
272
+ for (const it of meta?.anthropic?.iterations ?? [])
273
+ if (it.type === 'advisor_message' && t.advice)
274
+ t.advice.addTokens({
275
+ input_tokens: it.inputTokens ?? 0,
276
+ output_tokens: it.outputTokens ?? 0,
277
+ cache_read_input_tokens: it.cacheReadInputTokens ?? 0,
278
+ cache_creation_input_tokens: it.cacheCreationInputTokens ?? 0,
279
+ });
55
280
  }
56
281
  async onChatResponse(result) {
57
282
  await this.#end(result.status === 'completed' ? 'ok' : result.status === 'aborted' ? 'stopped' : 'error');
@@ -60,6 +285,9 @@ export class LumenAgent extends Think {
60
285
  void this.#end('error');
61
286
  return error;
62
287
  }
288
+ #spent() {
289
+ return (this.#turn?.meters ?? []).reduce((a, m) => a + m.part().usd, 0);
290
+ }
63
291
  async #end(outcome) {
64
292
  const t = this.#turn;
65
293
  if (!t)
@@ -69,8 +297,9 @@ export class LumenAgent extends Think {
69
297
  try {
70
298
  await this.settle({
71
299
  outcome,
72
- parts: [t.meter.part()],
300
+ parts: t.meters.map((m) => m.part()).filter((p) => p.calls),
73
301
  ...(t.context ? { context: t.context } : {}),
302
+ steps: t.steps,
74
303
  ms: Date.now() - t.t0,
75
304
  capped: t.capped,
76
305
  });
@@ -1,5 +1,5 @@
1
1
  import type { AssistantEffort } from '@columenlabs/lumen-ui-types/assistant';
2
- import type { LanguageModel } from 'ai';
2
+ import type { JSONValue, LanguageModel } from 'ai';
3
3
  import type { ModelDef } from '../assistant/models.js';
4
4
  /** The key a model runs on. `plan`: an OpenAI access token from a ChatGPT plan sign-in. */
5
5
  export interface ModelKey {
@@ -28,5 +28,5 @@ export declare function turnSettings(model: ModelDef, o: {
28
28
  plan?: boolean;
29
29
  }): {
30
30
  maxOutputTokens?: number;
31
- providerOptions?: Record<string, Record<string, unknown>>;
31
+ providerOptions?: Record<string, Record<string, JSONValue>>;
32
32
  };
@@ -23,6 +23,10 @@ export interface DraftCall {
23
23
  export interface AiToolsOptions {
24
24
  /** A draft's card was made: return what the model is told (its id, "a card is on screen"). */
25
25
  draft?: (d: DraftCall) => Promise<unknown>;
26
+ /** A write ran at once: what it did, for Undo. Its answer is added to the model's result. */
27
+ wrote?: (w: DraftCall & {
28
+ result: unknown;
29
+ }) => Promise<unknown>;
26
30
  /** Every call's outcome, for the app's meter: shapes only. */
27
31
  called?: (c: {
28
32
  group: string;
@@ -33,11 +37,20 @@ export interface AiToolsOptions {
33
37
  /** A result handed to the model is cut past this many characters. */
34
38
  maxChars?: number;
35
39
  }
40
+ /** One tool offered this answer, as the instructions describe the set. */
41
+ export interface ToolInfo {
42
+ name: string;
43
+ group: string;
44
+ kind: 'read' | 'draft' | 'write';
45
+ find: boolean;
46
+ }
36
47
  /** Tools open for one answer, as the AI SDK's set, and how to let them go. */
37
48
  export interface OpenedTools {
38
49
  tools: ToolSet;
39
50
  /** Names of the reads, for a lazy loader's catalog or `activeTools`. */
40
51
  reads: string[];
52
+ /** Each tool offered: its name, group, kind and whether it is an app's find. */
53
+ info: ToolInfo[];
41
54
  failed: number;
42
55
  close(): Promise<void>;
43
56
  }
@@ -25,6 +25,7 @@ export async function openAiTools(sources, o = {}) {
25
25
  const max = o.maxChars ?? 16_000;
26
26
  const tools = {};
27
27
  const reads = [];
28
+ const info = [];
28
29
  for (const { s, open } of opened)
29
30
  for (const def of open.tools) {
30
31
  const canWrite = s.access === 'draft' || s.access === 'write';
@@ -36,6 +37,7 @@ export async function openAiTools(sources, o = {}) {
36
37
  const name = toolName(s.group, def.name);
37
38
  if (kind === 'read')
38
39
  reads.push(name);
40
+ info.push({ name, group: s.group, kind, find: !!def.find });
39
41
  tools[name] = tool({
40
42
  description: describe(def, kind),
41
43
  inputSchema: jsonSchema(withoutMeta(def.inputSchema)),
@@ -47,7 +49,13 @@ export async function openAiTools(sources, o = {}) {
47
49
  const out = await open.call(def.name, args);
48
50
  o.called?.({ group: s.group, tool: def.name, ok: true, ms: Date.now() - t0 });
49
51
  const text = typeof out === 'string' ? out : JSON.stringify(out);
50
- return text.length > max ? `${text.slice(0, max)} …(cut)` : out;
52
+ const shown = text.length > max ? `${text.slice(0, max)} …(cut)` : out;
53
+ if (kind === 'write' && o.wrote) {
54
+ const change = await o.wrote({ group: s.group, tool: def.name, args, result: out });
55
+ if (change !== undefined)
56
+ return { result: shown, change };
57
+ }
58
+ return shown;
51
59
  }
52
60
  catch (err) {
53
61
  o.called?.({ group: s.group, tool: def.name, ok: false, ms: Date.now() - t0 });
@@ -59,6 +67,7 @@ export async function openAiTools(sources, o = {}) {
59
67
  return {
60
68
  tools,
61
69
  reads,
70
+ info,
62
71
  failed: results.filter((r) => r.status === 'rejected').length,
63
72
  close: async () => {
64
73
  await Promise.all(opened.map((x) => x.open.close().catch(() => undefined)));
@@ -1,7 +1,7 @@
1
1
  /** An AI SDK step's usage as the kit's tokens and list-price cost. */
2
2
  import type { UsagePart } from '@columenlabs/lumen-ui-types/assistant';
3
3
  import type { LanguageModelUsage } from 'ai';
4
- import { type ModelDef } from '../assistant/models.js';
4
+ import { type ModelDef, type TokenUsage } from '../assistant/models.js';
5
5
  /**
6
6
  * One call's usage in the kit's terms. Input splits into plain, cache writes and cache reads where
7
7
  * the provider says (Claude reports all three; OpenAI reports reads, its writes cost nothing extra).
@@ -25,5 +25,12 @@ export declare class UsageMeter {
25
25
  read: number;
26
26
  usd: number;
27
27
  };
28
+ /** Tokens a provider reports apart from the step's usage (Claude's advisor calls). */
29
+ addTokens(u: TokenUsage): {
30
+ in: number;
31
+ out: number;
32
+ read: number;
33
+ usd: number;
34
+ };
28
35
  part(): UsagePart;
29
36
  }
@@ -27,7 +27,11 @@ export class UsageMeter {
27
27
  this.p = { label, calls: 0, in: 0, read: 0, out: 0, usd: 0 };
28
28
  }
29
29
  add(u) {
30
- const t = priced(this.model, tokenUsage(u));
30
+ return this.addTokens(tokenUsage(u));
31
+ }
32
+ /** Tokens a provider reports apart from the step's usage (Claude's advisor calls). */
33
+ addTokens(u) {
34
+ const t = priced(this.model, u);
31
35
  this.p.calls += 1;
32
36
  this.p.in += t.in;
33
37
  this.p.read += t.read;
@@ -1,4 +1,5 @@
1
1
  import type { AllowanceRequest, AllowanceState, AllowanceUnit, AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantOwnWay, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
2
+ import type { AgentTurnEnd, AgentTurnPlan } from '../agent/agent.js';
2
3
  import { type BearingsExtra, type BearingsNow } from './bearings.js';
3
4
  import { type ModelDef, type TaskDef, type TierDef } from './models.js';
4
5
  import { State } from './state.js';
@@ -173,6 +174,18 @@ export declare class AssistantError extends Error {
173
174
  constructor(message: string, code: string, status?: number);
174
175
  }
175
176
  type Emit = (event: string, data: unknown) => void;
177
+ /** What a Think turn calls this turn: what one turn asks besides the message itself. */
178
+ export interface AgentTurnInput {
179
+ message: string;
180
+ context?: AssistantMessage['context'];
181
+ refs?: AssistantMessage['refs'];
182
+ /** The tier the person picked for this message (`@`). */
183
+ tier?: string;
184
+ /** The browser's IANA zone, for the bearings. */
185
+ time_zone?: string;
186
+ /** When the chat's last message was, for "since you last asked". */
187
+ last_at?: number;
188
+ }
176
189
  export declare class Assistant {
177
190
  private readonly o;
178
191
  readonly name: string;
@@ -203,6 +216,35 @@ export declare class Assistant {
203
216
  private mainTier;
204
217
  private access;
205
218
  private groupById;
219
+ /**
220
+ * One turn as a Think agent's plan (`LumenAgent` in `@columenlabs/lumen-ai/agent`): the person's
221
+ * tier and its model on whoever pays, the tool groups at their access, the kit's rules, the
222
+ * bearings and the page, hand off to a higher tier and Claude's advisor. `settle` records the
223
+ * spend and the turn. Throws `AssistantError` when nobody can pay (its text says what to do).
224
+ * Drafts, Undo and compaction stay with `turn` for now.
225
+ */
226
+ agentTurn(caller: Caller, input: AgentTurnInput): Promise<{
227
+ plan: AgentTurnPlan;
228
+ settle: (end: AgentTurnEnd) => Promise<void>;
229
+ }>;
230
+ /**
231
+ * Saves a Think chat's draft card (`LumenAgent.applyDraft`): its group's write runs as the person,
232
+ * while that group is still on Read + draft or Read + write.
233
+ */
234
+ runDraft(caller: Caller, card: {
235
+ group: string;
236
+ tool: string;
237
+ args: unknown;
238
+ }): Promise<unknown>;
239
+ /** Takes back a Think chat's write made at once (`LumenAgent.undoWrite`) through its group's `undo`. */
240
+ undoChange(caller: Caller, card: {
241
+ group: string;
242
+ tool: string;
243
+ args: unknown;
244
+ result?: unknown;
245
+ }): Promise<void>;
246
+ /** The escalate tool of a Think turn: one task to a higher tier, its memo back. */
247
+ private agentEscalate;
206
248
  status(caller: Caller): Promise<AssistantStatus>;
207
249
  private get unit();
208
250
  private approver;
@@ -222,6 +264,8 @@ export declare class Assistant {
222
264
  purge(): Promise<number>;
223
265
  private writeChat;
224
266
  /** Opens the tools the person allows: each group's, then the web's. */
267
+ /** The tool groups (and the web) at the person's access, not opened yet. */
268
+ private sourcesOf;
225
269
  private openTools;
226
270
  private toolsFor;
227
271
  /** `batch`: any of the reads, up to BATCH_MAX at once, as one step. Null with no reads. */
@@ -18,7 +18,9 @@
18
18
  */
19
19
  import Anthropic from '@anthropic-ai/sdk';
20
20
  import { HAND_OFF_WHYS } from '@columenlabs/lumen-ui-types/assistant';
21
+ import { generateText, jsonSchema, stepCountIs, tool } from 'ai';
21
22
  import OpenAI from 'openai';
23
+ import { aiModel, turnSettings } from '../agent/models.js';
22
24
  import { bearings } from './bearings.js';
23
25
  import { CHATGPT_REDIRECT, SigninError, finishChatgpt, startChatgpt, testChatgpt, } from './chatgpt.js';
24
26
  import { ALL_MODELS, DEFAULT_OUT, Meter, PROVIDERS, effortParams, } from './models.js';
@@ -229,6 +231,29 @@ const withoutMeta = (schema) => {
229
231
  const shows = (output) => output.some((b) => b.type === 'function_call' ||
230
232
  (b.type === 'message' && b.content.some((c) => c.type === 'output_text' && c.text)));
231
233
  const isApiError = (err) => err instanceof Anthropic.APIError || err instanceof OpenAI.APIError;
234
+ /**
235
+ * The tools of a Think turn as the rules describe them. Think calls several tools in one step on
236
+ * its own, so the kit's batch tool is not offered and its line is left out (`batch`).
237
+ */
238
+ function openedSummary(t) {
239
+ return {
240
+ size: t.info.length,
241
+ find: t.info.some((i) => i.find),
242
+ reads: t.reads.length > 0,
243
+ batch: true,
244
+ web: t.info.some((i) => i.group === 'web'),
245
+ };
246
+ }
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
+ }
232
257
  export class Assistant {
233
258
  o;
234
259
  name;
@@ -338,6 +363,245 @@ export class Assistant {
338
363
  groupById(id) {
339
364
  return this.groups.find((g) => g.id === id);
340
365
  }
366
+ // ---------------------------------------------------------------- a turn on Think
367
+ /**
368
+ * One turn as a Think agent's plan (`LumenAgent` in `@columenlabs/lumen-ai/agent`): the person's
369
+ * tier and its model on whoever pays, the tool groups at their access, the kit's rules, the
370
+ * bearings and the page, hand off to a higher tier and Claude's advisor. `settle` records the
371
+ * spend and the turn. Throws `AssistantError` when nobody can pay (its text says what to do).
372
+ * Drafts, Undo and compaction stay with `turn` for now.
373
+ */
374
+ async agentTurn(caller, input) {
375
+ const message = input.message.trim();
376
+ const picked = await this.mainTier(caller);
377
+ const lower = input.tier && !this.above(picked).some((t) => t.id === input.tier)
378
+ ? this.tierById(input.tier)
379
+ : undefined;
380
+ const main = await this.session(caller, lower ?? picked);
381
+ const { paying, wait } = await this.state.paying(caller.id, main.model.provider);
382
+ if (!paying) {
383
+ const code = wait ?? 'not_connected';
384
+ throw new AssistantError(waitText(code, main.model.provider, !!this.o.allowance), code, 402);
385
+ }
386
+ const keyOf = (provider) => {
387
+ const apiKey = paying.keys[provider];
388
+ return apiKey ? { apiKey, ...(paying.plans[provider] ? { plan: true } : {}) } : null;
389
+ };
390
+ const key = keyOf(main.model.provider) ?? { apiKey: paying.apiKey };
391
+ const p = await this.state.person(caller.id);
392
+ const policy = p.hand_off ?? 'next';
393
+ const advisor = await this.advisorFor(caller, p.advice, main);
394
+ const handTo = input.tier ? this.above(main.tier).find((t) => t.id === input.tier) : undefined;
395
+ const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
396
+ const asked = await this.expand(caller, message);
397
+ const told = await bearings({
398
+ timeZone: input.time_zone,
399
+ fallbackZone: this.o.timeZone,
400
+ lastAt: input.last_at,
401
+ app: this.o.app,
402
+ ...(this.o.bearings ? { extras: (b) => this.o.bearings?.(b, caller) } : {}),
403
+ });
404
+ const page = told +
405
+ (input.refs?.length
406
+ ? `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(input.refs).slice(0, 16000)}\n\n`
407
+ : '') +
408
+ (input.context
409
+ ? `Page: ${input.context.label}${input.context.note === undefined ? '' : `\n${JSON.stringify(input.context.note).slice(0, 4000)}`}\n\n`
410
+ : '');
411
+ const command = asked === message ? '' : `The person typed ${message.split(/\s/)[0]}: ${asked}\n\n`;
412
+ const reach = this.above(main.tier);
413
+ const counts = { handoffs: 0, end: main.tier };
414
+ const rulesFor = (s, t) => `${appSystem}\n\n${this.rules(s, policy, s ? advisor : null, openedSummary(t), t.failed)}`;
415
+ const plan = {
416
+ model: main.model,
417
+ key,
418
+ effort: main.effort,
419
+ out: main.tier.out ?? DEFAULT_OUT,
420
+ label: main.meter.label,
421
+ system: (t) => rulesFor(main, t),
422
+ lead: `${page}${handTo && reach.length
423
+ ? `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
+ : ''}${command}`,
425
+ sources: await this.sourcesOf(caller, p),
426
+ left: paying.left,
427
+ maxSteps: MAX_ROUNDS,
428
+ ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
429
+ ...(handTo && reach.length ? { first: 'escalate' } : {}),
430
+ ...(advisor
431
+ ? {
432
+ advisor: {
433
+ model: advisor.session.model,
434
+ label: advisor.session.meter.label,
435
+ maxUses: ADVICE_MAX,
436
+ maxTokens: ADVICE_TOKENS,
437
+ },
438
+ }
439
+ : {}),
440
+ tools: {
441
+ called: (c) => void Promise.resolve(this.o.onStep?.({
442
+ kind: 'tool',
443
+ tool: toolName(c.group, c.tool),
444
+ ok: c.ok,
445
+ ms: c.ms,
446
+ tier: main.tier.id,
447
+ handoff: false,
448
+ })).catch(() => undefined),
449
+ },
450
+ more: (tt) => reach.length
451
+ ? {
452
+ escalate: this.agentEscalate(tt, {
453
+ caller,
454
+ message: `${page}${asked}`,
455
+ asked: this.askedFor(message, main.tier),
456
+ handTo,
457
+ policy,
458
+ main,
459
+ keyOf,
460
+ counts,
461
+ system: (t) => rulesFor(null, t),
462
+ }),
463
+ }
464
+ : {},
465
+ };
466
+ const settle = async (end) => {
467
+ await this.state.addSpend(caller.id, paying.payer, end.parts).catch(() => undefined);
468
+ const total = end.parts.reduce((a, m) => ({
469
+ calls: a.calls + m.calls,
470
+ in: a.in + m.in,
471
+ out: a.out + m.out,
472
+ usd: a.usd + m.usd,
473
+ }), { calls: 0, in: 0, out: 0, usd: 0 });
474
+ await Promise.resolve(this.o.onStep?.({
475
+ kind: 'turn',
476
+ outcome: end.outcome,
477
+ ms: end.ms,
478
+ tier: main.tier.id,
479
+ end_tier: counts.end.id,
480
+ handoffs: counts.handoffs,
481
+ advice: advisor
482
+ ? (end.parts.find((x) => x.label === advisor.session.meter.label)?.calls ?? 0)
483
+ : 0,
484
+ steps: end.steps,
485
+ ...total,
486
+ })).catch(() => undefined);
487
+ };
488
+ return { plan, settle };
489
+ }
490
+ /**
491
+ * Saves a Think chat's draft card (`LumenAgent.applyDraft`): its group's write runs as the person,
492
+ * while that group is still on Read + draft or Read + write.
493
+ */
494
+ async runDraft(caller, card) {
495
+ const g = this.groupById(card.group);
496
+ const p = await this.state.person(caller.id);
497
+ const access = g ? this.access(p, g) : 'off';
498
+ if (access !== 'draft' && access !== 'write')
499
+ throw new AssistantError('Its tools are no longer on Read + draft or Read + write in Settings ▸ Tools.', 'off', 409);
500
+ const sources = (await this.sourcesOf(caller, p)).filter((x) => x.group === card.group);
501
+ for (const x of sources) {
502
+ const open = await x.source();
503
+ try {
504
+ const def = open.tools.find((d) => d.name === card.tool);
505
+ if (!def)
506
+ continue;
507
+ if (def.read)
508
+ throw new AssistantError('That card is not a change.', 'not_write', 400);
509
+ return await withDeadline(card.tool, 60_000, open.call(card.tool, card.args));
510
+ }
511
+ finally {
512
+ await open.close().catch(() => undefined);
513
+ }
514
+ }
515
+ throw new AssistantError('That tool is gone.', 'not_found', 404);
516
+ }
517
+ /** Takes back a Think chat's write made at once (`LumenAgent.undoWrite`) through its group's `undo`. */
518
+ async undoChange(caller, card) {
519
+ const g = this.groupById(card.group);
520
+ if (!g?.undo)
521
+ throw new AssistantError('That change cannot be undone here.', 'no_undo', 400);
522
+ await withDeadline(`Undo ${card.tool}`, 60_000, g.undo(caller, { tool: card.tool, args: card.args, result: card.result }));
523
+ }
524
+ /** The escalate tool of a Think turn: one task to a higher tier, its memo back. */
525
+ agentEscalate(tt, c) {
526
+ const reach = this.above(c.main.tier);
527
+ return tool({
528
+ description: 'Hands one task to a higher tier with the same tools. It works on its own and returns a memo of at most 200 words.',
529
+ inputSchema: jsonSchema({
530
+ type: 'object',
531
+ properties: {
532
+ tier: { type: 'string', enum: reach.map((t) => t.id) },
533
+ why: { type: 'string', enum: [...HAND_OFF_WHYS] },
534
+ task: {
535
+ type: 'string',
536
+ description: 'The task, with every fact from the chat it needs.',
537
+ },
538
+ },
539
+ required: ['tier', 'why', 'task'],
540
+ }),
541
+ execute: async (input, { abortSignal }) => {
542
+ const tier = reach.find((t) => t.id === input.tier);
543
+ const why = HAND_OFF_WHYS.find((w) => w === input.why);
544
+ const task = typeof input.task === 'string' ? input.task.trim() : '';
545
+ if (!tier || !why || !task)
546
+ return {
547
+ error: `Give {tier: ${reach.map((t) => `"${t.id}"`).join(' | ')}, why: ${HAND_OFF_WHYS.map((w) => `"${w}"`).join(' | ')}, task}.`,
548
+ };
549
+ const asked = new Set(c.asked);
550
+ if (c.handTo)
551
+ asked.add(c.handTo.id);
552
+ const own = c.policy === 'any' ? reach : c.policy === 'next' ? reach.slice(0, 1) : [];
553
+ const may = new Set([...own.map((t) => t.id), ...asked]);
554
+ if (!may.has(tier.id))
555
+ return {
556
+ error: may.size
557
+ ? `Not ${tier.id} now: you may call ${[...may].join(' or ')}.`
558
+ : 'The person has not asked for a higher tier: answer yourself.',
559
+ };
560
+ if (tt.over())
561
+ return { error: 'No money left for a higher tier this answer: answer yourself.' };
562
+ const sub = await this.session(c.caller, tier);
563
+ const key = c.keyOf(sub.model.provider);
564
+ const provider = PROVIDERS[sub.model.provider].label;
565
+ if (!key)
566
+ return {
567
+ error: `${tier.label} runs on ${provider} and no ${provider} key is set for whoever pays this answer: answer yourself.`,
568
+ };
569
+ const meter = tt.meter(sub.model, sub.meter.label, !!key.plan);
570
+ c.counts.handoffs += 1;
571
+ if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
572
+ c.counts.end = tier;
573
+ const shown = asked.has(tier.id) ? 'asked' : why;
574
+ try {
575
+ const r = await generateText({
576
+ model: aiModel(sub.model, key, {
577
+ ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
578
+ name: this.name,
579
+ }),
580
+ instructions: c.system(tt.opened),
581
+ prompt: `${c.message}\n\n${this.name} handed you this task (${WHY_TEXT[shown]}): ${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.`,
582
+ tools: tt.opened.tools,
583
+ stopWhen: stepCountIs(MAX_ROUNDS),
584
+ prepareStep: () => (tt.over() ? { activeTools: [] } : undefined),
585
+ onStepFinish: (s) => {
586
+ meter.add(s.usage);
587
+ },
588
+ ...(abortSignal ? { abortSignal } : {}),
589
+ ...turnSettings(sub.model, {
590
+ effort: sub.effort,
591
+ out: tier.out ?? DEFAULT_OUT,
592
+ plan: !!key.plan,
593
+ }),
594
+ });
595
+ return { memo: `Memo from ${sub.meter.label}:\n${r.text.slice(0, MEMO_CHARS)}` };
596
+ }
597
+ catch (err) {
598
+ if (abortSignal?.aborted)
599
+ throw err;
600
+ return { error: `${sub.meter.label} failed: ${shownError(err).message}` };
601
+ }
602
+ },
603
+ });
604
+ }
341
605
  // ---------------------------------------------------------------- status and settings
342
606
  async status(caller) {
343
607
  const main = await this.mainTier(caller);
@@ -583,8 +847,8 @@ export class Assistant {
583
847
  }
584
848
  // ---------------------------------------------------------------- tools
585
849
  /** Opens the tools the person allows: each group's, then the web's. */
586
- async openTools(caller, p) {
587
- const opened = [];
850
+ /** The tool groups (and the web) at the person's access, not opened yet. */
851
+ async sourcesOf(caller, p) {
588
852
  const sources = [];
589
853
  for (const g of this.groups) {
590
854
  const access = this.access(p, g);
@@ -592,7 +856,7 @@ export class Assistant {
592
856
  continue;
593
857
  const s = g.tools(caller);
594
858
  for (const source of Array.isArray(s) ? s : [s])
595
- sources.push({ group: g.id, access, source });
859
+ sources.push({ group: g.id, access, source, ...(g.writes ? { writes: true } : {}) });
596
860
  }
597
861
  if (p.web ?? true) {
598
862
  if (this.o.web?.fetch !== false)
@@ -610,6 +874,11 @@ export class Assistant {
610
874
  source: scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch),
611
875
  });
612
876
  }
877
+ return sources;
878
+ }
879
+ async openTools(caller, p) {
880
+ const opened = [];
881
+ const sources = await this.sourcesOf(caller, p);
613
882
  const results = await Promise.allSettled(sources.map((s) => s.source()));
614
883
  results.forEach((r, i) => {
615
884
  const s = sources[i];
@@ -796,7 +1065,7 @@ export class Assistant {
796
1065
  let outcome = 'ok';
797
1066
  try {
798
1067
  const appSystem = typeof this.o.system === 'string' ? this.o.system : await this.o.system(caller);
799
- const kitRules = this.rules(main, policy, advisor, tools.fns, tools.failed);
1068
+ const kitRules = this.rules(main, policy, advisor, summarize(tools.fns), tools.failed);
800
1069
  const system = [
801
1070
  { type: 'text', text: appSystem },
802
1071
  { type: 'text', text: kitRules, cache_control: { type: 'ephemeral' } },
@@ -805,7 +1074,7 @@ export class Assistant {
805
1074
  { type: 'text', text: appSystem },
806
1075
  {
807
1076
  type: 'text',
808
- text: this.rules(null, policy, null, tools.fns, tools.failed),
1077
+ text: this.rules(null, policy, null, summarize(tools.fns), tools.failed),
809
1078
  cache_control: { type: 'ephemeral' },
810
1079
  },
811
1080
  ];
@@ -960,12 +1229,12 @@ export class Assistant {
960
1229
  const lines = [
961
1230
  `You are ${this.name}. Each message starts with <bearings>: the time now in the person's zone and where they are. Take today, now and how long ago from it; never guess the date. It is for you only: never quote it or start an answer with it.`,
962
1231
  'Answer in short plain sentences. Use the tools for facts; never guess a number a tool can give you. Call several tools at once when they do not depend on each other.',
963
- ...([...fns.values()].some((f) => f.def.find)
1232
+ ...(fns.find
964
1233
  ? [
965
1234
  'Questions about an app\'s records go through its find tool (the tool named <app>_find): put every condition of the question in one call (filters, words to include or leave out, which fields to look in), ask only for the fields the answer needs, then judge the rest yourself from what it returns. Never list or open records one by one to filter them. A loose ask (a mood, an occasion, "something light", "good for a party") goes in its like field when the tool has one; then pick from what it returns yourself. How many match is the result\'s count: say that number (a like search has no count, only the nearest rows: never give a number of matches for it), and name each row in same_name, which are different records that share a name. Rows a find already returned you use again only when they answer the whole new question; a new condition (another ingredient, a kind of dish, how quick) is a new find call.',
966
1235
  ]
967
1236
  : []),
968
- ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
1237
+ ...(fns.reads && !fns.batch
969
1238
  ? [
970
1239
  `To read several records, or one tool with several inputs, put the reads in one ${BATCH} call: one step for all of them, never one call at a time. Results you already have from earlier in the chat you use, without reading them again.`,
971
1240
  ]
@@ -979,7 +1248,7 @@ export class Assistant {
979
1248
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
980
1249
  if (failed)
981
1250
  lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
982
- if ([...fns.values()].some((f) => f.group === 'web'))
1251
+ if (fns.web)
983
1252
  lines.push('Web: use web__fetch first for APIs and plain pages; the browser tools only for pages that need JavaScript or after a fetch fails.');
984
1253
  if (main) {
985
1254
  const reach = this.above(main.tier);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.177",
3
+ "version": "0.1.179",
4
4
  "description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,8 +48,8 @@
48
48
  "@ai-sdk/openai": "4.0.108",
49
49
  "@anthropic-ai/sdk": "0.133.0",
50
50
  "@cloudflare/think": "0.23.0",
51
- "@columenlabs/lumen-ops": "0.1.177",
52
- "@columenlabs/lumen-ui-types": "0.1.177",
51
+ "@columenlabs/lumen-ops": "0.1.179",
52
+ "@columenlabs/lumen-ui-types": "0.1.179",
53
53
  "agents": "0.32.0",
54
54
  "ai": "7.0.162",
55
55
  "just-bash": "3.6.0",