@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 +1 -1
- package/unify/engine.js +58 -0
- package/unify/sub-agent/runner.js +26 -7
- package/unify/web-bridge.js +32 -1
package/package.json
CHANGED
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',
|
|
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',
|
|
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,
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
313
|
+
try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_turn_end', content: assistantText })); } catch { /* ignore */ }
|
|
295
314
|
}
|
|
296
315
|
}
|
|
297
316
|
}
|
package/unify/web-bridge.js
CHANGED
|
@@ -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');
|