@columenlabs/lumen-ai 0.1.247 → 0.1.248

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
@@ -253,6 +253,8 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
253
253
 
254
254
  **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.
255
255
 
256
+ **Tool lines.** Each tool call shows as a line with the tool's own `title` (an MCP tool's `title`, a plain tool's `title`), kept in the chat's storage so a chat read back words its lines the same; a tool with no title shows its name. An app that keeps its insides off the screen overrides `plainSteps()` to return `true`: every line reads its tool's title, else a word for what it did (Look something up, Make a change, Draft a change), with no tool id, row count or error text (a failed line says `didn't work`), and a hand-off section reads Take a closer look with no tier or task. The details go to the app's log instead: `stepFailed({ tool, error, ms })` hears each failed call with its whole error (override it to log it with the chat's ids), and `onStep` carries `error` and `caller` on a failed tool or hand-off. `stepTitleOf(name, kind)` gives a line's title for an app's own card answers.
257
+
256
258
  **A write in progress.** A tool line starts (`step_start`) as soon as the model starts the call, while it is still writing the arguments. For a Read + write tool it carries `write: true`, and `useAssistantChat` marks that running step `writing` until its result lands or the answer ends, so an app can show what the tool changes as being changed now (a pending edit is not ready to accept while the model rewrites it).
257
259
 
258
260
  **Steps.** One answer makes at most `plan.maxSteps` model calls (`MAX_STEPS`, 8). The last one runs with no tools and `LAST_STEP_NOTE`, so an answer always ends in words: what it found, and what is left when the task did not fit.
@@ -191,6 +191,23 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
191
191
  * `stay`. Off (`false`) unless the app overrides it; a throw counts as `false`.
192
192
  */
193
193
  protected offerFresh(_input: AskInput): Promise<boolean>;
194
+ /**
195
+ * Whether the chat's tool lines keep inner names off the screen: each reads its tool's title
196
+ * (or a word for what it did), with no tool id, tier, model, row count or error text. Off
197
+ * (`false`) unless the app overrides it; the app logs the details from `onStep`.
198
+ */
199
+ protected plainSteps(): boolean;
200
+ /**
201
+ * A tool call this answer failed: its name and whole error (a plain line shows neither). Does
202
+ * nothing unless the app overrides it, e.g. to log it with the chat's ids.
203
+ */
204
+ protected stepFailed(_f: {
205
+ tool: string;
206
+ error: string;
207
+ ms: number;
208
+ }): void;
209
+ /** A tool line's title as this chat words it (`plainSteps`), for an app's own card answers. */
210
+ protected stepTitleOf(name: string, kind?: 'read' | 'draft' | 'write'): string;
194
211
  /** The chat's running use: every answer's parts added up, and the last context size. */
195
212
  usage(): ChatUsage;
196
213
  /** The chat as the panel reads it: title, last message, items and use. */
@@ -20,7 +20,7 @@ import { CONNECTOR_GROUP, checkConnectorUrl, connectorGroup, connectorId, connec
20
20
  import { aiModel, cachedTurn, planFallback, turnSettings } from './models.js';
21
21
  import { screenMapOf, screenNews, screenOutcomesOf, screenRules, screenTool, } from './screen.js';
22
22
  import { LAZY_AT, TOOL_SEARCH, lazyTools, usedTools } from './search.js';
23
- import { chunkEvents, itemsOf } from './stream.js';
23
+ import { chunkEvents, itemsOf, shownTitle, } from './stream.js';
24
24
  import { openAiTools, } from './tools.js';
25
25
  import { UsageMeter } from './usage.js';
26
26
  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.';
@@ -193,6 +193,43 @@ export class LumenAgent extends Think {
193
193
  async offerFresh(_input) {
194
194
  return false;
195
195
  }
196
+ /**
197
+ * Whether the chat's tool lines keep inner names off the screen: each reads its tool's title
198
+ * (or a word for what it did), with no tool id, tier, model, row count or error text. Off
199
+ * (`false`) unless the app overrides it; the app logs the details from `onStep`.
200
+ */
201
+ plainSteps() {
202
+ return false;
203
+ }
204
+ /** How this chat words its tool lines: plain or not, with the titles of every tool it offered. */
205
+ #names() {
206
+ return {
207
+ plain: this.plainSteps(),
208
+ titleOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.title ??
209
+ this.#get('titles')?.[n],
210
+ };
211
+ }
212
+ /**
213
+ * A tool call this answer failed: its name and whole error (a plain line shows neither). Does
214
+ * nothing unless the app overrides it, e.g. to log it with the chat's ids.
215
+ */
216
+ stepFailed(_f) { }
217
+ /** A tool line's title as this chat words it (`plainSteps`), for an app's own card answers. */
218
+ stepTitleOf(name, kind) {
219
+ return shownTitle(name, this.#names(), kind);
220
+ }
221
+ /** Keeps the titles of a turn's tools, so a chat read back words its lines the same. */
222
+ #keepTitles(info) {
223
+ const titles = this.#get('titles') ?? {};
224
+ let changed = false;
225
+ for (const i of info)
226
+ if (i.title && titles[i.name] !== i.title) {
227
+ titles[i.name] = i.title.slice(0, 80);
228
+ changed = true;
229
+ }
230
+ if (changed)
231
+ this.#put('titles', titles);
232
+ }
196
233
  /** The chat's running use: every answer's parts added up, and the last context size. */
197
234
  usage() {
198
235
  return this.#get('usage') ?? chatUsage([]);
@@ -203,7 +240,7 @@ export class LumenAgent extends Think {
203
240
  id,
204
241
  title: this.#get('title') ?? '',
205
242
  at: this.#get('at') ?? 0,
206
- items: itemsOf(await this.getMessages(), this.cards(), this.#screens(), this.#get('steers') ?? {}),
243
+ items: itemsOf(await this.getMessages(), this.cards(), this.#screens(), this.#get('steers') ?? {}, this.#names()),
207
244
  usage: this.usage(),
208
245
  };
209
246
  }
@@ -258,6 +295,8 @@ export class LumenAgent extends Think {
258
295
  cards: () => new Map(this.cards().map((c) => [c.id, c])),
259
296
  screens: () => this.#screens(),
260
297
  kindOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.kind,
298
+ names: this.#names(),
299
+ failed: (f) => this.stepFailed(f),
261
300
  });
262
301
  const map = screenMapOf(input.screen);
263
302
  const userId = `u_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
