@yeaft/webchat-agent 0.1.716 → 0.1.718

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.716",
3
+ "version": "0.1.718",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -219,6 +219,27 @@ export class Engine {
219
219
  /** @type {((agentId: string, evt: object) => void) | null} */
220
220
  #subAgentEventSink = null;
221
221
 
222
+ /**
223
+ * PR-4 — current-featureId accessor.
224
+ *
225
+ * The feature arc lives in the per-VP web-bridge (`runVpTurn` creates
226
+ * one per turn) so the engine itself does NOT own the featureId. To
227
+ * let sub-agents inherit their parent's featureId without coupling the
228
+ * engine to FeatureArc, the bridge plugs in a thin accessor right
229
+ * after creating the arc — `engine.setCurrentFeatureIdAccessor(() =>
230
+ * arc.getFeatureId() || null)`. The engine surfaces the result via
231
+ * `parentEngineDeps.getCurrentFeatureId` (read lazily at sub-agent
232
+ * event-emit time, so a feature that opens mid-turn still tags the
233
+ * sub-agent's later events).
234
+ *
235
+ * The accessor is cleared (set to null) by the bridge in the same
236
+ * `finally` block that clears the per-turn AbortController, so a
237
+ * stale arc reference can't leak into the next turn.
238
+ *
239
+ * @type {(() => (string|null)) | null}
240
+ */
241
+ #currentFeatureIdAccessor = null;
242
+
222
243
  /**
223
244
  * task-325a — abort state.
224
245
  *
@@ -716,6 +737,13 @@ export class Engine {
716
737
  parentVpPersona: vpCtx?.vpPersona || null,
717
738
  onEvent: this.#subAgentEventSink || null,
718
739
  language: this.#config?.language || 'en',
740
+ // PR-4: lazy accessor so the sub-agent runner can stamp the
741
+ // parent's active featureId on every forwarded event. Read at
742
+ // emit-time (NOT at spawn-time) so a feature that opens AFTER
743
+ // the sub-agent starts still tags later events.
744
+ getCurrentFeatureId: () => {
745
+ try { return this.#currentFeatureIdAccessor?.() || null; } catch { return null; }
746
+ },
719
747
  },
720
748
  };
721
749
  }
@@ -732,6 +760,36 @@ export class Engine {
732
760
  this.#subAgentEventSink = typeof sink === 'function' ? sink : null;
733
761
  }
734
762
 
763
+ /**
764
+ * PR-4 — install / clear the per-turn current-featureId accessor.
765
+ *
766
+ * Called by `runVpTurn` in web-bridge.js: right after `arc =
767
+ * createFeatureArc(...)` it passes `() => arc.getFeatureId() || null`,
768
+ * and clears it (passes null) in the `finally` so a stale arc
769
+ * reference can't leak into the next turn. The engine reads it
770
+ * lazily via `parentEngineDeps.getCurrentFeatureId` so sub-agents
771
+ * inherit whatever featureId is live at the moment they emit.
772
+ *
773
+ * Concurrency note: callers are expected to serialize per-VP turns
774
+ * (web-bridge does — `runVpTurn` runs one turn at a time per VP and
775
+ * its `finally` clears the accessor before the next turn lands).
776
+ * If two installs collide (which today would be a bug, not by
777
+ * design), we warn so it's visible in logs.
778
+ *
779
+ * @param {(() => (string|null)) | null} fn
780
+ */
781
+ setCurrentFeatureIdAccessor(fn) {
782
+ const next = typeof fn === 'function' ? fn : null;
783
+ if (next && this.#currentFeatureIdAccessor) {
784
+ // Canary: a prior accessor is still installed when we're about
785
+ // to overwrite it. Today's lifecycle (per-turn install + finally
786
+ // clear) means this should never fire; if it does, two turns
787
+ // are racing on the same engine.
788
+ console.warn('[Engine] setCurrentFeatureIdAccessor: overwriting non-null accessor — concurrent turns on the same VP engine?');
789
+ }
790
+ this.#currentFeatureIdAccessor = next;
791
+ }
792
+
735
793
  /**
736
794
  * Perform memory recall for a given prompt.
737
795
  *
@@ -181,6 +181,25 @@ export function startSubAgent(agent, deps = {}) {
181
181
  */
182
182
  async function driveSubAgent(agent, subEngine, vpPersona, deps) {
183
183
  const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
184
+ // PR-4: lazy parent-feature lookup. The parent's web-bridge installs
185
+ // an accessor on its engine right after creating the per-turn arc;
186
+ // that accessor flows here as `deps.getCurrentFeatureId`. We read it
187
+ // at every emit (NOT once at spawn-time) so a feature that opens AFTER
188
+ // the sub-agent starts still tags later events. Returns null when the
189
+ // parent is not in a feature run, in which case we leave `featureId`
190
+ // unset on the forwarded event (NOT explicitly null) so the frontend
191
+ // sub-agent card renders in its anchor-based fallback position.
192
+ const getParentFeatureId = (typeof deps.getCurrentFeatureId === 'function')
193
+ ? deps.getCurrentFeatureId
194
+ : null;
195
+ const wrapEvt = (evt) => {
196
+ const base = { ...evt, agentId: agent.id, agentName: agent.name };
197
+ if (!getParentFeatureId) return base;
198
+ let fid = null;
199
+ try { fid = getParentFeatureId(); } catch { fid = null; }
200
+ if (fid && !base.featureId) base.featureId = fid;
201
+ return base;
202
+ };
184
203
 
185
204
  // Helper: append a user message and either start or resume.
186
205
  const dequeueNextUserPrompt = () => {
@@ -197,7 +216,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
197
216
 
198
217
  agent.status = 'running';
199
218
  if (onEvent) {
200
- try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, agentName: agent.name, status: 'running' }); } catch { /* ignore */ }
219
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'running' })); } catch { /* ignore */ }
201
220
  }
