@yeaft/webchat-agent 1.0.247 → 1.0.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +59 -22
- 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 +17 -2
- 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 +23 -0
- package/yeaft/work-center/runner.js +1 -1
- package/yeaft/work-center/service.js +19 -5
- package/yeaft/work-center/store.js +1160 -147
- package/yeaft/work-center/workflow.js +75 -5
|
Binary file
|
package/package.json
CHANGED
|
@@ -7,6 +7,29 @@ export function eventMatchesActionGeneration(event, action) {
|
|
|
7
7
|
return generation(event.actionGeneration) === generation(action.generation);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export function currentActionInputEventIds(events, action) {
|
|
11
|
+
const inputEvents = new Map((Array.isArray(events) ? events : [])
|
|
12
|
+
.filter(event => event?.type === 'action.input_added' && event.actionId === action?.id)
|
|
13
|
+
.map(event => [String(event.id), event]));
|
|
14
|
+
const valid = new Set();
|
|
15
|
+
for (const event of inputEvents.values()) {
|
|
16
|
+
if (eventMatchesActionGeneration(event, action)) valid.add(String(event.id));
|
|
17
|
+
}
|
|
18
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
19
|
+
if (event?.type !== 'action.input_rebound' || event.actionId !== action?.id
|
|
20
|
+
|| !eventMatchesActionGeneration(event, action)) continue;
|
|
21
|
+
const sourceEventIds = [
|
|
22
|
+
event.data?.sourceEventId,
|
|
23
|
+
...(Array.isArray(event.data?.sourceEventIds) ? event.data.sourceEventIds : []),
|
|
24
|
+
].filter(value => value != null);
|
|
25
|
+
for (const eventId of sourceEventIds) {
|
|
26
|
+
const key = String(eventId);
|
|
27
|
+
if (inputEvents.has(key)) valid.add(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return valid;
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
export function runMatchesActionIdentity(run, action) {
|
|
11
34
|
if (!run || !action) return false;
|
|
12
35
|
const actionGeneration = generation(action.generation);
|
|
@@ -5,6 +5,7 @@ import { defaultRegistry } from '../vp/registry.js';
|
|
|
5
5
|
import { scanVpLibrary } from '../vp/vp-store.js';
|
|
6
6
|
import { WorkCenterService } from './service.js';
|
|
7
7
|
import { WorkItemRunner } from './runner.js';
|
|
8
|
+
import { WorkItemCoordinator } from './coordinator.js';
|
|
8
9
|
import { projectWorkCenterEvent, projectWorkItemDetail } from './projection.js';
|
|
9
10
|
import { previewWorkCenterPlan } from './planner.js';
|
|
10
11
|
import { readWorkCenterSettings, writeWorkCenterSettings } from './settings.js';
|
|
@@ -19,7 +20,7 @@ let shutdownPromise = null;
|
|
|
19
20
|
let serviceFactory = null;
|
|
20
21
|
|
|
21
22
|
const BROWSER_DETAIL_OPS = new Set([
|
|
22
|
-
'get', 'create', 'update', 'start', 'cancel', '
|
|
23
|
+
'get', 'create', 'update', 'start', 'cancel', 'action_input', 'retry_action', 'guide', 'retry',
|
|
23
24
|
]);
|
|
24
25
|
const BROWSER_ACTION_DEBUG_OPS = new Set(['get_action_messages', 'get_action_requests', 'get_action_request']);
|
|
25
26
|
// `files` is an internal server-to-Agent field. The browser relay rejects any
|
|
@@ -28,7 +29,7 @@ const BROWSER_FILE_FIELDS = Object.freeze({
|
|
|
28
29
|
create: [
|
|
29
30
|
'title', 'goal', 'acceptanceCriteria', 'workItemType', 'workDir', 'reuseMemory', 'files', 'start',
|
|
30
31
|
],
|
|
31
|
-
work_item_message: ['id', 'text', 'revision'],
|
|
32
|
+
work_item_message: ['id', 'text', 'revision', 'planRevision', 'ledgerRevision', 'coordinatorRevision'],
|
|
32
33
|
action_input: ['id', 'text', 'actionId', 'revision', 'generation', 'files'],
|
|
33
34
|
retry_action: ['id', 'actionId', 'revision', 'generation'],
|
|
34
35
|
delete: ['id', 'revision'],
|
|
@@ -117,9 +118,22 @@ async function createDefaultService() {
|
|
|
117
118
|
registry: defaultRegistry,
|
|
118
119
|
store: null,
|
|
119
120
|
});
|
|
121
|
+
const coordinator = new WorkItemCoordinator({
|
|
122
|
+
store: null,
|
|
123
|
+
runtimeProvider: async () => {
|
|
124
|
+
const runtime = await runner.runtimeProvider();
|
|
125
|
+
if (defaultRegistry.vpCount() === 0) {
|
|
126
|
+
for (const vp of scanVpLibrary({ dir: join(yeaftDir, 'virtual-persons') })) defaultRegistry.setVp(vp);
|
|
127
|
+
}
|
|
128
|
+
return runtime;
|
|
129
|
+
},
|
|
130
|
+
policyProvider: async () => readWorkCenterSettings(yeaftDir),
|
|
131
|
+
registry: defaultRegistry,
|
|
132
|
+
});
|
|
120
133
|
const created = new WorkCenterService({
|
|
121
134
|
yeaftDir,
|
|
122
135
|
runner,
|
|
136
|
+
coordinator,
|
|
123
137
|
runtimeInfoProvider: getSettingsRuntime,
|
|
124
138
|
listAvailableVpIds: () => defaultRegistry.listVps().map(vp => vp.id),
|
|
125
139
|
watcherOptions: {
|
|
@@ -130,6 +144,7 @@ async function createDefaultService() {
|
|
|
130
144
|
},
|
|
131
145
|
});
|
|
132
146
|
runner.store = created.store;
|
|
147
|
+
coordinator.store = created.store;
|
|
133
148
|
return created;
|
|
134
149
|
}
|
|
135
150
|
|
|
@@ -44,9 +44,31 @@ export function validateCompletedResult(result, action, workItem) {
|
|
|
44
44
|
result.error = 'Completed Action requires one ordered acceptance check with evidence for every acceptance criterion';
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Intermediate test Actions validate their own task-specific expected result.
|
|
48
|
+
// Requiring them to prove the entire WorkItem contract makes any DAG with
|
|
49
|
+
// later tests or reviews impossible to complete. Global proof belongs at the
|
|
50
|
+
// delivery boundary or an approved review with no unfinished downstream work.
|
|
51
|
+
const remainingStages = Array.isArray(workItem?.workflowSnapshot?.stages)
|
|
52
|
+
? workItem.workflowSnapshot.stages.filter(stage => stage?.id !== action.stageId)
|
|
53
|
+
: [];
|
|
54
|
+
const downstream = new Set([action.stageId]);
|
|
55
|
+
let expanded = true;
|
|
56
|
+
while (expanded) {
|
|
57
|
+
expanded = false;
|
|
58
|
+
for (const stage of remainingStages) {
|
|
59
|
+
if (downstream.has(stage?.id)) continue;
|
|
60
|
+
if (Array.isArray(stage?.dependsOnStageIds)
|
|
61
|
+
&& stage.dependsOnStageIds.some(stageId => downstream.has(stageId))) {
|
|
62
|
+
downstream.add(stage.id);
|
|
63
|
+
expanded = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const hasDownstreamStage = remainingStages.some(stage => downstream.has(stage?.id));
|
|
68
|
+
const hasDeliverStage = remainingStages.some(stage => stage?.type === 'deliver');
|
|
69
|
+
const mustVerify = action.type === 'deliver'
|
|
70
|
+
|| (action.type === 'review' && result.reviewDecision === 'approved'
|
|
71
|
+
&& !hasDownstreamStage && !hasDeliverStage);
|
|
50
72
|
if (mustVerify && checks.some(check => check.status !== 'passed')) {
|
|
51
73
|
result.outcome = 'failed';
|
|
52
74
|
result.error = `${action.type} Action requires every acceptance check to pass`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import {
|
|
2
3
|
actionForStage,
|
|
3
4
|
actionInstruction,
|
|
5
|
+
canonicalActionInstruction,
|
|
6
|
+
withoutActionInputContext,
|
|
4
7
|
applyGeneratedPlan,
|
|
5
8
|
getNextStep,
|
|
6
9
|
initialActionFor,
|
|
@@ -76,6 +79,28 @@ function normalizeTerminalResult(result, action) {
|
|
|
76
79
|
return normalized;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
function canonicalReplacementAction(workItem, action, context) {
|
|
83
|
+
const replacement = {
|
|
84
|
+
type: action.type,
|
|
85
|
+
stageId: action.stageId || action.type,
|
|
86
|
+
assignmentPolicy: action.assignmentPolicy,
|
|
87
|
+
modelPolicy: action.modelPolicy,
|
|
88
|
+
requiredRole: action.requiredRole,
|
|
89
|
+
dependsOnStageIds: action.dependsOnStageIds,
|
|
90
|
+
workspaceMode: action.workspaceMode,
|
|
91
|
+
changesRequestedStageId: action.changesRequestedStageId,
|
|
92
|
+
brief: action.brief,
|
|
93
|
+
context,
|
|
94
|
+
maxAttempts: action.maxAttempts || 2,
|
|
95
|
+
};
|
|
96
|
+
replacement.instruction = canonicalActionInstruction(
|
|
97
|
+
workItem,
|
|
98
|
+
{ ...action, ...replacement },
|
|
99
|
+
context,
|
|
100
|
+
);
|
|
101
|
+
return replacement;
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
function contextEntry(action, result, run) {
|
|
80
105
|
return {
|
|
81
106
|
type: action.type,
|
|
@@ -161,50 +186,22 @@ export class WorkflowController {
|
|
|
161
186
|
const current = this.store.getWorkItem(id);
|
|
162
187
|
const graphMode = current?.workflowSnapshot?.executionMode === 'graph';
|
|
163
188
|
if (!expected.actionId || !Number.isInteger(expected.revision)
|
|
164
|
-
|| (graphMode && !Number.isInteger(expected.generation))) {
|
|
189
|
+
|| (graphMode && (!Number.isInteger(expected.generation) || expected.generation < 1))) {
|
|
165
190
|
throw new Error(`actionId, revision${graphMode ? ', and generation' : ''} are required for guidance`);
|
|
166
191
|
}
|
|
167
192
|
const detail = this.store.addActionGuidance(id, guidanceSummary, expected, (workItem, previous) => {
|
|
168
|
-
const context = [...(previous.context
|
|
193
|
+
const context = [...withoutActionInputContext(previous.context), {
|
|
169
194
|
type: 'guidance',
|
|
170
195
|
role: 'user',
|
|
171
196
|
summary: guidanceSummary,
|
|
172
197
|
evidence: [],
|
|
173
198
|
}];
|
|
174
|
-
|
|
175
|
-
type: previous.type,
|
|
176
|
-
stageId: previous.stageId || previous.type,
|
|
177
|
-
assignmentPolicy: previous.assignmentPolicy,
|
|
178
|
-
modelPolicy: previous.modelPolicy,
|
|
179
|
-
requiredRole: previous.requiredRole,
|
|
180
|
-
dependsOnStageIds: previous.dependsOnStageIds,
|
|
181
|
-
workspaceMode: previous.workspaceMode,
|
|
182
|
-
changesRequestedStageId: previous.changesRequestedStageId,
|
|
183
|
-
brief: previous.brief,
|
|
184
|
-
};
|
|
185
|
-
return {
|
|
186
|
-
...step,
|
|
187
|
-
context,
|
|
188
|
-
instruction: actionInstruction(step, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
|
|
189
|
-
maxAttempts: previous.maxAttempts || 2,
|
|
190
|
-
};
|
|
199
|
+
return canonicalReplacementAction(workItem, previous, context);
|
|
191
200
|
}, input.attachments, input.addedAttachments);
|
|
192
201
|
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
193
202
|
return detail;
|
|
194
203
|
}
|
|
195
204
|
|
|
196
|
-
message(id, input = {}) {
|
|
197
|
-
const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
|
|
198
|
-
if (!text) throw new Error('WorkItem message is required');
|
|
199
|
-
const revision = Number(input.revision);
|
|
200
|
-
if (!Number.isInteger(revision)) throw new Error('revision is required for WorkItem messages');
|
|
201
|
-
const detail = this.store.addWorkItemMessage(id, text, revision, (workItem, action) => (
|
|
202
|
-
actionInstruction(action, workItem, action.context || [], renderSessionContextSnapshot(workItem.sessionContext))
|
|
203
|
-
));
|
|
204
|
-
if (!detail) throw new Error(`WorkItem not found: ${id}`);
|
|
205
|
-
return detail;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
205
|
input(id, input = {}) {
|
|
209
206
|
const text = typeof input.text === 'string' ? input.text.trim().slice(0, 8_000) : '';
|
|
210
207
|
const addedAttachmentCount = Math.max(0, Number(input.addedAttachmentCount) || 0);
|
|
@@ -212,10 +209,14 @@ export class WorkflowController {
|
|
|
212
209
|
const workItem = this.store.getWorkItem(id);
|
|
213
210
|
if (!workItem) throw new Error(`WorkItem not found: ${id}`);
|
|
214
211
|
const targetAction = this.store.getAction(input.actionId);
|
|
212
|
+
const expectedGeneration = Number(input.generation);
|
|
213
|
+
if (!Number.isInteger(expectedGeneration) || expectedGeneration < 1) {
|
|
214
|
+
throw new Error('actionId, revision, and generation are required for Action input');
|
|
215
|
+
}
|
|
215
216
|
const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
|
|
216
|
-
const targetMatches =
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
const targetMatches = targetAction?.workItemId === id
|
|
218
|
+
&& targetAction.generation === expectedGeneration
|
|
219
|
+
&& (graphMode || workItem.currentActionId === input.actionId);
|
|
219
220
|
if (!targetMatches || workItem.revision !== input.revision) {
|
|
220
221
|
throw new Error('Action changed before input was applied; refresh and try again');
|
|
221
222
|
}
|
|
@@ -226,27 +227,8 @@ export class WorkflowController {
|
|
|
226
227
|
const inputSummary = text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`;
|
|
227
228
|
return this.store.addActionInput(id, inputSummary, {
|
|
228
229
|
actionId: input.actionId,
|
|
229
|
-
generation:
|
|
230
|
+
generation: expectedGeneration,
|
|
230
231
|
revision: input.revision,
|
|
231
|
-
}, (current, currentAction) => {
|
|
232
|
-
const context = [...(currentAction.context || []), {
|
|
233
|
-
type: 'input', role: 'user', summary: inputSummary, evidence: [],
|
|
234
|
-
}];
|
|
235
|
-
const step = {
|
|
236
|
-
type: currentAction.type,
|
|
237
|
-
stageId: currentAction.stageId || currentAction.type,
|
|
238
|
-
assignmentPolicy: currentAction.assignmentPolicy,
|
|
239
|
-
modelPolicy: currentAction.modelPolicy,
|
|
240
|
-
requiredRole: currentAction.requiredRole,
|
|
241
|
-
dependsOnStageIds: currentAction.dependsOnStageIds,
|
|
242
|
-
workspaceMode: currentAction.workspaceMode,
|
|
243
|
-
changesRequestedStageId: currentAction.changesRequestedStageId,
|
|
244
|
-
brief: currentAction.brief,
|
|
245
|
-
};
|
|
246
|
-
return {
|
|
247
|
-
context,
|
|
248
|
-
instruction: actionInstruction(step, current, context, renderSessionContextSnapshot(current.sessionContext)),
|
|
249
|
-
};
|
|
250
232
|
}, input.attachments, input.addedAttachments);
|
|
251
233
|
}
|
|
252
234
|
if (!['waiting', 'failed'].includes(targetAction.status)) {
|
|
@@ -258,6 +240,7 @@ export class WorkflowController {
|
|
|
258
240
|
expected: { actionId: input.actionId, generation: input.generation, revision: input.revision },
|
|
259
241
|
attachments: input.attachments,
|
|
260
242
|
inputEvent: {
|
|
243
|
+
inputId: randomUUID(),
|
|
261
244
|
text: text || `The user added ${addedAttachmentCount} attachment(s) as additional context for this Action.`,
|
|
262
245
|
attachments: input.addedAttachments,
|
|
263
246
|
},
|
|
@@ -271,20 +254,7 @@ export class WorkflowController {
|
|
|
271
254
|
if (previous?.status === 'waiting' && !answer && addedAttachmentCount === 0) {
|
|
272
255
|
throw new Error('answer or attachments are required to resume a waiting Action');
|
|
273
256
|
}
|
|
274
|
-
const
|
|
275
|
-
? {
|
|
276
|
-
type: previous.type,
|
|
277
|
-
stageId: previous.stageId || previous.type,
|
|
278
|
-
assignmentPolicy: previous.assignmentPolicy,
|
|
279
|
-
modelPolicy: previous.modelPolicy,
|
|
280
|
-
requiredRole: previous.requiredRole,
|
|
281
|
-
dependsOnStageIds: previous.dependsOnStageIds,
|
|
282
|
-
workspaceMode: previous.workspaceMode,
|
|
283
|
-
changesRequestedStageId: previous.changesRequestedStageId,
|
|
284
|
-
brief: previous.brief,
|
|
285
|
-
}
|
|
286
|
-
: initialActionFor(workItem);
|
|
287
|
-
const context = Array.isArray(previous?.context) ? [...previous.context] : [];
|
|
257
|
+
const context = withoutActionInputContext(previous?.context);
|
|
288
258
|
if (previousRun) {
|
|
289
259
|
context.push({
|
|
290
260
|
type: previous.type,
|
|
@@ -299,12 +269,19 @@ export class WorkflowController {
|
|
|
299
269
|
: null),
|
|
300
270
|
});
|
|
301
271
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
272
|
+
if (Number(workItem.executionSchemaVersion) === 2 && input.inputEvent?.inputId) {
|
|
273
|
+
context.push({
|
|
274
|
+
type: 'input',
|
|
275
|
+
role: 'user',
|
|
276
|
+
inputId: input.inputEvent.inputId,
|
|
277
|
+
summary: input.inputEvent.text || '',
|
|
278
|
+
attachments: Array.isArray(input.inputEvent.attachments) ? input.inputEvent.attachments : [],
|
|
279
|
+
evidence: [],
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return previous
|
|
283
|
+
? canonicalReplacementAction(workItem, previous, context)
|
|
284
|
+
: { ...initialActionFor(workItem), context };
|
|
308
285
|
}, {
|
|
309
286
|
expected: input.expected || null,
|
|
310
287
|
attachments: input.attachments,
|
|
@@ -483,7 +460,7 @@ export class WorkflowController {
|
|
|
483
460
|
: planProposal
|
|
484
461
|
? { ...effectiveWorkItem, workflowSnapshot: planProposal.workflowSnapshot }
|
|
485
462
|
: effectiveWorkItem;
|
|
486
|
-
const context = [...(action.context
|
|
463
|
+
const context = [...withoutActionInputContext(action.context), contextEntry(action, result, activeRun)];
|
|
487
464
|
if (plannedWorkItem.workflowSnapshot?.executionMode === 'graph') {
|
|
488
465
|
if (staleReplanMutation) {
|
|
489
466
|
return {
|
|
@@ -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
|
+
}
|