@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 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-only DSH bundle. No dsh.client entry exists because this package has no browser module.
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
- Запуск набора из 26 автоматизированных тестов и статической проверки типов:
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
- 运行全部 26 个单元测试及静态代码检查:
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
+ });
@@ -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 !== undefined
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 !== undefined;
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 raw = Config(config) ?? {};
89
- const maxCallsPerRepeatGroup = positiveInteger(raw.maxCallsPerRepeatGroup ?? raw.maxCallsPerToolPerTurn, 5);
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: positiveInteger(raw.maxRepeatedAssistantLines, 5),
99
- maxRepeatedAssistantBlocks: positiveInteger(raw.maxRepeatedAssistantBlocks, 5),
100
- maxAssistantBlockChars: positiveInteger(raw.maxAssistantBlockChars, 16_384),
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 (raw.assistantOutputGuard !== false) {
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",
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",