@yeaft/webchat-agent 1.0.384 → 1.0.385

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/yeaft/engine.js CHANGED
@@ -694,6 +694,9 @@ export class Engine {
694
694
  /** Wire turn id of the active query, used by late async completion rows. */
695
695
  #currentQueryTurnId = null;
696
696
 
697
+ /** Durable formal-CLI root identity inherited by every row in this query. */
698
+ #currentCausalRootId = null;
699
+
697
700
  /**
698
701
  * Identity-bound hook into the active query's local read-only result cache.
699
702
  * Async task terminal events can arrive while an adapter stream is already
@@ -1538,8 +1541,17 @@ export class Engine {
1538
1541
  return Boolean(this.#conversationStore) && !this.#config._readOnly;
1539
1542
  }
1540
1543
 
1541
- #conversationRecord(message, { sessionId, turnId, model, incomplete = false, stopReason = null, executionOrigin = null } = {}) {
1544
+ #conversationRecord(message, { sessionId, turnId, causalRootId = undefined, model, incomplete = false, stopReason = null, executionOrigin = null } = {}) {
1542
1545
  const effectiveTurnId = turnId || message.turnId || null;
1546
+ const normalizeId = value => (typeof value === 'string' && value.trim() ? value.trim() : null);
1547
+ // `null` is an explicit override used by a carried T2 reflection whose
1548
+ // originating query predates causal-root metadata. `undefined` inherits the
1549
+ // active query, which keeps every ordinary write path centralized here.
1550
+ const effectiveCausalRootId = causalRootId === null
1551
+ ? null
1552
+ : (normalizeId(causalRootId)
1553
+ || normalizeId(message.causalRootId)
1554
+ || this.#currentCausalRootId);
1543
1555
  const effectiveVpId = message.speakerVpId || this.#vpId || null;
1544
1556
  const record = {
1545
1557
  role: message.role,
@@ -1569,6 +1581,7 @@ export class Engine {
1569
1581
  if (effectiveTurnId && (message.role === 'assistant' || message.role === 'tool' || message.internal === true)) {
1570
1582
  record.turnId = effectiveTurnId;
1571
1583
  }
1584
+ if (effectiveCausalRootId) record.causalRootId = effectiveCausalRootId;
1572
1585
  if (executionOrigin === 'route_forward' && (message.role === 'assistant' || message.role === 'tool')) {
1573
1586
  record.executionOrigin = executionOrigin;
1574
1587
  }
@@ -1997,6 +2010,8 @@ export class Engine {
1997
2010
  * user-message content; the string `prompt` is then only used for
1998
2011
  * logging / history. When omitted the engine falls back to the
1999
2012
  * string-prompt shape (no regression for existing callers).
2013
+ * @param {string|null} [params.causalRootId] - Stable durable identity for
2014
+ * every row generated as part of one externally accepted causal root.
2000
2015
  * @yields {EngineEvent}
2001
2016
  */
2002
2017
  async *query(params = {}) {
@@ -2045,7 +2060,7 @@ export class Engine {
2045
2060
  }
2046
2061
  }
2047
2062
 
2048
- async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
2063
+ async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
2049
2064
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
2050
2065
  const error = new Error('prompt is required and must be a non-empty string');
2051
2066
  yield {
@@ -2091,6 +2106,8 @@ export class Engine {
2091
2106
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2092
2107
  ? collabToolPolicy
2093
2108
  : null;
2109
+ const effectiveCausalRootId = typeof causalRootId === 'string' && causalRootId.trim()
2110
+ ? causalRootId.trim() : null;
2094
2111
 
2095
2112
  // ─── task-325a: engine-owned AbortController ─────────────
2096
2113
  // We create our own controller for this query run so `engine.abort()`
@@ -2134,7 +2151,8 @@ export class Engine {
2134
2151
  };
2135
2152
  try {
2136
2153
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
2137
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: explicitUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2154
+ this.#currentCausalRootId = effectiveCausalRootId;
2155
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: explicitUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, causalRootId: effectiveCausalRootId, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2138
2156
  } finally {
2139
2157
  // Closing the async generator at a visible retry boundary means the
2140
2158
  // continuation never reached a provider. Keep it out of history and
@@ -2163,6 +2181,7 @@ export class Engine {
2163
2181
  this.#currentAbortCtrl = null;
2164
2182
  this.#abortReason = null;
2165
2183
  this.#currentQueryTurnId = null;
2184
+ this.#currentCausalRootId = null;
2166
2185
  this.#currentThreadId = MAIN_THREAD_ID;
2167
2186
  this.#pendingUserMessages.length = 0;
2168
2187
  this.#externalUserWakePending = false;
@@ -2188,7 +2207,7 @@ export class Engine {
2188
2207
  * in a try/finally without indenting the whole loop.
2189
2208
  * @private
2190
2209
  */
2191
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
2210
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
2192
2211
 
2193
2212
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2194
2213
  ? collabToolPolicy
@@ -3961,6 +3980,7 @@ export class Engine {
3961
3980
  count: pairs.length,
3962
3981
  originalUserMsg: prompt,
3963
3982
  originatingTurnId: queryTurnId,
3983
+ causalRootId,
3964
3984
  executionOrigin,
3965
3985
  ready: false,
3966
3986
  result: null,
@@ -4782,6 +4802,7 @@ export class Engine {
4782
4802
  reflectionMessage,
4783
4803
  {
4784
4804
  ...context,
4805
+ causalRootId: info.causalRootId || null,
4785
4806
  executionOrigin: info.executionOrigin === 'route_forward' ? 'route_forward' : null,
4786
4807
  },
4787
4808
  );
@@ -4827,6 +4848,12 @@ export class Engine {
4827
4848
  return this.#currentThreadId || MAIN_THREAD_ID;
4828
4849
  }
4829
4850
 
4851
+ /** Stable owner scope for external exactly-once task coordinators. */
4852
+ get sessionId() { return this.#sessionId; }
4853
+
4854
+ /** Stable owner scope for external exactly-once task coordinators. */
4855
+ get vpId() { return this.#vpId; }
4856
+
4830
4857
  /**
4831
4858
  * Append a user message into the currently running query. The loop consumes
4832
4859
  * it only at adapter boundaries, never mid-token and never between an
@@ -89,6 +89,7 @@ export function createRouter(deps = {}) {
89
89
  }
90
90
  const meta = coordinator.group.getMeta();
91
91
  if (!meta) return { ok: false, error: 'group_not_initialised' };
92
+ const claimedVpIds = args.inboundEnvelope?._cliTurnContext?.claimedVpIds;
92
93
 
93
94
  // Roster membership — `all` is reserved broadcast sentinel handled by
94
95
  // coordinator; anything else must resolve to a real member so we fail fast
@@ -101,6 +102,9 @@ export function createRouter(deps = {}) {
101
102
  if (targetVpId === from) {
102
103
  return { ok: false, error: 'self_forward_rejected' };
103
104
  }
105
+ if (targetVpId !== 'all' && claimedVpIds?.has?.(targetVpId)) {
106
+ return { ok: false, error: 'target_already_claimed' };
107
+ }
104
108
 
105
109
  // Build the causedBy chain BEFORE constructing the synthetic user-like
106
110
  // message. We don't know the new msgId yet (coordinator mints it on
@@ -133,15 +137,11 @@ export function createRouter(deps = {}) {
133
137
  // stamp + `synthetic` marker let Coordinator's `selectRespondingVps`
134
138
  // still treat this like a routed turn (target VPs need to respond) even
135
139
  // though role is now 'assistant'.
136
- const injectText = targetVpId === 'all'
137
- ? `@all ${text}`
138
- : `@${targetVpId} ${text}`;
139
-
140
140
  const report = coordinator.ingest(
141
141
  {
142
142
  from, // real VP id — preserved for provenance
143
143
  role: 'assistant', // VP-authored — persists as assistant turn
144
- text: injectText,
144
+ text,
145
145
  taskId: args.taskId ?? null,
146
146
  // route_forward is already visible as the source VP's tool action.
147
147
  // Persist the synthetic handoff for audit/dispatch, but keep it out
@@ -151,6 +151,7 @@ export function createRouter(deps = {}) {
151
151
  meta: {
152
152
  synthetic: true,
153
153
  injectedBy: 'route_forward',
154
+ routeForwardTarget: targetVpId,
154
155
  senderVpId: from,
155
156
  reason: args.reason || null,
156
157
  causedBy: chain,
@@ -74,11 +74,25 @@ export function createCoordinator(group, options = {}) {
74
74
  // treat it as "user-like" for dispatch purposes only. Persistence still
75
75
  // honours the caller's `role` field so the on-disk record correctly
76
76
  // attributes the turn to the sending VP, not to the user.
77
- const isRouteForwardInjection = input?.meta?.injectedBy === 'route_forward';
77
+ const inputMeta = input?.meta && typeof input.meta === 'object' ? input.meta : {};
78
+ const routingIntent = input?._routingIntent && typeof input._routingIntent === 'object'
79
+ ? input._routingIntent
80
+ : null;
81
+ const isRouteForwardInjection = inputMeta.injectedBy === 'route_forward';
82
+ const isTaskResultInjection = inputMeta.injectedBy === 'task_result';
78
83
  const fromUser = input.from === 'user'
79
84
  || input.role === 'user'
80
- || isRouteForwardInjection;
81
- const mentions = parseMentions(input.text);
85
+ || isRouteForwardInjection
86
+ || isTaskResultInjection;
87
+ const forcedRouteTarget = (isRouteForwardInjection || isTaskResultInjection)
88
+ && typeof inputMeta.routeTargetVpId === 'string'
89
+ ? inputMeta.routeTargetVpId.trim()
90
+ : (isRouteForwardInjection && typeof inputMeta.routeForwardTarget === 'string'
91
+ ? inputMeta.routeForwardTarget.trim()
92
+ : '');
93
+ const mentions = routingIntent
94
+ ? (routingIntent.broadcast === true ? ['all'] : routingIntent.targetVpIds.slice())
95
+ : (forcedRouteTarget ? [forcedRouteTarget] : parseMentions(input.text));
82
96
 
83
97
  // Persist first — audit log / replay works even if dispatch has bugs.
84
98
  //
@@ -137,38 +151,49 @@ export function createCoordinator(group, options = {}) {
137
151
  };
138
152
  }
139
153
 
154
+ const deliverSelected = (vpIds, trigger) => {
155
+ const dispatched = [];
156
+ const errors = [];
157
+ for (const vpId of vpIds) {
158
+ const outcome = deliver(vpId, makeEnvelope(stored, meta, trigger, ephemeral));
159
+ if (outcome === false || outcome?.ok === false) {
160
+ errors.push({ vpId, error: outcome?.error || 'delivery_rejected' });
161
+ } else {
162
+ dispatched.push(vpId);
163
+ }
164
+ }
165
+ return { dispatched, errors };
166
+ };
167
+
140
168
  if (selection.reason === 'broadcast') {
141
- const envelope = makeEnvelope(stored, meta, 'broadcast', ephemeral);
142
- for (const vpId of selection.dispatched) deliver(vpId, envelope);
169
+ const delivered = deliverSelected(selection.dispatched, 'broadcast');
143
170
  return {
144
171
  message: stored,
145
- dispatched: selection.dispatched,
172
+ dispatched: delivered.dispatched,
146
173
  fallback: null,
147
- errors: selection.errors,
174
+ errors: [...selection.errors, ...delivered.errors],
148
175
  broadcast: true,
149
176
  truncatedAtFanOutCap: !!selection.truncatedAtFanOutCap,
150
177
  };
151
178
  }
152
179
 
153
180
  if (selection.reason === 'mention') {
154
- for (const vpId of selection.dispatched) {
155
- deliver(vpId, makeEnvelope(stored, meta, 'mention', ephemeral));
156
- }
181
+ const delivered = deliverSelected(selection.dispatched, 'mention');
157
182
  return {
158
183
  message: stored,
159
- dispatched: selection.dispatched,
184
+ dispatched: delivered.dispatched,
160
185
  fallback: null,
161
- errors: selection.errors,
186
+ errors: [...selection.errors, ...delivered.errors],
162
187
  };
163
188
  }
164
189
 
165
190
  if (selection.reason === 'fallback' && selection.fallback) {
166
- deliver(selection.fallback, makeEnvelope(stored, meta, 'fallback', ephemeral));
191
+ const delivered = deliverSelected([selection.fallback], 'fallback');
167
192
  return {
168
193
  message: stored,
169
- dispatched: selection.dispatched,
170
- fallback: selection.fallback,
171
- errors: selection.errors,
194
+ dispatched: delivered.dispatched,
195
+ fallback: delivered.dispatched.includes(selection.fallback) ? selection.fallback : null,
196
+ errors: [...selection.errors, ...delivered.errors],
172
197
  };
173
198
  }
174
199