202
221
 
203
222
  while (agent.status !== 'closed' && agent.status !== 'completed' && agent.status !== 'failed') {
@@ -206,7 +225,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
206
225
  // Nothing to do — go idle and wait for SendMessage / CloseAgent.
207
226
  agent.status = 'idle';
208
227
  if (onEvent) {
209
- try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'idle' }); } catch { /* ignore */ }
228
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'idle' })); } catch { /* ignore */ }
210
229
  }
211
230
  await waitUntilResumed(agent);
212
231
  // Either we have a new prompt now (back to running) or status is closed.
@@ -231,7 +250,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
231
250
  // the agent identity attached. Frontend renders these inside
232
251
  // the sub-agent's collapsed card.
233
252
  if (onEvent) {
234
- try { onEvent(agent.id, { ...evt, agentId: agent.id, agentName: agent.name }); } catch { /* ignore listener errors */ }
253
+ try { onEvent(agent.id, wrapEvt(evt)); } catch { /* ignore listener errors */ }
235
254
  }
236
255
  if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
237
256
  assistantText += evt.text;
@@ -250,7 +269,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
250
269
  agent.error = err && err.message ? err.message : String(err);
251
270
  agent.diagnostics.push({ type: 'query_error', error: agent.error, at: Date.now() });
252
271
  if (onEvent) {
253
- try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
272
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: agent.error })); } catch { /* ignore */ }
254
273
  }
255
274
  return;
256
275
  }
@@ -262,7 +281,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
262
281
  agent.error = streamError;
263
282
  agent.diagnostics.push({ type: 'stream_error', error: streamError, at: Date.now() });
264
283
  if (onEvent) {
265
- try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: streamError }); } catch { /* ignore */ }
284
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: streamError })); } catch { /* ignore */ }
266
285
  }
267
286
  return;
268
287
  }
@@ -281,7 +300,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
281
300
  agent.status = 'failed';
282
301
  agent.error = agent.error || 'sub-agent stream ended without end_turn';
283
302
  if (onEvent) {
284
- try { onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
303
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: agent.error })); } catch { /* ignore */ }
285
304
  }
