@yeaft/webchat-agent 1.0.325 → 1.0.326

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.
@@ -1 +1 @@
1
- {"version":"1.0.325"}
1
+ {"version":"1.0.326"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.325",
3
+ "version": "1.0.326",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -365,7 +365,7 @@ export function shouldAllowGroupReflection({
365
365
  * @typedef {{ type: 'turn_start', turnNumber: number }} TurnStartEvent
366
366
  * @typedef {{ type: 'turn_end', turnNumber: number, stopReason: string, terminal?: boolean }} TurnEndEvent
367
367
  * @typedef {{ type: 'tool_start', id: string, name: string, input: object }} ToolStartEvent
368
- * @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean }} ToolEndEvent
368
+ * @typedef {{ type: 'tool_end', id: string, name: string, output: string, isError: boolean, skipped?: boolean }} ToolEndEvent
369
369
  * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
370
370
  * @typedef {{ type: 'recall', entryCount: number, cached: boolean }} RecallEvent
371
371
  * @typedef {{ type: 'fallback', from: string, to: string, reason: string }} FallbackEvent
@@ -1339,6 +1339,7 @@ export class Engine {
1339
1339
  // can mark "after this batch, end the turn — do NOT call adapter
1340
1340
  // again". Honored at the top of the tool-loop continuation.
1341
1341
  requestEndTurn: vpCtx?.requestEndTurn,
1342
+ requestToolBatchBarrier: vpCtx?.requestToolBatchBarrier,
1342
1343
  // Result-producing async-task ownership hook. Tools such as SpawnAgent
1343
1344
  // call this with the new `task.id` so the engine keeps the current query
1344
1345
  // parked at end_turn until the result arrives. Persistent background
@@ -3636,6 +3637,8 @@ export class Engine {
3636
3637
  }
3637
3638
 
3638
3639
  // Execute tool calls and feed results back
3640
+ /** @type {{ kind?: string, message?: string, sourceToolCallId?: string, sourceToolName?: string } | null} */
3641
+ let toolBatchBarrier = null;
3639
3642
  let currentToolCallForAsyncTask = null;
3640
3643
  // task-707: requestEndTurn is a per-batch closure that lets a tool
3641
3644
  // signal "end this turn after the current batch — no adapter retry".
@@ -3667,6 +3670,17 @@ export class Engine {
3667
3670
  endTurnRequested = reason || { kind: 'tool_handoff' };
3668
3671
  }
3669
3672
  },
3673
+ requestToolBatchBarrier: (reason) => {
3674
+ if (toolBatchBarrier != null) return;
3675
+ const detail = reason && typeof reason === 'object'
3676
+ ? { ...reason }
3677
+ : { message: String(reason || 'A preceding tool result invalidated the remaining batch.') };
3678
+ toolBatchBarrier = {
3679
+ ...detail,
3680
+ sourceToolCallId: currentToolCallForAsyncTask?.id || null,
3681
+ sourceToolName: currentToolCallForAsyncTask?.name || null,
3682
+ };
3683
+ },
3670
3684
  });
3671
3685
 
3672
3686
  // task-325a: track whether we aborted mid tool-loop so we can
