@yeaft/webchat-agent 1.0.431 → 1.0.432

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.431"}
1
+ {"version":"1.0.432"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.431",
3
+ "version": "1.0.432",
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
@@ -89,6 +89,13 @@ import {
89
89
  /** Maximum auto-continue turns when stopReason is 'max_tokens'. */
90
90
  const MAX_CONTINUE_TURNS = 3;
91
91
 
92
+ /**
93
+ * Keep one provider tool batch from opening an unbounded number of filesystem,
94
+ * network, or subprocess reads. Only tools whose metadata explicitly declares
95
+ * both read-only and concurrency-safe execution enter this lane.
96
+ */
97
+ const MAX_CONCURRENT_READ_ONLY_TOOLS = 4;
98
+
92
99
  /** Bound the best-effort post-turn AMS LLM call independently of the user turn. */
93
100
  const MAINTENANCE_CALL_TIMEOUT_MS = 30_000;
94
101
 
@@ -112,6 +119,16 @@ function isReadOnlyTool(engine, name, input) {
112
119
  }
113
120
  }
114
121
 
122
+ function isConcurrencySafeTool(engine, name, input) {
123
+ try {
124
+ const tool = toolDefinitionFor(engine, name);
125
+ return tool?.isReadOnly?.(input) === true
126
+ && tool?.isConcurrencySafe?.(input) === true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
115
132
  function isCacheableTool(engine, name, input) {
116
133
  try {
117
134
  const tool = toolDefinitionFor(engine, name);
@@ -4218,21 +4235,131 @@ export class Engine {
4218
4235
  let abortedDuringTools = false;
4219
4236
  /** @type {string[]} */
4220
4237
  const pendingDupReminders = [];
4238
+ /**
4239
+ * Completed executions waiting for their original-order commit. Starting
4240
+ * a bounded read-only segment together removes wall-clock latency without
4241
+ * changing tool result, persistence, trace, or provider message order.
4242
+ * @type {Map<string, { call: object, startedAt: number, durationMs: number, output?: string, error?: Error, toolErrorOutput?: string|null }>}
4243
+ */
4244
+ const parallelToolExecutions = new Map();
4245
+ const announcedParallelToolCalls = new Set();
4246
+ const toolAllowedForRequest = (toolCall) => (this.#toolRegistry
4247
+ ? this.#toolRegistry.isAllowed(toolCall.name, {
4248
+ collabToolPolicy: effectiveCollabToolPolicy,
4249
+ plugins: this.#config?.plugins,
4250
+ activeToolNames,
4251
+ })
4252
+ : this.#tools.has(toolCall.name));
4253
+ const toolContextForCall = (toolCall) => {
4254
+ const stableToolCall = {
4255
+ id: toolCall.id,
4256
+ name: toolCall.name,
4257
+ threadId: runtimeThreadId,
4258
+ };
4259
+ return {
4260
+ ...toolCtx,
4261
+ currentToolCall: () => ({ ...stableToolCall }),
4262
+ askUser: typeof askUser === 'function'
4263
+ ? input => askUser(input, { ...stableToolCall })
4264
+ : toolCtx.askUser,
4265
+ registerAsyncTask: (taskId, meta = {}) => {
4266
+ this.#registerAsyncTask(taskId, { ...stableToolCall, ...(meta || {}) });
4267
+ },
4268
+ requestToolBatchBarrier: (reason) => {
4269
+ if (toolBatchBarrier != null) return;
4270
+ const detail = reason && typeof reason === 'object'
4271
+ ? { ...reason }
4272
+ : { message: String(reason || 'A preceding tool result invalidated the remaining batch.') };
4273
+ toolBatchBarrier = {
4274
+ ...detail,
4275
+ sourceToolCallId: stableToolCall.id,
4276
+ sourceToolName: stableToolCall.name,
4277
+ };
4278
+ },
4279
+ };
4280
+ };
4221
4281
 
4222
- for (const tc of toolCalls) {
4223
- // task-325a: honour abort between tools. We don't cancel a tool
4224
- // that's already running (the signal is passed in, tools decide
4225
- // themselves whether to bail early), but we stop dispatching
4226
- // any remaining tools the moment abort fires.
4227
- if (signal?.aborted && !toolBatchBarrier) {
4228
- abortedDuringTools = true;
4229
- break;
4230
- }
4282
+ for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) {
4283
+ const tc = toolCalls[toolCallIndex];
4284
+ const preparedParallelExecution = parallelToolExecutions.get(tc.id) || null;
4285
+ // task-325a: honour abort between tools. We don't cancel tools already
4286
+ // running in a parallel segment; every provider call still commits a
4287
+ // paired result in provider order, but no not-yet-started call is
4288
+ // dispatched after the abort boundary.
4231
4289
  if (signal?.aborted) abortedDuringTools = true;
4232
4290
 
4291
+ if (!preparedParallelExecution && !toolBatchBarrier && !signal?.aborted
4292
+ && toolAllowedForRequest(tc) && isConcurrencySafeTool(this, tc.name, tc.input)
4293
+ && !mayMutateWorkspaceAfterReturn(this, tc.name, tc.input)) {
4294
+ const parallelCalls = [];
4295
+ const segmentCacheKeys = new Set();
4296
+ for (let candidateIndex = toolCallIndex;
4297
+ candidateIndex < toolCalls.length && parallelCalls.length < MAX_CONCURRENT_READ_ONLY_TOOLS;
4298
+ candidateIndex += 1) {
4299
+ const candidate = toolCalls[candidateIndex];
4300
+ if (!toolAllowedForRequest(candidate)
4301
+ || !isConcurrencySafeTool(this, candidate.name, candidate.input)
4302
+ || mayMutateWorkspaceAfterReturn(this, candidate.name, candidate.input)) break;
4303
+ const candidateKey = `${candidate.name}\u001f${argsHashOf(candidate.input)}`;
4304
+ const candidateCacheable = isCacheableTool(this, candidate.name, candidate.input);
4305
+ // Keep identical cacheable reads on the serial commit path so the
4306
+ // second call reuses the first result instead of duplicating I/O.
4307
+ if (candidateCacheable
4308
+ && (readOnlyToolResults.has(candidateKey) || segmentCacheKeys.has(candidateKey))) break;
4309
+ parallelCalls.push(candidate);
4310
+ if (candidateCacheable) segmentCacheKeys.add(candidateKey);
4311
+ }
4312
+
4313
+ if (parallelCalls.length > 1) {
4314
+ const executions = [];
4315
+ for (const call of parallelCalls) {
4316
+ if (signal?.aborted) {
4317
+ abortedDuringTools = true;
4318
+ break;
4319
+ }
4320
+ announcedParallelToolCalls.add(call.id);
4321
+ yield {
4322
+ type: 'tool_start',
4323
+ id: call.id,
4324
+ name: call.name,
4325
+ input: call.input,
4326
+ threadId: this.currentThreadId,
4327
+ };
4328
+ if (signal?.aborted) {
4329
+ abortedDuringTools = true;
4330
+ break;
4331
+ }
4332
+ executions.push((async () => {
4333
+ const startedAt = Date.now();
4334
+ const callContext = toolContextForCall(call);
4335
+ const toolErrorOutput = this.#toolRegistry
4336
+ ? this.#toolRegistry.get(call.name)?.errorOutput || null
4337
+ : this.#tools.get(call.name)?.errorOutput || null;
4338
+ try {
4339
+ const output = this.#toolRegistry
4340
+ ? await this.#toolRegistry.execute(call.name, call.input, callContext)
4341
+ : normalizeToolOutput(await this.#tools.get(call.name).execute(call.input, callContext));
4342
+ return { call, startedAt, durationMs: Date.now() - startedAt, output, toolErrorOutput };
4343
+ } catch (error) {
4344
+ return { call, startedAt, durationMs: Date.now() - startedAt, error, toolErrorOutput };
4345
+ }
4346
+ })());
4347
+ }
4348
+ const completed = await Promise.all(executions);
4349
+ for (const execution of completed) {
4350
+ parallelToolExecutions.set(execution.call.id, execution);
4351
+ }
4352
+ }
4353
+ }
4354
+
4355
+ const readyParallelExecution = parallelToolExecutions.get(tc.id) || null;
4356
+ if (readyParallelExecution) announcedParallelToolCalls.delete(tc.id);
4233
4357
  const activeToolBatchBarrier = toolBatchBarrier;
4234
- const skipped = activeToolBatchBarrier != null;
4235
- const toolStartTime = Date.now();
4358
+ const abortSkipped = Boolean(signal?.aborted && !readyParallelExecution);
4359
+ if (abortSkipped) announcedParallelToolCalls.delete(tc.id);
4360
+ const skipped = abortSkipped
4361
+ || (activeToolBatchBarrier != null && !readyParallelExecution);
4362
+ const toolStartTime = readyParallelExecution?.startedAt || Date.now();
4236
4363
 
4237
4364
  // PR-L: duplicate-call detection. If this exact (toolName,
4238
4365
  // argsHash) pair has already been executed DUP_TOOL_THRESHOLD
@@ -4297,7 +4424,20 @@ export class Engine {
4297
4424
  : new Set();
4298
4425
  const needsProjectDocReload = missingProjectDocScopes.size > 0;
4299
4426
 
4300
- if (skipped) {
4427
+ if (abortSkipped) {
4428
+ output = `Skipped ${tc.name} because the turn was aborted before this tool started.`;
4429
+ isError = true;
4430
+ yield {
4431
+ type: 'tool_end',
4432
+ id: tc.id,
4433
+ name: tc.name,
4434
+ output,
4435
+ isError: true,
4436
+ skipped: true,
4437
+ aborted: true,
4438
+ threadId: this.currentThreadId,
4439
+ };
4440
+ } else if (skipped) {
4301
4441
  const source = activeToolBatchBarrier.sourceToolName || 'a preceding tool';
4302
4442
  const sourceId = activeToolBatchBarrier.sourceToolCallId
4303
4443
  ? ` (${activeToolBatchBarrier.sourceToolCallId})`
@@ -4381,22 +4521,32 @@ export class Engine {
4381
4521
  threadId: this.currentThreadId,
4382
4522
  };
4383
4523
  } else try {
4384
- yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
4385
- if (this.#toolRegistry) {
4386
- toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
4387
- output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
4524
+ if (readyParallelExecution) {
4525
+ parallelToolExecutions.delete(tc.id);
4526
+ toolErrorOutput = readyParallelExecution.toolErrorOutput || null;
4527
+ if (readyParallelExecution.error) throw readyParallelExecution.error;
4528
+ output = readyParallelExecution.output;
4388
4529
  } else {
4389
- const tool = this.#tools.get(tc.name);
4390
- toolErrorOutput = tool.errorOutput || null;
4391
- // Pass the full toolCtx (cwd, workDir, signal, …) — not just
4392
- // `{ signal }`. Legacy registerTool() callers historically got
4393
- // a 1-field ctx, but that means tools like bash/file-read run
4394
- // in the agent process cwd instead of the group's workDir.
4395
- // Real production goes through #toolRegistry; the legacy path
4396
- // is exercised by tests and a few standalone tools. Aligning
4397
- // both paths keeps `ctx.cwd` semantics consistent.
4398
- const rawOutput = await tool.execute(tc.input, toolCtx);
4399
- output = normalizeToolOutput(rawOutput);
4530
+ if (!announcedParallelToolCalls.delete(tc.id)) {
4531
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
4532
+ }
4533
+ const callToolCtx = toolContextForCall(tc);
4534
+ if (this.#toolRegistry) {
4535
+ toolErrorOutput = this.#toolRegistry.get(tc.name)?.errorOutput || null;
4536
+ output = await this.#toolRegistry.execute(tc.name, tc.input, callToolCtx);
4537
+ } else {
4538
+ const tool = this.#tools.get(tc.name);
4539
+ toolErrorOutput = tool.errorOutput || null;
4540
+ // Pass the full toolCtx (cwd, workDir, signal, …) — not just
4541
+ // `{ signal }`. Legacy registerTool() callers historically got
4542
+ // a 1-field ctx, but that means tools like bash/file-read run
4543
+ // in the agent process cwd instead of the group's workDir.
4544
+ // Real production goes through #toolRegistry; the legacy path
4545
+ // is exercised by tests and a few standalone tools. Aligning
4546
+ // both paths keeps `ctx.cwd` semantics consistent.
4547
+ const rawOutput = await tool.execute(tc.input, callToolCtx);
4548
+ output = normalizeToolOutput(rawOutput);
4549
+ }
4400
4550
  }
4401
4551
  displayImages = extractDisplayImages(tc.name, output);
4402
4552
  if (displayImages.length > 0) {
@@ -4443,7 +4593,7 @@ export class Engine {
4443
4593
 
4444
4594
  currentToolCallForAsyncTask = null;
4445
4595
 
4446
- const toolDurationMs = Date.now() - toolStartTime;
4596
+ const toolDurationMs = readyParallelExecution?.durationMs ?? (Date.now() - toolStartTime);
4447
4597
 
4448
4598
  // feat-6af5f9f1 PR B: emit a structured `tool_exec` event for the
4449
4599
  // debug panel. Keep raw output here; the model-facing tool