@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.
- package/connection/message-router.js +4 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +88 -86
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +89 -0
- package/yeaft/engine.js +25 -1
- package/yeaft/sessions/index.js +1 -0
- package/yeaft/sessions/session-crud.js +64 -2
- package/yeaft/sub-agent/execution-control.js +66 -6
- package/yeaft/sub-agent/liveness.js +9 -1
- package/yeaft/sub-agent/runner.js +48 -39
- package/yeaft/sub-agent/spawned-prompt.js +3 -2
- package/yeaft/sub-agent/tool-access.js +138 -0
- package/yeaft/templates/personas/reviewer.md +5 -2
- package/yeaft/tools/activation.js +2 -0
- package/yeaft/tools/agent.js +30 -14
- package/yeaft/tools/git-read.js +266 -0
- package/yeaft/tools/index.js +4 -0
- package/yeaft/tools/update-agent.js +79 -0
- package/yeaft/web-bridge.js +23 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
package/yeaft/sessions/index.js
CHANGED
|
@@ -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
|
|
533
|
-
const
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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') {
|