@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 +3 -0
- package/README.md +46 -0
- package/README.ru.md +46 -0
- package/README.zh.md +46 -0
- package/cordis.patch.yml +15 -0
- package/docs/architecture/baseline.md +37 -0
- package/docs/research/reuse-first.md +17 -0
- package/docs/testing/test-plan.md +21 -0
- package/lib/assistant-output-state.js +252 -0
- package/lib/guard-state.js +326 -0
- package/lib/index.js +176 -0
- package/package.json +43 -0
package/LICENSE
ADDED
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.
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|