@yeaft/webchat-agent 1.0.513 → 1.0.515

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.
@@ -2,6 +2,22 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { ToolRegistry, isToolErrorOutput } from '../tools/registry.js';
4
4
 
5
+ export const BUDGET_FIELDS = ['max_tokens', 'max_turns', 'max_tool_calls', 'max_llm_calls', 'wall_time_ms'];
6
+
7
+ /** Validate a partial budget. Updates use absolute lifetime ceilings, never reset usage. */
8
+ export function validateBudget(budget) {
9
+ if (!budget || typeof budget !== 'object' || Array.isArray(budget)) return 'budget must be an object';
10
+ for (const key of Object.keys(budget)) {
11
+ if (!BUDGET_FIELDS.includes(key)) return `unknown budget field: ${key}`;
12
+ const value = budget[key];
13
+ if (!Number.isFinite(value) || value <= 0
14
+ || (key !== 'max_tokens' && !Number.isSafeInteger(value))) {
15
+ return `budget.${key} must be finite and positive (counts and milliseconds must be integers)`;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
5
21
  /** Defaults are safety ceilings, not targets; explicit positive limits override each field. */
6
22
  export function resolveSubAgentBudget(budget, persona) {
7
23
  return {
@@ -25,11 +41,10 @@ function fingerprint(value) {
25
41
  * Parent Active Tool Set checks remain independent and must not be bypassed.
26
42
  */
27
43
  export class SubAgentToolRegistry extends ToolRegistry {
28
- constructor({ allows = () => true, agent = null, stopBudget = null } = {}) {
44
+ constructor({ allows = () => true, agent = null } = {}) {
29
45
  super();
30
46
  this.allows = allows;
31
47
  this.agent = agent;
32
- this.stopBudget = stopBudget;
33
48
  this.recentFingerprints = [];
34
49
  }
35
50
 
@@ -38,9 +53,53 @@ export class SubAgentToolRegistry extends ToolRegistry {
38
53
  return this;
39
54
  }
40
55
 
56
+ /** Provider-boundary guidance: leave the original tool results untouched. */
57
+ prepareProviderRequest() {
58
+ const agent = this.agent;
59
+ if (!agent) return null;
60
+ const stats = agent.execution || createExecutionStats();
61
+ const limit = agent.budget?.max_tool_calls;
62
+ const llmLimit = agent.budget?.max_llm_calls;
63
+ const llmCalls = agent.usage?.llmCalls || 0;
64
+ const reason = limit && stats.toolCalls >= limit ? `max_tool_calls (${limit}) reached`
65
+ : llmLimit && llmCalls >= llmLimit ? `max_llm_calls (${llmLimit}) reached` : null;
66
+ if (reason) {
67
+ agent.executionBudgetReason ||= reason;
68
+ agent.budgetReportStarted = true;
69
+ return {
70
+ finalize: true,
71
+ maxOutputTokens: 4096,
72
+ prompt: `[Sub-agent execution limit] ${reason}; ${stats.toolCalls} tools and ${llmCalls} LLM requests used. No more tools are available. Use the evidence already in this conversation to return your final handoff now: conclusion, supported findings, actual verification, and any unexamined scope or blockers. Do not claim a complete review if checks remain unfinished. This is the single reserved reporting response; do not plan further work.`,
73
+ };
74
+ }
75
+ const nearLimit = (limit && stats.toolCalls >= Math.ceil(limit * 0.75))
76
+ || (llmLimit && llmCalls >= Math.ceil(llmLimit * 0.75));
77
+ const elapsedMs = Date.now() - (agent.usage?.startedAt || Date.now());
78
+ const nearTime = agent.budget?.wall_time_ms && elapsedMs >= agent.budget.wall_time_ms * 0.75;
79
+ const updated = agent.controlRevision ? `[Parent control revision ${agent.controlRevision}] Current lifetime ceilings replace the initial preamble: ${JSON.stringify(agent.budget)}. Extra tool grants: ${JSON.stringify(agent.allowTools || [])}. Use DiscoverTools if an allowed tool is not yet visible.\n` : '';
80
+ return nearLimit || nearTime || updated ? {
81
+ prompt: `${updated}[Sub-agent execution budget] ${stats.toolCalls}/${limit ?? 'unset'} tools, ${llmCalls}/${llmLimit ?? 'unset'} LLM requests used; ${Math.max(0, (agent.budget?.wall_time_ms || 0) - elapsedMs)}ms remaining. Finish the assigned result using existing evidence where possible. Investigate only essential remaining unknowns, then return a conclusion.`,
82
+ } : null;
83
+ }
84
+
85
+ /** Reserve at actual Engine dispatch (including retries), not UI turn_start. */
86
+ reserveProviderRequest({ reporting = false } = {}) {
87
+ const agent = this.agent;
88
+ if (!agent) return;
89
+ if (agent.abortController?.signal.aborted) throw new Error('Sub-agent aborted');
90
+ const usage = agent.usage ||= { tokens: 0, turns: 0, startedAt: Date.now() };
91
+ if (reporting) {
92
+ if (usage.reportingLlmCalls) throw new Error('Sub-agent reporting request already used');
93
+ usage.reportingLlmCalls = 1;
94
+ } else if (agent.budget?.max_llm_calls && (usage.llmCalls || 0) >= agent.budget.max_llm_calls) {
95
+ throw new Error(`max_llm_calls (${agent.budget.max_llm_calls}) reached before dispatch`);
96
+ }
97
+ usage.llmCalls = (usage.llmCalls || 0) + 1;
98
+ }
99
+
41
100
  async execute(name, input, ctx = {}) {
42
101
  const tool = this.get(name);
43
- if (!tool) throw new Error(`Unknown or disallowed child tool: ${name}`);
102
+ if (!tool || !this.allows(tool)) throw new Error(`Unknown or disallowed child tool: ${name}`);
44
103
  const agent = this.agent;
45
104
  if (!agent) return super.execute(name, input, ctx);
46
105
  // Serial dispatch can resume after a tool_start yield; never start a write
@@ -50,9 +109,10 @@ export class SubAgentToolRegistry extends ToolRegistry {
50
109
  const stats = agent.execution || (agent.execution = createExecutionStats());
51
110
  const limit = agent.budget?.max_tool_calls;
52
111
  if (limit !== undefined && stats.toolCalls >= limit) {
53
- const reason = `max_tool_calls (${limit}) reached; return partial evidence to the parent before extending scope`;
54
- this.stopBudget?.(reason);
55
- throw new Error(reason);
112
+ // Fence dispatch without aborting already reserved parallel calls. The
113
+ // next provider boundary gets one tool-free response with their evidence.
114
+ agent.toolBudgetReason ||= `max_tool_calls (${limit}) reached`;
115
+ throw new Error(`${agent.toolBudgetReason}; no further tools may execute. Return findings from the available evidence.`);
56
116
  }
57
117
  stats.toolCalls += 1;
58
118
  agent.usage ||= { tokens: 0, turns: 0, startedAt: Date.now() };
@@ -135,7 +135,15 @@ export function diagnoseAgentLiveness(agent, opts = {}) {
135
135
  ...agent.execution,
136
136
  recentCalls: agent.execution.recentCalls.map(call => ({ ...call })),
137
137
  remainingToolCalls: Math.max(0, (agent.budget?.max_tool_calls || 0) - agent.execution.toolCalls),
138
- limits: agent.budget,
138
+ limits: { ...agent.budget },
139
+ llmCalls: agent.usage?.llmCalls || 0,
140
+ reportingLlmCalls: agent.usage?.reportingLlmCalls || 0,
141
+ remainingLlmCalls: agent.budget?.max_llm_calls === undefined ? null
142
+ : Math.max(0, agent.budget.max_llm_calls - (agent.usage?.llmCalls || 0)),
143
+ remainingWallTimeMs: agent.budget?.wall_time_ms === undefined ? null
144
+ : Math.max(0, agent.budget.wall_time_ms - (now - (agent.usage?.startedAt || now))),
145
+ allowTools: [...(agent.allowTools || [])],
146
+ controlRevision: agent.controlRevision || 0,
139
147
  progressNote: 'Execution counts and repeated results are diagnostics, not proof of semantic progress or stalling.',
140
148
  } : null,
141
149
  msSinceLastEvent: liveness.msSinceLastEvent ?? msSinceActivity,
@@ -39,6 +39,7 @@ import { Engine } from '../engine.js';
39
39
  import { snapshotEffortDecision } from '../effort.js';
40
40
  import { SubAgentToolRegistry, resolveSubAgentBudget, createExecutionStats } from './execution-control.js';
41
41
  import { getPersona } from '../personas.js';
42
+ import { RESTRICTED_TOOLS, createChildToolPolicy } from './tool-access.js';
42
43
  import { buildSpawnedPreamble } from './spawned-prompt.js';
43
44
  import { STATUS, isTerminalAgentStatus } from './status.js';
44
45
  import { createOutputLog } from './output-log.js';
@@ -59,19 +60,6 @@ async function loadTickAgent() {
59
60
  return _tickAgent;
60
61
  }
61
62
 
62
- const RESTRICTED_TOOLS = new Set([
63
- 'SpawnAgent',
64
- 'Agent', // legacy alias
65
- 'PromptAgent',
66
- 'SendMessage', // legacy alias
67
- 'WaitAgent',
68
- 'CloseAgent',
69
- 'ListAgents',
70
- 'RouteForward',
71
- 'AskUser',
72
- 'CreateWorkItem',
73
- ]);
74
-
75
63
  /** How long an idle sub-agent may wait for a follow-up before the watchdog reaps it. */
76
64
  const IDLE_ABANDON_MS = 5 * 60 * 1000; // 5 minutes
77
65
 
@@ -85,24 +73,11 @@ const LAST_RESULT_MAX_CHARS = 8 * 1024;
85
73
  * @param {ToolRegistry|null} parentRegistry
86
74
  * @returns {ToolRegistry}
87
75
  */
88
- export function buildChildToolRegistry(parentRegistry, { agent = null, stopBudget = null } = {}) {
89
- const preset = agent?.personaData || getPersona(agent?.persona);
90
- // Implementers retain work tools; read-only roles are a structural allowlist.
91
- // Resolve legacy template names (Read) to canonical FileRead before filtering.
92
- const allowed = preset && preset.id !== 'implementer'
93
- ? new Set([...preset.tools.map(name => parentRegistry?.get(name)?.name || (name === 'Read' ? 'FileRead' : name)), 'DiscoverTools'])
94
- : null;
95
- const child = new SubAgentToolRegistry({
96
- agent, stopBudget,
97
- allows: tool => !RESTRICTED_TOOLS.has(tool.name) && (!allowed || allowed.has(tool.name)),
98
- });
99
- if (!parentRegistry || typeof parentRegistry.getAllTools !== 'function') {
100
- return child;
101
- }
102
- for (const t of parentRegistry.getAllTools()) {
103
- if (RESTRICTED_TOOLS.has(t.name)) continue;
104
- child.register(t);
105
- }
76
+ export function buildChildToolRegistry(parentRegistry, { agent = null } = {}) {
77
+ const policy = createChildToolPolicy(parentRegistry, agent);
78
+ const child = new SubAgentToolRegistry({ agent, allows: policy.allows });
79
+ policy.refresh(child);
80
+ if (agent) agent.refreshToolPolicy = () => policy.refresh(child);
106
81
  return child;
107
82
  }
108
83
 
@@ -164,10 +139,7 @@ export function startSubAgent(agent, deps = {}) {
164
139
  // sub-agent (matches parent VP persona memory).
165
140
  agent.budget = resolveSubAgentBudget(agent.budget, agent.persona);
166
141
  agent.execution = agent.execution || createExecutionStats();
167
- const childRegistry = buildChildToolRegistry(deps.parentToolRegistry, {
168
- agent,
169
- stopBudget: reason => stopForBudget(agent, reason),
170
- });
142
+ const childRegistry = buildChildToolRegistry(deps.parentToolRegistry, { agent });
171
143
  subEngine = new Engine({
172
144
  adapter: deps.adapter,
173
145
  trace: deps.trace,
@@ -214,6 +186,7 @@ export function startSubAgent(agent, deps = {}) {
214
186
  expectedOutput: agent.expected_output,
215
187
  presetPrompt: (agent.personaData || getPersona(agent.persona))?.systemPrompt,
216
188
  budget: agent.budget,
189
+ allowTools: agent.allowTools || [],
217
190
  language: deps.language ?? deps.config?.language ?? 'en',
218
191
  });
219
192
 
@@ -262,6 +235,7 @@ export function startSubAgent(agent, deps = {}) {
262
235
  agent.outputFile = null;
263
236
  agent.subEngine = null;
264
237
  agent.subVpPersona = null;
238
+ agent.refreshToolPolicy = null;
265
239
  agent.__driverStarted = false;
266
240
  throw err;
267
241
  }
@@ -307,7 +281,13 @@ function armWallTimeWatchdog(agent, deps) {
307
281
  const remainingMs = Math.max(0, startedAt + wallTimeMs - Date.now());
308
282
  const timer = setTimeout(() => {
309
283
  if (isTerminalAgentStatus(agent.status)) return;
310
- const reason = `wall_time_ms (${wallTimeMs}) exceeded`;
284
+ // Node timers above 2^31-1 overflow to 1ms. Large explicit ceilings are
285
+ // chunked without changing the original deadline.
286
+ if (Date.now() < startedAt + agent.budget.wall_time_ms) {
287
+ agent.rearmWallTimeWatchdog?.();
288
+ return;
289
+ }
290
+ const reason = `wall_time_ms (${agent.budget.wall_time_ms}) exceeded`;
311
291
  agent.result = buildWallTimeBudgetResult(agent, reason);
312
292
  agent.partial_output = agent.result.partial_output || '';
313
293
  if (agent.abortController && !agent.abortController.signal.aborted) {
@@ -318,14 +298,19 @@ function armWallTimeWatchdog(agent, deps) {
318
298
  diagnostic: 'wall_time_watchdog',
319
299
  deps,
320
300
  });
321
- }, remainingMs);
301
+ }, Math.min(remainingMs, 2 ** 31 - 1));
322
302
  timer.unref?.();
323
303
  return timer;
324
304
  }
325
305
 
326
306
  async function driveSubAgent(agent, subEngine, vpPersona, deps) {
327
307
  const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
328
- const wallTimeWatchdog = armWallTimeWatchdog(agent, deps);
308
+ let wallTimeWatchdog = null;
309
+ agent.rearmWallTimeWatchdog = () => {
310
+ if (wallTimeWatchdog) clearTimeout(wallTimeWatchdog);
311
+ wallTimeWatchdog = armWallTimeWatchdog(agent, deps);
312
+ };
313
+ agent.rearmWallTimeWatchdog();
329
314
  const idleAbandonMs = typeof deps.idleAbandonMs === 'number' && deps.idleAbandonMs > 0
330
315
  ? deps.idleAbandonMs : IDLE_ABANDON_MS;
331
316
 
@@ -456,6 +441,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
456
441
  agent.lastResult = '';
457
442
  agent.result = '';
458
443
  let assistantText = '';
444
+ let budgetReportText = '';
459
445
  let endedNormally = false;
460
446
  let streamError = null;
461
447
  const turnTokenStart = agent.liveness?.tokenCount || 0;
@@ -501,6 +487,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
501
487
 
502
488
  if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
503
489
  assistantText += evt.text;
490
+ if (agent.budgetReportStarted) budgetReportText += evt.text;
504
491
  // Mid-stream visibility: keep lastResult fresh so a parent
505
492
  // calling WaitAgent during a long generation sees what the
506
493
  // child is currently saying, not stale text from the prior
@@ -527,7 +514,8 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
527
514
  }
528
515
  }
529
516
  } catch (err) {
530
- if (!agent.budgetStopReason) {
517
+ streamError = err && err.message ? err.message : String(err);
518
+ if (!agent.budgetStopReason && !agent.toolBudgetReason && !agent.executionBudgetReason) {
531
519
  transitionTerminal(agent, STATUS.FAILED, {
532
520
  error: err && err.message ? err.message : String(err),
533
521
  diagnostic: 'query_error',
@@ -545,6 +533,25 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
545
533
  return;
546
534
  }
547
535
 
536
+ if (isTerminalAgentStatus(agent.status)) return;
537
+
538
+ if (agent.executionBudgetReason || agent.toolBudgetReason) {
539
+ // A report is evidence, not proof that the assigned review completed.
540
+ // Prefer its complete text over the concatenated progress preview.
541
+ const partial = budgetReportText.trim() || assistantText.trim();
542
+ agent.partial_output = partial || 'No final report was produced before the tool limit. The investigation is incomplete; inspect the execution log before retrying.';
543
+ const reason = agent.executionBudgetReason || agent.toolBudgetReason;
544
+ agent.result = buildWallTimeBudgetResult(agent, reason);
545
+ agent.result.reporting = { attempted: !!agent.budgetReportStarted, received: !!budgetReportText.trim() };
546
+ if (streamError) agent.result.reporting.error = streamError;
547
+ agent.usage.turns += 1;
548
+ agent.result.usage = { ...agent.usage };
549
+ transitionTerminal(agent, STATUS.COMPLETED, {
550
+ error: reason, diagnostic: 'execution_budget_report', deps,
551
+ });
552
+ return;
553
+ }
554
+
548
555
  if (streamError) {
549
556
  transitionTerminal(agent, STATUS.FAILED, {
550
557
  error: streamError,
@@ -624,6 +631,8 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
624
631
  }
625
632
  } finally {
626
633
  if (wallTimeWatchdog) clearTimeout(wallTimeWatchdog);
634
+ agent.rearmWallTimeWatchdog = null;
635
+ agent.refreshToolPolicy = null;
627
636
  // Always clean up driver-owned resources. We intentionally do NOT
628
637
  // unset agent.result / agent.lastResult / agent.liveness / agent.
629
638
  // outputFile — those are observable by the parent after termination.
@@ -25,7 +25,7 @@
25
25
  * @param {'en'|'zh'} [args.language='en']
26
26
  * @returns {string} preamble block (already ## headed, ready to concat)
27
27
  */
28
- export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, expectedOutput, presetPrompt, budget, language = 'en' } = {}) {
28
+ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, mission, expectedOutput, presetPrompt, budget, allowTools = [], language = 'en' } = {}) {
29
29
  // Resolve template markers before embedding: the outer VP renderer treats
30
30
  // markers as sections of the whole soul and would drop the parent + contract.
31
31
  const locale = language === 'zh' || language === 'zh-CN' ? 'zh' : 'en';
@@ -35,8 +35,9 @@ export function buildSpawnedPreamble({ parentName, parentVpId, agentName, missio
35
35
  : presetPrompt;
36
36
  const contract = [
37
37
  rolePrompt || '',
38
+ `## Tool authority\nDefault persona tools plus explicit parent grants: ${JSON.stringify(allowTools)}. Parent grants override a default read-only role only within the mission's scope. Bash permits arbitrary shell/writes; it is not a sandbox. You cannot grant yourself tools or budget; report blockers to the parent. UpdateAgent is parent-only.`,
38
39
  expectedOutput ? `## expected_output\nReturn the requested structure; mark unverified facts and blockers honestly.\n${JSON.stringify(expectedOutput)}` : '',
39
- budget ? `## Execution budget\n${JSON.stringify(budget)}\nLimits are ceilings, not targets. Stop once the mission is answered. Return partial findings before exhausting the budget; do not automatically restart the same work.` : '',
40
+ budget ? `## Execution budget\n${JSON.stringify(budget)}\nLimits are ceilings, not targets. Complete the assigned result, then stop; do not stop with a plan or promise to continue. If a tool or prerequisite is unavailable, return the evidence and blocker instead of searching for unavailable capabilities. Near the tool limit, prioritize a supported conclusion. At the limit, one tool-free report may be requested within the remaining time/token budget; do not automatically restart the work.` : '',
40
41
  ].filter(Boolean).join('\n\n');
41
42
  const m = [(mission || '').trim(), contract].filter(Boolean).join('\n\n');
42
43
  if (language === 'zh') {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Child tool authorization policy.
3
+ *
4
+ * Persona tools are the baseline. `agent.allowTools` is an additional,
5
+ * replaceable allowlist of canonical tools from the parent registry. Bash is
6
+ * deliberately not wrapped or narrowed here: granting Bash grants the actual
7
+ * parent shell tool, including its write capabilities.
8
+ */
9
+ import { getPersona } from '../personas.js';
10
+
11
+ const MAX_TOOL_GRANTS = 32;
12
+ const TOOL_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,127}$/;
13
+
14
+ export const RESTRICTED_TOOLS = new Set([
15
+ 'SpawnAgent',
16
+ 'Agent', // legacy alias
17
+ 'UpdateAgent',
18
+ 'PromptAgent',
19
+ 'SendMessage', // legacy alias
20
+ 'WaitAgent',
21
+ 'CloseAgent',
22
+ 'ListAgents',
23
+ 'RouteForward',
24
+ 'AskUser',
25
+ 'CreateWorkItem',
26
+ ]);
27
+
28
+ function canonicalTool(parentRegistry, name) {
29
+ if (!parentRegistry || typeof parentRegistry.get !== 'function') return null;
30
+ const tool = parentRegistry.get(name);
31
+ return tool && typeof tool.name === 'string' ? tool : null;
32
+ }
33
+
34
+ function isRestricted(tool, requestedName = null) {
35
+ return RESTRICTED_TOOLS.has(tool?.name) || (requestedName && RESTRICTED_TOOLS.has(requestedName));
36
+ }
37
+
38
+ /**
39
+ * Validate and canonicalize explicit child grants.
40
+ *
41
+ * @param {unknown} names
42
+ * @param {import('../tools/registry.js').ToolRegistry|null} parentRegistry
43
+ * @returns {{ ok: true, tools: string[] }|{ ok: false, error: string }}
44
+ */
45
+ export function validateToolGrants(names, parentRegistry) {
46
+ if (!Array.isArray(names)) {
47
+ return { ok: false, error: 'allow_tools must be an array of tool names' };
48
+ }
49
+ if (names.length > MAX_TOOL_GRANTS) {
50
+ return { ok: false, error: `allow_tools may contain at most ${MAX_TOOL_GRANTS} names` };
51
+ }
52
+ if (!parentRegistry || typeof parentRegistry.get !== 'function') {
53
+ return names.length === 0
54
+ ? { ok: true, tools: [] }
55
+ : { ok: false, error: 'parent tool registry is unavailable' };
56
+ }
57
+
58
+ const tools = [];
59
+ const seen = new Set();
60
+ for (const value of names) {
61
+ if (typeof value !== 'string' || !value.trim()) {
62
+ return { ok: false, error: 'allow_tools entries must be non-empty tool names' };
63
+ }
64
+ const name = value.trim();
65
+ if (!TOOL_NAME_RE.test(name)) {
66
+ return { ok: false, error: `Invalid tool name: ${name}` };
67
+ }
68
+ const tool = canonicalTool(parentRegistry, name);
69
+ if (!tool) return { ok: false, error: `Parent tool is not available: ${name}` };
70
+ if (isRestricted(tool, name)) {
71
+ return { ok: false, error: `Tool cannot be granted to a child agent: ${tool.name}` };
72
+ }
73
+ if (!seen.has(tool.name)) {
74
+ seen.add(tool.name);
75
+ tools.push(tool.name);
76
+ }
77
+ }
78
+ return { ok: true, tools };
79
+ }
80
+
81
+ function unregisterTool(registry, tool) {
82
+ registry.unregister(tool.name);
83
+ for (const alias of tool.aliases || []) registry.unregister(alias);
84
+ }
85
+
86
+ /**
87
+ * Create a live policy for one child. `refresh()` reconciles only the child
88
+ * registry; the parent registry is used as the source of canonical ToolDef
89
+ * objects and is never mutated.
90
+ *
91
+ * @param {import('../tools/registry.js').ToolRegistry|null} parentRegistry
92
+ * @param {object|null} agent
93
+ * @returns {{ allows(tool: object): boolean, refresh(childRegistry: import('../tools/registry.js').ToolRegistry): void }}
94
+ */
95
+ export function createChildToolPolicy(parentRegistry, agent) {
96
+ const preset = agent?.personaData || getPersona(agent?.persona);
97
+ const baseline = preset && preset.id !== 'implementer'
98
+ ? new Set([
99
+ ...preset.tools.map(name => canonicalTool(parentRegistry, name)?.name || (name === 'Read' ? 'FileRead' : name)),
100
+ 'DiscoverTools',
101
+ ])
102
+ : null;
103
+
104
+ const allows = (tool) => {
105
+ if (!tool || typeof tool.name !== 'string' || isRestricted(tool)) return false;
106
+
107
+ // Require the exact ToolDef owned by the parent. This prevents aliases or a
108
+ // child-local/MCP hot registration from manufacturing an allowed name.
109
+ if (canonicalTool(parentRegistry, tool.name) !== tool) return false;
110
+ if (baseline === null || baseline.has(tool.name)) return true;
111
+
112
+ for (const name of agent?.allowTools || []) {
113
+ const granted = canonicalTool(parentRegistry, name);
114
+ if (granted === tool && !isRestricted(granted, name)) return true;
115
+ }
116
+ return false;
117
+ };
118
+
119
+ const refresh = (childRegistry) => {
120
+ if (!childRegistry
121
+ || typeof childRegistry.getAllTools !== 'function'
122
+ || typeof childRegistry.register !== 'function'
123
+ || typeof childRegistry.unregister !== 'function') return;
124
+
125
+ for (const tool of childRegistry.getAllTools()) {
126
+ if (!allows(tool)) unregisterTool(childRegistry, tool);
127
+ }
128
+ if (!parentRegistry || typeof parentRegistry.getAllTools !== 'function') return;
129
+ for (const tool of parentRegistry.getAllTools()) {
130
+ if (!allows(tool)) continue;
131
+ const current = childRegistry.get(tool.name);
132
+ if (current && current !== tool) unregisterTool(childRegistry, current);
133
+ if (childRegistry.get(tool.name) !== tool) childRegistry.register(tool);
134
+ }
135
+ };
136
+
137
+ return { allows, refresh };
138
+ }
@@ -20,7 +20,6 @@ import { getRuntimePlatformInfo } from '../runtime-platform.js';
20
20
  const LOG_PREVIEW_BYTES = 4096;
21
21
  const SUB_AGENT_LOG_PREVIEW_BYTES = 1024 * 1024;
22
22
  const DEFAULT_CANCEL_ESCALATION_MS = 2000;
23
- const PROMPT_TASK_LIMIT = 5;
24
23
 
25
24
  function logPreviewBytesFor(task) {
26
25
  return task?.kind === 'sub_agent' ? SUB_AGENT_LOG_PREVIEW_BYTES : LOG_PREVIEW_BYTES;
@@ -55,37 +54,6 @@ function publicSnapshot(task) {
55
54
  };
56
55
  }
57
56
 
58
- function taskKindLabel(kind, language) {
59
- const zh = String(language || '').toLowerCase().startsWith('zh');
60
- if (kind === 'sub_agent') return zh ? '子 Agent' : 'sub-agent';
61
- if (kind === 'shell') return zh ? '后台命令' : 'background command';
62
- return zh ? '后台任务' : 'background task';
63
- }
64
-
65
- function safeTaskName(task) {
66
- const name = typeof task?.runtime?.name === 'string' ? task.runtime.name.trim() : '';
67
- return /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,63}$/u.test(name) ? name : '';
68
- }
69
-
70
- function promptTaskLabel(task, language) {
71
- const name = task?.kind === 'sub_agent' ? safeTaskName(task) : '';
72
- if (!name) return taskKindLabel(task?.kind, language);
73
- return String(language || '').toLowerCase().startsWith('zh')
74
- ? `子 Agent ${name}`
75
- : `sub-agent ${name}`;
76
- }
77
-
78
- function taskStatusLabel(status, language) {
79
- const zh = String(language || '').toLowerCase().startsWith('zh');
80
- if (!zh) return String(status || 'running').replace(/_/g, ' ');
81
- const labels = {
82
- running: '运行中',
83
- queued: '等待中',
84
- cancelling: '正在取消',
85
- };
86
- return labels[status] || String(status || '运行中').replace(/_/g, ' ');
87
- }
88
-
89
57
  export class TaskManager {
90
58
  constructor({ yeaftDir, onEvent = null, runtimePlatform = null, cancelEscalationMs = DEFAULT_CANCEL_ESCALATION_MS } = {}) {
91
59
  if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
@@ -384,28 +352,4 @@ export class TaskManager {
384
352
  this.#emit('updated', task);
385
353
  return publicSnapshot(task);
386
354
  }
387
-
388
- renderActiveTasksForPrompt(sessionId = null, { language = 'en', limit = PROMPT_TASK_LIMIT } = {}) {
389
- const tasks = this.listActiveTasks(sessionId);
390
- if (tasks.length === 0) return '';
391
- const zh = String(language || '').toLowerCase().startsWith('zh');
392
- const maxTasks = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : PROMPT_TASK_LIMIT;
393
- const visible = tasks.slice(0, maxTasks);
394
- const lines = [zh ? '## 可能相关的任务' : '## Possibly Relevant Tasks'];
395
- lines.push(zh
396
- ? '以下任务仍在后台运行。需要进度或完整输出时使用任务工具查询;不要把它们当成记忆事实。'
397
- : 'These tasks are still running in the background. Use the task tools for progress or full output; do not treat them as memory facts.');
398
- for (const task of visible) {
399
- const title = promptTaskLabel(task, language);
400
- const detail = zh
401
- ? `${taskKindLabel(task.kind, language)},${taskStatusLabel(task.status, language)}`
402
- : `${taskKindLabel(task.kind, language)}, ${taskStatusLabel(task.status, language)}`;
403
- lines.push(`- ${title} (${detail})`);
404
- }
405
- if (tasks.length > visible.length) {
406
- const remaining = tasks.length - visible.length;
407
- lines.push(zh ? `- 另有 ${remaining} 个运行中任务,可用任务列表查看。` : `- ${remaining} more running task${remaining === 1 ? '' : 's'}; use the task list to inspect them.`);
408
- }
409
- return lines.join('\n');
410
- }
411
355
  }
@@ -2,10 +2,10 @@
2
2
 
3
3
  ## Compatibility Note
4
4
 
5
- Runtime prompt assembly now uses `core.md` plus active-tool guidance. This file remains packaged for deployments or integrations that still read the historical fragment directly.
5
+ Runtime prompt assembly now uses `core.md`; tool behavior is carried by provider tool schemas. This file remains packaged for deployments or integrations that still read the historical fragment directly.
6
6
 
7
7
  <!-- lang:zh -->
8
8
 
9
9
  ## 兼容说明
10
10
 
11
- 运行时 Prompt 现在由 `core.md` 和当前活跃工具指引组成。此文件继续随包发布,只用于仍直接读取旧 fragment 的部署或集成兼容。
11
+ 运行时 Prompt 现在使用 `core.md`;工具行为由 provider tool schema 承载。此文件继续随包发布,只用于仍直接读取旧 fragment 的部署或集成兼容。
@@ -4,6 +4,7 @@ name: Reviewer
4
4
  description: Critical read-only reviewer for code changes and designs
5
5
  modelTier: primary
6
6
  tools:
7
+ - GitRead
7
8
  - Read
8
9
  - Grep
9
10
  - Glob
@@ -18,7 +19,8 @@ You are a **Reviewer** sub-agent. Your job is to audit code or designs and surfa
18
19
 
19
20
  ## Operating Principles
20
21
 
21
- - **Read-only**: Never modify files.
22
+ - **Read-only by default**: Do not modify files unless the parent explicitly grants the necessary tools for a scoped edit/verification task. Bash is not a read-only sandbox; keep it within the assigned scope.
23
+ - **Diff first**: Start with `GitRead` status/diff to establish the actual change set, then inspect only the relevant files and lines.
22
24
  - **Evidence-based**: Every finding must cite `path:line`.
23
25
  - **Severity-tagged**: Label each finding `blocker | major | minor | nit`.
24
26
  - **Constructive**: Suggest fixes, not just complaints.
@@ -35,7 +37,8 @@ Structured list of findings. For each: severity, location, description, suggeste
35
37
 
36
38
  ## 操作原则
37
39
 
38
- - **只读**:不要修改文件。
40
+ - **默认只读**:只有父级为明确的编辑/验证任务显式授予必要工具后,才可在该范围内写入。Bash 并非只读沙箱,不得扩大任务范围。
41
+ - **先读 diff**:先用 `GitRead` 的 status/diff 确认实际改动范围,再只检查相关文件和行段。
39
42
  - **证据优先**:每个 finding 都必须引用 `path:line`。
40
43
  - **标注严重度**:每个 finding 标为 `blocker | major | minor | nit`。
41
44
  - **建设性**:不仅指出问题,也要给出修复建议。
@@ -31,6 +31,7 @@ export const BACKGROUND_TASK_TOOL_NAMES = Object.freeze([
31
31
  ]);
32
32
 
33
33
  export const SUB_AGENT_MANAGEMENT_TOOL_NAMES = Object.freeze([
34
+ 'UpdateAgent',
34
35
  'PromptAgent',
35
36
  'WaitAgent',
36
37
  'CloseAgent',
@@ -45,6 +46,7 @@ export const CONDITIONAL_BUILTIN_TOOL_NAMES = new Set([
45
46
  'ReadTaskLog',
46
47
  'CancelTask',
47
48
  'SpawnAgent',
49
+ 'UpdateAgent',
48
50
  'PromptAgent',
49
51
  'WaitAgent',
50
52
  'CloseAgent',