@yeaft/webchat-agent 1.0.252 → 1.0.253

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,13 +1,141 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { resolveMaxOutputTokens } from '../models.js';
3
+ import {
4
+ LLMAuthError,
5
+ LLMContextError,
6
+ LLMRateLimitError,
7
+ LLMServerError,
8
+ } from '../llm/adapter.js';
2
9
  import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
3
10
  import { normalizeContractPatch } from './completion-contract.js';
4
11
  import { applyCoordinatorReplan } from './plan-mutation.js';
5
12
  import { buildWorkItemAttachmentContext } from './attachments.js';
13
+ import { sanitizeDiagnosticText } from './debug-projection.js';
6
14
 
7
15
  const COORDINATOR_MAX_REPLY_CHARS = 8_000;
8
16
  const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
9
17
  const COORDINATOR_MAX_OUTPUT_TOKENS = 8_192;
10
18
  const COORDINATOR_MAX_SNAPSHOT_BYTES = 64 * 1024;
19
+ const COORDINATOR_RECOVERY_DECISION_ATTEMPTS = 2;
20
+ const COORDINATOR_MAX_CONVERSATION_MESSAGES = 20;
21
+ const COORDINATOR_MAX_ACTIONS = 64;
22
+ const COORDINATOR_MAX_WORK_ITEM_BYTES = 14 * 1024;
23
+ const COORDINATOR_MAX_ACTIONS_BYTES = 34 * 1024;
24
+ const COORDINATOR_MAX_CONVERSATION_BYTES = 10 * 1024;
25
+ const COORDINATOR_MAX_STAGE_ID_BYTES = 256;
26
+ const COORDINATOR_TEMPORARY_ERROR = 'Work Center Coordinator is temporarily unavailable; automatic recovery will retry';
27
+
28
+ function truncateUtf8(value, maxBytes) {
29
+ const bytes = Buffer.from(String(value || ''), 'utf8');
30
+ if (bytes.length <= maxBytes) return bytes.toString('utf8');
31
+ let end = Math.min(maxBytes, bytes.length);
32
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
33
+ return bytes.subarray(0, end).toString('utf8');
34
+ }
35
+
36
+ function jsonByteLength(value) {
37
+ return Buffer.byteLength(JSON.stringify(value), 'utf8');
38
+ }
39
+
40
+ function boundedJsonArray(values, maxBytes, options = {}) {
41
+ const source = Array.isArray(values) ? values : [];
42
+ const selected = [];
43
+ const indexes = options.newestFirst
44
+ ? [...source.keys()].reverse()
45
+ : [...source.keys()];
46
+ for (const index of indexes) {
47
+ const candidate = options.newestFirst
48
+ ? [source[index], ...selected]
49
+ : [...selected, source[index]];
50
+ if (jsonByteLength(candidate) <= maxBytes) selected.splice(0, selected.length, ...candidate);
51
+ }
52
+ return selected;
53
+ }
54
+
55
+ function boundedEvidence(value) {
56
+ return (Array.isArray(value) ? value : []).slice(0, 3).map(item => ({
57
+ kind: truncateUtf8(item?.kind, 32) || 'text',
58
+ label: truncateUtf8(item?.label, 192),
59
+ ...(item?.ref ? { ref: truncateUtf8(item.ref, 256) } : {}),
60
+ ...(item?.status ? { status: truncateUtf8(item.status, 32) } : {}),
61
+ })).filter(item => item.label);
62
+ }
63
+
64
+ function coordinatorStageReferences(detail) {
65
+ const actions = (Array.isArray(detail?.actions) ? detail.actions : [])
66
+ .filter(action => !['superseded', 'cancelled'].includes(action?.status));
67
+ const stageIds = [...new Set(actions
68
+ .map(action => typeof action?.stageId === 'string' ? action.stageId : '')
69
+ .filter(Boolean))];
70
+ const reserved = new Set(stageIds);
71
+ const used = new Set();
72
+ const aliasByStageId = new Map();
73
+ const stageIdByReference = new Map();
74
+
75
+ for (const stageId of stageIds) {
76
+ let alias = stageId;
77
+ if (Buffer.byteLength(alias, 'utf8') > COORDINATOR_MAX_STAGE_ID_BYTES) {
78
+ const digest = createHash('sha256').update(stageId, 'utf8').digest('hex');
79
+ let counter = 0;
80
+ do {
81
+ const suffix = `~${digest}${counter > 0 ? `-${counter}` : ''}`;
82
+ alias = `${truncateUtf8(stageId, COORDINATOR_MAX_STAGE_ID_BYTES - Buffer.byteLength(suffix, 'utf8'))}${suffix}`;
83
+ counter += 1;
84
+ } while (used.has(alias) || (reserved.has(alias) && alias !== stageId));
85
+ }
86
+ if (used.has(alias)) throw new Error(`Coordinator snapshot has duplicate stage identity: ${stageId}`);
87
+ used.add(alias);
88
+ aliasByStageId.set(stageId, alias);
89
+ stageIdByReference.set(stageId, stageId);
90
+ stageIdByReference.set(alias, stageId);
91
+ }
92
+ for (const action of actions) {
93
+ if (typeof action?.id === 'string' && action.id && typeof action.stageId === 'string'
94
+ && !stageIdByReference.has(action.id)) {
95
+ stageIdByReference.set(action.id, action.stageId);
96
+ }
97
+ }
98
+ return {
99
+ project(value) {
100
+ const stageId = typeof value === 'string' ? value : '';
101
+ return aliasByStageId.get(stageId) || truncateUtf8(stageId, COORDINATOR_MAX_STAGE_ID_BYTES);
102
+ },
103
+ resolve(value) {
104
+ const reference = typeof value === 'string' ? value.trim() : '';
105
+ return stageIdByReference.get(reference) || reference;
106
+ },
107
+ };
108
+ }
109
+
110
+ function boundedAction(action, result, stageReferences, compact = false) {
111
+ const brief = action?.brief && typeof action.brief === 'object' ? action.brief : null;
112
+ return {
113
+ stageId: stageReferences.project(action?.stageId),
114
+ type: truncateUtf8(action?.type, 64),
115
+ status: truncateUtf8(action?.status, 64),
116
+ generation: Math.max(1, Number(action?.generation) || 1),
117
+ dependencies: (Array.isArray(action?.dependsOnStageIds) ? action.dependsOnStageIds : [])
118
+ .slice(0, 8)
119
+ .map(value => stageReferences.project(value))
120
+ .filter(Boolean),
121
+ workspaceMode: truncateUtf8(action?.workspaceMode, 64),
122
+ ...(!compact && brief ? {
123
+ brief: {
124
+ objective: truncateUtf8(brief.objective, 256),
125
+ approach: truncateUtf8(brief.approach, 256),
126
+ expectedOutcome: truncateUtf8(brief.expectedOutcome, 256),
127
+ },
128
+ } : {}),
129
+ result: result ? {
130
+ status: truncateUtf8(result.status, 64),
131
+ summary: truncateUtf8(result.summary, compact ? 256 : 768),
132
+ ...(!compact ? { evidence: boundedEvidence(result.evidence) } : {}),
133
+ waitingReason: truncateUtf8(result.waitingReason, 384) || null,
134
+ error: truncateUtf8(result.error, 384) || null,
135
+ reviewDecision: truncateUtf8(result.reviewDecision, 64) || null,
136
+ } : null,
137
+ };
138
+ }
11
139
 