@@ -3681,11 +3695,14 @@ export class Engine {
3681
3695
  // that's already running (the signal is passed in, tools decide
3682
3696
  // themselves whether to bail early), but we stop dispatching
3683
3697
  // any remaining tools the moment abort fires.
3684
- if (signal?.aborted) {
3698
+ if (signal?.aborted && !toolBatchBarrier) {
3685
3699
  abortedDuringTools = true;
3686
3700
  break;
3687
3701
  }
3702
+ if (signal?.aborted) abortedDuringTools = true;
3688
3703
 
3704
+ const activeToolBatchBarrier = toolBatchBarrier;
3705
+ const skipped = activeToolBatchBarrier != null;
3689
3706
  const toolStartTime = Date.now();
3690
3707
 
3691
3708
  // PR-L: duplicate-call detection. If this exact (toolName,
@@ -3696,25 +3713,27 @@ export class Engine {
3696
3713
  // assistant(tool_use) → user(tool_result, …) pairing demanded
3697
3714
  // by the Anthropic / OpenAI Responses APIs stays intact. We
3698
3715
  // don't block the call — the LLM still decides.
3699
- const dupHash = argsHashOf(tc.input);
3700
- // PR-L follow-up: lookback is by user-conversation turn
3701
- // (`queryNumber`), NOT by inner adapter loop iteration. Each call
3702
- // to query() bumps queryNumber once, so "last 2 turns" means the
3703
- // current user turn + the previous two user turns the natural
3704
- // semantic for "the model is stuck in a loop across the
3705
- // conversation."
3706
- const dupInfo = this.#execLog.dupInfo({
3707
- toolName: tc.name,
3708
- argsHash: dupHash,
3709
- currentTurn: queryNumber,
3710
- lookbackTurns: 2,
3711
- });
3712
- if (dupInfo.count + 1 >= DUP_TOOL_THRESHOLD) {
3713
- pendingDupReminders.push(buildDuplicateReminder({
3716
+ if (!skipped) {
3717
+ const dupHash = argsHashOf(tc.input);
3718
+ // PR-L follow-up: lookback is by user-conversation turn
3719
+ // (`queryNumber`), NOT by inner adapter loop iteration. Each call
3720
+ // to query() bumps queryNumber once, so "last 2 turns" means the
3721
+ // current user turn + the previous two user turns the natural
3722
+ // semantic for "the model is stuck in a loop across the
3723
+ // conversation."
3724
+ const dupInfo = this.#execLog.dupInfo({
3714
3725
  toolName: tc.name,
3715
- count: dupInfo.count + 1,
3716
- lastResultBrief: dupInfo.lastResultBrief,
3717
- }));
3726
+ argsHash: dupHash,
3727
+ currentTurn: queryNumber,
3728
+ lookbackTurns: 2,
3729
+ });
3730
+ if (dupInfo.count + 1 >= DUP_TOOL_THRESHOLD) {
3731
+ pendingDupReminders.push(buildDuplicateReminder({
3732
+ toolName: tc.name,
3733
+ count: dupInfo.count + 1,
3734
+ lastResultBrief: dupInfo.lastResultBrief,
3735
+ }));
3736
+ }
3718
3737
  }
3719
3738
 
3720
3739
  let output;
@@ -3722,18 +3741,40 @@ export class Engine {
3722
3741
  let isError = false;
3723
3742
  let toolErrorOutput = null;
3724
3743
  let fatalToolError = null;
3725
- currentToolCallForAsyncTask = {
3726
- id: tc.id,
3727
- name: tc.name,
3728
- threadId: runtimeThreadId,
3729
- };
3744
+ currentToolCallForAsyncTask = skipped
3745
+ ? null
3746
+ : {
3747
+ id: tc.id,
3748
+ name: tc.name,
3749
+ threadId: runtimeThreadId,
3750
+ };
3730
3751
 
3731
3752
  // Resolve tool: prefer ToolRegistry, fallback to legacy #tools Map
3732
3753
  const hasTool = this.#toolRegistry
3733
3754
  ? this.#toolRegistry.isAllowed(tc.name, { collabToolPolicy: effectiveCollabToolPolicy })
3734
3755
  : this.#tools.has(tc.name);
3735
3756
 
3736
- if (!hasTool) {
3757
+ if (skipped) {
3758
+ const source = activeToolBatchBarrier.sourceToolName || 'a preceding tool';
3759
+ const sourceId = activeToolBatchBarrier.sourceToolCallId
3760
+ ? ` (${activeToolBatchBarrier.sourceToolCallId})`
3761
+ : '';
3762
+ output = [
3763
+ `Skipped ${tc.name} because ${source}${sourceId} invalidated the remaining tool batch.`,
3764
+ activeToolBatchBarrier.message || 'Review the preceding tool result before deciding whether to retry this call.',
3765
+ 'This tool was not executed. Submit it again only after reviewing the preceding result.',
3766
+ ].join('\n');
3767
+ isError = true;
3768
+ yield {
3769
+ type: 'tool_end',
3770
+ id: tc.id,
3771
+ name: tc.name,
3772
+ output,
3773
+ isError: true,
3774
+ skipped: true,
3775
+ threadId: this.currentThreadId,
3776
+ };
3777
+ } else if (!hasTool) {
3737
3778
  output = `Error: unknown tool "${tc.name}"`;
3738
3779
  isError = true;
3739
3780
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
@@ -3816,13 +3857,14 @@ export class Engine {
3816
3857
  durationMs: toolDurationMs,
3817
3858
  isError,
3818
3859
  toolOutput: output,
3860
+ ...(skipped ? { skipped: true } : {}),
3819
3861
  ...(displayImages.length > 0 ? { displayImageCount: displayImages.length } : {}),
3820
3862
  };
3821
3863
 
3822
3864
  // 2026-05-13: feed the per-tool counters. Stays best-effort — a
3823
3865
  // stats sink that throws shouldn't crash the engine. `record`
3824
3866
  // already swallows internal write errors.
3825
- if (this.#toolStats && typeof this.#toolStats.record === 'function') {
3867
+ if (!skipped && this.#toolStats && typeof this.#toolStats.record === 'function') {
3826
3868
  try {
3827
3869
  this.#toolStats.record({
3828
3870
  name: tc.name,
@@ -3841,6 +3883,7 @@ export class Engine {
3841
3883
  toolOutput: output,
3842
3884
  durationMs: toolDurationMs,
3843
3885
  isError,
3886
+ skipped,
3844
3887
  });
3845
3888
 
3846
3889
  // Append only the bounded copy to the model message history. Raw
@@ -3876,14 +3919,16 @@ export class Engine {
3876
3919
  // user-conversation turn), not the inner loop's turnNumber.
3877
3920
  // Aligns exec-log layout with dup detection lookback and the
3878
3921
  // T2 fallback-stub readTurn() call below.
3879
- this.#execLog.append(queryNumber, buildExecLogEntry({
3880
- loopIdx: queryToolCount,
3881
- toolName: tc.name,
3882
- args: tc.input,
3883
- output,
3884
- isError,
3885
- }));
3886
- queryToolCount += 1;
3922
+ if (!skipped) {
3923
+ this.#execLog.append(queryNumber, buildExecLogEntry({
3924
+ loopIdx: queryToolCount,
3925
+ toolName: tc.name,
3926
+ args: tc.input,
3927
+ output,
3928
+ isError,
3929
+ }));
3930
+ queryToolCount += 1;
3931
+ }
3887
3932
  if (fatalToolError) throw fatalToolError;