286
305
  return;
287
306
  }
@@ -291,7 +310,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
291
310
  // by SendMessage, run the next; else go idle.
292
311
  agent.result = assistantText;
293
312
  if (onEvent) {
294
- try { onEvent(agent.id, { type: 'sub_agent_turn_end', agentId: agent.id, agentName: agent.name, content: assistantText }); } catch { /* ignore */ }
313
+ try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_turn_end', content: assistantText })); } catch { /* ignore */ }
295
314
  }
296
315
  }
297
316
  }
@@ -1615,6 +1615,13 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1615
1615
  const assistantTextParts = [];
1616
1616
  const toolCallsAccum = [];
1617
1617
  const toolResultsAccum = [];
1618
+ // PR-4 (review fix): hoist `vpEngine` so the `finally` can clear
1619
+ // the per-turn featureId accessor on the SAME engine instance
1620
+ // we installed it on — even if the VP was kicked or its group
1621
+ // deleted mid-turn (both code paths call `vpEngines.delete(...)`).
1622
+ // Calling `getOrCreateVpEngine` again from `finally` would
1623
+ // resurrect a zombie engine for a VP that no longer exists.
1624
+ let vpEngine = null;
1618
1625
 
1619
1626
  // task-707: per-VP engine + persistent group coord. The coord is
1620
1627
  // created in handleUnifyGroupChat via getOrCreateGroupContext and
@@ -1686,6 +1693,19 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1686
1693
  // ready; the main engine loop must not be held back waiting for it.
1687
1694
  arc.startTrackA();
1688
1695
 
1696
+ // PR-4: let sub-agents spawned during this turn inherit the
1697
+ // parent's active featureId. Read lazily inside the engine's
1698
+ // parentEngineDeps so a feature that opens AFTER a sub-agent
1699
+ // spawns still tags the sub-agent's later events. Cleared in the
1700
+ // `finally` below so a stale `arc` reference doesn't leak into
1701
+ // the next turn.
1702
+ vpEngine = getOrCreateVpEngine(groupId, vpId);
1703
+ if (typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
1704
+ vpEngine.setCurrentFeatureIdAccessor(() => {
1705
+ try { return arc?.getFeatureId?.() || null; } catch { return null; }
1706
+ });
1707
+ }
1708
+
1689
1709
  const handlerCtx = {
1690
1710
  assistantTextParts,
1691
1711
  toolCallsAccum,
@@ -1705,7 +1725,6 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1705
1725
  const trimmedMessages = trimSnapshotForBudget(baseSnapshot, {
1706
1726
  messageTokenBudget: session?.config?.messageTokenBudget,
1707
1727
  });
1708
- const vpEngine = getOrCreateVpEngine(groupId, vpId);
1709
1728
  for await (const event of vpEngine.query({
1710
1729
  prompt,
1711
1730
  messages: trimmedMessages,
@@ -1743,6 +1762,18 @@ async function runVpTurn({ prompt, groupId, vpId, turnId, envelope: inboundEnvel
1743
1762
  }, envelope);
1744
1763
  } finally {
1745
1764
  if (queryTimer) clearTimeout(queryTimer);
1765
+ // PR-4 (review fix): clear the accessor on the SAME engine
1766
+ // instance we installed it on. Reusing the captured reference
1767
+ // (instead of calling getOrCreateVpEngine again) avoids
1768
+ // resurrecting a zombie engine if the VP/group was torn down
1769
+ // mid-turn. `vpEngine` is null only when the install path threw
1770
+ // before the engine lookup (very early failure) — in that case
1771
+ // there's nothing to clear.
1772
+ try {
1773
+ if (vpEngine && typeof vpEngine.setCurrentFeatureIdAccessor === 'function') {
1774
+ vpEngine.setCurrentFeatureIdAccessor(null);
1775
+ }
1776
+ } catch { /* best-effort */ }
1746
1777
  }
1747
1778
  } catch (err) {
1748
1779
  const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');