@yeaft/webchat-agent 0.1.1071 → 0.1.1073

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1071",
3
+ "version": "0.1.1073",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -449,10 +449,19 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
449
449
  return;
450
450
  }
451
451
 
452
- // Turn complete cleanly. Stash the result for WaitAgent and emit
453
- // a turn-end event for the UI. Loop re-enters: if more
454
- // pendingPrompts queued by PromptAgent, run the next; else idle.
452
+ // Turn complete cleanly. Task-backed sub-agents are one-shot
453
+ // background tasks: once they produce their mission result, complete
454
+ // the agent/task instead of letting the idle watchdog later mark the
455
+ // already-delivered work as abandoned. Legacy in-process callers with
456
+ // no TaskManager keep the old idle/PromptAgent continuation flow.
455
457
  emit({ type: 'sub_agent_turn_end', content: assistantText });
458
+ if (agent.taskId && deps.taskManager && agent.parentSessionId) {
459
+ transitionTerminal(agent, STATUS.COMPLETED, {
460
+ diagnostic: 'task_turn_complete',
461
+ deps,
462
+ });
463
+ return;
464
+ }
456
465
  }
457
466
  } finally {
458
467
  if (wallTimeWatchdog) clearTimeout(wallTimeWatchdog);
@@ -512,6 +521,9 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
512
521
  deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
513
522
  status: taskStatus,
514
523
  error: error || agent.error || null,
524
+ summary: status === STATUS.COMPLETED
525
+ ? (typeof agent.result === 'string' ? agent.result : (agent.lastResult || null))
526
+ : null,
515
527
  });
516
528
  } catch { /* ignore */ }
517
529
  }
@@ -519,34 +531,37 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
519
531
  try { deps.onEvent(agent.id, evt); } catch { /* ignore */ }
520
532
  }
521
533
 
522
- // Push the re-entry notification so the parent learns about this
523
- // even if it forgot to call WaitAgent. A prior idle notification may
524
- // still be indexed by agentId after the parent consumed the queue; remove
525
- // it so terminal state can replace that non-terminal progress notice.
534
+ // Push the legacy sub-agent notification so the parent learns about this
535
+ // even if it forgot to call WaitAgent. Task-backed sub-agents use the
536
+ // generic TaskManager async tool-result re-entry instead; queueing both
537
+ // would make the owner VP see duplicate results.
526
538
  try { consumeNotificationForAgent(agent.id); } catch { /* ignore */ }
527
- try {
528
- const budgetResult = agent.result && typeof agent.result === 'object'
529
- && agent.result.status === 'budget_exceeded'
530
- ? agent.result
531
- : null;
532
- enqueueTerminalNotification({
533
- agentId: agent.id,
534
- agentName: agent.name,
535
- status,
536
- result: budgetResult
537
- ? (budgetResult.partial_output || '')
538
- : (typeof agent.result === 'string' ? agent.result : (agent.lastResult || '')),
539
- error: error || agent.error || null,
540
- outputFile: agent.outputFile || null,
541
- turns: agent.usage?.turns || 0,
542
- parentVpId: agent.parentVpId || null,
543
- parentSessionId: agent.parentSessionId || null,
544
- parentThreadId: agent.parentThreadId || 'main',
545
- budgetExceeded: !!budgetResult,
546
- budgetReason: budgetResult?.reason || null,
547
- budgetUsage: budgetResult?.usage || null,
548
- });
549
- } catch { /* never let the notification queue throw kill the driver */ }
539
+ const taskBacked = !!(agent.taskId && deps?.taskManager && agent.parentSessionId);
540
+ if (!taskBacked) {
541
+ try {
542
+ const budgetResult = agent.result && typeof agent.result === 'object'
543
+ && agent.result.status === 'budget_exceeded'
544
+ ? agent.result
545
+ : null;
546
+ enqueueTerminalNotification({
547
+ agentId: agent.id,
548
+ agentName: agent.name,
549
+ status,
550
+ result: budgetResult
551
+ ? (budgetResult.partial_output || '')
552
+ : (typeof agent.result === 'string' ? agent.result : (agent.lastResult || '')),
553
+ error: error || agent.error || null,
554
+ outputFile: agent.outputFile || null,
555
+ turns: agent.usage?.turns || 0,
556
+ parentVpId: agent.parentVpId || null,
557
+ parentSessionId: agent.parentSessionId || null,
558
+ parentThreadId: agent.parentThreadId || 'main',
559
+ budgetExceeded: !!budgetResult,
560
+ budgetReason: budgetResult?.reason || null,
561
+ budgetUsage: budgetResult?.usage || null,
562
+ });
563
+ } catch { /* never let the notification queue throw kill the driver */ }
564
+ }
550
565
  }
