@yeaft/webchat-agent 1.0.512 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.512",
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",
@@ -411,6 +411,8 @@ function serializeMessage(msg) {
411
411
 
412
412
  if (msg.mode) fm.push(`mode: ${msg.mode}`);
413
413
  if (msg.model) fm.push(`model: ${msg.model}`);
414
+ if (msg.effort) fm.push(`effort: ${msg.effort}`);
415
+ if (Number.isInteger(msg.llmCallCount) && msg.llmCallCount > 0) fm.push(`llmCallCount: ${msg.llmCallCount}`);
414
416
  if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
415
417
  if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
416
418
  if (msg.eventType) fm.push(`eventType: ${msg.eventType}`);
@@ -560,6 +562,8 @@ export function parseMessage(raw) {
560
562
  case 'time': msg.time = value; break;
561
563
  case 'mode': msg.mode = value; break;
562
564
  case 'model': msg.model = value; break;
565
+ case 'effort': msg.effort = value; break;
566
+ case 'llmCallCount': msg.llmCallCount = parseInt(value, 10); break;
563
567
  case 'turnNumber': msg.turnNumber = parseInt(value, 10); break;
564
568
  case 'toolCallId': msg.toolCallId = value; break;
565
569
  case 'eventType': msg.eventType = value; break;
@@ -823,6 +827,20 @@ class SegmentStore {
823
827
  : out.sort(compareMessagesBySeq);
824
828
  }
825
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
+
826
844
  *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null } = {}) {
827
845
  if (!this.hasData()) return;
828
846
  const idx = this.loadIndex();
@@ -1611,6 +1629,81 @@ export class ConversationStore {
1611
1629
  return this.loadRecentBySession(sessionId, Infinity);
1612
1630
  }
1613
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
+
1614
1707
  /**
1615
1708
  * VP-scoped view of Session history, used to build a pair-safe provider
1616
1709
  * snapshot for one VP. It operates on what the VP can actually see, not
package/yeaft/engine.js CHANGED
@@ -2577,6 +2577,8 @@ export class Engine {
2577
2577
  let displayImageAnchorMessage = null;
2578
2578
  let lastPersistedAssistantMessage = null;
2579
2579
  let lastPersistedAssistantTextMessage = null;
2580
+ let lastSuccessfulModel = null;
2581
+ let lastSuccessfulEffort = null;
2580
2582
  // `refreshConfig()` may publish a new Session model while a stream or a
2581
2583
  // tool is running. Apply it only before the next provider request; the
2582
2584
  // current request keeps the snapshot captured below.
@@ -2834,6 +2836,12 @@ export class Engine {
2834
2836
  else discoveredToolNames.delete(name);
2835
2837
  }
2836
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 = [];
2837
2845
  ({ resolvedSkillContent, resolvedSkills, skillResolutionError } = resolveSkillPromptState({
2838
2846
  skillManager: this.#skillManager,
2839
2847
  prompt,
@@ -2851,6 +2859,7 @@ export class Engine {
2851
2859
  reportedSkillNames = currentSkillNames;
2852
2860
  reportedSkillError = skillResolutionError;
2853
2861
  systemPrompt = buildCurrentSystemPrompt();
2862
+ if (executionPolicy?.prompt) systemPrompt += `\n\n${executionPolicy.prompt}`;
2854
2863
 
2855
2864
  try {
2856
2865
  // Resolve effort per provider request so a saved Session effort takes
@@ -2964,6 +2973,7 @@ export class Engine {
2964
2973
  const requestMaxOutputTokens = Math.max(1, Math.min(
2965
2974
  requestConfig.maxOutputTokens || resolveMaxOutputTokens(currentModel, requestConfig),
2966
2975
  resolveMaxOutputTokens(currentModel, requestConfig),
2976
+ executionPolicy?.maxOutputTokens || Infinity,
2967
2977
  ));
2968
2978
  const toolSchemaTokens = toolDefs.length > 0
2969
2979
  ? approxTokens(JSON.stringify(toolDefs)) : 0;
@@ -3057,7 +3067,12 @@ export class Engine {
3057
3067
  retryLifecycle.pendingContinuation = null;
3058
3068
  continuationCommitted = true;
3059
3069
  };
3070
+ let childDispatchReserved = false;
3060
3071
  const commitDispatch = () => {
3072
+ if (isSubAgent && !childDispatchReserved) {
3073
+ this.#toolRegistry?.reserveProviderRequest?.({ reporting: !!executionPolicy?.finalize });
3074
+ childDispatchReserved = true;
3075
+ }
3061
3076
  if (!activeProviderRequest && typeof prepareProviderRequest === 'function') {
3062
3077
  activeProviderRequest = prepareProviderRequest({
3063
3078
  turnNumber,
@@ -3164,6 +3179,9 @@ export class Engine {
3164
3179
  }
3165
3180
  break;
3166
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;
3167
3185
  if (toolCalls.length === 0) {
3168
3186
  traceRequest('llm.first_tool_call', {
3169
3187
  durationMs: perfNowMs() - requestPerfStart,
@@ -3282,6 +3300,11 @@ export class Engine {
3282
3300
  contextTokens: peakContextTokens,
3283
3301
  contextWindow: peakContextWindow,
3284
3302
  };
3303
+ // This request completed normally. Preserve the actual model and the
3304
+ // adapter-resolved effort so the visible response can identify the last
3305
+ // successful provider call (including fallback-model switches).
3306
+ lastSuccessfulModel = currentModel;
3307
+ lastSuccessfulEffort = requestEffortDecision.effective || resolvedEffort || null;
3285
3308
  // Stream completed without throwing — reset the retry counter so
3286
3309
  // the next turn starts with a clean budget. In-band adapter errors
3287
3310
  // are converted to throws above so they share the real error path.
@@ -3402,7 +3425,7 @@ export class Engine {
3402
3425
  // the caller. Replaying that request would publish a duplicate call and
3403
3426
  // leave ambiguous execution ownership, so only pre-tool failures are
3404
3427
  // eligible for transparent retry or model fallback.
3405
- const canReplayProviderRequest = toolCalls.length === 0;
3428
+ const canReplayProviderRequest = toolCalls.length === 0 && !executionPolicy?.finalize;
3406
3429
  if (earlyIsContextOverflow && canReplayProviderRequest
3407
3430
  && contextOverflowRecoveryAttempts < 3) {
3408
3431
  contextOverflowRecoveryAttempts += 1;
@@ -3801,6 +3824,14 @@ export class Engine {
3801
3824
  conversationMessages.push(assistantMsg);
3802
3825
  fullResponseText += responseText;
3803
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
+
3804
3835
  // ─── Handle max_tokens → auto-continue ────────────
3805
3836
  // A suppressed call leaves a synthetic reminder as the latest user
3806
3837
  // message. Some models answer it with an empty end_turn. Continue exactly
@@ -4974,13 +5005,15 @@ export class Engine {
4974
5005
  // Loop back to call adapter again with tool results
4975
5006
  }
4976
5007
 
4977
- // Store the final provider-call count on the response row itself. Debug
4978
- // events are transient; the response card must retain the count after a
4979
- // reload without inventing a second counter.
5008
+ // Store final provider metadata on the response row itself. Debug events
5009
+ // are transient; the response card must retain the last successful model,
5010
+ // effort, and call count after a reload.
4980
5011
  const llmCountMessage = lastPersistedAssistantTextMessage || lastPersistedAssistantMessage;
4981
5012
  if (llmCountMessage && typeof this.#conversationStore?.update === 'function') {
4982
5013
  const updated = this.#conversationStore.update(llmCountMessage, {
4983
5014
  llmCallCount: turnNumber,
5015
+ ...(lastSuccessfulModel ? { model: lastSuccessfulModel } : {}),
5016
+ ...(lastSuccessfulEffort ? { effort: lastSuccessfulEffort } : {}),
4984
5017
  });
4985
5018
  if (updated?.id === lastPersistedAssistantTextMessage?.id) lastPersistedAssistantTextMessage = updated;
4986
5019
  if (updated?.id === lastPersistedAssistantMessage?.id) lastPersistedAssistantMessage = updated;
@@ -4997,6 +5030,8 @@ export class Engine {
4997
5030
  totalMs: Date.now() - queryStartedAt,
4998
5031
  totalTokens: cumulativeInputTokens + cumulativeOutputTokens,
4999
5032
  loopCount: turnNumber,
5033
+ ...(lastSuccessfulModel ? { model: lastSuccessfulModel } : {}),
5034
+ ...(lastSuccessfulEffort ? { effort: lastSuccessfulEffort } : {}),
5000
5035
  };
5001
5036
 
5002
5037
  // The visible response is complete at the yield above. Only when the
@@ -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.