@nowcrew/daemon 0.6.104 → 0.6.106
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/external-output.js +96 -69
- package/dist/local-executor.js +10 -22
- package/dist/runtime-answer-collector.js +184 -0
- package/package.json +1 -1
package/dist/external-output.js
CHANGED
|
@@ -42,6 +42,16 @@ function classifyMarkerTag(body) {
|
|
|
42
42
|
kind: resemblesExternalAnswerTag(name) ? "suspicious" : "other",
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
function isUnfinishedAnswerTag(value) {
|
|
46
|
+
const body = value.slice(1).trim().replace(/^\//u, "");
|
|
47
|
+
if (classifyMarkerTag(body).kind !== "other")
|
|
48
|
+
return true;
|
|
49
|
+
const name = body.toLowerCase().replace(/[_\-\s]/gu, "").replace(/^now+ork/u, "nowork");
|
|
50
|
+
// A bare '<' or ordinary comparison is not a protocol replacement. Wait until
|
|
51
|
+
// the prefix identifies the external-answer namespace before rejecting it.
|
|
52
|
+
return name.length >= "noworkext".length
|
|
53
|
+
&& [CANONICAL_EXTERNAL_ANSWER_TAG, "nowwebexternalanswerplaceholder"].some(tag => tag.startsWith(name));
|
|
54
|
+
}
|
|
45
55
|
function nextMarkerTag(value, from) {
|
|
46
56
|
let cursor = from;
|
|
47
57
|
while (cursor < value.length) {
|
|
@@ -97,96 +107,113 @@ function parseExternalAnswerText(value) {
|
|
|
97
107
|
return { kind: "plain", text: value.trim() };
|
|
98
108
|
return { kind: "marked", text: sections.join("").trim() };
|
|
99
109
|
}
|
|
100
|
-
/**
|
|
101
|
-
* 跨 chunk 的契约标签解码器:push() 在合法闭合后返回完整标签块(供 answerStream 转发);
|
|
102
|
-
* 同时记住整轮里**最后一个完整闭合**的标签块(`lastClosedBlock`),供完结时作为契约答案——
|
|
103
|
-
* 只有闭合了的块才算数:半截块(被工具调用拆开的前半段)与叙述里提到的开标签
|
|
104
|
-
* ("我会把结论包在 <marker> 里,先去跑测试")都不会被当成答案(review of a027bf15, C1)。
|
|
105
|
-
* 所有 runtime 的文本都经此解码器(local-executor 事件循环),因此"后到者胜"在
|
|
106
|
-
* 覆盖式与增量式 runtime 上语义一致。
|
|
107
|
-
*/
|
|
108
110
|
export class ExternalAnswerDecoder {
|
|
109
|
-
state = "outside";
|
|
110
111
|
pending = "";
|
|
111
112
|
block = "";
|
|
113
|
+
depth = 0;
|
|
114
|
+
opening = null;
|
|
115
|
+
invalid = null;
|
|
112
116
|
closed = null;
|
|
113
|
-
|
|
117
|
+
ambiguous = false;
|
|
118
|
+
revision = 0;
|
|
119
|
+
rejection = null;
|
|
114
120
|
push(text) {
|
|
115
|
-
if (!text)
|
|
116
|
-
return [];
|
|
117
121
|
const output = [];
|
|
118
|
-
this.consume(
|
|
122
|
+
this.consume(text, output);
|
|
119
123
|
return output;
|
|
120
124
|
}
|
|
125
|
+
/** 消息/轮次边界不允许把未闭合的新答案接到下一条消息里。 */
|
|
121
126
|
finish() {
|
|
127
|
+
if (this.depth > 0)
|
|
128
|
+
this.reject(this.invalid ?? "incomplete_block");
|
|
129
|
+
else if (this.pending && isUnfinishedAnswerTag(this.pending))
|
|
130
|
+
this.reject("incomplete_block");
|
|
122
131
|
this.pending = "";
|
|
123
|
-
this.
|
|
124
|
-
this.block = "";
|
|
132
|
+
this.resetBlock();
|
|
125
133
|
return [];
|
|
126
134
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
get lastClosedBlock() { return this.ambiguous ? null : this.closed; }
|
|
136
|
+
/** 保留诊断候选,不代表它仍可交付。 */
|
|
137
|
+
get lastValidClosedBlock() { return this.closed; }
|
|
138
|
+
get selectionRevision() { return this.revision; }
|
|
139
|
+
get isAmbiguous() { return this.ambiguous; }
|
|
140
|
+
get rejectionReason() { return this.rejection; }
|
|
141
|
+
reject(reason) {
|
|
142
|
+
this.ambiguous = true;
|
|
143
|
+
this.rejection = reason;
|
|
144
|
+
this.revision += 1;
|
|
145
|
+
}
|
|
146
|
+
resetBlock() {
|
|
147
|
+
this.block = "";
|
|
148
|
+
this.depth = 0;
|
|
149
|
+
this.opening = null;
|
|
150
|
+
this.invalid = null;
|
|
151
|
+
}
|
|
152
|
+
marker(body, output) {
|
|
153
|
+
const tag = classifyMarkerTag(body);
|
|
154
|
+
if (tag.kind === "other") {
|
|
155
|
+
if (this.depth > 0)
|
|
156
|
+
this.block += `<${body}>`;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const name = body.trim().replace(/^\//u, "").trim().toLowerCase();
|
|
160
|
+
if (!tag.closing) {
|
|
161
|
+
if (this.depth === 0) {
|
|
162
|
+
this.opening = { kind: tag.kind, name };
|
|
163
|
+
this.invalid = tag.kind === "suspicious" ? "malformed_marker" : null;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
this.invalid = "nested_marker";
|
|
167
|
+
}
|
|
168
|
+
this.depth += 1;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (this.depth === 0) {
|
|
172
|
+
this.reject("unexpected_close");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (tag.kind === "suspicious" && this.opening?.kind === "known") {
|
|
176
|
+
this.invalid = "malformed_marker";
|
|
177
|
+
}
|
|
178
|
+
this.depth -= 1;
|
|
179
|
+
if (this.depth > 0)
|
|
180
|
+
return;
|
|
181
|
+
const completed = this.block.trim();
|
|
182
|
+
// 仅精确的空占位块是无内容的尾声。带正文、错配或嵌套的替换仍须拒绝旧答案。
|
|
183
|
+
const emptyPlaceholder = this.invalid === "malformed_marker"
|
|
184
|
+
&& completed === ""
|
|
185
|
+
&& this.opening?.name === "nowweb_external_answer_placeholder"
|
|
186
|
+
&& name === this.opening.name;
|
|
187
|
+
if (emptyPlaceholder) {
|
|
188
|
+
this.rejection = "empty_placeholder";
|
|
189
|
+
}
|
|
190
|
+
else if (this.invalid !== null) {
|
|
191
|
+
this.reject(this.invalid);
|
|
192
|
+
}
|
|
193
|
+
else if (completed) {
|
|
194
|
+
this.closed = completed;
|
|
195
|
+
this.ambiguous = false;
|
|
196
|
+
this.revision += 1;
|
|
197
|
+
output.push(completed);
|
|
198
|
+
}
|
|
199
|
+
this.resetBlock();
|
|
130
200
|
}
|
|
131
201
|
consume(value, output) {
|
|
132
|
-
this.pending = "";
|
|
133
|
-
const appendInside = (text) => {
|
|
134
|
-
if (!text)
|
|
135
|
-
return;
|
|
136
|
-
this.block += text;
|
|
137
|
-
};
|
|
138
202
|
for (const character of value) {
|
|
139
203
|
if (this.pending) {
|
|
140
204
|
this.pending += character;
|
|
141
205
|
if (character === ">") {
|
|
142
|
-
|
|
143
|
-
const tag = classifyMarkerTag(body);
|
|
144
|
-
if (tag.kind === "known" && body.length <= MAX_MARKER_TAG_LENGTH) {
|
|
145
|
-
if (tag.closing && this.state === "inside") {
|
|
146
|
-
const completed = this.block.trim();
|
|
147
|
-
if (completed) {
|
|
148
|
-
this.closed = completed;
|
|
149
|
-
output.push(completed);
|
|
150
|
-
}
|
|
151
|
-
this.block = "";
|
|
152
|
-
this.state = "outside";
|
|
153
|
-
}
|
|
154
|
-
else if (!tag.closing && this.state === "outside") {
|
|
155
|
-
this.block = "";
|
|
156
|
-
this.state = "inside";
|
|
157
|
-
}
|
|
158
|
-
else if (!tag.closing && this.state === "inside") {
|
|
159
|
-
// 嵌套答案标记说明模型正在讨论协议或混入了思考文本;
|
|
160
|
-
// 不要把内层短片段误当成最终答案。
|
|
161
|
-
this.rejected = true;
|
|
162
|
-
this.block = "";
|
|
163
|
-
this.state = "outside";
|
|
164
|
-
}
|
|
165
|
-
else {
|
|
166
|
-
this.rejected = true;
|
|
167
|
-
this.block = "";
|
|
168
|
-
this.state = tag.closing ? "outside" : "inside";
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
else if (tag.kind === "suspicious" || tag.kind === "known") {
|
|
172
|
-
this.rejected = true;
|
|
173
|
-
this.block = "";
|
|
174
|
-
this.state = "outside";
|
|
175
|
-
}
|
|
176
|
-
else if (this.state === "inside") {
|
|
177
|
-
appendInside(this.pending);
|
|
178
|
-
}
|
|
206
|
+
this.marker(this.pending.slice(1, -1), output);
|
|
179
207
|
this.pending = "";
|
|
180
208
|
}
|
|
181
209
|
else if (this.pending.length > MAX_MARKER_TAG_LENGTH + 2) {
|
|
182
210
|
const tag = classifyMarkerTag(this.pending.slice(1));
|
|
183
|
-
if (tag.kind
|
|
184
|
-
this.
|
|
185
|
-
this.
|
|
186
|
-
this.state = "outside";
|
|
211
|
+
if (tag.kind !== "other") {
|
|
212
|
+
this.reject("overlong_marker");
|
|
213
|
+
this.invalid = "overlong_marker";
|
|
187
214
|
}
|
|
188
|
-
else if (this.
|
|
189
|
-
|
|
215
|
+
else if (this.depth > 0) {
|
|
216
|
+
this.block += this.pending;
|
|
190
217
|
}
|
|
191
218
|
this.pending = "";
|
|
192
219
|
}
|
|
@@ -194,8 +221,8 @@ export class ExternalAnswerDecoder {
|
|
|
194
221
|
else if (character === "<") {
|
|
195
222
|
this.pending = character;
|
|
196
223
|
}
|
|
197
|
-
else if (this.
|
|
198
|
-
|
|
224
|
+
else if (this.depth > 0) {
|
|
225
|
+
this.block += character;
|
|
199
226
|
}
|
|
200
227
|
}
|
|
201
228
|
}
|
package/dist/local-executor.js
CHANGED
|
@@ -14,7 +14,7 @@ import { spawnKimi } from "./runtimes/kimi.js";
|
|
|
14
14
|
import { applyDeepSeekHarnessMachineEnv, applyProviderEnv, providerFingerprint, } from "./provider-env.js";
|
|
15
15
|
import { deliverCloudReports, prepareCloudReportExecution } from "./cloud-report-uploader.js";
|
|
16
16
|
import { reportDeliveryContext } from "./cloud-report-receipt.js";
|
|
17
|
-
import {
|
|
17
|
+
import { extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
|
|
18
18
|
import { readSession, writeSession, pickResumeId, isNearBudget } from "./session.js";
|
|
19
19
|
import { NativeThreadContext, isolateMaintenanceSession } from "./native-thread-context.js";
|
|
20
20
|
import { prepareThreadDelivery } from "./thread-input-delivery.js";
|
|
@@ -25,7 +25,8 @@ import { providerErrorFromExit } from "./provider-error.js";
|
|
|
25
25
|
import { createConsoleFormatter } from "./console-formatter.js";
|
|
26
26
|
import { withLocalExecutionFacts, } from "./local-execution-prompt.js";
|
|
27
27
|
export { withLocalExecutionFacts, } from "./local-execution-prompt.js";
|
|
28
|
-
import {
|
|
28
|
+
import { stripExternalAnswerMarkers } from "./external-output.js";
|
|
29
|
+
import { RuntimeAnswerCollector } from "./runtime-answer-collector.js";
|
|
29
30
|
import { cleanupMaterializedAttachments as cleanupAttachments, executionAttachmentDirectory, materializeAttachments, } from "./attachments.js";
|
|
30
31
|
import { routeRuntimeAttachments, runtimeCapability, } from "./runtime-capabilities.js";
|
|
31
32
|
import { awaitWithCancellation, RuntimeCancelledError, } from "./runtime-cancellation.js";
|
|
@@ -691,14 +692,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
691
692
|
let sessionId = launchSessionId;
|
|
692
693
|
let usage;
|
|
693
694
|
let observedModel = currentModel;
|
|
694
|
-
let finalText = null;
|
|
695
695
|
let sentViaCrew = false;
|
|
696
|
-
|
|
697
|
-
// finalText 只保留"最后一条"助手消息,模型打完 <nowwork_external_answer> 标签后若又干了
|
|
698
|
-
// 别的(如顺手 git commit),收尾消息会覆盖 finalText,只从 finalText 提取就把已履约的
|
|
699
|
-
// 结论整段丢掉——server 回落 finalText 后频道里只剩无标签的尾声
|
|
700
|
-
// (incident: 2026-09-02, boss-alarm 排查结论未投递)。所有 runtime 的文本都喂给它。
|
|
701
|
-
const externalOutput = new ExternalAnswerDecoder();
|
|
696
|
+
const answers = new RuntimeAnswerCollector(runtime.name);
|
|
702
697
|
// 每轮独立:tool_use/result 关联状态不能跨 execution 泄漏。
|
|
703
698
|
const consoleFormatter = createConsoleFormatter(runtime.name);
|
|
704
699
|
let runtimeReady = false;
|
|
@@ -816,13 +811,8 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
816
811
|
activities.push(activity);
|
|
817
812
|
callbacks.onActivity?.(activity);
|
|
818
813
|
}
|
|
819
|
-
const
|
|
820
|
-
if (extracted) {
|
|
821
|
-
finalText = isIncrementalTextEvent(event) ? `${finalText ?? ""}${extracted}` : extracted;
|
|
822
|
-
}
|
|
823
|
-
for (const text of decodeExternalOutputEvent(runtime.name, event, externalOutput)) {
|
|
814
|
+
for (const text of answers.observe(event))
|
|
824
815
|
callbacks.onExternalOutput?.(text);
|
|
825
|
-
}
|
|
826
816
|
for (const chunk of consoleFormatter.format(event)) {
|
|
827
817
|
callbacks.onConsole?.(chunk);
|
|
828
818
|
}
|
|
@@ -969,13 +959,11 @@ async function executeLocalUnlocked(input, callbacks, dependencies) {
|
|
|
969
959
|
await writeFile(legacyCwdMarker, "1", "utf8");
|
|
970
960
|
}
|
|
971
961
|
}
|
|
972
|
-
const externalAnswer =
|
|
973
|
-
|
|
974
|
-
:
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
? null
|
|
978
|
-
: finalText;
|
|
962
|
+
const { finalText: reportFinalText, externalAnswer, diagnostics } = answers.finish();
|
|
963
|
+
dslog("execution.answer_extracted", "执行答案提取已完成", {
|
|
964
|
+
execution_id: input.executionId, runtime: runtime.name, capture_final: input.captureFinal,
|
|
965
|
+
...diagnostics,
|
|
966
|
+
});
|
|
979
967
|
const cloudReportResult = exitCode === 0
|
|
980
968
|
? await deliverCloudReports({
|
|
981
969
|
facts: cloudReportUpload, ...reportRoots, before: reportSnapshot, finalText: reportFinalText,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { decodeExternalOutputEvent, extractExternalAnswer, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
|
|
3
|
+
import { extractFinalText, isIncrementalTextEvent, normalizeEvent } from "./normalize.js";
|
|
4
|
+
const TOOL_BOUNDARIES = new Set(["tool", "tool_result", "reading", "sending", "claiming", "checking", "crew"]);
|
|
5
|
+
/** 每次短命 execution 的文本归一化;不调度重试、不保存跨轮状态、不读取工具输出。 */
|
|
6
|
+
export class RuntimeAnswerCollector {
|
|
7
|
+
runtime;
|
|
8
|
+
stream = new ExternalAnswerDecoder();
|
|
9
|
+
streamRevision = 0;
|
|
10
|
+
rawFinalText = null;
|
|
11
|
+
startNewTextSegment = false;
|
|
12
|
+
selected = null;
|
|
13
|
+
source = "none";
|
|
14
|
+
blocked = false;
|
|
15
|
+
allowUnclosedFinal = false;
|
|
16
|
+
incompleteFinalCandidate = null;
|
|
17
|
+
resultSeen = false;
|
|
18
|
+
snapshotCount = 0;
|
|
19
|
+
rawResultLength = 0;
|
|
20
|
+
snapshotTextLength = 0;
|
|
21
|
+
seenSnapshots = new Set();
|
|
22
|
+
lastSnapshot = null;
|
|
23
|
+
rejection = null;
|
|
24
|
+
constructor(runtime) {
|
|
25
|
+
this.runtime = runtime;
|
|
26
|
+
}
|
|
27
|
+
observe(value) {
|
|
28
|
+
const event = (value ?? {});
|
|
29
|
+
// 主进程 stdout 同时携带 Task 子 agent 事件,它们不能覆盖主会话结果。
|
|
30
|
+
if (this.runtime === "claude" && event.parent_tool_use_id != null)
|
|
31
|
+
return [];
|
|
32
|
+
if (normalizeEvent(value).some(activity => TOOL_BOUNDARIES.has(activity.kind))) {
|
|
33
|
+
this.finishStreamMessage();
|
|
34
|
+
this.allowUnclosedFinal = false;
|
|
35
|
+
this.startNewTextSegment = true;
|
|
36
|
+
}
|
|
37
|
+
if (this.runtime === "claude" && event.type === "stream_event"
|
|
38
|
+
&& (event.event?.type === "message_start" || event.event?.type === "message_stop"
|
|
39
|
+
|| event.event?.type === "content_block_stop")) {
|
|
40
|
+
this.finishStreamMessage();
|
|
41
|
+
}
|
|
42
|
+
if (this.runtime === "claude" && event.type === "assistant") {
|
|
43
|
+
this.observeSnapshot(event);
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const completedText = event.type === "result"
|
|
47
|
+
|| (event.type === "item.completed" && event.item?.type === "agent_message")
|
|
48
|
+
|| (event.type === undefined && event.role === "assistant");
|
|
49
|
+
if (completedText)
|
|
50
|
+
this.finishStreamMessage();
|
|
51
|
+
if (event.type === "result") {
|
|
52
|
+
this.resultSeen = true;
|
|
53
|
+
this.rawResultLength = typeof event.result === "string" ? event.result.length : 0;
|
|
54
|
+
}
|
|
55
|
+
const extracted = extractFinalText(value);
|
|
56
|
+
if (event.event?.delta?.type === "text_delta")
|
|
57
|
+
this.allowUnclosedFinal = false;
|
|
58
|
+
if (extracted) {
|
|
59
|
+
this.allowUnclosedFinal = completedText;
|
|
60
|
+
this.rawFinalText = isIncrementalTextEvent(value) && !this.startNewTextSegment
|
|
61
|
+
? `${this.rawFinalText ?? ""}${extracted}` : extracted;
|
|
62
|
+
this.startNewTextSegment = false;
|
|
63
|
+
}
|
|
64
|
+
const output = decodeExternalOutputEvent(this.runtime, value, this.stream);
|
|
65
|
+
this.syncStream();
|
|
66
|
+
if (completedText && extracted) {
|
|
67
|
+
this.finishStreamMessage();
|
|
68
|
+
this.observeFinalResult(extracted);
|
|
69
|
+
}
|
|
70
|
+
return output;
|
|
71
|
+
}
|
|
72
|
+
finish() {
|
|
73
|
+
this.finishStreamMessage();
|
|
74
|
+
const fallback = this.rawFinalText === null ? null : extractExternalAnswer(this.rawFinalText);
|
|
75
|
+
// 只在最后一条完整 final 事件上沿用未闭合尾标记兼容,过程中的开标签不成候选。
|
|
76
|
+
if (this.blocked && this.allowUnclosedFinal && this.rejection === "incomplete_block"
|
|
77
|
+
&& this.incompleteFinalCandidate !== null && this.runtime !== "pi") {
|
|
78
|
+
this.selected = this.incompleteFinalCandidate;
|
|
79
|
+
this.source = "final_result";
|
|
80
|
+
this.blocked = false;
|
|
81
|
+
}
|
|
82
|
+
const externalAnswer = this.blocked ? null
|
|
83
|
+
: this.selected ?? (this.runtime === "pi" ? null : fallback);
|
|
84
|
+
const finalText = this.blocked || (this.runtime === "pi" && externalAnswer === null)
|
|
85
|
+
? null : this.rawFinalText;
|
|
86
|
+
return {
|
|
87
|
+
finalText,
|
|
88
|
+
externalAnswer,
|
|
89
|
+
diagnostics: {
|
|
90
|
+
result_seen: this.resultSeen,
|
|
91
|
+
assistant_snapshot_count: this.snapshotCount,
|
|
92
|
+
assistant_snapshot_text_length: this.snapshotTextLength,
|
|
93
|
+
raw_result_length: this.rawResultLength,
|
|
94
|
+
raw_final_text_length: this.rawFinalText?.length ?? 0,
|
|
95
|
+
final_text_length: finalText === null ? 0 : stripExternalAnswerMarkers(finalText).length,
|
|
96
|
+
external_answer_length: externalAnswer?.length ?? 0,
|
|
97
|
+
retained_candidate_length: this.stream.lastValidClosedBlock?.length ?? 0,
|
|
98
|
+
answer_source: externalAnswer ? this.source
|
|
99
|
+
: finalText !== null && stripExternalAnswerMarkers(finalText) ? "final_result" : "none",
|
|
100
|
+
answer_rejection_reason: this.rejection ?? this.stream.rejectionReason,
|
|
101
|
+
answer_ambiguous: this.blocked,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
finishStreamMessage() {
|
|
106
|
+
this.stream.finish();
|
|
107
|
+
this.syncStream();
|
|
108
|
+
}
|
|
109
|
+
syncStream() {
|
|
110
|
+
if (this.stream.selectionRevision === this.streamRevision)
|
|
111
|
+
return;
|
|
112
|
+
this.streamRevision = this.stream.selectionRevision;
|
|
113
|
+
this.applyCandidate(this.stream, "stream");
|
|
114
|
+
}
|
|
115
|
+
applyCandidate(decoder, source) {
|
|
116
|
+
if (decoder.rejectionReason !== null)
|
|
117
|
+
this.rejection = decoder.rejectionReason;
|
|
118
|
+
if (decoder.isAmbiguous) {
|
|
119
|
+
this.selected = null;
|
|
120
|
+
this.source = "none";
|
|
121
|
+
this.blocked = true;
|
|
122
|
+
}
|
|
123
|
+
else if (decoder.lastClosedBlock !== null) {
|
|
124
|
+
this.selected = decoder.lastClosedBlock;
|
|
125
|
+
this.source = source;
|
|
126
|
+
this.blocked = false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
observeSnapshot(event) {
|
|
130
|
+
this.allowUnclosedFinal = false;
|
|
131
|
+
const blocks = event.message?.content;
|
|
132
|
+
if (!Array.isArray(blocks)) {
|
|
133
|
+
this.finishStreamMessage();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const texts = blocks.flatMap((block) => block.type === "text" && typeof block.text === "string"
|
|
137
|
+
? [block.text] : []);
|
|
138
|
+
if (texts.length === 0) {
|
|
139
|
+
this.finishStreamMessage();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// 快照只在完成时做权威候选,不再次喂入流式解码器或再次发送 text_delta。
|
|
143
|
+
const text = JSON.stringify(texts);
|
|
144
|
+
if (this.lastSnapshot?.id === event.message?.id && this.lastSnapshot?.text === text)
|
|
145
|
+
return;
|
|
146
|
+
if (event.message?.id) {
|
|
147
|
+
const key = `${event.message.id}:${createHash("sha256").update(text).digest("hex")}`;
|
|
148
|
+
if (this.seenSnapshots.has(key))
|
|
149
|
+
return;
|
|
150
|
+
this.seenSnapshots.add(key);
|
|
151
|
+
if (this.seenSnapshots.size > 128)
|
|
152
|
+
this.seenSnapshots.delete(this.seenSnapshots.values().next().value);
|
|
153
|
+
}
|
|
154
|
+
this.lastSnapshot = { id: event.message?.id, text };
|
|
155
|
+
this.snapshotCount += 1;
|
|
156
|
+
this.snapshotTextLength += texts.reduce((sum, item) => sum + item.length, 0);
|
|
157
|
+
this.finishStreamMessage();
|
|
158
|
+
for (const item of texts) {
|
|
159
|
+
const decoder = new ExternalAnswerDecoder();
|
|
160
|
+
decoder.push(item);
|
|
161
|
+
decoder.finish();
|
|
162
|
+
this.applyCandidate(decoder, "assistant_snapshot");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
observeFinalResult(text) {
|
|
166
|
+
const decoder = new ExternalAnswerDecoder();
|
|
167
|
+
decoder.push(text);
|
|
168
|
+
decoder.finish();
|
|
169
|
+
// 兼容单一 final 块的尾标记截断;已有旧答案后又开始替换时不能拼旧结论回退。
|
|
170
|
+
this.incompleteFinalCandidate = decoder.rejectionReason === "incomplete_block"
|
|
171
|
+
&& decoder.lastValidClosedBlock === null ? extractExternalAnswer(text) : null;
|
|
172
|
+
if (decoder.lastClosedBlock !== null) {
|
|
173
|
+
this.selected = decoder.lastClosedBlock;
|
|
174
|
+
this.source = "final_result";
|
|
175
|
+
this.blocked = false;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.applyCandidate(decoder, "final_result");
|
|
179
|
+
if (!decoder.isAmbiguous && stripExternalAnswerMarkers(text)) {
|
|
180
|
+
// 权威 result 的普通正文可独立交付,不能回退到已被后续输出否定的旧候选。
|
|
181
|
+
this.blocked = false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|