@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 +18 -0
- package/dist/agent/agent.d.ts +68 -6
- package/dist/agent/agent.js +245 -16
- package/dist/agent/models.d.ts +2 -2
- package/dist/agent/tools.d.ts +13 -0
- package/dist/agent/tools.js +10 -1
- package/dist/agent/usage.d.ts +8 -1
- package/dist/agent/usage.js +5 -1
- package/dist/assistant/engine.d.ts +44 -0
- package/dist/assistant/engine.js +277 -8
- package/package.json +3 -3
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
|
+
```
|
package/dist/agent/agent.d.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
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;
|
package/dist/agent/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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:
|
|
30
|
-
|
|
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(
|
|
248
|
+
beforeStep(ctx) {
|
|
40
249
|
const t = this.#turn;
|
|
41
|
-
if (!t
|
|
42
|
-
return undefined;
|
|
43
|
-
if (t.meter.part().usd < t.plan.left)
|
|
250
|
+
if (!t)
|
|
44
251
|
return undefined;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
});
|
package/dist/agent/models.d.ts
CHANGED
|
@@ -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,
|
|
31
|
+
providerOptions?: Record<string, Record<string, JSONValue>>;
|
|
32
32
|
};
|
package/dist/agent/tools.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agent/tools.js
CHANGED
|
@@ -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
|
-
|
|
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)));
|
package/dist/agent/usage.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/agent/usage.js
CHANGED
|
@@ -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
|
-
|
|
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. */
|
package/dist/assistant/engine.js
CHANGED
|
@@ -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
|
-
|
|
587
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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 (
|
|
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.
|
|
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.
|
|
52
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
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",
|