@@ -575,6 +614,7 @@ export class LumenAgent extends Think {
575
614
  if (advice)
576
615
  meters.push(advice);
577
616
  let system = typeof plan.system === 'string' ? plan.system : plan.system(opened);
617
+ this.#keepTitles(opened.info);
578
618
  this.#turn = {
579
619
  plan,
580
620
  system,
@@ -8,6 +8,22 @@ import type { AgentCard } from './agent.js';
8
8
  import type { ScreenCall } from './screen.js';
9
9
  /** A tool's name as a line's title: `notes__find_notes` → `find notes`. */
10
10
  export declare function stepTitle(name: string): string;
11
+ /**
12
+ * How a chat's tool lines are worded. `plain` keeps every inner name off the screen: no tool ids,
13
+ * no tier or model, no row counts and no error text (the app logs those; see `StepRecord.error`).
14
+ * A line reads the tool's own `title` (`titleOf`), else a word for what it did.
15
+ */
16
+ export interface StepNames {
17
+ plain?: boolean;
18
+ /** The tool's own title, when it has one. */
19
+ titleOf?: (name: string) => string | undefined;
20
+ }
21
+ /** What a plain line says when a call failed: the assistant reads the error and goes on. */
22
+ export declare const PLAIN_FAILED = "didn't work";
23
+ /** A plain hand-off's heading: a closer look, never the tier it went to. */
24
+ export declare const PLAIN_HANDOFF = "Take a closer look";
25
+ /** The line's title: the tool's own title, else its name (or, plain, a word for what it did). */
26
+ export declare function shownTitle(name: string, names?: StepNames, kind?: 'read' | 'draft' | 'write'): string;
11
27
  /**
12
28
  * Turns UI message chunks into the panel's events, one chunk at a time. `cards` says how a draft
13
29
  * or write ended (a card's id is its tool call's). `advisor` names the advice part.
@@ -19,6 +35,14 @@ export declare function chunkEvents(o?: {
19
35
  screens?: () => Map<string, ScreenCall>;
20
36
  /** A tool's kind this turn: a draft shows as its card only, with no line before it. */
21
37
  kindOf?: (name: string) => 'read' | 'draft' | 'write' | undefined;
38
+ /** How the lines are worded. */
39
+ names?: StepNames;
40
+ /** A tool call failed: its name and whole error, before a plain line hides them (for logs). */
41
+ failed?: (f: {
42
+ tool: string;
43
+ error: string;
44
+ ms: number;
45
+ }) => void;
22
46
  }): (chunk: {
23
47
  type: string;
24
48
  [k: string]: unknown;
@@ -51,5 +75,5 @@ export interface SteerNote {
51
75
  text: string;
52
76
  at: number;
53
77
  }
54
- export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[], screens?: Map<string, ScreenCall>, steers?: Record<string, SteerNote[]>): ChatItem[];
78
+ export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[], screens?: Map<string, ScreenCall>, steers?: Record<string, SteerNote[]>, names?: StepNames): ChatItem[];
55
79
  export {};
