@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.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +2 -2
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/work-center/coordinator.js +522 -136
- package/yeaft/work-center/projection.js +1 -1
- package/yeaft/work-center/service.js +102 -0
- package/yeaft/work-center/store.js +159 -21
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
357
|
+
actions: normalizeCoordinatorActionReferences(source.actions, detail),
|
|
123
358
|
},
|
|
124
359
|
};
|
|
125
360
|
}
|
|
126
361
|
|
|
127
362
|
function coordinatorHistory(messages) {
|
|
128
|
-
|
|
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(-
|
|
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
|
|
136
|
-
: message.text
|
|
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 =
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
},
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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() {
|