@gaunt-sloth/core 2.0.0-beta.2 → 2.0.0-beta.4
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/shell-policy.d.ts +2 -2
- package/dist/config/shell-policy.js +3 -3
- package/dist/config/tool-descriptions.d.ts +5 -5
- package/dist/config/tool-descriptions.js +3 -3
- package/dist/core/GthAbstractAgent.d.ts +67 -1
- package/dist/core/GthAbstractAgent.js +248 -4
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +91 -10
- package/dist/core/GthAgentRunner.js +327 -21
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +3 -2
- package/dist/core/GthLangChainAgent.js +25 -5
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/reasoningBlocks.d.ts +4 -6
- package/dist/core/reasoningBlocks.js +4 -6
- package/dist/core/reasoningBlocks.js.map +1 -1
- package/dist/core/refusal.d.ts +54 -1
- package/dist/core/refusal.js +109 -1
- package/dist/core/refusal.js.map +1 -1
- package/dist/core/shell/ShellCommandFailedError.d.ts +4 -4
- package/dist/core/shell/ShellCommandFailedError.js +4 -4
- package/dist/core/shell/openWorld.d.ts +4 -3
- package/dist/core/shell/openWorld.js +4 -3
- package/dist/core/shell/openWorld.js.map +1 -1
- package/dist/core/terminationNotice.d.ts +111 -0
- package/dist/core/terminationNotice.js +209 -0
- package/dist/core/terminationNotice.js.map +1 -0
- package/dist/core/terminationReason.d.ts +271 -0
- package/dist/core/terminationReason.js +405 -0
- package/dist/core/terminationReason.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +49 -2
- package/dist/core/toolDisplay.js +137 -19
- package/dist/core/toolDisplay.js.map +1 -1
- package/dist/core/types.d.ts +33 -6
- package/dist/core/types.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +2 -2
- package/dist/providers/geminiSchemaSanitizer.js +2 -2
- package/dist/providers/geminiThinking.d.ts +10 -5
- package/dist/providers/geminiThinking.js +10 -5
- package/dist/providers/geminiThinking.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +22 -0
- package/dist/runtime/askStructured.js +53 -0
- package/dist/runtime/askStructured.js.map +1 -1
- package/dist/runtime/conversation.d.ts +11 -0
- package/dist/runtime/conversation.js +13 -1
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.d.ts +11 -0
- package/dist/runtime/singleShot.js +21 -1
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/aiignoreUtils.d.ts +6 -0
- package/dist/utils/aiignoreUtils.js +69 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/debugDump.d.ts +54 -0
- package/dist/utils/debugDump.js +32 -0
- package/dist/utils/debugDump.js.map +1 -1
- package/dist/utils/displayWidth.d.ts +10 -5
- package/dist/utils/displayWidth.js +148 -54
- package/dist/utils/displayWidth.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +1 -1
- package/dist/utils/llmUtils.js +1 -1
- package/dist/utils/systemPromptNotes.d.ts +3 -3
- package/dist/utils/systemPromptNotes.js +3 -3
- package/dist/utils/vertexaiUtils.js +100 -1
- package/dist/utils/vertexaiUtils.js.map +1 -1
- package/package.json +4 -3
|
@@ -7,6 +7,8 @@ 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';
|
|
11
|
+
import { terminationLogLine } from '#src/core/terminationNotice.js';
|
|
10
12
|
import { applyDestructiveFloor, effectivePreflightFloorFinding, isBelowDestructiveFloor, isNegotiableCall, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
|
|
11
13
|
import { RaterHealth } from '#src/core/shell/raterHealth.js';
|
|
12
14
|
import { alignmentApprovalNotice, isAlignmentFailClosed, runAlignmentCheck, } from '#src/core/shell/alignment.js';
|
|
@@ -207,10 +209,29 @@ export class GthAgentRunner {
|
|
|
207
209
|
* {@link getRunStats} before cleanup. Defaults to an empty tally.
|
|
208
210
|
*/
|
|
209
211
|
lastRunStats = { tools: [] };
|
|
212
|
+
/**
|
|
213
|
+
* [[EXT-159]] — why the current turn ended, as classified by the sites the RUNNER owns (the two
|
|
214
|
+
* exception wrappers, the approvals re-throws, the empty-response throws, the ordinary end of a
|
|
215
|
+
* turn). The agent owns the sites inside it and its answer outranks this one; see
|
|
216
|
+
* {@link getTerminationReason}.
|
|
217
|
+
*/
|
|
218
|
+
terminationReason = null;
|
|
219
|
+
/**
|
|
220
|
+
* [[EXT-159]] — snapshot of the agent's own termination reason, for the same reason
|
|
221
|
+
* {@link lastRunStats} exists: {@link cleanup} nulls the agent, and the single-shot path reads
|
|
222
|
+
* the reason afterwards.
|
|
223
|
+
*/
|
|
224
|
+
agentTerminationReason = null;
|
|
225
|
+
/**
|
|
226
|
+
* [[EXT-159]] — snapshot of the agent's per-message `finish_reason` observations, kept for the
|
|
227
|
+
* same reason {@link agentTerminationReason} is: `/debug-dump` and the non-interactive verbs ask
|
|
228
|
+
* after {@link cleanup} has already dropped the agent.
|
|
229
|
+
*/
|
|
230
|
+
agentFinishReasons = [];
|
|
210
231
|
/**
|
|
211
232
|
* CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
|
|
212
233
|
* {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
|
|
213
|
-
* **This field, not the interrupt wiring, is where the rung lives.** The
|
|
234
|
+
* **This field, not the interrupt wiring, is where the rung lives.** The agent wires the
|
|
214
235
|
* interrupt rung-independently, so every tool any rung could gate arrives at the top of
|
|
215
236
|
* {@link decideToolApproval} and is judged against the rung recorded here — which is what makes
|
|
216
237
|
* `/approvals manual` take effect mid-session, and what keeps a config that pre-selects
|
|
@@ -813,6 +834,8 @@ export class GthAgentRunner {
|
|
|
813
834
|
}
|
|
814
835
|
// GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
|
|
815
836
|
this.resetRunStats();
|
|
837
|
+
// [[EXT-159]] — the previous turn's termination reason goes with the previous turn's tally.
|
|
838
|
+
this.resetTerminationReason();
|
|
816
839
|
// GS2-48 — record this turn's transcript tail for the crash handler.
|
|
817
840
|
updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
|
|
818
841
|
// [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
|
|
@@ -845,11 +868,17 @@ export class GthAgentRunner {
|
|
|
845
868
|
// CFG-27 — an approvals STOP is not a stream failure: it is the gate deliberately
|
|
846
869
|
// ending the run, and its message IS the explanation the spec requires it to carry.
|
|
847
870
|
// Re-thrown unchanged (the outer catch does the same) so nothing buries it.
|
|
848
|
-
if (streamError instanceof ApprovalStopError)
|
|
871
|
+
if (streamError instanceof ApprovalStopError) {
|
|
872
|
+
this.noteApprovalStop('runner.stream-approval-stop', streamError);
|
|
849
873
|
throw streamError;
|
|
874
|
+
}
|
|
850
875
|
// Handle streaming-specific errors
|
|
851
876
|
debugLogError('Stream processing', streamError);
|
|
852
|
-
|
|
877
|
+
// [[EXT-159]] — classify the ORIGINAL error, not the wrapper built from it: the wrapper's
|
|
878
|
+
// message is where the diagnosis was being thrown away. The one reason is attached to the
|
|
879
|
+
// wrapper as well, so a catcher that only ever sees the re-thrown error still reads it.
|
|
880
|
+
const reason = this.classifyThrownAt('runner.stream-error', streamError);
|
|
881
|
+
throw attachTerminationReason(new Error(`Stream processing failed: ${streamError instanceof Error ? streamError.message : String(streamError)}`), reason);
|
|
853
882
|
}
|
|
854
883
|
debugLog(`Stream completed. Total response length: ${result.length}`);
|
|
855
884
|
// EXT-37: a content-policy refusal (OpenAI content_filter / Anthropic stop_reason=refusal /
|
|
@@ -877,10 +906,16 @@ export class GthAgentRunner {
|
|
|
877
906
|
const fallback = await this.agent.invoke(messages, this.runConfig);
|
|
878
907
|
debugLog(`Fallback non-stream response length: ${fallback.length}`);
|
|
879
908
|
if (fallback.trim().length === 0) {
|
|
880
|
-
|
|
909
|
+
// [[EXT-159]] — the retry has already been spent here, so this is the terminal empty
|
|
910
|
+
// turn rather than the first one.
|
|
911
|
+
const reason = terminationReason('runner.empty-after-fallback', 'control', 'empty_response');
|
|
912
|
+
this.noteTermination(reason);
|
|
913
|
+
throw attachTerminationReason(new Error('Model returned an empty response after tool execution. Try again or switch to a more stable model.'), reason);
|
|
881
914
|
}
|
|
915
|
+
this.noteCompleted('runner.completed');
|
|
882
916
|
return fallback;
|
|
883
917
|
}
|
|
918
|
+
this.noteCompleted('runner.completed');
|
|
884
919
|
return result;
|
|
885
920
|
}
|
|
886
921
|
else {
|
|
@@ -897,8 +932,13 @@ export class GthAgentRunner {
|
|
|
897
932
|
result += await this.resolveToolInterrupts();
|
|
898
933
|
debugLog(`Non-stream response length: ${result.length}`);
|
|
899
934
|
if (result.trim().length === 0) {
|
|
900
|
-
|
|
935
|
+
// [[EXT-159]] — the non-streaming path has no retry to spend, so an empty turn is
|
|
936
|
+
// terminal here at once.
|
|
937
|
+
const reason = terminationReason('runner.empty-invoke', 'control', 'empty_response');
|
|
938
|
+
this.noteTermination(reason);
|
|
939
|
+
throw attachTerminationReason(new Error('Model returned an empty response. Try again or switch to a more stable model.'), reason);
|
|
901
940
|
}
|
|
941
|
+
this.noteCompleted('runner.completed');
|
|
902
942
|
return result;
|
|
903
943
|
}
|
|
904
944
|
}
|
|
@@ -907,13 +947,20 @@ export class GthAgentRunner {
|
|
|
907
947
|
// its own words: the command, the rating and its reason are the whole point of it. Wrapping
|
|
908
948
|
// it as "Agent processing failed: …" would bury the explanation the spec requires it to
|
|
909
949
|
// carry, so it is re-thrown unchanged.
|
|
910
|
-
if (error instanceof ApprovalStopError)
|
|
950
|
+
if (error instanceof ApprovalStopError) {
|
|
951
|
+
this.noteApprovalStop('runner.turn-approval-stop', error);
|
|
911
952
|
throw error;
|
|
953
|
+
}
|
|
912
954
|
// Handle agent invocation errors
|
|
913
955
|
debugLogError('Agent processing', error);
|
|
956
|
+
// [[EXT-159]] — the OUTER of two nested wrappers. On the streaming path the inner one has
|
|
957
|
+
// already classified this same failure and re-thrown, so `noteTermination`'s first-write-wins
|
|
958
|
+
// keeps the inner, truer site; on the non-streaming path this is the only classification
|
|
959
|
+
// there is. Both are reachable, so both classify.
|
|
960
|
+
const reason = this.classifyThrownAt('runner.turn-error', error);
|
|
914
961
|
const originalMessage = error instanceof Error ? error.message : String(error);
|
|
915
962
|
const enhancedMessage = enhanceVertexUnauthorizedMessage(originalMessage, this.config?.llm);
|
|
916
|
-
throw new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined);
|
|
963
|
+
throw attachTerminationReason(new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined), reason);
|
|
917
964
|
}
|
|
918
965
|
finally {
|
|
919
966
|
// [[TUI-C69]] §5.4 — the turn is over, so the argument is over. The reasoning is the same as
|
|
@@ -946,10 +993,10 @@ export class GthAgentRunner {
|
|
|
946
993
|
* streamed across all resume turns (empty when nothing was resumed).
|
|
947
994
|
*
|
|
948
995
|
* No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
|
|
949
|
-
* `streamResume` absent) — that is the only exemption. As of EXT-52
|
|
950
|
-
* `run_shell_command` and
|
|
951
|
-
*
|
|
952
|
-
*
|
|
996
|
+
* `streamResume` absent) — that is the only exemption. As of EXT-52 the shipped agent gates
|
|
997
|
+
* `run_shell_command` and exposes the interrupt surface, so the lean agent is exactly the agent
|
|
998
|
+
* this loop serves; only an agent implementation without those methods (e.g. a test double)
|
|
999
|
+
* skips it.
|
|
953
1000
|
*/
|
|
954
1001
|
async resolveToolInterrupts() {
|
|
955
1002
|
const agent = this.agent;
|
|
@@ -959,11 +1006,17 @@ export class GthAgentRunner {
|
|
|
959
1006
|
if (!agent.getPendingToolInterrupts || !agent.streamResume)
|
|
960
1007
|
return '';
|
|
961
1008
|
let resumedText = '';
|
|
1009
|
+
// [[EXT-159]] — which way the loop left decides what ended the turn, and only the loop knows.
|
|
1010
|
+
// Falling out of the bound and draining cleanly are different endings; a caller sees the same
|
|
1011
|
+
// returned string either way, so a caller cannot tell them apart.
|
|
1012
|
+
let drained = false;
|
|
962
1013
|
// Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
|
|
963
1014
|
for (let guard = 0; guard < 100; guard++) {
|
|
964
1015
|
const pending = await agent.getPendingToolInterrupts(runConfig);
|
|
965
|
-
if (pending.length === 0)
|
|
1016
|
+
if (pending.length === 0) {
|
|
1017
|
+
drained = true;
|
|
966
1018
|
break;
|
|
1019
|
+
}
|
|
967
1020
|
const decisions = [];
|
|
968
1021
|
for (const tool of pending) {
|
|
969
1022
|
decisions.push(await this.decideToolApproval(tool));
|
|
@@ -971,6 +1024,14 @@ export class GthAgentRunner {
|
|
|
971
1024
|
const stream = await agent.streamResume({ decisions }, runConfig);
|
|
972
1025
|
resumedText += await this.drainTextStream(stream);
|
|
973
1026
|
}
|
|
1027
|
+
// [[EXT-159]] — the runtime gave up, and the turn ends here because of that. It still returns
|
|
1028
|
+
// into `processMessages`, which then reports an ordinary completion (or an empty turn) at its
|
|
1029
|
+
// own site — so routing to an enumerated site is no defence: that site would state a category
|
|
1030
|
+
// that is affirmatively false. Noting it HERE, before those sites run, is what makes
|
|
1031
|
+
// first-write-wins keep the one fact they cannot see.
|
|
1032
|
+
if (!drained) {
|
|
1033
|
+
this.noteTermination(terminationReason('runner.interrupt-guard-exhausted', 'control', 'interrupt_drain_guard'));
|
|
1034
|
+
}
|
|
974
1035
|
return resumedText;
|
|
975
1036
|
}
|
|
976
1037
|
/**
|
|
@@ -1085,7 +1146,7 @@ export class GthAgentRunner {
|
|
|
1085
1146
|
* (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
|
|
1086
1147
|
* cannot run.
|
|
1087
1148
|
*
|
|
1088
|
-
* **Step 0 is the rung.** The
|
|
1149
|
+
* **Step 0 is the rung.** The agent wires the interrupt over every tool ANY rung could gate,
|
|
1089
1150
|
* because the graph is built once and `/approvals <rung>` moves the rung under it for the rest of
|
|
1090
1151
|
* the session. So a call arriving here has not yet been judged against the rung in force: this is
|
|
1091
1152
|
* where that happens, on `sessionApprovals.rung`, which a mid-session switch has already updated.
|
|
@@ -1321,7 +1382,7 @@ export class GthAgentRunner {
|
|
|
1321
1382
|
const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
|
|
1322
1383
|
const isShellCommand = tool.name === SHELL_TOOL_NAME && command !== null;
|
|
1323
1384
|
const approvals = this.sessionApprovals;
|
|
1324
|
-
// (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the
|
|
1385
|
+
// (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the agent built the
|
|
1325
1386
|
// interrupt from, asked about this one call, so the wiring and the decision cannot disagree.
|
|
1326
1387
|
// Scope `once`, so nothing is written to any allow-list: this is not a grant, it is the absence
|
|
1327
1388
|
// of a gate.
|
|
@@ -2224,7 +2285,7 @@ export class GthAgentRunner {
|
|
|
2224
2285
|
if (registered.length === 0)
|
|
2225
2286
|
return [];
|
|
2226
2287
|
// The LIVE gated set, from the SAME shared policy `decideToolApproval` decides on and the
|
|
2227
|
-
//
|
|
2288
|
+
// agent derives its interrupt from, so "granted" here means exactly what it means at
|
|
2228
2289
|
// tool-registration time (§4.5) and at the gate.
|
|
2229
2290
|
//
|
|
2230
2291
|
// EXT-80 makes this non-drift property load-bearing rather than incidental. At `manual` the
|
|
@@ -2629,6 +2690,8 @@ export class GthAgentRunner {
|
|
|
2629
2690
|
}
|
|
2630
2691
|
// GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
|
|
2631
2692
|
this.resetRunStats();
|
|
2693
|
+
// [[EXT-159]] — the previous turn's termination reason goes with the previous turn's tally.
|
|
2694
|
+
this.resetTerminationReason();
|
|
2632
2695
|
// GS2-48 — record this turn's transcript tail for the crash handler.
|
|
2633
2696
|
updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
|
|
2634
2697
|
// [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
|
|
@@ -2644,8 +2707,102 @@ export class GthAgentRunner {
|
|
|
2644
2707
|
debugLog('Processing messages (event stream)...');
|
|
2645
2708
|
debugLogObject('Input Messages', messages);
|
|
2646
2709
|
try {
|
|
2647
|
-
|
|
2648
|
-
|
|
2710
|
+
// [[TUI-C100]] — **a tool call that never produced a result is closed here, and nowhere
|
|
2711
|
+
// earlier.** `processEventStream` ends a call when its own result arrives and otherwise
|
|
2712
|
+
// leaves it open, because from inside a stream a call suspended at the approval gate looks
|
|
2713
|
+
// exactly like a terminal one: the graph interrupts, the stream returns, and the calls it
|
|
2714
|
+
// announced are still outstanding either way. The two only part company at THIS level — the
|
|
2715
|
+
// prompt is opened by `resolveToolInterruptsWithEvents` below, after the first stream has
|
|
2716
|
+
// finished — so closing anything before that point would tell the surface a call had
|
|
2717
|
+
// finished while the human was still being asked whether it may run at all.
|
|
2718
|
+
//
|
|
2719
|
+
// **Membership in this set at the drain IS "produced no result".** A call is forgotten on its
|
|
2720
|
+
// own `tool_end` or its own `tool_result`, and a call that actually ran always yields the
|
|
2721
|
+
// latter from its `ToolMessage` — so nothing that survives to the loop below has a result,
|
|
2722
|
+
// whatever the reason. The reasons are several and the surface cannot tell most of them
|
|
2723
|
+
// apart: the turn was abandoned; a refusal made the middleware jump back to the model and the
|
|
2724
|
+
// whole round's tool node was skipped; the call was terminal. They do not differ in the one
|
|
2725
|
+
// thing a row reports, so they are closed alike — as a call with no result, which is what an
|
|
2726
|
+
// error result says, and never with the tick and the word `done`, which no arm of this loop
|
|
2727
|
+
// could make true.
|
|
2728
|
+
const unendedToolCalls = new Set();
|
|
2729
|
+
// [[EXT-159]] — did this turn produce an ANSWER? Grammar member (2), "the model produced
|
|
2730
|
+
// nothing", had two sites on the string path and none here, so an empty typed-event turn —
|
|
2731
|
+
// the node's own motivating symptom, on the surface users actually watch — reported
|
|
2732
|
+
// `completed`, i.e. a legitimate hand-back.
|
|
2733
|
+
//
|
|
2734
|
+
// `text` is the exact analogue of the string path's `result.trim().length === 0`: that path
|
|
2735
|
+
// enqueues only `answerTextOf(chunk.content)`, which drops reasoning segments, and this path
|
|
2736
|
+
// splits the same segments into `text` (answer) and `reasoning_delta` (not the answer). So a
|
|
2737
|
+
// reasoning-only turn is empty on BOTH surfaces, and they cannot disagree about one turn.
|
|
2738
|
+
// Testing each delta rather than the concatenation is equivalent: if every delta is blank
|
|
2739
|
+
// their join is blank, and one non-blank delta makes the join non-blank.
|
|
2740
|
+
//
|
|
2741
|
+
// This is CLASSIFICATION only. The string path's empty-stream retry / `invoke` fallback is
|
|
2742
|
+
// still deliberately not duplicated here (see this method's docblock) — naming what happened
|
|
2743
|
+
// is not fixing it.
|
|
2744
|
+
let sawAnswerText = false;
|
|
2745
|
+
const tracking = async function* (source) {
|
|
2746
|
+
for await (const event of source) {
|
|
2747
|
+
if (event.type === 'tool_start')
|
|
2748
|
+
unendedToolCalls.add(event.id);
|
|
2749
|
+
else if (event.type === 'tool_end' || event.type === 'tool_result')
|
|
2750
|
+
unendedToolCalls.delete(event.id);
|
|
2751
|
+
else if (event.type === 'text' && event.delta.trim().length > 0)
|
|
2752
|
+
sawAnswerText = true;
|
|
2753
|
+
yield event;
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
yield* tracking(this.agent.streamWithEvents(messages, this.runConfig, signal));
|
|
2757
|
+
yield* tracking(this.resolveToolInterruptsWithEvents(signal));
|
|
2758
|
+
// The abort is the one case that IS distinguishable here, and it buys the wording rather than
|
|
2759
|
+
// a different outcome: a turn stopped part-way may have had a call in flight, so this says
|
|
2760
|
+
// what is certain — no result arrived — without claiming the command never started.
|
|
2761
|
+
const noResult = signal?.aborted
|
|
2762
|
+
? 'Cancelled before this call produced a result.'
|
|
2763
|
+
: 'This call did not run.';
|
|
2764
|
+
// Deliberately at the end of the `try` rather than in the `finally`. Esc does NOT break out
|
|
2765
|
+
// of the consumer's loop — it aborts the signal, `streamWithEvents` catches the `AbortError`
|
|
2766
|
+
// and returns cleanly, and `resolveToolInterruptsWithEvents` returns at its own aborted
|
|
2767
|
+
// guard, so an abandoned turn reaches this line like any other and its rows are closed here
|
|
2768
|
+
// too. The case the placement is actually for is a consumer that stops consuming: breaking
|
|
2769
|
+
// out of a `for await`, or calling `return()` on this generator, closes it, and yielding
|
|
2770
|
+
// during that forced return suspends the generator again instead of finishing it — which
|
|
2771
|
+
// would leave `clearNegotiationDisplay()` below unreached. Rows abandoned that way keep
|
|
2772
|
+
// whatever they last said, which is the price of the `finally` still running.
|
|
2773
|
+
//
|
|
2774
|
+
// A close is not cosmetic either way: an unclosed row on the TUI sits at `running` for the
|
|
2775
|
+
// rest of the session, and a client is owed a terminal state for every call it was shown.
|
|
2776
|
+
for (const id of unendedToolCalls) {
|
|
2777
|
+
yield { type: 'tool_result', id, content: noResult, isError: true };
|
|
2778
|
+
}
|
|
2779
|
+
// [[EXT-159]] — the typed-event turn reached its own end. `signal?.aborted` is read again
|
|
2780
|
+
// rather than reused from `noResult` above because a turn can be cancelled with no tool call
|
|
2781
|
+
// outstanding, and that turn owes a reason just as much. First-write-wins keeps whatever the
|
|
2782
|
+
// agent's own sites already said (a refusal, a suspend, an earlier abort).
|
|
2783
|
+
if (signal?.aborted) {
|
|
2784
|
+
this.noteTermination(terminationReason('runner.events-cancelled', 'control', {
|
|
2785
|
+
category: 'cancelled',
|
|
2786
|
+
detail: 'signal',
|
|
2787
|
+
}));
|
|
2788
|
+
}
|
|
2789
|
+
else if (!sawAnswerText) {
|
|
2790
|
+
// A turn that ended having said nothing did NOT complete, and saying it did is worse than
|
|
2791
|
+
// saying nothing: this design defines an ABSENT reason as "a site we missed", and a
|
|
2792
|
+
// present-but-false one silences that detector at the one case it was built for.
|
|
2793
|
+
this.noteTermination(terminationReason('runner.events-empty', 'control', 'empty_response'));
|
|
2794
|
+
}
|
|
2795
|
+
else {
|
|
2796
|
+
this.noteCompleted('runner.events-completed');
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
catch (error) {
|
|
2800
|
+
// [[EXT-159]] — the typed-event path had NO catch at all, so a provider fault on the surface
|
|
2801
|
+
// most users are looking at was the one termination nothing classified. Re-thrown UNCHANGED:
|
|
2802
|
+
// this site adds a reason and takes nothing away, and the consumer's own error rendering is
|
|
2803
|
+
// not this node's business.
|
|
2804
|
+
this.classifyThrownAt('runner.events-error', error);
|
|
2805
|
+
throw error;
|
|
2649
2806
|
}
|
|
2650
2807
|
finally {
|
|
2651
2808
|
// [[TUI-C69]] §5.4 — **the turn is over, so the argument is over.** Until this existed the
|
|
@@ -2660,6 +2817,12 @@ export class GthAgentRunner {
|
|
|
2660
2817
|
// In `finally` because an abort and a thrown stream end the turn just as much as a return
|
|
2661
2818
|
// does, and those are the paths where rows left standing are least likely to be noticed.
|
|
2662
2819
|
this.clearNegotiationDisplay();
|
|
2820
|
+
// [[EXT-159]] — the one ending that reaches NEITHER the end of the try NOR the catch: a
|
|
2821
|
+
// consumer that stops consuming (breaking out of its `for await`, or calling `return()` on
|
|
2822
|
+
// this generator). Nothing was wrong with the run and nothing failed, so no other site can
|
|
2823
|
+
// speak for it, and without this the turn would end with no reason at all — the state that
|
|
2824
|
+
// must mean "a site we missed".
|
|
2825
|
+
this.noteTermination(terminationReason('runner.events-abandoned', 'control', 'abandoned'));
|
|
2663
2826
|
}
|
|
2664
2827
|
}
|
|
2665
2828
|
/**
|
|
@@ -2675,9 +2838,9 @@ export class GthAgentRunner {
|
|
|
2675
2838
|
*
|
|
2676
2839
|
* No-ops (yields nothing) when the agent does not support interrupts
|
|
2677
2840
|
* (`getPendingToolInterrupts`/`streamWithEventsResume` absent) — that is the only exemption.
|
|
2678
|
-
* As of EXT-52
|
|
2679
|
-
* lean
|
|
2680
|
-
*
|
|
2841
|
+
* As of EXT-52 the shipped agent gates `run_shell_command` and exposes the interrupt surface, so
|
|
2842
|
+
* the lean agent is exactly the agent this loop serves; only an agent implementation without
|
|
2843
|
+
* those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the
|
|
2681
2844
|
* resumed stream.
|
|
2682
2845
|
*/
|
|
2683
2846
|
async *resolveToolInterruptsWithEvents(signal) {
|
|
@@ -2687,19 +2850,29 @@ export class GthAgentRunner {
|
|
|
2687
2850
|
return;
|
|
2688
2851
|
if (!agent.getPendingToolInterrupts || !agent.streamWithEventsResume)
|
|
2689
2852
|
return;
|
|
2853
|
+
// [[EXT-159]] — see {@link resolveToolInterrupts}: which way the loop left is the fact that
|
|
2854
|
+
// distinguishes this ending, and it is discarded unless the loop itself records it. An abort
|
|
2855
|
+
// `return`s below and never reaches the note, which is right — a cancelled turn was stopped by
|
|
2856
|
+
// the user, not by this bound.
|
|
2857
|
+
let drained = false;
|
|
2690
2858
|
// Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
|
|
2691
2859
|
for (let guard = 0; guard < 100; guard++) {
|
|
2692
2860
|
if (signal?.aborted)
|
|
2693
2861
|
return;
|
|
2694
2862
|
const pending = await agent.getPendingToolInterrupts(runConfig);
|
|
2695
|
-
if (pending.length === 0)
|
|
2863
|
+
if (pending.length === 0) {
|
|
2864
|
+
drained = true;
|
|
2696
2865
|
break;
|
|
2866
|
+
}
|
|
2697
2867
|
const decisions = [];
|
|
2698
2868
|
for (const tool of pending) {
|
|
2699
2869
|
decisions.push(await this.decideToolApproval(tool));
|
|
2700
2870
|
}
|
|
2701
2871
|
yield* agent.streamWithEventsResume({ decisions }, runConfig, [], signal);
|
|
2702
2872
|
}
|
|
2873
|
+
if (!drained) {
|
|
2874
|
+
this.noteTermination(terminationReason('runner.events-interrupt-guard-exhausted', 'control', 'interrupt_drain_guard'));
|
|
2875
|
+
}
|
|
2703
2876
|
}
|
|
2704
2877
|
// noinspection JSUnusedGlobalSymbols
|
|
2705
2878
|
getAgent() {
|
|
@@ -2719,6 +2892,133 @@ export class GthAgentRunner {
|
|
|
2719
2892
|
/* fail-soft: analytics must never affect a run */
|
|
2720
2893
|
}
|
|
2721
2894
|
}
|
|
2895
|
+
/**
|
|
2896
|
+
* [[EXT-159]] — forget the previous turn's termination reason, on both this runner and the live
|
|
2897
|
+
* agent, so a new turn starts with none. Called at the top of each `processMessages` /
|
|
2898
|
+
* `processMessagesWithEvents`, alongside {@link resetRunStats}. Fail-soft.
|
|
2899
|
+
*/
|
|
2900
|
+
resetTerminationReason() {
|
|
2901
|
+
this.terminationReason = null;
|
|
2902
|
+
this.agentTerminationReason = null;
|
|
2903
|
+
this.agentFinishReasons = [];
|
|
2904
|
+
try {
|
|
2905
|
+
this.agent?.resetTerminationReason?.();
|
|
2906
|
+
}
|
|
2907
|
+
catch {
|
|
2908
|
+
/* fail-soft: classification must never affect a run */
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* [[EXT-159]] — record why the turn ended, **first-write-wins**.
|
|
2913
|
+
*
|
|
2914
|
+
* The runner's two exception wrappers are NESTED, not alternatives: a stream fault is classified
|
|
2915
|
+
* at the inner one, re-thrown, and caught again by the outer one. Under last-write-wins the outer
|
|
2916
|
+
* site would overwrite the inner classification on every streamed failure — the funnel this
|
|
2917
|
+
* taxonomy replaces, rebuilt one level up.
|
|
2918
|
+
*/
|
|
2919
|
+
noteTermination(reason) {
|
|
2920
|
+
try {
|
|
2921
|
+
if (this.terminationReason)
|
|
2922
|
+
return;
|
|
2923
|
+
this.terminationReason = reason;
|
|
2924
|
+
// [[EXT-159]] — the debug log carried the wrapped error string and never the classification.
|
|
2925
|
+
// Written at the decision so it survives a session whose surface never got to ask, and so a
|
|
2926
|
+
// dump taken after a kill still holds it (the ring buffer behind `debugLog` is always on).
|
|
2927
|
+
debugLog(terminationLogLine(reason));
|
|
2928
|
+
}
|
|
2929
|
+
catch {
|
|
2930
|
+
/* fail-soft */
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
/**
|
|
2934
|
+
* [[EXT-159]] — classify a thrown value at a runner site: record it here AND attach it to the
|
|
2935
|
+
* error, then hand the error back so the throw reads as one expression.
|
|
2936
|
+
*
|
|
2937
|
+
* Both carriers matter. The runner's own field serves a caller holding the runner; the attached
|
|
2938
|
+
* value serves every layer above that only ever sees the error — and neither is the message, so
|
|
2939
|
+
* no user-facing string is the only carrier of the classification.
|
|
2940
|
+
*/
|
|
2941
|
+
classifyThrownAt(site, error) {
|
|
2942
|
+
// A reason already on the error was attached by an INNER site that saw the failure first, and
|
|
2943
|
+
// that one is the truer classification — so it is inherited rather than replaced, and the two
|
|
2944
|
+
// carriers cannot end up disagreeing about the same failure.
|
|
2945
|
+
const existing = terminationReasonOf(error);
|
|
2946
|
+
const reason = existing ?? terminationReason(site, 'exception', classifyThrownTermination(error));
|
|
2947
|
+
this.noteTermination(reason);
|
|
2948
|
+
if (!existing)
|
|
2949
|
+
attachTerminationReason(error, reason);
|
|
2950
|
+
return reason;
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* [[EXT-159]] — classify an approvals stop, which the generic classifier cannot see.
|
|
2954
|
+
*
|
|
2955
|
+
* The gate's errors are typed by their own subclass names, and their prose is the explanation
|
|
2956
|
+
* rather than a diagnosis, so nothing in the exception classifier's grammar recognises one. It
|
|
2957
|
+
* does not need to: this site reaches an `instanceof ApprovalStopError` branch, so it *knows*
|
|
2958
|
+
* what ended the run, and stating the category is more honest than pattern-matching for it.
|
|
2959
|
+
*/
|
|
2960
|
+
noteApprovalStop(site, error) {
|
|
2961
|
+
const reason = terminationReason(site, 'control', {
|
|
2962
|
+
category: 'approval_stop',
|
|
2963
|
+
detail: error instanceof Error ? error.name : undefined,
|
|
2964
|
+
});
|
|
2965
|
+
this.noteTermination(reason);
|
|
2966
|
+
attachTerminationReason(error, reason);
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2969
|
+
* [[EXT-159]] — record that the turn ended because the model finished.
|
|
2970
|
+
*
|
|
2971
|
+
* An ordinary completion is a termination too, and recording it is what makes "no reason" mean
|
|
2972
|
+
* *a site nobody classified* rather than *nothing went wrong*. First-write-wins keeps a deeper
|
|
2973
|
+
* site's answer — a refusal or a truncation is what really ended a turn that also returned text.
|
|
2974
|
+
*/
|
|
2975
|
+
noteCompleted(site) {
|
|
2976
|
+
this.noteTermination(terminationReason(site, 'control', 'completed'));
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* [[EXT-159]] — why the just-finished turn ended, or `null` when nothing classified it.
|
|
2980
|
+
*
|
|
2981
|
+
* The agent's answer wins when it has one: its sites (the metadata reader, the cancellation and
|
|
2982
|
+
* suspend paths, the run-ending middlewares) sit INSIDE the runner's catches, so the innermost
|
|
2983
|
+
* classification is the true one. Never throws.
|
|
2984
|
+
*/
|
|
2985
|
+
getTerminationReason() {
|
|
2986
|
+
return this.captureAgentTerminationReason() ?? this.terminationReason;
|
|
2987
|
+
}
|
|
2988
|
+
/** [[EXT-159]] — read the live agent's reason into the snapshot (fail-soft). */
|
|
2989
|
+
captureAgentTerminationReason() {
|
|
2990
|
+
try {
|
|
2991
|
+
const reason = this.agent?.getTerminationReason?.();
|
|
2992
|
+
if (reason)
|
|
2993
|
+
this.agentTerminationReason = reason;
|
|
2994
|
+
}
|
|
2995
|
+
catch {
|
|
2996
|
+
/* fail-soft */
|
|
2997
|
+
}
|
|
2998
|
+
return this.agentTerminationReason;
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* [[EXT-159]] — what the provider said about why each model message stopped, this turn.
|
|
3002
|
+
*
|
|
3003
|
+
* Reads live from the agent while one is present and falls back to the {@link cleanup} snapshot
|
|
3004
|
+
* afterwards, the same way {@link getRunStats} does — `/debug-dump` on the readline surface and
|
|
3005
|
+
* the non-interactive verbs both ask once the agent has been dropped.
|
|
3006
|
+
*/
|
|
3007
|
+
getFinishReasonObservations() {
|
|
3008
|
+
return this.captureFinishReasonObservations();
|
|
3009
|
+
}
|
|
3010
|
+
/** [[EXT-159]] — read the live agent's finish-reason observations into the snapshot (fail-soft). */
|
|
3011
|
+
captureFinishReasonObservations() {
|
|
3012
|
+
try {
|
|
3013
|
+
const observed = this.agent?.getFinishReasonObservations?.();
|
|
3014
|
+
if (observed)
|
|
3015
|
+
this.agentFinishReasons = observed;
|
|
3016
|
+
}
|
|
3017
|
+
catch {
|
|
3018
|
+
/* fail-soft */
|
|
3019
|
+
}
|
|
3020
|
+
return this.agentFinishReasons;
|
|
3021
|
+
}
|
|
2722
3022
|
/** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
|
|
2723
3023
|
captureRunStats() {
|
|
2724
3024
|
try {
|
|
@@ -2770,6 +3070,12 @@ export class GthAgentRunner {
|
|
|
2770
3070
|
// GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
|
|
2771
3071
|
// (runSingleShot records history after calling cleanup) still gets this turn's analytics.
|
|
2772
3072
|
this.lastRunStats = this.captureRunStats();
|
|
3073
|
+
// [[EXT-159]] — and the agent's termination reason with it, for the same reason: the
|
|
3074
|
+
// single-shot path asks why the run ended after the agent has already been nulled.
|
|
3075
|
+
this.captureAgentTerminationReason();
|
|
3076
|
+
// [[EXT-159]] — likewise the provider's per-message finish reasons, which `/debug-dump` and the
|
|
3077
|
+
// non-interactive verbs read post-cleanup.
|
|
3078
|
+
this.captureFinishReasonObservations();
|
|
2773
3079
|
if (this.agent && 'cleanup' in this.agent && typeof this.agent.cleanup === 'function') {
|
|
2774
3080
|
await this.agent.cleanup();
|
|
2775
3081
|
}
|