@moxxy/sdk 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/event-store.d.ts +113 -0
- package/dist/event-store.d.ts.map +1 -0
- package/dist/event-store.js +2 -0
- package/dist/event-store.js.map +1 -0
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -0
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/stuck-loop.d.ts +20 -6
- package/dist/mode/stuck-loop.d.ts.map +1 -1
- package/dist/mode/stuck-loop.js +14 -3
- package/dist/mode/stuck-loop.js.map +1 -1
- package/dist/mode-helpers.d.ts +1 -1
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +41 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +11 -0
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/services.d.ts +47 -0
- package/dist/services.d.ts.map +1 -0
- package/dist/services.js +2 -0
- package/dist/services.js.map +1 -0
- package/dist/session-like.d.ts +43 -2
- package/dist/session-like.d.ts.map +1 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +1 -0
- package/dist/tool-dispatch.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +1 -0
- package/src/event-store.ts +118 -0
- package/src/events.ts +1 -0
- package/src/hooks.ts +6 -0
- package/src/index.ts +16 -1
- package/src/mode/collect-stream.ts +1 -0
- package/src/mode/stuck-loop.ts +28 -11
- package/src/mode-helpers.ts +1 -0
- package/src/mode.test.ts +20 -1
- package/src/mode.ts +43 -0
- package/src/plugin.ts +10 -1
- package/src/services.ts +47 -0
- package/src/session-like.ts +45 -2
- package/src/stuck-loop.test.ts +16 -3
- package/src/tool-dispatch.ts +1 -0
package/src/mode/stuck-loop.ts
CHANGED
|
@@ -34,6 +34,27 @@ export interface StuckLoopDetector {
|
|
|
34
34
|
record(toolName: string, input: unknown): StuckSignal;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* User-tunable loop-guard settings (config `context.loopGuard`). All optional —
|
|
39
|
+
* omitted fields use the defaults below. Set `enabled: false` to turn the guard
|
|
40
|
+
* off entirely and rely solely on the mode's `maxIterations` cap.
|
|
41
|
+
*/
|
|
42
|
+
export interface LoopGuardSettings {
|
|
43
|
+
readonly enabled?: boolean;
|
|
44
|
+
readonly windowSize?: number;
|
|
45
|
+
readonly repeatThreshold?: number;
|
|
46
|
+
readonly nearWindowSize?: number;
|
|
47
|
+
readonly nearThreshold?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Default exact-repeat window + trip count. Deliberately generous: the
|
|
51
|
+
* `maxIterations` cap (500 in default mode) is the real runaway backstop, so
|
|
52
|
+
* this only needs to catch a *tight* same-call loop, not legitimately repeated
|
|
53
|
+
* work (re-reading a file, re-running `git status` across steps, a couple of
|
|
54
|
+
* retries). Tunable via `context.loopGuard`. */
|
|
55
|
+
export const DEFAULT_LOOP_WINDOW_SIZE = 12;
|
|
56
|
+
export const DEFAULT_LOOP_REPEAT_THRESHOLD = 8;
|
|
57
|
+
|
|
37
58
|
/** Identity arguments that pin "the same target" across volatile-arg variation,
|
|
38
59
|
* best-first. The first present string field wins. */
|
|
39
60
|
const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
|
|
@@ -48,19 +69,13 @@ function identityArg(input: unknown): string | null {
|
|
|
48
69
|
return null;
|
|
49
70
|
}
|
|
50
71
|
|
|
51
|
-
export function createStuckLoopDetector(
|
|
52
|
-
opts
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
nearWindowSize?: number;
|
|
56
|
-
nearThreshold?: number;
|
|
57
|
-
} = {},
|
|
58
|
-
): StuckLoopDetector {
|
|
59
|
-
const windowSize = opts.windowSize ?? 8;
|
|
60
|
-
const repeatThreshold = opts.repeatThreshold ?? 3;
|
|
72
|
+
export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoopDetector {
|
|
73
|
+
const enabled = opts.enabled ?? true;
|
|
74
|
+
const windowSize = opts.windowSize ?? DEFAULT_LOOP_WINDOW_SIZE;
|
|
75
|
+
const repeatThreshold = opts.repeatThreshold ?? DEFAULT_LOOP_REPEAT_THRESHOLD;
|
|
61
76
|
// Near-dups need a higher count + a wider window (they're spread out across a
|
|
62
77
|
// burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
|
|
63
|
-
const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2,
|
|
78
|
+
const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 24);
|
|
64
79
|
const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
|
|
65
80
|
const recent: string[] = [];
|
|
66
81
|
const recentNear: string[] = [];
|
|
@@ -68,6 +83,8 @@ export function createStuckLoopDetector(
|
|
|
68
83
|
windowSize,
|
|
69
84
|
repeatThreshold,
|
|
70
85
|
record(toolName, input): StuckSignal {
|
|
86
|
+
// Disabled → never trip (rely on the maxIterations cap alone).
|
|
87
|
+
if (!enabled) return { stuck: false, count: 0, kind: 'exact' };
|
|
71
88
|
const key = `${toolName}|${stableHash(input)}`;
|
|
72
89
|
recent.push(key);
|
|
73
90
|
if (recent.length > windowSize) recent.shift();
|
package/src/mode-helpers.ts
CHANGED
package/src/mode.test.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { migrateModeName } from './mode.js';
|
|
2
|
+
import { migrateModeName, isSelectableMode } from './mode.js';
|
|
3
|
+
|
|
4
|
+
describe('isSelectableMode', () => {
|
|
5
|
+
it('treats a plain mode (no `special`) as selectable', () => {
|
|
6
|
+
expect(isSelectableMode({ special: undefined })).toBe(true);
|
|
7
|
+
expect(isSelectableMode({})).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it('treats a special mode as NOT selectable (any descriptor, even empty)', () => {
|
|
10
|
+
expect(isSelectableMode({ special: {} })).toBe(false);
|
|
11
|
+
expect(isSelectableMode({ special: { invokedBy: 'collab' } })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it('filters special modes out of a list, keeping plain ones', () => {
|
|
14
|
+
const modes = [
|
|
15
|
+
{ name: 'default' },
|
|
16
|
+
{ name: 'goal' },
|
|
17
|
+
{ name: 'collaborative', special: { invokedBy: 'collab' } },
|
|
18
|
+
];
|
|
19
|
+
expect(modes.filter(isSelectableMode).map((m) => m.name)).toEqual(['default', 'goal']);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
3
22
|
|
|
4
23
|
describe('migrateModeName', () => {
|
|
5
24
|
it('maps every legacy mode name to its current target', () => {
|
package/src/mode.ts
CHANGED
|
@@ -2,10 +2,12 @@ import type { CacheStrategyDef } from './cache-strategy.js';
|
|
|
2
2
|
import type { CompactorDef } from './compactor.js';
|
|
3
3
|
import type { EmittedEvent, MoxxyEvent } from './events.js';
|
|
4
4
|
import type { HookDispatcher } from './hooks.js';
|
|
5
|
+
import type { ServiceRegistry } from './services.js';
|
|
5
6
|
import type { SessionId, TurnId } from './ids.js';
|
|
6
7
|
import type { EventLogReader } from './log.js';
|
|
7
8
|
import type { PermissionResolver } from './permission.js';
|
|
8
9
|
import type { LLMProvider } from './provider.js';
|
|
10
|
+
import type { LoopGuardSettings } from './mode/stuck-loop.js';
|
|
9
11
|
import type { Skill } from './skill.js';
|
|
10
12
|
import type { SubagentSpawner } from './subagent.js';
|
|
11
13
|
import type { ToolDef } from './tool.js';
|
|
@@ -106,8 +108,15 @@ export interface ModeContext {
|
|
|
106
108
|
readonly approval?: ApprovalResolver;
|
|
107
109
|
readonly hooks: HookDispatcher;
|
|
108
110
|
readonly pluginHost: PluginHostHandle;
|
|
111
|
+
/** Inter-plugin service registry (mirrors {@link AppContext.services}). */
|
|
112
|
+
readonly services: ServiceRegistry;
|
|
109
113
|
readonly signal: AbortSignal;
|
|
110
114
|
readonly maxIterations?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Loop-guard tuning (config `context.loopGuard`), forwarded to each mode's
|
|
117
|
+
* stuck-loop detector. When omitted the detector uses its defaults.
|
|
118
|
+
*/
|
|
119
|
+
readonly loopGuard?: LoopGuardSettings;
|
|
111
120
|
/**
|
|
112
121
|
* Spawn one or more child agents that share the parent's registries
|
|
113
122
|
* but run in isolation. Children stream their events back to the
|
|
@@ -197,9 +206,43 @@ export interface ModeDef {
|
|
|
197
206
|
* accent badge while this mode is active. See {@link ModeBadge}.
|
|
198
207
|
*/
|
|
199
208
|
readonly badge?: ModeBadge;
|
|
209
|
+
/**
|
|
210
|
+
* Marks a "special" mode — one the user does NOT pick from `/mode`. Set this
|
|
211
|
+
* and the mode is, uniformly across every surface (TUI, mobile, desktop):
|
|
212
|
+
* - **hidden** from every mode list / picker (presence), and
|
|
213
|
+
* - **refused** by the normal mode-switch by name (execution) — it can only
|
|
214
|
+
* be entered through its own invocation (a slash command, a spawned
|
|
215
|
+
* agent's `MOXXY_MODE`, …) via the raw registry `setActive`.
|
|
216
|
+
* The mode stays registered + runnable; it's just never *offered* or
|
|
217
|
+
* name-switched. Extensible: any number of special modes (collaborative today,
|
|
218
|
+
* more later) opt in the same way. See {@link isSelectableMode}.
|
|
219
|
+
*/
|
|
220
|
+
readonly special?: ModeSpecial;
|
|
200
221
|
run(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
|
|
201
222
|
}
|
|
202
223
|
|
|
224
|
+
/** Descriptor for a {@link ModeDef.special} mode — see that field. */
|
|
225
|
+
export interface ModeSpecial {
|
|
226
|
+
/**
|
|
227
|
+
* Human-facing entry point that enters this mode, e.g. `'collab'` (the
|
|
228
|
+
* `/collab` command). Surfaced when the user tries to pick/switch it ("entered
|
|
229
|
+
* via /collab") so the UI can point at the right action.
|
|
230
|
+
*/
|
|
231
|
+
readonly invokedBy?: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether a mode is user-selectable — i.e. NOT a {@link ModeDef.special} mode.
|
|
236
|
+
* The single predicate every surface (TUI/mobile/desktop mode lists + the
|
|
237
|
+
* by-name mode-switch) uses to filter/refuse special modes uniformly. The
|
|
238
|
+
* parameter is intentionally loose (`{ special? }`) so it also applies to the
|
|
239
|
+
* minimal `{ name }` rows the swap-UI registries expose — non-mode defs simply
|
|
240
|
+
* carry no `special` and are always selectable.
|
|
241
|
+
*/
|
|
242
|
+
export function isSelectableMode(mode: { readonly special?: unknown }): boolean {
|
|
243
|
+
return mode.special === undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
/**
|
|
204
247
|
* Legacy mode-name → current-name map for backward compatibility. Modes were
|
|
205
248
|
* renamed/slimmed: `tool-use`→`default`, `deep-research`→`research`, and the
|
package/src/plugin.ts
CHANGED
|
@@ -16,8 +16,9 @@ import type { SynthesizerDef } from './synthesizer.js';
|
|
|
16
16
|
import type { ViewRendererDef } from './view-renderer.js';
|
|
17
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
18
18
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
19
|
+
import type { EventStoreDef } from './event-store.js';
|
|
19
20
|
|
|
20
|
-
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor';
|
|
21
|
+
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor' | 'event-store';
|
|
21
22
|
|
|
22
23
|
export interface PluginSpec {
|
|
23
24
|
readonly name: string;
|
|
@@ -44,6 +45,14 @@ export interface PluginSpec {
|
|
|
44
45
|
* the proxy relay). One active per session; core seeds a `localhost` no-op.
|
|
45
46
|
*/
|
|
46
47
|
readonly tunnelProviders?: ReadonlyArray<TunnelProviderDef>;
|
|
48
|
+
/**
|
|
49
|
+
* Event-store backends — the storage behind a session's event log. Core seeds
|
|
50
|
+
* a protected JSONL default; a plugin can contribute an alternative (SQLite,
|
|
51
|
+
* remote, encrypted), activated only by explicit `plugins.eventStore.default`
|
|
52
|
+
* (a discovered store is registered but never auto-active — the trust
|
|
53
|
+
* boundary, since the store sees every event). See {@link EventStoreDef}.
|
|
54
|
+
*/
|
|
55
|
+
readonly eventStores?: ReadonlyArray<EventStoreDef>;
|
|
47
56
|
readonly channels?: ReadonlyArray<ChannelDef>;
|
|
48
57
|
/**
|
|
49
58
|
* Interactive surfaces contributed by the plugin — long-lived panes a human
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal read-only view of a name-keyed registry. The host publishes its core
|
|
3
|
+
* registries (agents, tools, …) on the {@link ServiceRegistry} under these
|
|
4
|
+
* names, so a discovery-loaded plugin can resolve one in `onInit` (e.g.
|
|
5
|
+
* `ctx.services.require<NamedRegistry<AgentDef>>('agents')`) without importing
|
|
6
|
+
* `@moxxy/core`'s concrete registry types. Core's `DefMapRegistry`-based
|
|
7
|
+
* registries satisfy it structurally.
|
|
8
|
+
*
|
|
9
|
+
* Well-known service names the host publishes: `'agents'`, `'tools'`,
|
|
10
|
+
* `'providers'`, `'viewRenderers'`, `'synthesizers'`.
|
|
11
|
+
*/
|
|
12
|
+
export interface NamedRegistry<T> {
|
|
13
|
+
get(name: string): T | undefined;
|
|
14
|
+
list(): ReadonlyArray<T>;
|
|
15
|
+
has(name: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Inter-plugin service registry. A plugin can **publish** a named service in its
|
|
20
|
+
* `onInit` hook (e.g. the vault plugin publishes its secret store) and another
|
|
21
|
+
* plugin can **consume** it in its own `onInit` — decoupling cross-plugin
|
|
22
|
+
* dependencies from the host's constructor wiring so a plugin can be
|
|
23
|
+
* discovery-loaded (default-exported, no `build*({deps})` closure) instead of
|
|
24
|
+
* hand-built by the orchestrator.
|
|
25
|
+
*
|
|
26
|
+
* Ordering is by plugin requirements: declare `moxxy.requirements` on the
|
|
27
|
+
* consumer (e.g. `@moxxy/plugin-oauth` requires `@moxxy/plugin-vault`) so the
|
|
28
|
+
* provider's `onInit` runs first and the service is registered before the
|
|
29
|
+
* consumer resolves it. Exposed on {@link AppContext.services}.
|
|
30
|
+
*
|
|
31
|
+
* Plugin `onInit` already runs with full in-process privileges (the security
|
|
32
|
+
* isolation wraps *tool* execution, not plugin code), so reaching a sibling
|
|
33
|
+
* plugin's service here doesn't widen the effective trust surface.
|
|
34
|
+
*/
|
|
35
|
+
export interface ServiceRegistry {
|
|
36
|
+
/** Publish a named service for other plugins to consume in their `onInit`. */
|
|
37
|
+
register<T>(name: string, impl: T): void;
|
|
38
|
+
/** Resolve a published service, or `undefined` when it isn't registered. */
|
|
39
|
+
get<T>(name: string): T | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve a published service or throw — use when a declared requirement
|
|
42
|
+
* guarantees the provider ran first.
|
|
43
|
+
*/
|
|
44
|
+
require<T>(name: string): T;
|
|
45
|
+
/** Whether a service has been published. */
|
|
46
|
+
has(name: string): boolean;
|
|
47
|
+
}
|
package/src/session-like.ts
CHANGED
|
@@ -289,6 +289,37 @@ export interface LoadedPluginView {
|
|
|
289
289
|
readonly version: string;
|
|
290
290
|
/** Contribution categories (e.g. `['provider']`, `['tool','command']`). */
|
|
291
291
|
readonly kinds: ReadonlyArray<string>;
|
|
292
|
+
/**
|
|
293
|
+
* True when discovered from `~/.moxxy/plugins` (installed on demand), false/
|
|
294
|
+
* absent when statically bundled into the binary. Lets surfaces distinguish
|
|
295
|
+
* "installed" from "built-in". Optional so a thin-client `RemoteSession` may
|
|
296
|
+
* omit it (treated as built-in).
|
|
297
|
+
*/
|
|
298
|
+
readonly installed?: boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** One swappable contribution within a {@link CategoryView}. */
|
|
302
|
+
export interface CategoryItemView {
|
|
303
|
+
/** Contribution name (e.g. provider `anthropic`, compactor `summarize`). */
|
|
304
|
+
readonly name: string;
|
|
305
|
+
/** Whether this item is the active default for its category. */
|
|
306
|
+
readonly isDefault: boolean;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* One registry category in {@link PluginsAdminView.categories} — the active
|
|
311
|
+
* default plus the available items to swap between. Powers the per-category
|
|
312
|
+
* tabs in the `/plugins` picker and the `list_defaults` tool.
|
|
313
|
+
*/
|
|
314
|
+
export interface CategoryView {
|
|
315
|
+
/** Registry kind, e.g. `provider` | `compactor` | `mode`. */
|
|
316
|
+
readonly category: string;
|
|
317
|
+
/** Active default contribution name, or null when the category is empty. */
|
|
318
|
+
readonly active: string | null;
|
|
319
|
+
/** The protected floor's name (never disableable), or null when none. */
|
|
320
|
+
readonly floor: string | null;
|
|
321
|
+
/** Swappable contributions registered for this category. */
|
|
322
|
+
readonly items: ReadonlyArray<CategoryItemView>;
|
|
292
323
|
}
|
|
293
324
|
|
|
294
325
|
/**
|
|
@@ -301,12 +332,24 @@ export interface LoadedPluginView {
|
|
|
301
332
|
export interface PluginsAdminView {
|
|
302
333
|
/** Currently-loaded plugins with their contribution kinds (for kind tabs). */
|
|
303
334
|
loaded(): ReadonlyArray<LoadedPluginView>;
|
|
304
|
-
/** Package names currently disabled (config `plugins[name].enabled=false`). */
|
|
335
|
+
/** Package names currently disabled (config `plugins.packages[name].enabled=false`). */
|
|
305
336
|
disabled(): ReadonlyArray<string>;
|
|
337
|
+
/** Kernel package names that cannot be disabled (swap their default instead). */
|
|
338
|
+
protectedPackages(): ReadonlyArray<string>;
|
|
306
339
|
/** Curated installable-plugin catalog for the picker's "Installable" tab. */
|
|
307
340
|
catalog(): ReadonlyArray<InstallablePluginView>;
|
|
308
|
-
/** Persist `plugins[name].enabled` AND apply it to the live session. */
|
|
341
|
+
/** Persist `plugins.packages[name].enabled` AND apply it to the live session. */
|
|
309
342
|
setEnabled(packageName: string, enabled: boolean): Promise<void>;
|
|
343
|
+
/**
|
|
344
|
+
* Per-category active default + swappable items (the swap axis). Powers the
|
|
345
|
+
* `/plugins` category tabs and the `list_defaults` tool.
|
|
346
|
+
*/
|
|
347
|
+
categories(): ReadonlyArray<CategoryView>;
|
|
348
|
+
/**
|
|
349
|
+
* Persist `plugins.<category>.default` AND apply it to the live session
|
|
350
|
+
* (`setActive`). Rejects an unknown category or an unregistered name.
|
|
351
|
+
*/
|
|
352
|
+
setCategoryDefault(category: string, name: string): Promise<void>;
|
|
310
353
|
}
|
|
311
354
|
|
|
312
355
|
/**
|
package/src/stuck-loop.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createStuckLoopDetector, stableHash } from './mode-helpers.js';
|
|
|
3
3
|
|
|
4
4
|
describe('createStuckLoopDetector', () => {
|
|
5
5
|
it('trips on exact-input repeats at repeatThreshold', () => {
|
|
6
|
-
const d = createStuckLoopDetector(
|
|
6
|
+
const d = createStuckLoopDetector({ repeatThreshold: 3 });
|
|
7
7
|
const input = { x: 1 };
|
|
8
8
|
expect(d.record('Read', input).stuck).toBe(false);
|
|
9
9
|
expect(d.record('Read', input).stuck).toBe(false);
|
|
@@ -11,8 +11,21 @@ describe('createStuckLoopDetector', () => {
|
|
|
11
11
|
expect(sig).toMatchObject({ stuck: true, count: 3, kind: 'exact' });
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
it('uses a generous default exact threshold (does not trip on a few repeats)', () => {
|
|
15
|
+
const d = createStuckLoopDetector(); // default repeatThreshold = 8
|
|
16
|
+
const input = { x: 1 };
|
|
17
|
+
for (let i = 0; i < 7; i++) expect(d.record('Read', input).stuck).toBe(false);
|
|
18
|
+
expect(d.record('Read', input).stuck).toBe(true); // 8th identical call trips
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('never trips when disabled (relies on maxIterations alone)', () => {
|
|
22
|
+
const d = createStuckLoopDetector({ enabled: false, repeatThreshold: 2 });
|
|
23
|
+
const input = { x: 1 };
|
|
24
|
+
for (let i = 0; i < 20; i++) expect(d.record('Read', input).stuck).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
14
27
|
it('trips on same-target near-dups even when volatile args vary', () => {
|
|
15
|
-
const d = createStuckLoopDetector(); // nearThreshold 5
|
|
28
|
+
const d = createStuckLoopDetector({ repeatThreshold: 3 }); // nearThreshold → 5
|
|
16
29
|
const url = 'https://example.com/big';
|
|
17
30
|
// Same url, different maxBytes each time — exact check never fires.
|
|
18
31
|
for (let i = 0; i < 4; i++) {
|
|
@@ -44,7 +57,7 @@ describe('createStuckLoopDetector', () => {
|
|
|
44
57
|
// dispatch path; a throw there crashes the whole turn. These assert it stays
|
|
45
58
|
// total on hostile/partial input — the worst case the provider can hand us.
|
|
46
59
|
it('does not throw recording a tool input with a circular reference', () => {
|
|
47
|
-
const d = createStuckLoopDetector();
|
|
60
|
+
const d = createStuckLoopDetector({ repeatThreshold: 3 });
|
|
48
61
|
const input: Record<string, unknown> = { a: 1 };
|
|
49
62
|
input.self = input;
|
|
50
63
|
expect(() => d.record('weird', input)).not.toThrow();
|
package/src/tool-dispatch.ts
CHANGED