12
140
  const COORDINATOR_SYSTEM_PROMPT = `You are the Work Center Coordinator. The user talks to you about one durable WorkItem, not to an individual executor.
13
141
 
@@ -24,8 +152,9 @@ Return exactly one JSON object and no surrounding prose:
24
152
  {
25
153
  "reply": "natural user-facing response",
26
154
  "decision": {
27
- "kind": "answer|guide_actions|replan",
155
+ "kind": "answer|guide_actions|replan|request_human",
28
156
  "reason": "short audit reason",
157
+ "question": null,
29
158
  "contractPatch": null,
30
159
  "guidance": [],
31
160
  "actions": []
@@ -36,7 +165,10 @@ Decision rules:
36
165
  - answer: use for explanation or status questions. Do not include contractPatch, guidance, or actions.
37
166
  - guide_actions: use only when the contract and graph stay valid. guidance must contain one or more {"stageId":"existing unfinished stage id","instruction":"specific next instruction"}. Do not include contractPatch or actions.
38
167
  - replan: use when the WorkItem contract or unfinished topology changes. contractPatch may be null or contain title, goal, and/or acceptanceCriteria. actions must be the COMPLETE desired unfinished Action graph after this decision; omit completed Actions. Each Action requires id, name, type, objective, approach, expectedOutcome, capability, candidateVpIds, assignmentReason, dependsOnActionIds, workspaceMode, and may include separateFromActionTypes, changesRequestedActionId, maxAttempts. Dependencies may reference immutable completed stage ids or earlier Actions in this actions array.
168
+ - request_human: use only during automatic failure recovery, and only when no safe retry, guidance, or replan can be decided without human information. Set question to the exact information or decision required. Do not include contractPatch, guidance, or actions.
39
169
  - Every replan must keep exactly one final acceptance gate: normally one deliver Action, or one terminal review when no delivery operation is required. It must be the unique graph sink and transitively depend on all other Actions.
170
+ - Action references are stage ids, never internal database Action ids.
171
+ - Stage ids in the snapshot may be bounded aliases. Echo them exactly; the runtime resolves them to durable identities.
40
172
  - Never return destructive cancellation. Tell the user to use the explicit cancel control instead.`;
41
173
 
