dominds 1.16.7 → 1.16.8
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/dialog-display-state.js +35 -3
- package/dist/docs/dialog-system.md +45 -0
- package/dist/docs/dialog-system.zh.md +45 -0
- package/dist/llm/kernel-driver/flow.js +171 -51
- package/dist/llm/kernel-driver/reply-guidance.js +25 -1
- package/dist/llm/kernel-driver/tellask-special.js +3 -5
- package/dist/persistence.js +1 -1
- package/dist/runtime/interjection-pause-stop.d.ts +5 -0
- package/dist/runtime/interjection-pause-stop.js +35 -0
- package/dist/server/websocket-handler.js +14 -0
- package/package.json +2 -2
|
@@ -51,6 +51,7 @@ const evt_registry_1 = require("./evt-registry");
|
|
|
51
51
|
const log_1 = require("./log");
|
|
52
52
|
const persistence_1 = require("./persistence");
|
|
53
53
|
const persistence_errors_1 = require("./persistence-errors");
|
|
54
|
+
const interjection_pause_stop_1 = require("./runtime/interjection-pause-stop");
|
|
54
55
|
const log = (0, log_1.createLogger)('dialog-display-state');
|
|
55
56
|
let broadcastToClients;
|
|
56
57
|
const activeRunsByDialogKey = new Map();
|
|
@@ -128,11 +129,21 @@ async function getRunControlCountsSnapshot() {
|
|
|
128
129
|
}
|
|
129
130
|
else if (latest?.executionMarker?.kind === 'interrupted' &&
|
|
130
131
|
isStoppedReasonResumable(latest.executionMarker.reason)) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
// Keep run-control counts aligned with actual Continue affordance:
|
|
133
|
+
// - ordinary interrupted dialogs count as resumable only when no blocker remains
|
|
134
|
+
// - interjection-paused dialogs still count as resumable even if blocker facts remain,
|
|
135
|
+
// because the intended UX is that Continue exits the temporary paused projection
|
|
136
|
+
// and re-evaluates the original task from fresh facts
|
|
137
|
+
if ((0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
|
|
134
138
|
resumable++;
|
|
135
139
|
}
|
|
140
|
+
else {
|
|
141
|
+
const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialogId, 'running');
|
|
142
|
+
const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialogId, 'running');
|
|
143
|
+
if (q4h.length === 0 && pendingSubdialogs.length === 0) {
|
|
144
|
+
resumable++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
149
|
catch (error) {
|
|
@@ -470,6 +481,27 @@ async function refreshRunControlProjectionFromPersistenceFacts(dialogId, trigger
|
|
|
470
481
|
latest.executionMarker.kind === 'dead') {
|
|
471
482
|
return { kind: 'dead', reason: latest.executionMarker.reason };
|
|
472
483
|
}
|
|
484
|
+
if (latest.executionMarker?.kind === 'interrupted' &&
|
|
485
|
+
(0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
|
|
486
|
+
// WARNING:
|
|
487
|
+
// This is the one place where the projection intentionally preserves the paused-interjection
|
|
488
|
+
// stopped state ahead of the current blocker facts. That is not a bug: after a user
|
|
489
|
+
// interjection we want the UI to keep showing "original task paused; click Continue" even if
|
|
490
|
+
// the underlying dialog is still waiting on Q4H/subdialogs.
|
|
491
|
+
//
|
|
492
|
+
// The true source-of-truth decision about what Continue should do next lives in
|
|
493
|
+
// `flow.ts`'s resume path, which performs a fresh fact scan at resume time and then either:
|
|
494
|
+
// - restores `blocked`, or
|
|
495
|
+
// - keeps driving immediately.
|
|
496
|
+
//
|
|
497
|
+
// Do not "heal" this branch away by prioritizing blocker facts here; that would collapse the
|
|
498
|
+
// temporary interjection UX and make repeated interjection turns revert too early.
|
|
499
|
+
return {
|
|
500
|
+
kind: 'stopped',
|
|
501
|
+
reason: latest.executionMarker.reason,
|
|
502
|
+
continueEnabled: isStoppedReasonResumable(latest.executionMarker.reason),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
473
505
|
const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialogId, 'running');
|
|
474
506
|
const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialogId, 'running');
|
|
475
507
|
const hasQ4H = q4h.length > 0;
|
|
@@ -155,6 +155,51 @@ Dialog state is persisted to storage at key points:
|
|
|
155
155
|
|
|
156
156
|
This ensures crash recovery and enables the backend to resume from any persisted state without depending on frontend state.
|
|
157
157
|
|
|
158
|
+
### User Interjection Pause And Continue Semantics
|
|
159
|
+
|
|
160
|
+
When a dialog still carries an inter-dialog reply obligation, but the user temporarily interjects and asks it to handle a local question first, the system must distinguish between the **UI projection** and the **true driving source state**.
|
|
161
|
+
|
|
162
|
+
**Normative semantics**:
|
|
163
|
+
|
|
164
|
+
1. Every user interjection message is driven as a complete normal round.
|
|
165
|
+
2. If that round needs tools, the system MUST finish the full tool round and any post-tool follow-up before pausing.
|
|
166
|
+
3. The system only projects the original task as resumable `stopped` when this interjection has actually parked an original task that still needs explicit restoration.
|
|
167
|
+
4. If there is no parked original task to resume afterwards (for example, no inter-dialog reply obligation needs reassertion), the interjection round should simply finish and return to the true underlying state without showing this special `stopped` panel.
|
|
168
|
+
5. As long as the user keeps sending new messages, the dialog stays in temporary interjection-chat handling, and that paused projection remains in place only if it was established in the first place.
|
|
169
|
+
6. Only an explicit UI `Continue` attempts to restore the original task.
|
|
170
|
+
|
|
171
|
+
**Key point**: this `stopped` state is only a temporary run-control / UI projection. It is not the same as an ordinary system-stop failure, and it is not the final business source of truth. It also does not apply to every interjection; it exists only when there really is a parked original task to resume.
|
|
172
|
+
|
|
173
|
+
After the user clicks `Continue`, the backend MUST re-evaluate fresh persistence facts and decide which true-source case now applies. It must not infer the result purely from the visible `displayState`:
|
|
174
|
+
|
|
175
|
+
- **Case 1: the dialog no longer has a reply obligation**
|
|
176
|
+
If there is also no blocker, the dialog should simply continue driving. If it has already become ordinary idle-waiting-user, then `resume_dialog` is no longer actually resumable.
|
|
177
|
+
- **Case 2: the dialog still has a reply obligation and is still suspended**
|
|
178
|
+
Typical examples are pending Q4H or pending subdialogs. In this case, `Continue` should exit the interjection-paused projection and restore the true `blocked` state.
|
|
179
|
+
- **Case 3: the dialog still has a reply obligation but is no longer suspended and is eligible to proceed**
|
|
180
|
+
For example, the blocker has disappeared, or a queued prompt provides a valid continuation path. In this case, `Continue` must not first fall back to an intermediate placeholder `blocked/idle` state; it should keep driving immediately.
|
|
181
|
+
|
|
182
|
+
**This leads to two implementation constraints**:
|
|
183
|
+
|
|
184
|
+
- `refreshRunControlProjectionFromPersistenceFacts()` MUST preserve the special "interjection handled; original task paused" `stopped` projection until the user explicitly clicks `Continue`; otherwise the UI collapses back to ordinary `blocked` too early and breaks multi-turn interjection UX. Conversely, when there is no parked original task, this paused projection should not be created at all.
|
|
185
|
+
- The actual outcome of `Continue` MUST be decided in the resume drive path by re-reading fresh persistence facts. "Continue is clickable" does not mean "the dialog will definitely enter proceeding immediately".
|
|
186
|
+
- The run-control toolbar's `resumable` count should align with "manual Continue attempt is meaningful". Therefore an interjection-paused `stopped` dialog still counts as resumable even when underlying blocker facts remain, because the business meaning of `Continue` there is "exit the temporary paused projection and re-evaluate from source-of-truth facts".
|
|
187
|
+
|
|
188
|
+
**Mental-model warning**:
|
|
189
|
+
|
|
190
|
+
- Do not reason about this flow from `displayState.kind === 'stopped'` alone.
|
|
191
|
+
- Do not reason about it from blocker facts alone and then wonder why the UI still shows `stopped`.
|
|
192
|
+
- Do not reason about it from `resume_dialog` eligibility alone and assume resumption always means immediate running.
|
|
193
|
+
|
|
194
|
+
You need all of the following together to understand the behavior correctly:
|
|
195
|
+
|
|
196
|
+
- reply-guidance suppression / deferred reassertion for interjection turns
|
|
197
|
+
- flow logic for "pause after local interjection reply" plus "fresh-fact second decision after Continue"
|
|
198
|
+
- dialog-display-state projection preservation
|
|
199
|
+
- websocket resume entry semantics distinguishing "allowed to attempt Continue" from "actually re-entered driving"
|
|
200
|
+
|
|
201
|
+
This is an intentionally cross-module semantic contract. Do not locally "simplify" one piece based only on its surface meaning.
|
|
202
|
+
|
|
158
203
|
---
|
|
159
204
|
|
|
160
205
|
## 3-Type Teammate Tellask Taxonomy
|
|
@@ -154,6 +154,51 @@
|
|
|
154
154
|
|
|
155
155
|
这确保了崩溃恢复,并使后端能够从任何持久化状态恢复,而不依赖于前端状态。
|
|
156
156
|
|
|
157
|
+
### 用户插话暂停与 Continue 语义
|
|
158
|
+
|
|
159
|
+
当某个对话仍带有跨对话回复义务,但用户临时插话要求它先处理本地问题时,系统必须区分**UI 投影**与**真实驱动源状态**。
|
|
160
|
+
|
|
161
|
+
**规范语义**:
|
|
162
|
+
|
|
163
|
+
1. 每条用户插话消息都按正常驱动轮完整执行。
|
|
164
|
+
2. 若该轮需要工具,则必须先完整跑完该工具轮及其 post-tool follow-up。
|
|
165
|
+
3. 只有当这条插话确实打断了一个仍待恢复的“原任务”时,系统才把该原任务投影为可 `Continue` 的 `stopped`,让用户先看到最后一条回复。
|
|
166
|
+
4. 若当前并不存在待恢复的原任务(例如没有待重申的跨对话回复义务),则插话轮结束后应直接回到真实 underlying state,而不显示这个特殊 `stopped` 面板。
|
|
167
|
+
5. 只要用户继续发送新消息,就继续作为插话临时对话处理;这个 paused projection 仅在它已被建立时持续保持。
|
|
168
|
+
6. 只有用户显式点击 UI `Continue`,系统才尝试恢复原任务。
|
|
169
|
+
|
|
170
|
+
**关键点**:这里的 `stopped` 只是一个临时 run-control / UI 投影,不等于普通 system-stop 失败,也不是最终的业务真源;并且它不是所有插话都会出现,只在“确有一个待恢复的原任务被临时停靠”时出现。
|
|
171
|
+
|
|
172
|
+
点击 `Continue` 后,后端必须重新从 persistence 真源判定当前对话属于哪一种情况,而不能只根据表面的 `displayState` 做静态推断:
|
|
173
|
+
|
|
174
|
+
- **情况 1:当前对话没有回复义务**
|
|
175
|
+
这时若也没有其他 blocker,就应直接继续 drive;若已回到普通待用户输入态,则 `resume_dialog` 不应再被视为可继续。
|
|
176
|
+
- **情况 2:当前对话仍有回复义务,但处于 suspend 状态**
|
|
177
|
+
常见于仍在等待 Q4H / pending subdialogs。此时 `Continue` 应退出插话 paused projection,并恢复成真实的 `blocked`。
|
|
178
|
+
- **情况 3:当前对话仍有回复义务,但已不再 suspend,具备继续推进条件**
|
|
179
|
+
例如 blocker 已消失,或存在允许继续的 queued prompt。此时 `Continue` 不应先落一个中间 `blocked/idle` 占位态,而应直接继续 drive。
|
|
180
|
+
|
|
181
|
+
**因此有两个实现约束**:
|
|
182
|
+
|
|
183
|
+
- `refreshRunControlProjectionFromPersistenceFacts()` 在用户尚未点击 `Continue` 前,必须保留这层“插话已处理;原任务已暂停”的 `stopped` 投影;否则 UI 会过早塌回普通 `blocked`,破坏多轮插话体验。反过来,如果当前其实没有待恢复原任务,则根本不应建立这层 paused projection。
|
|
184
|
+
- 真正决定 `Continue` 结果的逻辑,必须在恢复驱动路径中重新读取 fresh persistence facts;不能把“可点 Continue”误解为“必然立即 proceeding”。
|
|
185
|
+
- run-control 工具栏中的 `resumable` 计数,应与“是否允许手动 Continue 尝试”保持一致。因此,处于 interjection-paused `stopped` 的对话即便底层仍有 blocker,也应计入 `resumable`;因为 `Continue` 的业务语义正是“退出这层临时 paused projection,并从真源重判下一步”。
|
|
186
|
+
|
|
187
|
+
**心智模型提醒**:
|
|
188
|
+
|
|
189
|
+
- 不能只看 `displayState.kind === 'stopped'` 就理解这条链路。
|
|
190
|
+
- 不能只看 blocker facts 就理解为什么 UI 仍显示 `stopped`。
|
|
191
|
+
- 也不能只看 `resume_dialog` eligibility 就推断恢复后一定马上运行。
|
|
192
|
+
|
|
193
|
+
必须把以下几块一起看,才能形成完整且精确的理解:
|
|
194
|
+
|
|
195
|
+
- reply-guidance 中对插话轮的回复义务 suppression / deferred reassertion
|
|
196
|
+
- flow 中“插话回复后停车”与“Continue 后 fresh fact 二次判定”
|
|
197
|
+
- dialog-display-state 中 paused projection 的保留策略
|
|
198
|
+
- websocket resume 入口对“可尝试 Continue”与“实际已恢复 drive”的区分
|
|
199
|
+
|
|
200
|
+
这是一条跨模块协同语义,不允许在单点上做“表面看起来更简单”的局部简化。
|
|
201
|
+
|
|
157
202
|
---
|
|
158
203
|
|
|
159
204
|
## 三类队友诉请分类
|
|
@@ -10,6 +10,7 @@ const log_1 = require("../../log");
|
|
|
10
10
|
const load_1 = require("../../minds/load");
|
|
11
11
|
const persistence_1 = require("../../persistence");
|
|
12
12
|
const driver_messages_1 = require("../../runtime/driver-messages");
|
|
13
|
+
const interjection_pause_stop_1 = require("../../runtime/interjection-pause-stop");
|
|
13
14
|
const reply_prompt_copy_1 = require("../../runtime/reply-prompt-copy");
|
|
14
15
|
const work_language_1 = require("../../runtime/work-language");
|
|
15
16
|
const client_1 = require("../client");
|
|
@@ -40,6 +41,30 @@ async function buildReplyToolReminderPrompt(args) {
|
|
|
40
41
|
}),
|
|
41
42
|
});
|
|
42
43
|
}
|
|
44
|
+
async function loadFreshSuspensionStatusFromPersistence(dialog) {
|
|
45
|
+
const q4h = await persistence_1.DialogPersistence.loadQuestions4HumanState(dialog.id, dialog.status);
|
|
46
|
+
const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(dialog.id, dialog.status);
|
|
47
|
+
const hasQ4H = q4h.length > 0;
|
|
48
|
+
const hasSubdialogs = pendingSubdialogs.length > 0;
|
|
49
|
+
return {
|
|
50
|
+
q4h: hasQ4H,
|
|
51
|
+
subdialogs: hasSubdialogs,
|
|
52
|
+
blockingSubdialogs: hasSubdialogs,
|
|
53
|
+
canDrive: !hasQ4H && !hasSubdialogs,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function buildDisplayStateFromSuspensionStatus(args) {
|
|
57
|
+
if (args.q4h && args.subdialogs) {
|
|
58
|
+
return { kind: 'blocked', reason: { kind: 'needs_human_input_and_subdialogs' } };
|
|
59
|
+
}
|
|
60
|
+
if (args.q4h) {
|
|
61
|
+
return { kind: 'blocked', reason: { kind: 'needs_human_input' } };
|
|
62
|
+
}
|
|
63
|
+
if (args.subdialogs) {
|
|
64
|
+
return { kind: 'blocked', reason: { kind: 'waiting_for_subdialogs' } };
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'idle_waiting_user' };
|
|
67
|
+
}
|
|
43
68
|
async function loadPendingDiagnosticsSnapshot(args) {
|
|
44
69
|
const ownerDialogIdObj = new dialog_1.DialogID(args.ownerDialogId, args.rootId);
|
|
45
70
|
try {
|
|
@@ -304,6 +329,8 @@ async function executeDriveRound(args) {
|
|
|
304
329
|
let subdialogReplyTarget;
|
|
305
330
|
let activeTellaskReplyDirective;
|
|
306
331
|
let activePromptWasReplyToolReminder = false;
|
|
332
|
+
let shouldPauseAfterLocalUserInterjection = false;
|
|
333
|
+
let resumeFromInterjectionPause = false;
|
|
307
334
|
const allowResumeFromInterrupted = driveOptions?.allowResumeFromInterrupted === true || humanPrompt?.origin === 'user';
|
|
308
335
|
const driveSource = resolveDriveRequestSource(humanPrompt, driveOptions);
|
|
309
336
|
try {
|
|
@@ -340,6 +367,11 @@ async function executeDriveRound(args) {
|
|
|
340
367
|
});
|
|
341
368
|
return;
|
|
342
369
|
}
|
|
370
|
+
resumeFromInterjectionPause =
|
|
371
|
+
humanPrompt === undefined &&
|
|
372
|
+
allowResumeFromInterrupted &&
|
|
373
|
+
latest?.executionMarker?.kind === 'interrupted' &&
|
|
374
|
+
(0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason);
|
|
343
375
|
}
|
|
344
376
|
catch (err) {
|
|
345
377
|
log_1.log.warn('kernel-driver failed to check execution facts before drive; proceeding best-effort', err, {
|
|
@@ -382,10 +414,39 @@ async function executeDriveRound(args) {
|
|
|
382
414
|
return;
|
|
383
415
|
}
|
|
384
416
|
}
|
|
385
|
-
|
|
417
|
+
// WARNING:
|
|
418
|
+
// `allowResumeFromInterrupted` covers multiple stop reasons, but the interjection-pause case
|
|
419
|
+
// is semantically special. Clicking Continue here does NOT mean "blindly clear stopped and
|
|
420
|
+
// drive". We must re-read the fresh persistence facts first because there are three distinct
|
|
421
|
+
// true-source cases behind the same visible stopped panel:
|
|
422
|
+
// - no active reply obligation / not suspended anymore -> continue real driving now
|
|
423
|
+
// - active reply obligation + suspended -> restore true blocked state
|
|
424
|
+
// - active reply obligation + still proceeding entitlement (for example queued upNext) ->
|
|
425
|
+
// continue real driving now
|
|
426
|
+
//
|
|
427
|
+
// Do not refactor this branch using only `displayState` or only the previous interrupted
|
|
428
|
+
// marker. The correct behavior emerges from combining fresh blocker facts, queued prompt
|
|
429
|
+
// state, and the deferred reply reassertion logic elsewhere.
|
|
430
|
+
const suspension = resumeFromInterjectionPause
|
|
431
|
+
? await loadFreshSuspensionStatusFromPersistence(dialog)
|
|
432
|
+
: await dialog.getSuspensionStatus();
|
|
386
433
|
const queuedPrompt = dialog.peekUpNext();
|
|
387
434
|
const queuedSubdialogPromptCanResume = dialog instanceof dialog_1.SubDialog && queuedPrompt !== undefined;
|
|
388
435
|
if (!suspension.canDrive && !queuedSubdialogPromptCanResume) {
|
|
436
|
+
if (resumeFromInterjectionPause) {
|
|
437
|
+
const restoredState = buildDisplayStateFromSuspensionStatus({
|
|
438
|
+
q4h: suspension.q4h,
|
|
439
|
+
subdialogs: suspension.subdialogs,
|
|
440
|
+
});
|
|
441
|
+
await (0, dialog_display_state_1.setDialogDisplayState)(dialog.id, restoredState);
|
|
442
|
+
log_1.log.debug('kernel-driver continue after interjection pause restored true suspended state from fresh persistence facts', undefined, {
|
|
443
|
+
dialogId: dialog.id.valueOf(),
|
|
444
|
+
restoredState,
|
|
445
|
+
waitingQ4H: suspension.q4h,
|
|
446
|
+
waitingSubdialogs: suspension.subdialogs,
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
389
450
|
const lastTrigger = dialog_global_registry_1.globalDialogRegistry.getLastDriveTrigger(dialog.id.rootId);
|
|
390
451
|
const lastTriggerAgeMs = lastTrigger !== undefined ? Math.max(0, Date.now() - lastTrigger.emittedAtMs) : undefined;
|
|
391
452
|
log_1.log.debug('kernel-driver skip queued auto-drive while dialog is suspended', undefined, {
|
|
@@ -413,6 +474,15 @@ async function executeDriveRound(args) {
|
|
|
413
474
|
});
|
|
414
475
|
return;
|
|
415
476
|
}
|
|
477
|
+
if (resumeFromInterjectionPause) {
|
|
478
|
+
log_1.log.debug('kernel-driver continue after interjection pause passed fresh fact scan and will keep driving', undefined, {
|
|
479
|
+
dialogId: dialog.id.valueOf(),
|
|
480
|
+
waitingQ4H: suspension.q4h,
|
|
481
|
+
waitingSubdialogs: suspension.subdialogs,
|
|
482
|
+
hasQueuedUpNext: dialog.hasUpNext(),
|
|
483
|
+
queuedSubdialogPromptCanResume,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
416
486
|
}
|
|
417
487
|
const minds = await (0, load_1.loadAgentMinds)(dialog.agentId, dialog);
|
|
418
488
|
const policy = (0, guardrails_1.buildKernelDriverPolicy)({
|
|
@@ -508,6 +578,14 @@ async function executeDriveRound(args) {
|
|
|
508
578
|
dlg: dialog,
|
|
509
579
|
prompt: effectivePrompt,
|
|
510
580
|
});
|
|
581
|
+
// Only park into the special interjection stopped-panel state when this user turn has
|
|
582
|
+
// suppressed a still-pending inter-dialog reply obligation that must be reasserted later.
|
|
583
|
+
// User interjections without a parked original task should simply finish and fall back to the
|
|
584
|
+
// dialog's true underlying state, without showing the special stopped panel.
|
|
585
|
+
shouldPauseAfterLocalUserInterjection =
|
|
586
|
+
effectivePrompt?.origin === 'user' &&
|
|
587
|
+
replyGuidance.suppressInterDialogReplyGuidance &&
|
|
588
|
+
replyGuidance.deferredReplyReassertionDirective !== undefined;
|
|
511
589
|
activeTellaskReplyDirective = replyGuidance.activeReplyDirective;
|
|
512
590
|
activePromptWasReplyToolReminder = isReplyToolReminderPrompt(effectivePrompt);
|
|
513
591
|
if (effectivePrompt && effectivePrompt.userLanguageCode) {
|
|
@@ -573,28 +651,64 @@ async function executeDriveRound(args) {
|
|
|
573
651
|
});
|
|
574
652
|
}
|
|
575
653
|
else {
|
|
576
|
-
if (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
scheduleDrive: args.scheduleDrive,
|
|
654
|
+
if (!activePromptWasReplyToolReminder) {
|
|
655
|
+
const language = (0, work_language_1.getWorkLanguage)();
|
|
656
|
+
followUp = {
|
|
657
|
+
prompt: await buildReplyToolReminderPrompt({
|
|
658
|
+
dlg: dialog,
|
|
659
|
+
directive: activeTellaskReplyDirective,
|
|
660
|
+
language,
|
|
661
|
+
}),
|
|
662
|
+
msgId: (0, id_1.generateShortId)(),
|
|
663
|
+
grammar: 'markdown',
|
|
664
|
+
origin: 'runtime',
|
|
665
|
+
userLanguageCode: language,
|
|
666
|
+
tellaskReplyDirective: activeTellaskReplyDirective,
|
|
667
|
+
subdialogReplyTarget,
|
|
668
|
+
};
|
|
669
|
+
log_1.log.debug('kernel-driver queued subdialog replyTellask reminder after plain reply', undefined, {
|
|
670
|
+
dialogId: dialog.id.valueOf(),
|
|
671
|
+
targetCallId: activeTellaskReplyDirective.targetCallId,
|
|
672
|
+
targetOwnerDialogId: subdialogReplyTarget?.ownerDialogId,
|
|
596
673
|
});
|
|
597
|
-
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
if (typeof driveResult.lastAssistantSayingGenseq !== 'number' ||
|
|
677
|
+
!Number.isFinite(driveResult.lastAssistantSayingGenseq) ||
|
|
678
|
+
driveResult.lastAssistantSayingGenseq <= 0) {
|
|
679
|
+
throw new Error(`Subdialog response supply invariant violation: missing lastAssistantSayingGenseq for dialog=${dialog.id.valueOf()}`);
|
|
680
|
+
}
|
|
681
|
+
const responseGenseq = Math.floor(driveResult.lastAssistantSayingGenseq);
|
|
682
|
+
const directFallbackCallId = `direct-fallback-${(0, id_1.generateShortId)()}`;
|
|
683
|
+
let supplied = false;
|
|
684
|
+
if (subdialogReplyTarget) {
|
|
685
|
+
supplied = await (0, subdialog_1.supplySubdialogResponseToSpecificCallerIfPendingV2)({
|
|
686
|
+
subdialog: dialog,
|
|
687
|
+
responseText: driveResult.lastAssistantSayingContent,
|
|
688
|
+
responseGenseq,
|
|
689
|
+
target: subdialogReplyTarget,
|
|
690
|
+
deliveryMode: 'direct_fallback',
|
|
691
|
+
replyResolution: {
|
|
692
|
+
callId: directFallbackCallId,
|
|
693
|
+
replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
|
|
694
|
+
},
|
|
695
|
+
scheduleDrive: args.scheduleDrive,
|
|
696
|
+
});
|
|
697
|
+
if (!supplied) {
|
|
698
|
+
supplied = await (0, subdialog_1.supplySubdialogResponseToAssignedCallerIfPendingV2)({
|
|
699
|
+
subdialog: dialog,
|
|
700
|
+
responseText: driveResult.lastAssistantSayingContent,
|
|
701
|
+
responseGenseq,
|
|
702
|
+
deliveryMode: 'direct_fallback',
|
|
703
|
+
replyResolution: {
|
|
704
|
+
callId: directFallbackCallId,
|
|
705
|
+
replyCallName: activeTellaskReplyDirective.expectedReplyCallName,
|
|
706
|
+
},
|
|
707
|
+
scheduleDrive: args.scheduleDrive,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
598
712
|
supplied = await (0, subdialog_1.supplySubdialogResponseToAssignedCallerIfPendingV2)({
|
|
599
713
|
subdialog: dialog,
|
|
600
714
|
responseText: driveResult.lastAssistantSayingContent,
|
|
@@ -607,35 +721,21 @@ async function executeDriveRound(args) {
|
|
|
607
721
|
scheduleDrive: args.scheduleDrive,
|
|
608
722
|
});
|
|
609
723
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const diagnostics = await loadPendingDiagnosticsSnapshot({
|
|
626
|
-
rootId: dialog.id.rootId,
|
|
627
|
-
ownerDialogId: subdialogReplyTarget.ownerDialogId,
|
|
628
|
-
expectedSubdialogId: dialog.id.selfId,
|
|
629
|
-
status: dialog.status,
|
|
630
|
-
});
|
|
631
|
-
log_1.log.debug('kernel-driver failed to supply subdialog response to specific caller', undefined, {
|
|
632
|
-
calleeId: dialog.id.valueOf(),
|
|
633
|
-
targetOwner: subdialogReplyTarget.ownerDialogId,
|
|
634
|
-
targetOwnerDialogId: subdialogReplyTarget.ownerDialogId,
|
|
635
|
-
targetCallType: subdialogReplyTarget.callType,
|
|
636
|
-
targetCallId: subdialogReplyTarget.callId,
|
|
637
|
-
diagnostics,
|
|
638
|
-
});
|
|
724
|
+
if (!supplied && subdialogReplyTarget) {
|
|
725
|
+
const diagnostics = await loadPendingDiagnosticsSnapshot({
|
|
726
|
+
rootId: dialog.id.rootId,
|
|
727
|
+
ownerDialogId: subdialogReplyTarget.ownerDialogId,
|
|
728
|
+
expectedSubdialogId: dialog.id.selfId,
|
|
729
|
+
status: dialog.status,
|
|
730
|
+
});
|
|
731
|
+
log_1.log.debug('kernel-driver failed to supply subdialog response to specific caller', undefined, {
|
|
732
|
+
calleeId: dialog.id.valueOf(),
|
|
733
|
+
targetOwnerDialogId: subdialogReplyTarget.ownerDialogId,
|
|
734
|
+
targetCallType: subdialogReplyTarget.callType,
|
|
735
|
+
targetCallId: subdialogReplyTarget.callId,
|
|
736
|
+
diagnostics,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
639
739
|
}
|
|
640
740
|
}
|
|
641
741
|
}
|
|
@@ -716,6 +816,26 @@ async function executeDriveRound(args) {
|
|
|
716
816
|
},
|
|
717
817
|
});
|
|
718
818
|
}
|
|
819
|
+
if (shouldPauseAfterLocalUserInterjection &&
|
|
820
|
+
!interruptedBySignal &&
|
|
821
|
+
followUp === undefined &&
|
|
822
|
+
driveResult?.lastAssistantSayingContent !== null) {
|
|
823
|
+
const pauseReason = (0, interjection_pause_stop_1.buildUserInterjectionPauseStopReason)();
|
|
824
|
+
await (0, dialog_display_state_1.setDialogDisplayState)(dialog.id, {
|
|
825
|
+
kind: 'stopped',
|
|
826
|
+
reason: pauseReason,
|
|
827
|
+
continueEnabled: true,
|
|
828
|
+
});
|
|
829
|
+
(0, dialog_display_state_1.broadcastDisplayStateMarker)(dialog.id, {
|
|
830
|
+
kind: 'interrupted',
|
|
831
|
+
reason: pauseReason,
|
|
832
|
+
});
|
|
833
|
+
log_1.log.debug('kernel-driver paused original task after local user interjection reply', undefined, {
|
|
834
|
+
dialogId: dialog.id.valueOf(),
|
|
835
|
+
rootId: dialog.id.rootId,
|
|
836
|
+
selfId: dialog.id.selfId,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
719
839
|
}
|
|
720
840
|
catch (error) {
|
|
721
841
|
tailError = error;
|
|
@@ -6,6 +6,8 @@ exports.buildReplyObligationSuppressionGuide = buildReplyObligationSuppressionGu
|
|
|
6
6
|
exports.buildReplyObligationReassertionPrompt = buildReplyObligationReassertionPrompt;
|
|
7
7
|
const dialog_1 = require("../../dialog");
|
|
8
8
|
const dialog_instance_registry_1 = require("../../dialog-instance-registry");
|
|
9
|
+
const persistence_1 = require("../../persistence");
|
|
10
|
+
const interjection_pause_stop_1 = require("../../runtime/interjection-pause-stop");
|
|
9
11
|
const reply_prompt_copy_1 = require("../../runtime/reply-prompt-copy");
|
|
10
12
|
const work_language_1 = require("../../runtime/work-language");
|
|
11
13
|
const tellask_special_1 = require("./tellask-special");
|
|
@@ -81,6 +83,17 @@ function buildPromptContentWithExactReplyToolName(args) {
|
|
|
81
83
|
return `${note}\n\n${args.prompt.content}`;
|
|
82
84
|
}
|
|
83
85
|
async function shouldSuppressInterDialogReplyGuidanceForUserInterjection(args) {
|
|
86
|
+
// WARNING:
|
|
87
|
+
// This suppression decision is not a cosmetic prompt tweak. It is one leg of the full
|
|
88
|
+
// interjection-pause state machine:
|
|
89
|
+
// 1. user interjection suppresses the live reply obligation here;
|
|
90
|
+
// 2. `flow.ts` answers locally and parks the original task in a resumable stopped state;
|
|
91
|
+
// 3. manual Continue later decides from fresh persistence facts whether the dialog should stay
|
|
92
|
+
// blocked or resume real driving.
|
|
93
|
+
//
|
|
94
|
+
// Do not "simplify" this into a pure display-state check or a pure pending-subdialog check.
|
|
95
|
+
// The business anchor is the deferred reply reassertion, while the paused execution marker keeps
|
|
96
|
+
// repeated interjection turns behaving as local side conversation until explicit Continue.
|
|
84
97
|
const prompt = args.prompt;
|
|
85
98
|
if (!prompt) {
|
|
86
99
|
return false;
|
|
@@ -91,7 +104,18 @@ async function shouldSuppressInterDialogReplyGuidanceForUserInterjection(args) {
|
|
|
91
104
|
if (prompt.tellaskReplyDirective !== undefined) {
|
|
92
105
|
return false;
|
|
93
106
|
}
|
|
94
|
-
|
|
107
|
+
const latest = await persistence_1.DialogPersistence.loadDialogLatest(args.dlg.id, args.dlg.status);
|
|
108
|
+
if (latest?.deferredReplyReassertion?.reason === 'user_interjection_while_pending_subdialog') {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (latest?.executionMarker?.kind === 'interrupted' &&
|
|
112
|
+
(0, interjection_pause_stop_1.isUserInterjectionPauseStopReason)(latest.executionMarker.reason)) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// Use strict persistence reads here. This branch changes business behavior, so a read failure
|
|
116
|
+
// must loud-fail the round instead of being silently treated as "pending subdialogs exist".
|
|
117
|
+
const pendingSubdialogs = await persistence_1.DialogPersistence.loadPendingSubdialogs(args.dlg.id, args.dlg.status);
|
|
118
|
+
return pendingSubdialogs.length > 0;
|
|
95
119
|
}
|
|
96
120
|
async function resolvePromptReplyGuidance(args) {
|
|
97
121
|
const prompt = args.prompt;
|
|
@@ -140,8 +140,8 @@ async function deliverTellaskBackReplyFromDirective(args) {
|
|
|
140
140
|
// the dialog running `replyTellaskBack` is the ask-back responder, while
|
|
141
141
|
// directive.targetDialogId points to the ask-back requester that must receive the canonical
|
|
142
142
|
// tellaskBack result. Keep those roles explicit, otherwise it is very easy to accidentally
|
|
143
|
-
// write the same business result
|
|
144
|
-
//
|
|
143
|
+
// write the same business result twice by confusing the responder's local plaintext with the
|
|
144
|
+
// canonical upstream delivery that must come only from an explicit reply tool call.
|
|
145
145
|
const rootDialog = args.replyingDialog instanceof dialog_1.RootDialog
|
|
146
146
|
? args.replyingDialog
|
|
147
147
|
: args.replyingDialog instanceof dialog_1.SubDialog
|
|
@@ -910,9 +910,6 @@ function findDeliveredTellaskBackReplyOnAskBackRequester(args) {
|
|
|
910
910
|
return undefined;
|
|
911
911
|
}
|
|
912
912
|
async function extractAskBackResponderPlaintextFallback(args) {
|
|
913
|
-
// This fallback is intentionally second-class: it exists only for legacy/plain-reply flows
|
|
914
|
-
// where no explicit `replyTellaskBack` canonical result has been delivered. It must never
|
|
915
|
-
// compete with or overwrite an already delivered canonical tellaskBack result.
|
|
916
913
|
try {
|
|
917
914
|
return extractLastAssistantResponse(args.responderDialog.msgs, 'Supdialog completed without producing output.');
|
|
918
915
|
}
|
|
@@ -1253,6 +1250,7 @@ async function executeTellaskCall(dlg, mentionList, body, callId, callbacks, opt
|
|
|
1253
1250
|
tellaskContent: body,
|
|
1254
1251
|
responseBody: responseText,
|
|
1255
1252
|
status: 'completed',
|
|
1253
|
+
deliveryMode: 'direct_fallback',
|
|
1256
1254
|
language: (0, work_language_1.getWorkLanguage)(),
|
|
1257
1255
|
});
|
|
1258
1256
|
askBackRequesterDialog.setSuspensionState('resumed');
|
package/dist/persistence.js
CHANGED
|
@@ -1553,7 +1553,7 @@ class DiskFileDialogStore extends dialog_1.DialogStore {
|
|
|
1553
1553
|
// Duplicate final results are not harmless transcript noise. They mean two different program
|
|
1554
1554
|
// paths both believed they owned the same business-level completion fact for one callId.
|
|
1555
1555
|
// In ask-back flows this usually points to identity confusion between requester/responder or
|
|
1556
|
-
// canonical reply-tool delivery versus
|
|
1556
|
+
// canonical reply-tool delivery versus another mistaken write path. We fail fast here so the
|
|
1557
1557
|
// second writer keeps its own stack trace instead of silently corrupting the dialog transcript.
|
|
1558
1558
|
const err = new Error(`${args.kind} duplicate callId invariant violation: rootId=${args.dialog.id.rootId} selfId=${args.dialog.id.selfId} ` +
|
|
1559
1559
|
`callId=${args.callId} callName=${args.callName} existingCourse=${args.existingCourse} ` +
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DialogInterruptionReason } from '@longrun-ai/kernel/types/display-state';
|
|
2
|
+
export declare function buildUserInterjectionPauseStopReason(): Extract<DialogInterruptionReason, {
|
|
3
|
+
kind: 'system_stop';
|
|
4
|
+
}>;
|
|
5
|
+
export declare function isUserInterjectionPauseStopReason(reason: DialogInterruptionReason | undefined): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildUserInterjectionPauseStopReason = buildUserInterjectionPauseStopReason;
|
|
4
|
+
exports.isUserInterjectionPauseStopReason = isUserInterjectionPauseStopReason;
|
|
5
|
+
const USER_INTERJECTION_PAUSE_STOP_DETAIL = 'user_interjection_pause_resume_original_task';
|
|
6
|
+
// WARNING:
|
|
7
|
+
// This special stop reason is only a UI/run-control projection for "user interjected, and there
|
|
8
|
+
// is still an original task parked for explicit Continue". It is intentionally encoded as
|
|
9
|
+
// `system_stop`, but it does NOT mean the same thing as ordinary system-stop failure semantics.
|
|
10
|
+
//
|
|
11
|
+
// Not every user interjection should use this reason. If there is no parked original task to
|
|
12
|
+
// resume afterwards, the interjection should simply complete and the dialog should fall back to
|
|
13
|
+
// its true underlying state without showing this stopped panel.
|
|
14
|
+
//
|
|
15
|
+
// Do not change this file in isolation. The complete behavior depends on coordinated logic across:
|
|
16
|
+
// - `reply-guidance.ts` suppressing upstream reply obligation during interjection chat
|
|
17
|
+
// - `flow.ts` parking after the local reply, then re-running fresh-fact resume
|
|
18
|
+
// - `dialog-display-state.ts` preserving this paused projection until explicit Continue
|
|
19
|
+
// - `websocket-handler.ts` treating Continue as "resume attempt" rather than immediate success
|
|
20
|
+
//
|
|
21
|
+
// Reading only this stop reason or only `displayState.kind === 'stopped'` gives an incomplete and
|
|
22
|
+
// often wrong mental model.
|
|
23
|
+
function buildUserInterjectionPauseStopReason() {
|
|
24
|
+
return {
|
|
25
|
+
kind: 'system_stop',
|
|
26
|
+
detail: USER_INTERJECTION_PAUSE_STOP_DETAIL,
|
|
27
|
+
i18nStopReason: {
|
|
28
|
+
zh: '插话已处理;原任务已暂停。点击“继续”恢复原任务,继续发送新消息则继续这段临时对话。',
|
|
29
|
+
en: 'Interjection handled; the original task is paused. Click Continue to resume it, or send another message to keep this temporary side conversation going.',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function isUserInterjectionPauseStopReason(reason) {
|
|
34
|
+
return reason?.kind === 'system_stop' && reason.detail === USER_INTERJECTION_PAUSE_STOP_DETAIL;
|
|
35
|
+
}
|
|
@@ -102,6 +102,15 @@ function emitRunControlRefresh(reason) {
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
function buildResumeIneligibleMessage(latest) {
|
|
105
|
+
// WARNING:
|
|
106
|
+
// `resume_dialog` eligibility is intentionally based on the freshly healed projection, not on a
|
|
107
|
+
// naive local check of raw blocker facts. In particular, the paused-interjection stopped state
|
|
108
|
+
// must remain resumable here so the user can explicitly press Continue even while the underlying
|
|
109
|
+
// dialog may still be blocked.
|
|
110
|
+
//
|
|
111
|
+
// The actual outcome of that Continue attempt is decided later in `flow.ts` from fresh facts:
|
|
112
|
+
// it may restore `blocked`, or it may immediately continue driving. Do not reinterpret a
|
|
113
|
+
// resumable stopped state here as "guaranteed to run now".
|
|
105
114
|
const state = latest?.displayState;
|
|
106
115
|
if (!state) {
|
|
107
116
|
return {
|
|
@@ -1288,6 +1297,11 @@ async function handleResumeDialog(ws, packet) {
|
|
|
1288
1297
|
}
|
|
1289
1298
|
const dialogIdObj = new dialog_1.DialogID(dialog.selfId, dialog.rootId);
|
|
1290
1299
|
const latest = await (0, dialog_display_state_1.refreshRunControlProjectionFromPersistenceFacts)(dialogIdObj, 'resume_dialog');
|
|
1300
|
+
// WARNING:
|
|
1301
|
+
// Passing this gate only means "a manual Continue attempt is allowed". It does not mean the
|
|
1302
|
+
// dialog is guaranteed to re-enter proceeding immediately. For the paused-interjection flow, the
|
|
1303
|
+
// resumed drive itself performs a second fresh-fact decision and may land in true `blocked`
|
|
1304
|
+
// instead of proceeding.
|
|
1291
1305
|
if (!(0, dialog_display_state_1.isDialogLatestResumable)(latest)) {
|
|
1292
1306
|
const ineligible = buildResumeIneligibleMessage(latest);
|
|
1293
1307
|
log.warn('resume_dialog rejected after fresh fact scan', undefined, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dominds",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.8",
|
|
4
4
|
"description": "Dominds CLI and aggregation shell for the LongRun AI kernel/runtime packages.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"publishConfig": {
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"ws": "^8.19.0",
|
|
53
53
|
"yaml": "^2.8.2",
|
|
54
54
|
"zod": "^4.3.6",
|
|
55
|
-
"@longrun-ai/shell": "1.8.13",
|
|
56
55
|
"@longrun-ai/kernel": "1.8.13",
|
|
56
|
+
"@longrun-ai/shell": "1.8.13",
|
|
57
57
|
"@longrun-ai/codex-auth": "0.12.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|