@adecore/agents 0.49.3 → 0.49.5

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.
@@ -182,6 +182,15 @@ export declare class ChatCore {
182
182
  writeRecord(chatId: string, info: ChatInfo, items: ChatItem[], preambles?: string[]): Promise<void>;
183
183
  deleteRecord(chatId: string): Promise<boolean>;
184
184
  lastTurn(chatId: string): Promise<ChatTurnItem | null>;
185
+ settleMessages(chatId: string, messages: ReadonlyArray<{
186
+ from: string;
187
+ text: string;
188
+ }>, state: 'heard' | 'dropped'): void;
189
+ steerMessages(chatId: string, steer: {
190
+ text: string;
191
+ preamble: string;
192
+ messageHop: number;
193
+ }): Promise<boolean>;
185
194
  addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void>;
186
195
  deliverNote(chatId: string, delivery: {
187
196
  noteId: string;
@@ -647,6 +647,14 @@ export class ChatCore {
647
647
  const stored = await this.store?.read(chatId);
648
648
  return stored?.items.findLast((item) => item.kind === 'turn') ?? null;
649
649
  }
650
+ /* What became of messages shown as waiting in a chat. One nobody has loaded catches up when it next takes a turn. */
651
+ settleMessages(chatId, messages, state) {
652
+ this.chats.get(chatId)?.settleMessages(messages, state);
653
+ }
654
+ /* Lets messages join a chat's running turn; false when it holds none that can take them. */
655
+ steerMessages(chatId, steer) {
656
+ return this.chats.get(chatId)?.steerMessages(steer) ?? Promise.resolve(false);
657
+ }
650
658
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
651
659
  async addNote(chatId, level, text, message) {
652
660
  await this.loaded(chatId);
@@ -191,6 +191,7 @@ export declare class ChatSession {
191
191
  taskIds: string[];
192
192
  summaryFor?: string;
193
193
  messageFrom?: string[];
194
+ messageHop?: number;
194
195
  mentions?: string[];
195
196
  skills?: string[];
196
197
  }): string | null;
@@ -210,6 +211,15 @@ export declare class ChatSession {
210
211
  from?: string;
211
212
  preamble: string;
212
213
  }): boolean;
214
+ settleMessages(messages: ReadonlyArray<{
215
+ from: string;
216
+ text: string;
217
+ }>, state: 'heard' | 'dropped'): void;
218
+ steerMessages(steer: {
219
+ text: string;
220
+ preamble: string;
221
+ messageHop: number;
222
+ }): Promise<boolean>;
213
223
  upsertItems(items: readonly ChatItem[]): void;
214
224
  addNote(level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): void;
215
225
  end(reason: string): void;
@@ -6,6 +6,7 @@ import { ChatError } from './errors.js';
6
6
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
7
7
  import { isMainAgentOutput, ThreadProjector } from './projector.js';
8
8
  import { readableAssistantText } from './readable-text.js';
9
+ import { turnHop } from '../messages/deliver-notice.js';
9
10
  import { ChatThread } from './thread.js';
10
11
  import { UiReplies } from './ui-replies.js';
11
12
  import { asError, errorText } from '../error-text.js';
@@ -897,6 +898,7 @@ export class ChatSession {
897
898
  label: wake.label,
898
899
  ...(wake.taskIds.length === 0 ? {} : { taskIds: wake.taskIds }),
899
900
  ...(wake.messageFrom === undefined || wake.messageFrom.length === 0 ? {} : { messageFrom: wake.messageFrom }),
901
+ ...(wake.messageHop === undefined ? {} : { messageHop: wake.messageHop }),
900
902
  ...(wake.summaryFor === undefined ? {} : { summaryFor: wake.summaryFor }),
901
903
  endedAt: null,
902
904
  costUsd: 0
@@ -937,7 +939,8 @@ export class ChatSession {
937
939
  const opened = this.wake({
938
940
  ...wake,
939
941
  taskIds: turn.taskIds ?? [],
940
- ...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom })
942
+ ...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom }),
943
+ ...(turn.messageHop === undefined ? {} : { messageHop: turn.messageHop })
941
944
  });