42
174
  function parseJsonObject(value) {
@@ -63,6 +195,66 @@ function cleanText(value, limit, name) {
63
195
  return text;
64
196
  }
65
197
 
198
+ function permanentCoordinatorDiagnostic(cause, phase) {
199
+ if (cause instanceof LLMAuthError) {
200
+ return 'Work Center Coordinator authentication failed. Update the configured provider credentials before retrying this Action.';
201
+ }
202
+ if (cause instanceof LLMContextError) {
203
+ return 'Work Center Coordinator exceeded the model context limit. Reduce the WorkItem context or select a model with a larger context window before retrying this Action.';
204
+ }
205
+ const detail = sanitizeDiagnosticText(cause?.message || String(cause || ''), 2_000);
206
+ const label = phase === 'runtime'
207
+ ? 'runtime could not be loaded'
208
+ : phase === 'policy'
209
+ ? 'settings could not be loaded'
210
+ : phase === 'selection'
211
+ ? 'executor or model selection failed'
212
+ : 'provider request failed';
213
+ return `Work Center Coordinator ${label}${detail ? `: ${detail}` : '.'}`;
214
+ }
215
+
216
+ function coordinatorExecutionError(cause, phase) {
217
+ if (cause?.coordinatorClassified === true) return cause;
218
+ const explicitlyPermanent = cause?.retryable === false;
219
+ const retryable = !explicitlyPermanent && (
220
+ cause instanceof LLMRateLimitError
221
+ || cause instanceof LLMServerError
222
+ || (['runtime', 'policy'].includes(phase) && cause?.retryable === true)
223
+ );
224
+ const error = new Error(retryable
225
+ ? COORDINATOR_TEMPORARY_ERROR
226
+ : permanentCoordinatorDiagnostic(cause, phase));
227
+ error.coordinatorClassified = true;
228
+ error.coordinatorRetryable = retryable;
229
+ error.coordinatorPhase = phase;
230
+ error.cause = cause;
231
+ return error;
232
+ }
233
+
234
+ function permanentRecoveryDecision(error) {
235
+ const diagnostic = String(error?.message || 'Work Center Coordinator cannot recover this Action automatically');
236
+ const resolution = error?.cause instanceof LLMAuthError
237
+ ? 'Update the provider credentials, then tell Yeaft to retry or replan the failed Action.'
238
+ : error?.cause instanceof LLMContextError
239
+ ? 'Reduce the WorkItem context or choose a model with a larger context window, then tell Yeaft to retry or replan the failed Action.'
240
+ : error?.coordinatorPhase === 'selection'
241
+ ? 'Configure an available VP and model, then tell Yeaft to retry or replan the failed Action.'
242
+ : error?.coordinatorPhase === 'policy'
243
+ ? 'Correct the Work Center settings, then tell Yeaft to retry or replan the failed Action.'
244
+ : 'Correct the Coordinator runtime or provider configuration, then tell Yeaft to retry or replan the failed Action.';
245
+ return {
246
+ reply: `${diagnostic} Automatic recovery stopped to avoid repeated attempts.`,
247
+ decision: {
248
+ kind: 'request_human',
249
+ reason: `Automatic recovery stopped after a non-retryable ${error?.coordinatorPhase || 'Coordinator'} error`,
250
+ question: `${diagnostic} ${resolution}`,
251
+ contractPatch: null,
252
+ guidance: [],
253
+ actions: [],
254
+ },
255
+ };
256
+ }
257
+
66
258
  function normalizeGuidance(value, detail) {
67
259
  if (!Array.isArray(value) || value.length < 1 || value.length > 8) {
68
260
  throw new Error('Work Center Coordinator guidance requires between 1 and 8 targets');
@@ -70,9 +262,10 @@ function normalizeGuidance(value, detail) {
70
262
  const activeByStage = new Map((detail.actions || [])
71
263
  .filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status))
72
264
  .map(action => [action.stageId, action]));
265
+ const stageReferences = coordinatorStageReferences(detail);
73
266
  const seen = new Set();
74
267
  return value.map(entry => {
75
- const stageId = typeof entry?.stageId === 'string' ? entry.stageId.trim() : '';
268
+ const stageId = stageReferences.resolve(entry?.stageId);
76
269
  if (!stageId || seen.has(stageId) || !activeByStage.has(stageId)) {
77
270
  throw new Error(`Work Center Coordinator guidance references an invalid unfinished Action: ${stageId || '(missing)'}`);
78
271
  }
@@ -84,26 +277,68 @@ function normalizeGuidance(value, detail) {
84
277
  });
85
278
  }
86
279
 
87
- export function normalizeCoordinatorResponse(value, detail) {
280
+ function normalizeCoordinatorActionReferences(actions, detail) {
281
+ const stageReferences = coordinatorStageReferences(detail);
282
+ const normalizeReference = value => typeof value === 'string'
283
+ ? stageReferences.resolve(value)
284
+ : value;
285
+ return actions.map(action => ({
286
+ ...structuredClone(action),
287
+ ...(typeof action?.id === 'string' ? { id: normalizeReference(action.id) } : {}),
288
+ ...(Array.isArray(action?.dependsOnActionIds) ? {
289
+ dependsOnActionIds: action.dependsOnActionIds.map(normalizeReference),
290
+ } : {}),
291
+ ...(Object.hasOwn(action || {}, 'changesRequestedActionId') ? {
292
+ changesRequestedActionId: normalizeReference(action.changesRequestedActionId),
293
+ } : {}),
294
+ }));
295
+ }
296
+
297
+ export function normalizeCoordinatorResponse(value, detail, options = {}) {
88
298
  const parsed = typeof value === 'string' ? parseJsonObject(value) : value;
89
299
  const reply = cleanText(parsed?.reply, COORDINATOR_MAX_REPLY_CHARS, 'reply');
90
300
  const source = parsed?.decision && typeof parsed.decision === 'object' && !Array.isArray(parsed.decision)
91
301
  ? parsed.decision
92
302
  : {};
93
- const kind = ['answer', 'guide_actions', 'replan'].includes(source.kind) ? source.kind : '';
303
+ const allowedKinds = options.recovery === true
304
+ ? ['guide_actions', 'replan', 'request_human']
305
+ : ['answer', 'guide_actions', 'replan'];
306
+ const kind = allowedKinds.includes(source.kind) ? source.kind : '';
94
307
  if (!kind) throw new Error('Work Center Coordinator decision kind is invalid');
95
308
  const reason = cleanText(source.reason, 2_000, 'decision reason');
96
309
  if (kind === 'answer') {
97
310
  return { reply, decision: { kind, reason, contractPatch: null, guidance: [], actions: [] } };
98
311
  }
99
312
  if (kind === 'guide_actions') {
313
+ const guidance = normalizeGuidance(source.guidance, detail);
314
+ if (options.recovery === true) {
315
+ const failed = detail.actions?.find(action => (
316
+ action.id === options.recoveryActionId && action.status === 'failed'
317
+ ));
318
+ if (!failed || guidance.length !== 1 || guidance[0].stageId !== failed.stageId) {
319
+ throw new Error('Work Center Coordinator recovery guidance must target only the failed Action');
320
+ }
321
+ }
322
+ return {
323
+ reply,
324
+ decision: {
325
+ kind,
326
+ reason,
327
+ contractPatch: null,
328
+ guidance,
329
+ actions: [],
330
+ },
331
+ };
332
+ }
333
+ if (kind === 'request_human') {
100
334
  return {
101
335
  reply,
102
336
  decision: {
103
337
  kind,
104
338
  reason,
339
+ question: cleanText(source.question, COORDINATOR_MAX_REPLY_CHARS, 'human question'),
105
340
  contractPatch: null,
106
- guidance: normalizeGuidance(source.guidance, detail),
341
+ guidance: [],
107
342
  actions: [],
108
343
  },
109
344
  };
@@ -119,30 +354,32 @@ export function normalizeCoordinatorResponse(value, detail) {
119
354
  reason,
120
355
  contractPatch,
121
356
  guidance: [],
122
- actions: structuredClone(source.actions),
357
+ actions: normalizeCoordinatorActionReferences(source.actions, detail),
123
358
  },
124
359
  };
125
360
  }
126
361
 
127
362
  function coordinatorHistory(messages) {
128
- return (Array.isArray(messages) ? messages : [])
363
+ const history = (Array.isArray(messages) ? messages : [])
129
364
  .filter(message => message?.role !== 'assistant' || message.status !== 'thinking')
130
365
  .filter(message => typeof message?.text === 'string' && message.text.trim())
131
- .slice(-20)
366
+ .slice(-COORDINATOR_MAX_CONVERSATION_MESSAGES)
132
367
  .map(message => ({
133
368
  role: message.role === 'assistant' ? 'assistant' : 'user',
134
- text: message.role === 'legacy_instruction'
135
- ? `[Legacy global instruction already delivered to executors] ${message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS)}`
136
- : message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS),
369
+ text: truncateUtf8(message.role === 'legacy_instruction'
370
+ ? `[Legacy global instruction already delivered to executors] ${message.text}`
371
+ : message.text, 2_000),
137
372
  }));
373
+ return boundedJsonArray(history, COORDINATOR_MAX_CONVERSATION_BYTES, { newestFirst: true });
138
374
  }
139
375
 
140
376
  function coordinatorSnapshotText(detail) {
141
- const snapshot = JSON.stringify(coordinatorSnapshot(detail));
142
- if (Buffer.byteLength(snapshot, 'utf8') > COORDINATOR_MAX_SNAPSHOT_BYTES) {
143
- throw new Error('WorkItem is too large for a safe Coordinator turn; compact the Action history first');
377
+ const snapshot = coordinatorSnapshot(detail);
378
+ const serialized = JSON.stringify(snapshot);
379
+ if (Buffer.byteLength(serialized, 'utf8') > COORDINATOR_MAX_SNAPSHOT_BYTES) {
380
+ throw new Error('WorkItem cannot be represented within the Coordinator snapshot budget');
144
381
  }
145
- return snapshot;
382
+ return serialized;
146
383
  }
147
384
 
148
385
  function finalizedCriteria(detail, contractPatch) {
@@ -165,41 +402,64 @@ function coordinatorSnapshot(detail) {
165
402
  : candidates[0];
166
403
  if (canonical) canonicalRunByAction.set(action.id, canonical);
167
404
  }
405
+
406
+ const acceptanceCriteria = boundedJsonArray(
407
+ (Array.isArray(detail.acceptanceCriteria) ? detail.acceptanceCriteria : [])
408
+ .slice(0, 24)
409
+ .map(value => truncateUtf8(value, 768))
410
+ .filter(Boolean),
411
+ 8 * 1024,
412
+ );
413
+ const workItem = {
414
+ id: truncateUtf8(detail.id, 256),
415
+ revision: detail.revision,
416
+ planRevision: detail.planRevision,
417
+ ledgerRevision: detail.ledgerRevision,
418
+ status: truncateUtf8(detail.status, 64),
419
+ title: truncateUtf8(detail.title, 1 * 1024),
420
+ goal: truncateUtf8(detail.goal, 4 * 1024),
421
+ acceptanceCriteria,
422
+ workItemType: truncateUtf8(detail.workflowSnapshot?.workItemType, 256) || null,
423
+ };
424
+ if (jsonByteLength(workItem) > COORDINATOR_MAX_WORK_ITEM_BYTES) {
425
+ throw new Error('WorkItem contract cannot be represented within the Coordinator snapshot budget');
426
+ }
427
+
428
+ const currentActions = (Array.isArray(detail.actions) ? detail.actions : [])
429
+ .filter(action => !['superseded', 'cancelled'].includes(action.status));
430
+ const stageReferences = coordinatorStageReferences(detail);
431
+ const unfinished = currentActions.filter(action => action.status !== 'completed');
432
+ const completed = currentActions.filter(action => action.status === 'completed');
433
+ const selected = [
434
+ ...unfinished,
435
+ ...completed.slice(-Math.max(0, COORDINATOR_MAX_ACTIONS - unfinished.length)),
436
+ ].slice(0, COORDINATOR_MAX_ACTIONS);
437
+ let projectedActions = selected.map(action => boundedAction(
438
+ action,
439
+ canonicalRunByAction.get(action.id),
440
+ stageReferences,
441
+ action.status === 'completed',
442
+ ));
443
+ let actions = boundedJsonArray(projectedActions, COORDINATOR_MAX_ACTIONS_BYTES);
444
+ let includedActionIdentities = new Set(actions.map(action => `${action.stageId}:${action.generation}`));
445
+ if (unfinished.some(action => !includedActionIdentities.has(`${stageReferences.project(action.stageId)}:${action.generation}`))) {
446
+ projectedActions = selected.map(action => boundedAction(
447
+ action,
448
+ canonicalRunByAction.get(action.id),
449
+ stageReferences,
450
+ true,
451
+ ));
452
+ actions = boundedJsonArray(projectedActions, COORDINATOR_MAX_ACTIONS_BYTES);
453
+ includedActionIdentities = new Set(actions.map(action => `${action.stageId}:${action.generation}`));
454
+ }
455
+ if (unfinished.some(action => !includedActionIdentities.has(`${stageReferences.project(action.stageId)}:${action.generation}`))) {
456
+ throw new Error('Active Actions cannot be represented within the Coordinator snapshot budget');
457
+ }
458
+
168
459
  return {
169
- workItem: {
170
- id: detail.id,
171
- revision: detail.revision,
172
- planRevision: detail.planRevision,
173
- ledgerRevision: detail.ledgerRevision,
174
- status: detail.status,
175
- title: detail.title,
176
- goal: detail.goal,
177
- acceptanceCriteria: detail.acceptanceCriteria || [],
178
- workItemType: detail.workflowSnapshot?.workItemType || null,
179
- },
180
- actions: (detail.actions || [])
181
- .filter(action => !['superseded', 'cancelled'].includes(action.status))
182
- .map(action => {
183
- const result = canonicalRunByAction.get(action.id);
184
- return {
185
- id: action.id,
186
- stageId: action.stageId,
187
- type: action.type,
188
- status: action.status,
189
- generation: action.generation,
190
- dependencies: action.dependsOnStageIds || [],
191
- workspaceMode: action.workspaceMode,
192
- brief: action.brief || null,
193
- result: result ? {
194
- status: result.status,
195
- summary: result.summary || '',
196
- evidence: (result.evidence || []).slice(0, 20),
197
- waitingReason: result.waitingReason || null,
198
- error: result.error || null,
199
- reviewDecision: result.reviewDecision || null,
200
- } : null,
201
- };
202
- }),
460
+ workItem,
461
+ actions,
462
+ omittedCompletedActionCount: Math.max(0, completed.length - actions.filter(action => action.status === 'completed').length),
203
463
  conversation: coordinatorHistory(detail.messages),
204
464
  };
205
465
  }
@@ -231,104 +491,230 @@ export class WorkItemCoordinator {
231
491
  planRevision: Number(input.planRevision),
232
492
  ledgerRevision: Number(input.ledgerRevision),
233
493
  coordinatorRevision: Number(input.coordinatorRevision),
234
- }, input.attachments, addedAttachments);
494
+ }, {
495
+ attachments: input.attachments,
496
+ addedAttachments,
497
+ });
235
498
  if (!started) throw new Error(`WorkItem not found: ${id}`);
