@columenlabs/lumen-ai 0.1.176 → 0.1.178

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
@@ -210,3 +210,32 @@ ctx.waitUntil(meaningStep({ def: notesFind, sql: d1Sql(env.DB), embed, index: en
210
210
  - `meaningStep` embeds only the rows whose `changed` column moved past its bookmark (`mark`: get and put, kept by the app), at most 50 a step in one embedding call and one upsert, and takes rows the find hides (its `where`) out of the index. It resumes where it stopped and runs safely twice at once; `done` is false while rows are left.
211
211
  - A person's own rows get a `namespace` each (`vectorMeaning` and `meaningStep` take one), so one index serves one app.
212
212
  - When the index cannot answer in 3 s, `like` is left out, the rest of the find runs and the result says so.
213
+
214
+ ## An agent on Cloudflare Think
215
+
216
+ `@columenlabs/lumen-ai/agent` runs one chat as a Cloudflare Think agent (`@cloudflare/think`, experimental) on the app's tiers, keys and tool groups. Think keeps the loop, the messages, streaming, resume and compaction; the kit adds the model on the payer's key, the tools at the person's access, the spend cap and the usage. It will replace `./assistant`'s own loop.
217
+
218
+ | Export | What it does |
219
+ |---|---|
220
+ | `LumenAgent` | An abstract Think agent. The app answers `plan(turn)` (model, key, effort, instructions, tool sources, money left) and hears `settle(end)` (outcome, usage parts, context size, time) when the turn ends |
221
+ | `aiModel(model, key, opts?)` | The AI SDK model for a tier on an API key, or on a ChatGPT plan sign-in (`key.plan`, through `planFetch`) |
222
+ | `planFetch(fetch?, name?)` | Rewrites Responses calls to a plan sign-in's rules: tools in one namespace, no output cap, a forced call as the only tool |
223
+ | `turnSettings(model, opts)` | Output cap and effort as the provider takes them |
224
+ | `openAiTools(sources, opts)` | The app's tool groups as AI SDK tools: reads always, drafts and writes by access, results cut at 16k characters |
225
+ | `UsageMeter`, `tokenUsage(u)` | Prices each step's tokens (cache reads and writes, reasoning) into one usage part |
226
+
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. Drafts, Undo and compaction still run only in `turn`.
230
+
231
+ ```ts
232
+ class Chat extends LumenAgent<Env> {
233
+ #settle?: (end: AgentTurnEnd) => Promise<void>;
234
+ async plan(turn: TurnContext) {
235
+ const { plan, settle } = await assistant.agentTurn(caller, { message: lastText(turn), ...turn.body });
236
+ this.#settle = settle;
237
+ return plan;
238
+ }
239
+ settle(end: AgentTurnEnd) { return this.#settle?.(end) ?? Promise.resolve(); }
240
+ }
241
+ ```
@@ -0,0 +1,93 @@
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 { type ChatResponseResult, type PrepareStepContext, type StepConfig, type StepContext, Think, type ThinkModel, type TurnConfig, type TurnContext } from '@cloudflare/think';
15
+ import type { AssistantEffort, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
16
+ import type { ModelMessage, ToolSet } from 'ai';
17
+ import type { ModelDef } from '../assistant/models.js';
18
+ import { type ModelKey } from './models.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
+ }
29
+ /** What one turn runs on, as the app decides it. */
30
+ export interface AgentTurnPlan {
31
+ /** The tier's model, and the key or sign-in it runs on. */
32
+ model: ModelDef;
33
+ key: ModelKey;
34
+ effort?: AssistantEffort;
35
+ /** Output tokens per call; left out on a plan sign-in, which takes no cap. */
36
+ out?: number;
37
+ /** The usage part's label: `Tier · Model`. */
38
+ label: 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;
43
+ /** The tool groups at the person's access. */
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
+ };
56
+ /** Dollars the payer has left; at or past it the model gets no more tools. */
57
+ left?: number;
58
+ /** Most model calls in one answer; 8. */
59
+ maxSteps?: number;
60
+ /** Fetch for provider calls (a metered one). */
61
+ fetch?: typeof fetch;
62
+ tools?: Omit<AiToolsOptions, 'maxChars'>;
63
+ }
64
+ /** How a turn ended, with its usage, for the app to record. */
65
+ export interface AgentTurnEnd {
66
+ outcome: 'ok' | 'stopped' | 'error';
67
+ /** The main model's part first, then advice and hand offs; parts with no calls left out. */
68
+ parts: UsagePart[];
69
+ /** Tokens in the main model's last call: how full its context is. */
70
+ context?: number;
71
+ /** Model calls of the main tier. */
72
+ steps: number;
73
+ ms: number;
74
+ /** The spend cap stopped tool use in this answer. */
75
+ capped: boolean;
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
+ /** The messages with `lead` put before the newest message the person wrote. */
79
+ export declare function withLead(messages: ModelMessage[], lead: string | undefined): ModelMessage[];
80
+ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
81
+ #private;
82
+ /** The turn the app runs now. Throw to refuse it (no key, no money); the error is shown. */
83
+ abstract plan(turn: TurnContext): Promise<AgentTurnPlan>;
84
+ /** A turn ended: record its spend and how it went. Errors here are logged, never shown. */
85
+ abstract settle(end: AgentTurnEnd): Promise<void>;
86
+ /** Never read: `beforeTurn` gives every turn its own model. */
87
+ getModel(): ThinkModel;
88
+ beforeTurn(ctx: TurnContext): Promise<TurnConfig>;
89
+ beforeStep(ctx: PrepareStepContext): StepConfig | undefined;
90
+ onStepFinish(ctx: StepContext): void;
91
+ onChatResponse(result: ChatResponseResult): Promise<void>;
92
+ onChatError(error: unknown): unknown;
93
+ }
@@ -0,0 +1,166 @@
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';
15
+ import { Think, } from '@cloudflare/think';
16
+ import { aiModel, turnSettings } from './models.js';
17
+ import { openAiTools } from './tools.js';
18
+ import { UsageMeter } from './usage.js';
19
+ 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.';
20
+ /** The messages with `lead` put before the newest message the person wrote. */
21
+ export function withLead(messages, lead) {
22
+ if (!lead)
23
+ return messages;
24
+ let i = messages.length - 1;
25
+ while (i >= 0 && messages[i]?.role !== 'user')
26
+ i -= 1;
27
+ const m = messages[i];
28
+ if (!m || m.role !== 'user')
29
+ return messages;
30
+ const content = typeof m.content === 'string'
31
+ ? `${lead}${m.content}`
32
+ : [{ type: 'text', text: lead }, ...m.content];
33
+ return [...messages.slice(0, i), { ...m, content }, ...messages.slice(i + 1)];
34
+ }
35
+ export class LumenAgent extends Think {
36
+ #turn = null;
37
+ /** Never read: `beforeTurn` gives every turn its own model. */
38
+ getModel() {
39
+ return '@cf/none';
40
+ }
41
+ async beforeTurn(ctx) {
42
+ await this.#close();
43
+ const plan = await this.plan(ctx);
44
+ const opened = await openAiTools(plan.sources, plan.tools ?? {});
45
+ const free = !!plan.key.plan;
46
+ const main = new UsageMeter(plan.model, plan.label, free);
47
+ const meters = [main];
48
+ const advice = plan.advisor
49
+ ? new UsageMeter(plan.advisor.model, plan.advisor.label, free)
50
+ : undefined;
51
+ if (advice)
52
+ meters.push(advice);
53
+ const system = typeof plan.system === 'string' ? plan.system : plan.system(opened);
54
+ this.#turn = {
55
+ plan,
56
+ system,
57
+ tools: opened,
58
+ main,
59
+ ...(advice ? { advice } : {}),
60
+ meters,
61
+ steps: 0,
62
+ capped: false,
63
+ t0: Date.now(),
64
+ };
65
+ const more = plan.more?.({
66
+ opened,
67
+ over: () => plan.left !== undefined && this.#spent() >= plan.left,
68
+ meter: (model, label, f = free) => {
69
+ const m = new UsageMeter(model, label, f);
70
+ meters.push(m);
71
+ return m;
72
+ },
73
+ });
74
+ const advisor = {};
75
+ if (plan.advisor)
76
+ advisor.advisor = anthropic.tools.advisor_20260301({
77
+ model: plan.advisor.model.id,
78
+ maxUses: plan.advisor.maxUses,
79
+ maxTokens: plan.advisor.maxTokens,
80
+ });
81
+ return {
82
+ model: aiModel(plan.model, plan.key, {
83
+ ...(plan.fetch ? { fetch: plan.fetch } : {}),
84
+ name: this.constructor.name,
85
+ }),
86
+ instructions: system,
87
+ messages: withLead(ctx.messages, plan.lead),
88
+ tools: { ...opened.tools, ...more, ...advisor },
89
+ ...(plan.first ? { toolChoice: { type: 'tool', toolName: plan.first } } : {}),
90
+ maxSteps: plan.maxSteps ?? 8,
91
+ ...turnSettings(plan.model, {
92
+ effort: plan.effort ?? 'default',
93
+ ...(plan.out ? { out: plan.out } : {}),
94
+ plan: free,
95
+ }),
96
+ };
97
+ }
98
+ beforeStep(ctx) {
99
+ const t = this.#turn;
100
+ if (!t)
101
+ return undefined;
102
+ // The SDK's step result is a union that Omit narrows to shared keys; these exist on it.
103
+ if (t.plan.left !== undefined && this.#spent() >= t.plan.left) {
104
+ t.capped = true;
105
+ return { activeTools: [], instructions: `${t.system}\n\n${CAP_NOTE}` };
106
+ }
107
+ // A forced first call is for the first step only.
108
+ if (t.plan.first && ctx.stepNumber > 0)
109
+ return { toolChoice: 'auto' };
110
+ return undefined;
111
+ }
112
+ onStepFinish(ctx) {
113
+ const t = this.#turn;
114
+ if (!t)
115
+ return;
116
+ t.steps += 1;
117
+ const p = t.main.add(ctx.usage);
118
+ t.context = p.in + p.out;
119
+ // Claude's advisor bills apart from the step's own usage: its calls are listed one by one.
120
+ const meta = ctx
121
+ .providerMetadata;
122
+ for (const it of meta?.anthropic?.iterations ?? [])
123
+ if (it.type === 'advisor_message' && t.advice)
124
+ t.advice.addTokens({
125
+ input_tokens: it.inputTokens ?? 0,
126
+ output_tokens: it.outputTokens ?? 0,
127
+ cache_read_input_tokens: it.cacheReadInputTokens ?? 0,
128
+ cache_creation_input_tokens: it.cacheCreationInputTokens ?? 0,
129
+ });
130
+ }
131
+ async onChatResponse(result) {
132
+ await this.#end(result.status === 'completed' ? 'ok' : result.status === 'aborted' ? 'stopped' : 'error');
133
+ }
134
+ onChatError(error) {
135
+ void this.#end('error');
136
+ return error;
137
+ }
138
+ #spent() {
139
+ return (this.#turn?.meters ?? []).reduce((a, m) => a + m.part().usd, 0);
140
+ }
141
+ async #end(outcome) {
142
+ const t = this.#turn;
143
+ if (!t)
144
+ return;
145
+ this.#turn = null;
146
+ await t.tools.close();
147
+ try {
148
+ await this.settle({
149
+ outcome,
150
+ parts: t.meters.map((m) => m.part()).filter((p) => p.calls),
151
+ ...(t.context ? { context: t.context } : {}),
152
+ steps: t.steps,
153
+ ms: Date.now() - t.t0,
154
+ capped: t.capped,
155
+ });
156
+ }
157
+ catch (err) {
158
+ console.error(JSON.stringify({ event: 'agent_settle_error', error: String(err).slice(0, 500) }));
159
+ }
160
+ }
161
+ async #close() {
162
+ const t = this.#turn;
163
+ this.#turn = null;
164
+ await t?.tools.close();
165
+ }
166
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The assistant on Cloudflare Think: the kit's models, keys, tool groups and usage as AI SDK parts,
3
+ * and `LumenAgent`, a Think agent that runs one chat on them. The app keeps its tiers, tools and
4
+ * payer rules; Think keeps the loop, the messages, streaming, resume and compaction.
5
+ */
6
+ export * from './agent.js';
7
+ export * from './models.js';
8
+ export * from './tools.js';
9
+ export * from './usage.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The assistant on Cloudflare Think: the kit's models, keys, tool groups and usage as AI SDK parts,
3
+ * and `LumenAgent`, a Think agent that runs one chat on them. The app keeps its tiers, tools and
4
+ * payer rules; Think keeps the loop, the messages, streaming, resume and compaction.
5
+ */
6
+ export * from './agent.js';
7
+ export * from './models.js';
8
+ export * from './tools.js';
9
+ export * from './usage.js';
@@ -0,0 +1,32 @@
1
+ import type { AssistantEffort } from '@columenlabs/lumen-ui-types/assistant';
2
+ import type { JSONValue, LanguageModel } from 'ai';
3
+ import type { ModelDef } from '../assistant/models.js';
4
+ /** The key a model runs on. `plan`: an OpenAI access token from a ChatGPT plan sign-in. */
5
+ export interface ModelKey {
6
+ apiKey: string;
7
+ plan?: boolean;
8
+ }
9
+ export interface AiModelOptions {
10
+ /** The fetch provider calls go through (a Worker's own, a metered one). */
11
+ fetch?: typeof fetch;
12
+ /** What a plan sign-in's tool namespace is described as (the assistant's name). */
13
+ name?: string;
14
+ }
15
+ /**
16
+ * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
17
+ * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
18
+ * limitations). This fetch rewrites each Responses request to those rules, so the AI SDK's OpenAI
19
+ * provider runs on a plan unchanged.
20
+ */
21
+ export declare function planFetch(base?: typeof fetch, name?: string): typeof fetch;
22
+ /** The AI SDK model a tier runs: its provider's client on the payer's key. */
23
+ export declare function aiModel(model: ModelDef, key: ModelKey, o?: AiModelOptions): LanguageModel;
24
+ /** A tier's effort and output cap as a turn's settings; nothing at the model's own default. */
25
+ export declare function turnSettings(model: ModelDef, o: {
26
+ effort: AssistantEffort;
27
+ out?: number;
28
+ plan?: boolean;
29
+ }): {
30
+ maxOutputTokens?: number;
31
+ providerOptions?: Record<string, Record<string, JSONValue>>;
32
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The kit's models as AI SDK language models, so a Think agent (or any `streamText` call) runs on
3
+ * the same tiers, keys and sign-ins as the assistant: Claude on an API key, OpenAI on an API key or
4
+ * a person's ChatGPT plan sign-in.
5
+ */
6
+ import { createAnthropic } from '@ai-sdk/anthropic';
7
+ import { createOpenAI } from '@ai-sdk/openai';
8
+ /**
9
+ * A ChatGPT plan sign-in takes function tools only inside one namespace and no output cap, and a
10
+ * forced call is the one tool offered with any call required (developers.openai.com/siwc, Preview
11
+ * limitations). This fetch rewrites each Responses request to those rules, so the AI SDK's OpenAI
12
+ * provider runs on a plan unchanged.
13
+ */
14
+ export function planFetch(base = fetch, name = 'Assistant') {
15
+ return async (input, init) => {
16
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
17
+ if (!url.endsWith('/responses') || typeof init?.body !== 'string')
18
+ return base(input, init);
19
+ const { max_output_tokens: _cap, ...body } = JSON.parse(init.body);
20
+ let tools = body.tools ?? [];
21
+ const choice = body.tool_choice;
22
+ if (choice && typeof choice === 'object' && choice.type === 'function' && choice.name) {
23
+ tools = tools.filter((t) => t.name === choice.name);
24
+ body.tool_choice = 'required';
25
+ }
26
+ const fns = tools.filter((t) => t.type === 'function').map(({ strict: _, ...t }) => t);
27
+ const rest = tools.filter((t) => t.type !== 'function');
28
+ if (fns.length)
29
+ body.tools = [
30
+ ...rest,
31
+ { type: 'namespace', name: 'app', description: `${name}'s tools`, tools: fns },
32
+ ];
33
+ return base(input, { ...init, body: JSON.stringify(body) });
34
+ };
35
+ }
36
+ /** The AI SDK model a tier runs: its provider's client on the payer's key. */
37
+ export function aiModel(model, key, o = {}) {
38
+ if (model.provider === 'anthropic')
39
+ return createAnthropic({ apiKey: key.apiKey, ...(o.fetch ? { fetch: o.fetch } : {}) })(model.id);
40
+ const f = key.plan ? planFetch(o.fetch, o.name) : o.fetch;
41
+ return createOpenAI({ apiKey: key.apiKey, ...(f ? { fetch: f } : {}) }).responses(model.id);
42
+ }
43
+ /** A tier's effort and output cap as a turn's settings; nothing at the model's own default. */
44
+ export function turnSettings(model, o) {
45
+ const out = o.plan || !o.out ? {} : { maxOutputTokens: o.out };
46
+ if (o.effort === 'default')
47
+ return out;
48
+ return model.provider === 'anthropic'
49
+ ? { ...out, providerOptions: { anthropic: { effort: o.effort } } }
50
+ : { ...out, providerOptions: { openai: { reasoningEffort: o.effort } } };
51
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The kit's tool sources (MCP servers, plain functions) as an AI SDK tool set, with the person's
3
+ * access applied: Off leaves a group out, Read keeps its reads, Read + draft adds its writes as
4
+ * drafts (a card the person saves; nothing changes), Read + write runs writes at once.
5
+ */
6
+ import type { AssistantAccess } from '@columenlabs/lumen-ui-types/assistant';
7
+ import { type ToolSet } from 'ai';
8
+ import { type ToolSource } from '../assistant/tools.js';
9
+ /** A group's tools to open for one answer, at the person's access. */
10
+ export interface SourceAt {
11
+ group: string;
12
+ access: AssistantAccess;
13
+ source: ToolSource;
14
+ /** The group runs writes at once on Read + write; else a write drafts. */
15
+ writes?: boolean;
16
+ }
17
+ /** A write the model asked for while its group drafts: the card's content. */
18
+ export interface DraftCall {
19
+ group: string;
20
+ tool: string;
21
+ args: unknown;
22
+ }
23
+ export interface AiToolsOptions {
24
+ /** A draft's card was made: return what the model is told (its id, "a card is on screen"). */
25
+ draft?: (d: DraftCall) => Promise<unknown>;
26
+ /** Every call's outcome, for the app's meter: shapes only. */
27
+ called?: (c: {
28
+ group: string;
29
+ tool: string;
30
+ ok: boolean;
31
+ ms: number;
32
+ }) => void;
33
+ /** A result handed to the model is cut past this many characters. */
34
+ maxChars?: number;
35
+ }
36
+ /** One tool offered this answer, as the instructions describe the set. */
37
+ export interface ToolInfo {
38
+ name: string;
39
+ group: string;
40
+ kind: 'read' | 'draft' | 'write';
41
+ find: boolean;
42
+ }
43
+ /** Tools open for one answer, as the AI SDK's set, and how to let them go. */
44
+ export interface OpenedTools {
45
+ tools: ToolSet;
46
+ /** Names of the reads, for a lazy loader's catalog or `activeTools`. */
47
+ reads: string[];
48
+ /** Each tool offered: its name, group, kind and whether it is an app's find. */
49
+ info: ToolInfo[];
50
+ failed: number;
51
+ close(): Promise<void>;
52
+ }
53
+ /** Opens every source at once; one that fails is counted and left out. */
54
+ export declare function openAiTools(sources: SourceAt[], o?: AiToolsOptions): Promise<OpenedTools>;
@@ -0,0 +1,70 @@
1
+ import { jsonSchema, tool } from 'ai';
2
+ import { toolName } from '../assistant/tools.js';
3
+ const withoutMeta = (schema) => {
4
+ const { $schema: _, ...rest } = schema;
5
+ return { type: 'object', ...rest };
6
+ };
7
+ function describe(def, kind) {
8
+ const lead = kind === 'draft'
9
+ ? "DRAFT ONLY: this call changes nothing. It puts a card on the person's screen that they Save or Dismiss. "
10
+ : kind === 'write'
11
+ ? 'WRITES AT ONCE: this call makes the change now; the person can undo it. '
12
+ : '';
13
+ return `${lead}${def.title ? `${def.title}. ` : ''}${def.description ?? ''}`.slice(0, 4000);
14
+ }
15
+ /** Opens every source at once; one that fails is counted and left out. */
16
+ export async function openAiTools(sources, o = {}) {
17
+ const live = sources.filter((s) => s.access !== 'off');
18
+ const results = await Promise.allSettled(live.map((s) => s.source()));
19
+ const opened = [];
20
+ results.forEach((r, i) => {
21
+ const s = live[i];
22
+ if (r.status === 'fulfilled' && s)
23
+ opened.push({ s, open: r.value });
24
+ });
25
+ const max = o.maxChars ?? 16_000;
26
+ const tools = {};
27
+ const reads = [];
28
+ const info = [];
29
+ for (const { s, open } of opened)
30
+ for (const def of open.tools) {
31
+ const canWrite = s.access === 'draft' || s.access === 'write';
32
+ if (!def.read && !canWrite)
33
+ continue;
34
+ const kind = def.read ? 'read' : s.access === 'write' && s.writes ? 'write' : 'draft';
35
+ if (kind === 'draft' && !o.draft)
36
+ continue;
37
+ const name = toolName(s.group, def.name);
38
+ if (kind === 'read')
39
+ reads.push(name);
40
+ info.push({ name, group: s.group, kind, find: !!def.find });
41
+ tools[name] = tool({
42
+ description: describe(def, kind),
43
+ inputSchema: jsonSchema(withoutMeta(def.inputSchema)),
44
+ execute: async (args) => {
45
+ if (kind === 'draft')
46
+ return o.draft?.({ group: s.group, tool: def.name, args });
47
+ const t0 = Date.now();
48
+ try {
49
+ const out = await open.call(def.name, args);
50
+ o.called?.({ group: s.group, tool: def.name, ok: true, ms: Date.now() - t0 });
51
+ const text = typeof out === 'string' ? out : JSON.stringify(out);
52
+ return text.length > max ? `${text.slice(0, max)} …(cut)` : out;
53
+ }
54
+ catch (err) {
55
+ o.called?.({ group: s.group, tool: def.name, ok: false, ms: Date.now() - t0 });
56
+ return { error: String(err?.message ?? err).slice(0, 500) };
57
+ }
58
+ },
59
+ });
60
+ }
61
+ return {
62
+ tools,
63
+ reads,
64
+ info,
65
+ failed: results.filter((r) => r.status === 'rejected').length,
66
+ close: async () => {
67
+ await Promise.all(opened.map((x) => x.open.close().catch(() => undefined)));
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,36 @@
1
+ /** An AI SDK step's usage as the kit's tokens and list-price cost. */
2
+ import type { UsagePart } from '@columenlabs/lumen-ui-types/assistant';
3
+ import type { LanguageModelUsage } from 'ai';
4
+ import { type ModelDef, type TokenUsage } from '../assistant/models.js';
5
+ /**
6
+ * One call's usage in the kit's terms. Input splits into plain, cache writes and cache reads where
7
+ * the provider says (Claude reports all three; OpenAI reports reads, its writes cost nothing extra).
8
+ * A field a provider leaves out counts as 0.
9
+ */
10
+ export declare function tokenUsage(u: LanguageModelUsage): {
11
+ input_tokens: number;
12
+ output_tokens: number;
13
+ cache_creation_input_tokens: number;
14
+ cache_read_input_tokens: number;
15
+ };
16
+ /** A running part of an answer (a tier, its advice): calls, tokens and dollars. `free` on a plan. */
17
+ export declare class UsageMeter {
18
+ readonly model: ModelDef;
19
+ private readonly free;
20
+ private p;
21
+ constructor(model: ModelDef, label: string, free?: boolean);
22
+ add(u: LanguageModelUsage): {
23
+ in: number;
24
+ out: number;
25
+ read: number;
26
+ usd: number;
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
+ };
35
+ part(): UsagePart;
36
+ }
@@ -0,0 +1,46 @@
1
+ import { priced } from '../assistant/models.js';
2
+ /**
3
+ * One call's usage in the kit's terms. Input splits into plain, cache writes and cache reads where
4
+ * the provider says (Claude reports all three; OpenAI reports reads, its writes cost nothing extra).
5
+ * A field a provider leaves out counts as 0.
6
+ */
7
+ export function tokenUsage(u) {
8
+ const d = u.inputTokenDetails;
9
+ const read = d?.cacheReadTokens ?? 0;
10
+ const written = d?.cacheWriteTokens ?? 0;
11
+ const plain = d?.noCacheTokens ?? Math.max(0, (u.inputTokens ?? 0) - read - written);
12
+ return {
13
+ input_tokens: plain,
14
+ output_tokens: u.outputTokens ?? 0,
15
+ cache_creation_input_tokens: written,
16
+ cache_read_input_tokens: read,
17
+ };
18
+ }
19
+ /** A running part of an answer (a tier, its advice): calls, tokens and dollars. `free` on a plan. */
20
+ export class UsageMeter {
21
+ model;
22
+ free;
23
+ p;
24
+ constructor(model, label, free = false) {
25
+ this.model = model;
26
+ this.free = free;
27
+ this.p = { label, calls: 0, in: 0, read: 0, out: 0, usd: 0 };
28
+ }
29
+ add(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);
35
+ this.p.calls += 1;
36
+ this.p.in += t.in;
37
+ this.p.read += t.read;
38
+ this.p.out += t.out;
39
+ if (!this.free)
40
+ this.p.usd += t.usd;
41
+ return t;
42
+ }
43
+ part() {
44
+ return { ...this.p };
45
+ }
46
+ }
@@ -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,19 @@ 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
+ /** The escalate tool of a Think turn: one task to a higher tier, its memo back. */
231
+ private agentEscalate;
206
232
  status(caller: Caller): Promise<AssistantStatus>;
207
233
  private get unit();
208
234
  private approver;
@@ -222,6 +248,8 @@ export declare class Assistant {
222
248
  purge(): Promise<number>;
223
249
  private writeChat;
224
250
  /** Opens the tools the person allows: each group's, then the web's. */
251
+ /** The tool groups (and the web) at the person's access, not opened yet. */
252
+ private sourcesOf;
225
253
  private openTools;
226
254
  private toolsFor;
227
255
  /** `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,211 @@ 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
+ /** The escalate tool of a Think turn: one task to a higher tier, its memo back. */
491
+ agentEscalate(tt, c) {
492
+ const reach = this.above(c.main.tier);
493
+ return tool({
494
+ 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.',
495
+ inputSchema: jsonSchema({
496
+ type: 'object',
497
+ properties: {
498
+ tier: { type: 'string', enum: reach.map((t) => t.id) },
499
+ why: { type: 'string', enum: [...HAND_OFF_WHYS] },
500
+ task: {
501
+ type: 'string',
502
+ description: 'The task, with every fact from the chat it needs.',
503
+ },
504
+ },
505
+ required: ['tier', 'why', 'task'],
506
+ }),
507
+ execute: async (input, { abortSignal }) => {
508
+ const tier = reach.find((t) => t.id === input.tier);
509
+ const why = HAND_OFF_WHYS.find((w) => w === input.why);
510
+ const task = typeof input.task === 'string' ? input.task.trim() : '';
511
+ if (!tier || !why || !task)
512
+ return {
513
+ error: `Give {tier: ${reach.map((t) => `"${t.id}"`).join(' | ')}, why: ${HAND_OFF_WHYS.map((w) => `"${w}"`).join(' | ')}, task}.`,
514
+ };
515
+ const asked = new Set(c.asked);
516
+ if (c.handTo)
517
+ asked.add(c.handTo.id);
518
+ const own = c.policy === 'any' ? reach : c.policy === 'next' ? reach.slice(0, 1) : [];
519
+ const may = new Set([...own.map((t) => t.id), ...asked]);
520
+ if (!may.has(tier.id))
521
+ return {
522
+ error: may.size
523
+ ? `Not ${tier.id} now: you may call ${[...may].join(' or ')}.`
524
+ : 'The person has not asked for a higher tier: answer yourself.',
525
+ };
526
+ if (tt.over())
527
+ return { error: 'No money left for a higher tier this answer: answer yourself.' };
528
+ const sub = await this.session(c.caller, tier);
529
+ const key = c.keyOf(sub.model.provider);
530
+ const provider = PROVIDERS[sub.model.provider].label;
531
+ if (!key)
532
+ return {
533
+ error: `${tier.label} runs on ${provider} and no ${provider} key is set for whoever pays this answer: answer yourself.`,
534
+ };
535
+ const meter = tt.meter(sub.model, sub.meter.label, !!key.plan);
536
+ c.counts.handoffs += 1;
537
+ if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
538
+ c.counts.end = tier;
539
+ const shown = asked.has(tier.id) ? 'asked' : why;
540
+ try {
541
+ const r = await generateText({
542
+ model: aiModel(sub.model, key, {
543
+ ...(this.o.fetch ? { fetch: this.o.fetch } : {}),
544
+ name: this.name,
545
+ }),
546
+ instructions: c.system(tt.opened),
547
+ 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.`,
548
+ tools: tt.opened.tools,
549
+ stopWhen: stepCountIs(MAX_ROUNDS),
550
+ prepareStep: () => (tt.over() ? { activeTools: [] } : undefined),
551
+ onStepFinish: (s) => {
552
+ meter.add(s.usage);
553
+ },
554
+ ...(abortSignal ? { abortSignal } : {}),
555
+ ...turnSettings(sub.model, {
556
+ effort: sub.effort,
557
+ out: tier.out ?? DEFAULT_OUT,
558
+ plan: !!key.plan,
559
+ }),
560
+ });
561
+ return { memo: `Memo from ${sub.meter.label}:\n${r.text.slice(0, MEMO_CHARS)}` };
562
+ }
563
+ catch (err) {
564
+ if (abortSignal?.aborted)
565
+ throw err;
566
+ return { error: `${sub.meter.label} failed: ${shownError(err).message}` };
567
+ }
568
+ },
569
+ });
570
+ }
341
571
  // ---------------------------------------------------------------- status and settings
342
572
  async status(caller) {
343
573
  const main = await this.mainTier(caller);
@@ -583,8 +813,8 @@ export class Assistant {
583
813
  }
584
814
  // ---------------------------------------------------------------- tools
585
815
  /** Opens the tools the person allows: each group's, then the web's. */
586
- async openTools(caller, p) {
587
- const opened = [];
816
+ /** The tool groups (and the web) at the person's access, not opened yet. */
817
+ async sourcesOf(caller, p) {
588
818
  const sources = [];
589
819
  for (const g of this.groups) {
590
820
  const access = this.access(p, g);
@@ -592,7 +822,7 @@ export class Assistant {
592
822
  continue;
593
823
  const s = g.tools(caller);
594
824
  for (const source of Array.isArray(s) ? s : [s])
595
- sources.push({ group: g.id, access, source });
825
+ sources.push({ group: g.id, access, source, ...(g.writes ? { writes: true } : {}) });
596
826
  }
597
827
  if (p.web ?? true) {
598
828
  if (this.o.web?.fetch !== false)
@@ -610,6 +840,11 @@ export class Assistant {
610
840
  source: scrapeqlTools(sq.url, token, sq.fetch ?? this.o.fetch),
611
841
  });
612
842
  }
843
+ return sources;
844
+ }
845
+ async openTools(caller, p) {
846
+ const opened = [];
847
+ const sources = await this.sourcesOf(caller, p);
613
848
  const results = await Promise.allSettled(sources.map((s) => s.source()));
614
849
  results.forEach((r, i) => {
615
850
  const s = sources[i];
@@ -796,7 +1031,7 @@ export class Assistant {
796
1031
  let outcome = 'ok';
797
1032
  try {
798
1033
  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);
1034
+ const kitRules = this.rules(main, policy, advisor, summarize(tools.fns), tools.failed);
800
1035
  const system = [
801
1036
  { type: 'text', text: appSystem },
802
1037
  { type: 'text', text: kitRules, cache_control: { type: 'ephemeral' } },
@@ -805,7 +1040,7 @@ export class Assistant {
805
1040
  { type: 'text', text: appSystem },
806
1041
  {
807
1042
  type: 'text',
808
- text: this.rules(null, policy, null, tools.fns, tools.failed),
1043
+ text: this.rules(null, policy, null, summarize(tools.fns), tools.failed),
809
1044
  cache_control: { type: 'ephemeral' },
810
1045
  },
811
1046
  ];
@@ -960,12 +1195,12 @@ export class Assistant {
960
1195
  const lines = [
961
1196
  `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
1197
  '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)
1198
+ ...(fns.find
964
1199
  ? [
965
1200
  '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
1201
  ]
967
1202
  : []),
968
- ...([...fns.values()].some((f) => !f.draft && !f.write) && !fns.has(BATCH)
1203
+ ...(fns.reads && !fns.batch
969
1204
  ? [
970
1205
  `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
1206
  ]
@@ -979,7 +1214,7 @@ export class Assistant {
979
1214
  lines.push('No tools are on now: the person can turn them on in Settings ▸ Tools.');
980
1215
  if (failed)
981
1216
  lines.push(`${failed} tool source${failed === 1 ? '' : 's'} could not be reached this turn.`);
982
- if ([...fns.values()].some((f) => f.group === 'web'))
1217
+ if (fns.web)
983
1218
  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
1219
  if (main) {
985
1220
  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.176",
3
+ "version": "0.1.178",
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",
@@ -18,6 +18,10 @@
18
18
  "./assistant": {
19
19
  "types": "./dist/assistant/index.d.ts",
20
20
  "default": "./dist/assistant/index.js"
21
+ },
22
+ "./agent": {
23
+ "types": "./dist/agent/index.d.ts",
24
+ "default": "./dist/agent/index.js"
21
25
  }
22
26
  },
23
27
  "files": [
@@ -40,9 +44,15 @@
40
44
  "registry": "https://registry.npmjs.org/"
41
45
  },
42
46
  "dependencies": {
47
+ "@ai-sdk/anthropic": "4.0.96",
48
+ "@ai-sdk/openai": "4.0.108",
43
49
  "@anthropic-ai/sdk": "0.133.0",
44
- "@columenlabs/lumen-ops": "0.1.176",
45
- "@columenlabs/lumen-ui-types": "0.1.176",
50
+ "@cloudflare/think": "0.23.0",
51
+ "@columenlabs/lumen-ops": "0.1.178",
52
+ "@columenlabs/lumen-ui-types": "0.1.178",
53
+ "agents": "0.32.0",
54
+ "ai": "7.0.162",
55
+ "just-bash": "3.6.0",
46
56
  "openai": "7.32.0",
47
57
  "zod": "^4.3.6"
48
58
  },