942
945
  if (opened === null) {
943
946
  return 'wait';
@@ -1051,6 +1054,49 @@ export class ChatSession {
1051
1054
  this.options.persist();
1052
1055
  return true;
1053
1056
  }
1057
+ /*
1058
+ * What became of messages that were shown as waiting: the oldest line of each that still says so.
1059
+ * Dropped stays on the line, heard is the plain line it is for every message from before this.
1060
+ */
1061
+ settleMessages(messages, state) {
1062
+ const items = messages.flatMap(({ from, text }) => {
1063
+ const line = this.thread
1064
+ .list()
1065
+ .find((item) => item.kind === 'note' && item.message?.state === 'waiting' && item.message.from === from && item.message.text === text);
1066
+ if (line?.kind !== 'note' || line.message === undefined) {
1067
+ return [];
1068
+ }
1069
+ const { state: _waiting, ...message } = line.message;
1070
+ return [this.thread.upsert({ ...line, message: state === 'dropped' ? { ...message, state } : message })];
1071
+ });
1072
+ if (items.length > 0) {
1073
+ this.emit(items);
1074
+ this.options.persist();
1075
+ }
1076
+ }
1077
+ /*
1078
+ * Lets messages join the running turn, under the same limits as a person's steer. The turn then
1079
+ * stands as far along the chain as they do, so what it sends next counts on from there.
1080
+ */
1081
+ async steerMessages(steer) {
1082
+ const backend = this.backend;
1083
+ const turnId = this.info.activeTurnId;
1084
+ if (this.info.queuePaused || this.goalWorks() || !this.busy || !turnId || !backend?.steerTurn || this.pendingStart || this.replacementWaits()) {
1085
+ return false;
1086
+ }
1087
+ const accepted = await backend
1088
+ .steerTurn({ promptId: newId('steer'), text: steer.text, preamble: steer.preamble, attachments: [], mentions: [], skills: [] })
1089
+ // A lost acknowledgement proves nothing. The message waits on, and is at worst heard twice rather than never.
1090
+ .catch(() => false);
1091
+ const turn = this.thread.get(turnId);
1092
+ if (!accepted || turn?.kind !== 'turn') {
1093
+ return accepted;
1094
+ }
1095
+ // Only the hop: `messageFrom` says what woke a turn, and this one was running already.
1096
+ this.emit([this.thread.upsert({ ...turn, messageHop: Math.max(turnHop(this.thread.list(), turnId), steer.messageHop) })]);
1097
+ this.options.persist();
1098
+ return true;
1099
+ }
1054
1100
  /* Writes items a host draws in the thread itself (a row for work it delegated), leaving the ones that did not change alone. */
1055
1101
  upsertItems(items) {
1056
1102
  const changed = items.filter((item) => JSON.stringify(this.thread.get(item.id)) !== JSON.stringify(item));
@@ -10,6 +10,7 @@ export interface WakeChat {
10
10
  note?: string;
11
11
  taskIds: string[];
12
12
  messageFrom?: string[];
13
+ messageHop?: number;
13
14
  }): boolean;
14
15
  relocating?(): boolean;
15
16
  }
@@ -9,7 +9,7 @@ export { type ChatRequests, chatRequests, WAITING_GRACE_MS, WaitingObserver, typ
9
9
  export { RESULT_PREVIEW_BYTES, wakePrompt } from '../tasks/wake-parent.ts';
10
10
  export { type TaskVerbs, type TaskWords, wireTasks, type TaskChats, type TaskRecord, type TaskWiringDeps, type TaskWiring } from '../tasks/wiring.ts';
11
11
  export { DeliverMessageWorkSchema, deliverMessageHandler, type DeliverMessageWork, type DeliverMessageEntry, type DeliverMessageDeps } from '../messages/deliver-message.ts';
12
- export { type MessageWords, type ChatNoticeTargets, type ChatDelivery, chatNoticeTargets, deliverToChat, showNotices, type NoticeChat } from '../messages/deliver-notice.ts';
12
+ export { type MessageWords, type ChatNoticeTargets, type ChatDelivery, chatNoticeTargets, MAX_MESSAGE_HOPS, steerNotices, withinHops, type SteerChat, deliverToChat, showNotices, type NoticeChat } from '../messages/deliver-notice.ts';
13
13
  export { NoticeNotes, unshownNotes } from '../messages/notice-notes.ts';
14
14
  export { MAX_NOTICES, NOTICE_MAX_AGE_MS, type Notice, NoticeStore } from '../messages/notice-store.ts';
15
15
  export { AgentLineageStore } from '../lineage.ts';
@@ -10,7 +10,7 @@ export { chatRequests, WAITING_GRACE_MS, WaitingObserver } from '../tasks/waitin
10
10
  export { RESULT_PREVIEW_BYTES, wakePrompt } from '../tasks/wake-parent.js';
11
11
  export { wireTasks } from '../tasks/wiring.js';
12
12
  export { DeliverMessageWorkSchema, deliverMessageHandler } from '../messages/deliver-message.js';
13
- export { chatNoticeTargets, deliverToChat, showNotices } from '../messages/deliver-notice.js';
13
+ export { chatNoticeTargets, MAX_MESSAGE_HOPS, steerNotices, withinHops, deliverToChat, showNotices } from '../messages/deliver-notice.js';
14
14
  export { NoticeNotes, unshownNotes } from '../messages/notice-notes.js';
15
15
  export { MAX_NOTICES, NOTICE_MAX_AGE_MS, NoticeStore } from '../messages/notice-store.js';
16
16
  export { AgentLineageStore } from '../lineage.js';
@@ -35,7 +35,8 @@ export function deliverMessageHandler(deps) {
35
35
  text: deps.words.prompt(waiting.length),
36
36
  label: deps.words.label(waiting),
37
37
  taskIds: [],
38
- messageFrom: [...new Set(waiting.map((notice) => notice.from))]
38
+ messageFrom: [...new Set(waiting.map((notice) => notice.from))],
39
+ messageHop: Math.max(...waiting.map((notice) => notice.hop ?? 0))
39
40
  });
40
41
  };
41
42
  }
@@ -9,15 +9,25 @@ export interface MessageWords {
9
9
  }
10
10
  export interface ChatNoticeTargets {
11
11
  chat(id: string): Promise<'idle' | 'running' | 'none'>;
12
- wokenBy(id: string): readonly string[];
12
+ hop(id: string): number;
13
13
  }
14
+ export declare const MAX_MESSAGE_HOPS = 4;
14
15
  export interface ChatDelivery {
15
- outcome: 'wake' | 'no-chat' | 'in-turn' | 'from-message';
16
+ outcome: 'wake' | 'no-chat' | 'in-turn' | 'hop-limit';
16
17
  waiting: number;
17
18
  }
18
- export declare function turnWokenBy(items: readonly ChatItem[], activeTurnId: string | null): readonly string[];
19
+ export declare function turnHop(items: readonly ChatItem[], activeTurnId: string | null): number;
20
+ export declare function withinHops(notice: Pick<Notice, 'hop'>): boolean;
19
21
  export declare function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): ChatNoticeTargets;