@@ -5,6 +5,25 @@ export function stepTitle(name) {
5
5
  const own = name.includes('__') ? name.slice(name.indexOf('__') + 2) : name;
6
6
  return own.replace(/_/g, ' ');
7
7
  }
8
+ /** What a plain line says when a call failed: the assistant reads the error and goes on. */
9
+ export const PLAIN_FAILED = "didn't work";
10
+ /** A plain hand-off's heading: a closer look, never the tier it went to. */
11
+ export const PLAIN_HANDOFF = 'Take a closer look';
12
+ /** The line's title: the tool's own title, else its name (or, plain, a word for what it did). */
13
+ export function shownTitle(name, names = {}, kind) {
14
+ const own = names.titleOf?.(name);
15
+ if (own)
16
+ return own;
17
+ if (!names.plain)
18
+ return stepTitle(name);
19
+ if (name === 'escalate')
20
+ return PLAIN_HANDOFF;
21
+ return kind === 'write'
22
+ ? 'Make a change'
23
+ : kind === 'draft'
24
+ ? 'Draft a change'
25
+ : 'Look something up';
26
+ }
8
27
  const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
9
28
  /** A tool's `image` output as the panel's card: a same-site path or an https address only. */
10
29
  function stepImage(v) {
@@ -19,7 +38,7 @@ function stepImage(v) {
19
38
  };
20
39
  }
21
40
  /** What one finished tool call shows as: a line, a card, a hand-off section or nothing. */