236
499
  options.onUpdate?.('coordinator.turn_started', started.detail);
500
+ return this.#scheduleTurn(started, {
501
+ text: promptText,
502
+ recovery: false,
503
+ addedAttachments,
504
+ options,
505
+ });
506
+ }
507
+
508
+ recover(id, options = {}) {
509
+ if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
510
+ const detail = this.store.getWorkItemDetail(id);
511
+ const hasExplicitIdentity = typeof options.actionId === 'string' && options.actionId;
512
+ const action = hasExplicitIdentity
513
+ ? detail?.actions?.find(candidate => (
514
+ candidate.id === options.actionId
515
+ && candidate.generation === options.actionGeneration
516
+ ))
517
+ : detail?.actions?.find(candidate => (
518
+ candidate.id === detail.currentActionId && candidate.status === 'failed'
519
+ ));
520
+ if (!detail || ['done', 'cancelled'].includes(detail.status) || action?.status !== 'failed') return null;
521
+ const started = this.store.beginCoordinatorTurn(id, '', {
522
+ revision: detail.revision,
523
+ planRevision: detail.planRevision,
524
+ ledgerRevision: detail.ledgerRevision,
525
+ coordinatorRevision: detail.coordinatorRevision,
526
+ }, {
527
+ recovery: {
528
+ actionId: action.id,
529
+ actionGeneration: action.generation,
530
+ stageId: action.stageId,
531
+ },
532
+ });
533
+ if (!started) return null;
534
+ options.onUpdate?.('coordinator.recovery_started', started.detail);
535
+ const text = `Action stage "${action.stageId}" failed. Decide the next safe control transition. `
536
+ + 'Failure is not a terminal WorkItem state: guide or replan executable work whenever possible. '
537
+ + 'Request human input only when the snapshot lacks information required for a safe decision.';
538
+ return this.#scheduleTurn(started, { text, recovery: true, options });
539
+ }
237
540
 
