@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.
@@ -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
- rejected = false;
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(this.pending + text, output);
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.state = "outside";
124
- this.block = "";
132
+ this.resetBlock();
125
133
  return [];
126
134
  }
127
- /** 整轮里最后一个完整闭合的标签块(已 trim);没有闭合过任何块则为 null。 */
128
- get lastClosedBlock() {
129
- return this.rejected ? null : this.closed;
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
- const body = this.pending.slice(1, -1);
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 === "suspicious" || tag.kind === "known") {
184
- this.rejected = true;
185
- this.block = "";
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.state === "inside") {
189
- appendInside(this.pending);
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.state === "inside") {
198
- appendInside(character);
224
+ else if (this.depth > 0) {
225
+ this.block += character;
199
226
  }
200
227
  }
201
228
  }
@@ -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 { extractFinalText, isIncrementalTextEvent, extractRunMeta, normalizeEvent, parseLine, } from "./normalize.js";
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 { decodeExternalOutputEvent, extractExternalAnswer, ExternalAnswerDecoder, stripExternalAnswerMarkers, } from "./external-output.js";
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
- // 契约答案解码器:除了给 answerStream 实时转发,还记住整轮最后一个**闭合**的标签块。
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 extracted = extractFinalText(event);
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 = runtime.name === "pi"
973
- ? externalOutput.lastClosedBlock
974
- : externalOutput.lastClosedBlock
975
- ?? (finalText === null ? null : extractExternalAnswer(finalText));
976
- const reportFinalText = runtime.name === "pi" && externalAnswer === null
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.104",
3
+ "version": "0.6.106",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",