@yeaft/webchat-agent 1.0.246 → 1.0.248

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.
@@ -1,5 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { eventMatchesActionGeneration, runMatchesActionIdentity } from './action-identity.js';
2
+ import {
3
+ currentActionInputEventIds,
4
+ eventMatchesActionGeneration,
5
+ runMatchesActionIdentity,
6
+ } from './action-identity.js';
3
7
  import { normalizeSessionContextSnapshot } from './session-context.js';
4
8
 
5
9
  export const MAINLINE_CONTEXT_HARD_LIMIT_BYTES = 64 * 1024;
@@ -64,32 +68,93 @@ function clamp(value, minimum, maximum) {
64
68
  return Math.min(maximum, Math.max(minimum, value));
65
69
  }
66
70
 
67
- function workItemMessageView(messages) {
68
- return (Array.isArray(messages) ? messages : [])
69
- .filter(message => typeof message?.text === 'string' && message.text)
71
+ function inputEventView(event) {
72
+ return {
73
+ eventId: event.id,
74
+ inputId: event.data?.inputId || null,
75
+ actionId: event.actionId || null,
76
+ text: event.data?.text || '',
77
+ attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
78
+ };
79
+ }
80
+
81
+ function canonicalActionUserContext(events, action) {
82
+ const actionEvents = (Array.isArray(events) ? events : [])
83
+ .filter(event => event?.actionId === action?.id
84
+ && ['action.guidance_added', 'action.input_added'].includes(event.type))
70
85
  .slice()
71
- .sort((left, right) => count(left.createdAt) - count(right.createdAt)
72
- || String(left.id).localeCompare(String(right.id)))
73
- .map(message => ({
74
- messageId: message.id,
75
- text: message.text,
76
- createdAt: count(message.createdAt),
77
- }));
86
+ .sort((left, right) => count(left.id) - count(right.id));
87
+ const inputEvents = actionEvents.filter(event => event.type === 'action.input_added');
88
+ const validInputEventIds = currentActionInputEventIds(events, action);
89
+ const eventByInputId = new Map(inputEvents
90
+ .filter(event => event.data?.inputId)
91
+ .map(event => [event.data.inputId, event]));
92
+ const currentInputEvents = inputEvents.filter(event => validInputEventIds.has(String(event.id)));
93
+ const usedEventIds = new Set();
94
+ const contextEntries = (Array.isArray(action?.context) ? action.context : [])
95
+ .filter(entry => ['input', 'guidance', 'coordinator-guidance'].includes(entry?.type)
96
+ && typeof entry.summary === 'string');
97
+ const values = contextEntries.flatMap((entry, index) => {
98
+ if (entry.type !== 'input') {
99
+ const event = actionEvents.find(candidate => !usedEventIds.has(candidate.id)
100
+ && candidate.type === 'action.guidance_added'
101
+ && (candidate.data?.guidance || '') === entry.summary) || null;
102
+ if (event) usedEventIds.add(event.id);
103
+ return [{
104
+ eventId: event?.id ?? null,
105
+ inputId: null,
106
+ actionId: action.id,
107
+ text: entry.summary,
108
+ attachments: Array.isArray(entry.attachments)
109
+ ? entry.attachments
110
+ : Array.isArray(event?.data?.attachments) ? event.data.attachments : [],
111
+ }];
112
+ }
113
+ let event = entry.inputId ? eventByInputId.get(entry.inputId) : null;
114
+ if (!event && typeof entry.inputId === 'string' && entry.inputId.startsWith('legacy-event:')) {
115
+ const legacyEventId = Number(entry.inputId.slice('legacy-event:'.length));
116
+ event = inputEvents.find(candidate => candidate.id === legacyEventId) || null;
117
+ }
118
+ if (!event && !entry.inputId) {
119
+ event = currentInputEvents.find(candidate => !usedEventIds.has(candidate.id)
120
+ && (candidate.data?.text || '') === entry.summary) || null;
121
+ }
122
+ if (!entry.inputId && !event) return [];
123
+ if (event) usedEventIds.add(event.id);
124
+ return [{
125
+ eventId: event?.id ?? null,
126
+ inputId: entry.inputId || event?.data?.inputId || `legacy-context:${index}`,
127
+ actionId: action.id,
128
+ text: entry.summary,
129
+ attachments: Array.isArray(entry.attachments)
130
+ ? entry.attachments
131
+ : Array.isArray(event?.data?.attachments) ? event.data.attachments : [],
132
+ }];
133
+ });
134
+ return { values, usedEventIds, validInputEventIds };
78
135
  }
79
136
 
80
137
  function guidanceView(events, action) {
81
- return (Array.isArray(events) ? events : [])
138
+ const allEvents = Array.isArray(events) ? events : [];
139
+ const canonicalEntries = canonicalActionUserContext(allEvents, action);
140
+ const currentEvents = allEvents
82
141
  .filter(event => event?.actionId === action?.id
83
- && eventMatchesActionGeneration(event, action)
84
- && ['action.guidance_added', 'action.input_added'].includes(event.type))
142
+ && ((event.type === 'action.input_added'
143
+ && canonicalEntries.validInputEventIds.has(String(event.id)))
144
+ || (event.type === 'action.guidance_added' && eventMatchesActionGeneration(event, action)))
145
+ && !canonicalEntries.usedEventIds.has(event.id))
85
146
  .slice()
86
147
  .sort((left, right) => count(left.id) - count(right.id))
87
- .map(event => ({
88
- eventId: event.id,
89
- actionId: event.actionId || null,
90
- text: event.data?.guidance || event.data?.text || '',
91
- attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
92
- }));
148
+ .map(event => event.type === 'action.input_added'
149
+ ? inputEventView(event)
150
+ : {
151
+ eventId: event.id,
152
+ inputId: null,
153
+ actionId: event.actionId || null,
154
+ text: event.data?.guidance || '',
155
+ attachments: Array.isArray(event.data?.attachments) ? event.data.attachments : [],
156
+ });
157
+ return [...canonicalEntries.values, ...currentEvents];
93
158
  }