20
- export declare function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery>;
22
+ export declare function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt' | 'hop'>): Promise<ChatDelivery>;
23
+ export interface SteerChat {
24
+ steer(chatId: string, steer: {
25
+ text: string;
26
+ preamble: string;
27
+ messageHop: number;
28
+ }): Promise<boolean>;
29
+ }
30
+ export declare function steerNotices(store: Pick<NoticeStore, 'waiting' | 'remove'>, chat: SteerChat, words: Pick<MessageWords, 'heard' | 'prompt'>, targetId: string): Promise<void>;
21
31
  export interface NoticeChat {
22
32
  has(id: string): Promise<boolean>;
23
33
  note(id: string, text: string, message: ChatNoteMessage): Promise<void>;
@@ -1,26 +1,37 @@
1
1
  /*
2
- * Who sent the message that opened the turn a chat is running, empty when no message opened it,
3
- * which stops two agents that read each other from waking each other forever. It rides on the turn,
4
- * so it survives a restart. Between turns the last one counts, since work it left running (a
5
- * background subagent) may still write, and a turn the CLI opened by itself takes the step of the
6
- * turn before it.
2
+ * How far a chain of messages may wake chats: enough for a question, an answer and a report on each,
3
+ * and a bound on two agents reading each other, which nothing but a person should be able to keep going.
7
4
  */
8
- export function turnWokenBy(items, activeTurnId) {
5
+ export const MAX_MESSAGE_HOPS = 4;
6
+ /* Where a turn from before hops stood: its message and the answer it could still get. */
7
+ const HOP_OF_OLDER_TURN = 2;
8
+ /*
9
+ * How many messages led to the turn a chat is running. It rides on the turn, so it survives a restart.
10
+ * Between turns the last one counts, since work it left running (a background subagent) may still
11
+ * write, and a turn the CLI opened by itself takes the step of the turn before it.
12
+ */
13
+ export function turnHop(items, activeTurnId) {
9
14
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
10
15
  for (let i = from; i >= 0; i--) {
11
16
  const turn = items[i];
12
17
  if (turn.kind !== 'turn') {
13
18
  continue;
14
19
  }
15
- const senders = turn.messageFrom ?? [];
16
- if (senders.length > 0) {
17
- return senders;
20
+ if (turn.messageHop !== undefined) {
21
+ return turn.messageHop;
22
+ }
23
+ if ((turn.messageFrom ?? []).length > 0) {
24
+ return HOP_OF_OLDER_TURN;
18
25
  }
19
26
  if (turn.origin !== 'agent' || turn.taskIds !== undefined || turn.summaryFor !== undefined) {
20
- return [];
27
+ return 0;
21
28
  }
22
29
  }
23
- return [];
30
+ return 0;
31
+ }
32
+ /* Whether a message may still move a chat. One of an older host has no hop and waits, as it always did. */
33
+ export function withinHops(notice) {
34
+ return notice.hop !== undefined && notice.hop <= MAX_MESSAGE_HOPS;
24
35
  }
25
36
  /* The chats of a core as a message sees them. */
26
37
  export function chatNoticeTargets(chats) {
@@ -33,39 +44,65 @@ export function chatNoticeTargets(chats) {
33
44
  // A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
34
45
  return (await chats.hasStored(id)) ? 'idle' : 'none';
35
46
  },
36
- wokenBy: (id) => {
47
+ hop: (id) => {
37
48
  const session = chats.get(id);
38
- return session === undefined ? [] : turnWokenBy(session.thread.list(), session.info.activeTurnId);
49
+ return session === undefined ? 0 : turnHop(session.thread.list(), session.info.activeTurnId);
39
50
  }
40
51
  };
41
52
  }
42
53
  /*
43
54
  * One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
44
55
  * one, as for a settled task, or the message sits there until someone happens to prompt that chat.
45
- * A turn a message opened wakes nobody with a message of its own, except the sender it answers,
46
- * once: only while that sender's own turn was opened by no message, so a question and its answer
47
- * take two steps and the turn the answer opens wakes nobody. A node that runs something else, such
48
- * as a terminal, is the host's to deliver to before this.
56
+ * Each message is one hop further than the turn that sent it, and past `MAX_MESSAGE_HOPS` it only
57
+ * waits. A node that runs something else, such as a terminal, is the host's to deliver to before this.
49
58
  */
50
59
  export async function deliverToChat(store, targets, notice) {
51
- const waiting = await store.put(notice);
60
+ const hop = targets.hop(notice.from) + 1;
61
+ const waiting = await store.put({ ...notice, hop });
52
62
  const chat = await targets.chat(notice.targetId);
53
63
  if (chat === 'none') {
54
64
  return { outcome: 'no-chat', waiting };
55
65
  }
56
- if (chat === 'running') {
57
- return { outcome: 'in-turn', waiting };
66
+ if (hop > MAX_MESSAGE_HOPS) {
67
+ return { outcome: 'hop-limit', waiting };
68
+ }
69
+ return { outcome: chat === 'running' ? 'in-turn' : 'wake', waiting };
70
+ }
71
+ const steering = new Set();
72
+ /*
73
+ * Hands what waits for a running chat to its running turn, one batch at a time per chat, so a message
74
+ * landing during a slow acknowledgement goes in the next batch and not twice. What the CLI did not take
75
+ * stays in the queue, for the turn that opens when this one ends.
76
+ */
77
+ export async function steerNotices(store, chat, words, targetId) {
78
+ if (steering.has(targetId)) {
79
+ return;
80
+ }
81
+ steering.add(targetId);
82
+ try {
83
+ for (;;) {
84
+ const batch = store.waiting(targetId).filter(withinHops);
85
+ if (batch.length === 0) {
86
+ return;
87
+ }
88
+ const accepted = await chat.steer(targetId, {
89
+ text: words.prompt(batch.length),
90
+ preamble: batch.map((notice) => words.heard(notice)).join('\n\n'),
91
+ messageHop: Math.max(...batch.map((notice) => notice.hop ?? 0))
92
+ });
93
+ if (!accepted) {
94
+ return;
95
+ }
96
+ await store.remove(targetId, batch);
97
+ }
58
98
  }
59
- const wokenBy = targets.wokenBy(notice.from);
60
- const answer = wokenBy.includes(notice.targetId) && targets.wokenBy(notice.targetId).length === 0;
61
- if (wokenBy.length > 0 && !answer) {
62
- return { outcome: 'from-message', waiting };
99
+ finally {
100
+ steering.delete(targetId);
63
101
  }
64
- return { outcome: 'wake', waiting };
65
102
  }
66
103
  /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
67
104
  export function noticeMessage(notice) {
68
- return { from: notice.from, title: notice.fromTitle, text: notice.text };
105
+ return { from: notice.from, title: notice.fromTitle, text: notice.text, state: 'waiting' };
69
106
  }
70
107
  /*
71
108
  * Shows a person the message in the chat's thread the moment it lands, not when the model gets round
@@ -9,6 +9,7 @@ declare const NoticeSchema: z.ZodObject<{
9
9
  text: z.ZodString;
10
10
  createdAt: z.ZodNumber;
11
11
  shown: z.ZodOptional<z.ZodBoolean>;
12
+ hop: z.ZodOptional<z.ZodNumber>;
12
13
  }, z.core.$strip>;
13
14
  export type Notice = z.infer<typeof NoticeSchema>;
14
15
  export declare class NoticeStore {
@@ -16,9 +17,14 @@ export declare class NoticeStore {
16
17
  private readonly queues;
17
18
  private readonly now;
18
19
  private lastDrop;
20
+ private settledListener;
19
21
  constructor(dataDir: string, now?: () => number);
20
22
  load(): Promise<void>;
21
23
  put(notice: Omit<Notice, 'createdAt'>): Promise<number>;
24
+ onSettled(listener: (targetId: string, notices: readonly Notice[], state: 'heard' | 'dropped') => void): void;
25
+ remove(targetId: string, notices: readonly Notice[]): Promise<void>;
26
+ private tell;
27
+ private stale;
22
28
  take(targetId: string): Notice[];
23
29
  settled(): Promise<void>;
24
30
  show(targetId: string): Promise<Notice[]>;
@@ -23,7 +23,9 @@ const NoticeSchema = z.object({
23
23
  * emptied by the model reading the message, and the two readers must not take it from each
24
24
  * other. Absent is unshown, so a message left by an older host is still shown once.
25
25
  */
26
- shown: z.boolean().optional()
26
+ shown: z.boolean().optional(),
27
+ /* How many messages led here since a person or a task last started something; absent for a message of an older host. */
28
+ hop: z.number().optional()
27
29
  });
28
30
  /* A queue waits in the file of the node it is for, so the first message in it names that node. */
29
31
  const FileSchema = z.object({ notices: z.array(NoticeSchema).min(1) });
@@ -37,6 +39,7 @@ export class NoticeStore {
37
39
  queues;
38
40
  now;
39
41
  lastDrop = Promise.resolve();
42
+ settledListener = null;
40
43
  constructor(dataDir, now = Date.now) {
41
44
  this.dir = join(dataDir, 'notices');
42
45
  this.now = now;
@@ -56,10 +59,31 @@ export class NoticeStore {
56
59
  }
57
60
  /* Puts one message in a node's queue and answers how many now wait for it. */
58
61
  async put(notice) {
59
- const queue = [...this.waiting(notice.targetId), { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
62
+ const before = this.waiting(notice.targetId);
63
+ const queue = [...before, { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
64
+ const dropped = [...this.stale(notice.targetId), ...before.slice(0, Math.max(0, before.length + 1 - MAX_NOTICES))];
60
65
  await this.persist(notice.targetId, queue);
66
+ this.tell(notice.targetId, dropped, 'dropped');
61
67
  return queue.length;
62
68
  }
69
+ /* Who hears that a message left the queue, read or not, so a thread can say what became of it. */
70
+ onSettled(listener) {
71
+ this.settledListener = listener;
72
+ }
73
+ /* Takes these messages out of the queue once their model heard them while others stay behind. */
74
+ async remove(targetId, notices) {
75
+ const gone = new Set(notices.map((notice) => notice.createdAt));
76
+ await this.persist(targetId, this.waiting(targetId).filter((notice) => !gone.has(notice.createdAt)));
77
+ this.tell(targetId, notices, 'heard');
78
+ }
79
+ tell(targetId, notices, state) {
80
+ if (notices.length > 0) {
81
+ this.settledListener?.(targetId, notices, state);
82
+ }
83
+ }
84
+ stale(targetId) {
85
+ return (this.queues.get(targetId)?.notices ?? []).filter((notice) => !this.fresh(notice));
86
+ }
63
87
  /*
64
88
  * Everything waiting for a node, gone the moment it is asked for. Synchronous, because the
65
89
  * callers are: a shell's first screen and a hook's answer are both written before anything can
@@ -68,9 +92,11 @@ export class NoticeStore {
68
92
  */
69
93
  take(targetId) {
70
94
  const queue = this.waiting(targetId);
95
+ this.tell(targetId, this.stale(targetId), 'dropped');
71
96
  if (queue.length === 0) {
72
97
  return [];
73
98
  }
99
+ this.tell(targetId, queue, 'heard');
74
100
  this.lastDrop = this.persist(targetId, []).catch((e) => console.error(`Dropping the messages of ${targetId} failed:`, errorText(e)));
75
101
  return queue;
76
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.49.3",
3
+ "version": "0.49.5",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -93,8 +93,8 @@
93
93
  "test": "bun --conditions=source test"
94
94
  },
95
95
  "dependencies": {
96
- "@adecore/agent-contracts": "0.49.3",
97
- "@adecore/intelligent-ui": "0.49.3",
96
+ "@adecore/agent-contracts": "0.49.5",
97
+ "@adecore/intelligent-ui": "0.49.5",
98
98
  "zod": "^4.6.7"
99
99
  },
100
100
  "devDependencies": {
@@ -985,6 +985,16 @@ export class ChatCore {
985
985
  return stored?.items.findLast((item): item is ChatTurnItem => item.kind === 'turn') ?? null;
986
986
  }
987
987
 
988
+ /* What became of messages shown as waiting in a chat. One nobody has loaded catches up when it next takes a turn. */
989
+ settleMessages(chatId: string, messages: ReadonlyArray<{ from: string; text: string }>, state: 'heard' | 'dropped'): void {
990
+ this.chats.get(chatId)?.settleMessages(messages, state);
991
+ }
992
+
993
+ /* Lets messages join a chat's running turn; false when it holds none that can take them. */
994
+ steerMessages(chatId: string, steer: { text: string; preamble: string; messageHop: number }): Promise<boolean> {
995
+ return this.chats.get(chatId)?.steerMessages(steer) ?? Promise.resolve(false);
996
+ }
997
+
988
998
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
989
999
  async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string, message?: ChatNoteMessage): Promise<void> {
990
1000
  await this.loaded(chatId);
@@ -34,6 +34,7 @@ import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.ts';
34
34
  import { isMainAgentOutput, ThreadProjector } from './projector.ts';
35
35
  import { readableAssistantText } from './readable-text.ts';
36
36
  import type { SubagentSettlement } from './subagent-settlement.ts';
37
+ import { turnHop } from '../messages/deliver-notice.ts';
37
38
  import { ChatThread } from './thread.ts';
38
39
  import { UiReplies, type UiReplyHost } from './ui-replies.ts';
39
40
  import { asError, errorText } from '../error-text.ts';
@@ -1246,6 +1247,7 @@ export class ChatSession {
1246
1247
  taskIds: string[];
1247
1248
  summaryFor?: string;
1248
1249
  messageFrom?: string[];
1250
+ messageHop?: number;
1249
1251
  mentions?: string[];
1250
1252
  skills?: string[];
1251
1253
  }): string | null {
@@ -1270,6 +1272,7 @@ export class ChatSession {
1270
1272
  label: wake.label,
1271
1273
  ...(wake.taskIds.length === 0 ? {} : { taskIds: wake.taskIds }),
1272
1274
  ...(wake.messageFrom === undefined || wake.messageFrom.length === 0 ? {} : { messageFrom: wake.messageFrom }),
1275
+ ...(wake.messageHop === undefined ? {} : { messageHop: wake.messageHop }),
1273
1276
  ...(wake.summaryFor === undefined ? {} : { summaryFor: wake.summaryFor }),
1274
1277
  endedAt: null,
1275
1278
  costUsd: 0
@@ -1317,7 +1320,8 @@ export class ChatSession {
1317
1320
  const opened = this.wake({
1318
1321
  ...wake,
1319
1322
  taskIds: turn.taskIds ?? [],
1320
- ...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom })
1323
+ ...(turn.messageFrom === undefined ? {} : { messageFrom: turn.messageFrom }),
1324
+ ...(turn.messageHop === undefined ? {} : { messageHop: turn.messageHop })
1321
1325
  });
1322
1326
 
1323
1327
  if (opened === null) {
@@ -1464,6 +1468,61 @@ export class ChatSession {
1464
1468
  return true;
1465
1469
  }
1466
1470
 
1471
+ /*
1472
+ * What became of messages that were shown as waiting: the oldest line of each that still says so.
1473
+ * Dropped stays on the line, heard is the plain line it is for every message from before this.
1474
+ */
1475
+ settleMessages(messages: ReadonlyArray<{ from: string; text: string }>, state: 'heard' | 'dropped'): void {
1476
+ const items = messages.flatMap(({ from, text }) => {
1477
+ const line = this.thread
1478
+ .list()
1479
+ .find((item) => item.kind === 'note' && item.message?.state === 'waiting' && item.message.from === from && item.message.text === text);
1480
+
1481
+ if (line?.kind !== 'note' || line.message === undefined) {
1482
+ return [];
1483
+ }
1484
+
1485
+ const { state: _waiting, ...message } = line.message;
1486
+
1487
+ return [this.thread.upsert({ ...line, message: state === 'dropped' ? { ...message, state } : message })];
1488
+ });
1489
+
1490
+ if (items.length > 0) {
1491
+ this.emit(items);
1492
+ this.options.persist();
1493
+ }
1494
+ }
1495
+
1496
+ /*
1497
+ * Lets messages join the running turn, under the same limits as a person's steer. The turn then
1498
+ * stands as far along the chain as they do, so what it sends next counts on from there.
1499
+ */
1500
+ async steerMessages(steer: { text: string; preamble: string; messageHop: number }): Promise<boolean> {
1501
+ const backend = this.backend;
1502
+ const turnId = this.info.activeTurnId;
1503
+
1504
+ if (this.info.queuePaused || this.goalWorks() || !this.busy || !turnId || !backend?.steerTurn || this.pendingStart || this.replacementWaits()) {
1505
+ return false;
1506
+ }
1507
+
1508
+ const accepted = await backend
1509
+ .steerTurn({ promptId: newId('steer'), text: steer.text, preamble: steer.preamble, attachments: [], mentions: [], skills: [] })
1510
+ // A lost acknowledgement proves nothing. The message waits on, and is at worst heard twice rather than never.
1511
+ .catch(() => false);
1512
+
1513
+ const turn = this.thread.get(turnId);
1514
+
1515
+ if (!accepted || turn?.kind !== 'turn') {
1516
+ return accepted;
1517
+ }
1518
+
1519
+ // Only the hop: `messageFrom` says what woke a turn, and this one was running already.
1520
+ this.emit([this.thread.upsert({ ...turn, messageHop: Math.max(turnHop(this.thread.list(), turnId), steer.messageHop) })]);
1521
+ this.options.persist();
1522
+
1523
+ return true;
1524
+ }
1525
+
1467
1526
  /* Writes items a host draws in the thread itself (a row for work it delegated), leaving the ones that did not change alone. */
1468
1527
  upsertItems(items: readonly ChatItem[]): void {
1469
1528
  const changed = items.filter((item) => JSON.stringify(this.thread.get(item.id)) !== JSON.stringify(item));
@@ -9,7 +9,7 @@ import type { ChatSession } from './chat-session.ts';
9
9
  export interface WakeChat {
10
10
  items(): ChatItem[];
11
11
  persist?(): Promise<void>;
12
- wake(wake: { text: string; label: string; note?: string; taskIds: string[]; messageFrom?: string[] }): boolean;
12
+ wake(wake: { text: string; label: string; note?: string; taskIds: string[]; messageFrom?: string[]; messageHop?: number }): boolean;
13
13
  /* Whether the host is moving the chat to another folder, which turns every wake away until it ends. */
14
14
  relocating?(): boolean;
15
15
  }
@@ -67,6 +67,10 @@ export {
67
67
  type ChatNoticeTargets,
68
68
  type ChatDelivery,
69
69
  chatNoticeTargets,
70
+ MAX_MESSAGE_HOPS,
71
+ steerNotices,
72
+ withinHops,
73
+ type SteerChat,
70
74
  deliverToChat,
71
75
  showNotices,
72
76
  type NoticeChat
@@ -60,7 +60,8 @@ export function deliverMessageHandler(deps: DeliverMessageDeps) {
60
60
  text: deps.words.prompt(waiting.length),
61
61
  label: deps.words.label(waiting),
62
62
  taskIds: [],
63
- messageFrom: [...new Set(waiting.map((notice) => notice.from))]
63
+ messageFrom: [...new Set(waiting.map((notice) => notice.from))],
64
+ messageHop: Math.max(...waiting.map((notice) => notice.hop ?? 0))
64
65
  });
65
66
  };
66
67
  }
@@ -18,28 +18,35 @@ export interface MessageWords {
18
18
  export interface ChatNoticeTargets {
19
19
  /* What the host holds under this id: a chat between turns, one that is in a turn, or no chat at all. */
20
20
  chat(id: string): Promise<'idle' | 'running' | 'none'>;
21
- /* The senders whose message opened the turn this node is running, empty for a turn no message opened. */
22
- wokenBy(id: string): readonly string[];
21
+ /* How many messages led to the turn this node is running; 0 for a turn no message opened. */
22
+ hop(id: string): number;
23
23
  }
24
24
 
25
25
  /*
26
- * Where a message for a chat went, and how many now wait for it. Only `wake` owes the chat a turn,
27
- * which the host opens through a `deliver-message` entry; every other outcome waits in the queue for
28
- * the chat's next turn.
26
+ * How far a chain of messages may wake chats: enough for a question, an answer and a report on each,
27
+ * and a bound on two agents reading each other, which nothing but a person should be able to keep going.
28
+ */
29
+ export const MAX_MESSAGE_HOPS = 4;
30
+
31
+ /* Where a turn from before hops stood: its message and the answer it could still get. */
32
+ const HOP_OF_OLDER_TURN = 2;
33
+
34
+ /*
35
+ * Where a message for a chat went, and how many now wait for it. Only `wake` owes the chat a turn now,
36
+ * which the host opens through a `deliver-message` entry; `in-turn` is owed one when that turn ends,
37
+ * or may join it; every other outcome waits in the queue for the chat's next turn.
29
38
  */
30
39
  export interface ChatDelivery {
31
- outcome: 'wake' | 'no-chat' | 'in-turn' | 'from-message';
40
+ outcome: 'wake' | 'no-chat' | 'in-turn' | 'hop-limit';
32
41
  waiting: number;
33
42
  }
34
43
 
35
44
  /*
36
- * Who sent the message that opened the turn a chat is running, empty when no message opened it,
37
- * which stops two agents that read each other from waking each other forever. It rides on the turn,
38
- * so it survives a restart. Between turns the last one counts, since work it left running (a
39
- * background subagent) may still write, and a turn the CLI opened by itself takes the step of the
40
- * turn before it.
45
+ * How many messages led to the turn a chat is running. It rides on the turn, so it survives a restart.
46
+ * Between turns the last one counts, since work it left running (a background subagent) may still
47
+ * write, and a turn the CLI opened by itself takes the step of the turn before it.
41
48
  */
42
- export function turnWokenBy(items: readonly ChatItem[], activeTurnId: string | null): readonly string[] {
49
+ export function turnHop(items: readonly ChatItem[], activeTurnId: string | null): number {
43
50
  const from = activeTurnId === null ? items.findLastIndex((item) => item.kind === 'turn') : items.findIndex((item) => item.id === activeTurnId);
44
51
 
45
52
  for (let i = from; i >= 0; i--) {
@@ -49,18 +56,25 @@ export function turnWokenBy(items: readonly ChatItem[], activeTurnId: string | n
49
56
  continue;
50
57
  }
51
58
 
52
- const senders = turn.messageFrom ?? [];
59
+ if (turn.messageHop !== undefined) {
60
+ return turn.messageHop;
61
+ }
53
62
 
54
- if (senders.length > 0) {
55
- return senders;
63
+ if ((turn.messageFrom ?? []).length > 0) {
64
+ return HOP_OF_OLDER_TURN;
56
65
  }
57
66
 
58
67
  if (turn.origin !== 'agent' || turn.taskIds !== undefined || turn.summaryFor !== undefined) {
59
- return [];
68
+ return 0;
60
69
  }
61
70
  }
62
71
 
63
- return [];
72
+ return 0;
73
+ }
74
+
75
+ /* Whether a message may still move a chat. One of an older host has no hop and waits, as it always did. */
76
+ export function withinHops(notice: Pick<Notice, 'hop'>): boolean {
77
+ return notice.hop !== undefined && notice.hop <= MAX_MESSAGE_HOPS;
64
78
  }
65
79
 
66
80
  /* The chats of a core as a message sees them. */
@@ -76,10 +90,10 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
76
90
  // A chat nobody has loaded is idle: the turn opens on the thread the host reads back from disk.
77
91
  return (await chats.hasStored(id)) ? 'idle' : 'none';
78
92
  },
79
- wokenBy: (id) => {
93
+ hop: (id) => {
80
94
  const session = chats.get(id);
81
95
 
82
- return session === undefined ? [] : turnWokenBy(session.thread.list(), session.info.activeTurnId);
96
+ return session === undefined ? 0 : turnHop(session.thread.list(), session.info.activeTurnId);
83
97
  }
84
98
  };
85
99
  }
@@ -87,31 +101,76 @@ export function chatNoticeTargets(chats: Pick<ChatCore, 'get' | 'hasStored'>): C
87
101
  /*
88
102
  * One message on its way to a chat, and whether the chat owes a turn on it. A chat between turns gets
89
103
  * one, as for a settled task, or the message sits there until someone happens to prompt that chat.
90
- * A turn a message opened wakes nobody with a message of its own, except the sender it answers,
91
- * once: only while that sender's own turn was opened by no message, so a question and its answer
92
- * take two steps and the turn the answer opens wakes nobody. A node that runs something else, such
93
- * as a terminal, is the host's to deliver to before this.
104
+ * Each message is one hop further than the turn that sent it, and past `MAX_MESSAGE_HOPS` it only
105
+ * waits. A node that runs something else, such as a terminal, is the host's to deliver to before this.
94
106
  */
95
- export async function deliverToChat(store: Pick<NoticeStore, 'put'>, targets: ChatNoticeTargets, notice: Omit<Notice, 'createdAt'>): Promise<ChatDelivery> {
96
- const waiting = await store.put(notice);
107
+ export async function deliverToChat(
108
+ store: Pick<NoticeStore, 'put'>,
109
+ targets: ChatNoticeTargets,
110
+ notice: Omit<Notice, 'createdAt' | 'hop'>
111
+ ): Promise<ChatDelivery> {
112
+ const hop = targets.hop(notice.from) + 1;
113
+ const waiting = await store.put({ ...notice, hop });
97
114
  const chat = await targets.chat(notice.targetId);
98
115
 
99
116
  if (chat === 'none') {
100
117
  return { outcome: 'no-chat', waiting };
101
118
  }
102
119
 
103
- if (chat === 'running') {
104
- return { outcome: 'in-turn', waiting };
120
+ if (hop > MAX_MESSAGE_HOPS) {
121
+ return { outcome: 'hop-limit', waiting };
105
122
  }
106
123
 
107
- const wokenBy = targets.wokenBy(notice.from);
108
- const answer = wokenBy.includes(notice.targetId) && targets.wokenBy(notice.targetId).length === 0;
124
+ return { outcome: chat === 'running' ? 'in-turn' : 'wake', waiting };
125
+ }
126
+
127
+ /* The chat a message may join while it runs. False when it cannot take one now, which leaves the message waiting. */
128
+ export interface SteerChat {
129
+ steer(chatId: string, steer: { text: string; preamble: string; messageHop: number }): Promise<boolean>;
130
+ }
131
+
132
+ const steering = new Set<string>();
109
133
 
110
- if (wokenBy.length > 0 && !answer) {
111
- return { outcome: 'from-message', waiting };
134
+ /*
135
+ * Hands what waits for a running chat to its running turn, one batch at a time per chat, so a message
136
+ * landing during a slow acknowledgement goes in the next batch and not twice. What the CLI did not take
137
+ * stays in the queue, for the turn that opens when this one ends.
138
+ */
139
+ export async function steerNotices(
140
+ store: Pick<NoticeStore, 'waiting' | 'remove'>,
141
+ chat: SteerChat,
142
+ words: Pick<MessageWords, 'heard' | 'prompt'>,
143
+ targetId: string
144
+ ): Promise<void> {
145
+ if (steering.has(targetId)) {
146
+ return;
112
147
  }
113
148
 
114
- return { outcome: 'wake', waiting };
149
+ steering.add(targetId);
150
+
151
+ try {
152
+ for (;;) {
153
+ const batch = store.waiting(targetId).filter(withinHops);
154
+
155
+ if (batch.length === 0) {
156
+ return;
157
+ }
158
+
159
+ const accepted = await chat.steer(targetId, {
160
+ text: words.prompt(batch.length),
161
+ preamble: batch.map((notice) => words.heard(notice)).join('\n\n'),
162
+ messageHop: Math.max(...batch.map((notice) => notice.hop ?? 0))
163
+ });
164
+
165
+ if (!accepted) {
166
+ return;
167
+ }
168
+
169
+ await store.remove(targetId, batch);
170
+ }
171
+ } finally {
172
+ steering.delete(targetId);
173
+ }
115
174
  }
116
175
 
117
176
  /* The chat a message was left for, as the two things showing it needs: whether it is there, and a line in its thread. */
@@ -124,7 +183,7 @@ export interface NoticeChat {
124
183
 
125
184
  /* The message itself on the line in the thread, so a client can draw it as said to the chat. */
126
185
  export function noticeMessage(notice: Notice): ChatNoteMessage {
127
- return { from: notice.from, title: notice.fromTitle, text: notice.text };
186
+ return { from: notice.from, title: notice.fromTitle, text: notice.text, state: 'waiting' };
128
187
  }
129
188
 
130
189
  /*
@@ -26,7 +26,9 @@ const NoticeSchema = z.object({
26
26
  * emptied by the model reading the message, and the two readers must not take it from each
27
27
  * other. Absent is unshown, so a message left by an older host is still shown once.
28
28
  */
29
- shown: z.boolean().optional()
29
+ shown: z.boolean().optional(),
30
+ /* How many messages led here since a person or a task last started something; absent for a message of an older host. */
31
+ hop: z.number().optional()
30
32
  });
31
33
 
32
34
  export type Notice = z.infer<typeof NoticeSchema>;
@@ -46,6 +48,7 @@ export class NoticeStore {
46
48
  private readonly queues: RecordDirectory<NoticeFile>;
47
49
  private readonly now: () => number;
48
50
  private lastDrop: Promise<void> = Promise.resolve();
51
+ private settledListener: ((targetId: string, notices: readonly Notice[], state: 'heard' | 'dropped') => void) | null = null;
49
52
 
50
53
  constructor(dataDir: string, now: () => number = Date.now) {
51
54
  this.dir = join(dataDir, 'notices');
@@ -70,12 +73,40 @@ export class NoticeStore {
70
73
 
71
74
  /* Puts one message in a node's queue and answers how many now wait for it. */
72
75
  async put(notice: Omit<Notice, 'createdAt'>): Promise<number> {
73
- const queue = [...this.waiting(notice.targetId), { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
76
+ const before = this.waiting(notice.targetId);
77
+ const queue = [...before, { ...notice, createdAt: this.now() }].slice(-MAX_NOTICES);
78
+ const dropped = [...this.stale(notice.targetId), ...before.slice(0, Math.max(0, before.length + 1 - MAX_NOTICES))];
74
79
  await this.persist(notice.targetId, queue);
80
+ this.tell(notice.targetId, dropped, 'dropped');
75
81
 
76
82
  return queue.length;
77
83
  }
78
84
 
85
+ /* Who hears that a message left the queue, read or not, so a thread can say what became of it. */
86
+ onSettled(listener: (targetId: string, notices: readonly Notice[], state: 'heard' | 'dropped') => void): void {
87
+ this.settledListener = listener;
88
+ }
89
+
90
+ /* Takes these messages out of the queue once their model heard them while others stay behind. */
91
+ async remove(targetId: string, notices: readonly Notice[]): Promise<void> {
92
+ const gone = new Set(notices.map((notice) => notice.createdAt));
93
+ await this.persist(
94
+ targetId,
95
+ this.waiting(targetId).filter((notice) => !gone.has(notice.createdAt))
96
+ );
97
+ this.tell(targetId, notices, 'heard');
98
+ }
99
+
100
+ private tell(targetId: string, notices: readonly Notice[], state: 'heard' | 'dropped'): void {
101
+ if (notices.length > 0) {
102
+ this.settledListener?.(targetId, notices, state);
103
+ }
104
+ }
105
+
106
+ private stale(targetId: string): Notice[] {
107
+ return (this.queues.get(targetId)?.notices ?? []).filter((notice) => !this.fresh(notice));
108
+ }
109
+
79
110
  /*
80
111
  * Everything waiting for a node, gone the moment it is asked for. Synchronous, because the
81
112
  * callers are: a shell's first screen and a hook's answer are both written before anything can
@@ -84,11 +115,13 @@ export class NoticeStore {
84
115
  */
85
116
  take(targetId: string): Notice[] {
86
117
  const queue = this.waiting(targetId);
118
+ this.tell(targetId, this.stale(targetId), 'dropped');
87
119
 
88
120
  if (queue.length === 0) {
89
121
  return [];
90
122
  }
91
123
 
124
+ this.tell(targetId, queue, 'heard');
92
125
  this.lastDrop = this.persist(targetId, []).catch((e) => console.error(`Dropping the messages of ${targetId} failed:`, errorText(e)));
93
126
 
94
127
  return queue;