@gaunt-sloth/core 2.0.0-beta.4 → 2.0.0-beta.6
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/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.js +64 -2
- package/dist/config/schema.js.map +1 -1
- package/dist/config/tokenBudget.d.ts +88 -0
- package/dist/config/tokenBudget.js +155 -0
- package/dist/config/tokenBudget.js.map +1 -0
- package/dist/config/types.d.ts +33 -4
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +24 -0
- package/dist/core/GthAbstractAgent.js +37 -1
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +216 -1
- package/dist/core/GthAgentRunner.js +424 -3
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +196 -0
- package/dist/core/GthLangChainAgent.js +392 -2
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/approvalRequest.d.ts +142 -0
- package/dist/core/approvals/approvalRequest.js +198 -3
- package/dist/core/approvals/approvalRequest.js.map +1 -1
- package/dist/core/approvals/conversationGrants.d.ts +60 -0
- package/dist/core/approvals/conversationGrants.js +77 -0
- package/dist/core/approvals/conversationGrants.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +16 -0
- package/dist/core/approvals/grants.js +20 -5
- package/dist/core/approvals/grants.js.map +1 -1
- package/dist/core/compaction.d.ts +181 -0
- package/dist/core/compaction.js +293 -0
- package/dist/core/compaction.js.map +1 -0
- package/dist/core/compactionThreshold.d.ts +158 -0
- package/dist/core/compactionThreshold.js +183 -0
- package/dist/core/compactionThreshold.js.map +1 -0
- package/dist/core/contextWindow.d.ts +146 -0
- package/dist/core/contextWindow.js +256 -0
- package/dist/core/contextWindow.js.map +1 -0
- package/dist/core/exitOutputChannel.d.ts +51 -0
- package/dist/core/exitOutputChannel.js +65 -0
- package/dist/core/exitOutputChannel.js.map +1 -0
- package/dist/core/refusal.d.ts +17 -2
- package/dist/core/refusal.js +80 -14
- package/dist/core/refusal.js.map +1 -1
- package/dist/core/runStats.d.ts +1 -1
- package/dist/core/terminationNotice.d.ts +8 -0
- package/dist/core/terminationNotice.js +10 -4
- package/dist/core/terminationNotice.js.map +1 -1
- package/dist/core/terminationReason.d.ts +28 -0
- package/dist/core/terminationReason.js +27 -0
- package/dist/core/terminationReason.js.map +1 -1
- package/dist/core/types.d.ts +35 -1
- package/dist/core/types.js.map +1 -1
- package/dist/history/checkpointRetention.d.ts +279 -0
- package/dist/history/checkpointRetention.js +567 -0
- package/dist/history/checkpointRetention.js.map +1 -0
- package/dist/history/checkpointSaver.d.ts +93 -0
- package/dist/history/checkpointSaver.js +464 -0
- package/dist/history/checkpointSaver.js.map +1 -0
- package/dist/history/historyEnabled.d.ts +27 -0
- package/dist/history/historyEnabled.js +23 -0
- package/dist/history/historyEnabled.js.map +1 -0
- package/dist/history/historyFormat.d.ts +27 -0
- package/dist/history/historyFormat.js +125 -2
- package/dist/history/historyFormat.js.map +1 -1
- package/dist/history/historyStore.d.ts +61 -0
- package/dist/history/historyStore.js +180 -7
- package/dist/history/historyStore.js.map +1 -1
- package/dist/history/recordSession.d.ts +84 -22
- package/dist/history/recordSession.js +187 -12
- package/dist/history/recordSession.js.map +1 -1
- package/dist/history/sessionCheckpointer.d.ts +48 -0
- package/dist/history/sessionCheckpointer.js +200 -0
- package/dist/history/sessionCheckpointer.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/modelCatalog.d.ts +14 -0
- package/dist/providers/modelCatalog.js +4 -0
- package/dist/providers/modelCatalog.js.map +1 -1
- package/dist/providers/modelDiscovery.d.ts +3 -1
- package/dist/providers/modelDiscovery.js +22 -8
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.js +3 -19
- package/dist/providers/ollama.js.map +1 -1
- package/dist/runtime/conversation.js +7 -1
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.js +6 -1
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +77 -0
- package/dist/utils/consoleUtils.js +81 -0
- package/dist/utils/consoleUtils.js.map +1 -1
- package/package.json +2 -2
- package/schema/gsloth-config.schema.json +33 -14
|
@@ -7,7 +7,7 @@ import { classifyCommand } from '#src/core/shell/arity.js';
|
|
|
7
7
|
import { describeAbstention } from '#src/core/shell/abstention.js';
|
|
8
8
|
import { normalizeCommand } from '#src/core/shell/normalize.js';
|
|
9
9
|
import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
|
|
10
|
-
import { attachTerminationReason, classifyThrownTermination, terminationReason, terminationReasonOf, } from '#src/core/terminationReason.js';
|
|
10
|
+
import { attachTerminationReason, classifyThrownTermination, replaceTerminationReason, terminationPosture, terminationReason, terminationReasonOf, } from '#src/core/terminationReason.js';
|
|
11
11
|
import { terminationLogLine } from '#src/core/terminationNotice.js';
|
|
12
12
|
import { applyDestructiveFloor, effectivePreflightFloorFinding, isBelowDestructiveFloor, isNegotiableCall, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
|
|
13
13
|
import { RaterHealth } from '#src/core/shell/raterHealth.js';
|
|
@@ -33,6 +33,7 @@ import { getNewRunnableConfig } from '#src/utils/llmUtils.js';
|
|
|
33
33
|
import { initDebugLogging, debugLog, debugLogError, debugLogObject, } from '#src/utils/debugUtils.js';
|
|
34
34
|
import { updateCrashContext } from '#src/utils/crashHandler.js';
|
|
35
35
|
import { setToolDisplayConfig } from '#src/core/toolDisplay.js';
|
|
36
|
+
import { compactMessages, conversationSize, createModelSummarizer, DEFAULT_KEEP_RECENT, } from '#src/core/compaction.js';
|
|
36
37
|
/**
|
|
37
38
|
* GS2-48 — how many trailing messages of the in-flight turn to hand the crash handler as the
|
|
38
39
|
* transcript tail. A crash file is triage, not the full session, so only the last few messages are
|
|
@@ -228,6 +229,12 @@ export class GthAgentRunner {
|
|
|
228
229
|
* after {@link cleanup} has already dropped the agent.
|
|
229
230
|
*/
|
|
230
231
|
agentFinishReasons = [];
|
|
232
|
+
/**
|
|
233
|
+
* GS2-23 — how many turns are being driven right now, through either driver. Read by
|
|
234
|
+
* {@link compactConversation}, which refuses to rewrite the thread underneath a running turn:
|
|
235
|
+
* the graph would be writing checkpoints for the turn while the compaction wrote a competing one.
|
|
236
|
+
*/
|
|
237
|
+
turnsInFlight = 0;
|
|
231
238
|
/**
|
|
232
239
|
* CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
|
|
233
240
|
* {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
|
|
@@ -284,6 +291,15 @@ export class GthAgentRunner {
|
|
|
284
291
|
* both are handed to the same matcher, so a runtime refusal and a declared one are one list.
|
|
285
292
|
*/
|
|
286
293
|
denyGrants = new ApprovalGrantStore();
|
|
294
|
+
/**
|
|
295
|
+
* GS2-20 — told whenever the session-scoped contents of {@link sessionGrants} or
|
|
296
|
+
* {@link denyGrants} change, so the session can write them against its conversation and a resume
|
|
297
|
+
* can restore them. Set via {@link setSessionGrantsListener}; `null` means nobody records them,
|
|
298
|
+
* which is where every surface without a conversation row (ACP, AG-UI, a single-shot run) stays.
|
|
299
|
+
* Deliberately NOT fired by {@link resumeConversation}: what it installs was read from the store
|
|
300
|
+
* a moment ago, and writing it straight back would be a no-op with a disk write in it.
|
|
301
|
+
*/
|
|
302
|
+
sessionGrantsListener = null;
|
|
287
303
|
/**
|
|
288
304
|
* The persisted (`always`) grant store, loaded lazily on first use from
|
|
289
305
|
* `.gsloth/.gsloth-settings/shell-allowlist.json`. Null until a gated call actually needs it, and
|
|
@@ -560,6 +576,9 @@ export class GthAgentRunner {
|
|
|
560
576
|
const removedFromFile = target.origin === 'persisted'
|
|
561
577
|
? (this.getPersistedDenials()?.remove(target.entry) ?? false)
|
|
562
578
|
: false;
|
|
579
|
+
// GS2-20 — a lifted session refusal must leave the conversation's record too, or a resume
|
|
580
|
+
// would put back the very refusal the person just removed.
|
|
581
|
+
this.notifySessionGrantsChanged();
|
|
563
582
|
const key = renderApprovalEntryObject(target.entry);
|
|
564
583
|
return {
|
|
565
584
|
outcome: 'lifted',
|
|
@@ -612,6 +631,106 @@ export class GthAgentRunner {
|
|
|
612
631
|
}
|
|
613
632
|
return grants;
|
|
614
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* GS2-20 — **the grants that belong to the conversation**: every `session`-scoped entry in the
|
|
636
|
+
* two runtime stores, deep-copied on the way out for the reason {@link getGrants} gives.
|
|
637
|
+
*
|
|
638
|
+
* `always` entries are deliberately left out. They mirror the project's allow-list / deny-list
|
|
639
|
+
* files, which every run reads for itself; recording them against the conversation as well would
|
|
640
|
+
* make one decision two records that can disagree, and a resume that restored a lifted one would
|
|
641
|
+
* resurrect a refusal the person had already removed. What is here is exactly what a fresh
|
|
642
|
+
* process would otherwise have lost.
|
|
643
|
+
*/
|
|
644
|
+
getSessionScopedGrants() {
|
|
645
|
+
const copy = (grant) => ({
|
|
646
|
+
...grant,
|
|
647
|
+
entry: copyApprovalEntry(grant.entry),
|
|
648
|
+
...(grant.annotations ? { annotations: { ...grant.annotations } } : {}),
|
|
649
|
+
});
|
|
650
|
+
return {
|
|
651
|
+
allow: this.sessionGrants
|
|
652
|
+
.list()
|
|
653
|
+
.filter((grant) => grant.scope === 'session')
|
|
654
|
+
.map(copy),
|
|
655
|
+
deny: this.denyGrants
|
|
656
|
+
.list()
|
|
657
|
+
.filter((grant) => grant.scope === 'session')
|
|
658
|
+
.map(copy),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* GS2-20 — hear about every change to the conversation's grants ({@link getSessionScopedGrants}),
|
|
663
|
+
* after it has landed. The session records them against its conversation row, which is how a
|
|
664
|
+
* grant outlives the process and reaches a resume. Pass `null` to stop listening.
|
|
665
|
+
*/
|
|
666
|
+
setSessionGrantsListener(listener) {
|
|
667
|
+
this.sessionGrantsListener = listener;
|
|
668
|
+
}
|
|
669
|
+
/** Fire the listener; a listener that throws must not turn a recorded grant into a failed turn. */
|
|
670
|
+
notifySessionGrantsChanged() {
|
|
671
|
+
try {
|
|
672
|
+
this.sessionGrantsListener?.();
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
/* fail-soft: recording a grant is a side benefit, never the critical path of the gate */
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* GS2-20 — **re-enter a stored conversation: its thread, and its grants.** The ONE seam both
|
|
680
|
+
* `--resume <id>` (at boot, after {@link init}) and `/resume <id>` (mid-session) go through, so
|
|
681
|
+
* the two spellings cannot come to mean different things.
|
|
682
|
+
*
|
|
683
|
+
* The thread half is {@link resetThread}'s rotation pointed at a stored id instead of a fresh
|
|
684
|
+
* one: the durable checkpointer is one database serving any thread, so re-entering a conversation
|
|
685
|
+
* needs no new saver, only `configurable.thread_id` naming the thread whose checkpoint holds its
|
|
686
|
+
* state. The negotiation window and the rater's noted clarifications go with the old thread for
|
|
687
|
+
* the reason {@link resetThread} gives — they are context of the conversation being LEFT.
|
|
688
|
+
*
|
|
689
|
+
* The grants half replaces the session-scoped grants with the conversation's own. Replaces, not
|
|
690
|
+
* adds: a grant's lifetime is the conversation, so what the session granted while it was in
|
|
691
|
+
* another conversation does not follow it here — that stays recorded against the conversation it
|
|
692
|
+
* was made in. The `always` mirrors stay, because the project files they mirror are still in
|
|
693
|
+
* force. Every restored grant is stamped `session` whatever the document said, since that is the
|
|
694
|
+
* only scope this seam is handed and the only one the stores should hold for it.
|
|
695
|
+
*
|
|
696
|
+
* The listener is NOT fired: what was installed is what the store already holds.
|
|
697
|
+
*
|
|
698
|
+
* **Refuses while a turn is running, and refuses a graph suspended on a pending tool approval**
|
|
699
|
+
* — the same two refusals as {@link compactConversation}, for the same reason: the runnable
|
|
700
|
+
* config is what the in-flight work resumes through. Measured without the guard: a mid-turn
|
|
701
|
+
* call returned normally, the turn's approval `Command({ resume })` then went to the OTHER
|
|
702
|
+
* thread, the gated tool never ran, the turn died as "Model returned an empty response after
|
|
703
|
+
* tool execution" — a failure blamed on the model — and the thread being resumed INTO received
|
|
704
|
+
* a checkpoint from the turn being left. Neither shipped surface can reach this (`/resume` is
|
|
705
|
+
* idle-only on both), so this is the seam refusing on its own behalf, for its next caller.
|
|
706
|
+
*/
|
|
707
|
+
async resumeConversation(target) {
|
|
708
|
+
if (!this.agent || !this.config || !this.runConfig) {
|
|
709
|
+
throw new Error('AgentRunner not initialized. Call init() first.');
|
|
710
|
+
}
|
|
711
|
+
if (this.turnsInFlight > 0) {
|
|
712
|
+
throw new Error('A turn is still running; wait for it to finish before resuming another conversation.');
|
|
713
|
+
}
|
|
714
|
+
const pendingApprovals = (await this.agent.getPendingToolInterrupts?.(this.runConfig)) ?? [];
|
|
715
|
+
if (pendingApprovals.length > 0) {
|
|
716
|
+
throw new Error('A tool approval is still pending; answer it before resuming another conversation.');
|
|
717
|
+
}
|
|
718
|
+
this.rotateThread(target.threadId);
|
|
719
|
+
for (const grant of this.sessionGrants.list()) {
|
|
720
|
+
if (grant.scope === 'session')
|
|
721
|
+
this.sessionGrants.remove(grant.entry);
|
|
722
|
+
}
|
|
723
|
+
for (const grant of this.denyGrants.list()) {
|
|
724
|
+
if (grant.scope === 'session')
|
|
725
|
+
this.denyGrants.remove(grant.entry);
|
|
726
|
+
}
|
|
727
|
+
for (const grant of target.grants.allow) {
|
|
728
|
+
this.sessionGrants.add({ ...grant, scope: 'session' });
|
|
729
|
+
}
|
|
730
|
+
for (const grant of target.grants.deny) {
|
|
731
|
+
this.denyGrants.add({ ...grant, scope: 'session' });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
615
734
|
/**
|
|
616
735
|
* §4.7.1 — **which of each server's annotation hints this session believes**, for display.
|
|
617
736
|
*
|
|
@@ -792,6 +911,16 @@ export class GthAgentRunner {
|
|
|
792
911
|
initDebugLogging(configIn.debugLog ?? false);
|
|
793
912
|
debugLog(`Initializing GthAgentRunner with command: ${command || 'default'}`);
|
|
794
913
|
this.runConfig = getNewRunnableConfig();
|
|
914
|
+
// GS2-20 — drive a caller-supplied thread when there is one, so the durable checkpointer writes
|
|
915
|
+
// under the id the session has already recorded against its conversation (and, on a resume,
|
|
916
|
+
// reads back the state stored there). Overlaid on the minted config rather than replacing it, so
|
|
917
|
+
// the recursion limit and anything else `getNewRunnableConfig` sets survive.
|
|
918
|
+
if (options?.threadId) {
|
|
919
|
+
this.runConfig = {
|
|
920
|
+
...this.runConfig,
|
|
921
|
+
configurable: { ...this.runConfig.configurable, thread_id: options.threadId },
|
|
922
|
+
};
|
|
923
|
+
}
|
|
795
924
|
debugLogObject('Runnable Config', this.runConfig);
|
|
796
925
|
this.warnIfSubagentsCannotBeHonored(configIn, command ?? options?.owningCommand);
|
|
797
926
|
this.agent = this.agentFactory(this.statusUpdate, this.resolvers);
|
|
@@ -850,6 +979,25 @@ export class GthAgentRunner {
|
|
|
850
979
|
this.negotiation.noteUserMessages(humanMessageTexts(messages));
|
|
851
980
|
debugLog('Processing messages...');
|
|
852
981
|
debugLogObject('Input Messages', messages);
|
|
982
|
+
return this.runTurn(messages, 0);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* The turn itself, separated from the per-turn bookkeeping above so [[EXT-160]] can run it twice.
|
|
986
|
+
*
|
|
987
|
+
* `attempt` is 0 for the turn the user asked for and 1 for the single retry that follows a
|
|
988
|
+
* compaction; nothing else calls this. The retry passes an EMPTY message list, because the user's
|
|
989
|
+
* message is already in the graph's state — the input step commits before the model step throws
|
|
990
|
+
* (measured), so re-sending it would append a second copy of the same turn.
|
|
991
|
+
*
|
|
992
|
+
* The preamble is deliberately NOT repeated on the retry: resetting the analytics tally, ending
|
|
993
|
+
* the negotiation and re-recording the crash transcript are things a NEW user turn does, and a
|
|
994
|
+
* retry is the same turn being attempted again.
|
|
995
|
+
*/
|
|
996
|
+
async runTurn(messages, attempt) {
|
|
997
|
+
if (!this.agent || !this.config || !this.runConfig) {
|
|
998
|
+
throw new Error('AgentRunner not initialized. Call init() first.');
|
|
999
|
+
}
|
|
1000
|
+
this.turnsInFlight++;
|
|
853
1001
|
try {
|
|
854
1002
|
// Decision: Use streaming or non-streaming based on config
|
|
855
1003
|
if (this.config.streamOutput) {
|
|
@@ -951,6 +1099,18 @@ export class GthAgentRunner {
|
|
|
951
1099
|
this.noteApprovalStop('runner.turn-approval-stop', error);
|
|
952
1100
|
throw error;
|
|
953
1101
|
}
|
|
1102
|
+
// [[EXT-160]] — **the reactive seam: catch, classify, compact, retry once.**
|
|
1103
|
+
//
|
|
1104
|
+
// Here rather than in the streaming branch's inner `catch` because BOTH paths reach this one
|
|
1105
|
+
// and only one of them has an inner catch — and because the streaming path does not always
|
|
1106
|
+
// use it: an overflow raised while the stream is being CREATED (`await this.agent.stream(…)`,
|
|
1107
|
+
// above the inner `try`) lands here too, which is exactly what the local measurement showed.
|
|
1108
|
+
// One seam, both paths, once each.
|
|
1109
|
+
const retryAfterCompaction = await this.handleContextOverflow(error, attempt);
|
|
1110
|
+
if (retryAfterCompaction) {
|
|
1111
|
+
const answer = await this.runTurn([], attempt + 1);
|
|
1112
|
+
return answer;
|
|
1113
|
+
}
|
|
954
1114
|
// Handle agent invocation errors
|
|
955
1115
|
debugLogError('Agent processing', error);
|
|
956
1116
|
// [[EXT-159]] — the OUTER of two nested wrappers. On the streaming path the inner one has
|
|
@@ -969,6 +1129,7 @@ export class GthAgentRunner {
|
|
|
969
1129
|
// the one place the panel outlives its turn. Display-only, and a no-op on today's readline
|
|
970
1130
|
// surface, which appends to scrollback and implements no `end`.
|
|
971
1131
|
this.clearNegotiationDisplay();
|
|
1132
|
+
this.turnsInFlight--;
|
|
972
1133
|
}
|
|
973
1134
|
}
|
|
974
1135
|
/**
|
|
@@ -2566,6 +2727,8 @@ export class GthAgentRunner {
|
|
|
2566
2727
|
// refusal in force for this run even when the file cannot be written, and the display
|
|
2567
2728
|
// de-duplicates by entry identity.
|
|
2568
2729
|
this.denyGrants.add({ entry, grantedAt, scope: landed });
|
|
2730
|
+
// GS2-20 — after the record, never before it: the listener reads the stores.
|
|
2731
|
+
this.notifySessionGrantsChanged();
|
|
2569
2732
|
return landed;
|
|
2570
2733
|
}
|
|
2571
2734
|
/**
|
|
@@ -2602,6 +2765,8 @@ export class GthAgentRunner {
|
|
|
2602
2765
|
const saved = persisted?.add({ ...grant, grantedAt, scope: 'always' }) ?? false;
|
|
2603
2766
|
const grantScope = saved ? 'always' : 'session';
|
|
2604
2767
|
this.sessionGrants.add({ ...grant, grantedAt, scope: grantScope });
|
|
2768
|
+
// GS2-20 — after the record, never before it: the listener reads the stores.
|
|
2769
|
+
this.notifySessionGrantsChanged();
|
|
2605
2770
|
return grantScope;
|
|
2606
2771
|
}
|
|
2607
2772
|
/**
|
|
@@ -2660,6 +2825,9 @@ export class GthAgentRunner {
|
|
|
2660
2825
|
// place would silently swallow the human's re-approval of the same tool.
|
|
2661
2826
|
this.sessionGrants.remove(entry);
|
|
2662
2827
|
persisted?.remove(entry);
|
|
2828
|
+
// GS2-20 — and out of the conversation's record, so a resume cannot bring back a grant the
|
|
2829
|
+
// tool has since weakened out from under.
|
|
2830
|
+
this.notifySessionGrantsChanged();
|
|
2663
2831
|
this.statusUpdate(StatusLevel.WARNING, describeWeakenedGrant(entry, weakened, held.annotations, effective));
|
|
2664
2832
|
}
|
|
2665
2833
|
}
|
|
@@ -2706,6 +2874,7 @@ export class GthAgentRunner {
|
|
|
2706
2874
|
this.negotiation.noteUserMessages(humanMessageTexts(messages));
|
|
2707
2875
|
debugLog('Processing messages (event stream)...');
|
|
2708
2876
|
debugLogObject('Input Messages', messages);
|
|
2877
|
+
this.turnsInFlight++;
|
|
2709
2878
|
try {
|
|
2710
2879
|
// [[TUI-C100]] — **a tool call that never produced a result is closed here, and nowhere
|
|
2711
2880
|
// earlier.** `processEventStream` ends a call when its own result arrives and otherwise
|
|
@@ -2823,6 +2992,7 @@ export class GthAgentRunner {
|
|
|
2823
2992
|
// speak for it, and without this the turn would end with no reason at all — the state that
|
|
2824
2993
|
// must mean "a site we missed".
|
|
2825
2994
|
this.noteTermination(terminationReason('runner.events-abandoned', 'control', 'abandoned'));
|
|
2995
|
+
this.turnsInFlight--;
|
|
2826
2996
|
}
|
|
2827
2997
|
}
|
|
2828
2998
|
/**
|
|
@@ -3033,7 +3203,7 @@ export class GthAgentRunner {
|
|
|
3033
3203
|
}
|
|
3034
3204
|
/**
|
|
3035
3205
|
* GS2-16 — the analytics harvested from the just-finished turn (token usage + invoked tools),
|
|
3036
|
-
* to thread into the
|
|
3206
|
+
* to thread into the local history recorder. Reads live from the agent when one is present,
|
|
3037
3207
|
* otherwise the snapshot captured at {@link cleanup} (the single-shot path reads post-cleanup).
|
|
3038
3208
|
* Never throws.
|
|
3039
3209
|
*/
|
|
@@ -3053,6 +3223,13 @@ export class GthAgentRunner {
|
|
|
3053
3223
|
* of any checkpointer-specific delete API, mirroring how `init()` mints the initial config.
|
|
3054
3224
|
*/
|
|
3055
3225
|
resetThread() {
|
|
3226
|
+
this.rotateThread();
|
|
3227
|
+
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Move the runner onto another thread: a fresh one (`/clear`) or a stored one (a resume — see
|
|
3230
|
+
* {@link resumeConversation}). Everything that is context of the thread being left goes with it.
|
|
3231
|
+
*/
|
|
3232
|
+
rotateThread(threadId) {
|
|
3056
3233
|
// [[EXT-29]] §5.1 — the negotiation goes with the thread, user messages included. The rater's
|
|
3057
3234
|
// last-5 window is conversation context; leaving it behind a `/clear` would quote the user's
|
|
3058
3235
|
// previous conversation into a rating made after they asked for it to be forgotten.
|
|
@@ -3062,9 +3239,253 @@ export class GthAgentRunner {
|
|
|
3062
3239
|
// sentence above is the whole argument for dropping them: they would decide how rows are drawn
|
|
3063
3240
|
// in a conversation the user has just asked to start fresh.
|
|
3064
3241
|
this.clearRaterClarifications();
|
|
3065
|
-
|
|
3242
|
+
const minted = getNewRunnableConfig();
|
|
3243
|
+
// GS2-20 — overlaid on the minted config exactly as `init` overlays a caller's thread, so the
|
|
3244
|
+
// recursion limit and anything else `getNewRunnableConfig` sets survive the switch.
|
|
3245
|
+
this.runConfig = threadId
|
|
3246
|
+
? { ...minted, configurable: { ...minted.configurable, thread_id: threadId } }
|
|
3247
|
+
: minted;
|
|
3066
3248
|
debugLogObject('Reset Runnable Config', this.runConfig);
|
|
3067
3249
|
}
|
|
3250
|
+
/**
|
|
3251
|
+
* GS2-23 — **fold the older conversation into a summary, in the live graph.** The idle,
|
|
3252
|
+
* user-invoked seam: what `/compact` calls between turns.
|
|
3253
|
+
*
|
|
3254
|
+
* It is not the seam for the involuntary paths. EXT-160's compact-and-retry runs inside the
|
|
3255
|
+
* driver's `try`, where a turn is in flight and this method refuses; that path composes
|
|
3256
|
+
* `compactMessages` with `replaceGraphMessages` (or the agent's `replaceConversationMessages`)
|
|
3257
|
+
* from inside the turn, or adds a guard-free internal when it needs one, rather than calling this.
|
|
3258
|
+
*
|
|
3259
|
+
* Reads the thread's messages from the graph, runs the shared `compactMessages` with the
|
|
3260
|
+
* summariser bound to the session model, and writes the replacement back through the graph's own
|
|
3261
|
+
* state update — so the compacted history is checkpointed and a later resume loads it compacted.
|
|
3262
|
+
* `after` is read back from the graph rather than computed, so the report describes what the
|
|
3263
|
+
* graph actually holds.
|
|
3264
|
+
*
|
|
3265
|
+
* Refuses while a turn is running (the two drivers count themselves in and out). Refuses a graph
|
|
3266
|
+
* suspended on a pending tool approval — idle, but not between turns: the state write lands on
|
|
3267
|
+
* such a graph and erases the interrupt payload (measured: one pending interrupt becomes none,
|
|
3268
|
+
* `next` preserved), so the approval could never be answered. A graph a THROWN turn left behind
|
|
3269
|
+
* is not refused: its checkpoint ends on the pending human turn with no interrupt, the write
|
|
3270
|
+
* lands, and the next turn runs from the compacted state. Invariant (c) is relative there — the
|
|
3271
|
+
* mechanism never creates a trailing assistant turn and the pending human stays last; having the
|
|
3272
|
+
* next human turn present before the model is invoked again is the caller's job. Does nothing on
|
|
3273
|
+
* a conversation no longer than the kept tail: `changed: false`, nothing written.
|
|
3274
|
+
*/
|
|
3275
|
+
/**
|
|
3276
|
+
* EXT-161 — the preventive compaction threshold in force, with its provenance, or `undefined`
|
|
3277
|
+
* when this session has no resolved model to derive one from.
|
|
3278
|
+
*
|
|
3279
|
+
* Read by `/status` and by the bare `/autocompact`. Asynchronous because resolving a context
|
|
3280
|
+
* window can reach the models.dev cache (and, on a cold cache, the network) — memoised behind
|
|
3281
|
+
* the agent's one window resolution, so calling it per command costs nothing after the first.
|
|
3282
|
+
*
|
|
3283
|
+
* **Never throws.** A status line that could take a session down would be worse than a missing
|
|
3284
|
+
* one, and every source under it already degrades to "unknown" rather than failing.
|
|
3285
|
+
*/
|
|
3286
|
+
async getAutocompactStatus() {
|
|
3287
|
+
try {
|
|
3288
|
+
return await this.agent?.autocompact?.status();
|
|
3289
|
+
}
|
|
3290
|
+
catch {
|
|
3291
|
+
return undefined;
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
/**
|
|
3295
|
+
* EXT-161 — move the preventive compaction threshold for the rest of this session.
|
|
3296
|
+
*
|
|
3297
|
+
* Returns the status that LANDED, so the surface's notice describes what is actually in force
|
|
3298
|
+
* rather than what was asked for — the same discipline `/approvals` and `/compact` follow.
|
|
3299
|
+
* `undefined` when there is no resolved model, in which case nothing was changed.
|
|
3300
|
+
*
|
|
3301
|
+
* Takes an already-parsed budget: the grammar is the shared parser's, and a second entry point
|
|
3302
|
+
* accepting text would be a second place it could drift.
|
|
3303
|
+
*/
|
|
3304
|
+
async setAutocompactThreshold(budget) {
|
|
3305
|
+
const controller = this.agent?.autocompact;
|
|
3306
|
+
if (!controller)
|
|
3307
|
+
return undefined;
|
|
3308
|
+
controller.setSessionBudget(budget);
|
|
3309
|
+
return this.getAutocompactStatus();
|
|
3310
|
+
}
|
|
3311
|
+
async compactConversation(options = {}) {
|
|
3312
|
+
if (!this.agent || !this.config || !this.runConfig) {
|
|
3313
|
+
throw new Error('AgentRunner not initialized. Call init() first.');
|
|
3314
|
+
}
|
|
3315
|
+
if (this.turnsInFlight > 0) {
|
|
3316
|
+
throw new Error('A turn is still running; wait for it to finish before compacting.');
|
|
3317
|
+
}
|
|
3318
|
+
const agent = this.agent;
|
|
3319
|
+
if (!agent.getConversationMessages || !agent.replaceConversationMessages) {
|
|
3320
|
+
throw new Error('This agent does not expose its conversation state, so it cannot be compacted.');
|
|
3321
|
+
}
|
|
3322
|
+
const runConfig = this.runConfig;
|
|
3323
|
+
const pendingApprovals = (await agent.getPendingToolInterrupts?.(runConfig)) ?? [];
|
|
3324
|
+
if (pendingApprovals.length > 0) {
|
|
3325
|
+
throw new Error('A tool approval is still pending; answer it before compacting.');
|
|
3326
|
+
}
|
|
3327
|
+
return this.applyCompaction(options);
|
|
3328
|
+
}
|
|
3329
|
+
/**
|
|
3330
|
+
* [[EXT-160]] — the read-compact-write itself, with **no turn-state guards**: the shared internal
|
|
3331
|
+
* behind both the idle `/compact` above and the involuntary compact-and-retry inside a turn.
|
|
3332
|
+
*
|
|
3333
|
+
* It exists because {@link compactConversation}'s guards are exactly wrong for the involuntary
|
|
3334
|
+
* case. That method refuses while `turnsInFlight > 0`, and the overflow seam runs inside the
|
|
3335
|
+
* driver's `catch`, where the turn it is recovering is still counted in — so calling the public
|
|
3336
|
+
* method from there throws every time. The alternative was for the seam to compose
|
|
3337
|
+
* `compactMessages` with `replaceConversationMessages` itself, which is the same six steps written
|
|
3338
|
+
* twice: two places to keep the summariser, the keep-recent default, the read-back and the
|
|
3339
|
+
* `changed: false` shape in agreement. One implementation with the guards on the caller that needs
|
|
3340
|
+
* them is the version that cannot drift.
|
|
3341
|
+
*
|
|
3342
|
+
* **The pending-approval guard is dropped here too, and that is deliberate rather than an
|
|
3343
|
+
* oversight.** `/compact` refuses under a pending approval because the user is mid-decision about
|
|
3344
|
+
* a tool call, and folding the conversation under them would rewrite the history that decision is
|
|
3345
|
+
* being made against. The involuntary path cannot be in that position: it runs from the `catch`
|
|
3346
|
+
* of a model call that threw, and a model call that threw produced no tool call, so there is no
|
|
3347
|
+
* new interrupt to answer. An approval raised EARLIER in the same turn has already been resolved
|
|
3348
|
+
* — the driver resolves interrupts in a loop before the model is asked again — so by the time an
|
|
3349
|
+
* overflow can be caught, there is nothing pending for the guard to protect.
|
|
3350
|
+
*/
|
|
3351
|
+
async applyCompaction(options = {}) {
|
|
3352
|
+
if (!this.agent || !this.config || !this.runConfig) {
|
|
3353
|
+
throw new Error('AgentRunner not initialized. Call init() first.');
|
|
3354
|
+
}
|
|
3355
|
+
const agent = this.agent;
|
|
3356
|
+
if (!agent.getConversationMessages || !agent.replaceConversationMessages) {
|
|
3357
|
+
throw new Error('This agent does not expose its conversation state, so it cannot be compacted.');
|
|
3358
|
+
}
|
|
3359
|
+
const runConfig = this.runConfig;
|
|
3360
|
+
const keepRecent = options.keepRecent ?? DEFAULT_KEEP_RECENT;
|
|
3361
|
+
const messages = await agent.getConversationMessages(runConfig);
|
|
3362
|
+
const before = conversationSize(messages);
|
|
3363
|
+
const result = await compactMessages({
|
|
3364
|
+
messages,
|
|
3365
|
+
summarize: createModelSummarizer(this.config.llm),
|
|
3366
|
+
keepRecent,
|
|
3367
|
+
...(options.focus !== undefined ? { focus: options.focus } : {}),
|
|
3368
|
+
});
|
|
3369
|
+
if (!result.changed) {
|
|
3370
|
+
return {
|
|
3371
|
+
changed: false,
|
|
3372
|
+
removedCount: 0,
|
|
3373
|
+
keptCount: messages.length,
|
|
3374
|
+
keepRecent,
|
|
3375
|
+
summaryText: '',
|
|
3376
|
+
before,
|
|
3377
|
+
after: before,
|
|
3378
|
+
};
|
|
3379
|
+
}
|
|
3380
|
+
await agent.replaceConversationMessages(runConfig, result.messages);
|
|
3381
|
+
const after = conversationSize(await agent.getConversationMessages(runConfig));
|
|
3382
|
+
debugLog(`Compacted the conversation: ${result.removedCount} folded, ${result.keptCount} kept, ` +
|
|
3383
|
+
`${before.messages}→${after.messages} messages, ${before.characters}→${after.characters} chars`);
|
|
3384
|
+
return {
|
|
3385
|
+
changed: true,
|
|
3386
|
+
removedCount: result.removedCount,
|
|
3387
|
+
keptCount: result.keptCount,
|
|
3388
|
+
keepRecent,
|
|
3389
|
+
summaryText: result.summaryText,
|
|
3390
|
+
before,
|
|
3391
|
+
after,
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
/**
|
|
3395
|
+
* [[EXT-160]] — **decide what a thrown turn's context overflow means, and act on it once.**
|
|
3396
|
+
*
|
|
3397
|
+
* Returns `true` when the conversation was made smaller and the turn is worth attempting again;
|
|
3398
|
+
* `false` for everything else, including every failure that is not an overflow at all, in which
|
|
3399
|
+
* case the caller's existing error path runs untouched.
|
|
3400
|
+
*
|
|
3401
|
+
* **The predicate is the taxonomy's, never a private one.** The category comes from the reason an
|
|
3402
|
+
* inner site already attached, or failing that from `classifyThrownTermination`, and the decision
|
|
3403
|
+
* is `remedy === 'reduce-context'` read out of the one POSTURE table. That is what makes this the
|
|
3404
|
+
* same fact [[EXT-159]] surfaces rather than a second opinion about it — and it is why an
|
|
3405
|
+
* `output_truncated` turn is not compacted here: the answer was cut off against the output cap,
|
|
3406
|
+
* its remedy is `change-request`, and folding the history would not add a single token of room to
|
|
3407
|
+
* the part that ran out. `context_overflow` is also the one category whose posture separates the
|
|
3408
|
+
* two facts this method depends on: retrying the SAME prompt is hopeless (`retryableAsIs: false`,
|
|
3409
|
+
* which is what `ContextOverflowError.getRetryable()` says too) while retrying a SMALLER one is
|
|
3410
|
+
* the whole move (`retryableAfterRemedy: true`).
|
|
3411
|
+
*
|
|
3412
|
+
* **One retry, and the reasons a compaction can decline.** A second overflow after the history has
|
|
3413
|
+
* already been folded is not worth a second fold — the tail it just kept is what the next
|
|
3414
|
+
* compaction would have to eat — so `attempt > 0` terminates at its own site. So does a compaction
|
|
3415
|
+
* that had nothing to fold, could not get a summary, or found an agent with no conversation state:
|
|
3416
|
+
* each is "the automatic remedy was tried and had nothing to give", which is a different fact from
|
|
3417
|
+
* "the model said no" and deserves to be said in its own words.
|
|
3418
|
+
*
|
|
3419
|
+
* The original overflow error is what surfaces in every declining branch. A compaction that throws
|
|
3420
|
+
* has its own failure logged and dropped rather than re-thrown, because replacing a diagnosis the
|
|
3421
|
+
* whole node exists to preserve with a summariser's stack trace buries the one useful thing the
|
|
3422
|
+
* turn produced.
|
|
3423
|
+
*/
|
|
3424
|
+
async handleContextOverflow(error, attempt) {
|
|
3425
|
+
const category = terminationReasonOf(error)?.category ?? classifyThrownTermination(error).category;
|
|
3426
|
+
if (terminationPosture(category).remedy !== 'reduce-context')
|
|
3427
|
+
return false;
|
|
3428
|
+
if (attempt > 0) {
|
|
3429
|
+
this.overrideTerminationReason(error, terminationReason('runner.overflow-compact-exhausted', 'exception', {
|
|
3430
|
+
category: 'context_overflow',
|
|
3431
|
+
detail: 'overflowed again after compaction',
|
|
3432
|
+
}));
|
|
3433
|
+
this.statusUpdate(StatusLevel.WARNING, 'The context overflowed again after compacting, so this turn was ended. Start a new ' +
|
|
3434
|
+
'conversation, or narrow what this turn is asking for.');
|
|
3435
|
+
return false;
|
|
3436
|
+
}
|
|
3437
|
+
// An agent that exposes no conversation state cannot be compacted at ALL, which is a different
|
|
3438
|
+
// fact from a compaction that ran and had nothing to give — and only the second is something
|
|
3439
|
+
// this seam knows. So nothing is overridden here: the wrapper's own classification is the
|
|
3440
|
+
// truest thing anyone has, and claiming the remedy was tried would be false.
|
|
3441
|
+
const agent = this.agent;
|
|
3442
|
+
if (!agent?.getConversationMessages || !agent?.replaceConversationMessages) {
|
|
3443
|
+
debugLog('Context overflow: this agent exposes no conversation state, so it cannot be compacted.');
|
|
3444
|
+
return false;
|
|
3445
|
+
}
|
|
3446
|
+
let compaction;
|
|
3447
|
+
try {
|
|
3448
|
+
compaction = await this.applyCompaction();
|
|
3449
|
+
}
|
|
3450
|
+
catch (compactionError) {
|
|
3451
|
+
debugLogError('Compacting after a context overflow', compactionError);
|
|
3452
|
+
compaction = { changed: false };
|
|
3453
|
+
}
|
|
3454
|
+
if (!compaction.changed) {
|
|
3455
|
+
this.overrideTerminationReason(error, terminationReason('runner.overflow-compact', 'exception', {
|
|
3456
|
+
category: 'context_overflow',
|
|
3457
|
+
detail: 'nothing left to compact',
|
|
3458
|
+
}));
|
|
3459
|
+
this.statusUpdate(StatusLevel.WARNING, 'The context overflowed and there was nothing left to compact, so this turn was ended. ' +
|
|
3460
|
+
'Start a new conversation, or narrow what this turn is asking for.');
|
|
3461
|
+
return false;
|
|
3462
|
+
}
|
|
3463
|
+
this.statusUpdate(StatusLevel.INFO, `The context overflowed, so ${compaction.removedCount} earlier messages were folded into a ` +
|
|
3464
|
+
`summary (${compaction.before.messages}→${compaction.after.messages} messages). Retrying.`);
|
|
3465
|
+
// The retry is a fresh attempt and owes its own reason: the FULL reset, so the failed attempt's
|
|
3466
|
+
// provider finish reasons go with it rather than being read later as the retry's.
|
|
3467
|
+
this.resetTerminationReason();
|
|
3468
|
+
return true;
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* [[EXT-160]] — record a reason that OVERRIDES what an inner site already said, on both carriers.
|
|
3472
|
+
*
|
|
3473
|
+
* {@link noteTermination} is first-write-wins and {@link classifyThrownAt} inherits, which is
|
|
3474
|
+
* right for the nested wrappers they serve: the inner site saw the failure first. The overflow
|
|
3475
|
+
* seam is the one site that legitimately knows better — it has watched the same turn overflow
|
|
3476
|
+
* twice, or watched the remedy come back empty, and the wrapper that classified the throw saw
|
|
3477
|
+
* neither. Both carriers move together so the runner's field and the error can never disagree.
|
|
3478
|
+
*/
|
|
3479
|
+
overrideTerminationReason(error, reason) {
|
|
3480
|
+
try {
|
|
3481
|
+
this.terminationReason = reason;
|
|
3482
|
+
replaceTerminationReason(error, reason);
|
|
3483
|
+
debugLog(terminationLogLine(reason));
|
|
3484
|
+
}
|
|
3485
|
+
catch {
|
|
3486
|
+
/* fail-soft: classification must never affect a run */
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3068
3489
|
async cleanup() {
|
|
3069
3490
|
debugLog('Cleaning up GthAgentRunner...');
|
|
3070
3491
|
// GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
|