@gaunt-sloth/core 2.0.0-alpha.8 → 2.0.0-beta.0
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/.gsloth.review.md +2 -0
- package/README.md +71 -20
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/configDiscovery.d.ts +79 -0
- package/dist/config/configDiscovery.js +80 -0
- package/dist/config/configDiscovery.js.map +1 -0
- package/dist/config/defaults.d.ts +20 -20
- package/dist/config/defaults.js +10 -8
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/filesystem-tools.d.ts +41 -0
- package/dist/config/filesystem-tools.js +56 -0
- package/dist/config/filesystem-tools.js.map +1 -0
- package/dist/config/loader.d.ts +171 -19
- package/dist/config/loader.js +1054 -144
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/providerKeys.d.ts +69 -0
- package/dist/config/providerKeys.js +69 -0
- package/dist/config/providerKeys.js.map +1 -0
- package/dist/config/schema.d.ts +2671 -138
- package/dist/config/schema.js +1361 -85
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +899 -111
- package/dist/config/shell-policy.js +800 -70
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +211 -0
- package/dist/config/tool-descriptions.js +272 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +352 -41
- package/dist/config/types.js +1 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +35 -1
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +28 -1
- package/dist/constants.js +28 -1
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +166 -11
- package/dist/core/GthAbstractAgent.js +484 -45
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +543 -57
- package/dist/core/GthAgentRunner.js +1494 -140
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +117 -2
- package/dist/core/GthLangChainAgent.js +602 -29
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/promptHeader.d.ts +28 -0
- package/dist/core/approvals/promptHeader.js +62 -0
- package/dist/core/approvals/promptHeader.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +74 -0
- package/dist/core/debugCapture.js +100 -0
- package/dist/core/debugCapture.js.map +1 -0
- package/dist/core/gthLeanAgentFactory.d.ts +4 -4
- package/dist/core/gthLeanAgentFactory.js +4 -4
- package/dist/core/launchBanner.d.ts +127 -0
- package/dist/core/launchBanner.js +414 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/modelLabel.d.ts +19 -0
- package/dist/core/modelLabel.js +26 -0
- package/dist/core/modelLabel.js.map +1 -0
- package/dist/core/plainToolIndication.d.ts +15 -0
- package/dist/core/plainToolIndication.js +174 -0
- package/dist/core/plainToolIndication.js.map +1 -0
- package/dist/core/reasoningBlocks.d.ts +65 -0
- package/dist/core/reasoningBlocks.js +103 -0
- package/dist/core/reasoningBlocks.js.map +1 -0
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +133 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/runHeader.d.ts +38 -0
- package/dist/core/runHeader.js +42 -0
- package/dist/core/runHeader.js.map +1 -0
- package/dist/core/runStats.d.ts +14 -3
- package/dist/core/runStats.js +48 -3
- package/dist/core/runStats.js.map +1 -1
- package/dist/core/shell/ShellCommandFailedError.d.ts +3 -4
- package/dist/core/shell/ShellCommandFailedError.js +3 -4
- package/dist/core/shell/ShellCommandFailedError.js.map +1 -1
- package/dist/core/shell/abstention.d.ts +88 -0
- package/dist/core/shell/abstention.js +184 -0
- package/dist/core/shell/abstention.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +271 -0
- package/dist/core/shell/approvalCapture.js +108 -0
- package/dist/core/shell/approvalCapture.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +123 -0
- package/dist/core/shell/approvalStop.js +269 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/escalationSeverity.d.ts +141 -0
- package/dist/core/shell/escalationSeverity.js +89 -0
- package/dist/core/shell/escalationSeverity.js.map +1 -0
- package/dist/core/shell/framing.d.ts +190 -0
- package/dist/core/shell/framing.js +633 -0
- package/dist/core/shell/framing.js.map +1 -0
- package/dist/core/shell/hardline.d.ts +103 -0
- package/dist/core/shell/hardline.js +780 -0
- package/dist/core/shell/hardline.js.map +1 -0
- package/dist/core/shell/negotiation.d.ts +328 -0
- package/dist/core/shell/negotiation.js +488 -0
- package/dist/core/shell/negotiation.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +44 -4
- package/dist/core/shell/normalize.js +61 -7
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +263 -0
- package/dist/core/shell/openWorld.js +1188 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +873 -0
- package/dist/core/shell/rater.js +1454 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/raterVocabulary.d.ts +121 -0
- package/dist/core/shell/raterVocabulary.js +116 -0
- package/dist/core/shell/raterVocabulary.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +69 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +123 -0
- package/dist/core/toolDisplay.js +451 -0
- package/dist/core/toolDisplay.js.map +1 -0
- package/dist/core/toolOutputChannel.d.ts +95 -0
- package/dist/core/toolOutputChannel.js +165 -0
- package/dist/core/toolOutputChannel.js.map +1 -0
- package/dist/core/types.d.ts +349 -16
- package/dist/core/types.js.map +1 -1
- package/dist/history/historyFormat.d.ts +12 -3
- package/dist/history/historyFormat.js +50 -8
- package/dist/history/historyFormat.js.map +1 -1
- package/dist/history/historyStore.d.ts +77 -0
- package/dist/history/historyStore.js +173 -6
- package/dist/history/historyStore.js.map +1 -1
- package/dist/history/recordSession.d.ts +10 -1
- package/dist/history/recordSession.js +27 -0
- package/dist/history/recordSession.js.map +1 -1
- package/dist/providers/anthropic.js +12 -0
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +107 -0
- package/dist/providers/configurationPassthrough.js +148 -0
- package/dist/providers/configurationPassthrough.js.map +1 -0
- package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
- package/dist/providers/geminiSchemaSanitizer.js +347 -0
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
- package/dist/providers/geminiThinking.d.ts +60 -0
- package/dist/providers/geminiThinking.js +92 -0
- package/dist/providers/geminiThinking.js.map +1 -0
- package/dist/providers/google-genai.js +18 -1
- package/dist/providers/google-genai.js.map +1 -1
- package/dist/providers/groq.js +12 -0
- package/dist/providers/groq.js.map +1 -1
- package/dist/providers/huggingface.d.ts +25 -0
- package/dist/providers/huggingface.js +69 -0
- package/dist/providers/huggingface.js.map +1 -0
- package/dist/providers/modelCatalog.d.ts +109 -0
- package/dist/providers/modelCatalog.js +245 -0
- package/dist/providers/modelCatalog.js.map +1 -0
- package/dist/providers/modelDiscovery.d.ts +99 -5
- package/dist/providers/modelDiscovery.js +191 -35
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.d.ts +18 -4
- package/dist/providers/ollama.js +67 -37
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.js +34 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +26 -4
- package/dist/providers/openrouter.js +83 -26
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.js +19 -1
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/providers/xai.js +20 -0
- package/dist/providers/xai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +105 -0
- package/dist/runtime/askStructured.js +120 -0
- package/dist/runtime/askStructured.js.map +1 -0
- package/dist/runtime/conversation.d.ts +64 -0
- package/dist/runtime/conversation.js +171 -0
- package/dist/runtime/conversation.js.map +1 -0
- package/dist/runtime/singleShot.d.ts +37 -6
- package/dist/runtime/singleShot.js +113 -67
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/runtime/structuredOutput.d.ts +104 -0
- package/dist/runtime/structuredOutput.js +393 -0
- package/dist/runtime/structuredOutput.js.map +1 -0
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +95 -0
- package/dist/utils/consoleUtils.js +112 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +134 -0
- package/dist/utils/debugDump.js +381 -0
- package/dist/utils/debugDump.js.map +1 -0
- package/dist/utils/debugUtils.d.ts +13 -4
- package/dist/utils/debugUtils.js +36 -13
- package/dist/utils/debugUtils.js.map +1 -1
- package/dist/utils/displayWidth.d.ts +53 -0
- package/dist/utils/displayWidth.js +195 -0
- package/dist/utils/displayWidth.js.map +1 -0
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/globalConfigUtils.d.ts +14 -2
- package/dist/utils/globalConfigUtils.js +22 -4
- package/dist/utils/globalConfigUtils.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +39 -8
- package/dist/utils/llmUtils.js +76 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.d.ts +63 -0
- package/dist/utils/redactSecrets.js +286 -0
- package/dist/utils/redactSecrets.js.map +1 -0
- package/dist/utils/systemPromptNotes.d.ts +222 -0
- package/dist/utils/systemPromptNotes.js +338 -0
- package/dist/utils/systemPromptNotes.js.map +1 -0
- package/dist/utils/systemUtils.d.ts +18 -1
- package/dist/utils/systemUtils.js +38 -3
- package/dist/utils/systemUtils.js.map +1 -1
- package/dist/utils/toolMatching.d.ts +30 -0
- package/dist/utils/toolMatching.js +44 -0
- package/dist/utils/toolMatching.js.map +1 -0
- package/dist/utils/untrustedText.d.ts +86 -0
- package/dist/utils/untrustedText.js +101 -0
- package/dist/utils/untrustedText.js.map +1 -0
- package/package.json +21 -7
- package/schema/gsloth-config.schema.json +1921 -326
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { applyRungAwareToolDescriptions, } from '#src/config.js';
|
|
1
2
|
import { StatusLevel, } from '#src/core/types.js';
|
|
2
3
|
import { accumulateMessage, createRunStatsAccumulator, finalizeRunStats, } from '#src/core/runStats.js';
|
|
4
|
+
import { collectDeclaredMcpToolAnnotations } from '#src/core/approvals/toolAnnotationSources.js';
|
|
5
|
+
import { modelProviderLabel } from '#src/core/modelLabel.js';
|
|
6
|
+
import { createPlainToolIndication } from '#src/core/plainToolIndication.js';
|
|
7
|
+
import { runHeaderLine } from '#src/core/runHeader.js';
|
|
3
8
|
import { debugLog, debugLogError, debugLogObject } from '#src/utils/debugUtils.js';
|
|
4
9
|
import { ProgressIndicator } from '#src/utils/ProgressIndicator.js';
|
|
5
10
|
import { stopWaitingForEscape, waitForEscape } from '#src/utils/systemUtils.js';
|
|
@@ -7,18 +12,123 @@ import { AIMessage, AIMessageChunk, ToolMessage } from '@langchain/core/messages
|
|
|
7
12
|
import { IterableReadableStream } from '@langchain/core/utils/stream';
|
|
8
13
|
import { interrupt, Command, GraphInterrupt } from '@langchain/langgraph';
|
|
9
14
|
import { extractInlineBinaryBlocks, materializeBinaryOutputs, renderAssistantContent, } from '#src/utils/binaryOutputUtils.js';
|
|
15
|
+
import { detectRefusal, buildRefusalMessage } from '#src/core/refusal.js';
|
|
16
|
+
import { answerTextOf, segmentAssistantContent, stripReasoningBlocks, } from '#src/core/reasoningBlocks.js';
|
|
17
|
+
const THINK_OPEN = '<think>';
|
|
18
|
+
const THINK_CLOSE = '</think>';
|
|
19
|
+
/**
|
|
20
|
+
* TUI-C22 — length of the longest suffix of `s` that is a *proper* (shorter-than-full) prefix of
|
|
21
|
+
* `tag`. Used by {@link createThinkTagSplitter} to hold back a trailing partial that might complete
|
|
22
|
+
* into `tag` on the next chunk (e.g. a chunk ending in `<thi` when the tag is `<think>`).
|
|
23
|
+
*/
|
|
24
|
+
function trailingPartialLen(s, tag) {
|
|
25
|
+
const max = Math.min(s.length, tag.length - 1);
|
|
26
|
+
for (let k = max; k > 0; k--) {
|
|
27
|
+
if (s.slice(s.length - k) === tag.slice(0, k))
|
|
28
|
+
return k;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* TUI-C22 — stateful separator of inline `<think>...</think>` thinking from answer text, robust to
|
|
34
|
+
* tags split across streamed chunks. Many thinking models served over an OpenAI-compatible `/v1`
|
|
35
|
+
* shim (qwen3 / deepseek-r1 over Ollama) inline their reasoning as `<think>…</think>` in the
|
|
36
|
+
* message `content` rather than in `additional_kwargs.reasoning_content`; without this it would
|
|
37
|
+
* render as answer text and the `/reasoning` panel would stay empty.
|
|
38
|
+
*
|
|
39
|
+
* `push(text)` returns the segments it can classify unambiguously *now*, buffering any trailing
|
|
40
|
+
* partial tag (so a `<think>` arriving as `<thi` + `nk>` across two chunks is still detected) and
|
|
41
|
+
* the run of thinking between an open and a not-yet-seen close tag. `flush()` drains the buffer at
|
|
42
|
+
* a message/stream boundary: an unterminated `<think>` at EOF yields its remainder as reasoning; a
|
|
43
|
+
* dangling non-tag partial (e.g. a lone `<` or `<thi` that never completed) yields as answer, so no
|
|
44
|
+
* text is ever dropped. Purely additive — text with no `<think>` passes straight through as answer.
|
|
45
|
+
*/
|
|
46
|
+
function createThinkTagSplitter() {
|
|
47
|
+
let buffer = '';
|
|
48
|
+
let inThink = false;
|
|
49
|
+
function push(text) {
|
|
50
|
+
const segments = [];
|
|
51
|
+
if (text.length === 0 && buffer.length === 0)
|
|
52
|
+
return segments;
|
|
53
|
+
buffer += text;
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (inThink) {
|
|
56
|
+
const idx = buffer.indexOf(THINK_CLOSE);
|
|
57
|
+
if (idx >= 0) {
|
|
58
|
+
if (idx > 0)
|
|
59
|
+
segments.push({ kind: 'reasoning', text: buffer.slice(0, idx) });
|
|
60
|
+
buffer = buffer.slice(idx + THINK_CLOSE.length);
|
|
61
|
+
inThink = false;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// No full close tag yet — emit reasoning except a trailing partial of `</think>`.
|
|
65
|
+
const hold = trailingPartialLen(buffer, THINK_CLOSE);
|
|
66
|
+
const emit = buffer.slice(0, buffer.length - hold);
|
|
67
|
+
if (emit.length > 0)
|
|
68
|
+
segments.push({ kind: 'reasoning', text: emit });
|
|
69
|
+
buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
const idx = buffer.indexOf(THINK_OPEN);
|
|
74
|
+
if (idx >= 0) {
|
|
75
|
+
if (idx > 0)
|
|
76
|
+
segments.push({ kind: 'answer', text: buffer.slice(0, idx) });
|
|
77
|
+
buffer = buffer.slice(idx + THINK_OPEN.length);
|
|
78
|
+
inThink = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// No full open tag yet — emit answer except a trailing partial of `<think>`.
|
|
82
|
+
const hold = trailingPartialLen(buffer, THINK_OPEN);
|
|
83
|
+
const emit = buffer.slice(0, buffer.length - hold);
|
|
84
|
+
if (emit.length > 0)
|
|
85
|
+
segments.push({ kind: 'answer', text: emit });
|
|
86
|
+
buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return segments;
|
|
91
|
+
}
|
|
92
|
+
function flush() {
|
|
93
|
+
const segments = [];
|
|
94
|
+
if (buffer.length > 0) {
|
|
95
|
+
segments.push({ kind: inThink ? 'reasoning' : 'answer', text: buffer });
|
|
96
|
+
}
|
|
97
|
+
buffer = '';
|
|
98
|
+
inThink = false;
|
|
99
|
+
return segments;
|
|
100
|
+
}
|
|
101
|
+
return { push, flush };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Pick this chunk's or message's reasoning delta/content.
|
|
105
|
+
* Precedence:
|
|
106
|
+
* 1. `additional_kwargs.reasoning_content` — standard DeepSeek/Anthropic/OpenRouter convention.
|
|
107
|
+
* 2. `additional_kwargs.reasoning` — direct reasoning fallback if present.
|
|
108
|
+
*/
|
|
109
|
+
function pickReasoningDelta(kwargs) {
|
|
110
|
+
if (!kwargs)
|
|
111
|
+
return '';
|
|
112
|
+
const reasoningContent = kwargs.reasoning_content;
|
|
113
|
+
if (typeof reasoningContent === 'string' && reasoningContent.length > 0) {
|
|
114
|
+
return reasoningContent;
|
|
115
|
+
}
|
|
116
|
+
const direct = kwargs.reasoning;
|
|
117
|
+
if (typeof direct === 'string' && direct.length > 0)
|
|
118
|
+
return direct;
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
10
121
|
/**
|
|
11
122
|
* Shared, graph-agnostic agent plumbing.
|
|
12
123
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* build the compiled LangGraph in {@link init}; everything downstream — invoking,
|
|
124
|
+
* A backend differs from another only in how it builds the compiled LangGraph in {@link init} —
|
|
125
|
+
* today that is the lean {@link GthLangChainAgent} (`createAgent`, in core); everything downstream — invoking,
|
|
16
126
|
* streaming to the console, emitting typed {@link AgentStreamEvent}s, client-tool
|
|
17
127
|
* `interrupt()` stubbing, suspend/resume, and cleanup — is identical and lives here.
|
|
18
128
|
*
|
|
19
|
-
* The base operates solely on the structural {@link GthCompiledGraph} surface, so it
|
|
20
|
-
*
|
|
21
|
-
*
|
|
129
|
+
* The base operates solely on the structural {@link GthCompiledGraph} surface, so it does NOT
|
|
130
|
+
* import a graph builder. Subclasses construct the graph and assign it to {@link agent} in their
|
|
131
|
+
* `init()`.
|
|
22
132
|
*/
|
|
23
133
|
export class GthAbstractAgent {
|
|
24
134
|
statusUpdate;
|
|
@@ -26,6 +136,44 @@ export class GthAbstractAgent {
|
|
|
26
136
|
agent = null;
|
|
27
137
|
config = null;
|
|
28
138
|
command = undefined;
|
|
139
|
+
/**
|
|
140
|
+
* GS2-95 — the name of the command the USER typed, as the run header should say it. Set from
|
|
141
|
+
* {@link GthAgentInitOptions#displayCommand} by the backend's `init`, and read by exactly one
|
|
142
|
+
* thing: {@link compactHeaderStatus}.
|
|
143
|
+
*
|
|
144
|
+
* Separate from {@link command} because that field is not a label — it selects the mode prompt,
|
|
145
|
+
* the approvals posture and the command-specific filesystem config. Naming the header off it is
|
|
146
|
+
* why `gth eval` used to open with `ask`, and moving it to fix the header would silently change
|
|
147
|
+
* which system prompt those runs execute under. Left `undefined` by every command whose init verb
|
|
148
|
+
* IS its name, which is most of them.
|
|
149
|
+
*/
|
|
150
|
+
displayCommand = undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Opt-in debug sink for the TUI `/debug` panel. Set AFTER {@link init} via
|
|
153
|
+
* `runner.getAgent()`; read lazily inside each backend's `wrapModelCall` capture middleware
|
|
154
|
+
* so that when it is `undefined` (the normal path) the middleware is a transparent
|
|
155
|
+
* pass-through. Lives on the base so every backend supports it; the AG-UI server / non-TUI
|
|
156
|
+
* callers simply never set it, so those contracts are unchanged.
|
|
157
|
+
*/
|
|
158
|
+
debugCapture;
|
|
159
|
+
/**
|
|
160
|
+
* GS2-56 — the ALWAYS-ON snapshot of the most recent model request (extras + the as-sent,
|
|
161
|
+
* post-summarization messages), populated UNCONDITIONALLY at each backend's `wrapModelCall` feed
|
|
162
|
+
* site — NOT gated on {@link debugCapture} being attached. This is what lets `/debug-dump` render
|
|
163
|
+
* the full model input even when the TUI `/debug` panel was never opened and on non-TUI surfaces
|
|
164
|
+
* (the sink only ever fed the live `/debug` panel). O(1): a single overwritten reference retaining
|
|
165
|
+
* only the LAST call — no accumulation, so the "pay nothing until you need it" intent is kept.
|
|
166
|
+
*/
|
|
167
|
+
lastModelRequest;
|
|
168
|
+
/**
|
|
169
|
+
* GS2-56 — stash the last model request (the as-sent messages + {@link DebugRequestExtras}).
|
|
170
|
+
* Called unconditionally from each backend's capture middleware, independent of the debug sink,
|
|
171
|
+
* so the snapshot is available to `/debug-dump` on every surface. Overwrites (retains only the
|
|
172
|
+
* most recent call). Callers already guard the invocation; kept trivial so it can never throw.
|
|
173
|
+
*/
|
|
174
|
+
setLastModelRequest(messages, extras) {
|
|
175
|
+
this.lastModelRequest = { messages, extras };
|
|
176
|
+
}
|
|
29
177
|
/**
|
|
30
178
|
* GS2-16 — per-run analytics tally (token usage + invoked tool names) folded from the messages
|
|
31
179
|
* flowing through {@link invoke} / the streaming paths. Reset at each turn boundary via
|
|
@@ -33,12 +181,147 @@ export class GthAbstractAgent {
|
|
|
33
181
|
* fully fail-soft (accumulation is guarded and never throws into a run).
|
|
34
182
|
*/
|
|
35
183
|
runStatsAcc = createRunStatsAccumulator();
|
|
184
|
+
/**
|
|
185
|
+
* EXT-58 — the names of the tools registered with the graph at the last {@link init}, recorded by
|
|
186
|
+
* {@link registerApprovalsAwareTools}. Read by `GthAgentRunner` to build the rater's
|
|
187
|
+
* granted-built-in list (§4.4), so a suggestion can only ever name a tool the model actually has.
|
|
188
|
+
*/
|
|
189
|
+
registeredToolNames = [];
|
|
190
|
+
/**
|
|
191
|
+
* EXT-70 §4.7.1 — what the connected MCP servers DECLARED about their own tools, captured from
|
|
192
|
+
* the same registration hook as {@link registeredToolNames} and keyed by the registered tool
|
|
193
|
+
* name. Read by `GthAgentRunner` as the `mcp` half of a `DeclaredToolAnnotationLookup`.
|
|
194
|
+
*
|
|
195
|
+
* It is a record of claims, never of decisions: no trust is applied here (that is
|
|
196
|
+
* `createEffectiveToolAnnotationSource`'s only job), and an absent tool yields the fail-closed
|
|
197
|
+
* defaults rather than "declared nothing".
|
|
198
|
+
*/
|
|
199
|
+
declaredMcpToolAnnotations = new Map();
|
|
36
200
|
constructor(statusUpdate, resolvers) {
|
|
37
201
|
this.statusUpdate = (level, message) => {
|
|
38
202
|
statusUpdate(level, message);
|
|
39
203
|
};
|
|
40
204
|
this.resolvers = resolvers;
|
|
41
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* GS2-101 — the run-header rung in force. An unset `output.header` resolves to `compact`: a run
|
|
208
|
+
* that was never configured opens with one attribution line and nothing else, and the technical
|
|
209
|
+
* preamble is opt-in via `output.header: 'debug'`.
|
|
210
|
+
*
|
|
211
|
+
* Defaulted HERE rather than in `DEFAULT_CONFIG` (the convention `injectModelContext` and
|
|
212
|
+
* `debugDump.redact` also follow) so the effective-config snapshot `gth config` prints does not
|
|
213
|
+
* grow a key nobody set.
|
|
214
|
+
*/
|
|
215
|
+
get headerRung() {
|
|
216
|
+
return this.config?.output?.header ?? 'compact';
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* GS2-93 — emit one line of the technical run-header preamble (the Workdir/Model/Tools/Middleware
|
|
220
|
+
* block). Only the `debug` rung shows it; `compact` replaces the whole block with
|
|
221
|
+
* {@link compactHeaderStatus}'s single line and `none` shows nothing. A non-`debug` rung only ever
|
|
222
|
+
* reaches here in non-TUI text modes: the interactive TUI forces `debug` before init (see
|
|
223
|
+
* `createTuiSession`), and the TUI event path never goes through the interrupt-hint site, so the
|
|
224
|
+
* whole preamble stays visible there. Only INFO header lines route through this — real model/tool
|
|
225
|
+
* output, warnings and errors keep using {@link statusUpdate} directly.
|
|
226
|
+
*/
|
|
227
|
+
headerStatus(message) {
|
|
228
|
+
if (this.headerRung !== 'debug')
|
|
229
|
+
return;
|
|
230
|
+
this.statusUpdate(StatusLevel.INFO, message);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* GS2-93 — the `compact` rung's whole output: one line naming the command and the model that
|
|
234
|
+
* served it, in place of the preamble. Called once from the backend's `init`, after the command
|
|
235
|
+
* and effective config are in place. The string is {@link runHeaderLine}'s, shared with the review
|
|
236
|
+
* document's opening line so the two writers cannot drift.
|
|
237
|
+
*
|
|
238
|
+
* **This renders; it does not decide.** The word after the product name is
|
|
239
|
+
* {@link displayCommand} when the command supplied one and the init verb otherwise — GS2-93
|
|
240
|
+
* forbids a label table inside the agent, because naming a user-facing line is the command's call,
|
|
241
|
+
* not the runtime's.
|
|
242
|
+
*
|
|
243
|
+
* Two runs deliberately emit nothing here:
|
|
244
|
+
*
|
|
245
|
+
* - **`review` and `pr`** already open with `reviewHeadingBlock`, which renders this same line.
|
|
246
|
+
* Emitting here as well would print the header twice on one screen.
|
|
247
|
+
* - **A run with no name at all** — the `pr` command's discovery sub-agent is the one such
|
|
248
|
+
* caller, and it runs inside a `pr` whose header is already on screen. The name is what this
|
|
249
|
+
* line is for, so with none there is nothing to say; inventing a word for it would be naming a
|
|
250
|
+
* user-facing surface from inside the agent.
|
|
251
|
+
*
|
|
252
|
+
* The model half is the shared {@link modelProviderLabel} spelling (DL-6), and it is dropped
|
|
253
|
+
* rather than faked when nothing resolves (DL-7), leaving the command on its own.
|
|
254
|
+
*/
|
|
255
|
+
compactHeaderStatus() {
|
|
256
|
+
if (this.headerRung !== 'compact')
|
|
257
|
+
return;
|
|
258
|
+
const command = this.displayCommand ?? this.command;
|
|
259
|
+
if (!command || command === 'review' || command === 'pr')
|
|
260
|
+
return;
|
|
261
|
+
const label = modelProviderLabel(this.config?.modelDisplayName, this.config?.modelProviderType);
|
|
262
|
+
this.statusUpdate(StatusLevel.INFO, runHeaderLine(command, label));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
|
|
266
|
+
* array, just before handing it to the graph builder. It does two things:
|
|
267
|
+
*
|
|
268
|
+
* 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
|
|
269
|
+
* rung, and leaves every granted tool's description untouched (the absence of the sentence is
|
|
270
|
+
* what marks a tool free). See {@link applyRungAwareToolDescriptions}.
|
|
271
|
+
* 2. Records the registered tool names for {@link getRegisteredToolNames}, which feeds the
|
|
272
|
+
* rater's granted-alternative list (§4.4).
|
|
273
|
+
* 3. EXT-70 §4.7.1 — records what the MCP servers declared about their own tools, for
|
|
274
|
+
* {@link getDeclaredMcpToolAnnotations}. This is the ONE place a `tools/list` annotation
|
|
275
|
+
* enters the approvals stack, and it enters as a claim: nothing here decides whether it is
|
|
276
|
+
* believed.
|
|
277
|
+
*
|
|
278
|
+
* `gatedTools` MUST be the **LIVE gated set for the rung in force** — `resolveGatedToolNames` for
|
|
279
|
+
* that rung — and NOT the set the caller wires into the approval interrupt. The two are different
|
|
280
|
+
* on purpose: the interrupt is installed once, at agent init, and is deliberately
|
|
281
|
+
* rung-independent (`resolveInterruptToolNames`, the union over every rung) so that
|
|
282
|
+
* `/approvals <mode>` can move the mode underneath it for the rest of the session. Passing that
|
|
283
|
+
* wider set here would describe tools as needing approval that the live mode does not gate — and
|
|
284
|
+
* a call the live mode does not gate is auto-approved the moment it reaches the runner, so the
|
|
285
|
+
* sentence would be a promise nothing keeps.
|
|
286
|
+
*
|
|
287
|
+
* What keeps a description from promising an approval the gate will not ask for is therefore that
|
|
288
|
+
* both this and `GthAgentRunner`'s own check are projections of the SAME rule,
|
|
289
|
+
* `isToolGatedAtRung`, evaluated against the SAME live mode — §4.5's "a description that disagrees
|
|
290
|
+
* with what the gate will actually do is worse than no description at all".
|
|
291
|
+
*
|
|
292
|
+
* `additionalToolNames` covers tools the graph builder registers itself and that therefore never
|
|
293
|
+
* appear in `tools`. Their descriptions are not ours to write, so they cannot be suffixed here;
|
|
294
|
+
* they are recorded only so the rater's suggestion list reflects what the model actually has.
|
|
295
|
+
*/
|
|
296
|
+
registerApprovalsAwareTools(tools, options) {
|
|
297
|
+
applyRungAwareToolDescriptions(tools, {
|
|
298
|
+
rung: options.rung,
|
|
299
|
+
gatedTools: options.gatedTools,
|
|
300
|
+
});
|
|
301
|
+
const names = tools
|
|
302
|
+
.map((tool) => tool?.name)
|
|
303
|
+
.filter((name) => typeof name === 'string' && name.length > 0);
|
|
304
|
+
this.registeredToolNames = [...names, ...(options.additionalToolNames ?? [])];
|
|
305
|
+
// `additionalToolNames` are deliberately NOT consulted: they are names the graph builder
|
|
306
|
+
// registers itself, with no tool object and therefore no declaration to read.
|
|
307
|
+
this.declaredMcpToolAnnotations = collectDeclaredMcpToolAnnotations(tools);
|
|
308
|
+
return tools;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* EXT-58 — the tool names registered with the graph at the last {@link init} (empty before it).
|
|
312
|
+
* The runner intersects these with the built-in summaries table to build the rater's
|
|
313
|
+
* granted-alternative list, so the rater can never name a tool this session does not have.
|
|
314
|
+
*/
|
|
315
|
+
getRegisteredToolNames() {
|
|
316
|
+
return [...this.registeredToolNames];
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* EXT-70 §4.7.1 — what the MCP servers declared for their tools at the last {@link init}, keyed
|
|
320
|
+
* by registered tool name (empty before it, and on a session with no MCP servers).
|
|
321
|
+
*/
|
|
322
|
+
getDeclaredMcpToolAnnotations() {
|
|
323
|
+
return this.declaredMcpToolAnnotations;
|
|
324
|
+
}
|
|
42
325
|
/**
|
|
43
326
|
* GS2-16 — clear the per-run analytics tally so the next turn starts from zero. The runner
|
|
44
327
|
* calls this at each turn boundary because it (and this agent) are reused across turns in an
|
|
@@ -73,6 +356,19 @@ export class GthAbstractAgent {
|
|
|
73
356
|
return 0;
|
|
74
357
|
}
|
|
75
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* EXT-37 — surface a detected content-policy refusal: emit the clear, user-facing explanation at
|
|
361
|
+
* WARNING level (an empty-content refusal streams nothing, so without this the console shows
|
|
362
|
+
* nothing) and return the same message so it becomes the turn's terminal answer. Shared by the
|
|
363
|
+
* non-streaming {@link invoke} and streaming {@link streamFromInput} paths so both render a
|
|
364
|
+
* refusal identically. A refusal is a *successful* (if declined) response — never a retry.
|
|
365
|
+
*/
|
|
366
|
+
surfaceRefusal(info) {
|
|
367
|
+
const message = buildRefusalMessage(info);
|
|
368
|
+
debugLog(`Content-policy refusal detected (provider=${info.provider} reason=${info.reason})`);
|
|
369
|
+
this.statusUpdate(StatusLevel.WARNING, message);
|
|
370
|
+
return message;
|
|
371
|
+
}
|
|
76
372
|
/**
|
|
77
373
|
* Invoke LLM with a message and runnable config.
|
|
78
374
|
* For streaming use {@link #stream} method, streaming is preferred if model API supports it.
|
|
@@ -102,11 +398,38 @@ export class GthAbstractAgent {
|
|
|
102
398
|
const response = await this.agent.invoke({ messages }, runConfig);
|
|
103
399
|
// Harvest token usage + invoked tool names from THIS turn's new messages only (fail-soft)
|
|
104
400
|
// so the opt-in history recorder can populate `gth insights`.
|
|
401
|
+
// TUI-C32 residual f — the streaming path renders the compact per-tool indication via
|
|
402
|
+
// streamFromInput's observer; the non-streaming invoke path (`streamOutput: false`) had
|
|
403
|
+
// none, so a plain-surface tool call surfaced nothing after the legacy fs notices were
|
|
404
|
+
// dropped (residual b). Feed THIS turn's new messages through the SAME observer so each
|
|
405
|
+
// tool call gets its `✓ 📁 name(args…)` block here too. Only the plain surface reaches
|
|
406
|
+
// invoke (the TUI uses processMessagesWithEvents); observe() is fail-soft internally.
|
|
105
407
|
const allMessages = Array.isArray(response.messages) ? response.messages : [];
|
|
106
|
-
|
|
408
|
+
const toolIndication = createPlainToolIndication();
|
|
409
|
+
for (const m of allMessages.slice(priorMessageCount)) {
|
|
107
410
|
this.recordRunStats(m);
|
|
411
|
+
toolIndication.observe(m);
|
|
412
|
+
}
|
|
108
413
|
const finalMessage = response.messages[response.messages.length - 1];
|
|
109
|
-
|
|
414
|
+
// EXT-37: content-policy refusal. A successful response whose stop/finish reason is a
|
|
415
|
+
// refusal (OpenAI content_filter / Anthropic stop_reason=refusal / Bedrock
|
|
416
|
+
// guardrail_intervened) is terminal-but-clear: surface the model's explanation and RETURN
|
|
417
|
+
// it as the answer. It must NOT flow into the empty-response retry (a refusal is
|
|
418
|
+
// deterministic — retrying just burns a paid call). Returning a non-empty message means the
|
|
419
|
+
// caller writes it to the output file and exits ok, rather than re-wrapping a *successful*
|
|
420
|
+
// (if declined) response as "Failed to get answer". A fallback-model attempt would hang
|
|
421
|
+
// here (see the extension point in GthAgentRunner.processMessages), but no runtime
|
|
422
|
+
// fallback-model config exists today, so we surface terminally.
|
|
423
|
+
const refusal = detectRefusal(finalMessage);
|
|
424
|
+
if (refusal) {
|
|
425
|
+
return this.surfaceRefusal(refusal);
|
|
426
|
+
}
|
|
427
|
+
// CFG-33: Gemini's thought summaries ride inside `content` as `thought: true` text blocks,
|
|
428
|
+
// which renderAssistantContent would print as part of the answer (and write to the output
|
|
429
|
+
// file). The plain surface has never shown reasoning — every other provider's arrives
|
|
430
|
+
// out-of-band in additional_kwargs — so drop them for rendering only; graph state keeps the
|
|
431
|
+
// message whole so the thought parts still replay as history.
|
|
432
|
+
const finalContent = stripReasoningBlocks(finalMessage?.content);
|
|
110
433
|
const processedContent = !this.config.writeBinaryOutputsToFile
|
|
111
434
|
? {
|
|
112
435
|
renderedContent: renderAssistantContent(finalContent),
|
|
@@ -186,6 +509,14 @@ export class GthAbstractAgent {
|
|
|
186
509
|
// GS2-16: bound so the stream `start()` closure (whose `this` is the stream source, not the
|
|
187
510
|
// agent) can fold each chunk into the run tally. Fail-soft inside recordRunStats.
|
|
188
511
|
const recordRunStats = (m) => this.recordRunStats(m);
|
|
512
|
+
// EXT-37: bound so the stream `start()` closure can surface a detected refusal (WARNING +
|
|
513
|
+
// returns the message to enqueue) without a `this` reference.
|
|
514
|
+
const surfaceRefusal = (info) => this.surfaceRefusal(info);
|
|
515
|
+
// TUI-C30 — compact per-tool-call indication for the plain surface (`name(args…)` + the
|
|
516
|
+
// canonical 10-line greyed preview when each ToolMessage lands). Per-stream state; emits at
|
|
517
|
+
// INFO level so the existing consoleLevel gate governs it like the historical tool notices.
|
|
518
|
+
// The TUI never runs this string path (it renders the typed event stream itself).
|
|
519
|
+
const toolIndication = createPlainToolIndication();
|
|
189
520
|
const interruptState = { escape: false, messageShown: false };
|
|
190
521
|
const abortController = new AbortController();
|
|
191
522
|
const showInterruptMessage = () => {
|
|
@@ -200,7 +531,12 @@ export class GthAbstractAgent {
|
|
|
200
531
|
if (!abortController.signal.aborted) {
|
|
201
532
|
abortController.abort();
|
|
202
533
|
}
|
|
203
|
-
}, this.config.canInterruptInferenceWithEsc
|
|
534
|
+
}, this.config.canInterruptInferenceWithEsc,
|
|
535
|
+
// GS2-93: the interrupt hint is part of the run-header preamble, so only the `debug` rung
|
|
536
|
+
// prints it. The Esc/Q handler stays armed at every rung — the hint box is what goes, not the
|
|
537
|
+
// interrupt. This site only runs in the non-TUI text path (`streamFromInput`); the TUI event
|
|
538
|
+
// path never reaches it.
|
|
539
|
+
this.headerRung === 'debug');
|
|
204
540
|
let stream;
|
|
205
541
|
try {
|
|
206
542
|
stream = await this.agent.stream(input, {
|
|
@@ -225,13 +561,44 @@ export class GthAbstractAgent {
|
|
|
225
561
|
let totalChunks = 0;
|
|
226
562
|
const seenBinaryBlocks = new Set();
|
|
227
563
|
const binaryBlocks = [];
|
|
564
|
+
// EXT-37: a content-policy refusal's stop/finish reason rides on a chunk's
|
|
565
|
+
// response_metadata (usually with empty content). Capture it here and surface it AFTER
|
|
566
|
+
// the stream drains, so the returned text is non-empty and the run loop treats it as
|
|
567
|
+
// terminal-but-clear instead of routing an empty streamed turn into the retry.
|
|
568
|
+
let refusalInfo = null;
|
|
569
|
+
// EXT-41: belt-and-suspenders — also concat the AI chunks so a refusal can be read off
|
|
570
|
+
// the FINAL aggregated message's stop/finish reason, not only a per-chunk one. Some
|
|
571
|
+
// providers surface the reason only on the assembled message (or split it across chunks
|
|
572
|
+
// that concat into it); without this fallback such a refusal would be swallowed by the
|
|
573
|
+
// empty-response retry, making the EXT-37 surfacing cosmetic on the DEFAULT streaming
|
|
574
|
+
// surface. Reset at each tool round (below) so a prior round's reason can't concatenate
|
|
575
|
+
// with the final turn's (mirrors processEventStream's per-round reset).
|
|
576
|
+
let aggregatedChunk = null;
|
|
228
577
|
for await (const [chunk, _metadata] of stream) {
|
|
229
578
|
debugLogObject('Stream chunk', { chunk, _metadata });
|
|
230
579
|
// GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into
|
|
231
580
|
// the run tally before the text-only handling below.
|
|
232
581
|
recordRunStats(chunk);
|
|
582
|
+
// EXT-37: first refusal signal wins; keep scanning chunks for text/binary as normal.
|
|
583
|
+
if (!refusalInfo) {
|
|
584
|
+
refusalInfo = detectRefusal(chunk);
|
|
585
|
+
}
|
|
586
|
+
// EXT-41: fold AI chunks into an aggregate for the aggregate-level refusal fallback,
|
|
587
|
+
// resetting at tool-round boundaries so a prior round's stop/finish reason can't bleed
|
|
588
|
+
// into the final turn's aggregate.
|
|
589
|
+
if (AIMessageChunk.isInstance(chunk)) {
|
|
590
|
+
aggregatedChunk = aggregatedChunk ? aggregatedChunk.concat(chunk) : chunk;
|
|
591
|
+
}
|
|
592
|
+
else if (chunk instanceof ToolMessage) {
|
|
593
|
+
aggregatedChunk = null;
|
|
594
|
+
}
|
|
595
|
+
// TUI-C30: fold the chunk into the plain-surface tool indication (renders each
|
|
596
|
+
// completed call when its ToolMessage arrives; a no-op for plain text chunks).
|
|
597
|
+
toolIndication.observe(chunk);
|
|
233
598
|
if (AIMessage.isInstance(chunk)) {
|
|
234
|
-
|
|
599
|
+
// CFG-33: the ANSWER text only. `.text` folds Gemini's `thought: true` blocks into the
|
|
600
|
+
// answer, which would print the model's thinking inline here and in the output file.
|
|
601
|
+
const text = answerTextOf(chunk.content);
|
|
235
602
|
totalChunks++;
|
|
236
603
|
if (text.length > 0) {
|
|
237
604
|
statusUpdate(StatusLevel.STREAM, text);
|
|
@@ -264,6 +631,19 @@ export class GthAbstractAgent {
|
|
|
264
631
|
statusUpdate(StatusLevel.SUCCESS, successMessage);
|
|
265
632
|
}
|
|
266
633
|
}
|
|
634
|
+
// EXT-41: aggregate-level fallback — if no per-chunk metadata flagged a refusal, inspect
|
|
635
|
+
// the FINAL aggregated message's stop/finish reason. Catches providers that expose the
|
|
636
|
+
// reason only on the assembled message (or split across chunks that concat into it).
|
|
637
|
+
if (!refusalInfo && aggregatedChunk) {
|
|
638
|
+
refusalInfo = detectRefusal(aggregatedChunk);
|
|
639
|
+
}
|
|
640
|
+
// EXT-37: surface a captured refusal as the terminal answer. Enqueue the clear message
|
|
641
|
+
// (so the drained result is non-empty and bypasses the empty-response retry) and print it
|
|
642
|
+
// once at WARNING level (surfaceRefusal). Any partial content already streamed is kept;
|
|
643
|
+
// the refusal notice follows it, and its explanation carries any model-provided text.
|
|
644
|
+
if (refusalInfo) {
|
|
645
|
+
controller.enqueue(surfaceRefusal(refusalInfo));
|
|
646
|
+
}
|
|
267
647
|
debugLog(`Stream completed. Total chunks: ${totalChunks}`);
|
|
268
648
|
controller.close();
|
|
269
649
|
}
|
|
@@ -425,6 +805,52 @@ export class GthAbstractAgent {
|
|
|
425
805
|
let aggregatedAIChunk = null;
|
|
426
806
|
let reasoningOpen = false;
|
|
427
807
|
const flushed = new Set();
|
|
808
|
+
// EXT-41: a content-policy refusal on this typed-event path was de-scoped by EXT-37 (there is
|
|
809
|
+
// no empty-response retry here, so no wrong-retry bug), but it still rendered as a SILENT empty
|
|
810
|
+
// turn. Capture it (first per-chunk signal wins; aggregate fallback at stream end) and surface
|
|
811
|
+
// it as a `text` event so every consumer (Ink TUI viewModel, AG-UI SSE) shows a clear notice.
|
|
812
|
+
let refusalInfo = null;
|
|
813
|
+
// TUI-C22 — one splitter for the whole stream so a <think> opened in one chunk and closed
|
|
814
|
+
// several chunks later is tracked across the boundary. Reset at message boundaries via flush().
|
|
815
|
+
const thinkSplitter = createThinkTagSplitter();
|
|
816
|
+
// TUI-C22 — emit ordered answer/reasoning segments, opening/closing the reasoning block as the
|
|
817
|
+
// kind switches. Shares `reasoningOpen` with the reasoning_content path so the two compose
|
|
818
|
+
// (a reasoning_content delta then think-derived reasoning stays one open block; answer text
|
|
819
|
+
// closes it), preserving the exact existing event sequence when no <think> tags are present.
|
|
820
|
+
function* emitSegments(segments) {
|
|
821
|
+
for (const seg of segments) {
|
|
822
|
+
if (seg.text.length === 0)
|
|
823
|
+
continue;
|
|
824
|
+
if (seg.kind === 'reasoning') {
|
|
825
|
+
if (!reasoningOpen) {
|
|
826
|
+
reasoningOpen = true;
|
|
827
|
+
yield { type: 'reasoning_start' };
|
|
828
|
+
}
|
|
829
|
+
yield { type: 'reasoning_delta', delta: seg.text };
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
if (reasoningOpen) {
|
|
833
|
+
reasoningOpen = false;
|
|
834
|
+
yield { type: 'reasoning_end' };
|
|
835
|
+
}
|
|
836
|
+
yield { type: 'text', delta: seg.text };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// CFG-33 — emit a message's content segments in order. A segment already classified as the
|
|
841
|
+
// model's thinking (a Gemini `thought: true` block) goes straight to the reasoning channel;
|
|
842
|
+
// answer text still passes through the TUI-C22 think splitter, so an inline `<think>` tag is
|
|
843
|
+
// peeled exactly as before. With no reasoning block this is the previous `.text` behaviour.
|
|
844
|
+
function* emitContentSegments(segments) {
|
|
845
|
+
for (const segment of segments) {
|
|
846
|
+
if (segment.kind === 'reasoning') {
|
|
847
|
+
yield* emitSegments([segment]);
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
yield* emitSegments(thinkSplitter.push(segment.text));
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
428
854
|
function* flushAggregated() {
|
|
429
855
|
if (!aggregatedAIChunk)
|
|
430
856
|
return;
|
|
@@ -456,41 +882,37 @@ export class GthAbstractAgent {
|
|
|
456
882
|
// GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into the
|
|
457
883
|
// run tally so the TUI turn can record real token/tool data. Fail-soft.
|
|
458
884
|
this.recordRunStats(chunk);
|
|
885
|
+
// EXT-41: reuse EXT-37's detector (do NOT fork a second one). First per-chunk signal wins;
|
|
886
|
+
// a ToolMessage / normal chunk yields null, so a normal turn never surfaces a false refusal.
|
|
887
|
+
if (!refusalInfo) {
|
|
888
|
+
refusalInfo = detectRefusal(chunk);
|
|
889
|
+
}
|
|
459
890
|
if (AIMessageChunk.isInstance(chunk)) {
|
|
460
891
|
aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(chunk) : chunk;
|
|
461
|
-
// Reasoning deltas — Ollama (Qwen3, deepseek-r1) and
|
|
462
|
-
// thinking
|
|
463
|
-
// separate event series so clients can render it apart from the answer.
|
|
464
|
-
const reasoningDelta = chunk.additional_kwargs
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
reasoningOpen = true;
|
|
468
|
-
yield { type: 'reasoning_start' };
|
|
469
|
-
}
|
|
470
|
-
yield { type: 'reasoning_delta', delta: reasoningDelta };
|
|
471
|
-
}
|
|
472
|
-
// Yield text incrementally — use this chunk's text (delta), not the
|
|
473
|
-
// aggregated content which is cumulative.
|
|
474
|
-
if (chunk.text) {
|
|
475
|
-
if (reasoningOpen) {
|
|
476
|
-
reasoningOpen = false;
|
|
477
|
-
yield { type: 'reasoning_end' };
|
|
478
|
-
}
|
|
479
|
-
yield { type: 'text', delta: chunk.text };
|
|
892
|
+
// Reasoning deltas — Ollama (Qwen3, deepseek-r1), Anthropic, and OpenRouter surface
|
|
893
|
+
// thinking in additional_kwargs.reasoning_content. Stream
|
|
894
|
+
// it as a separate event series so clients can render it apart from the answer.
|
|
895
|
+
const reasoningDelta = pickReasoningDelta(chunk.additional_kwargs);
|
|
896
|
+
if (reasoningDelta.length > 0) {
|
|
897
|
+
yield* emitSegments([{ kind: 'reasoning', text: reasoningDelta }]);
|
|
480
898
|
}
|
|
899
|
+
// Yield text incrementally — use this chunk's text (delta), not the aggregated content
|
|
900
|
+
// which is cumulative. TUI-C22 routes it through the think splitter so inline
|
|
901
|
+
// <think>...</think> (buffered across chunks) is peeled into the reasoning channel and
|
|
902
|
+
// stripped from the answer; text with no think tags passes straight through unchanged.
|
|
903
|
+
// CFG-33 classifies the chunk's content blocks first, in order, so Gemini's `thought: true`
|
|
904
|
+
// blocks reach the reasoning channel instead of the answer; answer text still goes through
|
|
905
|
+
// the think splitter, thought text does not (it is already classified).
|
|
906
|
+
yield* emitContentSegments(segmentAssistantContent(chunk.content));
|
|
481
907
|
}
|
|
482
908
|
else if (AIMessage.isInstance(chunk)) {
|
|
483
909
|
// Reasoning on a non-chunk AIMessage — a non-streamed / resumed thinking message
|
|
484
910
|
// (e.g. a checkpoint replay) still carries its thinking in
|
|
485
|
-
// additional_kwargs.reasoning_content. Mirror the AIMessageChunk branch and emit the
|
|
486
|
-
//
|
|
487
|
-
const reasoningContent = chunk.additional_kwargs
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
reasoningOpen = true;
|
|
491
|
-
yield { type: 'reasoning_start' };
|
|
492
|
-
}
|
|
493
|
-
yield { type: 'reasoning_delta', delta: reasoningContent };
|
|
911
|
+
// additional_kwargs.reasoning_content. Mirror the AIMessageChunk branch and emit the same
|
|
912
|
+
// reasoning event series, otherwise the thought is silently dropped (TUI-C15).
|
|
913
|
+
const reasoningContent = pickReasoningDelta(chunk.additional_kwargs);
|
|
914
|
+
if (reasoningContent.length > 0) {
|
|
915
|
+
yield* emitSegments([{ kind: 'reasoning', text: reasoningContent }]);
|
|
494
916
|
}
|
|
495
917
|
// Non-chunk AIMessage (e.g. on resumed runs) carries final tool_calls
|
|
496
918
|
// directly; merge them into the aggregate so flushAggregated emits them.
|
|
@@ -501,15 +923,17 @@ export class GthAbstractAgent {
|
|
|
501
923
|
});
|
|
502
924
|
aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(synthetic) : synthetic;
|
|
503
925
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
yield { type: 'text', delta: chunk.text };
|
|
510
|
-
}
|
|
926
|
+
yield* emitContentSegments(segmentAssistantContent(chunk.content));
|
|
927
|
+
// A non-chunk AIMessage is a COMPLETE message, not a delta — drain any residual now
|
|
928
|
+
// (an unterminated <think> becomes reasoning, a dangling partial becomes answer) so its
|
|
929
|
+
// buffered state never leaks into a subsequent message (TUI-C22).
|
|
930
|
+
yield* emitSegments(thinkSplitter.flush());
|
|
511
931
|
}
|
|
512
932
|
if (chunk instanceof ToolMessage) {
|
|
933
|
+
// TUI-C22 — drain buffered think text (emitting its segments, which may open/close
|
|
934
|
+
// reasoning) BEFORE closing the reasoning block, so a trailing reasoning slice can't land
|
|
935
|
+
// after reasoning_end or be dropped. A tool round ends the assistant message, so reset.
|
|
936
|
+
yield* emitSegments(thinkSplitter.flush());
|
|
513
937
|
if (reasoningOpen) {
|
|
514
938
|
reasoningOpen = false;
|
|
515
939
|
yield { type: 'reasoning_end' };
|
|
@@ -532,12 +956,27 @@ export class GthAbstractAgent {
|
|
|
532
956
|
};
|
|
533
957
|
}
|
|
534
958
|
}
|
|
959
|
+
// TUI-C22 — drain any buffered think text at stream end (an unterminated <think> surfaces as
|
|
960
|
+
// reasoning, a dangling partial as answer) before closing the reasoning block.
|
|
961
|
+
yield* emitSegments(thinkSplitter.flush());
|
|
535
962
|
// Close any still-open reasoning block before flushing tool calls.
|
|
536
963
|
if (reasoningOpen) {
|
|
537
964
|
yield { type: 'reasoning_end' };
|
|
538
965
|
}
|
|
539
966
|
// Flush any tool calls not followed by a ToolMessage (e.g. terminal tool calls).
|
|
540
967
|
yield* flushAggregated();
|
|
968
|
+
// EXT-41: aggregate-level fallback (I-1's robustness on this path too) — if no per-chunk
|
|
969
|
+
// metadata flagged a refusal, inspect the final aggregated message's stop/finish reason. Then
|
|
970
|
+
// surface any refusal as a `text` event so the user sees a clear notice instead of a silent
|
|
971
|
+
// empty turn. No statusUpdate here: consumers render the typed events, and a WARNING would
|
|
972
|
+
// double-render in the TUI.
|
|
973
|
+
if (!refusalInfo && aggregatedAIChunk) {
|
|
974
|
+
refusalInfo = detectRefusal(aggregatedAIChunk);
|
|
975
|
+
}
|
|
976
|
+
if (refusalInfo) {
|
|
977
|
+
debugLog(`Content-policy refusal detected on typed-event path (provider=${refusalInfo.provider} reason=${refusalInfo.reason})`);
|
|
978
|
+
yield { type: 'text', delta: buildRefusalMessage(refusalInfo) };
|
|
979
|
+
}
|
|
541
980
|
}
|
|
542
981
|
async cleanup() {
|
|
543
982
|
debugLog('Cleaning up agent...');
|