@goodandready/dsh-agent-loop-guard 0.2.2

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/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GooDAnDReaDY
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @goodandready/dsh-agent-loop-guard 0.2.2
2
+
3
+ Host-only DeepSeek Harness bundle that prevents tool-call loops without changing DSH core.
4
+
5
+ ## Behaviour
6
+
7
+ - treats a repeated call as a loop only when no successful result or state
8
+ change has appeared since the previous attempt;
9
+ - permits legitimate iterations with the same arguments when the result or
10
+ explicit progress token changes;
11
+ - allows read -> edit -> read -> edit when each step produces new evidence;
12
+ - keeps Gitea/curl operations distinct by their complete operation arguments,
13
+ including HTTP method and endpoint, even when they share a base URL;
14
+ - uses maxToolAttemptsPerTurn and maxProgressToolCallsPerTurn as budgets
15
+ since the last productive action, so productive work resets the counters;
16
+ - after a loop or budget denial, enters answer-only mode for the current turn
17
+ and returns a normal DSH tool denial that requires a text answer; the next
18
+ turn resets that mode;
19
+ - logs every LOOP_GUARD_STOP, LOOP_GUARD_LIMIT,
20
+ LOOP_GUARD_PROGRESS_LIMIT, LOOP_GUARD_DUPLICATE, and
21
+ LOOP_GUARD_REPEAT event with a redacted call summary and progress context;
22
+ - preserves the assistant-output guard: it detects text-only loops across
23
+ block/step/turn boundaries and cancels with keepInbox: false; both repeated
24
+ individual lines and repeated multi-line blocks are covered.
25
+
26
+ The repeat threshold remains controlled by maxCallsPerRepeatGroup (default 5),
27
+ but it is evaluated against the current progress epoch rather than raw call
28
+ count. A successful result is considered productive when its result fingerprint
29
+ or explicit progress token differs from the last successful evidence. Failed or
30
+ unknown results do not reset the guard.
31
+
32
+ The legacy maxCallsPerToolPerTurn setting remains accepted as a compatibility
33
+ alias. maxToolAttemptsPerTurn: 0 disables only the aggregate no-progress
34
+ budget; repeat, stop, progress, and assistant-output protections remain active.
35
+ Denials use the documented tools.guard API and remain normal structured DSH tool
36
+ results, preserving session persistence.
37
+
38
+ ## Verification
39
+
40
+ npm test
41
+ npm run check
42
+
43
+
44
+ ## Assistant output settings
45
+
46
+ The output guard uses maxRepeatedAssistantLines for single-line repetition and maxRepeatedAssistantBlocks (default 5) for identical multi-line blocks. maxAssistantBlockChars (default 16384) bounds the captured block fingerprint. Streaming chunks and their final assistant/message are deduplicated.
package/README.ru.md ADDED
@@ -0,0 +1,46 @@
1
+ # @goodandready/dsh-agent-loop-guard 0.2.2
2
+
3
+ Host-only DeepSeek Harness bundle that prevents tool-call loops without changing DSH core.
4
+
5
+ ## Behaviour
6
+
7
+ - treats a repeated call as a loop only when no successful result or state
8
+ change has appeared since the previous attempt;
9
+ - permits legitimate iterations with the same arguments when the result or
10
+ explicit progress token changes;
11
+ - allows read -> edit -> read -> edit when each step produces new evidence;
12
+ - keeps Gitea/curl operations distinct by their complete operation arguments,
13
+ including HTTP method and endpoint, even when they share a base URL;
14
+ - uses maxToolAttemptsPerTurn and maxProgressToolCallsPerTurn as budgets
15
+ since the last productive action, so productive work resets the counters;
16
+ - after a loop or budget denial, enters answer-only mode for the current turn
17
+ and returns a normal DSH tool denial that requires a text answer; the next
18
+ turn resets that mode;
19
+ - logs every LOOP_GUARD_STOP, LOOP_GUARD_LIMIT,
20
+ LOOP_GUARD_PROGRESS_LIMIT, LOOP_GUARD_DUPLICATE, and
21
+ LOOP_GUARD_REPEAT event with a redacted call summary and progress context;
22
+ - preserves the assistant-output guard: it detects text-only loops across
23
+ block/step/turn boundaries and cancels with keepInbox: false; both repeated
24
+ individual lines and repeated multi-line blocks are covered.
25
+
26
+ The repeat threshold remains controlled by maxCallsPerRepeatGroup (default 5),
27
+ but it is evaluated against the current progress epoch rather than raw call
28
+ count. A successful result is considered productive when its result fingerprint
29
+ or explicit progress token differs from the last successful evidence. Failed or
30
+ unknown results do not reset the guard.
31
+
32
+ The legacy maxCallsPerToolPerTurn setting remains accepted as a compatibility
33
+ alias. maxToolAttemptsPerTurn: 0 disables only the aggregate no-progress
34
+ budget; repeat, stop, progress, and assistant-output protections remain active.
35
+ Denials use the documented tools.guard API and remain normal structured DSH tool
36
+ results, preserving session persistence.
37
+
38
+ ## Verification
39
+
40
+ npm test
41
+ npm run check
42
+
43
+
44
+ ## Assistant output settings
45
+
46
+ The output guard uses maxRepeatedAssistantLines for single-line repetition and maxRepeatedAssistantBlocks (default 5) for identical multi-line blocks. maxAssistantBlockChars (default 16384) bounds the captured block fingerprint. Streaming chunks and their final assistant/message are deduplicated.
package/README.zh.md ADDED
@@ -0,0 +1,46 @@
1
+ # @goodandready/dsh-agent-loop-guard 0.2.2
2
+
3
+ Host-only DeepSeek Harness bundle that prevents tool-call loops without changing DSH core.
4
+
5
+ ## Behaviour
6
+
7
+ - treats a repeated call as a loop only when no successful result or state
8
+ change has appeared since the previous attempt;
9
+ - permits legitimate iterations with the same arguments when the result or
10
+ explicit progress token changes;
11
+ - allows read -> edit -> read -> edit when each step produces new evidence;
12
+ - keeps Gitea/curl operations distinct by their complete operation arguments,
13
+ including HTTP method and endpoint, even when they share a base URL;
14
+ - uses maxToolAttemptsPerTurn and maxProgressToolCallsPerTurn as budgets
15
+ since the last productive action, so productive work resets the counters;
16
+ - after a loop or budget denial, enters answer-only mode for the current turn
17
+ and returns a normal DSH tool denial that requires a text answer; the next
18
+ turn resets that mode;
19
+ - logs every LOOP_GUARD_STOP, LOOP_GUARD_LIMIT,
20
+ LOOP_GUARD_PROGRESS_LIMIT, LOOP_GUARD_DUPLICATE, and
21
+ LOOP_GUARD_REPEAT event with a redacted call summary and progress context;
22
+ - preserves the assistant-output guard: it detects text-only loops across
23
+ block/step/turn boundaries and cancels with keepInbox: false; both repeated
24
+ individual lines and repeated multi-line blocks are covered.
25
+
26
+ The repeat threshold remains controlled by maxCallsPerRepeatGroup (default 5),
27
+ but it is evaluated against the current progress epoch rather than raw call
28
+ count. A successful result is considered productive when its result fingerprint
29
+ or explicit progress token differs from the last successful evidence. Failed or
30
+ unknown results do not reset the guard.
31
+
32
+ The legacy maxCallsPerToolPerTurn setting remains accepted as a compatibility
33
+ alias. maxToolAttemptsPerTurn: 0 disables only the aggregate no-progress
34
+ budget; repeat, stop, progress, and assistant-output protections remain active.
35
+ Denials use the documented tools.guard API and remain normal structured DSH tool
36
+ results, preserving session persistence.
37
+
38
+ ## Verification
39
+
40
+ npm test
41
+ npm run check
42
+
43
+
44
+ ## Assistant output settings
45
+
46
+ The output guard uses maxRepeatedAssistantLines for single-line repetition and maxRepeatedAssistantBlocks (default 5) for identical multi-line blocks. maxAssistantBlockChars (default 16384) bounds the captured block fingerprint. Streaming chunks and their final assistant/message are deduplicated.
@@ -0,0 +1,15 @@
1
+ # Host-only DSH bundle. No dsh.client entry exists because this package has no browser module.
2
+ - insert:
3
+ - id: '@goodandready/dsh-agent-loop-guard'
4
+ name: '@goodandready/dsh-agent-loop-guard'
5
+ config:
6
+ maxToolAttemptsPerTurn: 64
7
+ maxProgressToolCallsPerTurn: 16
8
+ progressToolNames:
9
+ - todo_write
10
+ maxCallsPerRepeatGroup: 5
11
+ blockExactDuplicates: true
12
+ assistantOutputGuard: true
13
+ maxRepeatedAssistantLines: 5
14
+ maxRepeatedAssistantBlocks: 5
15
+ maxAssistantBlockChars: 16384
@@ -0,0 +1,37 @@
1
+ # Architecture baseline
2
+
3
+ The plugin owns in-memory guard state only. Agent pre-step records turn and stop
4
+ mode. The documented DSH tools.guard hook synchronously evaluates each tool
5
+ attempt and returns either allow or a denial reason. Each accepted call enters a
6
+ progress ledger with a full argument fingerprint, a normalized repeat
7
+ fingerprint, and a pending/committed lifecycle. tools/result records the result
8
+ fingerprint and optional progress token. A successful change from the last
9
+ successful evidence advances a progress epoch, clears no-progress repeat
10
+ groups, and resets contextual budgets. Failed or unknown results leave the
11
+ no-progress counters intact.
12
+
13
+ The independent assistant-output branch subscribes to DSH session/event. It
14
+ tracks assistant/chunk text-delta events and assistant/message final content by
15
+ session. The final-message path accepts only Array.isArray(message.content)
16
+ text blocks and deduplicates messages already seen in the stream. After the
17
+ configured number of identical complete normalized lines, and only when no tool
18
+ call is active, it calls the public agent.cancel API with keepInbox: false.
19
+ The streak survives block, step, and turn boundaries and latches until a new
20
+ user message; tool calls reset the streak, and session-disposed events dispose
21
+ the in-memory state.
22
+
23
+ user message -> pre-step records turn -> model tool call -> tools.guard -> normalized DSH tool result -> model text response
24
+ assistant/chunk or assistant/message -> output state -> repeated-line threshold -> agent.cancel(keepInbox: false)
25
+
26
+ No DSH core, session log, client plugin, database, or external service changes.
27
+
28
+ Ordinary loop-prone tools consume maxToolAttemptsPerTurn. Explicit progress/state
29
+ tools (default todo_write) use a separate finite maxProgressToolCallsPerTurn
30
+ budget, so required checklist writes remain available after ordinary research
31
+ reaches its cap without creating an unlimited escape from the guard. Exact and
32
+ normalized-repeat checks run before either budget and apply to both classes.
33
+
34
+
35
+ ## Assistant output loop detection
36
+
37
+ The host-only guard keeps two independent bounded detectors. The existing detector counts consecutive normalized complete lines. The multi-line detector fingerprints each completed assistant block (at block-end or final assistant message), keeps the count across step and turn boundaries, and trips only after the configured number of identical blocks without an intervening user message or tool call. A streamed block and its final message are counted once.
@@ -0,0 +1,17 @@
1
+ # Reuse-first research
2
+
3
+ Reviewed installed DSH components.
4
+
5
+ - dsh-repeat-tool-reminder warns about repeat chains but does not enforce a bound.
6
+ - dsh-approval-gate uses public tools.guard and demonstrates a fail-closed denial before tool execution.
7
+ - agent/pre-step can reject a whole step, but DSH ends that turn as blocked and gives no model text response.
8
+
9
+ GitHub search found no reusable external DSH anti-loop bundle on 2026-08-19. This project therefore adapts the documented DSH guard pattern with a small deterministic state module.
10
+
11
+ The official DSH documentation describes plugins as the extension boundary and
12
+ the Cordis primer documents session event handling and agent lifecycle APIs.
13
+ The closest reusable external implementation found was opencode-auto-resume,
14
+ which detects repeated model output in an OpenCode-specific runtime; it cannot
15
+ be installed as a DSH bundle. This plugin therefore uses the documented DSH
16
+ session/event and agents.get(...).cancel(..., { keepInbox: true }) APIs rather
17
+ than patching DSH core.
@@ -0,0 +1,21 @@
1
+ # Test plan
2
+
3
+ Unit coverage verifies canonical and normalized fingerprints, result/state
4
+ progress epochs, productive same-argument iterations, unchanged-result
5
+ no-progress loops, read/edit retries, contextual aggregate and progress
6
+ budgets, distinct Gitea/curl endpoints, stop recovery on the next turn,
7
+ redacted structured violation logs, failed-result handling, explicit progress
8
+ tokens, malformed non-array user content, and turn reset.
9
+
10
+ Separate assistant-output coverage verifies whitespace normalization, five-line
11
+ threshold behavior, split streaming chunks, cross-block/step/turn persistence,
12
+ assistant/message fallback with malformed-content safety, deduplication,
13
+ tool-call suppression, user-message reset, configurable thresholds, multi-line
14
+ block normalization, block repetition, and stream/final-message deduplication. The
15
+ fixture is derived from the observed reinstall narration loop.
16
+
17
+ Syntax checks validate ESM source files. Runtime smoke tests install the bundle
18
+ in both the container and host/test profiles, replay productive and
19
+ no-progress tool-result sequences through the public tools.guard lifecycle,
20
+ and verify one structured denial plus an answer-only stop path. Assistant
21
+ output smoke verifies one cancellation with keepInbox:false.
@@ -0,0 +1,252 @@
1
+ function sessionKey(session) {
2
+ if (typeof session === 'string' && session.length > 0) return session;
3
+ if (session !== null && typeof session === 'object') {
4
+ const id = session.id ?? session.session?.id ?? session.header?.id;
5
+ if (typeof id === 'string' && id.length > 0) return id;
6
+ }
7
+ return String(session ?? 'unknown');
8
+ }
9
+
10
+ export function normalizeAssistantLine(value) {
11
+ if (typeof value !== 'string') return '';
12
+ return value.replace(/\r/gu, '').replace(/\s+/gu, ' ').trim();
13
+ }
14
+
15
+ export function normalizeAssistantBlock(value) {
16
+ if (typeof value !== 'string') return '';
17
+ return value
18
+ .replace(/\r\n?/gu, '\n')
19
+ .split('\n')
20
+ .map((line) => normalizeAssistantLine(line))
21
+ .filter((line) => line !== '')
22
+ .join('\n');
23
+ }
24
+
25
+ export function positiveOutputLimit(value, fallback) {
26
+ return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
27
+ }
28
+
29
+ export function assistantTextFromMessage(message) {
30
+ const content = message?.content;
31
+ if (!Array.isArray(content)) return '';
32
+ return content
33
+ .filter((block) => block !== null
34
+ && typeof block === 'object'
35
+ && block.type === 'text'
36
+ && typeof block.text === 'string')
37
+ .map((block) => block.text)
38
+ .join('');
39
+ }
40
+
41
+ export class AssistantOutputGuardState {
42
+ constructor(config = {}) {
43
+ this.maxRepeatedAssistantLines = positiveOutputLimit(config.maxRepeatedAssistantLines, 5);
44
+ this.maxRepeatedAssistantBlocks = positiveOutputLimit(config.maxRepeatedAssistantBlocks, 5);
45
+ this.maxAssistantBlockChars = positiveOutputLimit(config.maxAssistantBlockChars, 16_384);
46
+ this.bySession = new Map();
47
+ }
48
+
49
+ stateFor(session) {
50
+ const key = sessionKey(session);
51
+ let state = this.bySession.get(key);
52
+ if (state === undefined) {
53
+ state = {
54
+ turn: undefined,
55
+ step: undefined,
56
+ index: undefined,
57
+ pending: '',
58
+ streamedText: '',
59
+ streamedSkipAvailable: false,
60
+ blockText: '',
61
+ blockFinalized: false,
62
+ lastBlock: '',
63
+ blockRepeatCount: 0,
64
+ lastLine: '',
65
+ repeatCount: 0,
66
+ activeTools: new Set(),
67
+ triggered: false,
68
+ seenMessages: new Set(),
69
+ };
70
+ this.bySession.set(key, state);
71
+ }
72
+ return state;
73
+ }
74
+
75
+ beginBlock(session, turn, step, index = 0) {
76
+ const state = this.stateFor(session);
77
+ if (state.turn !== turn || state.step !== step || state.index !== index) {
78
+ state.turn = turn;
79
+ state.step = step;
80
+ state.index = index;
81
+ state.pending = '';
82
+ state.streamedText = '';
83
+ state.streamedSkipAvailable = false;
84
+ state.blockText = '';
85
+ state.blockFinalized = false;
86
+ }
87
+ return state;
88
+ }
89
+
90
+ resetStreak(session) {
91
+ const state = this.stateFor(session);
92
+ state.pending = '';
93
+ state.streamedText = '';
94
+ state.streamedSkipAvailable = false;
95
+ state.blockText = '';
96
+ state.blockFinalized = false;
97
+ state.lastBlock = '';
98
+ state.blockRepeatCount = 0;
99
+ state.lastLine = '';
100
+ state.repeatCount = 0;
101
+ }
102
+
103
+ resetForUser(session) {
104
+ const state = this.stateFor(session);
105
+ state.pending = '';
106
+ state.streamedText = '';
107
+ state.streamedSkipAvailable = false;
108
+ state.blockText = '';
109
+ state.blockFinalized = false;
110
+ state.lastBlock = '';
111
+ state.blockRepeatCount = 0;
112
+ state.lastLine = '';
113
+ state.repeatCount = 0;
114
+ state.triggered = false;
115
+ state.seenMessages.clear();
116
+ }
117
+
118
+ observeLine(session, line) {
119
+ const state = this.stateFor(session);
120
+ const normalized = normalizeAssistantLine(line);
121
+ if (normalized === '' || state.activeTools.size > 0 || state.triggered) return undefined;
122
+ if (normalized === state.lastLine) {
123
+ state.repeatCount += 1;
124
+ } else {
125
+ state.lastLine = normalized;
126
+ state.repeatCount = 1;
127
+ }
128
+ if (state.repeatCount >= this.maxRepeatedAssistantLines) {
129
+ state.triggered = true;
130
+ return {
131
+ count: state.repeatCount,
132
+ line: normalized,
133
+ reason: 'LOOP_GUARD_ASSISTANT_OUTPUT: repeated assistant text detected; generation cancelled and pending inbox cleared.',
134
+ };
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ observeText(session, { turn, step, index = 0, text } = {}) {
140
+ if (typeof text !== 'string' || text.length === 0) return undefined;
141
+ const state = this.beginBlock(session, turn, step, index);
142
+ if (state.activeTools.size > 0 || state.triggered) return undefined;
143
+ state.streamedText += text;
144
+ state.blockText += text;
145
+ if (state.blockText.length > this.maxAssistantBlockChars) {
146
+ state.blockText = state.blockText.slice(-this.maxAssistantBlockChars);
147
+ }
148
+ state.streamedSkipAvailable = true;
149
+ state.pending += text;
150
+ const lines = state.pending.split(/\n/gu);
151
+ state.pending = lines.pop() ?? '';
152
+ for (const line of lines) {
153
+ const hit = this.observeLine(session, line);
154
+ if (hit) return hit;
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ observeBlock(session, block) {
160
+ const state = this.stateFor(session);
161
+ const normalized = normalizeAssistantBlock(block);
162
+ if (normalized === '' || !normalized.includes('\n') || state.activeTools.size > 0 || state.triggered) return undefined;
163
+ if (normalized === state.lastBlock) {
164
+ state.blockRepeatCount += 1;
165
+ } else {
166
+ state.lastBlock = normalized;
167
+ state.blockRepeatCount = 1;
168
+ }
169
+ if (state.blockRepeatCount >= this.maxRepeatedAssistantBlocks) {
170
+ state.triggered = true;
171
+ return {
172
+ count: state.blockRepeatCount,
173
+ block: normalized,
174
+ reason: 'LOOP_GUARD_ASSISTANT_BLOCK: repeated assistant text block detected; generation cancelled and pending inbox cleared.',
175
+ };
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ finalizeBlock(session) {
181
+ const state = this.stateFor(session);
182
+ if (state.blockFinalized) return undefined;
183
+ state.blockFinalized = true;
184
+ return this.observeBlock(session, state.blockText);
185
+ }
186
+
187
+ observeMessage(session, { id, turn, step, index = 0, text } = {}) {
188
+ if (typeof text !== 'string' || text.length === 0) return undefined;
189
+ const state = this.beginBlock(session, turn, step, index);
190
+ const key = id === undefined || id === null
191
+ ? String(turn ?? 'u') + ':' + String(step ?? 's') + ':' + String(text.length)
192
+ : String(id);
193
+ if (state.seenMessages.has(key)) return undefined;
194
+ state.seenMessages.add(key);
195
+ const prior = state.streamedText;
196
+ if (prior === text && state.streamedSkipAvailable) {
197
+ state.streamedSkipAvailable = false;
198
+ return this.finalizeBlock(session);
199
+ }
200
+ const suffix = prior.length > 0 && prior !== text && text.startsWith(prior) ? text.slice(prior.length) : text;
201
+ const hit = this.observeText(session, { turn, step, index, text: suffix });
202
+ state.streamedText = text;
203
+ state.streamedSkipAvailable = false;
204
+ if (hit) return hit;
205
+ return this.finalizeBlock(session);
206
+ }
207
+
208
+ flushPending(session) {
209
+ const state = this.stateFor(session);
210
+ if (state.pending === '') return undefined;
211
+ const pending = state.pending;
212
+ state.pending = '';
213
+ return this.observeLine(session, pending);
214
+ }
215
+
216
+ markToolCall(session, callId) {
217
+ const state = this.stateFor(session);
218
+ if (typeof callId === 'string' && callId.length > 0) state.activeTools.add(callId);
219
+ this.resetStreak(session);
220
+ }
221
+
222
+ markToolResult(session, callId) {
223
+ const state = this.stateFor(session);
224
+ if (typeof callId === 'string' && callId.length > 0) state.activeTools.delete(callId);
225
+ }
226
+
227
+ hasActiveTool(session) {
228
+ return this.stateFor(session).activeTools.size > 0;
229
+ }
230
+
231
+ hasTriggered(session) {
232
+ return this.stateFor(session).triggered;
233
+ }
234
+
235
+ endBlock(session) {
236
+ const hit = this.flushPending(session);
237
+ if (hit) return hit;
238
+ return this.finalizeBlock(session);
239
+ }
240
+
241
+ endStep(session) {
242
+ return this.endBlock(session);
243
+ }
244
+
245
+ endTurn(session) {
246
+ return this.endBlock(session);
247
+ }
248
+
249
+ dispose(session) {
250
+ this.bySession.delete(sessionKey(session));
251
+ }
252
+ }
@@ -0,0 +1,326 @@
1
+ const STOP_REQUEST_PATTERN = /(?:\b(?:stop|halt|cancel|answer(?:\s+now)?)\b|останов(?:ись|ить)|прекрати|хватит|ответь|петл[яиюе])/iu;
2
+ const SECRET_KEY_PATTERN = /(?:token|secret|password|authorization|api[-_]?key|cookie)/iu;
3
+
4
+ function stableJson(value) {
5
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
6
+ if (Array.isArray(value)) return '[' + value.map(stableJson).join(',') + ']';
7
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableJson(value[key])).join(',') + '}';
8
+ }
9
+
10
+ function normalizedJson(value) {
11
+ if (value === null || typeof value !== 'object') {
12
+ if (typeof value === 'string') return JSON.stringify(value.trim().replace(/\s+/gu, ' '));
13
+ return JSON.stringify(value);
14
+ }
15
+ if (Array.isArray(value)) return '[' + value.map(normalizedJson).join(',') + ']';
16
+ return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + normalizedJson(value[key])).join(',') + '}';
17
+ }
18
+
19
+ function textFrom(value) {
20
+ if (!Array.isArray(value)) return '';
21
+ return value.map((block) => {
22
+ if (block === null || typeof block !== 'object') return '';
23
+ if (typeof block.text === 'string') return block.text;
24
+ if (Array.isArray(block.content)) return textFrom(block.content);
25
+ return '';
26
+ }).join('\n');
27
+ }
28
+
29
+ function safeLogValue(value, depth = 0) {
30
+ if (depth > 3) return '[depth-limit]';
31
+ if (typeof value === 'string') {
32
+ return value.replace(/((?:Bearer|token)\s+)[^\s'"]+/giu, '$1[redacted]')
33
+ .replace(/((?:token|secret|password|authorization|api[-_]?key)=)[^\s&]+/giu, '$1[redacted]')
34
+ .slice(0, 400);
35
+ }
36
+ if (value === null || typeof value !== 'object') return value;
37
+ if (Array.isArray(value)) return value.slice(0, 20).map((item) => safeLogValue(item, depth + 1));
38
+ const output = {};
39
+ for (const [key, item] of Object.entries(value).slice(0, 40)) {
40
+ output[key] = SECRET_KEY_PATTERN.test(key) ? '[redacted]' : safeLogValue(item, depth + 1);
41
+ }
42
+ return output;
43
+ }
44
+
45
+ export function positiveInteger(value, fallback) {
46
+ return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
47
+ }
48
+
49
+ export function nonNegativeInteger(value, fallback) {
50
+ return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
51
+ }
52
+
53
+ export function hasStopRequest(messages) {
54
+ if (!Array.isArray(messages)) return false;
55
+ return messages.some((message) => message?.source?.kind === 'user' && STOP_REQUEST_PATTERN.test(textFrom(message.content)));
56
+ }
57
+
58
+ export function callFingerprint(name, args) {
59
+ try { return String(name) + ':' + stableJson(args); }
60
+ catch { return String(name) + ':<unserializable>'; }
61
+ }
62
+
63
+ export function callRepeatFingerprint(name, args) {
64
+ try { return String(name) + ':' + normalizedJson(args); }
65
+ catch { return String(name) + ':<unserializable>'; }
66
+ }
67
+
68
+ export function resultFingerprint(value) {
69
+ try { return stableJson(value); }
70
+ catch { return '<unserializable-result>'; }
71
+ }
72
+
73
+ export function safeCallSummary(name, args) {
74
+ return {
75
+ tool: String(name),
76
+ arguments: safeLogValue(args),
77
+ };
78
+ }
79
+
80
+ function hasOwn(object, key) {
81
+ return object !== null && typeof object === 'object' && Object.prototype.hasOwnProperty.call(object, key);
82
+ }
83
+
84
+ function outcomeFromExecution(execution) {
85
+ const data = execution?.data;
86
+ const hasResult = hasOwn(execution, 'result') || hasOwn(execution, 'output')
87
+ || hasOwn(data, 'result') || hasOwn(data, 'output') || hasOwn(execution, 'error');
88
+ if (!hasResult) return { known: false, successful: false, resultFingerprint: '<unknown>', progressToken: '<unknown>' };
89
+ const value = execution?.result ?? execution?.output ?? data?.result ?? data?.output
90
+ ?? (hasOwn(execution, 'error') ? { error: execution.error } : undefined);
91
+ const failed = execution?.error !== undefined
92
+ || value?.isError === true
93
+ || value?.success === false
94
+ || value?.ok === false
95
+ || value?.error !== undefined;
96
+ const explicitProgress = execution?.progressToken ?? execution?.progress ?? execution?.stateVersion
97
+ ?? data?.progressToken ?? data?.progress ?? data?.stateVersion;
98
+ return {
99
+ known: true,
100
+ successful: !failed,
101
+ resultFingerprint: resultFingerprint(value),
102
+ progressToken: explicitProgress === undefined ? resultFingerprint(value) : resultFingerprint(explicitProgress),
103
+ };
104
+ }
105
+
106
+ export class LoopGuardState {
107
+ constructor(config = {}) {
108
+ const progressToolNames = Array.isArray(config.progressToolNames)
109
+ ? config.progressToolNames
110
+ .filter((name) => typeof name === 'string' && name.trim() !== '')
111
+ .map((name) => name.trim())
112
+ : ['todo_write'];
113
+ this.config = {
114
+ ...config,
115
+ maxToolAttemptsPerTurn: nonNegativeInteger(config.maxToolAttemptsPerTurn, 64),
116
+ maxProgressToolCallsPerTurn: positiveInteger(config.maxProgressToolCallsPerTurn, 16),
117
+ maxCallsPerRepeatGroup: positiveInteger(config.maxCallsPerRepeatGroup, 5),
118
+ };
119
+ this.onViolation = typeof config.onViolation === 'function' ? config.onViolation : undefined;
120
+ this.progressToolNames = new Set(progressToolNames);
121
+ this.byAgent = new Map();
122
+ this.objectKeys = new WeakMap();
123
+ this.nextObjectKey = 1;
124
+ }
125
+
126
+ agentKey(agent) {
127
+ if (agent !== null && typeof agent === 'object') {
128
+ const sessionId = agent.session?.id ?? agent.session?.header?.id ?? agent.id;
129
+ if (typeof sessionId === 'string' && sessionId.length > 0) return 'session:' + sessionId;
130
+ let key = this.objectKeys.get(agent);
131
+ if (key === undefined) {
132
+ key = 'object:' + this.nextObjectKey++;
133
+ this.objectKeys.set(agent, key);
134
+ }
135
+ return key;
136
+ }
137
+ return 'agent:' + String(agent ?? 'unknown');
138
+ }
139
+
140
+ emptyState(turn = undefined) {
141
+ return {
142
+ turn,
143
+ attemptsSinceProgress: 0,
144
+ progressAttemptsSinceProgress: 0,
145
+ calls: [],
146
+ nextCallKey: 1,
147
+ stopRequested: false,
148
+ answerOnly: false,
149
+ stopReason: '',
150
+ progressEpoch: 0,
151
+ repeatGroups: new Map(),
152
+ exactGroups: new Map(),
153
+ lastResults: new Map(),
154
+ lastProgress: undefined,
155
+ lastEvidence: undefined,
156
+ };
157
+ }
158
+
159
+ beginTurn(agent, turn, stopRequested) {
160
+ const key = this.agentKey(agent);
161
+ const prior = this.byAgent.get(key);
162
+ if (prior === undefined || prior.turn !== turn) {
163
+ const next = this.emptyState(turn);
164
+ next.stopRequested = Boolean(stopRequested);
165
+ this.byAgent.set(key, next);
166
+ return;
167
+ }
168
+ if (stopRequested) prior.stopRequested = true;
169
+ }
170
+
171
+ stateFor(agent) {
172
+ const key = this.agentKey(agent);
173
+ let state = this.byAgent.get(key);
174
+ if (state === undefined) {
175
+ state = this.emptyState();
176
+ this.byAgent.set(key, state);
177
+ }
178
+ return state;
179
+ }
180
+
181
+ findCall(state, callId) {
182
+ if (typeof callId === 'string' && callId.length > 0) {
183
+ const exact = [...state.calls].reverse().find((call) => call.id === callId && !call.released);
184
+ if (exact !== undefined) return exact;
185
+ }
186
+ return [...state.calls].reverse().find((call) => !call.released && !call.resultSeen);
187
+ }
188
+
189
+ decrementReservations(state, call) {
190
+ if (!call || !call.counted || call.committed) return;
191
+ state.attemptsSinceProgress = Math.max(0, state.attemptsSinceProgress - 1);
192
+ if (call.isProgressTool) state.progressAttemptsSinceProgress = Math.max(0, state.progressAttemptsSinceProgress - 1);
193
+ }
194
+
195
+ violation(state, agent, name, args, callId, code, detail) {
196
+ state.answerOnly = true;
197
+ state.stopReason = code + ': ' + detail;
198
+ const summary = safeCallSummary(name, args);
199
+ const event = {
200
+ code,
201
+ reason: detail,
202
+ callId: typeof callId === 'string' ? callId : undefined,
203
+ agent: this.agentKey(agent),
204
+ progressEpoch: state.progressEpoch,
205
+ attemptsSinceProgress: state.attemptsSinceProgress,
206
+ progressAttemptsSinceProgress: state.progressAttemptsSinceProgress,
207
+ lastProgress: state.lastProgress,
208
+ ...summary,
209
+ };
210
+ try { this.onViolation?.(event); } catch {}
211
+ return code + ': ' + detail
212
+ + ' No productive progress was observed; provide the final text answer now. The next turn resets this guard.';
213
+ }
214
+
215
+ denyReason(agent, name, args, callId) {
216
+ const state = this.stateFor(agent);
217
+ if (state.stopRequested) {
218
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_STOP', 'user requested stop; tools are disabled for this turn');
219
+ }
220
+ if (state.answerOnly) {
221
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_STOP', state.stopReason || 'previous guard violation requires a text answer');
222
+ }
223
+ const fingerprint = callFingerprint(name, args);
224
+ const repeatFingerprint = callRepeatFingerprint(name, args);
225
+ const repeatGroup = state.repeatGroups.get(repeatFingerprint);
226
+ const exactGroup = state.exactGroups.get(fingerprint);
227
+ const repeatCount = repeatGroup?.progressEpoch === state.progressEpoch ? repeatGroup.count : 0;
228
+ const exactCount = exactGroup?.progressEpoch === state.progressEpoch ? exactGroup.count : 0;
229
+ if (repeatCount >= this.config.maxCallsPerRepeatGroup) {
230
+ if (this.config.blockExactDuplicates && exactCount >= this.config.maxCallsPerRepeatGroup) {
231
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_DUPLICATE', 'identical tool call repeated without a new result or state change after ' + repeatCount + ' attempts');
232
+ }
233
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_REPEAT', 'near-identical tool call repeated without a new result or state change after ' + repeatCount + ' attempts');
234
+ }
235
+ const isProgressTool = this.progressToolNames.has(String(name));
236
+ if (isProgressTool) {
237
+ if (state.progressAttemptsSinceProgress >= this.config.maxProgressToolCallsPerTurn) {
238
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_PROGRESS_LIMIT', 'progress/state tool budget exhausted without productive progress');
239
+ }
240
+ } else if (this.config.maxToolAttemptsPerTurn > 0 && state.attemptsSinceProgress >= this.config.maxToolAttemptsPerTurn) {
241
+ return this.violation(state, agent, name, args, callId, 'LOOP_GUARD_LIMIT', 'tool budget exhausted without productive progress');
242
+ }
243
+ const id = typeof callId === 'string' && callId.length > 0 ? callId : 'anonymous-' + String(state.nextCallKey++);
244
+ const priorRepeat = state.repeatGroups.get(repeatFingerprint);
245
+ const priorExact = state.exactGroups.get(fingerprint);
246
+ state.repeatGroups.set(repeatFingerprint, {
247
+ count: (priorRepeat?.progressEpoch === state.progressEpoch ? priorRepeat.count : 0) + 1,
248
+ progressEpoch: state.progressEpoch,
249
+ });
250
+ state.exactGroups.set(fingerprint, {
251
+ count: (priorExact?.progressEpoch === state.progressEpoch ? priorExact.count : 0) + 1,
252
+ progressEpoch: state.progressEpoch,
253
+ });
254
+ const call = {
255
+ id,
256
+ fingerprint,
257
+ repeatFingerprint,
258
+ isProgressTool,
259
+ pending: true,
260
+ committed: false,
261
+ released: false,
262
+ counted: true,
263
+ resultSeen: false,
264
+ };
265
+ state.calls.push(call);
266
+ state.attemptsSinceProgress += 1;
267
+ if (isProgressTool) state.progressAttemptsSinceProgress += 1;
268
+ return undefined;
269
+ }
270
+
271
+ commitCall(agent, callId) {
272
+ const state = this.stateFor(agent);
273
+ const call = this.findCall(state, callId);
274
+ if (call !== undefined) {
275
+ call.pending = false;
276
+ call.committed = true;
277
+ }
278
+ }
279
+
280
+ recordResult(agent, execution = {}) {
281
+ const state = this.stateFor(agent);
282
+ const call = this.findCall(state, execution.callId);
283
+ if (call === undefined) return { known: false, productive: false };
284
+ if (call.pending && !call.committed) {
285
+ call.released = true;
286
+ this.decrementReservations(state, call);
287
+ return { known: false, productive: false };
288
+ }
289
+ const outcome = outcomeFromExecution(execution);
290
+ call.pending = false;
291
+ call.committed = true;
292
+ call.resultSeen = true;
293
+ call.resultFingerprint = outcome.resultFingerprint;
294
+ call.progressToken = outcome.progressToken;
295
+ const prior = state.lastEvidence;
296
+ const productive = outcome.known && outcome.successful
297
+ && (prior === undefined
298
+ || prior.resultFingerprint !== outcome.resultFingerprint
299
+ || prior.progressToken !== outcome.progressToken);
300
+ call.productive = productive;
301
+ state.lastResults.set(call.repeatFingerprint, outcome);
302
+ if (productive) {
303
+ state.lastEvidence = outcome;
304
+ state.progressEpoch += 1;
305
+ state.attemptsSinceProgress = 0;
306
+ state.progressAttemptsSinceProgress = 0;
307
+ state.repeatGroups.clear();
308
+ state.exactGroups.clear();
309
+ state.lastProgress = {
310
+ callId: call.id,
311
+ tool: call.fingerprint.split(':', 1)[0],
312
+ resultFingerprint: outcome.resultFingerprint,
313
+ progressToken: outcome.progressToken,
314
+ };
315
+ }
316
+ return { known: outcome.known, productive };
317
+ }
318
+
319
+ releaseCall(agent, callId) {
320
+ const state = this.stateFor(agent);
321
+ const call = this.findCall(state, callId);
322
+ if (call === undefined) return;
323
+ call.released = true;
324
+ this.decrementReservations(state, call);
325
+ }
326
+ }
package/lib/index.js ADDED
@@ -0,0 +1,176 @@
1
+ import Schema from '@deepseek-ai/schemastery';
2
+ import {
3
+ LoopGuardState,
4
+ hasStopRequest,
5
+ nonNegativeInteger,
6
+ positiveInteger,
7
+ } from './guard-state.js';
8
+ import { AssistantOutputGuardState, assistantTextFromMessage } from './assistant-output-state.js';
9
+
10
+ export const name = '@goodandready/dsh-agent-loop-guard';
11
+ export const inject = ['tools', 'agents', 'sessions'];
12
+
13
+ export const Config = Schema.object({
14
+ maxToolAttemptsPerTurn: Schema.number().min(0).default(64),
15
+ maxProgressToolCallsPerTurn: Schema.number().min(1).default(16),
16
+ progressToolNames: Schema.array(Schema.string()).default(['todo_write']),
17
+ maxCallsPerRepeatGroup: Schema.number().min(1).default(5),
18
+ maxCallsPerToolPerTurn: Schema.number().min(1).default(5),
19
+ blockExactDuplicates: Schema.boolean().default(true),
20
+ assistantOutputGuard: Schema.boolean().default(true),
21
+ maxRepeatedAssistantLines: Schema.number().min(1).default(5),
22
+ maxRepeatedAssistantBlocks: Schema.number().min(1).default(5),
23
+ maxAssistantBlockChars: Schema.number().min(256).default(16_384),
24
+ });
25
+
26
+ function callIdFromEvent(event) {
27
+ return event?.data?.callId
28
+ ?? event?.data?.message?.source?.callId
29
+ ?? event?.data?.message?.source?.toolCallId;
30
+ }
31
+
32
+ function cancelForOutputLoop(ctx, session, reason) {
33
+ let agent;
34
+ try {
35
+ agent = ctx.agents?.get?.(session.id);
36
+ } catch (error) {
37
+ ctx.logger?.warn?.('dsh-agent-loop-guard: failed to resolve agent for output guard: ' + (error instanceof Error ? error.message : String(error)));
38
+ return;
39
+ }
40
+ if (agent === undefined || typeof agent.cancel !== 'function') return;
41
+ try {
42
+ agent.cancel(new Error(reason), { keepInbox: false });
43
+ } catch (error) {
44
+ ctx.logger?.warn?.('dsh-agent-loop-guard: output-loop cancellation threw: ' + (error instanceof Error ? error.message : String(error)));
45
+ }
46
+ }
47
+
48
+ function cancelOnOutputHit(ctx, session, assistantState, hit) {
49
+ if (!hit || assistantState.hasActiveTool(session.id)) return;
50
+ const code = String(hit.reason).split(':', 1)[0];
51
+ const event = {
52
+ code,
53
+ reason: hit.reason,
54
+ session: session.id,
55
+ count: hit.count,
56
+ outputKind: hit.block === undefined ? 'line' : 'block',
57
+ blockChars: typeof hit.block === 'string' ? hit.block.length : undefined,
58
+ };
59
+ try {
60
+ ctx.logger?.warn?.('dsh-agent-loop-guard: ' + JSON.stringify(event));
61
+ } catch (error) {
62
+ ctx.logger?.warn?.('dsh-agent-loop-guard: ' + code + ' session=' + session.id);
63
+ }
64
+ cancelForOutputLoop(ctx, session, hit.reason);
65
+ }
66
+
67
+ function logGuardViolation(ctx, event) {
68
+ const safeEvent = {
69
+ code: event.code,
70
+ reason: event.reason,
71
+ callId: event.callId,
72
+ agent: event.agent,
73
+ tool: event.tool,
74
+ arguments: event.arguments,
75
+ progressEpoch: event.progressEpoch,
76
+ attemptsSinceProgress: event.attemptsSinceProgress,
77
+ progressAttemptsSinceProgress: event.progressAttemptsSinceProgress,
78
+ lastProgress: event.lastProgress,
79
+ };
80
+ try {
81
+ ctx.logger?.warn?.('dsh-agent-loop-guard: ' + event.code + ' ' + JSON.stringify(safeEvent));
82
+ } catch (error) {
83
+ ctx.logger?.warn?.('dsh-agent-loop-guard: ' + event.code + ' (structured log unavailable)');
84
+ }
85
+ }
86
+
87
+ export function apply(ctx, config = {}) {
88
+ const raw = Config(config) ?? {};
89
+ const maxCallsPerRepeatGroup = positiveInteger(raw.maxCallsPerRepeatGroup ?? raw.maxCallsPerToolPerTurn, 5);
90
+ const state = new LoopGuardState({
91
+ ...raw,
92
+ maxToolAttemptsPerTurn: nonNegativeInteger(raw.maxToolAttemptsPerTurn, 64),
93
+ maxProgressToolCallsPerTurn: positiveInteger(raw.maxProgressToolCallsPerTurn, 16),
94
+ maxCallsPerRepeatGroup,
95
+ onViolation: (event) => logGuardViolation(ctx, event),
96
+ });
97
+ const assistantState = new AssistantOutputGuardState({
98
+ maxRepeatedAssistantLines: positiveInteger(raw.maxRepeatedAssistantLines, 5),
99
+ maxRepeatedAssistantBlocks: positiveInteger(raw.maxRepeatedAssistantBlocks, 5),
100
+ maxAssistantBlockChars: positiveInteger(raw.maxAssistantBlockChars, 16_384),
101
+ });
102
+
103
+ ctx.on('agent/pre-step', async ({ agent, turn, messages, signal }, next) => {
104
+ const decision = await next();
105
+ if (decision.kind === 'reject' || signal.aborted) return decision;
106
+ state.beginTurn(agent, turn, hasStopRequest(messages));
107
+ return decision;
108
+ });
109
+
110
+ ctx.on('tools/execute', async (execution, next) => {
111
+ state.commitCall(execution.agent, execution.callId);
112
+ return next();
113
+ });
114
+
115
+ ctx.on('tools/result', (execution) => {
116
+ state.recordResult(execution.agent, execution);
117
+ });
118
+
119
+ if (raw.assistantOutputGuard !== false) {
120
+ ctx.on('session/event', (session, event) => {
121
+ if (event.type === 'assistant/chunk') {
122
+ const chunk = event.data?.chunk;
123
+ if (chunk?.type === 'text-delta') {
124
+ const hit = assistantState.observeText(session.id, {
125
+ turn: event.data?.turn,
126
+ step: event.data?.step,
127
+ index: chunk.index,
128
+ text: chunk.text,
129
+ });
130
+ cancelOnOutputHit(ctx, session, assistantState, hit);
131
+ } else if (chunk?.type === 'block-end') {
132
+ const hit = assistantState.endBlock(session.id);
133
+ cancelOnOutputHit(ctx, session, assistantState, hit);
134
+ }
135
+ return;
136
+ }
137
+ if (event.type === 'assistant/message') {
138
+ const message = event.data?.message;
139
+ const hit = assistantState.observeMessage(session.id, {
140
+ id: message?.id ?? event.seq,
141
+ turn: event.data?.turn,
142
+ step: event.data?.step,
143
+ index: message?.index ?? 0,
144
+ text: assistantTextFromMessage(message),
145
+ });
146
+ cancelOnOutputHit(ctx, session, assistantState, hit);
147
+ return;
148
+ }
149
+ if (event.type === 'user/message') {
150
+ assistantState.resetForUser(session.id);
151
+ return;
152
+ }
153
+ if (event.type === 'tool/call') {
154
+ assistantState.markToolCall(session.id, callIdFromEvent(event));
155
+ return;
156
+ }
157
+ if (event.type === 'tool/result') {
158
+ assistantState.markToolResult(session.id, callIdFromEvent(event));
159
+ return;
160
+ }
161
+ if (event.type === 'step/end') {
162
+ const hit = assistantState.endStep(session.id);
163
+ cancelOnOutputHit(ctx, session, assistantState, hit);
164
+ return;
165
+ }
166
+ if (event.type === 'turn/end') {
167
+ const hit = assistantState.endTurn(session.id);
168
+ cancelOnOutputHit(ctx, session, assistantState, hit);
169
+ return;
170
+ }
171
+ });
172
+ ctx.on('session/disposed', (session) => assistantState.dispose(session.id));
173
+ }
174
+
175
+ return ctx.tools.guard((execution) => state.denyReason(execution.agent, execution.name, execution.arguments, execution.callId));
176
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@goodandready/dsh-agent-loop-guard",
3
+ "version": "0.2.2",
4
+ "description": "Fail-closed runtime tool-call loop guard for DeepSeek Harness.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json",
10
+ "./cordis.patch.yml": "./cordis.patch.yml"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "README.ru.md",
17
+ "README.zh.md",
18
+ "docs",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test",
23
+ "check": "node --check lib/index.js && node --check lib/guard-state.js && node --check lib/assistant-output-state.js"
24
+ },
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ }
29
+ },
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "@deepseek-ai/cordis": "^4.0.1",
33
+ "@deepseek-ai/schemastery": "^3.18.1"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/GooDAnDReaDY/dsh-agent-loop-guard.git"
38
+ },
39
+ "homepage": "https://github.com/GooDAnDReaDY/dsh-agent-loop-guard",
40
+ "bugs": {
41
+ "url": "https://github.com/GooDAnDReaDY/dsh-agent-loop-guard/issues"
42
+ }
43
+ }