@goodandready/dsh-agent-loop-guard 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/README.ru.md +51 -0
- package/README.zh.md +51 -0
- package/cordis.patch.yml +2 -12
- package/docs/README.ru.md +16 -6
- package/docs/README.zh.md +16 -6
- package/lib/assistant-output-state.js +12 -0
- package/lib/client.js +206 -0
- package/lib/guard-state.js +24 -2
- package/lib/index.js +45 -10
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -176,3 +176,12 @@ npm run check
|
|
|
176
176
|
## 📄 License
|
|
177
177
|
|
|
178
178
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
## Changed in v0.2.4
|
|
182
|
+
|
|
183
|
+
- **Settings UI**: allow saving `0` for `maxToolAttemptsPerTurn` to cleanly disable aggregate turn budget (#28).
|
|
184
|
+
- **Outcome Analysis**: recognize `{ error: null }` and `{ error: false }` as non-failures in execution outcome evaluation (#28).
|
|
185
|
+
- **Alternating Loop Breaker**: track prior results per tool fingerprint (`state.lastResults`) to block alternating non-productive loops (A ➔ B ➔ A ➔ B) (#28).
|
|
186
|
+
- **Settings Card**: add reactive `settings.plugin.item` card in DSH Settings with live configuration updates (#26).
|
|
187
|
+
- **Test Suite**: expanded to 31 automated tests covering error edge cases and alternating loop enforcement (#28).
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Changed in v0.2.4
|
|
50
|
+
|
|
51
|
+
#26: `settings.register` + Settings → Plugins card; live config updates. Patch uses `config: {}` (schema defaults). #2: add reviewed `package-lock.json`.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Changed in v0.2.4
|
|
50
|
+
|
|
51
|
+
#26: `settings.register` + Settings → Plugins card; live config. Patch `config: {}`. #2/#21: reviewed `package-lock.json`.
|
package/cordis.patch.yml
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
# Host
|
|
1
|
+
# Host + optional settings UI. Defaults live in Config schema / Settings card.
|
|
2
2
|
- insert:
|
|
3
3
|
- id: '@goodandready/dsh-agent-loop-guard'
|
|
4
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
|
|
5
|
+
config: {}
|
package/docs/README.ru.md
CHANGED
|
@@ -155,16 +155,26 @@ dsh-agent-loop-guard:
|
|
|
155
155
|
---
|
|
156
156
|
|
|
157
157
|
## 🧪 Тестирование
|
|
158
|
-
|
|
159
|
-
Запуск набора из
|
|
160
|
-
|
|
158
|
+
|
|
159
|
+
Запуск набора из 31 автоматизированного теста и статической проверки типов:
|
|
160
|
+
|
|
161
161
|
```bash
|
|
162
162
|
npm test
|
|
163
163
|
npm run check
|
|
164
164
|
```
|
|
165
|
-
|
|
165
|
+
|
|
166
166
|
---
|
|
167
|
-
|
|
167
|
+
|
|
168
168
|
## 📄 Лицензия
|
|
169
|
-
|
|
169
|
+
|
|
170
170
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Изменения в версии v0.2.4
|
|
175
|
+
|
|
176
|
+
- **Интерфейс настроек**: исправлено сохранение `0` для `maxToolAttemptsPerTurn` — теперь общий лимит попыток можно отключить через карточку плагина (#28).
|
|
177
|
+
- **Анализ результатов**: возвращаемые объектами поля `{ error: null }` и `{ error: false }` больше не считаются ошибками и не блокируют продвижение эпохи (#28).
|
|
178
|
+
- **Защита от чередующихся циклов**: добавлено отслеживание результатов по каждому инструменту (`state.lastResults`), исключающее зацикливание агента вида A ➔ B ➔ A ➔ B без изменения состояния (#28).
|
|
179
|
+
- **Карточка настроек**: внедрена стандартная карточка `settings.plugin.item` с динамическим обновлением параметров на лету (#26).
|
|
180
|
+
- **Набор тестов**: расширен до 31 автоматизированного теста.
|
package/docs/README.zh.md
CHANGED
|
@@ -153,16 +153,26 @@ dsh-agent-loop-guard:
|
|
|
153
153
|
---
|
|
154
154
|
|
|
155
155
|
## 🧪 测试与校验
|
|
156
|
-
|
|
157
|
-
运行全部
|
|
158
|
-
|
|
156
|
+
|
|
157
|
+
运行全部 31 个单元测试及静态代码检查:
|
|
158
|
+
|
|
159
159
|
```bash
|
|
160
160
|
npm test
|
|
161
161
|
npm run check
|
|
162
162
|
```
|
|
163
|
-
|
|
163
|
+
|
|
164
164
|
---
|
|
165
|
-
|
|
165
|
+
|
|
166
166
|
## 📄 开源许可证
|
|
167
|
-
|
|
167
|
+
|
|
168
168
|
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## v0.2.4 更新日志
|
|
173
|
+
|
|
174
|
+
- **设置界面**:修复 `maxToolAttemptsPerTurn` 保存为 `0` 的问题,允许正常禁用单回合工具调用上限 (#28)。
|
|
175
|
+
- **结果解析**:对象中的 `{ error: null }` 与 `{ error: false }` 正确识别为非错误,不再阻碍进展推进 (#28)。
|
|
176
|
+
- **交替循环拦截**:通过 `state.lastResults` 跟踪每个工具的先前结果,有效拦截无实质进展的交替循环调用 (A ➔ B ➔ A ➔ B) (#28)。
|
|
177
|
+
- **设置卡片**:集成标准 `settings.plugin.item` 配置卡片,支持动态热重载 (#26)。
|
|
178
|
+
- **测试用例**:自动化测试扩展至 31 个测试。
|
|
@@ -46,6 +46,18 @@ export class AssistantOutputGuardState {
|
|
|
46
46
|
this.bySession = new Map();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
updateConfig(config = {}) {
|
|
50
|
+
if (config.maxRepeatedAssistantLines !== undefined) {
|
|
51
|
+
this.maxRepeatedAssistantLines = positiveOutputLimit(config.maxRepeatedAssistantLines, this.maxRepeatedAssistantLines);
|
|
52
|
+
}
|
|
53
|
+
if (config.maxRepeatedAssistantBlocks !== undefined) {
|
|
54
|
+
this.maxRepeatedAssistantBlocks = positiveOutputLimit(config.maxRepeatedAssistantBlocks, this.maxRepeatedAssistantBlocks);
|
|
55
|
+
}
|
|
56
|
+
if (config.maxAssistantBlockChars !== undefined) {
|
|
57
|
+
this.maxAssistantBlockChars = positiveOutputLimit(config.maxAssistantBlockChars, this.maxAssistantBlockChars);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
stateFor(session) {
|
|
50
62
|
const key = sessionKey(session);
|
|
51
63
|
let state = this.bySession.get(key);
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@goodandready/dsh-agent-loop-guard',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
const React = require('react');
|
|
6
|
+
const NS = '@goodandready/dsh-agent-loop-guard';
|
|
7
|
+
|
|
8
|
+
const en = {
|
|
9
|
+
title: 'Agent Loop Guard',
|
|
10
|
+
sub: 'Fail-closed tool-call and assistant-output loop limits',
|
|
11
|
+
maxToolAttemptsPerTurn: 'Max tool attempts per turn',
|
|
12
|
+
maxProgressToolCallsPerTurn: 'Max progress tool calls per turn',
|
|
13
|
+
maxCallsPerRepeatGroup: 'Max calls per repeat group',
|
|
14
|
+
blockExactDuplicates: 'Block exact duplicate tool calls',
|
|
15
|
+
assistantOutputGuard: 'Assistant output guard',
|
|
16
|
+
maxRepeatedAssistantLines: 'Max repeated assistant lines',
|
|
17
|
+
maxRepeatedAssistantBlocks: 'Max repeated assistant blocks',
|
|
18
|
+
maxAssistantBlockChars: 'Max assistant block chars',
|
|
19
|
+
progressToolNames: 'Progress tool names (comma-separated)',
|
|
20
|
+
saving: 'Saving…',
|
|
21
|
+
ready: 'Save',
|
|
22
|
+
saved: 'Saved',
|
|
23
|
+
};
|
|
24
|
+
const ru = {
|
|
25
|
+
title: 'Agent Loop Guard',
|
|
26
|
+
sub: 'Жёсткие лимиты на петли tool-call и повторы ответа ассистента',
|
|
27
|
+
maxToolAttemptsPerTurn: 'Макс. попыток tool за ход',
|
|
28
|
+
maxProgressToolCallsPerTurn: 'Макс. progress-tool за ход',
|
|
29
|
+
maxCallsPerRepeatGroup: 'Макс. вызовов в repeat-группе',
|
|
30
|
+
blockExactDuplicates: 'Блокировать точные дубликаты tool-call',
|
|
31
|
+
assistantOutputGuard: 'Охрана повторов ответа ассистента',
|
|
32
|
+
maxRepeatedAssistantLines: 'Макс. повторов строк',
|
|
33
|
+
maxRepeatedAssistantBlocks: 'Макс. повторов блоков',
|
|
34
|
+
maxAssistantBlockChars: 'Макс. символов блока',
|
|
35
|
+
progressToolNames: 'Progress-инструменты (через запятую)',
|
|
36
|
+
saving: 'Сохранение…',
|
|
37
|
+
ready: 'Сохранить',
|
|
38
|
+
saved: 'Сохранено',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DEFAULTS = {
|
|
42
|
+
maxToolAttemptsPerTurn: 64,
|
|
43
|
+
maxProgressToolCallsPerTurn: 16,
|
|
44
|
+
progressToolNames: ['todo_write'],
|
|
45
|
+
maxCallsPerRepeatGroup: 5,
|
|
46
|
+
blockExactDuplicates: true,
|
|
47
|
+
assistantOutputGuard: true,
|
|
48
|
+
maxRepeatedAssistantLines: 5,
|
|
49
|
+
maxRepeatedAssistantBlocks: 5,
|
|
50
|
+
maxAssistantBlockChars: 16384,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function PluginCard({ ctx: _ctx, t }) {
|
|
54
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
55
|
+
const [draft, setDraft] = React.useState({ ...DEFAULTS, progressToolNamesText: 'todo_write' });
|
|
56
|
+
const [status, setStatus] = React.useState('loading');
|
|
57
|
+
const [saving, setSaving] = React.useState(false);
|
|
58
|
+
const [msg, setMsg] = React.useState('');
|
|
59
|
+
const scopeRef = React.useRef(null);
|
|
60
|
+
if (!scopeRef.current && _ctx && _ctx.settingsScope) {
|
|
61
|
+
try { scopeRef.current = _ctx.settingsScope.bind({ namespace: NS }); } catch (e) { scopeRef.current = null; }
|
|
62
|
+
}
|
|
63
|
+
const scope = scopeRef.current;
|
|
64
|
+
const tt = t || ((k) => en[k] || k);
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
if (!scope) { setStatus('unavailable'); return; }
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
(async () => {
|
|
70
|
+
try {
|
|
71
|
+
const snap = await scope.get();
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
if (snap && typeof snap === 'object' && 'status' in snap) {
|
|
74
|
+
if (snap.status === 'loading') { setStatus('loading'); return; }
|
|
75
|
+
if (snap.status === 'unavailable') { setStatus('unavailable'); return; }
|
|
76
|
+
}
|
|
77
|
+
const vals = snap && snap.values ? snap.values : snap;
|
|
78
|
+
if (vals && typeof vals === 'object') {
|
|
79
|
+
const names = Array.isArray(vals.progressToolNames) ? vals.progressToolNames : DEFAULTS.progressToolNames;
|
|
80
|
+
setDraft((d) => ({ ...d, ...DEFAULTS, ...vals, progressToolNamesText: names.join(', ') }));
|
|
81
|
+
}
|
|
82
|
+
setStatus('ready');
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (!cancelled) setStatus('unavailable');
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
return () => { cancelled = true; };
|
|
88
|
+
}, [scope]);
|
|
89
|
+
|
|
90
|
+
async function onSave() {
|
|
91
|
+
if (!scope) { setMsg('Settings unavailable'); return; }
|
|
92
|
+
setSaving(true); setMsg('');
|
|
93
|
+
const names = String(draft.progressToolNamesText || '')
|
|
94
|
+
.split(',')
|
|
95
|
+
.map((s) => s.trim())
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
const maxToolAttempts = Number(draft.maxToolAttemptsPerTurn);
|
|
98
|
+
const payload = {
|
|
99
|
+
maxToolAttemptsPerTurn: Number.isFinite(maxToolAttempts) && maxToolAttempts >= 0 ? maxToolAttempts : 64,
|
|
100
|
+
maxProgressToolCallsPerTurn: Number(draft.maxProgressToolCallsPerTurn) || 16,
|
|
101
|
+
maxCallsPerRepeatGroup: Number(draft.maxCallsPerRepeatGroup) || 5,
|
|
102
|
+
blockExactDuplicates: !!draft.blockExactDuplicates,
|
|
103
|
+
assistantOutputGuard: !!draft.assistantOutputGuard,
|
|
104
|
+
maxRepeatedAssistantLines: Number(draft.maxRepeatedAssistantLines) || 5,
|
|
105
|
+
maxRepeatedAssistantBlocks: Number(draft.maxRepeatedAssistantBlocks) || 5,
|
|
106
|
+
maxAssistantBlockChars: Number(draft.maxAssistantBlockChars) || 16384,
|
|
107
|
+
progressToolNames: names.length ? names : ['todo_write'],
|
|
108
|
+
};
|
|
109
|
+
const errs = [];
|
|
110
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
111
|
+
try { await scope.set(k, v); } catch (e) { errs.push(k + ': ' + (e && e.message || String(e))); }
|
|
112
|
+
}
|
|
113
|
+
setSaving(false);
|
|
114
|
+
setMsg(errs.length ? errs.join('; ') : tt('saved'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const field = (key, input) => React.createElement('div', {
|
|
118
|
+
className: 'alg-field',
|
|
119
|
+
style: { display: 'flex', flexDirection: 'column', gap: 6, padding: '10px 0' }
|
|
120
|
+
},
|
|
121
|
+
React.createElement('label', { style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, tt(key)),
|
|
122
|
+
input
|
|
123
|
+
);
|
|
124
|
+
const num = (key) => React.createElement('input', {
|
|
125
|
+
type: 'number', value: draft[key],
|
|
126
|
+
onChange: (e) => setDraft((d) => ({ ...d, [key]: e.target.value })),
|
|
127
|
+
style: { height: 34, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', borderRadius: 8, padding: '0 12px', fontSize: 13 }
|
|
128
|
+
});
|
|
129
|
+
const check = (key) => React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
|
|
130
|
+
React.createElement('input', {
|
|
131
|
+
type: 'checkbox', checked: !!draft[key],
|
|
132
|
+
onChange: (e) => setDraft((d) => ({ ...d, [key]: e.target.checked }))
|
|
133
|
+
}),
|
|
134
|
+
tt(key)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return React.createElement('li', {
|
|
138
|
+
className: 'alg-card',
|
|
139
|
+
style: { border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', borderRadius: 12, listStyle: 'none' }
|
|
140
|
+
},
|
|
141
|
+
React.createElement('button', {
|
|
142
|
+
onClick: () => setExpanded(!expanded),
|
|
143
|
+
'aria-expanded': expanded,
|
|
144
|
+
style: { appearance: 'none', width: '100%', font: 'inherit', color: 'inherit', textAlign: 'left', cursor: 'pointer', background: '0 0', border: 0, borderRadius: 12, display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px' }
|
|
145
|
+
},
|
|
146
|
+
React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
|
|
147
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-primary)', fontSize: 15, fontWeight: 600 } }, tt('title')),
|
|
148
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } }, tt('sub'))
|
|
149
|
+
),
|
|
150
|
+
React.createElement('span', { style: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)' } }, expanded ? '▲' : '▼')
|
|
151
|
+
),
|
|
152
|
+
expanded ? React.createElement('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2)', margin: '0 16px', paddingBottom: 8 } },
|
|
153
|
+
status === 'loading' ? React.createElement('div', { style: { padding: 12 } }, 'Loading…') :
|
|
154
|
+
status === 'unavailable' ? React.createElement('div', { style: { padding: 12 } }, 'Settings unavailable') :
|
|
155
|
+
React.createElement(React.Fragment, null,
|
|
156
|
+
field('maxToolAttemptsPerTurn', num('maxToolAttemptsPerTurn')),
|
|
157
|
+
field('maxProgressToolCallsPerTurn', num('maxProgressToolCallsPerTurn')),
|
|
158
|
+
field('maxCallsPerRepeatGroup', num('maxCallsPerRepeatGroup')),
|
|
159
|
+
field('progressToolNames', React.createElement('input', {
|
|
160
|
+
type: 'text', value: draft.progressToolNamesText || '',
|
|
161
|
+
onChange: (e) => setDraft((d) => ({ ...d, progressToolNamesText: e.target.value })),
|
|
162
|
+
style: { height: 34, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-3)', color: 'var(--dsw-alias-label-primary)', borderRadius: 8, padding: '0 12px', fontSize: 13 }
|
|
163
|
+
})),
|
|
164
|
+
React.createElement('div', { style: { padding: '8px 0' } }, check('blockExactDuplicates')),
|
|
165
|
+
React.createElement('div', { style: { padding: '8px 0' } }, check('assistantOutputGuard')),
|
|
166
|
+
field('maxRepeatedAssistantLines', num('maxRepeatedAssistantLines')),
|
|
167
|
+
field('maxRepeatedAssistantBlocks', num('maxRepeatedAssistantBlocks')),
|
|
168
|
+
field('maxAssistantBlockChars', num('maxAssistantBlockChars')),
|
|
169
|
+
msg ? React.createElement('div', { style: { fontSize: 12, color: msg === tt('saved') ? 'var(--dsw-alias-label-secondary)' : '#d73a4a', padding: '4px 0' } }, msg) : null,
|
|
170
|
+
React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', padding: '12px 0 4px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
171
|
+
React.createElement('button', {
|
|
172
|
+
onClick: onSave, disabled: saving,
|
|
173
|
+
style: { appearance: 'none', font: 'inherit', cursor: 'pointer', border: '1px solid transparent', borderRadius: 8, padding: '5px 14px', fontSize: 13, background: 'var(--dsw-alias-label-primary)', color: 'var(--dsw-alias-bg-layer-3)', opacity: saving ? 0.6 : 1 }
|
|
174
|
+
}, saving ? tt('saving') : tt('ready'))
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
) : null
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports.inject = ['slots', 'locale'];
|
|
182
|
+
module.exports.apply = function apply(ctx) {
|
|
183
|
+
try { ctx.locale.register(NS, { en, ru }); } catch (e) {}
|
|
184
|
+
if (!ctx.slots) return;
|
|
185
|
+
const register = () => {
|
|
186
|
+
try {
|
|
187
|
+
return ctx.slots.register({
|
|
188
|
+
name: 'settings.plugin.item',
|
|
189
|
+
key: NS,
|
|
190
|
+
locale: NS,
|
|
191
|
+
inject: () => ({ ctx }),
|
|
192
|
+
}, PluginCard);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.warn('[dsh-agent-loop-guard] settings.plugin.item register failed', e && e.message || e);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
if (typeof ctx.slots.inject === 'function') {
|
|
198
|
+
try { ctx.slots.inject('settings.plugin.item', register); }
|
|
199
|
+
catch (e) { try { register(); } catch (e2) {} }
|
|
200
|
+
} else {
|
|
201
|
+
register();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
return module.exports;
|
|
205
|
+
},
|
|
206
|
+
});
|
package/lib/guard-state.js
CHANGED
|
@@ -88,11 +88,11 @@ function outcomeFromExecution(execution) {
|
|
|
88
88
|
if (!hasResult) return { known: false, successful: false, resultFingerprint: '<unknown>', progressToken: '<unknown>' };
|
|
89
89
|
const value = execution?.result ?? execution?.output ?? data?.result ?? data?.output
|
|
90
90
|
?? (hasOwn(execution, 'error') ? { error: execution.error } : undefined);
|
|
91
|
-
const failed = execution?.error !==
|
|
91
|
+
const failed = (execution?.error != null && execution?.error !== false)
|
|
92
92
|
|| value?.isError === true
|
|
93
93
|
|| value?.success === false
|
|
94
94
|
|| value?.ok === false
|
|
95
|
-
|| value?.error !==
|
|
95
|
+
|| (value?.error != null && value?.error !== false);
|
|
96
96
|
const explicitProgress = execution?.progressToken ?? execution?.progress ?? execution?.stateVersion
|
|
97
97
|
?? data?.progressToken ?? data?.progress ?? data?.stateVersion;
|
|
98
98
|
return {
|
|
@@ -123,6 +123,23 @@ export class LoopGuardState {
|
|
|
123
123
|
this.nextObjectKey = 1;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
updateConfig(config = {}) {
|
|
127
|
+
const progressToolNames = Array.isArray(config.progressToolNames)
|
|
128
|
+
? config.progressToolNames
|
|
129
|
+
.filter((name) => typeof name === 'string' && name.trim() !== '')
|
|
130
|
+
.map((name) => name.trim())
|
|
131
|
+
: ['todo_write'];
|
|
132
|
+
this.config = {
|
|
133
|
+
...this.config,
|
|
134
|
+
...config,
|
|
135
|
+
maxToolAttemptsPerTurn: nonNegativeInteger(config.maxToolAttemptsPerTurn, this.config.maxToolAttemptsPerTurn ?? 64),
|
|
136
|
+
maxProgressToolCallsPerTurn: positiveInteger(config.maxProgressToolCallsPerTurn, this.config.maxProgressToolCallsPerTurn ?? 16),
|
|
137
|
+
maxCallsPerRepeatGroup: positiveInteger(config.maxCallsPerRepeatGroup ?? config.maxCallsPerToolPerTurn, this.config.maxCallsPerRepeatGroup ?? 5),
|
|
138
|
+
blockExactDuplicates: config.blockExactDuplicates !== undefined ? Boolean(config.blockExactDuplicates) : this.config.blockExactDuplicates,
|
|
139
|
+
};
|
|
140
|
+
this.progressToolNames = new Set(progressToolNames);
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
agentKey(agent) {
|
|
127
144
|
if (agent !== null && typeof agent === 'object') {
|
|
128
145
|
const sessionId = agent.session?.id ?? agent.session?.header?.id ?? agent.id;
|
|
@@ -293,7 +310,12 @@ export class LoopGuardState {
|
|
|
293
310
|
call.resultFingerprint = outcome.resultFingerprint;
|
|
294
311
|
call.progressToken = outcome.progressToken;
|
|
295
312
|
const prior = state.lastEvidence;
|
|
313
|
+
const priorForCall = state.lastResults.get(call.repeatFingerprint);
|
|
314
|
+
const resultUnchangedForCall = priorForCall !== undefined
|
|
315
|
+
&& priorForCall.resultFingerprint === outcome.resultFingerprint
|
|
316
|
+
&& priorForCall.progressToken === outcome.progressToken;
|
|
296
317
|
const productive = outcome.known && outcome.successful
|
|
318
|
+
&& !resultUnchangedForCall
|
|
297
319
|
&& (prior === undefined
|
|
298
320
|
|| prior.resultFingerprint !== outcome.resultFingerprint
|
|
299
321
|
|| prior.progressToken !== outcome.progressToken);
|
package/lib/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { AssistantOutputGuardState, assistantTextFromMessage } from './assistant-output-state.js';
|
|
9
9
|
|
|
10
10
|
export const name = '@goodandready/dsh-agent-loop-guard';
|
|
11
|
-
export const inject = ['tools', 'agents', 'sessions'];
|
|
11
|
+
export const inject = ['tools', 'agents', 'sessions', 'settings'];
|
|
12
12
|
|
|
13
13
|
export const Config = Schema.object({
|
|
14
14
|
maxToolAttemptsPerTurn: Schema.number().min(0).default(64),
|
|
@@ -85,19 +85,53 @@ function logGuardViolation(ctx, event) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
export function apply(ctx, config = {}) {
|
|
88
|
-
const
|
|
89
|
-
|
|
88
|
+
const NS = name;
|
|
89
|
+
let getConfig = () => Config(config) ?? {};
|
|
90
|
+
|
|
91
|
+
const syncFrom = (raw) => {
|
|
92
|
+
const maxCallsPerRepeatGroup = positiveInteger(raw.maxCallsPerRepeatGroup ?? raw.maxCallsPerToolPerTurn, 5);
|
|
93
|
+
return {
|
|
94
|
+
...raw,
|
|
95
|
+
maxToolAttemptsPerTurn: nonNegativeInteger(raw.maxToolAttemptsPerTurn, 64),
|
|
96
|
+
maxProgressToolCallsPerTurn: positiveInteger(raw.maxProgressToolCallsPerTurn, 16),
|
|
97
|
+
maxCallsPerRepeatGroup,
|
|
98
|
+
assistantOutputGuard: raw.assistantOutputGuard !== false,
|
|
99
|
+
maxRepeatedAssistantLines: positiveInteger(raw.maxRepeatedAssistantLines, 5),
|
|
100
|
+
maxRepeatedAssistantBlocks: positiveInteger(raw.maxRepeatedAssistantBlocks, 5),
|
|
101
|
+
maxAssistantBlockChars: positiveInteger(raw.maxAssistantBlockChars, 16_384),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let raw = syncFrom(getConfig());
|
|
90
106
|
const state = new LoopGuardState({
|
|
91
107
|
...raw,
|
|
92
|
-
maxToolAttemptsPerTurn: nonNegativeInteger(raw.maxToolAttemptsPerTurn, 64),
|
|
93
|
-
maxProgressToolCallsPerTurn: positiveInteger(raw.maxProgressToolCallsPerTurn, 16),
|
|
94
|
-
maxCallsPerRepeatGroup,
|
|
95
108
|
onViolation: (event) => logGuardViolation(ctx, event),
|
|
96
109
|
});
|
|
97
110
|
const assistantState = new AssistantOutputGuardState({
|
|
98
|
-
maxRepeatedAssistantLines:
|
|
99
|
-
maxRepeatedAssistantBlocks:
|
|
100
|
-
maxAssistantBlockChars:
|
|
111
|
+
maxRepeatedAssistantLines: raw.maxRepeatedAssistantLines,
|
|
112
|
+
maxRepeatedAssistantBlocks: raw.maxRepeatedAssistantBlocks,
|
|
113
|
+
maxAssistantBlockChars: raw.maxAssistantBlockChars,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const applyLiveConfig = () => {
|
|
117
|
+
raw = syncFrom(getConfig());
|
|
118
|
+
state.updateConfig(raw);
|
|
119
|
+
if (typeof assistantState.updateConfig === 'function') assistantState.updateConfig(raw);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
ctx.inject(['settings'], (sctx) => {
|
|
123
|
+
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
124
|
+
getConfig = () => Config(scope.get() ?? config) ?? {};
|
|
125
|
+
applyLiveConfig();
|
|
126
|
+
try {
|
|
127
|
+
if (typeof scope.watch === 'function') {
|
|
128
|
+
sctx.effect(() => scope.watch(() => applyLiveConfig()));
|
|
129
|
+
} else if (typeof scope.subscribe === 'function') {
|
|
130
|
+
sctx.effect(() => scope.subscribe(() => applyLiveConfig()));
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
ctx.logger?.warn?.('dsh-agent-loop-guard: settings watch failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
134
|
+
}
|
|
101
135
|
});
|
|
102
136
|
|
|
103
137
|
ctx.on('agent/pre-step', async ({ agent, turn, messages, signal }, next) => {
|
|
@@ -116,8 +150,9 @@ export function apply(ctx, config = {}) {
|
|
|
116
150
|
state.recordResult(execution.agent, execution);
|
|
117
151
|
});
|
|
118
152
|
|
|
119
|
-
if (
|
|
153
|
+
if (true) {
|
|
120
154
|
ctx.on('session/event', (session, event) => {
|
|
155
|
+
if (raw.assistantOutputGuard === false) return;
|
|
121
156
|
if (event.type === 'assistant/chunk') {
|
|
122
157
|
const chunk = event.data?.chunk;
|
|
123
158
|
if (chunk?.type === 'text-delta') {
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-agent-loop-guard",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Fail-closed runtime tool-call loop guard for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
9
10
|
"./package.json": "./package.json",
|
|
10
11
|
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
11
12
|
},
|
|
@@ -13,6 +14,8 @@
|
|
|
13
14
|
"lib",
|
|
14
15
|
"cordis.patch.yml",
|
|
15
16
|
"README.md",
|
|
17
|
+
"README.ru.md",
|
|
18
|
+
"README.zh.md",
|
|
16
19
|
"docs",
|
|
17
20
|
"LICENSE"
|
|
18
21
|
],
|
|
@@ -23,12 +26,20 @@
|
|
|
23
26
|
"dsh": {
|
|
24
27
|
"bundle": {
|
|
25
28
|
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"inject": [
|
|
33
|
+
"@deepseek-ai/dsh-client-locale",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
35
|
+
]
|
|
26
36
|
}
|
|
27
37
|
},
|
|
28
38
|
"license": "MIT",
|
|
29
39
|
"peerDependencies": {
|
|
30
40
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
31
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
41
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
42
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6"
|
|
32
43
|
},
|
|
33
44
|
"repository": {
|
|
34
45
|
"type": "git",
|