@yeaft/webchat-agent 1.0.550 → 1.0.552

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.550",
3
+ "version": "1.0.552",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -4,6 +4,7 @@ import { join, basename, dirname, extname, isAbsolute, relative, resolve } from
4
4
  import { platform } from 'os';
5
5
  import ctx from '../context.js';
6
6
  import { resolveAndValidatePath, BINARY_EXTENSIONS, VIDEO_EXTENSIONS } from './utils.js';
7
+ import { resolveWorkItemPath } from './work-item-path.js';
7
8
  import { sendWorkbenchResult } from './request-routing.js';
8
9
 
9
10
  export const MAX_WORKBENCH_PREVIEW_BYTES = 20 * 1024 * 1024;
@@ -271,7 +272,7 @@ export async function handleWriteFile(msg) {
271
272
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
272
273
 
273
274
  try {
274
- const resolved = resolveAndValidatePath(filePath, workDir);
275
+ const resolved = await resolveWorkItemPath(msg, filePath, workDir);
275
276
  await writeFile(resolved, content, 'utf-8');
276
277
 
277
278
  sendWorkbenchResult(ctx, msg, {
@@ -300,7 +301,8 @@ export async function handleWriteFile(msg) {
300
301
  }
301
302
 
302
303
  export async function handleListDirectory(msg) {
303
- const { conversationId, requestId, dirPath, _requestUserId, _requestClientId } = msg;
304
+ const { conversationId, requestId, _requestUserId, _requestClientId } = msg;
305
+ const dirPath = msg.dirPath || (msg.workbenchRoute?.runtimeProvider === 'work-center' ? msg.workDir : msg.dirPath);
304
306
  const directoryPickerScope = msg.directoryPickerScope === 'agent' ? 'agent' : undefined;
305
307
  const conv = ctx.conversations.get(conversationId);
306
308
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
@@ -362,7 +364,7 @@ export async function handleListDirectory(msg) {
362
364
  }
363
365
 
364
366
  try {
365
- const resolved = resolveAndValidatePath(dirPath, workDir);
367
+ const resolved = await resolveWorkItemPath(msg, dirPath, workDir);
366
368
  const entries = await readdir(resolved, { withFileTypes: true });
367
369
  const result = [];
368
370
 
@@ -426,7 +428,7 @@ export async function handleCreateFile(msg) {
426
428
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
427
429
 
428
430
  try {
429
- const resolved = resolveAndValidatePath(filePath, workDir);
431
+ const resolved = await resolveWorkItemPath(msg, filePath, workDir);
430
432
  if (isDirectory) {
431
433
  await mkdir(resolved, { recursive: true });
432
434
  } else {
@@ -464,7 +466,7 @@ export async function handleDeleteFiles(msg) {
464
466
 
465
467
  for (const p of paths) {
466
468
  try {
467
- const resolved = resolveAndValidatePath(p, workDir);
469
+ const resolved = await resolveWorkItemPath(msg, p, workDir);
468
470
  const s = await stat(resolved);
469
471
  if (s.isDirectory()) {
470
472
  await rm(resolved, { recursive: true, force: true });
@@ -503,7 +505,7 @@ export async function handleMoveFiles(msg) {
503
505
  if (!paths || paths.length === 0) throw new Error('No paths specified');
504
506
  if (!destination) throw new Error('No destination specified');
505
507
 
506
- const destResolved = resolveAndValidatePath(destination, workDir);
508
+ const destResolved = await resolveWorkItemPath(msg, destination, workDir);
507
509
  // Ensure destination directory exists
508
510
  await mkdir(destResolved, { recursive: true });
509
511
 
@@ -512,9 +514,9 @@ export async function handleMoveFiles(msg) {
512
514
 
513
515
  for (const p of paths) {
514
516
  try {
515
- const srcResolved = resolveAndValidatePath(p, workDir);
517
+ const srcResolved = await resolveWorkItemPath(msg, p, workDir);
516
518
  const name = (newName && paths.length === 1) ? newName : basename(srcResolved);
517
- const destPath = join(destResolved, name);
519
+ const destPath = await resolveWorkItemPath(msg, join(destResolved, name), workDir);
518
520
  if (existsSync(destPath)) {
519
521
  throw new Error('Target already exists: ' + name);
520
522
  }
@@ -551,7 +553,7 @@ export async function handleCopyFiles(msg) {
551
553
  if (!paths || paths.length === 0) throw new Error('No paths specified');
552
554
  if (!destination) throw new Error('No destination specified');
553
555
 
554
- const destResolved = resolveAndValidatePath(destination, workDir);
556
+ const destResolved = await resolveWorkItemPath(msg, destination, workDir);
555
557
  await mkdir(destResolved, { recursive: true });
556
558
 
557
559
  const copied = [];
@@ -559,7 +561,7 @@ export async function handleCopyFiles(msg) {
559
561
 
560
562
  for (const p of paths) {
561
563
  try {
562
- const srcResolved = resolveAndValidatePath(p, workDir);
564
+ const srcResolved = await resolveWorkItemPath(msg, p, workDir);
563
565
  const name = basename(srcResolved);
564
566
  let destPath = join(destResolved, name);
565
567
 
@@ -574,9 +576,19 @@ export async function handleCopyFiles(msg) {
574
576
  } while (existsSync(destPath));
575
577
  }
576
578
 
579
+ await resolveWorkItemPath(msg, destPath, workDir);
577
580
  const srcStat = await stat(srcResolved);
578
581
  if (srcStat.isDirectory()) {
579
- await cp(srcResolved, destPath, { recursive: true });
582
+ await cp(srcResolved, destPath, {
583
+ recursive: true,
584
+ ...(msg.workbenchRoute?.runtimeProvider === 'work-center' ? {
585
+ filter: async (source, target) => {
586
+ await resolveWorkItemPath(msg, source, workDir);
587
+ await resolveWorkItemPath(msg, target, workDir);
588
+ return true;
589
+ },
590
+ } : {}),
591
+ });
580
592
  } else {
581
593
  await copyFile(srcResolved, destPath);
582
594
  }
@@ -611,7 +623,7 @@ export async function handleUploadToDir(msg) {
611
623
  try {
612
624
  if (!files || files.length === 0) throw new Error('No files specified');
613
625
 
614
- const targetDir = resolveAndValidatePath(dirPath || workDir, workDir);
626
+ const targetDir = await resolveWorkItemPath(msg, dirPath || workDir, workDir);
615
627
  await mkdir(targetDir, { recursive: true });
616
628
 
617
629
  const saved = [];
@@ -619,7 +631,7 @@ export async function handleUploadToDir(msg) {
619
631
 
620
632
  for (const file of files) {
621
633
  try {
622
- const dest = join(targetDir, file.name);
634
+ const dest = await resolveWorkItemPath(msg, join(targetDir, file.name), workDir);
623
635
  const buffer = Buffer.from(file.data, 'base64');
624
636
  await writeFile(dest, buffer);
625
637
  saved.push(file.name);
@@ -1,6 +1,7 @@
1
1
  import { readdir, stat } from 'fs/promises';
2
2
  import { join, relative, resolve } from 'path';
3
3
  import ctx from '../context.js';
4
+ import { resolveWorkItemPath } from './work-item-path.js';
4
5
  import { sendWorkbenchResult } from './request-routing.js';
5
6
 
6
7
  export async function handleFileSearch(msg) {
@@ -15,7 +16,9 @@ export async function handleFileSearch(msg) {
15
16
  return;
16
17
  }
17
18
 
18
- const resolved = resolve(searchRoot);
19
+ const resolved = msg.workbenchRoute?.runtimeProvider === 'work-center'
20
+ ? await resolveWorkItemPath(msg, searchRoot, workDir)
21
+ : resolve(searchRoot);
19
22
  const results = [];
20
23
  const MAX_RESULTS = 100;
21
24
  const lowerQuery = query.toLowerCase();
@@ -1,9 +1,28 @@
1
- import { readFile, writeFile } from 'fs/promises';
2
- import { join, resolve } from 'path';
1
+ import { readFile, realpath, writeFile } from 'fs/promises';
2
+ import { join, relative, resolve } from 'path';
3
3
  import ctx from '../context.js';
4
4
  import { execAsync, resolveAndValidatePath, getGitRoot, validateGitPath } from './utils.js';
5
+ import { resolveWorkItemPath } from './work-item-path.js';
5
6
  import { sendWorkbenchResult } from './request-routing.js';
6
7
 
8
+ // Git paths and bulk operations are repository-scoped. A WorkItem must own
9
+ // that root, rather than silently widening a subdirectory workspace to it.
10
+ async function getWorkbenchGitRoot(msg, workDir) {
11
+ if (msg.workbenchRoute?.runtimeProvider !== 'work-center') return getGitRoot(workDir);
12
+ // Do not fall back to cwd on lookup failure: Git itself may ascend from it.
13
+ const { stdout } = await execAsync('git rev-parse --show-toplevel', {
14
+ cwd: workDir, timeout: 5000, windowsHide: true,
15
+ });
16
+ const gitRoot = stdout.trim();
17
+ const [workspace, repository] = await Promise.all([realpath(workDir), realpath(gitRoot)]);
18
+ if (relative(workspace, repository) !== '') {
19
+ const error = new Error('Git requires the WorkItem workspace to be the repository root. Files and Terminal remain available.');
20
+ error.code = 'WORK_ITEM_GIT_ROOT_REQUIRED';
21
+ throw error;
22
+ }
23
+ return gitRoot;
24
+ }
25
+
7
26
  export async function handleGitStatus(msg) {
8
27
  const { conversationId, _requestUserId } = msg;
9
28
  const conv = ctx.conversations.get(conversationId);
@@ -11,15 +30,7 @@ export async function handleGitStatus(msg) {
11
30
 
12
31
  try {
13
32
  // Get git repo root to ensure paths are consistent
14
- let gitRoot = workDir;
15
- try {
16
- const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
17
- cwd: workDir,
18
- timeout: 5000,
19
- windowsHide: true
20
- });
21
- gitRoot = rootOut.trim();
22
- } catch {}
33
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
23
34
 
24
35
  const { stdout: statusOut } = await execAsync('git status --porcelain', {
25
36
  cwd: gitRoot,
@@ -76,6 +87,7 @@ export async function handleGitStatus(msg) {
76
87
  conversationId,
77
88
  _requestUserId,
78
89
  error: e.message,
90
+ ...(e.code ? { errorCode: e.code } : {}),
79
91
  isGitRepo: !e.message.includes('not a git repository') && !e.message.includes('ENOENT')
80
92
  });
81
93
  }
@@ -100,20 +112,14 @@ export async function handleGitDiff(msg) {
100
112
  }
101
113
 
102
114
  // Get git repo root — git status paths are relative to this, not workDir
103
- let gitRoot = workDir;
104
- try {
105
- const { stdout: rootOut } = await execAsync('git rev-parse --show-toplevel', {
106
- cwd: workDir,
107
- timeout: 5000,
108
- windowsHide: true
109
- });
110
- gitRoot = rootOut.trim();
111
- } catch {}
115
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
112
116
 
113
117
  if (untracked) {
114
118
  // Untracked files: resolve path relative to git root
115
119
  const fullPath = resolve(gitRoot, filePath);
116
- const resolved = resolveAndValidatePath(fullPath, gitRoot);
120
+ // The root check above proved canonical workspace ownership. Validate
121
+ // against that same Git root, including when cwd is a symlink alias.
122
+ const resolved = await resolveWorkItemPath(msg, resolveAndValidatePath(fullPath, gitRoot), gitRoot);
117
123
  const content = await readFile(resolved, 'utf-8');
118
124
  sendWorkbenchResult(ctx, msg, {
119
125
  type: 'git_diff_result',
@@ -205,7 +211,7 @@ export async function handleGitAdd(msg) {
205
211
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
206
212
 
207
213
  try {
208
- const gitRoot = await getGitRoot(workDir);
214
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
209
215
 
210
216
  if (addAll) {
211
217
  await execAsync('git add -A', { cwd: gitRoot, timeout: 10000, windowsHide: true });
@@ -229,7 +235,7 @@ export async function handleGitReset(msg) {
229
235
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
230
236
 
231
237
  try {
232
- const gitRoot = await getGitRoot(workDir);
238
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
233
239
 
234
240
  if (resetAll) {
235
241
  await execAsync('git reset HEAD', { cwd: gitRoot, timeout: 10000, windowsHide: true });
@@ -258,7 +264,7 @@ export async function handleGitRestore(msg) {
258
264
  return;
259
265
  }
260
266
 
261
- const gitRoot = await getGitRoot(workDir);
267
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
262
268
  await execAsync(`git restore -- "${filePath}"`, { cwd: gitRoot, timeout: 10000, windowsHide: true });
263
269
  sendWorkbenchResult(ctx, msg, { type: 'git_op_result', conversationId, _requestUserId, operation: 'restore', success: true, message: `Restored: ${filePath}` });
264
270
  } catch (e) {
@@ -277,7 +283,7 @@ export async function handleGitCommit(msg) {
277
283
  return;
278
284
  }
279
285
 
280
- const gitRoot = await getGitRoot(workDir);
286
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
281
287
 
282
288
  // Write commit message to temp file to avoid shell injection
283
289
  const tmpFile = join(gitRoot, '.git', 'WEBCHAT_COMMIT_MSG');
@@ -303,7 +309,7 @@ export async function handleGitPush(msg) {
303
309
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
304
310
 
305
311
  try {
306
- const gitRoot = await getGitRoot(workDir);
312
+ const gitRoot = await getWorkbenchGitRoot(msg, workDir);
307
313
  const { stdout, stderr } = await execAsync('git push', {
308
314
  cwd: gitRoot, timeout: 60000, windowsHide: true
309
315
  });
@@ -0,0 +1,41 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ function assertWithin(root, candidate) {
5
+ const path = relative(root, candidate);
6
+ if (path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)) {
7
+ const error = new Error('File is outside the WorkItem workspace.');
8
+ error.code = 'FILE_OUTSIDE_WORKSPACE';
9
+ throw error;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Resolve a WorkItem file operation without following a symlink outside its
15
+ * Agent-resolved workspace. New destinations validate the nearest existing
16
+ * ancestor; dangling symlinks fail closed. Other Workbench routes keep their
17
+ * existing path-picker behavior. This is not a concurrent filesystem sandbox.
18
+ */
19
+ export async function resolveWorkItemPath(msg, filePath, workDir) {
20
+ const candidate = resolve(workDir, filePath);
21
+ if (msg.workbenchRoute?.runtimeProvider !== 'work-center') return candidate;
22
+ if (!isAbsolute(workDir)) throw new Error('WorkItem workspace must be absolute.');
23
+ const root = await realpath(workDir);
24
+ // Check both lexical and canonical ownership. Return the original path so a
25
+ // delete/rename still operates on a symlink itself, not on its target.
26
+ assertWithin(resolve(workDir), candidate);
27
+ let ancestor = candidate;
28
+ for (;;) {
29
+ try {
30
+ await lstat(ancestor);
31
+ } catch (error) {
32
+ if (error.code !== 'ENOENT') throw error;
33
+ const parent = dirname(ancestor);
34
+ if (parent === ancestor) throw error;
35
+ ancestor = parent;
36
+ continue;
37
+ }
38
+ assertWithin(root, await realpath(ancestor));
39
+ return candidate;
40
+ }
41
+ }
@@ -162,17 +162,21 @@ function availableModel(config, ref) {
162
162
  if (models.length === 0) return null;
163
163
  const parsed = parseModelRef(ref);
164
164
  return models.find(model => model.ref === ref)
165
- || models.find(model => !parsed.providerName && model.id === parsed.modelId)
165
+ || models.find(model => model.id === parsed.modelId
166
+ && (!parsed.providerName || model.provider === parsed.providerName))
166
167
  || null;
167
168
  }
168
169
 
169
- export function resolveWorkItemModel(config, vp, rawPolicy) {
170
+ export function resolveWorkItemModel(config, vp, rawPolicy, modelTags = {}) {
170
171
  const policy = normalizeModelPolicy(rawPolicy);
171
172
  let model;
172
173
  let source;
173
174
  if (policy.mode === 'specific') {
174
175
  model = policy.model;
175
176
  source = 'stage-specific';
177
+ } else if (policy.mode === 'tag') {
178
+ model = modelTags[policy.tag] || null;
179
+ source = `tag:${policy.tag}`;
176
180
  } else if (policy.mode === 'primary') {
177
181
  model = config.primaryModel || config.model || null;
178
182
  source = 'agent-primary';
@@ -191,8 +195,9 @@ export function resolveWorkItemModel(config, vp, rawPolicy) {
191
195
  if (Array.isArray(config.availableModels) && config.availableModels.length > 0 && !available) {
192
196
  throw policyError(`Configured Work Center model is unavailable: ${model}`);
193
197
  }
198
+ if (policy.mode === 'tag' && available?.ref) model = available.ref;
194
199
  const effortOptions = Array.isArray(available?.effortOptions) ? available.effortOptions : [];
195
- const effortOrder = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
200
+ const effortOrder = ['medium', 'high', 'xhigh'];
196
201
  const requestedIndex = effortOrder.indexOf(policy.effort);
197
202
  const effort = !policy.effort || effortOptions.length === 0
198
203
  ? null
@@ -20,7 +20,10 @@ import { isDynamicWorkItem } from './execution-mode.js';
20
20
  import { applyCoordinatorReplan } from './plan-mutation.js';
21
21
  import { buildWorkItemAttachmentContext } from './attachments.js';
22
22
  import { sanitizeDiagnosticText } from './debug-projection.js';
23
- import { generatedActionGraphRules } from './workflow.js';
23
+ import {
24
+ DEFAULT_WORK_CENTER_MODEL_TAGS,
25
+ generatedActionGraphRules,
26
+ } from './workflow.js';
24
27
  import { workItemCapabilityContext } from './capabilities.js';
25
28
 
26
29
  const COORDINATOR_MAX_REPLY_CHARS = 8_000;
@@ -900,7 +903,12 @@ export class WorkItemCoordinator {
900
903
  ...(settings?.modelPolicy || {}),
901
904
  effort: settings?.actionModelPolicies?.triage?.effort || settings?.modelPolicy?.effort || 'high',
902
905
  };
903
- resolved = resolveWorkItemModel(runtime.config, assignment.vp, coordinatorPolicy);
906
+ resolved = resolveWorkItemModel(
907
+ runtime.config,
908
+ assignment.vp,
909
+ coordinatorPolicy,
910
+ settings?.modelTags || DEFAULT_WORK_CENTER_MODEL_TAGS,
911
+ );
904
912
  } catch (error) {
905
913
  throw coordinatorExecutionError(error, 'selection', language);
906
914
  }
@@ -1,5 +1,5 @@
1
1
  import { resolveWorkItemModel, selectWorkItemVp } from './assignment.js';
2
- import { resolveWorkflowSnapshot } from './workflow.js';
2
+ import { normalizeWorkCenterSettings, resolveWorkflowSnapshot } from './workflow.js';
3
3
 
4
4
  function publicVp(vp) {
5
5
  return vp ? {
@@ -17,6 +17,7 @@ function publicVp(vp) {
17
17
  }
18
18
 
19
19
  export function previewWorkCenterPlan({ settings, workflowId, stageOverrides, registry, config }) {
20
+ const normalizedSettings = normalizeWorkCenterSettings(settings);
20
21
  const workflow = resolveWorkflowSnapshot(settings, workflowId, stageOverrides);
21
22
  const vps = registry.listVps();
22
23
  const syntheticRuns = [];
@@ -28,7 +29,12 @@ export function previewWorkCenterPlan({ settings, workflowId, stageOverrides, re
28
29
  vps,
29
30
  priorRuns: syntheticRuns,
30
31
  });
31
- const model = resolveWorkItemModel(config, assignment.vp, stage.modelPolicy);
32
+ const model = resolveWorkItemModel(
33
+ config,
34
+ assignment.vp,
35
+ stage.modelPolicy,
36
+ normalizedSettings.modelTags,
37
+ );
32
38
  syntheticRuns.push({
33
39
  actionType: stage.type,
34
40
  roleSnapshot: { actionType: stage.type },
@@ -970,6 +970,11 @@ export function projectWorkItemDetail(detail, options = {}) {
970
970
  }
971
971
  const projected = {
972
972
  id: detail.id,
973
+ // The browser needs the Agent-resolved workspace only to bind an explicitly
974
+ // selected WorkItem Workbench. It is not included in board/list projections.
975
+ workbench: typeof detail.workDir === 'string' && detail.workDir.trim()
976
+ ? { workDir: detail.workDir.trim() }
977
+ : null,
973
978
  revision: detail.revision,
974
979
  planRevision: count(detail.planRevision),
975
980
  ledgerRevision: count(detail.ledgerRevision),
@@ -37,7 +37,11 @@ import { loadMCPConfig } from '../config.js';
37
37
  import { MCPManager } from '../mcp.js';
38
38
  import { buildMcpFlattenedTools } from '../tools/mcp-tools.js';
39
39
  import { recallWorkspaceSessionContext } from './workspace-context.js';
40
- import { applyGeneratedPlan, BUILT_IN_ACTION_TYPES } from './workflow.js';
40
+ import {
41
+ applyGeneratedPlan,
42
+ BUILT_IN_ACTION_TYPES,
43
+ DEFAULT_WORK_CENTER_MODEL_TAGS,
44
+ } from './workflow.js';
41
45
  import { isDynamicWorkItem, usesMainlineContext } from './execution-mode.js';
42
46
  import {
43
47
  applyAdditivePlanProposal,
@@ -1081,8 +1085,10 @@ export class WorkItemRunner {
1081
1085
  async run({ workItem, action, run, signal, ownerBootId, onProgress, registerProgressReader, registerInputWake, onEngineEvent = null }) {
1082
1086
  assertCreateVpActionAuthority(workItem, action, this.registry);
1083
1087
  const runtime = await this.runtimeProvider();
1088
+ const settings = this.policyProvider ? await this.policyProvider() : null;
1084
1089
  const currentSettings = ['ai', 'coordinator'].includes(workItem?.workflowSnapshot?.planningMode)
1085
- && this.policyProvider ? await this.policyProvider() : null;
1090
+ ? settings
1091
+ : null;
1086
1092
  const currentModelPolicy = currentSettings?.actionModelPolicies?.[action.type]
1087
1093
  || currentSettings?.actionModelPolicies?.custom
1088
1094
  || currentSettings?.modelPolicy
@@ -1125,7 +1131,16 @@ export class WorkItemRunner {
1125
1131
  error.retryable = false;
1126
1132
  throw error;
1127
1133
  }
1128
- const resolvedModel = resolveWorkItemModel(runtime.config, vp, executionAction.modelPolicy);
1134
+ const fallbackModel = runtime.config.primaryModel || runtime.config.model || null;
1135
+ const modelTags = settings?.modelTags || Object.fromEntries(
1136
+ Object.keys(DEFAULT_WORK_CENTER_MODEL_TAGS).map(tag => [tag, fallbackModel]),
1137
+ );
1138
+ const resolvedModel = resolveWorkItemModel(
1139
+ runtime.config,
1140
+ vp,
1141
+ executionAction.modelPolicy,
1142
+ modelTags,
1143
+ );
1129
1144
  const memoryBlock = recallWorkItemMemory(
1130
1145
  { ...runtime, yeaftDir: runtime.yeaftDir || this.yeaftDir },
1131
1146
  workItem,
@@ -21,12 +21,35 @@ export const BUILT_IN_ACTION_TYPES = Object.freeze([
21
21
  ]);
22
22
  const STAGE_TYPES = new Set(BUILT_IN_ACTION_TYPES);
23
23
  const ASSIGNMENT_MODES = new Set(['auto', 'pool', 'fixed', 'planned']);
24
- const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific']);
25
- const MODEL_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
24
+ const MODEL_MODES = new Set(['inherit', 'primary', 'fast', 'specific', 'tag']);
25
+ const MODEL_EFFORTS = new Set(['medium', 'high', 'xhigh']);
26
26
  const WORKSPACE_MODES = new Set(['shared', 'read', 'isolated-write', 'integrate']);
27
- const HIGH_EFFORT_ACTION_TYPES = new Set(['triage', 'research', 'design', 'diagnose', 'review']);
28
27
  const ACTION_CONTEXT_QUOTE_MAX_BYTES = 8 * 1024;
29
28
 
29
+ export const DEFAULT_WORK_CENTER_MODEL_TAGS = Object.freeze({
30
+ fast: 'gpt-5.6-luna',
31
+ balanced: 'gpt-5.6-sol',
32
+ ultimate: 'gpt-6-astra',
33
+ });
34
+
35
+ const ULTIMATE_ACTION_TYPES = new Set(['triage', 'research', 'design', 'diagnose', 'review']);
36
+ const FAST_ACTION_TYPES = new Set(['document', 'deliver', 'write']);
37
+
38
+ function normalizeModelTags(value) {
39
+ const source = value && typeof value === 'object' && !Array.isArray(value)
40
+ ? value
41
+ : DEFAULT_WORK_CENTER_MODEL_TAGS;
42
+ return Object.fromEntries(Object.entries(source)
43
+ .map(([tag, model]) => [canonicalActionId(tag), typeof model === 'string' ? model.trim() : ''])
44
+ .filter(([tag, model]) => tag && model));
45
+ }
46
+
47
+ function defaultActionModelTag(type) {
48
+ if (ULTIMATE_ACTION_TYPES.has(type)) return { tag: 'ultimate', effort: 'xhigh' };
49
+ if (FAST_ACTION_TYPES.has(type)) return { tag: 'fast', effort: 'medium' };
50
+ return { tag: 'balanced', effort: 'high' };
51
+ }
52
+
30
53
  const DEFAULT_STAGE_INSTRUCTIONS = Object.freeze({
31
54
  triage: 'Turn the request into an executable contract. Inspect relevant repository facts before deciding the flow. Classify the WorkItem, identify constraints, risks, dependencies, and missing acceptance criteria, then plan only the Actions needed for this task. Do not implement. If the goal or acceptance criteria must change, submit a contractPatch and explain why.',
32
55
  research: 'Answer the Action objective with verifiable evidence. Search the repository and, when needed, authoritative external sources. Separate observed facts from inference, record unresolved uncertainty, and produce a concise conclusion that a later Action can use.',
@@ -97,26 +120,26 @@ const DEFAULT_SOFTWARE_CHANGE_STAGES = Object.freeze([
97
120
  {
98
121
  id: 'triage', name: 'Triage', type: 'triage',
99
122
  assignmentPolicy: { mode: 'auto', capability: 'triage', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
100
- modelPolicy: { mode: 'inherit', model: null, effort: null },
123
+ modelPolicy: { mode: 'tag', tag: 'ultimate', effort: 'xhigh' },
101
124
  maxAttempts: 2,
102
125
  },
103
126
  {
104
127
  id: 'implement', name: 'Implement', type: 'implement',
105
128
  assignmentPolicy: { mode: 'auto', capability: 'implement', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
106
- modelPolicy: { mode: 'inherit', model: null, effort: null },
129
+ modelPolicy: { mode: 'tag', tag: 'balanced', effort: 'high' },
107
130
  maxAttempts: 2,
108
131
  },
109
132
  {
110
133
  id: 'review', name: 'Review', type: 'review',
111
134
  assignmentPolicy: { mode: 'auto', capability: 'review', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: ['implement'] },
112
- modelPolicy: { mode: 'inherit', model: null, effort: null },
135
+ modelPolicy: { mode: 'tag', tag: 'ultimate', effort: 'xhigh' },
113
136
  maxAttempts: 2,
114
137
  changesRequestedStageId: 'implement',
115
138
  },
116
139
  {
117
140
  id: 'deliver', name: 'Deliver', type: 'deliver',
118
141
  assignmentPolicy: { mode: 'auto', capability: 'deliver', candidateVpIds: [], fixedVpId: null, separateFromStageTypes: [] },
119
- modelPolicy: { mode: 'inherit', model: null, effort: null },
142
+ modelPolicy: { mode: 'tag', tag: 'fast', effort: 'medium' },
120
143
  maxAttempts: 2,
121
144
  },
122
145
  ]);
@@ -183,14 +206,18 @@ export function normalizeModelPolicy(value) {
183
206
  const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
184
207
  const mode = MODEL_MODES.has(source.mode) ? source.mode : 'inherit';
185
208
  const model = typeof source.model === 'string' && source.model.trim() ? source.model.trim() : null;
209
+ const tag = typeof source.tag === 'string' && source.tag.trim()
210
+ ? canonicalActionId(source.tag)
211
+ : null;
186
212
  const effort = MODEL_EFFORTS.has(source.effort) ? source.effort : null;
187
213
  if (mode === 'specific' && !model) throw new Error('Specific Work Center model policy requires a model');
188
- return { mode, model, effort };
214
+ if (mode === 'tag' && !tag) throw new Error('Tagged Work Center model policy requires a tag');
215
+ return { mode, model, tag, effort };
189
216
  }
190
217
 
191
218
  export function defaultActionModelPolicy(type, fallback = null) {
192
- const base = normalizeModelPolicy(fallback);
193
- return { ...base, effort: HIGH_EFFORT_ACTION_TYPES.has(type) ? 'high' : 'medium' };
219
+ if (fallback) return normalizeModelPolicy(fallback);
220
+ return normalizeModelPolicy({ mode: 'tag', ...defaultActionModelTag(type) });
194
221
  }
195
222
 
196
223
  export function normalizeActionModelPolicies(value, fallback = null) {
@@ -308,8 +335,9 @@ export function defaultWorkCenterSettings() {
308
335
  maxConcurrentActions: 3,
309
336
  defaultWorkDir: '',
310
337
  globalInstructions: '',
311
- modelPolicy: { mode: 'inherit', model: null, effort: null },
312
- coordinatorModelPolicy: { mode: 'inherit', model: null, effort: 'high' },
338
+ modelTags: { ...DEFAULT_WORK_CENTER_MODEL_TAGS },
339
+ modelPolicy: normalizeModelPolicy({ mode: 'tag', tag: 'balanced', effort: 'high' }),
340
+ coordinatorModelPolicy: normalizeModelPolicy({ mode: 'tag', tag: 'ultimate', effort: 'xhigh' }),
313
341
  actionModelPolicies: normalizeActionModelPolicies(),
314
342
  actionInstructions: normalizeActionInstructions(),
315
343
  workflows: [normalizeWorkflowDefinition({
@@ -347,6 +375,7 @@ export function normalizeWorkCenterSettings(value) {
347
375
  maxConcurrentActions: Math.min(Math.max(Number(source.maxConcurrentActions) || 3, 1), 12),
348
376
  defaultWorkDir: typeof source.defaultWorkDir === 'string' ? source.defaultWorkDir.trim() : '',
349
377
  globalInstructions: normalizeGlobalInstructions(source.globalInstructions),
378
+ modelTags: normalizeModelTags(source.modelTags),
350
379
  modelPolicy: normalizeModelPolicy(source.modelPolicy || migratedModelPolicy),
351
380
  coordinatorModelPolicy: normalizeModelPolicy(
352
381
  source.coordinatorModelPolicy || { ...(source.modelPolicy || migratedModelPolicy), effort: 'high' },