@columenlabs/lumen-ai 0.1.182 → 0.1.183

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
@@ -234,11 +234,14 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
234
234
 
235
235
  **Lazy tools.** Past `LAZY_AT` (30) tools, a turn sends the model its core tools and `tool_search`, with one catalog line per group in the instructions. By default the core tools are the apps' finds, the hand off and the advisor. `plan.lazy` sets `{ at, core }`. A search matches the words in a tool's name and description and turns on up to 8 tools from the next step on.
236
236
 
237
+ **The panel.** `LumenAssistant` reads a Think chat with no change. `ask(input, sink)` runs one answer and sends the panel's own events (`AssistantEvent`: the chat, the text, each tool line, cards, hand-off sections, advice, the use) to `sink`. The app's `POST /chat` returns `askResponse((sink) => chat.ask(input, sink), { signal, stop: () => chat.stop() })`, the same server-sent events `assistantRoutes` sends. `view()` gives a stored chat as the panel's items, and `usage()` gives the chat's use so far. A card's id is its tool call's, so the panel's Save, Dismiss and Undo go to `saveCard`, `dismissCard` and `undoCard`. The app keeps the list of chats, and each chat is its own agent, such as a facet of the person's store. `chunkEvents` and `itemsOf` are the mapping, for an app that streams some other way.
238
+
237
239
  ```ts
238
240
  class Chat extends LumenAgent<Env> {
239
241
  #settle?: (end: AgentTurnEnd) => Promise<void>;
240
242
  async plan(turn: TurnContext) {
241
- const { plan, settle } = await assistant.agentTurn(caller, { message: lastText(turn), ...turn.body });
243
+ // this.input: what the person sent (message, page, refs, tier, time zone).
244
+ const { plan, settle } = await assistant.agentTurn(caller, this.input ?? { message: '' });
242
245
  this.#settle = settle;
243
246
  return plan;
244
247
  }
@@ -12,7 +12,7 @@
12
12
  * An `Assistant` gives both: `assistant.agentTurn(caller, input)` returns a plan and its settle.
13
13
  */
14
14
  import { type ChatResponseResult, type PrepareStepContext, type Session, 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';
15
+ import type { AssistantChat, AssistantEffort, AssistantEvent, AssistantRefNote, ChatUsage, UsagePart } from '@columenlabs/lumen-ui-types/assistant';
16
16
  import { type ModelMessage, type ToolSet } from 'ai';
17
17
  import type { ModelDef } from '../assistant/models.js';
18
18
  import { type AgentConnector, type ConnectorAt } from './connectors.js';
@@ -102,6 +102,23 @@ export declare const COMPACT_AT = 150000;
102
102
  export declare function cardNews(cards: AgentCard[]): string;
103
103
  /** The messages with `lead` put before the newest message the person wrote. */
104
104
  export declare function withLead(messages: ModelMessage[], lead: string | undefined): ModelMessage[];
105
+ /** One message the person sent, with the page, refs and tier the composer added. */
106
+ export interface AskInput {
107
+ message: string;
108
+ context?: {
109
+ label: string;
110
+ note?: unknown;
111
+ };
112
+ refs?: AssistantRefNote[];
113
+ tier?: string;
114
+ time_zone?: string;
115
+ }
116
+ /** Where an answer's events go: the app's route, over RPC. */
117
+ export interface AskSink {
118
+ event(e: AssistantEvent): void | Promise<void>;
119
+ }
120
+ /** Usage parts added up: same label, one part. */
121
+ export declare function chatUsage(parts: UsagePart[], context?: number): ChatUsage;
105
122
  export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
106
123
  #private;
107
124
  /** The turn the app runs now. Throw to refuse it (no key, no money); the error is shown. */
@@ -114,6 +131,19 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
114
131
  protected undoWrite(_card: AgentCard): Promise<void>;
115
132
  /** Connectors' tools join a turn through `plan.connectors`, at the person's access. */
116
133
  includeMcpTools: boolean;
134
+ /** What the person sent with the message this turn answers (page, refs, tier, zone). */
135
+ protected get input(): AskInput | null;
136
+ /** The chat's running use: every answer's parts added up, and the last context size. */
137
+ usage(): ChatUsage;
138
+ /** The chat as the panel reads it: title, last message, items and use. */
139
+ view(id?: string): Promise<AssistantChat>;
140
+ /** Stops the answer running now; what was said so far is kept. */
141
+ stop(): void;
142
+ /**
143
+ * One answer to `input`, its events sent to `sink` in the panel's words: the chat's title, the
144
+ * text, each tool line and card, then its use. The app's route calls it over RPC.
145
+ */
146
+ ask(input: AskInput, sink: AskSink): Promise<void>;
117
147
  /** The person's connectors, with their state and tool counts. */
118
148
  connectors(): AgentConnector[];
119
149
  /** Adds a connector by its https address; the answer says where to sign in when it asks. */
@@ -18,6 +18,7 @@ import { generateText } from 'ai';
18
18
  import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connectorSource, } from './connectors.js';
19
19
  import { aiModel, turnSettings } from './models.js';
20
20
  import { LAZY_AT, TOOL_SEARCH, lazyTools } from './search.js';
21
+ import { chunkEvents, itemsOf } from './stream.js';
21
22
  import { openAiTools, } from './tools.js';
22
23
  import { UsageMeter } from './usage.js';
23
24
  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.';
@@ -64,6 +65,34 @@ export function withLead(messages, lead) {
64
65
  : [{ type: 'text', text: lead }, ...m.content];
65
66
  return [...messages.slice(0, i), { ...m, content }, ...messages.slice(i + 1)];
66
67
  }
68
+ /** Usage parts added up: same label, one part. */
69
+ export function chatUsage(parts, context) {
70
+ const by = new Map();
71
+ for (const p of parts) {
72
+ const was = by.get(p.label);
73
+ by.set(p.label, was
74
+ ? {
75
+ label: p.label,
76
+ calls: was.calls + p.calls,
77
+ in: was.in + p.in,
78
+ read: was.read + p.read,
79
+ out: was.out + p.out,
80
+ usd: was.usd + p.usd,
81
+ }
82
+ : { ...p });
83
+ }
84
+ const all = [...by.values()];
85
+ const sum = (k) => all.reduce((a, p) => a + p[k], 0);
86
+ return {
87
+ calls: sum('calls'),
88
+ in: sum('in'),
89
+ read: sum('read'),
90
+ out: sum('out'),
91
+ usd: sum('usd'),
92
+ parts: all,
93
+ ...(context ? { context } : {}),
94
+ };
95
+ }
67
96
  export class LumenAgent extends Think {
68
97
  /** Saves a draft: runs its write as the person. The result is shown and told to the model. */
69
98
  applyDraft(_card) {
@@ -76,6 +105,113 @@ export class LumenAgent extends Think {
76
105
  /** Connectors' tools join a turn through `plan.connectors`, at the person's access. */
77
106
  includeMcpTools = false;
78
107
  #turn = null;
108
+ #input = null;
109
+ #metaTabled = false;
110
+ /** What the person sent with the message this turn answers (page, refs, tier, zone). */
111
+ get input() {
112
+ return this.#input;
113
+ }
114
+ #meta() {
115
+ if (!this.#metaTabled) {
116
+ this.sql `CREATE TABLE IF NOT EXISTS lumen_chat (k TEXT PRIMARY KEY, v TEXT NOT NULL)`;
117
+ this.#metaTabled = true;
118
+ }
119
+ }
120
+ #get(k) {
121
+ this.#meta();
122
+ const [r] = this.sql `SELECT v FROM lumen_chat WHERE k = ${k} LIMIT 1`;
123
+ return r ? JSON.parse(r.v) : null;
124
+ }
125
+ #put(k, v) {
126
+ this.#meta();
127
+ this.sql `INSERT OR REPLACE INTO lumen_chat (k, v) VALUES (${k}, ${JSON.stringify(v)})`;
128
+ }
129
+ /** The chat's running use: every answer's parts added up, and the last context size. */
130
+ usage() {
131
+ return this.#get('usage') ?? chatUsage([]);
132
+ }
133
+ /** The chat as the panel reads it: title, last message, items and use. */
134
+ async view(id = this.name) {
135
+ return {
136
+ id,
137
+ title: this.#get('title') ?? '',
138
+ at: this.#get('at') ?? 0,
139
+ items: itemsOf(await this.getMessages(), this.cards()),
140
+ usage: this.usage(),
141
+ };
142
+ }
143
+ /** Stops the answer running now; what was said so far is kept. */
144
+ stop() {
145
+ this.cancelAllChats();
146
+ }
147
+ /**
148
+ * One answer to `input`, its events sent to `sink` in the panel's words: the chat's title, the
149
+ * text, each tool line and card, then its use. The app's route calls it over RPC.
150
+ */
151
+ async ask(input, sink) {
152
+ const at = Date.now();
153
+ this.#capped = false;
154
+ let title = this.#get('title');
155
+ if (!title) {
156
+ title = input.message.replace(/\s+/g, ' ').trim().slice(0, 60);
157
+ this.#put('title', title);
158
+ }
159
+ this.#put('at', at);
160
+ await sink.event({ event: 'chat', data: { id: this.name, title } });
161
+ const meta = {
162
+ at,
163
+ ...(input.context ? { context: { label: input.context.label } } : {}),
164
+ ...(input.refs?.length ? { refs: input.refs } : {}),
165
+ ...(input.tier ? { tier: input.tier } : {}),
166
+ };
167
+ const map = chunkEvents({
168
+ ...(this.#last?.plan.advisor ? { advisor: this.#last.plan.advisor.label } : {}),
169
+ cards: () => new Map(this.cards().map((c) => [c.id, c])),
170
+ kindOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.kind,
171
+ });
172
+ this.#input = input;
173
+ let failed = false;
174
+ try {
175
+ await this.chat({
176
+ id: `u_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`,
177
+ role: 'user',
178
+ parts: [{ type: 'text', text: input.message }],
179
+ metadata: meta,
180
+ }, {
181
+ onStart: () => undefined,
182
+ onEvent: async (json) => {
183
+ let chunk;
184
+ try {
185
+ chunk = JSON.parse(json);
186
+ }
187
+ catch {
188
+ return;
189
+ }
190
+ for (const e of map(chunk))
191
+ await sink.event(e);
192
+ },
193
+ onDone: () => undefined,
194
+ onError: async (message) => {
195
+ failed = true;
196
+ await sink.event({ event: 'error', data: { message: message.slice(0, 500) } });
197
+ },
198
+ });
199
+ }
200
+ catch (err) {
201
+ if (!failed)
202
+ await sink.event({
203
+ event: 'error',
204
+ data: { message: String(err?.message ?? err).slice(0, 500) },
205
+ });
206
+ }
207
+ finally {
208
+ this.#input = null;
209
+ }
210
+ if (this.#capped)
211
+ await sink.event({ event: 'stopped', data: { reason: 'cap' } });
212
+ await sink.event({ event: 'done', data: { id: this.name, usage: this.usage() } });
213
+ }
214
+ #capped = false;
79
215
  /** The last turn's model and key, for compaction between turns. */
80
216
  #last = null;
81
217
  /** Usage spent between turns (compaction), settled with the next turn. */
@@ -90,9 +226,9 @@ export class LumenAgent extends Think {
90
226
  }
91
227
  #addCard(kind, d, result) {
92
228
  this.#cards();
93
- const id = `card_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
229
+ const id = d.call ?? `card_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
94
230
  this
95
- .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()})`;
231
+ .sql `INSERT OR REPLACE 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()})`;
96
232
  return id;
