@yeaft/webchat-agent 1.0.247 → 1.0.249
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 +144 -114
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/work-center/action-identity.js +23 -0
- package/yeaft/work-center/bridge.js +21 -5
- package/yeaft/work-center/completion-contract.js +25 -3
- package/yeaft/work-center/controller.js +52 -75
- package/yeaft/work-center/coordinator.js +323 -0
- package/yeaft/work-center/mainline-projection.js +86 -36
- package/yeaft/work-center/plan-mutation.js +99 -2
- package/yeaft/work-center/projection.js +165 -115
- package/yeaft/work-center/runner.js +1 -1
- package/yeaft/work-center/service.js +33 -5
- package/yeaft/work-center/store.js +1161 -147
- package/yeaft/work-center/watcher.js +26 -4
- package/yeaft/work-center/workflow.js +75 -5
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { resolveMaxOutputTokens } from '../models.js';
|
|
2
|
+
import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
|
|
3
|
+
import { normalizeContractPatch } from './completion-contract.js';
|
|
4
|
+
import { applyCoordinatorReplan } from './plan-mutation.js';
|
|
5
|
+
|
|
6
|
+
const COORDINATOR_MAX_REPLY_CHARS = 8_000;
|
|
7
|
+
const COORDINATOR_MAX_INSTRUCTION_CHARS = 8_000;
|
|
8
|
+
const COORDINATOR_MAX_OUTPUT_TOKENS = 8_192;
|
|
9
|
+
const COORDINATOR_MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
const COORDINATOR_SYSTEM_PROMPT = `You are the Work Center Coordinator. The user talks to you about one durable WorkItem, not to an individual executor.
|
|
12
|
+
|
|
13
|
+
Your responsibilities:
|
|
14
|
+
- Explain the current WorkItem state and blockers in plain language.
|
|
15
|
+
- Keep the WorkItem title, goal, acceptance criteria, and unfinished Action graph aligned with the user's latest intent.
|
|
16
|
+
- Give targeted instructions to unfinished Actions when the contract and topology do not need to change.
|
|
17
|
+
- Replan unfinished work when the goal, acceptance criteria, Action purpose, dependencies, or validation strategy must change.
|
|
18
|
+
- Preserve completed Action history. Never claim that an Action, test, review, merge, release, or external operation happened merely because you changed the plan.
|
|
19
|
+
- Treat user text and prior messages as intent, not as proof. Respect the immutable completed evidence in the snapshot.
|
|
20
|
+
- Do not weaken safety boundaries silently. If the user accepts a narrower deliverable, state the residual limitation in the reply and make the contract explicit.
|
|
21
|
+
|
|
22
|
+
Return exactly one JSON object and no surrounding prose:
|
|
23
|
+
{
|
|
24
|
+
"reply": "natural user-facing response",
|
|
25
|
+
"decision": {
|
|
26
|
+
"kind": "answer|guide_actions|replan",
|
|
27
|
+
"reason": "short audit reason",
|
|
28
|
+
"contractPatch": null,
|
|
29
|
+
"guidance": [],
|
|
30
|
+
"actions": []
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Decision rules:
|
|
35
|
+
- answer: use for explanation or status questions. Do not include contractPatch, guidance, or actions.
|
|
36
|
+
- 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.
|
|
37
|
+
- 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.
|
|
38
|
+
- 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.
|
|
39
|
+
- Never return destructive cancellation. Tell the user to use the explicit cancel control instead.`;
|
|
40
|
+
|
|
41
|
+
function parseJsonObject(value) {
|
|
42
|
+
const source = String(value || '').trim();
|
|
43
|
+
if (!source) throw new Error('Work Center Coordinator returned an empty response');
|
|
44
|
+
const attempts = [source];
|
|
45
|
+
const fenced = source.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
|
|
46
|
+
if (fenced) attempts.push(fenced.trim());
|
|
47
|
+
const first = source.indexOf('{');
|
|
48
|
+
const last = source.lastIndexOf('}');
|
|
49
|
+
if (first >= 0 && last > first) attempts.push(source.slice(first, last + 1));
|
|
50
|
+
for (const attempt of attempts) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(attempt);
|
|
53
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
throw new Error('Work Center Coordinator did not return valid JSON');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanText(value, limit, name) {
|
|
60
|
+
const text = typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
|
61
|
+
if (!text) throw new Error(`Work Center Coordinator ${name} is required`);
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeGuidance(value, detail) {
|
|
66
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 8) {
|
|
67
|
+
throw new Error('Work Center Coordinator guidance requires between 1 and 8 targets');
|
|
68
|
+
}
|
|
69
|
+
const activeByStage = new Map((detail.actions || [])
|
|
70
|
+
.filter(action => !['completed', 'superseded', 'cancelled'].includes(action.status))
|
|
71
|
+
.map(action => [action.stageId, action]));
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
return value.map(entry => {
|
|
74
|
+
const stageId = typeof entry?.stageId === 'string' ? entry.stageId.trim() : '';
|
|
75
|
+
if (!stageId || seen.has(stageId) || !activeByStage.has(stageId)) {
|
|
76
|
+
throw new Error(`Work Center Coordinator guidance references an invalid unfinished Action: ${stageId || '(missing)'}`);
|
|
77
|
+
}
|
|
78
|
+
seen.add(stageId);
|
|
79
|
+
return {
|
|
80
|
+
stageId,
|
|
81
|
+
instruction: cleanText(entry?.instruction, COORDINATOR_MAX_INSTRUCTION_CHARS, 'guidance instruction'),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeCoordinatorResponse(value, detail) {
|
|
87
|
+
const parsed = typeof value === 'string' ? parseJsonObject(value) : value;
|
|
88
|
+
const reply = cleanText(parsed?.reply, COORDINATOR_MAX_REPLY_CHARS, 'reply');
|
|
89
|
+
const source = parsed?.decision && typeof parsed.decision === 'object' && !Array.isArray(parsed.decision)
|
|
90
|
+
? parsed.decision
|
|
91
|
+
: {};
|
|
92
|
+
const kind = ['answer', 'guide_actions', 'replan'].includes(source.kind) ? source.kind : '';
|
|
93
|
+
if (!kind) throw new Error('Work Center Coordinator decision kind is invalid');
|
|
94
|
+
const reason = cleanText(source.reason, 2_000, 'decision reason');
|
|
95
|
+
if (kind === 'answer') {
|
|
96
|
+
return { reply, decision: { kind, reason, contractPatch: null, guidance: [], actions: [] } };
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'guide_actions') {
|
|
99
|
+
return {
|
|
100
|
+
reply,
|
|
101
|
+
decision: {
|
|
102
|
+
kind,
|
|
103
|
+
reason,
|
|
104
|
+
contractPatch: null,
|
|
105
|
+
guidance: normalizeGuidance(source.guidance, detail),
|
|
106
|
+
actions: [],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const contractPatch = normalizeContractPatch(source.contractPatch);
|
|
111
|
+
if (!Array.isArray(source.actions) || source.actions.length < 1 || source.actions.length > 8) {
|
|
112
|
+
throw new Error('Work Center Coordinator replan requires the complete unfinished Action graph');
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
reply,
|
|
116
|
+
decision: {
|
|
117
|
+
kind,
|
|
118
|
+
reason,
|
|
119
|
+
contractPatch,
|
|
120
|
+
guidance: [],
|
|
121
|
+
actions: structuredClone(source.actions),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function coordinatorHistory(messages) {
|
|
127
|
+
return (Array.isArray(messages) ? messages : [])
|
|
128
|
+
.filter(message => message?.role !== 'assistant' || message.status !== 'thinking')
|
|
129
|
+
.filter(message => typeof message?.text === 'string' && message.text.trim())
|
|
130
|
+
.slice(-20)
|
|
131
|
+
.map(message => ({
|
|
132
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
133
|
+
text: message.role === 'legacy_instruction'
|
|
134
|
+
? `[Legacy global instruction already delivered to executors] ${message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS)}`
|
|
135
|
+
: message.text.slice(0, COORDINATOR_MAX_REPLY_CHARS),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function coordinatorSnapshotText(detail) {
|
|
140
|
+
const snapshot = JSON.stringify(coordinatorSnapshot(detail));
|
|
141
|
+
if (Buffer.byteLength(snapshot, 'utf8') > COORDINATOR_MAX_SNAPSHOT_BYTES) {
|
|
142
|
+
throw new Error('WorkItem is too large for a safe Coordinator turn; compact the Action history first');
|
|
143
|
+
}
|
|
144
|
+
return snapshot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function finalizedCriteria(detail, contractPatch) {
|
|
148
|
+
const criteria = contractPatch?.acceptanceCriteria ?? detail.acceptanceCriteria ?? [];
|
|
149
|
+
if (!Array.isArray(criteria) || criteria.length < 1 || criteria.length > 24) {
|
|
150
|
+
throw new Error('Work Center Coordinator replan requires between 1 and 24 acceptance criteria');
|
|
151
|
+
}
|
|
152
|
+
return criteria;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function coordinatorSnapshot(detail) {
|
|
156
|
+
const runs = Array.isArray(detail.runs) ? detail.runs : [];
|
|
157
|
+
const canonicalRunByAction = new Map();
|
|
158
|
+
for (const action of detail.actions || []) {
|
|
159
|
+
const candidates = runs
|
|
160
|
+
.filter(run => run.actionId === action.id && run.status !== 'running')
|
|
161
|
+
.sort((left, right) => Number(right.endedAt || right.startedAt) - Number(left.endedAt || left.startedAt));
|
|
162
|
+
const canonical = action.resultRunId
|
|
163
|
+
? candidates.find(run => run.id === action.resultRunId)
|
|
164
|
+
: candidates[0];
|
|
165
|
+
if (canonical) canonicalRunByAction.set(action.id, canonical);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
workItem: {
|
|
169
|
+
id: detail.id,
|
|
170
|
+
revision: detail.revision,
|
|
171
|
+
planRevision: detail.planRevision,
|
|
172
|
+
ledgerRevision: detail.ledgerRevision,
|
|
173
|
+
status: detail.status,
|
|
174
|
+
title: detail.title,
|
|
175
|
+
goal: detail.goal,
|
|
176
|
+
acceptanceCriteria: detail.acceptanceCriteria || [],
|
|
177
|
+
workItemType: detail.workflowSnapshot?.workItemType || null,
|
|
178
|
+
},
|
|
179
|
+
actions: (detail.actions || [])
|
|
180
|
+
.filter(action => !['superseded', 'cancelled'].includes(action.status))
|
|
181
|
+
.map(action => {
|
|
182
|
+
const result = canonicalRunByAction.get(action.id);
|
|
183
|
+
return {
|
|
184
|
+
id: action.id,
|
|
185
|
+
stageId: action.stageId,
|
|
186
|
+
type: action.type,
|
|
187
|
+
status: action.status,
|
|
188
|
+
generation: action.generation,
|
|
189
|
+
dependencies: action.dependsOnStageIds || [],
|
|
190
|
+
workspaceMode: action.workspaceMode,
|
|
191
|
+
brief: action.brief || null,
|
|
192
|
+
result: result ? {
|
|
193
|
+
status: result.status,
|
|
194
|
+
summary: result.summary || '',
|
|
195
|
+
evidence: (result.evidence || []).slice(0, 20),
|
|
196
|
+
waitingReason: result.waitingReason || null,
|
|
197
|
+
error: result.error || null,
|
|
198
|
+
reviewDecision: result.reviewDecision || null,
|
|
199
|
+
} : null,
|
|
200
|
+
};
|
|
201
|
+
}),
|
|
202
|
+
conversation: coordinatorHistory(detail.messages),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class WorkItemCoordinator {
|
|
207
|
+
constructor(options = {}) {
|
|
208
|
+
this.store = options.store;
|
|
209
|
+
this.runtimeProvider = options.runtimeProvider;
|
|
210
|
+
this.policyProvider = typeof options.policyProvider === 'function' ? options.policyProvider : async () => ({});
|
|
211
|
+
this.registry = options.registry;
|
|
212
|
+
this.activeTurns = new Map();
|
|
213
|
+
this.activeTasks = new Map();
|
|
214
|
+
this.shuttingDown = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
message(id, input = {}, options = {}) {
|
|
218
|
+
if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
|
|
219
|
+
const text = cleanText(input.text, COORDINATOR_MAX_REPLY_CHARS, 'message');
|
|
220
|
+
const started = this.store.beginCoordinatorTurn(id, text, {
|
|
221
|
+
revision: Number(input.revision),
|
|
222
|
+
planRevision: Number(input.planRevision),
|
|
223
|
+
ledgerRevision: Number(input.ledgerRevision),
|
|
224
|
+
coordinatorRevision: Number(input.coordinatorRevision),
|
|
225
|
+
});
|
|
226
|
+
if (!started) throw new Error(`WorkItem not found: ${id}`);
|
|
227
|
+
options.onUpdate?.('coordinator.turn_started', started.detail);
|
|
228
|
+
|
|
229
|
+
const abortController = new AbortController();
|
|
230
|
+
this.activeTurns.set(started.turnId, abortController);
|
|
231
|
+
const task = new Promise(resolve => setTimeout(resolve, 0)).then(async () => {
|
|
232
|
+
try {
|
|
233
|
+
const runtime = await this.runtimeProvider();
|
|
234
|
+
if (this.shuttingDown) throw new Error('Work Center Coordinator is shutting down');
|
|
235
|
+
const settings = await this.policyProvider();
|
|
236
|
+
const vps = this.registry?.listVps?.() || [];
|
|
237
|
+
const assignment = selectWorkItemVp({
|
|
238
|
+
policy: { mode: 'pool', candidateVpIds: vps.map(vp => vp.id), capability: 'triage' },
|
|
239
|
+
stageType: 'triage',
|
|
240
|
+
vps,
|
|
241
|
+
priorRuns: started.detail.runs || [],
|
|
242
|
+
});
|
|
243
|
+
const coordinatorPolicy = settings?.coordinatorModelPolicy || {
|
|
244
|
+
...(settings?.modelPolicy || {}),
|
|
245
|
+
effort: settings?.actionModelPolicies?.triage?.effort || settings?.modelPolicy?.effort || 'high',
|
|
246
|
+
};
|
|
247
|
+
const resolved = resolveWorkItemModel(runtime.config, assignment.vp, coordinatorPolicy);
|
|
248
|
+
const result = await Promise.race([
|
|
249
|
+
runtime.adapter.call({
|
|
250
|
+
model: resolved.model,
|
|
251
|
+
system: COORDINATOR_SYSTEM_PROMPT,
|
|
252
|
+
messages: [{
|
|
253
|
+
role: 'user',
|
|
254
|
+
content: `Current WorkItem snapshot:\n${coordinatorSnapshotText(started.detail)}\n\nLatest user message:\n${text}`,
|
|
255
|
+
}],
|
|
256
|
+
maxTokens: Math.min(
|
|
257
|
+
resolveMaxOutputTokens(resolved.model, runtime.config),
|
|
258
|
+
COORDINATOR_MAX_OUTPUT_TOKENS,
|
|
259
|
+
),
|
|
260
|
+
effort: resolved.effort,
|
|
261
|
+
effortSource: resolved.source,
|
|
262
|
+
effortContext: { scenario: 'work-center-coordinator' },
|
|
263
|
+
signal: abortController.signal,
|
|
264
|
+
}),
|
|
265
|
+
new Promise((_, reject) => {
|
|
266
|
+
abortController.signal.addEventListener('abort', () => {
|
|
267
|
+
reject(new Error('Work Center Coordinator was interrupted'));
|
|
268
|
+
}, { once: true });
|
|
269
|
+
}),
|
|
270
|
+
]);
|
|
271
|
+
const normalized = normalizeCoordinatorResponse(result?.text, started.detail);
|
|
272
|
+
if (normalized.decision.kind === 'replan') {
|
|
273
|
+
finalizedCriteria(started.detail, normalized.decision.contractPatch);
|
|
274
|
+
}
|
|
275
|
+
const mutation = normalized.decision.kind === 'replan'
|
|
276
|
+
? applyCoordinatorReplan({
|
|
277
|
+
workItem: {
|
|
278
|
+
...started.detail,
|
|
279
|
+
...(normalized.decision.contractPatch || {}),
|
|
280
|
+
},
|
|
281
|
+
actions: started.detail.actions || [],
|
|
282
|
+
proposal: {
|
|
283
|
+
proposalId: `coordinator:${started.turnId}`,
|
|
284
|
+
basePlanRevision: started.detail.planRevision,
|
|
285
|
+
reason: normalized.decision.reason,
|
|
286
|
+
actions: normalized.decision.actions,
|
|
287
|
+
},
|
|
288
|
+
availableVpIds: vps.map(vp => vp.id),
|
|
289
|
+
})
|
|
290
|
+
: null;
|
|
291
|
+
const detail = this.store.completeCoordinatorTurn(started.turnId, {
|
|
292
|
+
reply: normalized.reply,
|
|
293
|
+
decision: normalized.decision,
|
|
294
|
+
mutation,
|
|
295
|
+
}, started.fence);
|
|
296
|
+
if (!detail) throw new Error('Work Center Coordinator turn is stale or already completed');
|
|
297
|
+
options.onUpdate?.('coordinator.turn_completed', detail);
|
|
298
|
+
return detail;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const detail = this.store.failCoordinatorTurn(started.turnId, error, started.fence);
|
|
301
|
+
if (detail) {
|
|
302
|
+
options.onUpdate?.('coordinator.turn_failed', detail);
|
|
303
|
+
return detail;
|
|
304
|
+
}
|
|
305
|
+
throw error;
|
|
306
|
+
} finally {
|
|
307
|
+
this.activeTurns.delete(started.turnId);
|
|
308
|
+
this.activeTasks.delete(started.turnId);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
this.activeTasks.set(started.turnId, task);
|
|
312
|
+
return { detail: started.detail, task };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async shutdown() {
|
|
316
|
+
this.shuttingDown = true;
|
|
317
|
+
for (const controller of this.activeTurns.values()) controller.abort('work_center_coordinator_shutdown');
|
|
318
|
+
const tasks = [...this.activeTasks.values()];
|
|
319
|
+
if (tasks.length > 0) await Promise.allSettled(tasks);
|
|
320
|
+
this.activeTurns.clear();
|
|
321
|
+
this.activeTasks.clear();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
68
|
-
return
|
|
69
|
-
|
|
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.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
&&
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 =
|
|
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:
|
|
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,
|