@yeaft/webchat-agent 0.1.1083 → 0.1.1085
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/package.json +1 -1
- package/yeaft/config.js +1 -1
- package/yeaft/engine.js +291 -2
- package/yeaft/sub-agent/runner.js +6 -0
- package/yeaft/tools/agent.js +8 -1
- package/yeaft/tools/bash.js +6 -0
- package/yeaft/web-bridge.js +110 -1
package/package.json
CHANGED
package/yeaft/config.js
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -466,6 +466,50 @@ export class Engine {
|
|
|
466
466
|
/** @type {Array<{content:string|Array, preview:string}>} */
|
|
467
467
|
#pendingUserMessages = [];
|
|
468
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Tasks (background bash, sub-agent spawns) that were launched DURING
|
|
471
|
+
* the currently-running query() and have NOT terminated yet. The query
|
|
472
|
+
* loop refuses to finalize end_turn while this set is non-empty — instead
|
|
473
|
+
* it parks on `#asyncTaskWaiters` until a terminal event or a new user
|
|
474
|
+
* append wakes it up. Cleared at the top of each query() and again in
|
|
475
|
+
* the finally block so a stale set never leaks across turns.
|
|
476
|
+
* @type {Set<string>}
|
|
477
|
+
*/
|
|
478
|
+
#pendingAsyncTaskIds = new Set();
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Terminal task events that arrived after their producing tool already
|
|
482
|
+
* returned. Each entry becomes a synthetic user message at the next
|
|
483
|
+
* adapter boundary. Format mirrors `#pendingUserMessages` so the same
|
|
484
|
+
* drain path can splice both into `conversationMessages`.
|
|
485
|
+
* @type {Array<{content:string|Array, preview:string, internal:boolean, taskId?:string}>}
|
|
486
|
+
*/
|
|
487
|
+
#pendingTaskResultMessages = [];
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Resolvers parked by the main loop while it waits for an async task to
|
|
491
|
+
* terminate (or a fresh user append to arrive). Wake order is FIFO; every
|
|
492
|
+
* resolver is invoked exactly once and the queue cleared, so a single
|
|
493
|
+
* task completion releases every waiter in the same engine and the loop
|
|
494
|
+
* decides on the next iteration whether to keep waiting.
|
|
495
|
+
* @type {Array<() => void>}
|
|
496
|
+
*/
|
|
497
|
+
#asyncTaskWaiters = [];
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* External coordinator hooks. `getOrCreateVpEngine` (web-bridge.js)
|
|
501
|
+
* installs these so the bridge can route a `taskManager` `completed`
|
|
502
|
+
* event back to THIS engine while it's still running its query() — same
|
|
503
|
+
* turn, next adapter loop. Unset (null) in non-bridge contexts (tests,
|
|
504
|
+
* sub-agents without a bridge) — the engine then degrades to "no
|
|
505
|
+
* coordinator", which still works because tools call back through
|
|
506
|
+
* `toolCtx.registerAsyncTask` and the engine waits locally; web-bridge
|
|
507
|
+
* fallback (legacy `scheduleTaskResultReentry` → new turn) handles the
|
|
508
|
+
* post-run case.
|
|
509
|
+
* @type {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null}
|
|
510
|
+
*/
|
|
511
|
+
#asyncTaskCoordinator = null;
|
|
512
|
+
|
|
469
513
|
/**
|
|
470
514
|
* Per-group "adjust has run at least once this engine lifetime" flag.
|
|
471
515
|
* Keyed by sessionId (or 'default'). The first turn always runs adjust;
|
|
@@ -1042,6 +1086,13 @@ export class Engine {
|
|
|
1042
1086
|
// can mark "after this batch, end the turn — do NOT call adapter
|
|
1043
1087
|
// again". Honored at the top of the tool-loop continuation.
|
|
1044
1088
|
requestEndTurn: vpCtx?.requestEndTurn,
|
|
1089
|
+
// Background-task ownership hook. Tools that produce a TaskManager
|
|
1090
|
+
// task (bash background, agent spawn) call this with the new
|
|
1091
|
+
// `task.id` so the engine keeps the current query parked at end_turn
|
|
1092
|
+
// until the task terminates — its result is then spliced into the
|
|
1093
|
+
// next adapter loop in the SAME turn. Tools that don't produce
|
|
1094
|
+
// async tasks ignore it.
|
|
1095
|
+
registerAsyncTask: (taskId) => this.#registerAsyncTask(taskId),
|
|
1045
1096
|
// Sub-agent plumbing — Agent tool needs these to spawn a child
|
|
1046
1097
|
// Engine that inherits the parent's adapter / stores / toolset.
|
|
1047
1098
|
parentEngineDeps: {
|
|
@@ -1065,6 +1116,12 @@ export class Engine {
|
|
|
1065
1116
|
// to. Null when the parent has no stats wired (e.g. tests).
|
|
1066
1117
|
toolStats: this.#toolStats || null,
|
|
1067
1118
|
taskManager: this.#taskManager || null,
|
|
1119
|
+
// Propagate the async-task coordinator so sub-agents launched
|
|
1120
|
+
// from this engine register their background tasks against the
|
|
1121
|
+
// SAME owner map the bridge uses. Without this, a sub-agent's
|
|
1122
|
+
// background bash terminal event would not find its engine and
|
|
1123
|
+
// would fall through to the legacy rescue path.
|
|
1124
|
+
asyncTaskCoordinator: this.#asyncTaskCoordinator || null,
|
|
1068
1125
|
},
|
|
1069
1126
|
};
|
|
1070
1127
|
}
|
|
@@ -1394,16 +1451,28 @@ export class Engine {
|
|
|
1394
1451
|
if (this.#pendingUserMessages.length > 0) {
|
|
1395
1452
|
pending.push(...this.#pendingUserMessages.splice(0));
|
|
1396
1453
|
}
|
|
1454
|
+
// Task-result re-entries flow through the same drain so the main loop
|
|
1455
|
+
// sees ONE append queue. Each entry carries `internal: true` so the
|
|
1456
|
+
// `user_append` event downstream is tagged correctly (UI hides the
|
|
1457
|
+
// bubble; persistence stamps role=assistant).
|
|
1458
|
+
if (this.#pendingTaskResultMessages.length > 0) {
|
|
1459
|
+
pending.push(...this.#pendingTaskResultMessages.splice(0));
|
|
1460
|
+
}
|
|
1397
1461
|
return pending
|
|
1398
1462
|
.map((item) => {
|
|
1399
|
-
if (typeof item === 'string') return { content: item, preview: item };
|
|
1463
|
+
if (typeof item === 'string') return { content: item, preview: item, internal: false };
|
|
1400
1464
|
if (!item || typeof item !== 'object') return null;
|
|
1401
1465
|
const content = item.content ?? item.text;
|
|
1402
1466
|
if (typeof content !== 'string' && !Array.isArray(content)) return null;
|
|
1403
1467
|
const preview = typeof item.preview === 'string'
|
|
1404
1468
|
? item.preview
|
|
1405
1469
|
: (typeof content === 'string' ? content : '[content blocks]');
|
|
1406
|
-
return {
|
|
1470
|
+
return {
|
|
1471
|
+
content,
|
|
1472
|
+
preview,
|
|
1473
|
+
internal: Boolean(item.internal),
|
|
1474
|
+
taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
|
|
1475
|
+
};
|
|
1407
1476
|
})
|
|
1408
1477
|
.filter(Boolean);
|
|
1409
1478
|
}
|
|
@@ -1512,6 +1581,27 @@ export class Engine {
|
|
|
1512
1581
|
this.#abortReason = null;
|
|
1513
1582
|
this.#currentThreadId = MAIN_THREAD_ID;
|
|
1514
1583
|
this.#pendingUserMessages.length = 0;
|
|
1584
|
+
// Hand back any async tasks still on the books to the coordinator so
|
|
1585
|
+
// a late terminal event falls through to the legacy rescue path
|
|
1586
|
+
// (new turn) instead of being silently swallowed. The coordinator
|
|
1587
|
+
// is responsible for keeping its owner map in sync.
|
|
1588
|
+
if (this.#pendingAsyncTaskIds.size > 0) {
|
|
1589
|
+
const leftover = Array.from(this.#pendingAsyncTaskIds);
|
|
1590
|
+
this.#pendingAsyncTaskIds.clear();
|
|
1591
|
+
for (const tid of leftover) {
|
|
1592
|
+
try { this.#asyncTaskCoordinator?.onUnregister?.(tid); } catch { /* ignore */ }
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
this.#pendingTaskResultMessages.length = 0;
|
|
1596
|
+
// Release any parked waiters so they don't pin a microtask after
|
|
1597
|
+
// query() returns. The loop has already exited so they're harmless,
|
|
1598
|
+
// but cleanup keeps the promise graph tight.
|
|
1599
|
+
if (this.#asyncTaskWaiters.length > 0) {
|
|
1600
|
+
const waiters = this.#asyncTaskWaiters.splice(0);
|
|
1601
|
+
for (const r of waiters) {
|
|
1602
|
+
try { r(); } catch { /* ignore */ }
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1515
1605
|
}
|
|
1516
1606
|
}
|
|
1517
1607
|
|
|
@@ -2460,6 +2550,80 @@ export class Engine {
|
|
|
2460
2550
|
continue;
|
|
2461
2551
|
}
|
|
2462
2552
|
|
|
2553
|
+
// If no tool calls, we're done — UNLESS we still own a pending
|
|
2554
|
+
// async task. The user-facing semantic: a turn that launched a
|
|
2555
|
+
// background bash / sub-agent stays "live" until those tasks
|
|
2556
|
+
// terminate (or the user appends, or abort). The model already
|
|
2557
|
+
// said end_turn; we just defer finalization by parking on the
|
|
2558
|
+
// wait queue, then splice the synthetic task-result message in
|
|
2559
|
+
// and run one more adapter loop. This matches the contract the
|
|
2560
|
+
// bridge documents for `formatTaskResultForVp`: "Consume it now:
|
|
2561
|
+
// tell the user the outcome or continue the work. Do not wait
|
|
2562
|
+
// for another user turn."
|
|
2563
|
+
if ((stopReason !== 'tool_use' || toolCalls.length === 0)
|
|
2564
|
+
&& this.#pendingAsyncTaskIds.size > 0
|
|
2565
|
+
&& !signal?.aborted) {
|
|
2566
|
+
// Drop into a wait loop. The loop wakes on (a) any task
|
|
2567
|
+
// terminal event delivered via `notifyAsyncTaskCompleted`, (b)
|
|
2568
|
+
// a fresh user append (which is honored as a higher priority
|
|
2569
|
+
// user input), or (c) abort. On wake we re-check: if either
|
|
2570
|
+
// queue has content, drain + splice + continue the outer loop.
|
|
2571
|
+
// If both queues are empty AND we still have pending tasks AND
|
|
2572
|
+
// we're not aborted, we just keep waiting. This is the only
|
|
2573
|
+
// place query() can block on something other than the LLM stream.
|
|
2574
|
+
yield {
|
|
2575
|
+
type: 'async_task_wait_start',
|
|
2576
|
+
turnId: queryTurnId,
|
|
2577
|
+
loopNumber: turnNumber,
|
|
2578
|
+
threadId,
|
|
2579
|
+
pendingTaskIds: Array.from(this.#pendingAsyncTaskIds),
|
|
2580
|
+
};
|
|
2581
|
+
// Race-safe wait: a `notifyAsyncTaskCompleted` callback that
|
|
2582
|
+
// arrives synchronously between `yield` and the `await` below
|
|
2583
|
+
// (e.g. the bridge handles a task event in the same microtask
|
|
2584
|
+
// as the wait_start event) will already have populated the
|
|
2585
|
+
// queues, so #waitForAsyncWake resolves immediately on its
|
|
2586
|
+
// fast-path check. Drain BEFORE deciding to continue so we
|
|
2587
|
+
// never "fall through" with content sitting in either queue.
|
|
2588
|
+
while (!signal?.aborted
|
|
2589
|
+
&& this.#pendingTaskResultMessages.length === 0
|
|
2590
|
+
&& this.#pendingUserMessages.length === 0) {
|
|
2591
|
+
if (this.#pendingAsyncTaskIds.size === 0) break;
|
|
2592
|
+
await this.#waitForAsyncWake(signal);
|
|
2593
|
+
}
|
|
2594
|
+
yield {
|
|
2595
|
+
type: 'async_task_wait_end',
|
|
2596
|
+
turnId: queryTurnId,
|
|
2597
|
+
loopNumber: turnNumber,
|
|
2598
|
+
threadId,
|
|
2599
|
+
aborted: Boolean(signal?.aborted),
|
|
2600
|
+
remainingTaskIds: Array.from(this.#pendingAsyncTaskIds),
|
|
2601
|
+
};
|
|
2602
|
+
if (!signal?.aborted) {
|
|
2603
|
+
const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
|
|
2604
|
+
if (appendedAfterAsyncWait.length > 0) {
|
|
2605
|
+
for (const item of appendedAfterAsyncWait) {
|
|
2606
|
+
conversationMessages.push({ role: 'user', content: item.content });
|
|
2607
|
+
yield {
|
|
2608
|
+
type: 'user_append',
|
|
2609
|
+
turnId: queryTurnId,
|
|
2610
|
+
loopNumber: turnNumber,
|
|
2611
|
+
threadId,
|
|
2612
|
+
preview: String(item.preview || '').slice(0, 200),
|
|
2613
|
+
internal: Boolean(item.internal),
|
|
2614
|
+
taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
yield { type: 'turn_end', turnNumber, stopReason: 'async_task_continue', threadId };
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
// If we fall through here, either abort fired or the wait
|
|
2622
|
+
// exited with no payload (all tasks released themselves via
|
|
2623
|
+
// engine teardown / unregister with no notification). Drop into
|
|
2624
|
+
// the regular end_turn path.
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2463
2627
|
// If no tool calls, we're done
|
|
2464
2628
|
if (stopReason !== 'tool_use' || toolCalls.length === 0) {
|
|
2465
2629
|
if (pendingSubAgentNotifs.length > 0) {
|
|
@@ -3109,9 +3273,134 @@ export class Engine {
|
|
|
3109
3273
|
if (typeof content === 'string' && !content.trim()) return false;
|
|
3110
3274
|
const preview = typeof content === 'string' ? content : '[content blocks]';
|
|
3111
3275
|
this.#pendingUserMessages.push({ content, preview });
|
|
3276
|
+
// Wake the async-task wait loop too. A user typing while the engine
|
|
3277
|
+
// is parked on a background task should release the loop immediately
|
|
3278
|
+
// so the user's words get spliced into the next iteration.
|
|
3279
|
+
this.#wakeAsyncTaskWaiters();
|
|
3280
|
+
return true;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
/**
|
|
3284
|
+
* Install the coordinator that lets an external dispatcher (web-bridge)
|
|
3285
|
+
* route a background task's terminal event back to THIS engine while
|
|
3286
|
+
* its query() is still running. Pass `null` to detach.
|
|
3287
|
+
* @param {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null} coord
|
|
3288
|
+
*/
|
|
3289
|
+
setAsyncTaskCoordinator(coord) {
|
|
3290
|
+
this.#asyncTaskCoordinator = (coord && typeof coord === 'object') ? coord : null;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
/**
|
|
3294
|
+
* True iff the currently-running query is holding pending async tasks.
|
|
3295
|
+
* Used by external probes (web-bridge fallback dispatcher) to decide
|
|
3296
|
+
* whether a task terminal event should be injected into the same turn
|
|
3297
|
+
* or fall back to the legacy "open a new turn" rescue path.
|
|
3298
|
+
* @returns {boolean}
|
|
3299
|
+
*/
|
|
3300
|
+
hasPendingAsyncTasks() {
|
|
3301
|
+
return this.#pendingAsyncTaskIds.size > 0;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
/**
|
|
3305
|
+
* True iff THIS engine has registered the given taskId as belonging to
|
|
3306
|
+
* the currently-running query. Web-bridge calls this before
|
|
3307
|
+
* `notifyAsyncTaskCompleted` so a terminal event for a task that wasn't
|
|
3308
|
+
* launched from this turn (or whose engine already finished) falls
|
|
3309
|
+
* through to the legacy rescue path.
|
|
3310
|
+
* @param {string} taskId
|
|
3311
|
+
* @returns {boolean}
|
|
3312
|
+
*/
|
|
3313
|
+
ownsPendingAsyncTask(taskId) {
|
|
3314
|
+
return typeof taskId === 'string' && this.#pendingAsyncTaskIds.has(taskId);
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
/**
|
|
3318
|
+
* Deliver a terminal task event into the currently-running query. Drops
|
|
3319
|
+
* the task from the pending set, queues a synthetic user message with
|
|
3320
|
+
* the rendered task result, and wakes the wait loop. Returns true iff
|
|
3321
|
+
* the engine accepted ownership (the bridge should NOT fall back to the
|
|
3322
|
+
* legacy rescue path). Returns false when the engine was never holding
|
|
3323
|
+
* this taskId — caller should fall through to its rescue path.
|
|
3324
|
+
*
|
|
3325
|
+
* @param {string} taskId
|
|
3326
|
+
* @param {string|Array} content — pre-formatted task result body
|
|
3327
|
+
* @param {{ preview?: string }} [opts]
|
|
3328
|
+
* @returns {boolean}
|
|
3329
|
+
*/
|
|
3330
|
+
notifyAsyncTaskCompleted(taskId, content, opts = {}) {
|
|
3331
|
+
if (!this.ownsPendingAsyncTask(taskId)) return false;
|
|
3332
|
+
if (typeof content !== 'string' && !Array.isArray(content)) return false;
|
|
3333
|
+
if (typeof content === 'string' && !content.trim()) return false;
|
|
3334
|
+
// Defensive: an empty content-block array would splice as a wire-valid
|
|
3335
|
+
// but semantically empty user message, which the adapter would happily
|
|
3336
|
+
// forward and bill for. Production callers (formatTaskResultForVp)
|
|
3337
|
+
// always emit a non-empty string today; this guards future refactors.
|
|
3338
|
+
if (Array.isArray(content) && content.length === 0) return false;
|
|
3339
|
+
this.#pendingAsyncTaskIds.delete(taskId);
|
|
3340
|
+
try { this.#asyncTaskCoordinator?.onUnregister?.(taskId); } catch { /* coord must not throw into engine */ }
|
|
3341
|
+
const preview = typeof opts.preview === 'string'
|
|
3342
|
+
? opts.preview
|
|
3343
|
+
: (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
|
|
3344
|
+
this.#pendingTaskResultMessages.push({
|
|
3345
|
+
content,
|
|
3346
|
+
preview,
|
|
3347
|
+
internal: true,
|
|
3348
|
+
taskId,
|
|
3349
|
+
});
|
|
3350
|
+
this.#wakeAsyncTaskWaiters();
|
|
3112
3351
|
return true;
|
|
3113
3352
|
}
|
|
3114
3353
|
|
|
3354
|
+
/**
|
|
3355
|
+
* Register a background task as belonging to the current query. Called
|
|
3356
|
+
* from tools (bash background, agent spawn) via `toolCtx.registerAsyncTask`.
|
|
3357
|
+
* @param {string} taskId
|
|
3358
|
+
* @returns {void}
|
|
3359
|
+
*/
|
|
3360
|
+
#registerAsyncTask(taskId) {
|
|
3361
|
+
if (typeof taskId !== 'string' || !taskId) return;
|
|
3362
|
+
this.#pendingAsyncTaskIds.add(taskId);
|
|
3363
|
+
try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
#wakeAsyncTaskWaiters() {
|
|
3367
|
+
if (this.#asyncTaskWaiters.length === 0) return;
|
|
3368
|
+
const waiters = this.#asyncTaskWaiters.splice(0);
|
|
3369
|
+
for (const resolve of waiters) {
|
|
3370
|
+
try { resolve(); } catch { /* never break the loop on a stray callback */ }
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
/**
|
|
3375
|
+
* Wait for *any* of: an async task terminal event, a fresh user append,
|
|
3376
|
+
* or signal abort. Resolves immediately if any of those is already
|
|
3377
|
+
* pending. The loop re-evaluates conditions on wake — multiple
|
|
3378
|
+
* concurrent tasks all wake the same waiter once, then the loop drains
|
|
3379
|
+
* everything in one iteration and decides whether to keep waiting.
|
|
3380
|
+
* @param {AbortSignal|null|undefined} signal
|
|
3381
|
+
* @returns {Promise<void>}
|
|
3382
|
+
*/
|
|
3383
|
+
#waitForAsyncWake(signal) {
|
|
3384
|
+
return new Promise((resolve) => {
|
|
3385
|
+
// Fast paths — anything already pending releases instantly. This is
|
|
3386
|
+
// the common case when a task finished between adapter loops.
|
|
3387
|
+
if (this.#pendingTaskResultMessages.length > 0) return resolve();
|
|
3388
|
+
if (this.#pendingUserMessages.length > 0) return resolve();
|
|
3389
|
+
if (signal?.aborted) return resolve();
|
|
3390
|
+
this.#asyncTaskWaiters.push(resolve);
|
|
3391
|
+
if (signal && typeof signal.addEventListener === 'function') {
|
|
3392
|
+
// Best-effort abort wakeup; the loop re-checks signal.aborted.
|
|
3393
|
+
const onAbort = () => {
|
|
3394
|
+
// Splice the resolver out of the wait queue so it can't double-fire.
|
|
3395
|
+
const idx = this.#asyncTaskWaiters.indexOf(resolve);
|
|
3396
|
+
if (idx >= 0) this.#asyncTaskWaiters.splice(idx, 1);
|
|
3397
|
+
try { resolve(); } catch { /* ignore */ }
|
|
3398
|
+
};
|
|
3399
|
+
try { signal.addEventListener('abort', onAbort, { once: true }); } catch { /* old runtimes */ }
|
|
3400
|
+
}
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3115
3404
|
/** @returns {string|null} */
|
|
3116
3405
|
get yeaftDir() { return this.#yeaftDir; }
|
|
3117
3406
|
|
|
@@ -156,6 +156,12 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
156
156
|
toolStats: deps.toolStats || null,
|
|
157
157
|
taskManager: deps.taskManager || null,
|
|
158
158
|
});
|
|
159
|
+
// Same-turn async-task plumbing: inherit the parent's coordinator so a
|
|
160
|
+
// background bash launched FROM this sub-agent registers itself against
|
|
161
|
+
// the shared owner map and its terminal event reaches the sub-engine.
|
|
162
|
+
if (deps.asyncTaskCoordinator && typeof subEngine.setAsyncTaskCoordinator === 'function') {
|
|
163
|
+
subEngine.setAsyncTaskCoordinator(deps.asyncTaskCoordinator);
|
|
164
|
+
}
|
|
159
165
|
|
|
160
166
|
agent.subEngine = subEngine;
|
|
161
167
|
agent.engineMessages = agent.engineMessages || [];
|
package/yeaft/tools/agent.js
CHANGED
|
@@ -427,7 +427,14 @@ use it as the default workflow or call it repeatedly in a loop.`,
|
|
|
427
427
|
runtime: { subAgentId: agentId, name, cwd: agent.cwd },
|
|
428
428
|
source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
|
|
429
429
|
});
|
|
430
|
-
if (task?.id)
|
|
430
|
+
if (task?.id) {
|
|
431
|
+
agent.taskId = task.id;
|
|
432
|
+
// Same-turn parking — see tools/bash.js for the contract. The
|
|
433
|
+
// sub-agent runs in its own engine but reports completion
|
|
434
|
+
// through the same TaskManager event, so the spawning turn
|
|
435
|
+
// stays parked until the sub-agent finishes.
|
|
436
|
+
try { ctx.registerAsyncTask?.(task.id); } catch { /* coord errors must not block spawn */ }
|
|
437
|
+
}
|
|
431
438
|
startSubAgent(agent, deps);
|
|
432
439
|
} catch (err) {
|
|
433
440
|
agent.status = STATUS.FAILED;
|
package/yeaft/tools/bash.js
CHANGED
|
@@ -246,6 +246,12 @@ Guidelines:
|
|
|
246
246
|
threadId: ctx.threadId || 'main',
|
|
247
247
|
},
|
|
248
248
|
});
|
|
249
|
+
// Same-turn parking: tell the engine "this turn has an async
|
|
250
|
+
// task in flight". The engine refuses to finalize end_turn
|
|
251
|
+
// while the set is non-empty and will splice the task result
|
|
252
|
+
// into the next adapter loop when it terminates. No-op when
|
|
253
|
+
// the engine didn't wire the hook (legacy callers / tests).
|
|
254
|
+
try { ctx.registerAsyncTask?.(task.id); } catch { /* never block tool return on coord errors */ }
|
|
249
255
|
return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
|
|
250
256
|
} catch (err) {
|
|
251
257
|
return JSON.stringify({ error: err?.message || String(err) });
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -258,6 +258,42 @@ const vpDrivers = new Map();
|
|
|
258
258
|
const vpEngines = new Map();
|
|
259
259
|
/** @type {Map<string, AbortController>} */
|
|
260
260
|
const vpAborts = new Map();
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Owner index for background tasks currently parked on a running engine.
|
|
264
|
+
* Populated by the per-engine async-task coordinator at register time;
|
|
265
|
+
* cleared at notify time or when the engine teardown unregisters whatever
|
|
266
|
+
* it didn't get to deliver. Used by `scheduleTaskResultReentry` to pick
|
|
267
|
+
* "same-turn injection" over the legacy "new turn" rescue path when the
|
|
268
|
+
* engine is still live.
|
|
269
|
+
* @type {Map<string, import('./engine.js').Engine>}
|
|
270
|
+
*/
|
|
271
|
+
const asyncTaskOwners = new Map();
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Build a coordinator for a freshly-constructed engine. The coordinator
|
|
275
|
+
* keeps `asyncTaskOwners` in sync so a `taskManager` `completed` event
|
|
276
|
+
* can find the engine that launched it in O(1).
|
|
277
|
+
*
|
|
278
|
+
* Defined as a factory (not a single shared object) so each engine's
|
|
279
|
+
* `onRegister` callback closes over its own engine reference — sub-agents
|
|
280
|
+
* inherit a coordinator that still associates their tasks with the
|
|
281
|
+
* sub-engine, not the parent.
|
|
282
|
+
*
|
|
283
|
+
* @returns {{ onRegister: (taskId: string, engine: import('./engine.js').Engine) => void, onUnregister: (taskId: string) => void }}
|
|
284
|
+
*/
|
|
285
|
+
function buildAsyncTaskCoordinator() {
|
|
286
|
+
return {
|
|
287
|
+
onRegister(taskId, engine) {
|
|
288
|
+
if (typeof taskId !== 'string' || !taskId) return;
|
|
289
|
+
asyncTaskOwners.set(taskId, engine);
|
|
290
|
+
},
|
|
291
|
+
onUnregister(taskId) {
|
|
292
|
+
if (typeof taskId !== 'string' || !taskId) return;
|
|
293
|
+
asyncTaskOwners.delete(taskId);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
261
297
|
/**
|
|
262
298
|
* Per-(sessionId, vpId) current TodoWrite list. Each VP in a group keeps
|
|
263
299
|
* its own todo state so two VPs in the same group can independently
|
|
@@ -956,6 +992,15 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
|
|
|
956
992
|
sessionId,
|
|
957
993
|
vpId,
|
|
958
994
|
});
|
|
995
|
+
// Install the async-task coordinator so background tasks launched
|
|
996
|
+
// from this engine register against the shared owner map and
|
|
997
|
+
// `scheduleTaskResultReentry` can deliver terminal events back into
|
|
998
|
+
// the same query() instead of opening a fresh turn.
|
|
999
|
+
try {
|
|
1000
|
+
if (typeof eng.setAsyncTaskCoordinator === 'function') {
|
|
1001
|
+
eng.setAsyncTaskCoordinator(buildAsyncTaskCoordinator());
|
|
1002
|
+
}
|
|
1003
|
+
} catch { /* coordinator is best-effort plumbing, never block engine creation */ }
|
|
959
1004
|
vpEngines.set(key, eng);
|
|
960
1005
|
return eng;
|
|
961
1006
|
}
|
|
@@ -1053,6 +1098,36 @@ function scheduleTaskResultReentry(event) {
|
|
|
1053
1098
|
const vpId = task.ownerVpId || null;
|
|
1054
1099
|
if (!sessionId || !vpId) return;
|
|
1055
1100
|
const threadId = task.source?.threadId || task.runtime?.threadId || 'main';
|
|
1101
|
+
const formatted = formatTaskResultForVp(task);
|
|
1102
|
+
|
|
1103
|
+
// Same-turn fast path: when the engine that launched this task is
|
|
1104
|
+
// still running its query() AND has parked on the wait queue, hand
|
|
1105
|
+
// the result straight in — it splices into the very next adapter
|
|
1106
|
+
// loop with no new turn / new VP envelope. Falls through to the
|
|
1107
|
+
// legacy "open a new turn" rescue path when:
|
|
1108
|
+
// - the engine already finished (typical orphan / late completion),
|
|
1109
|
+
// - the engine was torn down between register and complete, or
|
|
1110
|
+
// - the task wasn't registered with the engine in the first place
|
|
1111
|
+
// (e.g. legacy `taskManager.startTask` callers that bypass tools).
|
|
1112
|
+
const ownerEngine = asyncTaskOwners.get(task.id);
|
|
1113
|
+
if (ownerEngine
|
|
1114
|
+
&& typeof ownerEngine.ownsPendingAsyncTask === 'function'
|
|
1115
|
+
&& ownerEngine.ownsPendingAsyncTask(task.id)
|
|
1116
|
+
&& typeof ownerEngine.notifyAsyncTaskCompleted === 'function') {
|
|
1117
|
+
try {
|
|
1118
|
+
const accepted = ownerEngine.notifyAsyncTaskCompleted(task.id, formatted, {
|
|
1119
|
+
preview: `task ${task.kind || 'tool'} ${task.status}`,
|
|
1120
|
+
});
|
|
1121
|
+
if (accepted) {
|
|
1122
|
+
asyncTaskOwners.delete(task.id);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
} catch {
|
|
1126
|
+
// Same-turn delivery is best-effort. Fall through to the legacy
|
|
1127
|
+
// rescue path so we never drop a terminal event on the floor.
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1056
1131
|
const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
|
|
1057
1132
|
queueMicrotask(() => {
|
|
1058
1133
|
enqueueForVp(sessionId, vpId, {
|
|
@@ -1064,7 +1139,7 @@ function scheduleTaskResultReentry(event) {
|
|
|
1064
1139
|
id: msgId,
|
|
1065
1140
|
from: 'tool',
|
|
1066
1141
|
role: 'assistant',
|
|
1067
|
-
text:
|
|
1142
|
+
text: formatted,
|
|
1068
1143
|
meta: {
|
|
1069
1144
|
injectedBy: 'task_result',
|
|
1070
1145
|
taskId: task.id,
|
|
@@ -1399,6 +1474,7 @@ export async function __testResetVpState() {
|
|
|
1399
1474
|
vpInboxes.clear();
|
|
1400
1475
|
vpDrivers.clear();
|
|
1401
1476
|
vpEngines.clear();
|
|
1477
|
+
asyncTaskOwners.clear();
|
|
1402
1478
|
vpAborts.clear();
|
|
1403
1479
|
sessionContexts.clear();
|
|
1404
1480
|
vpCurrentTodos.clear();
|
|
@@ -2456,6 +2532,37 @@ function handleEngineEvent(event, hctx) {
|
|
|
2456
2532
|
}, envelope);
|
|
2457
2533
|
break;
|
|
2458
2534
|
|
|
2535
|
+
// Same-turn async-task wait. Engine parks at end_turn while a
|
|
2536
|
+
// background bash / sub-agent is still running and re-enters the
|
|
2537
|
+
// same turn when the terminal event arrives (see engine.js
|
|
2538
|
+
// `#runQuery` wait block). Bridge forwards both edges so the debug
|
|
2539
|
+
// panel (and any other in-process subscriber) can render the park
|
|
2540
|
+
// window with the live list of pending taskIds. Wire types stay
|
|
2541
|
+
// namespaced under `vp_async_task_*` to match the existing
|
|
2542
|
+
// `vp_thread_*` / `vp_typing_*` event family.
|
|
2543
|
+
case 'async_task_wait_start':
|
|
2544
|
+
sendSessionEvent({
|
|
2545
|
+
type: 'vp_async_task_wait_start',
|
|
2546
|
+
turnId: event.turnId,
|
|
2547
|
+
threadId: event.threadId,
|
|
2548
|
+
loopNumber: event.loopNumber,
|
|
2549
|
+
pendingTaskIds: Array.isArray(event.pendingTaskIds) ? event.pendingTaskIds : [],
|
|
2550
|
+
ts: Date.now(),
|
|
2551
|
+
}, envelope);
|
|
2552
|
+
break;
|
|
2553
|
+
|
|
2554
|
+
case 'async_task_wait_end':
|
|
2555
|
+
sendSessionEvent({
|
|
2556
|
+
type: 'vp_async_task_wait_end',
|
|
2557
|
+
turnId: event.turnId,
|
|
2558
|
+
threadId: event.threadId,
|
|
2559
|
+
loopNumber: event.loopNumber,
|
|
2560
|
+
aborted: Boolean(event.aborted),
|
|
2561
|
+
remainingTaskIds: Array.isArray(event.remainingTaskIds) ? event.remainingTaskIds : [],
|
|
2562
|
+
ts: Date.now(),
|
|
2563
|
+
}, envelope);
|
|
2564
|
+
break;
|
|
2565
|
+
|
|
2459
2566
|
case 'loop':
|
|
2460
2567
|
// feat-6af5f9f1 PR B: replaces the old `debug_turn` event. Same
|
|
2461
2568
|
// payload shape plus turnId + loopNumber + usage.totalTokens.
|
|
@@ -4160,6 +4267,7 @@ export function handleYeaftModelSwitch(msg) {
|
|
|
4160
4267
|
// Engines would otherwise keep the old effective config and drop newly
|
|
4161
4268
|
// selected effort values until process restart.
|
|
4162
4269
|
vpEngines.clear();
|
|
4270
|
+
asyncTaskOwners.clear();
|
|
4163
4271
|
|
|
4164
4272
|
sendSessionEvent({
|
|
4165
4273
|
type: 'model_switched',
|
|
@@ -4501,6 +4609,7 @@ export async function resetYeaftSession() {
|
|
|
4501
4609
|
vpInboxes.clear();
|
|
4502
4610
|
vpDrivers.clear();
|
|
4503
4611
|
vpEngines.clear();
|
|
4612
|
+
asyncTaskOwners.clear();
|
|
4504
4613
|
sessionContexts.clear();
|
|
4505
4614
|
vpCurrentTodos.clear();
|
|
4506
4615
|
threadClassifier = defaultClassifyThread;
|