97
233
  }
98
234
  #card(id) {
@@ -377,10 +513,13 @@ export class LumenAgent extends Think {
377
513
  return;
378
514
  this.#turn = null;
379
515
  await t.tools.close();
516
+ const parts = t.meters.map((m) => m.part()).filter((p) => p.calls);
517
+ this.#capped = t.capped;
518
+ this.#put('usage', chatUsage([...this.usage().parts, ...parts], t.context));
380
519
  try {
381
520
  await this.settle({
382
521
  outcome,
383
- parts: t.meters.map((m) => m.part()).filter((p) => p.calls),
522
+ parts,
384
523
  ...(t.context ? { context: t.context } : {}),
385
524
  steps: t.steps,
386
525
  ms: Date.now() - t.t0,
@@ -6,6 +6,8 @@
6
6
  export * from './agent.js';
7
7
  export * from './connectors.js';
8
8
  export * from './models.js';
9
+ export * from './respond.js';
9
10
  export * from './search.js';
11
+ export * from './stream.js';
10
12
  export * from './tools.js';
11
13
  export * from './usage.js';
@@ -6,6 +6,8 @@
6
6
  export * from './agent.js';
7
7
  export * from './connectors.js';
8
8
  export * from './models.js';
9
+ export * from './respond.js';
9
10
  export * from './search.js';
11
+ export * from './stream.js';
10
12
  export * from './tools.js';
11
13
  export * from './usage.js';
@@ -0,0 +1,10 @@
1
+ import type { AskSink } from './agent.js';
2
+ export interface AskResponseOptions {
3
+ /** The request's signal: when the person stops or leaves, `stop` ends the answer. */
4
+ signal?: AbortSignal;
5
+ stop?: () => unknown;
6
+ /** Keeps the answer running past the response (a Worker's `ctx.waitUntil`). */
7
+ waitUntil?: (p: Promise<unknown>) => void;
8
+ }
9
+ /** Streams `run`'s events as `event:` / `data:` blocks; a failure ends in an `error` event. */
10
+ export declare function askResponse(run: (sink: AskSink) => Promise<void>, o?: AskResponseOptions): Response;
@@ -0,0 +1,44 @@
1
+ /** Streams `run`'s events as `event:` / `data:` blocks; a failure ends in an `error` event. */
2
+ export function askResponse(run, o = {}) {
3
+ const { readable, writable } = new TransformStream();
4
+ const writer = writable.getWriter();
5
+ const enc = new TextEncoder();
6
+ let open = true;
7
+ const write = async (e) => {
8
+ if (!open)
9
+ return;
10
+ try {
11
+ await writer.write(enc.encode(`event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`));
12
+ }
13
+ catch {
14
+ open = false;
15
+ }
16
+ };
17
+ o.signal?.addEventListener('abort', () => {
18
+ open = false;
19
+ void Promise.resolve(o.stop?.()).catch(() => undefined);
20
+ });
21
+ const work = (async () => {
22
+ try {
23
+ await run({ event: write });
24
+ }
25
+ catch (err) {
26
+ await write({
27
+ event: 'error',
28
+ data: { message: String(err?.message ?? err).slice(0, 500) },
29
+ });
30
+ }
31
+ finally {
32
+ open = false;
33
+ await writer.close().catch(() => undefined);
34
+ }
35
+ })();
36
+ o.waitUntil?.(work);
37
+ return new Response(readable, {
38
+ headers: {
39
+ 'content-type': 'text/event-stream; charset=utf-8',
40
+ 'cache-control': 'no-store',
41
+ 'x-accel-buffering': 'no',
42
+ },
43
+ });
44
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A Think chat in the panel's words: the AI SDK's UI message stream (what `chat()` sends) as the
3
+ * panel's `AssistantEvent`s, and a stored chat's messages and cards as its `ChatItem`s. The panel
4
+ * (`@columenlabs/lumen-ui`'s `LumenAssistant`) reads a Think chat the same way as before.
5
+ */
6
+ import type { AssistantEvent, AssistantRefNote, ChatItem } from '@columenlabs/lumen-ui-types/assistant';
7
+ import type { AgentCard } from './agent.js';
8
+ /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
9
+ export declare function stepTitle(name: string): string;
10
+ /**
11
+ * Turns UI message chunks into the panel's events, one chunk at a time. `cards` says how a draft
12
+ * or write ended (a card's id is its tool call's). `advisor` names the advice part.
13
+ */
14
+ export declare function chunkEvents(o?: {
15
+ advisor?: string;
16
+ cards?: () => Map<string, AgentCard>;
17
+ /** A tool's kind this turn: a draft shows as its card only, with no line before it. */
18
+ kindOf?: (name: string) => 'read' | 'draft' | 'write' | undefined;
19
+ }): (chunk: {
20
+ type: string;
21
+ [k: string]: unknown;
22
+ }) => AssistantEvent[];
23
+ /** A stored UI message, as much of it as a chat's items need. */
24
+ interface StoredMessage {
25
+ role: string;
26
+ parts: {
27
+ type: string;
28
+ [k: string]: unknown;
29
+ }[];
30
+ metadata?: unknown;
31
+ }
32
+ /** A user message's extras, kept in its metadata by `LumenAgent.ask`. */
33
+ export interface AskMeta {
34
+ at?: number;
35
+ context?: {
36
+ label: string;
37
+ };
38
+ refs?: AssistantRefNote[];
39
+ tier?: string;
40
+ }
41
+ /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
42
+ export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[]): ChatItem[];
43
+ export {};
@@ -0,0 +1,180 @@
1
+ import { rowCount } from '../assistant/tools.js';
2
+ /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
3
+ export function stepTitle(name) {
4
+ const own = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
5
+ return own.replace(/_/g, ' ');
6
+ }
7
+ const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
8
+ /** What one finished tool call shows as: a line, a card, a hand-off section or nothing. */
9
+ function settledCall(id, c, out, ms, cards) {
10
+ const o = obj(out.output);
11
+ const card = cards.get(id);
12
+ if (card?.kind === 'draft' || (o && typeof o.card === 'string')) {
13
+ const draft = {
14
+ kind: 'draft',
15
+ id,
16
+ tool: c.name,
17
+ title: stepTitle(c.name),
18
+ args: card?.args ?? c.input ?? null,
19
+ status: card && card.status !== 'done' && card.status !== 'undone' ? card.status : 'open',
20
+ ...(card?.result !== undefined ? { result: card.result } : {}),
21
+ ...(card?.error ? { error: card.error } : {}),
22
+ };
23
+ return [{ event: 'draft', data: draft }];
24
+ }
25
+ const h = obj(o?.handoff);
26
+ if (h && typeof h.tier === 'string' && typeof o?.memo === 'string') {
27
+ const task = String(obj(c.input)?.task ?? '');
28
+ return [
29
+ {
30
+ event: 'handoff',
31
+ data: { kind: 'handoff', tier: h.tier, label: h.label, task, why: h.why },
32
+ },
33
+ { event: 'handoff_done', data: { usage: h.usage } },
34
+ ];
35
+ }
36
+ const error = out.error ?? (o && typeof o.error === 'string' ? o.error : undefined);
37
+ const rows = rowCount(o && 'result' in o ? o.result : out.output);
38
+ const step = {
39
+ kind: 'step',
40
+ id,
41
+ tool: c.name,
42
+ title: stepTitle(c.name),
43
+ ok: !error,
44
+ ms,
45
+ ...(rows !== undefined && !error ? { rows } : {}),
46
+ ...(error ? { error: error.slice(0, 300) } : {}),
47
+ ...(card?.kind === 'write' || (o && typeof o.change === 'string')
48
+ ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
49
+ : {}),
50
+ };
51
+ return [{ event: 'step', data: step }];
52
+ }
53
+ /**
54
+ * Turns UI message chunks into the panel's events, one chunk at a time. `cards` says how a draft
55
+ * or write ended (a card's id is its tool call's). `advisor` names the advice part.
56
+ */
57
+ export function chunkEvents(o = {}) {
58
+ const calls = new Map();
59
+ let advising = false;
60
+ return (chunk) => {
61
+ const id = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : '';
62
+ switch (chunk.type) {
63
+ case 'text-delta':
64
+ return typeof chunk.delta === 'string' && chunk.delta
65
+ ? [{ event: 'text', data: { delta: chunk.delta } }]
66
+ : [];
67
+ case 'text-end':
68
+ return [{ event: 'text_end', data: {} }];
69
+ case 'tool-input-start': {
70
+ const name = String(chunk.toolName ?? '');
71
+ calls.set(id, { name, t0: Date.now() });
72
+ if (name === 'advisor') {
73
+ advising = true;
74
+ return [{ event: 'advice', data: { tier: 'advisor', label: o.advisor ?? 'Advice' } }];
75
+ }
76
+ if (name === 'escalate' || o.kindOf?.(name) === 'draft')
77
+ return [];
78
+ return [{ event: 'step_start', data: { id, tool: name, title: stepTitle(name) } }];
79
+ }
80
+ case 'tool-input-available': {
81
+ const c = calls.get(id) ?? { name: String(chunk.toolName ?? ''), t0: Date.now() };
82
+ calls.set(id, { ...c, input: chunk.input });
83
+ return [];
84
+ }
85
+ case 'tool-output-available':
86
+ case 'tool-output-error': {
87
+ const c = calls.get(id);
88
+ if (!c)
89
+ return [];
90
+ calls.delete(id);
91
+ const error = chunk.type === 'tool-output-error' ? String(chunk.errorText ?? '') : undefined;
92
+ if (c.name === 'advisor') {
93
+ advising = false;
94
+ return [{ event: 'advice_done', data: error ? { error } : {} }];
95
+ }
96
+ return settledCall(id, c, error !== undefined ? { error } : { output: chunk.output }, Date.now() - c.t0, o.cards?.() ?? new Map());
97
+ }
98
+ case 'error':
99
+ return [
100
+ { event: 'error', data: { message: String(chunk.errorText ?? 'The answer failed.') } },
101
+ ];
102
+ case 'abort':
103
+ return advising
104
+ ? [
105
+ { event: 'advice_done', data: {} },
106
+ { event: 'stopped', data: {} },
107
+ ]
108
+ : [{ event: 'stopped', data: {} }];
109
+ default:
110
+ return [];
111
+ }
112
+ };
113
+ }
114
+ /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
115
+ export function itemsOf(messages, cards) {
116
+ const byId = new Map(cards.map((c) => [c.id, c]));
117
+ const items = [];
118
+ for (const m of messages) {
119
+ if (m.role === 'user') {
120
+ const text = m.parts
121
+ .filter((p) => p.type === 'text')
122
+ .map((p) => String(p.text ?? ''))
123
+ .join('');
124
+ const meta = (obj(m.metadata) ?? {});
125
+ items.push({
126
+ kind: 'user',
127
+ text,
128
+ at: meta.at ?? 0,
129
+ ...(meta.context?.label ? { context: meta.context.label } : {}),
130
+ ...(meta.refs?.length ? { refs: meta.refs } : {}),
131
+ ...(meta.tier ? { tier: meta.tier } : {}),
132
+ });
133
+ continue;
134
+ }
135
+ if (m.role !== 'assistant')
136
+ continue;
137
+ for (const p of m.parts) {
138
+ if (p.type === 'text' && typeof p.text === 'string' && p.text) {
139
+ const last = items[items.length - 1];
140
+ if (last?.kind === 'text')
141
+ last.text += p.text;
142
+ else
143
+ items.push({ kind: 'text', text: p.text });
144
+ continue;
145
+ }
146
+ const tool = p.type === 'dynamic-tool'
147
+ ? String(p.toolName ?? '')
148
+ : p.type.startsWith('tool-')
149
+ ? p.type.slice(5)
150
+ : '';
151
+ if (!tool || typeof p.toolCallId !== 'string')
152
+ continue;
153
+ if (tool === 'advisor') {
154
+ items.push({ kind: 'advice', tier: 'advisor', label: 'Advice' });
155
+ continue;
156
+ }
157
+ const ended = p.state === 'output-available'
158
+ ? { output: p.output }
159
+ : p.state === 'output-error'
160
+ ? { error: String(p.errorText ?? 'failed') }
161
+ : null;
162
+ if (!ended)
163
+ continue;
164
+ for (const e of settledCall(p.toolCallId, { name: tool, input: p.input, t0: 0 }, ended, 0, byId)) {
165
+ if (e.event === 'handoff')
166
+ items.push({ ...e.data, items: [] });
167
+ else if (e.event === 'handoff_done') {
168
+ const h = items[items.length - 1];
169
+ if (h?.kind === 'handoff')
170
+ h.usage = e.data.usage;
171
+ }
172
+ else if (e.event === 'step' || e.event === 'draft') {
173
+ const { handoff: _h, ...item } = e.data;
174
+ items.push(item);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ return items;
180
+ }
@@ -16,6 +16,8 @@ export interface SourceAt {
16
16
  }
17
17
  /** A write the model asked for while its group drafts: the card's content. */
18
18
  export interface DraftCall {
19
+ /** The model's tool call id: a card keeps it as its own. */
20
+ call?: string;
19
21
  group: string;
20
22
  tool: string;
21
23
  args: unknown;
@@ -41,9 +41,10 @@ export async function openAiTools(sources, o = {}) {
41
41
  tools[name] = tool({
42
42
  description: describe(def, kind),
43
43
  inputSchema: jsonSchema(withoutMeta(def.inputSchema)),
44
- execute: async (args) => {
44
+ execute: async (args, { toolCallId }) => {
45
+ const call = toolCallId;
45
46
  if (kind === 'draft')
46
- return o.draft?.({ group: s.group, tool: def.name, args });
47
+ return o.draft?.({ call, group: s.group, tool: def.name, args });
47
48
  const t0 = Date.now();
48
49
  try {
49
50
  const out = await open.call(def.name, args);
@@ -51,7 +52,13 @@ export async function openAiTools(sources, o = {}) {
51
52
  const text = typeof out === 'string' ? out : JSON.stringify(out);
52
53
  const shown = text.length > max ? `${text.slice(0, max)} …(cut)` : out;
53
54
  if (kind === 'write' && o.wrote) {
54
- const change = await o.wrote({ group: s.group, tool: def.name, args, result: out });
55
+ const change = await o.wrote({
56
+ call,
57
+ group: s.group,
58
+ tool: def.name,
59
+ args,
60
+ result: out,
61
+ });
55
62
  if (change !== undefined)
56
63
  return { result: shown, change };
57
64
  }
@@ -592,7 +592,11 @@ export class Assistant {
592
592
  plan: !!key.plan,
593
593
  }),
594
594
  });
595
- return { memo: `Memo from ${sub.meter.label}:\n${r.text.slice(0, MEMO_CHARS)}` };
595
+ return {
596
+ memo: `Memo from ${sub.meter.label}:\n${r.text.slice(0, MEMO_CHARS)}`,
597
+ // For the chat's hand-off section; the model reads the memo.
598
+ handoff: { tier: tier.id, label: sub.meter.label, why: shown, usage: meter.part() },
599
+ };
596
600
  }
597
601
  catch (err) {
598
602
  if (abortSignal?.aborted)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.182",
3
+ "version": "0.1.183",
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.182",
52
- "@columenlabs/lumen-ui-types": "0.1.182",
51
+ "@columenlabs/lumen-ops": "0.1.183",
52
+ "@columenlabs/lumen-ui-types": "0.1.183",
53
53
  "agents": "0.32.0",
54
54
  "ai": "7.0.162",
55
55
  "just-bash": "3.6.0",