541
+ #scheduleTurn(started, { text, recovery, addedAttachments = [], options }) {
238
542
  const abortController = new AbortController();
239
543
  this.activeTurns.set(started.turnId, abortController);
240
- const task = new Promise(resolve => setTimeout(resolve, 0)).then(async () => {
544
+ const task = new Promise(resolve => setTimeout(resolve, 0))
545
+ .then(() => this.#executeTurn(started, {
546
+ text, recovery, addedAttachments, options, abortController,
547
+ }))
548
+ .finally(() => {
549
+ this.activeTurns.delete(started.turnId);
550
+ this.activeTasks.delete(started.turnId);
551
+ });
552
+ this.activeTasks.set(started.turnId, task);
553
+ return { detail: started.detail, task };
554
+ }
555
+
556
+ async #executeTurn(started, {
557
+ text, recovery, addedAttachments, options, abortController,
558
+ }) {
559
+ try {
560
+ let normalized = null;
561
+ let mutation = null;
562
+ let attemptCount = 0;
563
+ let lastError = null;
564
+ const snapshotText = coordinatorSnapshotText(started.detail);
565
+ const attachmentContext = !recovery && this.attachmentRoot
566
+ ? buildWorkItemAttachmentContext({ ...started.detail, attachments: addedAttachments }, {
567
+ root: this.attachmentRoot,
568
+ inlineTextBytes: 32 * 1024,
569
+ })
570
+ : { promptBlock: '', promptParts: [] };
241
571
  try {
242
- const runtime = await this.runtimeProvider();
572
+ let runtime;
573
+ let settings;
574
+ try {
575
+ runtime = await this.runtimeProvider();
576
+ } catch (error) {
577
+ throw coordinatorExecutionError(error, 'runtime');
578
+ }
579
+ try {
580
+ settings = await this.policyProvider();
581
+ } catch (error) {
582
+ throw coordinatorExecutionError(error, 'policy');
583
+ }
243
584
  if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
244
- const settings = await this.policyProvider();
245
- const vps = this.registry?.listVps?.() || [];
246
- const assignment = selectWorkItemVp({
247
- policy: { mode: 'pool', candidateVpIds: vps.map(vp => vp.id), capability: 'triage' },
248
- stageType: 'triage',
249
- vps,
250
- priorRuns: started.detail.runs || [],
251
- });
252
- const coordinatorPolicy = settings?.coordinatorModelPolicy || {
253
- ...(settings?.modelPolicy || {}),
254
- effort: settings?.actionModelPolicies?.triage?.effort || settings?.modelPolicy?.effort || 'high',
255
- };
256
- const resolved = resolveWorkItemModel(runtime.config, assignment.vp, coordinatorPolicy);
257
- const attachmentContext = this.attachmentRoot
258
- ? buildWorkItemAttachmentContext({ ...started.detail, attachments: addedAttachments }, {
259
- root: this.attachmentRoot,
260
- inlineTextBytes: 32 * 1024,
261
- })
262
- : { promptBlock: '', promptParts: [] };
263
- const latestMessage = `Current WorkItem snapshot:\n${coordinatorSnapshotText(started.detail)}\n\nLatest user message:\n${promptText}${attachmentContext.promptBlock}`;
264
- const content = attachmentContext.promptParts.length > 0
265
- ? [{ type: 'text', text: latestMessage }, ...attachmentContext.promptParts]
266
- : latestMessage;
267
- const result = await Promise.race([
268
- runtime.adapter.call({
269
- model: resolved.model,
270
- system: COORDINATOR_SYSTEM_PROMPT,
271
- messages: [{
272
- role: 'user',
273
- content,
274
- }],
275
- maxTokens: Math.min(
276
- resolveMaxOutputTokens(resolved.model, runtime.config),
277
- COORDINATOR_MAX_OUTPUT_TOKENS,
278
- ),
279
- effort: resolved.effort,
280
- effortSource: resolved.source,
281
- effortContext: { scenario: 'work-center-coordinator' },
282
- signal: abortController.signal,
283
- }),
284
- new Promise((_, reject) => {
285
- abortController.signal.addEventListener('abort', () => {
286
- reject(new Error('Work Center Coordinator was interrupted'));
287
- }, { once: true });
288
- }),
289
- ]);
290
- const normalized = normalizeCoordinatorResponse(result?.text, started.detail);
291
- if (normalized.decision.kind === 'replan') {
292
- finalizedCriteria(started.detail, normalized.decision.contractPatch);
585
+ let vps;
586
+ let resolved;
587
+ try {
588
+ vps = this.registry?.listVps?.() || [];
589
+ if (vps.length === 0) {
590
+ const error = new Error('Work Center has no available VPs');
591
+ error.retryable = false;
592
+ throw error;
593
+ }
594
+ const assignment = selectWorkItemVp({
595
+ policy: { mode: 'pool', candidateVpIds: vps.map(vp => vp.id), capability: 'triage' },
596
+ stageType: 'triage',
597
+ vps,
598
+ priorRuns: started.detail.runs || [],
599
+ });
600
+ const coordinatorPolicy = settings?.coordinatorModelPolicy || {
601
+ ...(settings?.modelPolicy || {}),
602
+ effort: settings?.actionModelPolicies?.triage?.effort || settings?.modelPolicy?.effort || 'high',
603
+ };
604
+ resolved = resolveWorkItemModel(runtime.config, assignment.vp, coordinatorPolicy);
605
+ } catch (error) {
606
+ throw coordinatorExecutionError(error, 'selection');
607
+ }
608
+ const maxAttempts = recovery ? COORDINATOR_RECOVERY_DECISION_ATTEMPTS : 1;
609
+ for (let index = 0; index < maxAttempts; index += 1) {
610
+ attemptCount = index + 1;
611
+ mutation = null;
612
+ const correction = lastError
613
+ ? `\n\nYour previous decision was rejected by the deterministic validator:\n${String(lastError.message || lastError).slice(0, 2_000)}\nReturn a corrected complete JSON decision.`
614
+ : '';
615
+ try {
616
+ let result;
617
+ try {
618
+ const latestMessage = `Current WorkItem snapshot:\n${snapshotText}\n\n${recovery ? 'Automatic failure recovery trigger' : 'Latest user message'}:\n${text}${attachmentContext.promptBlock}${correction}`;
619
+ const content = attachmentContext.promptParts.length > 0
620
+ ? [{ type: 'text', text: latestMessage }, ...attachmentContext.promptParts]
621
+ : latestMessage;
622
+ result = await Promise.race([
623
+ runtime.adapter.call({
624
+ model: resolved.model,
625
+ system: COORDINATOR_SYSTEM_PROMPT,
626
+ messages: [{ role: 'user', content }],
627
+ maxTokens: Math.min(
628
+ resolveMaxOutputTokens(resolved.model, runtime.config),
629
+ COORDINATOR_MAX_OUTPUT_TOKENS,
630
+ ),
631
+ effort: resolved.effort,
632
+ effortSource: resolved.source,
633
+ effortContext: { scenario: 'work-center-coordinator' },
634
+ signal: abortController.signal,
635
+ }),
636
+ new Promise((_, reject) => {
637
+ abortController.signal.addEventListener('abort', () => {
638
+ reject(new Error('Work Center Coordinator was interrupted'));
639
+ }, { once: true });
640
+ }),
641
+ ]);
642
+ } catch (error) {
643
+ if (abortController.signal.aborted || this.shuttingDown) throw error;
644
+ throw coordinatorExecutionError(error, 'provider');
645
+ }
646
+ normalized = normalizeCoordinatorResponse(result?.text, started.detail, {
647
+ recovery,
648
+ recoveryActionId: started.fence.recovery?.actionId || null,
649
+ });
650
+ if (normalized.decision.kind === 'replan') {
651
+ finalizedCriteria(started.detail, normalized.decision.contractPatch);
652
+ mutation = applyCoordinatorReplan({
653
+ workItem: {
654
+ ...started.detail,
655
+ ...(normalized.decision.contractPatch || {}),
656
+ },
657
+ actions: started.detail.actions || [],
658
+ proposal: {
659
+ proposalId: `coordinator:${started.turnId}`,
660
+ basePlanRevision: started.detail.planRevision,
661
+ reason: normalized.decision.reason,
662
+ actions: normalized.decision.actions,
663
+ },
664
+ availableVpIds: vps.map(vp => vp.id),
665
+ });
666
+ }
667
+ lastError = null;
668
+ break;
669
+ } catch (error) {
670
+ normalized = null;
671
+ if (abortController.signal.aborted || this.shuttingDown || error?.coordinatorClassified) {
672
+ throw error;
673
+ }
674
+ lastError = error;
675
+ }
293
676
  }
294
- const mutation = normalized.decision.kind === 'replan'
295
- ? applyCoordinatorReplan({
296
- workItem: {
297
- ...started.detail,
298
- ...(normalized.decision.contractPatch || {}),
299
- },
300
- actions: started.detail.actions || [],
301
- proposal: {
302
- proposalId: `coordinator:${started.turnId}`,
303
- basePlanRevision: started.detail.planRevision,
304
- reason: normalized.decision.reason,
305
- actions: normalized.decision.actions,
306
- },
307
- availableVpIds: vps.map(vp => vp.id),
308
- })
309
- : null;
310
- const detail = this.store.completeCoordinatorTurn(started.turnId, {
311
- reply: normalized.reply,
312
- decision: normalized.decision,
313
- mutation,
314
- }, started.fence);
315
- if (!detail) throw new Error('Work Center Coordinator turn is stale or already completed');
316
- options.onUpdate?.('coordinator.turn_completed', detail);
317
- return detail;
318
677
  } catch (error) {
319
- const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
320
- if (detail) {
321
- options.onUpdate?.('coordinator.turn_failed', detail);
322
- return detail;
678
+ lastError = error;
679
+ }
680
+ if (abortController.signal.aborted || this.shuttingDown) {
681
+ throw lastError || new Error('Work Center Coordinator was interrupted');
682
+ }
683
+ if (!normalized) {
684
+ if (!recovery || lastError?.coordinatorRetryable) {
685
+ throw lastError || new Error('Work Center Coordinator did not produce a decision');
323
686
  }
324
- throw error;
325
- } finally {
326
- this.activeTurns.delete(started.turnId);
327
- this.activeTasks.delete(started.turnId);
687
+ normalized = lastError?.coordinatorClassified
688
+ ? permanentRecoveryDecision(lastError)
689
+ : {
690
+ reply: 'Automatic recovery could not choose a safe executable next step. Human input is required.',
691
+ decision: {
692
+ kind: 'request_human',
693
+ reason: 'Automatic recovery exhausted its bounded decision attempts',
694
+ question: 'Review the failed Action and provide the missing decision or constraint needed to retry or replan it safely.',
695
+ contractPatch: null,
696
+ guidance: [],
697
+ actions: [],
698
+ },
699
+ };
328
700
  }
329
- });
330
- this.activeTasks.set(started.turnId, task);
331
- return { detail: started.detail, task };
701
+ const detail = this.store.completeCoordinatorTurn(started.turnId, {
702
+ reply: normalized.reply,
703
+ decision: normalized.decision,
704
+ mutation,
705
+ attemptCount,
706
+ }, started.fence);
707
+ if (!detail) throw new Error('Work Center Coordinator turn is stale or already completed');
708
+ options.onUpdate?.(recovery ? 'coordinator.recovery_completed' : 'coordinator.turn_completed', detail);
709
+ return detail;
710
+ } catch (error) {
711
+ const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
712
+ if (detail) {
713
+ options.onUpdate?.('coordinator.turn_failed', detail);
714
+ return detail;
715
+ }
716
+ throw error;
717
+ }
332
718
  }
333
719
 
334
720
  async shutdown() {