94
159
 
95
160
  /**
@@ -259,21 +324,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
259
324
  return false;
260
325
  };
261
326
  const sessionContext = normalizeSessionContextSnapshot(detail.sessionContext);
262
- const workItemMessages = workItemMessageView(detail.messages);
263
327
  const guidance = guidanceView(detail.events, action);
264
- const newestFirstMessages = workItemMessages.slice().reverse();
265
- for (const [index, message] of newestFirstMessages.entries()) {
266
- const next = {
267
- ...snapshot.userContext,
268
- workItemMessages: [message, ...snapshot.userContext.workItemMessages],
269
- includedCount: snapshot.userContext.includedCount + 1,
270
- omittedCount: 0,
271
- };
272
- const included = trySet('userContext', next);
273
- if (index === 0 && !included) {
274
- throw mainlineContextBlocked('Latest WorkItem message exceeds the Mainline prompt budget');
275
- }
276
- }
277
328
  const otherUserEntries = [
278
329
  ...guidance.map(value => ({ kind: 'guidance', value })),
279
330
  ...sessionContext.map(value => ({ kind: 'sessionContext', value })),
@@ -287,8 +338,7 @@ export function buildMainlineContextSnapshot(detail, action, budgetInput = {}) {
287
338
  };
288
339
  trySet('userContext', next);
289
340
  }
290
- snapshot.userContext.omittedCount = workItemMessages.length + otherUserEntries.length
291
- - snapshot.userContext.includedCount;
341
+ snapshot.userContext.omittedCount = otherUserEntries.length - snapshot.userContext.includedCount;
292
342
 
293
343
  const siblingEntries = Object.entries(projection.canonicalActionResults)
294
344
  .filter(([actionId]) => actionId !== action.id && !dependencies.some(item => item.actionId === actionId))
@@ -4,6 +4,7 @@ import {
4
4
  canonicalActionId,
5
5
  canonicalExplicitActionId,
6
6
  canonicalExplicitActionIds,
7
+ validateGeneratedCompletionGate,
7
8
  } from './workflow.js';
8
9
 
9
10
  function cleanProposalId(value) {
@@ -174,9 +175,15 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
174
175
  }),
175
176
  ...canonicalActions,
176
177
  ];
178
+ const orderedActions = stableTopologicalActions(mergedActions);
179
+ validateGeneratedCompletionGate(orderedActions.map(action => ({
180
+ id: action.id,
181
+ type: action.type,
182
+ dependsOnStageIds: action.dependsOnActionIds || [],
183
+ })));
177
184
  const rawPlan = {
178
185
  workItemType: workItem.workflowSnapshot.workItemType,
179
- actions: stableTopologicalActions(mergedActions),
186
+ actions: orderedActions,
180
187
  };
181
188
  const workflowSnapshot = applyGeneratedPlan(synthetic, rawPlan, { availableVpIds });
182
189
  const addedStages = workflowSnapshot.stages.filter(stage => addedIds.has(stage.id));
@@ -189,6 +196,96 @@ export function applyAdditivePlanProposal({ workItem, actions, proposal, availab
189
196
  };
190
197
  }
191
198
 
199
+ export function applyCoordinatorReplan({ workItem, actions, proposal, availableVpIds = null }) {
200
+ if (workItem.workflowSnapshot?.executionMode !== 'graph'
201
+ || workItem.workflowSnapshot?.planningMode !== 'ai') {
202
+ throw new Error('Work Center Coordinator replan requires an AI-planned Action graph');
203
+ }
204
+ if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) {
205
+ throw new Error('Work Center Coordinator replan must be an object');
206
+ }
207
+ const proposalId = cleanProposalId(proposal.proposalId);
208
+ const basePlanRevision = Number(proposal.basePlanRevision);
209
+ if (!Number.isInteger(basePlanRevision) || basePlanRevision !== workItem.planRevision) {
210
+ throw new Error('Work Center Coordinator replan has a stale basePlanRevision');
211
+ }
212
+ if (!Array.isArray(proposal.actions) || proposal.actions.length < 1 || proposal.actions.length > 8) {
213
+ throw new Error('Work Center Coordinator replan requires between 1 and 8 unfinished Actions');
214
+ }
215
+
216
+ const active = actions.filter(candidate => !['superseded', 'cancelled'].includes(candidate.status));
217
+ const completed = active.filter(candidate => candidate.status === 'completed');
218
+ const completedStageIds = new Set(completed.map(candidate => candidate.stageId));
219
+ const unfinished = active.filter(candidate => candidate.status !== 'completed');
220
+ const unfinishedByStage = new Map(unfinished.map(candidate => [candidate.stageId, candidate]));
221
+ const historicalStageIds = new Set(actions.map(candidate => candidate.stageId));
222
+ const currentStages = new Map((workItem.workflowSnapshot.stages || []).map(stage => [stage.id, stage]));
223
+ const futureStageIds = new Set();
224
+
225
+ const normalizedFuture = proposal.actions.map(raw => {
226
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
227
+ throw new Error('Work Center Coordinator replan requires full Action specifications');
228
+ }
229
+ const id = canonicalActionId(raw.id);
230
+ if (!id || futureStageIds.has(id) || completedStageIds.has(id)) {
231
+ throw new Error(`Work Center Coordinator Action id is missing, duplicated, or completed: ${id || '(missing)'}`);
232
+ }
233
+ if (historicalStageIds.has(id) && !unfinishedByStage.has(id)) {
234
+ throw new Error(`Work Center Coordinator Action reuses historical stage identity: ${id}`);
235
+ }
236
+ futureStageIds.add(id);
237
+ const dependsOnActionIds = canonicalExplicitActionIds(
238
+ raw.dependsOnActionIds,
239
+ `Coordinator Action "${id}" dependencies`,
240
+ );
241
+ for (const dependencyId of dependsOnActionIds) {
242
+ if (!completedStageIds.has(dependencyId) && !futureStageIds.has(dependencyId)) {
243
+ throw new Error(`Work Center Coordinator Action "${id}" references a missing or future dependency "${dependencyId}"`);
244
+ }
245
+ }
246
+ return {
247
+ ...raw,
248
+ id,
249
+ dependsOnActionIds,
250
+ changesRequestedActionId: Object.hasOwn(raw, 'changesRequestedActionId')
251
+ ? canonicalExplicitActionId(raw.changesRequestedActionId, `Coordinator Action "${id}" review target`)
252
+ : undefined,
253
+ };
254
+ });
255
+
256
+ const completedInputs = completed.filter(candidate => candidate.type !== 'triage').map(candidate => {
257
+ const stage = currentStages.get(candidate.stageId);
258
+ if (!stage) throw new Error(`Work Center completed Action is missing from the frozen workflow: ${candidate.stageId}`);
259
+ return planActionFromStage(stage);
260
+ });
261
+ const synthetic = {
262
+ ...workItem,
263
+ workflowSnapshot: {
264
+ ...workItem.workflowSnapshot,
265
+ actionTemplates: [],
266
+ stages: [workItem.workflowSnapshot.stages[0]],
267
+ },
268
+ };
269
+ const workflowSnapshot = applyGeneratedPlan(synthetic, {
270
+ workItemType: workItem.workflowSnapshot.workItemType,
271
+ actions: [...completedInputs, ...normalizedFuture],
272
+ }, { availableVpIds });
273
+ const stageById = new Map(workflowSnapshot.stages.map(stage => [stage.id, stage]));
274
+
275
+ return {
276
+ proposalId,
277
+ reason: typeof proposal.reason === 'string' ? proposal.reason.trim().slice(0, 4_000) : '',
278
+ basePlanRevision,
279
+ workflowSnapshot,
280
+ unfinished,
281
+ nextActions: normalizedFuture.map(input => {
282
+ const prior = unfinishedByStage.get(input.id) || null;
283
+ const nextAction = actionForStage(stageById.get(input.id), { ...workItem, workflowSnapshot }, []);
284
+ return { prior, nextAction };
285
+ }),
286
+ };
287
+ }
288
+
192
289
  export function applyReplanMutation({ workItem, action, actions, proposal, availableVpIds = null }) {
193
290
  if (workItem.workflowSnapshot?.executionMode !== 'graph'
194
291
  || action?.type !== 'triage'
@@ -283,7 +380,7 @@ export function applyReplanMutation({ workItem, action, actions, proposal, avail
283
380
  }, { availableVpIds });
284
381
  const stageById = new Map(workflowSnapshot.stages.map(stage => [stage.id, stage]));
285
382
  const context = (Array.isArray(action.context) ? action.context : [])
286
- .filter(entry => entry?.type !== 'replan-barrier');
383
+ .filter(entry => entry?.type !== 'replan-barrier' && entry?.type !== 'input');
287
384
  return {
288
385
  proposalId,
289
386
  basePlanRevision,
@@ -732,6 +732,7 @@ function enforceWorkItemBrowserDtoBudget(value, options = {}) {
732
732
  omittedActionCount: originalCount,
733
733
  createdAt: count(workItem.createdAt),
734
734
  updatedAt: count(workItem.updatedAt),
735
+ coordinatorRevision: count(workItem.coordinatorRevision),
735
736
  };
736
737
  if (options.event === true) dto.workItem = minimalWorkItem;
737
738
  else return minimalWorkItem;
@@ -845,6 +846,9 @@ export function projectWorkItemDetail(detail) {
845
846
  const projected = {
846
847
  id: detail.id,
847
848
  revision: detail.revision,
849
+ planRevision: count(detail.planRevision),
850
+ ledgerRevision: count(detail.ledgerRevision),
851
+ coordinatorRevision: count(detail.coordinatorRevision),
848
852
  title: detail.title,
849
853
  goal: detail.goal,
850
854
  acceptanceCriteria: Array.isArray(detail.acceptanceCriteria) ? detail.acceptanceCriteria : [],
@@ -870,8 +874,21 @@ export function projectWorkItemDetail(detail) {
870
874
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
871
875
  messages: (Array.isArray(detail.messages) ? detail.messages : []).slice(-100).map(message => ({
872
876
  id: String(message.id || ''),
877
+ turnId: String(message.turnId || message.id || ''),
878
+ role: message.role === 'assistant' ? 'assistant' : message.role === 'legacy_instruction' ? 'legacy_instruction' : 'user',
873
879
  text: truncateUtf8(message.text || '', MAX_ACTION_MESSAGE_CHARS),
880
+ status: ['thinking', 'completed', 'failed'].includes(message.status) ? message.status : 'completed',
881
+ error: truncateUtf8(message.error || '', MAX_ACTION_DIAGNOSTIC_CHARS) || null,
882
+ decision: message.decision && typeof message.decision === 'object' ? {
883
+ kind: ['answer', 'guide_actions', 'replan'].includes(message.decision.kind)
884
+ ? message.decision.kind : null,
885
+ reason: truncateUtf8(message.decision.reason || '', MAX_ACTION_DIAGNOSTIC_CHARS),
886
+ changedContract: message.decision.changedContract === true,
887
+ affectedActionIds: Array.isArray(message.decision.affectedActionIds)
888
+ ? message.decision.affectedActionIds.map(id => String(id)).slice(0, 8) : [],
889
+ } : null,
874
890
  createdAt: count(message.createdAt),
891
+ updatedAt: count(message.updatedAt || message.createdAt),
875
892
  })),
876
893
  attachments: projectAttachments(detail.attachments),
877
894
  createdAt: detail.createdAt,
@@ -900,6 +917,9 @@ export function projectWorkItemSummary(detail) {
900
917
  return {
901
918
  id: detail.id,
902
919
  revision: detail.revision,
920
+ planRevision: count(detail.planRevision),
921
+ ledgerRevision: count(detail.ledgerRevision),
922
+ coordinatorRevision: count(detail.coordinatorRevision),
903
923
  title: detail.title,
904
924
  goal: detail.goal,
905
925
  workItemType: detail.workflowSnapshot?.workItemType || detail.workItemType || null,
@@ -931,6 +951,9 @@ export function projectWorkItemSummary(detail) {
931
951
  return {
932
952
  id: detail.id,
933
953
  revision: detail.revision,
954
+ planRevision: count(detail.planRevision),
955
+ ledgerRevision: count(detail.ledgerRevision),
956
+ coordinatorRevision: count(detail.coordinatorRevision),
934
957
  title: detail.title,
935
958
  goal: detail.goal,
936
959
  workItemType: detail.workflowSnapshot?.workItemType || detail.workItemType || null,
@@ -578,7 +578,7 @@ function completionContract(action, workItem) {
578
578
  "acceptanceChecks": ${JSON.stringify(acceptanceChecks)},
579
579
  "waitingReason": null,
580
580
  "error": null${reviewField}${triageField}${planField}
581
- }\nFor completed, provide at least one concrete evidence item and exactly one acceptanceChecks entry for every current acceptance criterion, in the same order, with status passed, deferred, or not_applicable and a non-empty evidence reference. Triage must use its proposed criteria when submitting a contractPatch. Test, approved review, and deliver require every criterion to be passed; if a criterion is not applicable, triage must remove or rewrite it through contractPatch before verification. This is a deterministic submission gate, not independent proof: later test, review, and deliver Actions must verify the claims. A model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
581
+ }\nFor completed, provide at least one concrete evidence item and exactly one acceptanceChecks entry for every current acceptance criterion, in the same order, with status passed, deferred, or not_applicable and a non-empty evidence reference. Triage must use its proposed criteria when submitting a contractPatch. An intermediate Action may defer criteria outside its task-specific expected result; the final deliver Action, and an approved review with no downstream work, require every criterion to pass. If a criterion is no longer applicable, ask the WorkItem Coordinator to revise the contract instead of pretending it passed. This is a deterministic submission gate, not independent proof: later verification and delivery Actions must verify the claims. A model turn ending is not completion. Use waiting when user or external input is required. Use retryable only for a transient failure. Do not start background jobs or delegate this Action.`;
582
582
  }
583
583
 
584
584
  function safeCheckpointUrl(value) {
@@ -105,6 +105,7 @@ export class WorkCenterService {
105
105
  this.controller = options.controller || new WorkflowController(this.store, {
106
106
  listAvailableVpIds: options.listAvailableVpIds,
107
107
  });
108
+ this.coordinator = options.coordinator || null;
108
109
  this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : () => {};
109
110
  this.watcher = new WorkItemWatcher({
110
111
  store: this.store,
@@ -271,14 +272,22 @@ export class WorkCenterService {
271
272
  return { id, deleted: true, cleanupWarning };
272
273
  }
273
274
  case 'work_item_message': {
275
+ if (!this.coordinator) throw new Error('Work Center Coordinator is unavailable');
274
276
  const id = requiredString(payload.id, 'id');
275
- const detail = this.controller.message(id, {
277
+ const turn = this.coordinator.message(id, {
276
278
  text: typeof payload.text === 'string' ? payload.text : '',
277
279
  revision: payload.revision,
280
+ planRevision: payload.planRevision,
281
+ ledgerRevision: payload.ledgerRevision,
282
+ coordinatorRevision: payload.coordinatorRevision,
283
+ }, {
284
+ onUpdate: (type, workItem) => {
285
+ this.watcher.abortInvalidWorkItemRuns(id);
286
+ this.#emit({ type, workItem });
287
+ },
278
288
  });
279
- this.watcher.notifyWorkItemInput(id);
280
- this.#emit({ type: 'work_item.message_added', workItem: detail });
281
- return detail;
289
+ turn.task.catch(() => {});
290
+ return { accepted: true, turnId: turn.detail.messages?.at(-1)?.turnId || null };
282
291
  }
283
292
  case 'retry_action': {
284
293
  const id = requiredString(payload.id, 'id');
@@ -296,6 +305,10 @@ export class WorkCenterService {
296
305
  }
297
306
  case 'action_input': {
298
307
  const id = requiredString(payload.id, 'id');
308
+ const generation = Number(payload.generation);
309
+ if (!Number.isInteger(generation) || generation < 1) {
310
+ throw new Error('generation must be a positive integer');
311
+ }
299
312
  const workItem = this.#requiredItem(id);
300
313
  let addedAttachments = [];
301
314
  let detail;
@@ -308,7 +321,7 @@ export class WorkCenterService {
308
321
  text: typeof payload.text === 'string' ? payload.text : '',
309
322
  actionId: typeof payload.actionId === 'string' ? payload.actionId : '',
310
323
  revision: payload.revision,
311
- generation: payload.generation,
324
+ generation,
312
325
  addedAttachmentCount: addedAttachments.length,
313
326
  addedAttachments,
314
327
  attachments: [...(workItem.attachments || []), ...addedAttachments],
@@ -429,6 +442,7 @@ export class WorkCenterService {
429
442
  }
430
443
 
431
444
  async shutdown() {
445
+ await this.coordinator?.shutdown?.();
432
446
  await this.watcher.stop();
433
447
  try { await this.watcher.runner?.shutdown?.(); } catch {}
434
448
  try { await this.watcher.runner?.trace?.close?.(); } catch {}