551
566
 
552
567
  /**
@@ -182,7 +182,7 @@ export class TaskManager {
182
182
  return publicSnapshot(task);
183
183
  }
184
184
 
185
- #completeTask(sessionId, taskId, { status, exitCode = null, signal = null, error = null } = {}) {
185
+ #completeTask(sessionId, taskId, { status, exitCode = null, signal = null, error = null, summary = null } = {}) {
186
186
  const key = this.#key(sessionId, taskId);
187
187
  const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
188
188
  if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
@@ -192,9 +192,9 @@ export class TaskManager {
192
192
  task.updatedAt = nowIso();
193
193
  task.endedAt = nowIso();
194
194
  task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
195
- task.result = { ...(task.result || {}), exitCode, signal, error };
195
+ task.result = { ...(task.result || {}), exitCode, signal, error, summary };
196
196
  this.store.writeTask(task);
197
- this.store.appendEvent(sessionId, { event: 'completed', taskId, status: task.status, exitCode, signal, error });
197
+ this.store.appendEvent(sessionId, { event: 'completed', taskId, status: task.status, exitCode, signal, error, summary });
198
198
  this.active.delete(key);
199
199
  this.processes.delete(key);
200
200
  this.#emit('completed', task);
@@ -396,7 +396,7 @@ function buildVpPromptPayload(vpId, envelope) {
396
396
  export function visibleInboundThreadId(envelope, fallbackThreadId = 'main') {
397
397
  const meta = envelope?.msg?.meta || {};
398
398
  if (
399
- meta.injectedBy === 'route_forward'
399
+ (meta.injectedBy === 'route_forward' || meta.injectedBy === 'task_result')
400
400
  && typeof meta.sourceThreadId === 'string'
401
401
  && meta.sourceThreadId.trim()
402
402
  ) {
@@ -1024,13 +1024,74 @@ function enqueueForVp(sessionId, vpId, envelope) {
1024
1024
  registerRoutePromise(envelope?.msg?.id, routePromise);
1025
1025
  }
1026
1026
 
1027
+ function formatTaskResultForVp(task) {
1028
+ const result = task?.result || {};
1029
+ const log = task?.log || {};
1030
+ const lines = [
1031
+ `<task-result id="${task.id}" kind="${task.kind}" status="${task.status}">`,
1032
+ `title: ${task.title || task.kind || task.id}`,
1033
+ ];
1034
+ if (result.exitCode !== undefined && result.exitCode !== null) lines.push(`exitCode: ${result.exitCode}`);
1035
+ if (result.signal) lines.push(`signal: ${result.signal}`);
1036
+ if (result.error) lines.push(`error: ${result.error}`);
1037
+ if (result.summary) lines.push(`summary: ${result.summary}`);
1038
+ if (log.path) lines.push(`log: ${log.path}`);
1039
+ if (log.preview) {
1040
+ const preview = String(log.preview).slice(-4000);
1041
+ lines.push('logTail:');
1042
+ lines.push(preview.split('\n').map(line => ` ${line}`).join('\n'));
1043
+ }
1044
+ lines.push('</task-result>');
1045
+ lines.push('This is an asynchronous tool result from a background task, not a user message. Consume it now: tell the user the outcome or continue the work. Do not wait for another user turn.');
1046
+ return lines.join('\n');
1047
+ }
1048
+
1049
+ function scheduleTaskResultReentry(event) {
1050
+ if (!event || event.event !== 'completed' || !event.task) return;
1051
+ const task = event.task;
1052
+ const sessionId = task.sessionId || event.sessionId || null;
1053
+ const vpId = task.ownerVpId || null;
1054
+ if (!sessionId || !vpId) return;
1055
+ const threadId = task.source?.threadId || task.runtime?.threadId || 'main';
1056
+ const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1057
+ queueMicrotask(() => {
1058
+ enqueueForVp(sessionId, vpId, {
1059
+ sessionId,
1060
+ taskId: task.id,
1061
+ trigger: 'task_result',
1062
+ _promptSuffix: '',
1063
+ msg: {
1064
+ id: msgId,
1065
+ from: 'tool',
1066
+ role: 'assistant',
1067
+ text: formatTaskResultForVp(task),
1068
+ meta: {
1069
+ injectedBy: 'task_result',
1070
+ taskId: task.id,
1071
+ taskKind: task.kind,
1072
+ taskStatus: task.status,
1073
+ sourceThreadId: threadId,
1074
+ },
1075
+ },
1076
+ });
1077
+ });
1078
+ }
1079
+
1027
1080
  async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1028
1081
  const { text, prompt, promptParts } = buildVpPromptPayload(vpId, envelope);
1029
1082
  const runningThreads = getRunningThreads(sessionId, vpId);
1030
1083
  let thread = null;
1031
1084
  let related = false;
1032
1085
 
1033
- if (runningThreads.length === 0) {
1086
+ const meta = envelope?.msg?.meta || {};
1087
+ const isTaskResult = meta.injectedBy === 'task_result';
1088
+ const sourceThreadId = typeof meta.sourceThreadId === 'string' && meta.sourceThreadId.trim()
1089
+ ? meta.sourceThreadId.trim()
1090
+ : null;
1091
+
1092
+ if (isTaskResult && sourceThreadId) {
1093
+ thread = getOrCreateVpThread({ sessionId, vpId, threadId: sourceThreadId, title: fallbackTitle(text) });
1094
+ } else if (runningThreads.length === 0) {
1034
1095
  thread = getOrCreateVpThread({ sessionId, vpId, title: fallbackTitle(text) });
1035
1096
  } else {
1036
1097
  const decision = await threadClassifier({
@@ -1064,23 +1125,24 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1064
1125
 
1065
1126
  if (related) {
1066
1127
  const content = promptParts || prompt;
1067
- const isForwardAppend = envelope?.msg?.meta?.injectedBy === 'route_forward';
1128
+ const injectedBy = envelope?.msg?.meta?.injectedBy;
1129
+ const isInternalAppend = injectedBy === 'route_forward' || injectedBy === 'task_result';
1068
1130
  thread.pendingQueries.push({
1069
1131
  content,
1070
1132
  preview: prompt,
1071
1133
  originalText: text,
1072
1134
  originalParts: Array.isArray(envelope?._promptParts) ? envelope._promptParts : null,
1073
- internal: isForwardAppend,
1135
+ internal: isInternalAppend,
1074
1136
  });
1075
1137
  persistInboundMessageOnceByMsgId({
1076
1138
  msgId: envelope?.msg?.id,
1077
1139
  text,
1078
1140
  sessionId,
1079
1141
  threadId: visibleInboundThreadId(envelope, thread.threadId),
1080
- role: isForwardAppend ? 'assistant' : 'user',
1142
+ role: isInternalAppend ? 'assistant' : 'user',
1081
1143
  speakerVpId: envelope?.msg?.meta?.senderVpId || envelope?.msg?.from || null,
1082
1144
  attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1083
- internal: isForwardAppend,
1145
+ internal: isInternalAppend,
1084
1146
  });
1085
1147
  thread.updatedAt = Date.now();
1086
1148
  try {
@@ -1165,17 +1227,18 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1165
1227
  const envMsgId = envelope?.msg?.id;
1166
1228
  if (envMsgId && text) {
1167
1229
  const meta = envelope?.msg?.meta || {};
1168
- const isForward = meta.injectedBy === 'route_forward';
1169
- const senderVpId = isForward ? (meta.senderVpId || envelope?.msg?.from || null) : null;
1230
+ const injectedBy = meta.injectedBy;
1231
+ const isInternal = injectedBy === 'route_forward' || injectedBy === 'task_result';
1232
+ const senderVpId = isInternal ? (meta.senderVpId || envelope?.msg?.from || null) : null;
1170
1233
  persistInboundMessageOnceByMsgId({
1171
1234
  msgId: envMsgId,
1172
1235
  text,
1173
1236
  sessionId,
1174
1237
  threadId: visibleInboundThreadId(envelope, thread.threadId),
1175
- role: isForward ? 'assistant' : 'user',
1238
+ role: isInternal ? 'assistant' : 'user',
1176
1239
  speakerVpId: senderVpId,
1177
1240
  attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
1178
- internal: isForward,
1241
+ internal: isInternal,
1179
1242
  });
1180
1243
  }
1181
1244
  } catch { /* never crash WS pipeline */ }