3888
3933
  }
3889
3934
 
@@ -3895,6 +3940,11 @@ export class Engine {
3895
3940
  conversationMessages.push({ role: 'user', content: reminder });
3896
3941
  }
3897
3942
 
3943
+ // A batch barrier deliberately returns control to the provider. Any
3944
+ // handoff requested by an earlier call belongs to the invalidated plan
3945
+ // and must not leak into a later provider-generated batch.
3946
+ if (toolBatchBarrier) endTurnRequested = null;
3947
+
3898
3948
  // task-707: tool-callable end-turn signal. If a tool in this batch
3899
3949
  // called toolCtx.requestEndTurn(reason), break out of the outer
3900
3950
  // while-loop now — DON'T call adapter.stream() again. The
@@ -3907,7 +3957,7 @@ export class Engine {
3907
3957
  // collapse the arc into a summary that's only valuable across
3908
3958
  // multi-iteration tool loops) and BEFORE the abortedDuringTools
3909
3959
  // check (so a clean handoff doesn't get reported as 'aborted').
3910
- if (endTurnRequested) {
3960
+ if (endTurnRequested && !toolBatchBarrier) {
3911
3961
  if (pendingSubAgentNotifs.length > 0) {
3912
3962
  acknowledgePendingNotifications(notifScope, pendingSubAgentNotifs.map(n => n.id));
3913
3963
  }
@@ -3945,7 +3995,8 @@ export class Engine {
3945
3995
  // batch within the same query gets a distinct entry — without
3946
3996
  // this the second batch would be silently skipped.
3947
3997
  const t1BatchDue = queryToolCount - lastT1AtToolCount >= TOOL_BATCH_SIZE;
3948
- if (groupReflectionAllowed && t1BatchDue && !abortedDuringTools && !signal?.aborted) {
3998
+ if (groupReflectionAllowed && t1BatchDue && !toolBatchBarrier
3999
+ && !abortedDuringTools && !signal?.aborted) {
3949
4000
  const t1DedupKey = `${queryNumber}:t1:${queryToolCount}`;
3950
4001
  if (this.#reflectedTurns.has(t1DedupKey)) {
3951
4002
  // Defensive: should never hit since t1BatchDue gates re-entry
@@ -29,23 +29,18 @@ const DEFAULT_TIMEOUT_MS = 120_000;
29
29
 
30
30
  /** Max timeout in ms (10 minutes). */
31
31
  const MAX_TIMEOUT_MS = 600_000;
32
- /**
33
- * ToolRegistry must not preempt Bash's own process timeout. Bash terminates
34
- * the process tree and waits for close before returning exit 124; the grace
35
- * covers TERM/KILL escalation and the bounded close-confirmation window.
36
- */
37
- const REGISTRY_TIMEOUT_MS = MAX_TIMEOUT_MS + 15_000;
38
32
  const LINUX_NAMESPACE_HELPER = resolve(
39
33
  dirname(fileURLToPath(import.meta.url)),
40
34
  '..',
41
35
  'linux-process-namespace.js',
42
36
  );
37
+ const RUN_PROCESS_OVERRIDE = Symbol('runProcessOverride');
43
38
 
44
39
  /**
45
40
  * Run a command in a child process.
46
- * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
41
+ * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean, terminationError: string | null }>}
47
42
  */
48
- function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
43
+ function runCommand(command, { cwd, timeout, signal, runtimePlatform, runProcessImpl = runProcess }) {
49
44
  const platform = runtimePlatform || getRuntimePlatformInfo();
50
45
  const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
51
46
  const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
@@ -76,7 +71,7 @@ function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
76
71
  systemdControl: null,
77
72
  };
78
73
  }
79
- return runProcess(invocation.command, invocation.args, {
74
+ return runProcessImpl(invocation.command, invocation.args, {
80
75
  cwd,
81
76
  env,
82
77
  signal,
@@ -91,10 +86,11 @@ function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
91
86
  stderr: result.stderr,
92
87
  exitCode: result.code,
93
88
  timedOut: result.timedOut,
89
+ terminationError: result.terminationError || null,
94
90
  }));
95
91
  }
96
92
 
97
- export default defineTool({
93
+ const bashTool = defineTool({
98
94
  name: 'Bash',
99
95
  description: {
100
96
  en: `Execute a shell command and return its output.
@@ -111,6 +107,7 @@ Guidelines:
111
107
  - Use absolute paths when possible
112
108
  - Avoid interactive commands (no stdin support)
113
109
  - Use background=true for long-running or persistent tasks that should survive across turns
110
+ - Foreground timeout and cleanup status is returned to you; decide whether to inspect, retry, stop, or use a background task
114
111
  - stderr is captured separately and included in the result`,
115
112
  zh: `执行 Shell 命令并返回输出。
116
113
 
@@ -123,6 +120,7 @@ Guidelines:
123
120
  - 尽量使用绝对路径
124
121
  - 避免交互式命令(不支持 stdin)
125
122
  - 长时间或需要跨 turn 持续存在的任务使用 background=true
123
+ - 前台命令的超时和清理状态会返回给你;由你决定检查、重试、停止或改用后台任务
126
124
  - stderr 单独捕获并包含在结果中`
127
125
  },
128
126
  parameters: {
@@ -167,7 +165,10 @@ Guidelines:
167
165
  required: ['command'],
168
166
  },
169
167
  errorOutput: null,
170
- timeoutMs: REGISTRY_TIMEOUT_MS,
168
+ // Foreground Bash owns a bounded timeout and process-tree cleanup state
169
+ // machine. A second ToolRegistry timer can preempt that cleanup and turn an
170
+ // owned exit 124 into a fatal orphan, so it must stay disabled for this tool.
171
+ timeoutMs: 0,
171
172
  isConcurrencySafe: () => false,
172
173
  isReadOnly: () => false,
173
174
  isDestructive: (input) => {
@@ -224,6 +225,7 @@ Guidelines:
224
225
  timeout,
225
226
  signal: ctx?.signal,
226
227
  runtimePlatform,
228
+ runProcessImpl: ctx?.[RUN_PROCESS_OVERRIDE] || runProcess,
227
229
  });
228
230
 
229
231
  // Format output similar to Claude Code
@@ -231,6 +233,24 @@ Guidelines:
231
233
  if (result.stdout) parts.push(result.stdout);
232
234
  if (result.stderr) parts.push(`STDERR:\n${result.stderr}`);
233
235
  if (result.timedOut) parts.push(`\n(Command timed out after ${timeout}ms)`);
236
+ if (result.terminationError) {
237
+ parts.push([
238
+ `WARNING: ${result.terminationError}`,
239
+ 'The command timed out, but process-tree termination could not be confirmed. The command may still be running.',
240
+ 'Decide whether to inspect or stop it, retry, or use background=true.',
241
+ ].join('\n'));
242
+ }
243
+ if (result.timedOut) {
244
+ ctx?.requestToolBatchBarrier?.({
245
+ kind: 'owned_timeout',
246
+ message: [
247
+ result.terminationError
248
+ ? 'The foreground Bash command timed out and process-tree termination could not be confirmed.'
249
+ : 'The foreground Bash command timed out.',
250
+ 'Return this result to the model before executing another tool call from the same batch.',
251
+ ].join(' '),
252
+ });
253
+ }
234
254
 
235
255
  const output = parts.join('\n');
236
256
  if (result.exitCode !== 0) {
@@ -243,3 +263,14 @@ Guidelines:
243
263
  }
244
264
  },
245
265
  });
266
+
267
+ export function createBashTool({ runProcessImpl = runProcess } = {}) {
268
+ return {
269
+ ...bashTool,
270
+ execute(input, ctx) {
271
+ return bashTool.execute(input, { ...ctx, [RUN_PROCESS_OVERRIDE]: runProcessImpl });
272
+ },
273
+ };
274
+ }
275
+
276
+ export default bashTool;
@@ -102,6 +102,7 @@ function killProcessTree(proc, signalName, platform, spawnProcessSync, systemdSc
102
102
  * @param {string} command
103
103
  * @param {string[]} args
104
104
  * @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, requireExitConfirmation?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
105
+ * @returns {Promise<{ code: number, stdout: string, stderr: string, truncated: boolean, timedOut: boolean, terminationError?: string }>}
105
106
  */
106
107
  export function runProcess(command, args, options = {}) {
107
108
  if (options.signal?.aborted) {
@@ -188,21 +189,30 @@ export function runProcess(command, args, options = {}) {
188
189
  if (settled) return;
189
190
  settled = true;
190
191
  cleanup();
191
- if (error) {
192
- reject(error);
193
- return;
194
- }
195
- if (aborted) {
196
- reject(abortError(options.signal));
197
- return;
198
- }
199
- resolve({
192
+ const result = {
200
193
  code: timedOut ? 124 : (code ?? 1),
201
194
  stdout: decode(stdout, stdoutTruncated, options.preserveCarriageReturns),
202
195
  stderr: decode(stderr, stderrTruncated),
203
196
  truncated,
204
197
  timedOut,
205
- });
198
+ };
199
+ if (aborted) {
200
+ reject(abortError(options.signal));
201
+ return;
202
+ }
203
+ if (error) {
204
+ // A command timeout is an owned, bounded outcome. Failure to observe
205
+ // the final child close is important context for the caller, but it
206
+ // must not turn the timeout into an infrastructure exception that
207
+ // prevents the model from deciding what to do next.
208
+ if (timedOut && error instanceof ProcessTerminationError) {
209
+ resolve({ ...result, terminationError: error.message });
210
+ return;
211
+ }
212
+ reject(error);
213
+ return;
214
+ }
215
+ resolve(result);
206
216
  };
207
217
  const terminationConfirmed = () => {
208
218
  if (!options.requireExitConfirmation) return directClosed;
@@ -334,6 +344,10 @@ export function runProcess(command, args, options = {}) {
334
344
 
335
345
  if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
336
346
  timer = setTimeout(() => {
347
+ // Preserve the first stop reason. Output overflow and Abort may have
348
+ // already started termination; a later deadline must not relabel that
349
+ // cleanup failure as a recoverable command timeout.
350
+ if (settled || stopRequested) return;
337
351
  timedOut = true;
338
352
  stop();
339
353
  }, options.timeoutMs);
@@ -45,6 +45,11 @@
45
45
  * the supplied reason as `detail`. `reason` may be a structured object
46
46
  * `{kind, ...}` so downstream observers (web-bridge) can render UI hints
47
47
  * (e.g. "↪ 已转交给 @vp-b") without re-parsing strings.
48
+ * @property {(reason?: string|object) => void} [requestToolBatchBarrier]
49
+ * — stop executing the remaining calls in the current assistant tool batch
50
+ * while still pairing each call with an explicit skipped tool result. The
51
+ * engine then returns those results to the provider before accepting another
52
+ * plan. Used when a completed tool result invalidates the rest of the batch.
48
53
  * @property {string} [senderVpId] — id of the VP whose turn is currently
49
54
  * running. Used by `route_forward` to stamp the forwarded message and
50
55
  * by the loop guard to key per-sender throttling.