@columenlabs/lumen-ai 0.1.215 → 0.1.216

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
@@ -241,6 +241,8 @@ Drafts and writes are cards in the chat's own storage (`lumen_cards` in the agen
241
241
 
242
242
  **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.
243
243
 
244
+ **Steer and queue.** While an answer runs, the panel's box stays open: Enter steers the message into the answer (`steer(message)` on the agent; the app routes `POST /chats/:id/steer` `{ message }` to it and answers `{ steered }`), Alt+Enter queues it for after. A steer joins at the answer's next step, after the tool it is on, with `STEER_NOTE` before it for the model; the panel shows it there marked Steered (event `steered`), and `view()` keeps it in place (`SteerNote`, under the message the answer is to). A steer the answer ends before reading comes back as `unsteered` and goes first in the queue. `plan.steer: false` turns it off for a model; an app without the route (404) shows Queue only. Queued messages each go as their own message when the answer before them ends; each has Steer now, Edit and Remove.
245
+
244
246
  ```ts
245
247
  class Chat extends LumenAgent<Env> {
246
248
  #settle?: (end: AgentTurnEnd) => Promise<void>;
@@ -76,6 +76,11 @@ export interface AgentTurnPlan {
76
76
  screen?: boolean;
77
77
  /** Most model calls in one answer; 8. */
78
78
  maxSteps?: number;
79
+ /**
80
+ * A message sent while the answer runs joins it at its next step (Steer); on unless `false`,
81
+ * for a model that should not read a new message between its calls. Refused steers are queued.
82
+ */
83
+ steer?: boolean;
79
84
  /** Fetch for provider calls (a metered one). */
80
85
  fetch?: typeof fetch;
81
86
  tools?: Omit<AiToolsOptions, 'maxChars'>;
@@ -94,6 +99,8 @@ export interface AgentTurnEnd {
94
99
  capped: boolean;
95
100
  }
96
101
  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.";
102
+ /** Put before a message the person steered into a running answer, for the model only. */
103
+ export declare const STEER_NOTE = "The person sent this while you were working. Take it into account from here on; it does not cancel what you were asked before unless it says so.";
97
104
  /** A card in the chat: a draft the person saves or dismisses, or a write made at once (Undo). */
98
105
  export interface AgentCard {
99
106
  id: string;
@@ -165,6 +172,14 @@ export declare abstract class LumenAgent<Env = unknown> extends Think<Env> {
165
172
  view(id?: string): Promise<AssistantChat>;
166
173
  /** Stops the answer running now; what was said so far is kept. */
167
174
  stop(): void;
175
+ /**
176
+ * A message for the answer running now: it joins at the answer's next step (after the tool it
177
+ * is on), shown there as steered. `steered: false` when no answer runs or its plan turned steer
178
+ * off: the panel queues it. One the answer ends without reading comes back as `unsteered`.
179
+ */
180
+ steer(message: string): {
181
+ steered: boolean;
182
+ };
168
183
  /**
169
184
  * One answer to `input`, its events sent to `sink` in the panel's words: the chat's title, the
170
185
  * text, each tool line and card, then its use. The app's route calls it over RPC.
@@ -24,6 +24,8 @@ import { chunkEvents, itemsOf } 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.';
27
+ /** Put before a message the person steered into a running answer, for the model only. */
28
+ export const STEER_NOTE = 'The person sent this while you were working. Take it into account from here on; it does not cancel what you were asked before unless it says so.';
27
29
  /** Past this many tokens in the chat Think summarizes its older part. */
28
30
  export const COMPACT_AT = 150_000;
29
31
  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.";
@@ -129,6 +131,14 @@ export class LumenAgent extends Think {
129
131
  includeMcpTools = false;
130
132
  #turn = null;
131
133
  #input = null;
134
+ /** The answer's events go here while it runs; a steer's line goes here too. */
135
+ #sink = null;
136
+ /** The person's message this answer is to, by id: where its steers are kept. */
137
+ #asked = null;
138
+ /** Steered messages waiting for the next step. */
139
+ #steers = [];
140
+ /** Steered messages read this answer: where they sit in the step's messages, and their words. */
141
+ #steered = [];
132
142
  #metaTabled = false;
133
143
  /** What the person sent with the message this turn answers (page, refs, tier, zone). */
134
144
  get input() {
@@ -159,7 +169,7 @@ export class LumenAgent extends Think {
159
169
  id,
160
170
  title: this.#get('title') ?? '',
161
171
  at: this.#get('at') ?? 0,
162
- items: itemsOf(await this.getMessages(), this.cards(), this.#screens()),
172
+ items: itemsOf(await this.getMessages(), this.cards(), this.#screens(), this.#get('steers') ?? {}),
163
173
  usage: this.usage(),
164
174
  };
165
175
  }
@@ -167,6 +177,19 @@ export class LumenAgent extends Think {
167
177
  stop() {
168
178
  this.cancelAllChats();
169
179
  }
180
+ /**
181
+ * A message for the answer running now: it joins at the answer's next step (after the tool it
182
+ * is on), shown there as steered. `steered: false` when no answer runs or its plan turned steer
183
+ * off: the panel queues it. One the answer ends without reading comes back as `unsteered`.
184
+ */
185
+ steer(message) {
186
+ const text = message.trim();
187
+ const t = this.#turn;
188
+ if (!text || !t || !this.#sink || t.plan.steer === false)
189
+ return { steered: false };
190
+ this.#steers.push({ text: text.slice(0, 20_000), at: Date.now() });
191
+ return { steered: true };
192
+ }
170
193
  /**
171
194
  * One answer to `input`, its events sent to `sink` in the panel's words: the chat's title, the
172
195
  * text, each tool line and card, then its use. The app's route calls it over RPC.
@@ -195,6 +218,11 @@ export class LumenAgent extends Think {
195
218
  kindOf: (n) => this.#turn?.tools.info.find((i) => i.name === n)?.kind,
196
219
  });
197
220
  const map = screenMapOf(input.screen);
221
+ const userId = `u_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`;
222
+ this.#sink = sink;
223
+ this.#asked = userId;
224
+ this.#steers = [];
225
+ this.#steered = [];
198
226
  // The images go in the message's parts; the turn's plan reads the rest.
199
227
  const { images: _images, ...asked } = input;
200
228
  this.#input = { ...asked, ...(map ? { screen: map } : {}) };
@@ -203,7 +231,7 @@ export class LumenAgent extends Think {
203
231
  let failed = false;
204
232
  try {
205
233
  await this.chat({
206
- id: `u_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}`,
234
+ id: userId,
207
235
  role: 'user',
208
236
  parts: [{ type: 'text', text: input.message }, ...imageParts(input.images)],
209
237
  metadata: meta,
@@ -236,7 +264,14 @@ export class LumenAgent extends Think {
236
264
  }
237
265
  finally {
238
266
  this.#input = null;
267
+ this.#sink = null;
268
+ this.#asked = null;
269
+ this.#steered = [];
239
270
  }
271
+ // Steers the answer ended before reading go back to the panel, first in its queue.
272
+ const unread = this.#steers.splice(0).map((s) => s.text);
273
+ if (unread.length)
274
+ await sink.event({ event: 'unsteered', data: { texts: unread } });
240
275
  if (this.#capped)
241
276
  await sink.event({ event: 'stopped', data: { reason: 'cap' } });
242
277
  await sink.event({ event: 'done', data: { id: this.name, usage: this.usage() } });
@@ -573,20 +608,58 @@ export class LumenAgent extends Think {
573
608
  const t = this.#turn;
574
609
  if (!t)
575
610
  return undefined;
611
+ const steered = this.#steerInto(ctx);
612
+ const messages = steered ? { messages: steered } : {};
576
613
  // The SDK's step result is a union that Omit narrows to shared keys; these exist on it.
577
614
  if (t.plan.left !== undefined && this.#spent() >= t.plan.left) {
578
615
  t.capped = true;
579
- return { activeTools: [], instructions: `${t.system}\n\n${CAP_NOTE}` };
616
+ const capped = {
617
+ ...messages,
618
+ activeTools: [],
619
+ instructions: `${t.system}\n\n${CAP_NOTE}`,
620
+ };
621
+ return capped;
580
622
  }
581
623
  // A forced first call is for the first step only; tools a search found join each step.
582
624
  const auto = t.plan.first && ctx.stepNumber > 0;
583
- if (!auto && !t.lazy)
625
+ if (!auto && !t.lazy && !steered)
584
626
  return undefined;
585
627
  return {
586
628
  ...(auto ? { toolChoice: 'auto' } : {}),
587
629
  ...(t.lazy ? { activeTools: t.lazy.active() } : {}),
630
+ ...messages,
588
631
  };
589
632
  }
633
+ /**
634
+ * The step's messages with every steer read so far in its place: waiting ones join at the end
635
+ * now (told to the panel and kept for the chat's history), earlier ones where they joined.
636
+ */
637
+ #steerInto(ctx) {
638
+ const sink = this.#sink;
639
+ const asked = this.#asked;
640
+ if (this.#steers.length && sink && asked) {
641
+ const now = this.#steers.splice(0);
642
+ const kept = this.#get('steers') ?? {};
643
+ kept[asked] = [
644
+ ...(kept[asked] ?? []),
645
+ ...now.map((s) => ({ step: ctx.stepNumber, text: s.text, at: s.at })),
646
+ ];
647
+ this.#put('steers', kept);
648
+ for (const s of now) {
649
+ this.#steered.push({ index: ctx.messages.length, text: s.text });
650
+ void Promise.resolve(sink.event({ event: 'steered', data: s })).catch(() => undefined);
651
+ }
652
+ }
653
+ if (!this.#steered.length)
654
+ return null;
655
+ const out = [...ctx.messages];
656
+ for (const s of [...this.#steered].reverse())
657
+ out.splice(s.index, 0, {
658
+ role: 'user',
659
+ content: [{ type: 'text', text: `${STEER_NOTE}\n\n${s.text}` }],
660
+ });
661
+ return out;
662
+ }
590
663
  onStepFinish(ctx) {
591
664
  const t = this.#turn;
592
665
  if (!t)
@@ -25,6 +25,7 @@ export declare function chunkEvents(o?: {
25
25
  }) => AssistantEvent[];
26
26
  /** A stored UI message, as much of it as a chat's items need. */
27
27
  interface StoredMessage {
28
+ id?: string;
28
29
  role: string;
29
30
  parts: {
30
31
  type: string;
@@ -42,5 +43,11 @@ export interface AskMeta {
42
43
  tier?: string;
43
44
  }
44
45
  /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
45
- export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[], screens?: Map<string, ScreenCall>): ChatItem[];
46
+ /** A message steered into an answer: the step it was read at, under the message the answer is to. */
47
+ export interface SteerNote {
48
+ step: number;
49
+ text: string;
50
+ at: number;
51
+ }
52
+ export declare function itemsOf(messages: StoredMessage[], cards: AgentCard[], screens?: Map<string, ScreenCall>, steers?: Record<string, SteerNote[]>): ChatItem[];
46
53
  export {};
@@ -127,12 +127,13 @@ export function chunkEvents(o = {}) {
127
127
  }
128
128
  };
129
129
  }
130
- /** A stored chat as the panel's items: the messages in order, each tool call as it ended. */
131
- export function itemsOf(messages, cards, screens = new Map()) {
130
+ export function itemsOf(messages, cards, screens = new Map(), steers = {}) {
132
131
  const byId = new Map(cards.map((c) => [c.id, c]));
133
132
  const items = [];
133
+ let asked;
134
134
  for (const m of messages) {
135
135
  if (m.role === 'user') {
136
+ asked = m.id;
136
137
  const text = m.parts
137
138
  .filter((p) => p.type === 'text')
138
139
  .map((p) => String(p.text ?? ''))
@@ -154,7 +155,21 @@ export function itemsOf(messages, cards, screens = new Map()) {
154
155
  }
155
156
  if (m.role !== 'assistant')
156
157
  continue;
158
+ // Steered messages show where the answer read them: before the step they joined.
159
+ const steered = [...((asked && steers[asked]) || [])].sort((x, y) => x.step - y.step);
160
+ const steerTo = (step) => {
161
+ while (steered.length && steered[0].step <= step) {
162
+ const n = steered.shift();
163
+ items.push({ kind: 'user', text: n.text, at: n.at, steered: true });
164
+ }
165
+ };
166
+ let step = -1;
157
167
  for (const p of m.parts) {
168
+ if (p.type === 'step-start') {
169
+ step += 1;
170
+ steerTo(step);
171
+ continue;
172
+ }
158
173
  if (p.type === 'text' && typeof p.text === 'string' && p.text) {
159
174
  const last = items[items.length - 1];
160
175
  if (last?.kind === 'text')
@@ -195,6 +210,7 @@ export function itemsOf(messages, cards, screens = new Map()) {
195
210
  }
196
211
  }
197
212
  }
213
+ steerTo(Number.POSITIVE_INFINITY);
198
214
  }
199
215
  return items;
200
216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.215",
3
+ "version": "0.1.216",
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.215",
56
- "@columenlabs/lumen-ui-types": "0.1.215",
55
+ "@columenlabs/lumen-ops": "0.1.216",
56
+ "@columenlabs/lumen-ui-types": "0.1.216",
57
57
  "agents": "0.32.0",
58
58
  "ai": "7.0.162",
59
59
  "just-bash": "3.6.0",