@khorsheed/dsh-room 0.2.2 → 0.2.4
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/CHANGELOG.md +58 -0
- package/README.en.md +29 -62
- package/README.i18n.yaml +2 -2
- package/README.md +28 -61
- package/lib/client.js +14620 -5705
- package/lib/index.js +4069 -730
- package/lib/journal-B4FOGO6S.js +482 -0
- package/lib/journal-Rid9AAnD.js +485 -0
- package/lib/tool.js +112 -166
- package/lib/typert.host.js +2031 -374
- package/lib/typert.remote-client.d.ts +34 -10
- package/lib/typert.remote-client.js +1599 -210
- package/lib/types/agent-setup.d.ts +1 -1
- package/lib/types/client/InviteAgentAction.js +4 -16
- package/lib/types/client/InviteDialog.d.ts +28 -6
- package/lib/types/client/InviteDialog.js +76 -12
- package/lib/types/client/MemberCard.d.ts +5 -1
- package/lib/types/client/MemberCard.js +4 -4
- package/lib/types/client/MembersView.d.ts +1 -1
- package/lib/types/client/MembersView.js +58 -22
- package/lib/types/client/RemoveMemberConfirm.d.ts +40 -0
- package/lib/types/client/RemoveMemberConfirm.js +85 -0
- package/lib/types/client/RoomApprovalView.d.ts +11 -0
- package/lib/types/client/RoomApprovalView.js +28 -0
- package/lib/types/client/RoomComposer.d.ts +1 -1
- package/lib/types/client/RoomComposer.js +195 -29
- package/lib/types/client/RoomDecisionView.d.ts +22 -0
- package/lib/types/client/RoomDecisionView.js +35 -0
- package/lib/types/client/RoomHelpView.d.ts +17 -0
- package/lib/types/client/RoomHelpView.js +56 -0
- package/lib/types/client/RoomInboxView.d.ts +10 -0
- package/lib/types/client/RoomInboxView.js +14 -0
- package/lib/types/client/RoomQuestionView.d.ts +13 -0
- package/lib/types/client/RoomQuestionView.js +61 -0
- package/lib/types/client/RoomRelayView.js +1 -1
- package/lib/types/client/RoomRunView.d.ts +3 -1
- package/lib/types/client/RoomRunView.js +12 -5
- package/lib/types/client/RoomSettingsView.d.ts +19 -0
- package/lib/types/client/RoomSettingsView.js +30 -0
- package/lib/types/client/RoomSpeechView.d.ts +22 -8
- package/lib/types/client/RoomSpeechView.js +94 -11
- package/lib/types/client/RoomStatsLine.d.ts +26 -5
- package/lib/types/client/RoomStatsLine.js +53 -7
- package/lib/types/client/RoomTaskCapsule.d.ts +19 -0
- package/lib/types/client/RoomTaskCapsule.js +37 -0
- package/lib/types/client/TasksTab.d.ts +41 -0
- package/lib/types/client/TasksTab.js +43 -0
- package/lib/types/client/TasksView.d.ts +44 -0
- package/lib/types/client/TasksView.js +149 -0
- package/lib/types/client/TeamView.d.ts +33 -0
- package/lib/types/client/TeamView.js +210 -0
- package/lib/types/client/at-menu.d.ts +127 -0
- package/lib/types/client/at-menu.js +173 -0
- package/lib/types/client/draft-chips.d.ts +67 -0
- package/lib/types/client/draft-chips.js +95 -0
- package/lib/types/client/executions.d.ts +0 -30
- package/lib/types/client/executions.js +4 -34
- package/lib/types/client/icons-local.d.ts +25 -0
- package/lib/types/client/icons-local.js +10 -0
- package/lib/types/client/icons.d.ts +2 -1
- package/lib/types/client/icons.js +1 -0
- package/lib/types/client/index.js +276 -72
- package/lib/types/client/locales.d.ts +482 -52
- package/lib/types/client/locales.js +490 -60
- package/lib/types/client/nodes.d.ts +112 -17
- package/lib/types/client/nodes.js +197 -26
- package/lib/types/client/slots.d.ts +117 -47
- package/lib/types/client/task-tag.d.ts +32 -0
- package/lib/types/client/task-tag.js +33 -0
- package/lib/types/client/tasks.d.ts +50 -0
- package/lib/types/client/tasks.js +82 -0
- package/lib/types/client/team.d.ts +73 -0
- package/lib/types/client/team.js +187 -0
- package/lib/types/decisions.d.ts +49 -0
- package/lib/types/decisions.js +101 -0
- package/lib/types/deliveries.d.ts +17 -1
- package/lib/types/deliveries.js +82 -6
- package/lib/types/dispatch.d.ts +102 -4
- package/lib/types/dispatch.js +498 -109
- package/lib/types/help.d.ts +137 -0
- package/lib/types/help.js +216 -0
- package/lib/types/helps.d.ts +26 -0
- package/lib/types/helps.js +47 -0
- package/lib/types/index.d.ts +292 -52
- package/lib/types/index.js +1421 -199
- package/lib/types/jobs.d.ts +22 -0
- package/lib/types/jobs.js +77 -0
- package/lib/types/journal.d.ts +17 -27
- package/lib/types/journal.js +120 -43
- package/lib/types/messaging.d.ts +65 -0
- package/lib/types/messaging.js +160 -0
- package/lib/types/plan-service.js +7 -1
- package/lib/types/report-gate.d.ts +81 -0
- package/lib/types/report-gate.js +89 -0
- package/lib/types/tool-definition.d.ts +43 -20
- package/lib/types/tool-definition.js +145 -212
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/tool.js +3 -2
- package/lib/types/types.d.ts +755 -78
- package/locale/zh.json +1 -1
- package/package.json +3 -3
- package/lib/types/client/RoomActivityDock.d.ts +0 -19
- package/lib/types/client/RoomActivityDock.js +0 -105
- package/lib/types/client/RoomDockCapsules.d.ts +0 -64
- package/lib/types/client/RoomDockCapsules.js +0 -277
- package/lib/types/client/RoomPlanTab.d.ts +0 -15
- package/lib/types/client/RoomPlanTab.js +0 -13
- package/lib/types/client/RoomTaskLineView.d.ts +0 -13
- package/lib/types/client/RoomTaskLineView.js +0 -21
package/lib/types/dispatch.js
CHANGED
|
@@ -8,22 +8,27 @@
|
|
|
8
8
|
* the exact dispatch history — but speech/dispatch events are UI projection
|
|
9
9
|
* and replay ONLY: no prompt ever consumes the room's running log.
|
|
10
10
|
*
|
|
11
|
-
* The prompt every member receives is uniform
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* The prompt every member receives is uniform: the member's FIRST dispatch
|
|
12
|
+
* opens with one room briefing (identity, public role, private instructions,
|
|
13
|
+
* goal, roster of public roles, the tool-based collaboration contract);
|
|
14
|
+
* every later dispatch carries only what changed since the member's previous
|
|
15
|
+
* one — an instructions edit, one-line room changes ({@link roomChangesSince}),
|
|
16
|
+
* a coordinator handoff — plus the member's confirmed-undelivered
|
|
17
|
+
* notifications and this dispatch's text. The member's own session (resume
|
|
18
|
+
* chain) holds its working memory; room never resends what it has seen, and
|
|
19
|
+
* no member ever receives another member's private instructions.
|
|
20
20
|
* @module @khorsheed/dsh-room/dispatch
|
|
21
21
|
*/
|
|
22
22
|
import { replayDeliveries } from './deliveries.js';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
24
25
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
25
26
|
import { probeLocalAgent, runOutputText } from './adapter.js';
|
|
26
|
-
import {
|
|
27
|
+
import { composeRoomAgent } from './agent-setup.js';
|
|
28
|
+
import { coordinatorMember, memberId, pendingInstructions, previousCursor, replay, roomChangesSince } from './journal.js';
|
|
29
|
+
import { envelopeLine, messageId, sendOrHold } from './messaging.js';
|
|
30
|
+
import { gateState, judgeReport, outstandingFor } from './report-gate.js';
|
|
31
|
+
import { decisionRecord } from './decisions.js';
|
|
27
32
|
/**
|
|
28
33
|
* The room producer's durable identity in its two historical forms: the
|
|
29
34
|
* released V3 wrapper's `plugin` value (`@khorsheed/dsh-room` — the official
|
|
@@ -38,6 +43,38 @@ export const ROOM_KIND = 'room';
|
|
|
38
43
|
export function roomSource() {
|
|
39
44
|
return Object.freeze({ kind: ROOM_KIND });
|
|
40
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A run-state edge's child-session identity: the main agent runs on the room
|
|
48
|
+
* itself, external members on their child session. Optional — a fresh CLI
|
|
49
|
+
* member's early running edge predates the child handle's publication (the
|
|
50
|
+
* terminal edge and the journal backfill cover it).
|
|
51
|
+
*/
|
|
52
|
+
export function runChildSessionId(roomId, member) {
|
|
53
|
+
if (member?.kind === 'main-agent')
|
|
54
|
+
return roomId;
|
|
55
|
+
return member?.childSessionId;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The child session round a run reached: the last turn/start the child wrote.
|
|
59
|
+
* Live sessions only — a reconcile path whose child is not in memory omits it.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* The id of the last assistant message a session's log holds after `afterSeq`:
|
|
63
|
+
* the message a member's round closed with, which per-message tools (Context
|
|
64
|
+
* Lens) address in the member's own session.
|
|
65
|
+
* @param session - the member's session.
|
|
66
|
+
* @param afterSeq - only messages after this seq count.
|
|
67
|
+
* @returns the message id, or undefined when the round produced none.
|
|
68
|
+
*/
|
|
69
|
+
export function lastAssistantMessageId(session, afterSeq = -1) {
|
|
70
|
+
const event = session?.snapshotEvents().findLast(entry => entry.type === 'assistant/message' && entry.seq > afterSeq);
|
|
71
|
+
const id = event?.data?.message?.id;
|
|
72
|
+
return typeof id === 'string' ? id : undefined;
|
|
73
|
+
}
|
|
74
|
+
export function runChildTurn(session) {
|
|
75
|
+
const last = session?.snapshotEvents().filter(event => event.type === 'turn/start').at(-1);
|
|
76
|
+
return last?.data?.turn;
|
|
77
|
+
}
|
|
41
78
|
/**
|
|
42
79
|
* Whether a durable source belongs to the room producer, in every form the
|
|
43
80
|
* log can hold: the current producer-owned kind (`room`), the V3→V4 migrated
|
|
@@ -59,27 +96,37 @@ function closedGoal(state, goalId) {
|
|
|
59
96
|
&& (state.plan.id !== goalId || ['completed', 'cancelled'].includes(state.plan.status));
|
|
60
97
|
}
|
|
61
98
|
/**
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* the
|
|
68
|
-
*
|
|
69
|
-
* orients the task).
|
|
99
|
+
* The member's first-dispatch briefing: who it is (name + public role), its
|
|
100
|
+
* private instructions, the goal, everyone else by PUBLIC role only (never
|
|
101
|
+
* their instructions — those read like orders addressed to the reader), and
|
|
102
|
+
* the collaboration contract. Members reach each other through tools —
|
|
103
|
+
* `member_message` on the local-agent bridge for CLI members, `room_message`
|
|
104
|
+
* for the room's own agent — and read the live roster with `room_read`; a
|
|
105
|
+
* trailing `@name` line in a reply is plain text and reaches nobody.
|
|
70
106
|
*/
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
.filter(
|
|
74
|
-
.map((
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
function briefingSection(state, member, instructions, coordinator) {
|
|
108
|
+
const others = state.members
|
|
109
|
+
.filter(entry => entry.name !== member.name)
|
|
110
|
+
.map((entry) => {
|
|
111
|
+
const facts = [
|
|
112
|
+
...coordinator?.name === entry.name ? ['协调者'] : [],
|
|
113
|
+
...entry.role === undefined ? [] : [entry.role],
|
|
114
|
+
entry.kind === 'main-agent' ? '原生 DSH' : entry.kind === 'native' ? `原生 DSH${entry.preset === undefined ? '' : ` · ${entry.preset}`}` : entry.provider ?? '',
|
|
115
|
+
].filter(Boolean);
|
|
116
|
+
return `- ${entry.name}(${facts.join(' · ')})`;
|
|
78
117
|
});
|
|
79
118
|
return [
|
|
80
|
-
'
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
'【房间简报】',
|
|
120
|
+
`你是协作间成员「${member.name}」${member.role === undefined ? '' : `,职责:${member.role}`}${coordinator?.name === member.name ? ',也是本房间的协调者' : ''}。`,
|
|
121
|
+
...instructions === undefined ? [] : [`你的指令:${instructions}`],
|
|
122
|
+
...state.goal === undefined ? [] : [`房间目标:${state.goal}`],
|
|
123
|
+
...others.length === 0 ? [] : ['其他成员:', ...others],
|
|
124
|
+
`联系成员、派活用 ${member.kind === 'cli' ? 'member_message' : 'room_message'},看房间现状和任务编号用 room_read,用法见工具说明。成员发来的消息开头标着来自谁和消息编号(#m12),没有标注的是人发的;派给你的活,你这一轮的最终回复会自动回报给派活人,不用另外回复。`,
|
|
125
|
+
'要找房间外的会话帮忙(人在消息里 @ 的会话 `@[…](dsh-session:…)`,包括另一个协作间或它的成员),直接用 room_help,不要转给别的成员代问。',
|
|
126
|
+
// A CLI reaches the room's tools (and its mode's) through an MCP server,
|
|
127
|
+
// which some CLIs keep behind tool discovery rather than in the visible list.
|
|
128
|
+
...member.kind === 'cli' ? [`这些房间工具由名为 dsh-member 的 MCP 服务提供${member.preset === undefined ? '' : `,你挂的「${member.preset}」模式的插件工具也在这个服务里`};没在工具列表里直接看到时,用工具发现查找 dsh-member 的工具。`, '需要人拍板或补充信息时,用 dsh-member 的 ask_user_question 把问题和选项发给人点选,不要把一串问题写在回复里;它会等人回答后把答案返回给你。'] : [],
|
|
129
|
+
...coordinator?.name === member.name ? ['作为协调者:普通对话直接回答,小任务派给成员,只在确有需要时建立正式目标。派完活就结束这一轮,回报会自己送到——不要 sleep 等待,也不要用 room_read 查结果。收到回报时用户已经看过成员的原话:需要推进就推进,否则一两句话即可,不要复述或做汇总表。'] : [],
|
|
83
130
|
].join('\n');
|
|
84
131
|
}
|
|
85
132
|
/** The notifications section: confirmed-undelivered relays addressed to the member. */
|
|
@@ -87,33 +134,90 @@ function notificationsSection(relays) {
|
|
|
87
134
|
return ['【通知】', ...relays.map(relay => `- ${relay.from} 给你的通知: ${relay.content}`)].join('\n');
|
|
88
135
|
}
|
|
89
136
|
/**
|
|
90
|
-
* Assemble the uniform member prompt:
|
|
91
|
-
*
|
|
92
|
-
* then the member's confirmed-undelivered
|
|
93
|
-
* this dispatch's own text already delivers),
|
|
137
|
+
* Assemble the uniform member prompt: the briefing on the member's first
|
|
138
|
+
* dispatch, otherwise only what changed since its previous one (handoff,
|
|
139
|
+
* instructions edit, room changes); then the member's confirmed-undelivered
|
|
140
|
+
* notifications (except the ones this dispatch's own text already delivers),
|
|
141
|
+
* then this dispatch's text.
|
|
94
142
|
*/
|
|
95
|
-
function assemblePrompt(room, member, cursor, text, relayIds) {
|
|
143
|
+
function assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq) {
|
|
96
144
|
const state = replay(room.snapshotEvents());
|
|
97
145
|
const carried = state.relays.filter(relay => relay.state === 'confirmed' && relay.to === member.name && !relayIds.includes(relay.id));
|
|
98
146
|
const sections = [];
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
const events = room.snapshotEvents();
|
|
148
|
+
const coordinator = coordinatorMember(state, events);
|
|
149
|
+
const handoff = events.filter(event => event.type === 'room/coordinator' && event.data.memberId === memberId(events, member)).at(-1);
|
|
150
|
+
const pending = pendingInstructions(events, member.name, cursor);
|
|
151
|
+
if (cursor === undefined) {
|
|
152
|
+
sections.push(briefingSection(state, member, pending?.instructions, coordinator));
|
|
153
|
+
if (handoff?.type === 'room/coordinator')
|
|
154
|
+
sections.push(handoff.data.handoff);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (handoff?.type === 'room/coordinator' && handoff.seq > cursor)
|
|
158
|
+
sections.push(`你现在是本房间的协调者。\n${handoff.data.handoff}`);
|
|
159
|
+
if (pending?.kind === 'update')
|
|
160
|
+
sections.push(`你的指令更新为:${pending.instructions}`);
|
|
161
|
+
const changes = roomChangesSince(events, cursor, member.name);
|
|
162
|
+
if (changes.length > 0)
|
|
163
|
+
sections.push(['【房间变动】', ...changes.map(change => `- ${change}`)].join('\n'));
|
|
164
|
+
}
|
|
113
165
|
if (carried.length > 0)
|
|
114
166
|
sections.push(notificationsSection(carried));
|
|
115
|
-
|
|
116
|
-
|
|
167
|
+
// Reports the gate held for this member and no inbox took: they ride this round.
|
|
168
|
+
const held = (state.deliveries ?? []).filter(row => row.memberId === memberId(events, member) && row.status === 'held' && row.dispatchSeq !== dispatchSeq);
|
|
169
|
+
for (const row of held)
|
|
170
|
+
sections.push(`${envelopeLine(events, row.dispatchSeq) ?? ''}\n${row.text}`.trim());
|
|
171
|
+
const envelope = dispatchSeq === undefined ? undefined : envelopeLine(events, dispatchSeq);
|
|
172
|
+
sections.push(envelope === undefined ? text : `${envelope}\n${text}`);
|
|
173
|
+
return { prompt: sections.join('\n\n'), carried, heldCarried: held.map(row => row.dispatchSeq) };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Reports held for a member that it has not yet seen in a round: hold
|
|
177
|
+
* verdicts without a later release.
|
|
178
|
+
*/
|
|
179
|
+
export function pendingHolds(events, recipientId) {
|
|
180
|
+
const open = new Map();
|
|
181
|
+
for (const event of events) {
|
|
182
|
+
if (event.type !== 'room/report-gate' || event.data.memberId !== recipientId)
|
|
183
|
+
continue;
|
|
184
|
+
if (event.data.verdict === 'hold')
|
|
185
|
+
open.set(event.data.dispatchSeq, event.seq);
|
|
186
|
+
else if (event.data.verdict === 'release')
|
|
187
|
+
open.delete(event.data.dispatchSeq);
|
|
188
|
+
}
|
|
189
|
+
return [...open.keys()];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The held reports of a member as one prompt section (their envelopes and
|
|
193
|
+
* texts), or undefined when nothing is held — what a turn the room did not
|
|
194
|
+
* start (a human waking the agent directly) must still carry.
|
|
195
|
+
*/
|
|
196
|
+
export function heldReportsText(events, recipientId) {
|
|
197
|
+
const held = (replay(events).deliveries ?? []).filter(row => row.memberId === recipientId && row.status === 'held');
|
|
198
|
+
if (held.length === 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
return {
|
|
201
|
+
text: held.map(row => `${envelopeLine(events, row.dispatchSeq) ?? ''}\n${row.text}`.trim()).join('\n\n'),
|
|
202
|
+
seqs: held.map(row => row.dispatchSeq),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** The text of the last assistant message after `afterSeq` ('' when none). */
|
|
206
|
+
function lastAssistantText(events, afterSeq) {
|
|
207
|
+
const reply = events.filter(event => event.seq > afterSeq && event.type === 'assistant/message').at(-1);
|
|
208
|
+
return reply?.type === 'assistant/message'
|
|
209
|
+
? reply.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
|
|
210
|
+
: '';
|
|
211
|
+
}
|
|
212
|
+
/** Who a delivery of this dispatch reports to (the coordinator for plan work), if anyone. */
|
|
213
|
+
function reportRecipient(events, dispatchSeq) {
|
|
214
|
+
const source = events.find(event => event.seq === dispatchSeq);
|
|
215
|
+
if (source?.type !== 'room/dispatch' || source.data.replyTo === undefined)
|
|
216
|
+
return undefined;
|
|
217
|
+
const state = replay(events);
|
|
218
|
+
return source.data.plan === undefined
|
|
219
|
+
? state.members.find(member => memberId(events, member) === source.data.replyTo)
|
|
220
|
+
: coordinatorMember(state, events);
|
|
117
221
|
}
|
|
118
222
|
/** The human-readable reason a run failed (Error message, else String()). */
|
|
119
223
|
function faultMessage(error) {
|
|
@@ -131,6 +235,18 @@ export class DispatchEngine {
|
|
|
131
235
|
queues = new Map();
|
|
132
236
|
scheduled = new Set();
|
|
133
237
|
inFlight = new Map();
|
|
238
|
+
/** room + recipient → the wake-anyway timer for its held reports. */
|
|
239
|
+
holdTimers = new Map();
|
|
240
|
+
/**
|
|
241
|
+
* room id → member ids whose FIRST start is in flight: the member has no
|
|
242
|
+
* child session id in the journal yet, but its process may already be
|
|
243
|
+
* asking the host for its mode tools (claude lists MCP tools at spawn).
|
|
244
|
+
*/
|
|
245
|
+
freshStarts = new Map();
|
|
246
|
+
/** Members of this room whose first start is in flight (see {@link freshStarts}). */
|
|
247
|
+
startingMembers(roomId) {
|
|
248
|
+
return [...this.freshStarts.get(roomId) ?? []];
|
|
249
|
+
}
|
|
134
250
|
/**
|
|
135
251
|
* @param ctx - host context carrying the session store and agents registry.
|
|
136
252
|
*/
|
|
@@ -165,7 +281,12 @@ export class DispatchEngine {
|
|
|
165
281
|
return;
|
|
166
282
|
this.scheduled.add(scopedId);
|
|
167
283
|
}
|
|
168
|
-
|
|
284
|
+
// A report to the room's own agent never waits behind that agent's
|
|
285
|
+
// running round: it joins the round (runMainAgent steers it in) — queued,
|
|
286
|
+
// it would wake a second turn once the first had already answered.
|
|
287
|
+
const source = options.dispatchSeq === undefined ? undefined : events.find(event => event.seq === options.dispatchSeq);
|
|
288
|
+
const joins = member.kind === 'main-agent' && source?.type === 'room/dispatch' && source.data.origin === 'report';
|
|
289
|
+
const tail = joins ? Promise.resolve() : this.queues.get(key) ?? Promise.resolve();
|
|
169
290
|
let submitted;
|
|
170
291
|
const submission = new Promise(resolve => { submitted = resolve; });
|
|
171
292
|
const next = tail.then(() => this.run(room, memberName, text, { ...options, targetId }, submitted))
|
|
@@ -177,6 +298,8 @@ export class DispatchEngine {
|
|
|
177
298
|
this.scheduled.delete(`${room.id}:${deliveryId}`);
|
|
178
299
|
});
|
|
179
300
|
this.inFlight.set(next, key);
|
|
301
|
+
if (joins)
|
|
302
|
+
return;
|
|
180
303
|
this.queues.set(key, submission);
|
|
181
304
|
void submission.then(() => { if (this.queues.get(key) === submission)
|
|
182
305
|
this.queues.delete(key); });
|
|
@@ -214,7 +337,7 @@ export class DispatchEngine {
|
|
|
214
337
|
const events = room.snapshotEvents();
|
|
215
338
|
const state = replay(events);
|
|
216
339
|
for (const dispatch of events) {
|
|
217
|
-
if (dispatch.type !== 'room/dispatch' || dispatch.data.targetIds === undefined)
|
|
340
|
+
if (dispatch.type !== 'room/dispatch' || dispatch.data.targetIds === undefined || dispatch.data.held === true)
|
|
218
341
|
continue;
|
|
219
342
|
for (const targetId of dispatch.data.targetIds) {
|
|
220
343
|
const id = `${dispatch.seq}:${targetId}`;
|
|
@@ -233,14 +356,19 @@ export class DispatchEngine {
|
|
|
233
356
|
const target = state.members.find(member => memberId(events, member) === targetId);
|
|
234
357
|
if (target !== undefined) {
|
|
235
358
|
const run = state.runs.find(run => run.member === target.name);
|
|
236
|
-
if (run?.state === 'running' && (run.runId === undefined || run.runId === id))
|
|
237
|
-
|
|
359
|
+
if (run?.state === 'running' && (run.runId === undefined || run.runId === id)) {
|
|
360
|
+
const childSessionId = runChildSessionId(room.id, target);
|
|
361
|
+
room.append('room/run-state', { member: target.name, state: 'failed', startedAt: run.startedAt,
|
|
362
|
+
...run.runId === undefined ? {} : { runId: run.runId },
|
|
363
|
+
...childSessionId === undefined ? {} : { childSessionId },
|
|
364
|
+
error: 'Execution outcome is unknown after restart; reconcile before retrying' });
|
|
365
|
+
}
|
|
238
366
|
}
|
|
239
367
|
await this.ctx.sessions.flush(room);
|
|
240
|
-
await this.
|
|
368
|
+
await this.reportOutcome(room, dispatch.seq, id, targetId, 'uncertain', 'Execution outcome is unknown after restart; reconcile before retrying');
|
|
241
369
|
}
|
|
242
370
|
else {
|
|
243
|
-
await this.
|
|
371
|
+
await this.reportOutcome(room, dispatch.seq, id, targetId, last.data.state, last.data.text ?? last.data.error ?? '');
|
|
244
372
|
}
|
|
245
373
|
continue;
|
|
246
374
|
}
|
|
@@ -249,6 +377,13 @@ export class DispatchEngine {
|
|
|
249
377
|
this.dispatch(room, target.name, dispatch.data.text, { dispatchSeq: dispatch.seq, targetId });
|
|
250
378
|
}
|
|
251
379
|
}
|
|
380
|
+
// recover() runs on every live access; only holds whose wake timer this
|
|
381
|
+
// process does not own (a restart lost it) are handed over now.
|
|
382
|
+
for (const member of state.members) {
|
|
383
|
+
const id = memberId(events, member);
|
|
384
|
+
if (!this.holdTimers.has(this.holdKey(room.id, id)) && pendingHolds(events, id).length > 0)
|
|
385
|
+
await this.releaseHeld(room, id, 'restart');
|
|
386
|
+
}
|
|
252
387
|
}
|
|
253
388
|
/** Execute one dispatch: run-state running → member turn → settle edges. */
|
|
254
389
|
async run(room, memberName, text, options, submitted) {
|
|
@@ -265,6 +400,10 @@ export class DispatchEngine {
|
|
|
265
400
|
memberName = member.name;
|
|
266
401
|
const identity = memberId(room.snapshotEvents(), member);
|
|
267
402
|
const deliveryId = options.dispatchSeq === undefined ? undefined : `${options.dispatchSeq}:${identity}`;
|
|
403
|
+
// Settled while it waited in the queue (a removal cancels queued work;
|
|
404
|
+
// an undo must not resurrect it).
|
|
405
|
+
if (deliveryId !== undefined && state.deliveries?.some(delivery => delivery.id === deliveryId && delivery.status !== 'queued'))
|
|
406
|
+
return;
|
|
268
407
|
const runId = deliveryId ?? randomUUID();
|
|
269
408
|
const cursor = previousCursor(room.snapshotEvents(), memberName, options.dispatchSeq ?? Number.MAX_SAFE_INTEGER);
|
|
270
409
|
let startedAt;
|
|
@@ -274,9 +413,13 @@ export class DispatchEngine {
|
|
|
274
413
|
startedAt = Date.now();
|
|
275
414
|
if (deliveryId !== undefined)
|
|
276
415
|
room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: 'running' });
|
|
277
|
-
|
|
416
|
+
// Re-read the roster: the captured member snapshot predates the child
|
|
417
|
+
// handle's publication (member-updated lands between admission and settle).
|
|
418
|
+
const current = replay(room.snapshotEvents()).members.find(entry => memberId(room.snapshotEvents(), entry) === identity);
|
|
419
|
+
memberName = current?.name ?? memberName;
|
|
420
|
+
const childSessionId = runChildSessionId(room.id, current ?? member);
|
|
278
421
|
room.append('room/run-state', { member: memberName, state: 'running', startedAt, runId,
|
|
279
|
-
...
|
|
422
|
+
...childSessionId === undefined ? {} : { childSessionId },
|
|
280
423
|
});
|
|
281
424
|
await this.hooks.admitted?.(room, options.dispatchSeq);
|
|
282
425
|
await this.ctx.sessions.flush(room);
|
|
@@ -285,8 +428,10 @@ export class DispatchEngine {
|
|
|
285
428
|
let outcome;
|
|
286
429
|
try {
|
|
287
430
|
outcome = member.kind === 'main-agent'
|
|
288
|
-
? await this.runMainAgent(room, member, cursor, text, await admitted(), options.relayIds ?? [])
|
|
289
|
-
:
|
|
431
|
+
? await this.runMainAgent(room, member, cursor, text, await admitted(), options.relayIds ?? [], options.dispatchSeq)
|
|
432
|
+
: member.kind === 'native'
|
|
433
|
+
? await this.runNativeMember(room, member, cursor, text, admitted, options.relayIds ?? [], options.dispatchSeq, runId)
|
|
434
|
+
: await this.runCliMember(room, member, cursor, text, admitted, options.relayIds ?? [], submitted, runId, options.dispatchSeq);
|
|
290
435
|
}
|
|
291
436
|
catch (error) {
|
|
292
437
|
this.ctx.logger.warn(`room: dispatch to "${memberName}" failed: ${String(error)}`);
|
|
@@ -298,30 +443,102 @@ export class DispatchEngine {
|
|
|
298
443
|
room.append('room/delivery-state', { id: deliveryId, dispatchSeq: options.dispatchSeq, memberId: identity, state: outcome.state,
|
|
299
444
|
...outcome.text === undefined ? {} : { text: outcome.text }, ...outcome.error === undefined ? {} : { error: outcome.error } });
|
|
300
445
|
await this.ctx.sessions.flush(room);
|
|
301
|
-
await this.
|
|
446
|
+
await this.reportOutcome(room, options.dispatchSeq, deliveryId, identity, outcome.state, outcome.text ?? outcome.error ?? '');
|
|
302
447
|
}
|
|
303
448
|
}
|
|
304
|
-
|
|
449
|
+
/**
|
|
450
|
+
* Report one delivery's outcome to whoever is waiting on it (the
|
|
451
|
+
* coordinator for plan work, else the dispatch's `replyTo`), at most once
|
|
452
|
+
* per delivery. Public so a removal can settle the waits it breaks.
|
|
453
|
+
*/
|
|
454
|
+
/** Report-gate decisions per recipient, one at a time (see reportOutcome). */
|
|
455
|
+
gateQueues = new Map();
|
|
456
|
+
/**
|
|
457
|
+
* Gate one arrived report for the member waiting on it. Decisions for the
|
|
458
|
+
* same recipient run one at a time: a decision-model judgement takes a
|
|
459
|
+
* second or two, and a later report deciding meanwhile (the 「最后一个结果」
|
|
460
|
+
* rule) would hand over its batch without the one still being judged —
|
|
461
|
+
* which then got held behind a round that had already answered.
|
|
462
|
+
*/
|
|
463
|
+
async reportOutcome(room, dispatchSeq, deliveryId, fromId, outcome, text) {
|
|
464
|
+
const source = room.snapshotEvents().find(event => event.seq === dispatchSeq);
|
|
465
|
+
const key = `${room.id} ${source?.type === 'room/dispatch' ? source.data.replyTo ?? '' : ''}`;
|
|
466
|
+
const next = (this.gateQueues.get(key) ?? Promise.resolve())
|
|
467
|
+
.catch(() => { })
|
|
468
|
+
.then(() => this.gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text));
|
|
469
|
+
this.gateQueues.set(key, next);
|
|
470
|
+
void next.then(() => { if (this.gateQueues.get(key) === next)
|
|
471
|
+
this.gateQueues.delete(key); }, () => { if (this.gateQueues.get(key) === next)
|
|
472
|
+
this.gateQueues.delete(key); });
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
async gateReport(room, dispatchSeq, deliveryId, fromId, outcome, text) {
|
|
305
476
|
const events = room.snapshotEvents();
|
|
306
477
|
const source = events.find(event => event.seq === dispatchSeq);
|
|
307
|
-
if (source?.type !== 'room/dispatch' || source.data.origin !== 'coordinator' || source.data.replyTo === undefined)
|
|
478
|
+
if (source?.type !== 'room/dispatch' || (source.data.origin !== 'coordinator' && source.data.origin !== 'member') || source.data.replyTo === undefined)
|
|
308
479
|
return;
|
|
309
480
|
if (events.some(event => event.type === 'room/dispatch' && event.data.reportFor === deliveryId))
|
|
310
481
|
return;
|
|
482
|
+
if (events.some(event => event.type === 'room/relay' && event.data.intent?.reportFor === deliveryId))
|
|
483
|
+
return;
|
|
311
484
|
const state = replay(events);
|
|
312
485
|
if (closedGoal(state, source.data.plan?.goalId))
|
|
313
486
|
return;
|
|
314
487
|
const recipient = source.data.plan === undefined ? state.members.find(member => memberId(events, member) === source.data.replyTo) : coordinatorMember(state, events);
|
|
315
|
-
const sender = state.members.find(member => memberId(events, member) === fromId);
|
|
316
488
|
if (recipient === undefined)
|
|
317
489
|
return;
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
490
|
+
// A removed sender is no longer on the roster: read its last known name.
|
|
491
|
+
const senderName = state.members.find(member => memberId(events, member) === fromId)?.name
|
|
492
|
+
?? events.reduce((name, event) => event.type === 'room/member-added' && event.data.id === fromId ? event.data.name : name, undefined);
|
|
493
|
+
// The envelope (`【#m12 的回报 · 来自 ada · 完成】`) carries who and how;
|
|
494
|
+
// the text is the member's own conclusion.
|
|
495
|
+
const reportText = text.trim() === '' ? `(${outcome === 'done' ? '没有文字输出' : outcome})` : text.slice(0, 32000);
|
|
496
|
+
const sourceTask = (state.deliveries ?? []).find(row => row.id === deliveryId)?.task;
|
|
497
|
+
const intent = { origin: 'report', hop: (source.data.hop ?? 1) + 1, reportFor: deliveryId,
|
|
498
|
+
...sourceTask === undefined ? {} : { task: sourceTask },
|
|
499
|
+
...source.data.plan === undefined ? {} : { plan: { goalId: source.data.plan.goalId } } };
|
|
500
|
+
// The gate decides WHEN the recipient gets it; plan work keeps its own cadence.
|
|
501
|
+
const recipientId = memberId(events, recipient);
|
|
502
|
+
const deliveries = state.deliveries ?? [];
|
|
503
|
+
const verdict = source.data.plan !== undefined
|
|
504
|
+
? { hold: false, reason: 'plan' }
|
|
505
|
+
: await judgeReport(this.ctx, {
|
|
506
|
+
timing: state.settings?.reportTiming ?? 'judge',
|
|
507
|
+
threshold: state.settings?.reportThreshold ?? 0.5,
|
|
508
|
+
outcome,
|
|
509
|
+
outstanding: outstandingFor(deliveries, recipientId, deliveryId).length,
|
|
510
|
+
state: () => gateState(room.snapshotEvents(), recipient, [
|
|
511
|
+
...deliveries.filter(row => row.memberId === recipientId && row.origin === 'report' && pendingHolds(events, recipientId).includes(row.dispatchSeq))
|
|
512
|
+
.map(row => ({ from: '', outcome: '', text: row.text })),
|
|
513
|
+
{ from: senderName ?? fromId, outcome, text: reportText },
|
|
514
|
+
], Date.now()),
|
|
515
|
+
});
|
|
516
|
+
// The judgement awaited: a concurrent path may have reported meanwhile.
|
|
517
|
+
if (room.snapshotEvents().some(event => event.type === 'room/dispatch' && event.data.reportFor === deliveryId))
|
|
518
|
+
return;
|
|
519
|
+
// Every timing decision the room weighed is recorded (the timeline row,
|
|
520
|
+
// Context Lens); the each-setting and plan cadence decide nothing.
|
|
521
|
+
if (verdict.reason !== 'setting' && verdict.reason !== 'plan') {
|
|
522
|
+
room.append('room/decision', decisionRecord('report-timing', {
|
|
523
|
+
outcome: verdict.hold ? 'later' : 'now',
|
|
524
|
+
...verdict.reason === 'model' ? {} : { rule: verdict.reason },
|
|
525
|
+
...verdict.probability === undefined ? {} : { probability: verdict.probability },
|
|
526
|
+
...verdict.call === undefined ? {} : { call: verdict.call },
|
|
527
|
+
about: { from: senderName ?? fromId, to: recipient.name },
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
if (verdict.hold) {
|
|
531
|
+
await this.holdReport(room, recipient, reportText, intent, verdict);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const sent = sendOrHold(room, { to: recipient, text: reportText, fromName: senderName ?? fromId, intent });
|
|
535
|
+
if (sent.delivered) {
|
|
536
|
+
room.append('room/report-gate', { dispatchSeq: sent.seq, memberId: recipientId, member: recipient.name, verdict: 'deliver', reason: verdict.reason,
|
|
537
|
+
...verdict.probability === undefined ? {} : { probability: verdict.probability } });
|
|
538
|
+
}
|
|
322
539
|
await this.ctx.sessions.flush(room);
|
|
323
|
-
if (this.hooks.allows?.(room,
|
|
324
|
-
this.dispatch(room, recipient.name, reportText, { dispatchSeq:
|
|
540
|
+
if (sent.delivered && this.hooks.allows?.(room, sent.seq) !== false)
|
|
541
|
+
this.dispatch(room, recipient.name, reportText, { dispatchSeq: sent.seq, targetId: memberId(events, recipient) });
|
|
325
542
|
}
|
|
326
543
|
/**
|
|
327
544
|
* Mark the relays this run delivered as sent. Delivery = the member's own
|
|
@@ -329,6 +546,85 @@ export class DispatchEngine {
|
|
|
329
546
|
* started): the text lives in the member's private context from then on,
|
|
330
547
|
* so a later run failure cannot un-deliver it.
|
|
331
548
|
*/
|
|
549
|
+
/**
|
|
550
|
+
* A round of this member started: every report held for it is now in its
|
|
551
|
+
* context — carried by this prompt (`seqs`) or already in its inbox — so
|
|
552
|
+
* close those holds and drop the member's wake timer.
|
|
553
|
+
*/
|
|
554
|
+
async markHeldReleased(room, member, seqs) {
|
|
555
|
+
const events = room.snapshotEvents();
|
|
556
|
+
const id = memberId(events, member);
|
|
557
|
+
for (const seq of seqs)
|
|
558
|
+
room.append('room/delivery-state', { id: `${seq}:${id}`, dispatchSeq: seq, memberId: id, state: 'done', text: 'carried' });
|
|
559
|
+
const open = pendingHolds(events, id);
|
|
560
|
+
for (const seq of new Set([...seqs, ...open]))
|
|
561
|
+
room.append('room/report-gate', { dispatchSeq: seq, memberId: id, member: member.name, verdict: 'release', reason: 'carried' });
|
|
562
|
+
this.clearHoldTimer(room.id, id);
|
|
563
|
+
if (seqs.length > 0 || open.length > 0)
|
|
564
|
+
await this.ctx.sessions.flush(room);
|
|
565
|
+
}
|
|
566
|
+
holdKey(roomId, recipientId) { return `${roomId} ${recipientId}`; }
|
|
567
|
+
clearHoldTimer(roomId, recipientId) {
|
|
568
|
+
const key = this.holdKey(roomId, recipientId);
|
|
569
|
+
const timer = this.holdTimers.get(key);
|
|
570
|
+
if (timer !== undefined)
|
|
571
|
+
clearTimeout(timer);
|
|
572
|
+
this.holdTimers.delete(key);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Keep a report for later: journal it as a held dispatch and arm the
|
|
576
|
+
* wake-anyway timer. It is NOT pushed into an agent inbox: the host runs
|
|
577
|
+
* one turn per queued inbox message, so held reports would each wake their
|
|
578
|
+
* own turn. They ride the recipient's next round instead — the room's own
|
|
579
|
+
* prompt, or (a human waking a native agent directly) the turn's first step
|
|
580
|
+
* through {@link heldReportsMessage}.
|
|
581
|
+
*/
|
|
582
|
+
async holdReport(room, recipient, text, intent, verdict) {
|
|
583
|
+
const events = room.snapshotEvents();
|
|
584
|
+
const recipientId = memberId(events, recipient);
|
|
585
|
+
const dispatch = room.append('room/dispatch', {
|
|
586
|
+
id: randomUUID(), origin: 'report', targets: [recipient.name], targetIds: [recipientId], text, held: true,
|
|
587
|
+
hop: intent.hop, ...intent.task === undefined ? {} : { task: intent.task }, ...intent.reportFor === undefined ? {} : { reportFor: intent.reportFor },
|
|
588
|
+
...intent.plan === undefined ? {} : { plan: intent.plan },
|
|
589
|
+
});
|
|
590
|
+
room.append('room/report-gate', { dispatchSeq: dispatch.seq, memberId: recipientId, member: recipient.name, verdict: 'hold', reason: verdict.reason,
|
|
591
|
+
...verdict.probability === undefined ? {} : { probability: verdict.probability } });
|
|
592
|
+
// Armed before the flush yields: a concurrent recover() must see this
|
|
593
|
+
// process owns the hold.
|
|
594
|
+
const key = this.holdKey(room.id, recipientId);
|
|
595
|
+
if (!this.holdTimers.has(key)) {
|
|
596
|
+
const timer = setTimeout(() => {
|
|
597
|
+
this.holdTimers.delete(key);
|
|
598
|
+
void this.releaseHeld(room, recipientId, 'timeout').catch(error => this.ctx.logger.warn(`room: held report release failed: ${String(error)}`));
|
|
599
|
+
}, (replay(events).settings?.reportWaitMinutes ?? 3) * 60_000);
|
|
600
|
+
timer.unref?.();
|
|
601
|
+
this.holdTimers.set(key, timer);
|
|
602
|
+
}
|
|
603
|
+
await this.ctx.sessions.flush(room);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Wake a member for the reports held for it (the hold timeout, a human's
|
|
607
|
+
* 「现在交给」, or a restart). One short release round: an inbox already
|
|
608
|
+
* holds the reports; a CLI member gets them carried by the round's prompt.
|
|
609
|
+
* A no-op when nothing is held any more.
|
|
610
|
+
* @returns whether a release round was dispatched.
|
|
611
|
+
*/
|
|
612
|
+
async releaseHeld(room, recipientId, reason) {
|
|
613
|
+
this.clearHoldTimer(room.id, recipientId);
|
|
614
|
+
const events = room.snapshotEvents();
|
|
615
|
+
const open = pendingHolds(events, recipientId);
|
|
616
|
+
const recipient = replay(events).members.find(member => memberId(events, member) === recipientId);
|
|
617
|
+
if (open.length === 0 || recipient === undefined)
|
|
618
|
+
return false;
|
|
619
|
+
const ids = open.map(seq => `#${messageId(seq)}`).join('、');
|
|
620
|
+
const text = `(以上 ${open.length} 条回报一直等着交给你:${ids}${reason === 'timeout' ? ',已经等了一段时间' : ''}。用户也已在房间里看到,不要复述。)`;
|
|
621
|
+
const releaseTask = replay(events).deliveries?.find(row => row.dispatchSeq === open[0] && row.memberId === recipientId)?.task;
|
|
622
|
+
const release = room.append('room/dispatch', { id: randomUUID(), origin: 'report', targets: [recipient.name], targetIds: [recipientId], text, releases: open, hop: 1,
|
|
623
|
+
...releaseTask === undefined ? {} : { task: releaseTask } });
|
|
624
|
+
await this.ctx.sessions.flush(room);
|
|
625
|
+
this.dispatch(room, recipient.name, text, { dispatchSeq: release.seq, targetId: recipientId });
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
332
628
|
async markSent(room, relays) {
|
|
333
629
|
if (relays.length === 0)
|
|
334
630
|
return;
|
|
@@ -337,29 +633,104 @@ export class DispatchEngine {
|
|
|
337
633
|
await this.ctx.sessions.flush(room);
|
|
338
634
|
}
|
|
339
635
|
/** Main-agent member turn: a producer-sourced followup on the room's own agent. */
|
|
340
|
-
async runMainAgent(room, member, cursor, text, _startedAt, relayIds) {
|
|
636
|
+
async runMainAgent(room, member, cursor, text, _startedAt, relayIds, dispatchSeq) {
|
|
341
637
|
const agent = this.ctx.agents.get(room.id);
|
|
342
638
|
if (agent === undefined) {
|
|
343
639
|
// Fail loud: the room's own agent is not live, the message goes nowhere.
|
|
344
640
|
return { state: 'failed', error: 'the room session has no live agent' };
|
|
345
641
|
}
|
|
346
|
-
const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
642
|
+
const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
|
|
643
|
+
const before = room.snapshotEvents().at(-1)?.seq ?? -1;
|
|
644
|
+
const message = createUserMessage({ content: [{ type: 'text', text: prompt }], source: roomSource() });
|
|
645
|
+
// A report reaching an agent mid-turn joins that turn at its next step:
|
|
646
|
+
// as its own turn it would make the agent answer a second time, after
|
|
647
|
+
// the turn that was already waiting for it.
|
|
648
|
+
const source = dispatchSeq === undefined ? undefined : room.snapshotEvents().find(event => event.seq === dispatchSeq);
|
|
649
|
+
const report = source?.type === 'room/dispatch' && source.data.origin === 'report';
|
|
650
|
+
const steerable = agent;
|
|
651
|
+
if (report && steerable.status === 'running' && typeof steerable.steer === 'function')
|
|
652
|
+
steerable.steer(message);
|
|
653
|
+
else
|
|
654
|
+
agent.followup(message);
|
|
655
|
+
await this.markHeldReleased(room, member, heldCarried);
|
|
351
656
|
await this.markSent(room, [
|
|
352
657
|
...carried,
|
|
353
658
|
...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
|
|
354
659
|
]);
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
660
|
+
// The followup's own turn converging to idle IS the run; the main agent's
|
|
661
|
+
// reply is its ordinary assistant speech (no room/speech projection —
|
|
662
|
+
// speech mirrors CLI members only). Its last assistant text is the
|
|
663
|
+
// outcome a waiting sender's report quotes.
|
|
358
664
|
await agent.whenIdle();
|
|
359
|
-
|
|
665
|
+
const output = lastAssistantText(room.snapshotEvents(), before);
|
|
666
|
+
return { state: 'done', ...output === '' ? {} : { text: output } };
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The native member's agent: live, resumed from its session after a
|
|
670
|
+
* restart, or created on its first round — a child session + agent in this
|
|
671
|
+
* host process (owned by the room's agent when live), composing the
|
|
672
|
+
* member's preset at creation. The handle is journaled before any prompt.
|
|
673
|
+
*/
|
|
674
|
+
async nativeAgent(room, member) {
|
|
675
|
+
if (member.childSessionId !== undefined) {
|
|
676
|
+
const live = this.ctx.agents.get(member.childSessionId);
|
|
677
|
+
if (live !== undefined)
|
|
678
|
+
return live;
|
|
679
|
+
const composition = await composeRoomAgent(this.ctx, member.preset);
|
|
680
|
+
const handle = await this.ctx.agents.resume({
|
|
681
|
+
resumeSessionId: member.childSessionId,
|
|
682
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
683
|
+
});
|
|
684
|
+
return handle.agent;
|
|
685
|
+
}
|
|
686
|
+
const composition = await composeRoomAgent(this.ctx, member.preset);
|
|
687
|
+
const parent = this.ctx.agents.get(room.id);
|
|
688
|
+
const sessionId = SessionId(randomUUID());
|
|
689
|
+
const cwd = member.cwd ?? room.header.cwd;
|
|
690
|
+
const handle = await this.ctx.agents.create({
|
|
691
|
+
sessionId,
|
|
692
|
+
...parent === undefined ? {} : { parentAgent: parent },
|
|
693
|
+
meta: {
|
|
694
|
+
parentSession: room.id, origin: 'subagent', delegationDepth: (room.header.delegationDepth ?? 0) + 1,
|
|
695
|
+
...cwd === undefined ? {} : { cwd },
|
|
696
|
+
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
|
|
697
|
+
},
|
|
698
|
+
...composition.setup === undefined ? {} : { setup: composition.setup },
|
|
699
|
+
});
|
|
700
|
+
room.append('room/member-updated', { name: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name, childSessionId: sessionId });
|
|
701
|
+
await this.ctx.sessions.flush(room);
|
|
702
|
+
return handle.agent;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Native member turn: a room-sourced followup on the member's own agent,
|
|
706
|
+
* mirrored into the room as speech like a CLI member's reply.
|
|
707
|
+
*/
|
|
708
|
+
async runNativeMember(room, member, cursor, text, admitted, relayIds, dispatchSeq, runId) {
|
|
709
|
+
const agent = await this.nativeAgent(room, member);
|
|
710
|
+
const startedAt = await admitted();
|
|
711
|
+
const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
|
|
712
|
+
const before = agent.session.snapshotEvents().at(-1)?.seq ?? -1;
|
|
713
|
+
agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: roomSource() }));
|
|
714
|
+
await this.markHeldReleased(room, member, heldCarried);
|
|
715
|
+
await this.markSent(room, [
|
|
716
|
+
...carried,
|
|
717
|
+
...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
|
|
718
|
+
]);
|
|
719
|
+
await agent.whenIdle();
|
|
720
|
+
const output = lastAssistantText(agent.session.snapshotEvents(), before);
|
|
721
|
+
const reportedTo = dispatchSeq === undefined ? undefined : reportRecipient(room.snapshotEvents(), dispatchSeq)?.name;
|
|
722
|
+
room.append('room/speech', {
|
|
723
|
+
member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
|
|
724
|
+
text: output, childSessionId: agent.session.id, durationMs: Date.now() - startedAt,
|
|
725
|
+
...runId === undefined ? {} : { runId },
|
|
726
|
+
...lastAssistantMessageId(agent.session, before) === undefined ? {} : { messageId: lastAssistantMessageId(agent.session, before) },
|
|
727
|
+
...reportedTo === undefined ? {} : { reportedTo },
|
|
728
|
+
});
|
|
729
|
+
await this.ctx.sessions.flush(room);
|
|
730
|
+
return { state: 'done', ...output === '' ? {} : { text: output } };
|
|
360
731
|
}
|
|
361
732
|
/** CLI member turn: facade start (first round) or resume, then the speech mirror. */
|
|
362
|
-
async runCliMember(room, member, cursor, text, admitted, relayIds, submitted, runId) {
|
|
733
|
+
async runCliMember(room, member, cursor, text, admitted, relayIds, submitted, runId, dispatchSeq) {
|
|
363
734
|
const facade = probeLocalAgent(this.ctx);
|
|
364
735
|
if (facade === undefined) {
|
|
365
736
|
this.ctx.logger.warn(`room: cannot dispatch to "${member.name}": local-agent facade unavailable`);
|
|
@@ -379,32 +750,59 @@ export class DispatchEngine {
|
|
|
379
750
|
const previous = room.snapshotEvents().filter(row => row.type === 'room/run-state' && row.data.runId === runId).at(-1);
|
|
380
751
|
if (previous?.type !== 'room/run-state')
|
|
381
752
|
return;
|
|
382
|
-
|
|
753
|
+
// TokenUsage counts are disjoint: the billed total is the sum of the
|
|
754
|
+
// buckets, cache reads and writes included. A provider's own
|
|
755
|
+
// totalTokens may leave the cache out (claude reports input+output),
|
|
756
|
+
// so the buckets win whenever they are there.
|
|
757
|
+
const total = event.usage === undefined ? undefined
|
|
758
|
+
: event.usage.inputTokens + (event.usage.cacheReadTokens ?? 0) + (event.usage.cacheWriteTokens ?? 0) + event.usage.outputTokens;
|
|
383
759
|
room.append('room/execution-metadata', { runId,
|
|
384
760
|
...event.observedModel === undefined ? {} : { model: event.observedModel },
|
|
385
761
|
...event.observedEffort === undefined ? {} : { effort: event.observedEffort },
|
|
386
762
|
...total === undefined || !Number.isFinite(total) || total < 0 ? {} : { tokens: total },
|
|
763
|
+
...event.usage === undefined ? {} : { usage: {
|
|
764
|
+
inputTokens: event.usage.inputTokens, outputTokens: event.usage.outputTokens,
|
|
765
|
+
...event.usage.cacheReadTokens === undefined ? {} : { cacheReadTokens: event.usage.cacheReadTokens },
|
|
766
|
+
...event.usage.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: event.usage.cacheWriteTokens },
|
|
767
|
+
...event.usage.reasoningTokens === undefined ? {} : { reasoningTokens: event.usage.reasoningTokens },
|
|
768
|
+
} },
|
|
387
769
|
});
|
|
388
770
|
void this.ctx.sessions.flush(room).catch(error => this.ctx.logger.warn(`room: execution metadata flush failed: ${String(error)}`));
|
|
389
771
|
},
|
|
390
772
|
};
|
|
391
773
|
if (!controlled)
|
|
392
774
|
startedAt = await admitted();
|
|
393
|
-
const { prompt, carried } = assemblePrompt(room, member, cursor, text, relayIds);
|
|
775
|
+
const { prompt, carried, heldCarried } = assemblePrompt(room, member, cursor, text, relayIds, dispatchSeq);
|
|
394
776
|
const prepared = member.childSessionId !== undefined && facade.isPreparedMember?.(member.childSessionId) === true;
|
|
395
777
|
const pendingRun = prepared
|
|
396
|
-
? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { preparedMemberId: member.childSessionId, ...admission })
|
|
778
|
+
? facade.start(room.id, provider, [{ type: 'text', text: prompt }], { preparedMemberId: member.childSessionId, label: member.name, ...admission })
|
|
397
779
|
: member.childSessionId === undefined
|
|
398
780
|
// The invite-time model lands as the delegation's own model: the facade
|
|
399
781
|
// records it with the first start and every later resume re-requests it
|
|
400
|
-
// (providers bind it at spawn for exec and live alike).
|
|
401
|
-
|
|
782
|
+
// (providers bind it at spawn for exec and live alike). The invite-time
|
|
783
|
+
// effort rides the same channel.
|
|
784
|
+
? facade.start(room.id, provider, [{ type: 'text', text: prompt }],
|
|
785
|
+
// The member's name labels its child session in the host's subagent list.
|
|
786
|
+
{ ...admission, label: member.name, ...member.model === undefined ? {} : { model: member.model }, ...member.effort === undefined ? {} : { effort: member.effort }, ...member.cwd === undefined ? {} : { cwd: member.cwd } })
|
|
402
787
|
: facade.resume(room.id, provider, member.childSessionId, [{ type: 'text', text: prompt }], { ...admission, ...member.cwd === undefined ? {} : { cwd: member.cwd } });
|
|
403
788
|
// Existing members can enter core's queue immediately. A fresh/prepared
|
|
404
789
|
// identity must publish its first handle before the next submission resumes it.
|
|
405
790
|
if (controlled && member.childSessionId !== undefined && !prepared)
|
|
406
791
|
submitted();
|
|
407
|
-
const
|
|
792
|
+
const fresh = !prepared && member.childSessionId === undefined;
|
|
793
|
+
const freshId = memberId(room.snapshotEvents(), member);
|
|
794
|
+
if (fresh) {
|
|
795
|
+
const starting = this.freshStarts.get(room.id) ?? new Set();
|
|
796
|
+
this.freshStarts.set(room.id, starting.add(freshId));
|
|
797
|
+
}
|
|
798
|
+
const run = await pendingRun.finally(() => {
|
|
799
|
+
if (!fresh)
|
|
800
|
+
return;
|
|
801
|
+
const starting = this.freshStarts.get(room.id);
|
|
802
|
+
starting?.delete(freshId);
|
|
803
|
+
if (starting?.size === 0)
|
|
804
|
+
this.freshStarts.delete(room.id);
|
|
805
|
+
});
|
|
408
806
|
if (startedAt === undefined) {
|
|
409
807
|
await run.dispose();
|
|
410
808
|
throw new Error('Provider published a room run without admission');
|
|
@@ -418,6 +816,7 @@ export class DispatchEngine {
|
|
|
418
816
|
...carried,
|
|
419
817
|
...replay(room.snapshotEvents()).relays.filter(relay => relayIds.includes(relay.id)),
|
|
420
818
|
]);
|
|
819
|
+
await this.markHeldReleased(room, member, heldCarried);
|
|
421
820
|
await this.ctx.sessions.flush(room);
|
|
422
821
|
}
|
|
423
822
|
catch (error) {
|
|
@@ -432,32 +831,22 @@ export class DispatchEngine {
|
|
|
432
831
|
const output = runOutputText(result.output);
|
|
433
832
|
const interrupted = result.stopReason === 'completed' ? undefined
|
|
434
833
|
: result.stopReason === 'aborted' ? 'cancelled' : 'failed';
|
|
834
|
+
const reportedTo = dispatchSeq === undefined ? undefined : reportRecipient(room.snapshotEvents(), dispatchSeq)?.name;
|
|
835
|
+
// The member's own closing message: per-message tools address it in its session.
|
|
836
|
+
const closingMessageId = lastAssistantMessageId(this.ctx.sessions.get(run.id));
|
|
435
837
|
const speech = output !== '' || interrupted === undefined ? room.append('room/speech', {
|
|
436
838
|
member: replay(room.snapshotEvents()).members.find(entry => entry.id === member.id)?.name ?? member.name,
|
|
437
839
|
text: output,
|
|
438
840
|
childSessionId: run.id,
|
|
841
|
+
runId,
|
|
842
|
+
...closingMessageId === undefined ? {} : { messageId: closingMessageId },
|
|
439
843
|
durationMs: Date.now() - startedAt,
|
|
440
844
|
...interrupted === undefined ? {} : { interrupted },
|
|
845
|
+
...reportedTo === undefined ? {} : { reportedTo },
|
|
441
846
|
}) : undefined;
|
|
442
847
|
if (speech !== undefined)
|
|
443
848
|
await this.ctx.sessions.flush(room);
|
|
444
849
|
if (result.stopReason === 'completed') {
|
|
445
|
-
// The fallback notification channel: a reply whose trailing own line is
|
|
446
|
-
// `@name <content>` (the roster-taught format) becomes a pending relay
|
|
447
|
-
// at the gate — the same shape the family bridge's member_message
|
|
448
|
-
// produces, so downstream handling is identical.
|
|
449
|
-
const directive = parseRelayDirective(runOutputText(result.output));
|
|
450
|
-
const roster = replay(room.snapshotEvents()).members;
|
|
451
|
-
if (directive !== undefined && directive.to !== member.name
|
|
452
|
-
&& roster.some(entry => entry.name === directive.to)) {
|
|
453
|
-
room.append('room/relay', {
|
|
454
|
-
id: randomUUID(),
|
|
455
|
-
from: member.name,
|
|
456
|
-
to: directive.to,
|
|
457
|
-
content: directive.content,
|
|
458
|
-
provenance: { kind: 'speech-fallback', speechSeq: speech.seq },
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
850
|
await this.ctx.sessions.flush(room);
|
|
462
851
|
return { state: 'done', text: runOutputText(result.output) };
|
|
463
852
|
}
|
|
@@ -468,23 +857,23 @@ export class DispatchEngine {
|
|
|
468
857
|
return { state: 'failed', text: output, error: `the member run ended with stopReason "${result.stopReason}"` };
|
|
469
858
|
}
|
|
470
859
|
}
|
|
471
|
-
/** Settle only this execution
|
|
860
|
+
/** Settle only this execution, preserving newer runs. */
|
|
472
861
|
async settle(room, member, runId, startedAt, outcome) {
|
|
473
862
|
const events = room.snapshotEvents();
|
|
474
|
-
const
|
|
863
|
+
const currentMember = replay(events).members.find(entry => memberId(events, entry) === memberId(events, member));
|
|
864
|
+
const memberName = currentMember?.name ?? member.name;
|
|
475
865
|
const terminal = events.find(event => event.type === 'room/run-state' && event.data.runId === runId && event.data.state !== 'running');
|
|
476
866
|
if (terminal?.type === 'room/run-state' && terminal.data.state !== 'running') {
|
|
477
867
|
return { ...outcome, state: terminal.data.state, ...terminal.data.error === undefined ? {} : { error: terminal.data.error } };
|
|
478
868
|
}
|
|
869
|
+
const childSessionId = runChildSessionId(room.id, currentMember ?? member);
|
|
870
|
+
const childTurn = runChildTurn(childSessionId === undefined ? undefined : this.ctx.sessions.get(childSessionId));
|
|
479
871
|
room.append('room/run-state', {
|
|
480
872
|
member: memberName, runId, state: outcome.state, startedAt, elapsedMs: Date.now() - startedAt,
|
|
873
|
+
...childSessionId === undefined ? {} : { childSessionId },
|
|
874
|
+
...childTurn === undefined ? {} : { childTurn },
|
|
481
875
|
...outcome.error === undefined ? {} : { error: outcome.error },
|
|
482
876
|
});
|
|
483
|
-
for (const task of replay(room.snapshotEvents()).tasks) {
|
|
484
|
-
if (task.member === memberName && task.status === 'in_progress' && task.deliveryId === runId) {
|
|
485
|
-
room.append('room/task-updated', { id: task.id, status: outcome.state });
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
877
|
await this.ctx.sessions.flush(room);
|
|
489
878
|
return outcome;
|
|
490
879
|
}
|