@yeaft/webchat-agent 1.0.513 → 1.0.514

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.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=2b5f864c"></script>
20
+ <script type="module" src="app.bundle.js?v=88c18e0b"></script>
21
21
  </body>
22
22
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.513",
3
+ "version": "1.0.514",
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",
@@ -827,6 +827,20 @@ class SegmentStore {
827
827
  : out.sort(compareMessagesBySeq);
828
828
  }
829
829
 
830
+ /**
831
+ * Read physical rows without applying reflection tombstones. Session cloning
832
+ * needs the complete durable transcript, including rows hidden by folding.
833
+ */
834
+ readAllRaw({ includeCold = false } = {}) {
835
+ if (!this.hasData()) return [];
836
+ const idx = this.loadIndex();
837
+ return (idx.segments || [])
838
+ .slice()
839
+ .sort((a, b) => (a.firstSeq || 0) - (b.firstSeq || 0))
840
+ .flatMap(segment => this.#readSegment(segment.file, { includeCold }))
841
+ .sort(compareMessagesBySeq);
842
+ }
843
+
830
844
  *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null } = {}) {
831
845
  if (!this.hasData()) return;
832
846
  const idx = this.loadIndex();
@@ -1615,6 +1629,81 @@ export class ConversationStore {
1615
1629
  return this.loadRecentBySession(sessionId, Infinity);
1616
1630
  }
1617
1631
 
1632
+ /**
1633
+ * Copy the complete durable transcript to a new Session identity.
1634
+ *
1635
+ * Unlike visible history readers, this includes cold rows, internal rows,
1636
+ * reflections, and the rows hidden by reflection tombstones. New persisted
1637
+ * message ids are allocated in original order. References that point to a
1638
+ * copied persisted message are remapped; external/client/tool identities are
1639
+ * intentionally preserved.
1640
+ *
1641
+ * @returns {{ copiedCount: number, idMap: Map<string, string> }}
1642
+ */
1643
+ copySession(sourceSessionId, targetSessionId) {
1644
+ if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
1645
+ return { copiedCount: 0, idMap: new Map() };
1646
+ }
1647
+ const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
1648
+ const segmentedRows = primary.readAllRaw({ includeCold: true });
1649
+ const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
1650
+ .map(entry => {
1651
+ try { return this.readMessageFile(entry.path); } catch (err) {
1652
+ if (isPermissionError(err)) return null;
1653
+ throw err;
1654
+ }
1655
+ })
1656
+ .filter(Boolean);
1657
+ // Migration can temporarily leave the same durable row in both the legacy
1658
+ // markdown layout and the segment store. Preserve legacy-only rows, but let
1659
+ // the canonical segment copy win when both contain the same persisted id.
1660
+ const rowsById = new Map();
1661
+ for (const row of [...legacyRows, ...segmentedRows]) {
1662
+ if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
1663
+ rowsById.set(row.id, row);
1664
+ }
1665
+ const rows = [...rowsById.values()].sort(compareMessagesBySeq);
1666
+ if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
1667
+
1668
+ const firstSeq = this.#getNextSeq();
1669
+ const idMap = new Map(rows.map((row, index) => [
1670
+ row.id,
1671
+ `m${String(firstSeq + index).padStart(4, '0')}`,
1672
+ ]));
1673
+ const remapId = id => idMap.get(id) || id;
1674
+ const copies = rows.map(row => {
1675
+ const copy = { ...row, sessionId: targetSessionId };
1676
+ delete copy.id;
1677
+ if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
1678
+ if (Array.isArray(row.foldedMessageIds)) {
1679
+ copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
1680
+ }
1681
+ if (Array.isArray(row.sourceMessageIds)) {
1682
+ copy.sourceMessageIds = row.sourceMessageIds.map(remapId);
1683
+ }
1684
+ if (row.cold === true) delete copy.cold;
1685
+ return copy;
1686
+ });
1687
+ const written = this.appendBatch(copies);
1688
+ for (let index = 0; index < written.length; index += 1) {
1689
+ if (rows[index].cold === true) this.moveToCold(written[index].id);
1690
+ }
1691
+
1692
+ // append() intentionally treats permission failures as best-effort for live
1693
+ // chat. A Session copy cannot: reporting success with a partial transcript
1694
+ // would make the new Session irrecoverably incomplete. Verify the target's
1695
+ // physical rows before the higher-level CRUD operation commits the clone.
1696
+ const target = this.#segmentStoreForConversationDir(this.#sessionConversationDir(targetSessionId));
1697
+ const persistedIds = new Set(target.readAllRaw({ includeCold: true }).map(row => row.id));
1698
+ const expectedIds = [...idMap.values()];
1699
+ if (written.length !== rows.length
1700
+ || persistedIds.size !== expectedIds.length
1701
+ || expectedIds.some(id => !persistedIds.has(id))) {
1702
+ throw new Error(`Session transcript copy incomplete: expected ${rows.length}, persisted ${persistedIds.size}`);
1703
+ }
1704
+ return { copiedCount: written.length, idMap };
1705
+ }
1706
+
1618
1707
  /**
1619
1708
  * VP-scoped view of Session history, used to build a pair-safe provider
1620
1709
  * snapshot for one VP. It operates on what the VP can actually see, not
package/yeaft/engine.js CHANGED
@@ -2836,6 +2836,12 @@ export class Engine {
2836
2836
  else discoveredToolNames.delete(name);
2837
2837
  }
2838
2838
  toolDefs = this.#getToolDefs(effectiveCollabToolPolicy, activeToolNames);
2839
+ // Only a child registry supplies this policy. Budget exhaustion closes
2840
+ // investigation, not the evidence-bearing conversation: reserve one
2841
+ // tool-free response for a useful handoff, under the existing signal.
2842
+ const executionPolicy = isSubAgent
2843
+ ? this.#toolRegistry?.prepareProviderRequest?.() : null;
2844
+ if (executionPolicy?.finalize) toolDefs = [];
2839
2845
  ({ resolvedSkillContent, resolvedSkills, skillResolutionError } = resolveSkillPromptState({
2840
2846
  skillManager: this.#skillManager,
2841
2847
  prompt,
@@ -2853,6 +2859,7 @@ export class Engine {
2853
2859
  reportedSkillNames = currentSkillNames;
2854
2860
  reportedSkillError = skillResolutionError;
2855
2861
  systemPrompt = buildCurrentSystemPrompt();
2862
+ if (executionPolicy?.prompt) systemPrompt += `\n\n${executionPolicy.prompt}`;
2856
2863
 
2857
2864
  try {
2858
2865
  // Resolve effort per provider request so a saved Session effort takes
@@ -2966,6 +2973,7 @@ export class Engine {
2966
2973
  const requestMaxOutputTokens = Math.max(1, Math.min(
2967
2974
  requestConfig.maxOutputTokens || resolveMaxOutputTokens(currentModel, requestConfig),
2968
2975
  resolveMaxOutputTokens(currentModel, requestConfig),
2976
+ executionPolicy?.maxOutputTokens || Infinity,
2969
2977
  ));
2970
2978
  const toolSchemaTokens = toolDefs.length > 0
2971
2979
  ? approxTokens(JSON.stringify(toolDefs)) : 0;
@@ -3059,7 +3067,12 @@ export class Engine {
3059
3067
  retryLifecycle.pendingContinuation = null;
3060
3068
  continuationCommitted = true;
3061
3069
  };
3070
+ let childDispatchReserved = false;
3062
3071
  const commitDispatch = () => {
3072
+ if (isSubAgent && !childDispatchReserved) {
3073
+ this.#toolRegistry?.reserveProviderRequest?.({ reporting: !!executionPolicy?.finalize });
3074
+ childDispatchReserved = true;
3075
+ }
3063
3076
  if (!activeProviderRequest && typeof prepareProviderRequest === 'function') {
3064
3077
  activeProviderRequest = prepareProviderRequest({
3065
3078
  turnNumber,
@@ -3166,6 +3179,9 @@ export class Engine {
3166
3179
  }
3167
3180
  break;
3168
3181
  case 'tool_call':
3182
+ // No dispatch or orphan protocol rows during the reserved report,
3183
+ // even if an adapter ignores the absence of tool definitions.
3184
+ if (executionPolicy?.finalize) break;
3169
3185
  if (toolCalls.length === 0) {
3170
3186
  traceRequest('llm.first_tool_call', {
3171
3187
  durationMs: perfNowMs() - requestPerfStart,
@@ -3409,7 +3425,7 @@ export class Engine {
3409
3425
  // the caller. Replaying that request would publish a duplicate call and
3410
3426
  // leave ambiguous execution ownership, so only pre-tool failures are
3411
3427
  // eligible for transparent retry or model fallback.
3412
- const canReplayProviderRequest = toolCalls.length === 0;
3428
+ const canReplayProviderRequest = toolCalls.length === 0 && !executionPolicy?.finalize;
3413
3429
  if (earlyIsContextOverflow && canReplayProviderRequest
3414
3430
  && contextOverflowRecoveryAttempts < 3) {
3415
3431
  contextOverflowRecoveryAttempts += 1;
@@ -3808,6 +3824,14 @@ export class Engine {
3808
3824
  conversationMessages.push(assistantMsg);
3809
3825
  fullResponseText += responseText;
3810
3826
 
3827
+ // The reporting allowance is exactly one provider response, not another
3828
+ // investigation loop. Ignore unsolicited calls even from a noncompliant
3829
+ // adapter, and never auto-continue a truncated reporting response.
3830
+ if (executionPolicy?.finalize) {
3831
+ yield { type: 'turn_end', turnNumber, stopReason: 'budget_report', threadId, terminal: true };
3832
+ break;
3833
+ }
3834
+
3811
3835
  // ─── Handle max_tokens → auto-continue ────────────
3812
3836
  // A suppressed call leaves a synthetic reminder as the latest user
3813
3837
  // message. Some models answer it with an empty end_turn. Continue exactly
@@ -42,6 +42,7 @@ export {
42
42
  makeSessionId,
43
43
  ensureDefaultSessionIfEmpty,
44
44
  createSessionFromSpec,
45
+ copySession,
45
46
  renameSession,
46
47
  archiveSession,
47
48
  deleteSession,
@@ -63,6 +63,7 @@ import {
63
63
  } from '../conversation/history-index-state.js';
64
64
  import { retireConversationHistoryIndex } from '../conversation/history-index.js';
65
65
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
66
+ import { ConversationStore } from '../conversation/persist.js';
66
67
  import { repairSessionStore } from './recovery.js';
67
68
  import {
68
69
  addOrUpdateManifestSession,
@@ -529,8 +530,13 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
529
530
  if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
530
531
 
531
532
  const callerRoster = Array.isArray(input.roster) ? input.roster.slice() : [];
532
- const fallbackVpId = callerRoster.length > 0 ? null : preferDefaultVp(scanSortedVpIds(libDir));
533
- const roster = callerRoster.length > 0 ? callerRoster : (fallbackVpId ? [fallbackVpId] : []);
533
+ const preserveEmptyRoster = options.preserveEmptyRoster === true && Array.isArray(input.roster);
534
+ const fallbackVpId = callerRoster.length > 0 || preserveEmptyRoster
535
+ ? null
536
+ : preferDefaultVp(scanSortedVpIds(libDir));
537
+ const roster = callerRoster.length > 0 || preserveEmptyRoster
538
+ ? callerRoster
539
+ : (fallbackVpId ? [fallbackVpId] : []);
534
540
  // Validate every member up-front so we fail before touching fs.
535
541
  for (const vpId of roster) {
536
542
  if (isReservedVpId(vpId)) {
@@ -593,6 +599,62 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
593
599
  return meta;
594
600
  }
595
601
 
602
+ /**
603
+ * Create an independent Session from an existing Session's durable state.
604
+ * Project membership and server-owned asset storage intentionally remain with
605
+ * their existing owners; message payloads and their references are preserved.
606
+ */
607
+ export function copySession(yeaftDir, sourceSessionId, options = {}) {
608
+ const sourceYeaftDir = resolveSessionYeaftDir(yeaftDir, sourceSessionId);
609
+ const source = requireSession(sourceYeaftDir, sourceSessionId);
610
+ let sourceMeta;
611
+ try {
612
+ sourceMeta = source.getMeta();
613
+ } finally {
614
+ source.close();
615
+ }
616
+
617
+ const requestedName = String(options.name || '').trim();
618
+ const name = requestedName || `${sourceMeta.name} copy`;
619
+ const sourceConfig = loadSessionConfig(sourceYeaftDir, sourceSessionId);
620
+ const copied = createSessionFromSpec(sourceYeaftDir, {
621
+ name,
622
+ roster: Array.isArray(sourceMeta.roster) ? sourceMeta.roster : [],
623
+ defaultVpId: sourceMeta.defaultVpId || null,
624
+ workDir: sourceMeta.workDir || '',
625
+ }, { ...options, preserveEmptyRoster: true });
626
+
627
+ try {
628
+ // Unlike ordinary Session creation, cloning is transactional: silently
629
+ // dropping a source override would make the copy behave differently.
630
+ saveSessionConfig(sourceYeaftDir, copied.id, sourceConfig);
631
+ const target = requireSession(sourceYeaftDir, copied.id);
632
+ try {
633
+ const targetMeta = target.getMeta();
634
+ target.saveMeta({
635
+ ...targetMeta,
636
+ announcement: sourceMeta.announcement || '',
637
+ metadataUpdatedAt: new Date().toISOString(),
638
+ });
639
+ } finally {
640
+ target.close();
641
+ }
642
+
643
+ const transcript = new ConversationStore(sourceYeaftDir);
644
+ const { copiedCount } = transcript.copySession(sourceSessionId, copied.id);
645
+ return { ...requireSessionMeta(sourceYeaftDir, copied.id), copiedMessageCount: copiedCount };
646
+ } catch (error) {
647
+ // A partial clone must never appear as a successful copy.
648
+ deleteSession(sourceYeaftDir, copied.id, options);
649
+ throw error;
650
+ }
651
+ }
652
+
653
+ function requireSessionMeta(yeaftDir, sessionId) {
654
+ const handle = requireSession(yeaftDir, sessionId);
655
+ try { return handle.getMeta(); } finally { handle.close(); }
656
+ }
657
+
596
658
  /**
597
659
  * (A.2) Rename — updates meta.name; preserves everything else.
598
660
  */
@@ -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') {