@adecore/agents 0.49.4 → 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.
- package/dist/chat/chat-core.d.ts +9 -0
- package/dist/chat/chat-core.js +8 -0
- package/dist/chat/chat-session.d.ts +10 -0
- package/dist/chat/chat-session.js +47 -1
- package/dist/chat/wake-chat.d.ts +1 -0
- package/dist/coordination/index.d.ts +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/messages/deliver-message.js +2 -1
- package/dist/messages/deliver-notice.d.ts +14 -4
- package/dist/messages/deliver-notice.js +63 -26
- package/dist/messages/notice-store.d.ts +6 -0
- package/dist/messages/notice-store.js +28 -2
- package/package.json +3 -3
- package/src/chat/chat-core.ts +10 -0
- package/src/chat/chat-session.ts +60 -1
- package/src/chat/wake-chat.ts +1 -1
- package/src/coordination/index.ts +4 -0
- package/src/messages/deliver-message.ts +2 -1
- package/src/messages/deliver-notice.ts +92 -33
- package/src/messages/notice-store.ts +35 -2
package/dist/chat/chat-core.d.ts
CHANGED
|
@@ -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;
|
package/dist/chat/chat-core.js
CHANGED
|
@@ -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));
|
package/dist/chat/wake-chat.d.ts
CHANGED
|
@@ -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
|
-
|
|
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' | '
|
|
16
|
+
outcome: 'wake' | 'no-chat' | 'in-turn' | 'hop-limit';
|
|
16
17
|
waiting: number;
|
|
17
18
|
}
|
|
18
|
-
export declare function
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
47
|
+
hop: (id) => {
|
|
37
48
|
const session = chats.get(id);
|
|
38
|
-
return session === undefined ?
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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 (
|
|
57
|
-
return { outcome: '
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
+
"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.
|
|
97
|
-
"@adecore/intelligent-ui": "0.49.
|
|
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": {
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -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);
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -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));
|
package/src/chat/wake-chat.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
-
/*
|
|
22
|
-
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
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' | '
|
|
40
|
+
outcome: 'wake' | 'no-chat' | 'in-turn' | 'hop-limit';
|
|
32
41
|
waiting: number;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
/*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
|
|
59
|
+
if (turn.messageHop !== undefined) {
|
|
60
|
+
return turn.messageHop;
|
|
61
|
+
}
|
|
53
62
|
|
|
54
|
-
if (
|
|
55
|
-
return
|
|
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
|
-
|
|
93
|
+
hop: (id) => {
|
|
80
94
|
const session = chats.get(id);
|
|
81
95
|
|
|
82
|
-
return session === undefined ?
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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(
|
|
96
|
-
|
|
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 (
|
|
104
|
-
return { outcome: '
|
|
120
|
+
if (hop > MAX_MESSAGE_HOPS) {
|
|
121
|
+
return { outcome: 'hop-limit', waiting };
|
|
105
122
|
}
|
|
106
123
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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;
|