@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/connection/message-router.js +4 -1
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +115 -129
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +93 -0
- package/yeaft/engine.js +39 -4
- 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 +29 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
|
4978
|
-
//
|
|
4979
|
-
//
|
|
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
|
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.
|