@yeaft/webchat-agent 1.0.408 → 1.0.410

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/yeaft/session.js CHANGED
@@ -23,6 +23,7 @@ import { recordAgentTokenUsage } from '../metrics.js';
23
23
  import { ConversationStore, setDefaultRecentTurnsLimit } from './conversation/persist.js';
24
24
  import { SkillManager, createSkillManager } from './skills.js';
25
25
  import { MCPManager } from './mcp.js';
26
+ import { resolveMcpPluginPolicy } from './plugins.js';
26
27
  import { createFullRegistry } from './tools/index.js';
27
28
  import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
28
29
  import { Engine } from './engine.js';
@@ -395,19 +396,21 @@ export async function loadSession(options = {}) {
395
396
  // present, is only a project tier overlay plus the storage root.
396
397
  const projectTierRoot = sessionWorkDir || process.cwd();
397
398
 
398
- let skillManager;
399
+ let loadedSkillManager;
399
400
  if (skipSkills) {
400
401
  // Pass the literal user-tier dir (matches the normal branch's tier 2)
401
402
  // so any save/remove calls land in the same place users expect. New
402
403
  // `SkillManager` API takes literal scan dirs — no auto-suffix of /skills.
403
- skillManager = new SkillManager(join(configDir, 'skills'));
404
+ loadedSkillManager = new SkillManager(join(configDir, 'skills'));
404
405
  // Don't call .load() — empty skill manager
405
406
  } else {
406
- skillManager = createSkillManager(configDir, projectTierRoot);
407
+ loadedSkillManager = createSkillManager(configDir, projectTierRoot);
407
408
  }
409
+ const skillManager = loadedSkillManager;
408
410
 
409
411
  // ─── 7. Connect MCP servers ────────────────────────────
410
- const mcpConfig = loadMCPConfig(configDir, undefined, projectTierRoot);
412
+ const rawMcpConfig = loadMCPConfig(configDir, undefined, projectTierRoot);
413
+ const { effective: mcpConfig } = resolveMcpPluginPolicy(rawMcpConfig, config.plugins);
411
414
  const mcpManager = new MCPManager();
412
415
  let mcpStatus = { connected: [], failed: [] };
413
416
 
@@ -79,12 +79,14 @@ export function createCoordinator(group, options = {}) {
79
79
  ? input._routingIntent
80
80
  : null;
81
81
  const isRouteForwardInjection = inputMeta.injectedBy === 'route_forward';
82
+ const isRouteForwardResultInjection = inputMeta.injectedBy === 'route_forward_result';
82
83
  const isTaskResultInjection = inputMeta.injectedBy === 'task_result';
83
84
  const fromUser = input.from === 'user'
84
85
  || input.role === 'user'
85
86
  || isRouteForwardInjection
87
+ || isRouteForwardResultInjection
86
88
  || isTaskResultInjection;
87
- const forcedRouteTarget = (isRouteForwardInjection || isTaskResultInjection)
89
+ const forcedRouteTarget = (isRouteForwardInjection || isRouteForwardResultInjection || isTaskResultInjection)
88
90
  && typeof inputMeta.routeTargetVpId === 'string'
89
91
  ? inputMeta.routeTargetVpId.trim()
90
92
  : (isRouteForwardInjection && typeof inputMeta.routeForwardTarget === 'string'
@@ -243,4 +245,3 @@ function makeEnvelope(msg, meta, trigger, ephemeral = {}) {
243
245
  * @property {boolean=} truncatedAtFanOutCap
244
246
  * @property {string=} skipped
245
247
  */
246
-
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * feature-flag.js — Reads `config.yeaft.multiVp.enabled` from ~/.yeaft/config.json.
3
3
  *
4
- * Per architecture §11: multi-VP group mode is opt-in for MVP. The flag
5
- * gates UI entry points and (later) migration. This module returns a plain
4
+ * Per architecture §11: multi-VP Sessions are opt-in for MVP. The flag
5
+ * gates UI entry points and (later) migration. The reader returns a plain
6
6
  * boolean and never throws — missing/corrupt config falls back to `false`.
7
7
  *
8
- * A second helper `setMultiVpEnabled(dir, enabled)` writes through via
9
- * writeAtomic so tests and future settings UI can toggle it.
8
+ * The exported writer rejects an existing malformed config or invalid Plugin
9
+ * schema rather than replacing it, so it cannot reopen a fail-closed Agent
10
+ * capability policy through an unrelated feature-flag update.
10
11
  */
11
12
 
12
13
  import { existsSync, readFileSync } from 'fs';
13
14
  import { join } from 'path';
15
+ import { normalizePluginConfig } from '../plugins.js';
14
16
  import { writeAtomic } from '../storage/index.js';
15
17
 
16
18
  const CONFIG_FILE = 'config.json';
@@ -20,12 +22,32 @@ function readConfig(yeaftDir) {
20
22
  const path = join(yeaftDir, CONFIG_FILE);
21
23
  if (!existsSync(path)) return {};
22
24
  try {
23
- return JSON.parse(readFileSync(path, 'utf8')) || {};
25
+ const config = JSON.parse(readFileSync(path, 'utf8'));
26
+ return config && typeof config === 'object' && !Array.isArray(config) ? config : {};
24
27
  } catch {
25
28
  return {};
26
29
  }
27
30
  }
28
31
 
32
+ /**
33
+ * Strict precondition for writes to the Agent-owned config document. Reads can
34
+ * remain tolerant because the flag is optional, but no mutation may replace a
35
+ * malformed root or a Plugin policy that the runtime must keep fail-closed.
36
+ */
37
+ function readConfigForWrite(yeaftDir) {
38
+ const path = join(yeaftDir, CONFIG_FILE);
39
+ if (!existsSync(path)) return {};
40
+ const config = JSON.parse(readFileSync(path, 'utf8'));
41
+ if (!config || typeof config !== 'object' || Array.isArray(config)
42
+ || Object.getPrototypeOf(config) !== Object.prototype) {
43
+ throw new Error('config.json must contain an object');
44
+ }
45
+ if (Object.prototype.hasOwnProperty.call(config, 'plugins')) {
46
+ normalizePluginConfig(config.plugins);
47
+ }
48
+ return config;
49
+ }
50
+
29
51
  export function isMultiVpEnabled(yeaftDir) {
30
52
  const cfg = readConfig(yeaftDir);
31
53
  let cur = cfg;
@@ -37,13 +59,24 @@ export function isMultiVpEnabled(yeaftDir) {
37
59
  }
38
60
 
39
61
  export function setMultiVpEnabled(yeaftDir, enabled) {
40
- const cfg = readConfig(yeaftDir);
62
+ let cfg;
63
+ try {
64
+ cfg = readConfigForWrite(yeaftDir);
65
+ } catch (err) {
66
+ return { error: `Failed to read config.json: ${err?.message || err}` };
67
+ }
41
68
  let cur = cfg;
42
69
  for (let i = 0; i < FLAG_PATH.length - 1; i++) {
43
70
  const seg = FLAG_PATH[i];
44
- if (!cur[seg] || typeof cur[seg] !== 'object') cur[seg] = {};
71
+ if (!cur[seg] || typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) cur[seg] = {};
45
72
  cur = cur[seg];
46
73
  }
47
- cur[FLAG_PATH[FLAG_PATH.length - 1]] = Boolean(enabled);
48
- writeAtomic(join(yeaftDir, CONFIG_FILE), JSON.stringify(cfg, null, 2));
74
+ const nextValue = Boolean(enabled);
75
+ cur[FLAG_PATH[FLAG_PATH.length - 1]] = nextValue;
76
+ try {
77
+ writeAtomic(join(yeaftDir, CONFIG_FILE), JSON.stringify(cfg, null, 2));
78
+ } catch (err) {
79
+ return { error: `Failed to write config.json: ${err?.message || err}` };
80
+ }
81
+ return { enabled: nextValue };
49
82
  }
@@ -107,6 +107,7 @@ export function buildMcpFlattenedTools(mcpManager) {
107
107
 
108
108
  return defineTool({
109
109
  name: flattenedName,
110
+ mcpServer: t.server,
110
111
  description: truncateDescription(
111
112
  t.description || `MCP tool ${fullName.split('__').slice(1).join('__')} from server ${t.server}`
112
113
  ),
@@ -290,6 +290,39 @@ export function isToolHiddenByCollabPolicy(toolName, policy) {
290
290
  return normalized === COLLAB_TOOL_POLICY.SINGLE_VP && FORWARD_TOOL_NAMES.includes(toolName);
291
291
  }
292
292
 
293
+ /**
294
+ * Normalise an optional Agent-level plugin selection. Missing category fields
295
+ * preserve historical behavior; explicit empty arrays disable that category.
296
+ *
297
+ * @param {object|null|undefined} plugins
298
+ * @returns {{ tools: Set<string>|null, mcpServers: Set<string>|null }}
299
+ */
300
+ export function normalizePluginToolPolicy(plugins) {
301
+ const normalize = (value) => {
302
+ if (!Array.isArray(value)) return null;
303
+ return new Set(value
304
+ .filter(item => typeof item === 'string' && item.trim())
305
+ .map(item => item.trim()));
306
+ };
307
+ return {
308
+ tools: normalize(plugins?.tools),
309
+ mcpServers: normalize(plugins?.mcpServers),
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Check a canonical ToolDef against the Agent-level plugin selection. MCP
315
+ * tools are controlled by server name; built-ins use their canonical name.
316
+ */
317
+ export function isToolHiddenByPluginPolicy(tool, plugins) {
318
+ if (!tool) return true;
319
+ const policy = normalizePluginToolPolicy(plugins);
320
+ if (tool.mcpServer) {
321
+ return policy.mcpServers !== null && !policy.mcpServers.has(tool.mcpServer);
322
+ }
323
+ return policy.tools !== null && !policy.tools.has(tool.name);
324
+ }
325
+
293
326
  export class ToolRegistry {
294
327
  /** @type {Map<string, import('./types.js').ToolDef>} */
295
328
  #tools = new Map();
@@ -383,7 +416,7 @@ export class ToolRegistry {
383
416
  * Sessions).
384
417
  *
385
418
  * @param {string} [language='en']
386
- * @param {{ collabToolPolicy?: string, activeToolNames?: Set<string>|string[] }} [opts]
419
+ * @param {{ collabToolPolicy?: string, plugins?: object, activeToolNames?: Set<string>|string[] }} [opts]
387
420
  * @returns {{ name: string, description: string, parameters: object }[]}
388
421
  */
389
422
  getToolDefs(language = 'en', opts = {}) {
@@ -395,6 +428,7 @@ export class ToolRegistry {
395
428
  return this.getAllTools()
396
429
  .filter(t => !activeToolNames || activeToolNames.has(t.name))
397
430
  .filter(t => !isToolHiddenByCollabPolicy(t.name, collabToolPolicy))
431
+ .filter(t => !isToolHiddenByPluginPolicy(t, opts?.plugins))
398
432
  .map(t => {
399
433
  return {
400
434
  name: t.name,
@@ -413,7 +447,7 @@ export class ToolRegistry {
413
447
  * may execute only when canonical `SpawnAgent` is active for this request.
414
448
  *
415
449
  * @param {string} name
416
- * @param {{ collabToolPolicy?: string, activeToolNames?: Set<string>|string[] }} [opts]
450
+ * @param {{ collabToolPolicy?: string, plugins?: object, activeToolNames?: Set<string>|string[] }} [opts]
417
451
  * @returns {boolean}
418
452
  */
419
453
  isAllowed(name, opts = {}) {
@@ -423,16 +457,26 @@ export class ToolRegistry {
423
457
  ? opts.activeToolNames
424
458
  : (Array.isArray(opts?.activeToolNames) ? new Set(opts.activeToolNames) : null);
425
459
  if (activeToolNames && !activeToolNames.has(tool.name)) return false;
426
- return !isToolHiddenByCollabPolicy(tool.name, opts?.collabToolPolicy);
460
+ return !isToolHiddenByCollabPolicy(tool.name, opts?.collabToolPolicy)
461
+ && !isToolHiddenByPluginPolicy(tool, opts?.plugins);
427
462
  }
428
463
 
429
464
  /**
430
- * Get all registered tool names (canonical only aliases are excluded
431
- * so debug surfaces like the tool-stats panel show one row per tool).
465
+ * Get registered canonical tool names under an optional policy. Aliases are
466
+ * excluded so debug surfaces still show one row per real tool.
467
+ * @param {{ collabToolPolicy?: string, plugins?: object, activeToolNames?: Set<string>|string[] }} [opts]
432
468
  * @returns {string[]}
433
469
  */
434
- getToolNames() {
435
- return this.getAllTools().map(t => t.name);
470
+ getToolNames(opts = {}) {
471
+ const collabToolPolicy = normalizeCollabToolPolicy(opts?.collabToolPolicy);
472
+ const activeToolNames = opts?.activeToolNames instanceof Set
473
+ ? opts.activeToolNames
474
+ : (Array.isArray(opts?.activeToolNames) ? new Set(opts.activeToolNames) : null);
475
+ return this.getAllTools()
476
+ .filter(t => !activeToolNames || activeToolNames.has(t.name))
477
+ .filter(t => !isToolHiddenByCollabPolicy(t.name, collabToolPolicy))
478
+ .filter(t => !isToolHiddenByPluginPolicy(t, opts?.plugins))
479
+ .map(t => t.name);
436
480
  }
437
481
 
438
482
  /**
@@ -73,6 +73,7 @@
73
73
  * @property {boolean | ((input?: object) => boolean)} [mayMutateWorkspaceAfterReturn] — may keep changing the workspace after execute() resolves; disables same-query read reuse
74
74
  * @property {(input?: object) => boolean} [isDestructive] — destructive operation?
75
75
  * @property {'json-error-envelope' | null} [errorOutput] — explicit returned-output error contract; null means only thrown errors fail
76
+ * @property {string} [mcpServer] — owning MCP server for flattened MCP tools
76
77
  * @property {'external' | 'run'} [sideEffectScope] — whether mutations escape the current Run collector
77
78
  */
78
79
 
@@ -90,6 +91,7 @@
90
91
  * mayMutateWorkspaceAfterReturn?: boolean | ((input?: object) => boolean),
91
92
  * isDestructive?: (input?: object) => boolean,
92
93
  * errorOutput?: 'json-error-envelope' | null,
94
+ * mcpServer?: string,
93
95
  * sideEffectScope?: 'external' | 'run',
94
96
  * timeoutMs?: number,
95
97
  * }} def
@@ -107,6 +109,7 @@ export function defineTool({
107
109
  mayMutateWorkspaceAfterReturn = false,
108
110
  isDestructive = () => false,
109
111
  errorOutput = 'json-error-envelope',
112
+ mcpServer,
110
113
  sideEffectScope = 'external',
111
114
  timeoutMs,
112
115
  }) {
@@ -132,6 +135,9 @@ export function defineTool({
132
135
  if (Array.isArray(aliases) && aliases.length > 0) {
133
136
  def.aliases = aliases.slice();
134
137
  }
138
+ if (typeof mcpServer === 'string' && mcpServer.trim()) {
139
+ def.mcpServer = mcpServer.trim();
140
+ }
135
141
  // Only attach `timeoutMs` when the tool author opts in. Leaving it
136
142
  // unset means ToolRegistry.execute uses DEFAULT_TOOL_TIMEOUT_MS — set
137
143
  // to <= 0 to disable the per-tool timeout entirely.