22
- function settledCall(id, c, out, ms, cards, screens = new Map()) {
41
+ function settledCall(id, c, out, ms, cards, screens = new Map(), names = {}) {
23
42
  const o = obj(out.output);
24
43
  if (c.name === 'screen') {
25
44
  // A call the page map refused goes back to the model only; the person sees the one that ran.
@@ -43,7 +62,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
43
62
  kind: 'draft',
44
63
  id,
45
64
  tool: c.name,
46
- title: stepTitle(c.name),
65
+ title: shownTitle(c.name, names, 'draft'),
47
66
  args: card?.args ?? c.input ?? null,
48
67
  status: card && card.status !== 'done' && card.status !== 'undone' ? card.status : 'open',
49
68
  ...(card?.result !== undefined ? { result: card.result } : {}),
@@ -53,11 +72,13 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
53
72
  }
54
73
  const h = obj(o?.handoff);
55
74
  if (h && typeof h.tier === 'string' && typeof o?.memo === 'string') {
56
- const task = String(obj(c.input)?.task ?? '');
75
+ // Plain: the tier and the task the model wrote for it stay off the screen.
76
+ const task = names.plain ? '' : String(obj(c.input)?.task ?? '');
77
+ const label = names.plain ? PLAIN_HANDOFF : h.label;
57
78
  return [
58
79
  {
59
80
  event: 'handoff',
60
- data: { kind: 'handoff', tier: h.tier, label: h.label, task, why: h.why },
81
+ data: { kind: 'handoff', tier: h.tier, label, task, why: h.why },
61
82
  },
62
83
  { event: 'handoff_done', data: { usage: h.usage } },
63
84
  ];
@@ -66,16 +87,17 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
66
87
  const rows = rowCount(o && 'result' in o ? o.result : out.output);
67
88
  const image = stepImage(o?.image);
68
89
  const web = webStep(c.name, c.input, out.output, !!error || obj(out.output)?.ok === false);
90
+ const wrote = card?.kind === 'write' || (o && typeof o.change === 'string');
69
91
  const step = {
70
92
  kind: 'step',
71
93
  id,
72
94
  tool: c.name,
73
- title: stepTitle(c.name),
95
+ title: shownTitle(c.name, names, wrote ? 'write' : 'read'),
74
96
  ok: !error,
75
97
  ms,
76
- ...(rows !== undefined && !error ? { rows } : {}),
77
- ...(error ? { error: error.slice(0, 300) } : {}),
78
- ...(card?.kind === 'write' || (o && typeof o.change === 'string')
98
+ ...(rows !== undefined && !error && !names.plain ? { rows } : {}),
99
+ ...(error ? { error: names.plain ? PLAIN_FAILED : error.slice(0, 300) } : {}),
100
+ ...(wrote
79
101
  ? { write: { undo: true, status: card?.status === 'undone' ? 'undone' : 'done' } }
80
102
  : {}),
81
103
  ...(web ? { web } : {}),
@@ -88,6 +110,7 @@ function settledCall(id, c, out, ms, cards, screens = new Map()) {
88
110
  * or write ended (a card's id is its tool call's). `advisor` names the advice part.
89
111
  */
90
112
  export function chunkEvents(o = {}) {
113
+ const names = o.names ?? {};
91
114
  const calls = new Map();
92
115
  let advising = false;
93
116
  return (chunk) => {
@@ -110,11 +133,17 @@ export function chunkEvents(o = {}) {
110
133
  return [];
111
134
  // A tool that makes a change at once says so as it starts, so an app can show that what it
112
135
  // changes is being changed now, before the model has finished writing the call.
113
- const write = o.kindOf?.(name) === 'write';
136
+ const kind = o.kindOf?.(name);
137
+ const write = kind === 'write';
114
138
  return [
115
139
  {
116
140
  event: 'step_start',
117
- data: { id, tool: name, title: stepTitle(name), ...(write ? { write } : {}) },
141
+ data: {
142
+ id,
143
+ tool: name,
144
+ title: shownTitle(name, names, kind),
145
+ ...(write ? { write } : {}),
146
+ },
118
147
  },
119
148
  ];
120
149
  }
@@ -134,7 +163,18 @@ export function chunkEvents(o = {}) {
134
163
  advising = false;
135
164
  return [{ event: 'advice_done', data: error ? { error } : {} }];
136
165
  }
137
- return settledCall(id, c, error !== undefined ? { error } : { output: chunk.output }, Date.now() - c.t0, o.cards?.() ?? new Map(), c.name === 'screen' ? (o.screens?.() ?? new Map()) : undefined);
166
+ const ms = Date.now() - c.t0;
167
+ const out = obj(chunk.output);
168
+ const said = error ?? (out && typeof out.error === 'string' ? out.error : undefined);
169
+ if (said !== undefined) {
170
+ try {
171
+ o.failed?.({ tool: c.name, error: said.slice(0, 2000), ms });
172
+ }
173
+ catch {
174
+ // a log hook never breaks the answer
175
+ }
176
+ }
177
+ return settledCall(id, c, error !== undefined ? { error } : { output: chunk.output }, ms, o.cards?.() ?? new Map(), c.name === 'screen' ? (o.screens?.() ?? new Map()) : undefined, names);
138
178
  }
139
179
  case 'error':
140
180
  return [
@@ -152,7 +192,7 @@ export function chunkEvents(o = {}) {
152
192
  }
153
193
  };
154
194
  }
155
- export function itemsOf(messages, cards, screens = new Map(), steers = {}) {
195
+ export function itemsOf(messages, cards, screens = new Map(), steers = {}, names = {}) {
156
196
  const byId = new Map(cards.map((c) => [c.id, c]));
157
197
  const items = [];
158
198
  let asked;
@@ -223,7 +263,7 @@ export function itemsOf(messages, cards, screens = new Map(), steers = {}) {
223
263
  : null;
224
264
  if (!ended)
225
265
  continue;
226
- for (const e of settledCall(p.toolCallId, { name: tool, input: p.input, t0: 0 }, ended, 0, byId, screens)) {
266
+ for (const e of settledCall(p.toolCallId, { name: tool, input: p.input, t0: 0 }, ended, 0, byId, screens, names)) {
227
267
  if (e.event === 'handoff')
228
268
  items.push({ ...e.data, items: [] });
229
269
  else if (e.event === 'handoff_done') {
@@ -35,6 +35,7 @@ export interface AiToolsOptions {
35
35
  tool: string;
36
36
  ok: boolean;
37
37
  ms: number;
38
+ error?: string;
38
39
  }) => void;
39
40
  /** A result handed to the model is cut past this many characters. */
40
41
  maxChars?: number;
@@ -45,6 +46,8 @@ export interface ToolInfo {
45
46
  group: string;
46
47
  kind: 'read' | 'draft' | 'write';
47
48
  find: boolean;
49
+ /** The tool's own title, in words a person reads ("Read the design guide"). */
50
+ title?: string;
48
51
  }
49
52
  /** Tools open for one answer, as the AI SDK's set, and how to let them go. */
50
53
  export interface OpenedTools {
@@ -37,7 +37,13 @@ export async function openAiTools(sources, o = {}) {
37
37
  const name = toolName(s.group, def.name);
38
38
  if (kind === 'read')
39
39
  reads.push(name);
40
- info.push({ name, group: s.group, kind, find: !!def.find });
40
+ info.push({
41
+ name,
42
+ group: s.group,
43
+ kind,
44
+ find: !!def.find,
45
+ ...(def.title ? { title: def.title } : {}),
46
+ });
41
47
  tools[name] = tool({
42
48
  description: describe(def, kind),
43
49
  inputSchema: jsonSchema(withoutMeta(def.inputSchema)),
@@ -65,8 +71,9 @@ export async function openAiTools(sources, o = {}) {
65
71
  return shown;
66
72
  }
67
73
  catch (err) {
68
- o.called?.({ group: s.group, tool: def.name, ok: false, ms: Date.now() - t0 });
69
- return { error: String(err?.message ?? err).slice(0, 500) };
74
+ const error = String(err?.message ?? err).slice(0, 500);
75
+ o.called?.({ group: s.group, tool: def.name, ok: false, ms: Date.now() - t0, error });
76
+ return { error };
70
77
  }
71
78
  },
72
79
  });
@@ -111,6 +111,8 @@ export type StepRecord = {
111
111
  ms: number;
112
112
  rows?: number;
113
113
  error?: string;
114
+ /** The person the answer was for (their id), so a log line links to them. */
115
+ caller?: string;
114
116
  tier: string;
115
117
  handoff: boolean;
116
118
  } | {
@@ -363,6 +363,8 @@ export class Assistant {
363
363
  tool: toolName(c.group, c.tool),
364
364
  ok: c.ok,
365
365
  ms: c.ms,
366
+ ...(c.error ? { error: c.error } : {}),
367
+ caller: caller.id,
366
368
  tier: main.tier.id,
367
369
  handoff: false,
368
370
  })).catch(() => undefined),
@@ -509,6 +511,7 @@ export class Assistant {
509
511
  if (this.tiers.indexOf(tier) > this.tiers.indexOf(c.counts.end))
510
512
  c.counts.end = tier;
511
513
  const shown = asked.has(tier.id) ? 'asked' : why;
514
+ const t0 = Date.now();
512
515
  try {
513
516
  const r = await generateText({
514
517
  model: aiModel(sub.model, key, {
@@ -539,6 +542,17 @@ export class Assistant {
539
542
  catch (err) {
540
543
  if (abortSignal?.aborted)
541
544
  throw err;
545
+ // The whole error goes to the app's log; the chat line may show none of it (plain steps).
546
+ await Promise.resolve(this.o.onStep?.({
547
+ kind: 'tool',
548
+ tool: 'escalate',
549
+ ok: false,
550
+ ms: Date.now() - t0,
551
+ error: `${sub.meter.label}: ${String(err?.message ?? err).slice(0, 500)}`,
552
+ caller: c.caller.id,
553
+ tier: tier.id,
554
+ handoff: true,
555
+ })).catch(() => undefined);
542
556
  return { error: `${sub.meter.label} failed: ${shownError(err).message}` };
543
557
  }
544
558
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.247",
3
+ "version": "0.1.248",
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",
@@ -52,8 +52,8 @@
52
52
  "@ai-sdk/openai": "4.0.108",
53
53
  "@anthropic-ai/sdk": "0.133.0",
54
54
  "@cloudflare/think": "0.23.0",
55
- "@columenlabs/lumen-ops": "0.1.247",
56
- "@columenlabs/lumen-ui-types": "0.1.247",
55
+ "@columenlabs/lumen-ops": "0.1.248",
56
+ "@columenlabs/lumen-ui-types": "0.1.248",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",