@@ -1211,7 +1274,9 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1211
1274
  } catch { /* never crash WS pipeline */ }
1212
1275
  }
1213
1276
  try {
1214
- if (text && envelope?.msg) {
1277
+ const injectedBy = envelope?.msg?.meta?.injectedBy;
1278
+ const isInternalMessage = injectedBy === 'route_forward' || injectedBy === 'task_result';
1279
+ if (text && envelope?.msg && !isInternalMessage) {
1215
1280
  sendSessionEvent({
1216
1281
  type: 'session_message',
1217
1282
  sessionId,
@@ -1949,6 +2014,7 @@ export function installYeaftRuntimeBridge(s) {
1949
2014
  try {
1950
2015
  const sessionId = event?.task?.sessionId || event?.sessionId || null;
1951
2016
  sendSessionEvent(event, { sessionId });
2017
+ scheduleTaskResultReentry(event);
1952
2018
  } catch { /* never let task event delivery throw */ }
1953
2019
  });
1954
2020
  }
@@ -3182,8 +3248,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3182
3248
  // the source VP's tool action. Do not append it as a visible prompt for
3183
3249
  // the target VP turn; otherwise UI replay can show a trailing handoff
3184
3250
  // block after the target response.
3185
- const inboundIsRouteForward = inboundEnvelope?.msg?.meta?.injectedBy === 'route_forward';
3186
- const visiblePrompts = inboundIsRouteForward ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
3251
+ const inboundInjectedBy = inboundEnvelope?.msg?.meta?.injectedBy;
3252
+ const inboundIsInternal = inboundInjectedBy === 'route_forward' || inboundInjectedBy === 'task_result';
3253
+ const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
3187
3254
  appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
3188
3255
 
3189
3256
  sendSessionOutputFrame({
@@ -3368,7 +3435,7 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
3368
3435
  * Persist an inbound message row to disk EXACTLY ONCE per
3369
3436
  * coordinator-ingest call, keyed by the coordinator-assigned `msgId`.
3370
3437
  * Both `handleYeaftSessionSend` (real user input, persists as
3371
- * role='user') and `enqueueForVp`'s driver loop (route_forward
3438
+ * role='user') and `enqueueForVp`'s driver loop (route_forward / task_result
3372
3439
  * synthetic injections, persists as role='assistant' attributed via
3373
3440
  * `speakerVpId`) call this — the Set guard makes either path the
3374
3441
  * writer, whichever runs first, while the other becomes a no-op.
@@ -3427,9 +3494,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
3427
3494
  try {
3428
3495
  // role defaults to 'user' for back-compat: handleYeaftSessionSend's
3429
3496
  // real-user call site passes no role and gets a user row. The driver
3430
- // loop passes role='assistant' + speakerVpId for route_forward
3431
- // injections so the on-disk record correctly attributes the text to
3432
- // the sending VP.
3497
+ // loop passes role='assistant' + speakerVpId for route_forward /
3498
+ // task_result injections so the on-disk record correctly attributes
3499
+ // the internal trigger text.
3433
3500
  const persistRole = role === 'assistant' ? 'assistant' : 'user';
3434
3501
  const record = {
3435
3502
  role: persistRole,