@moxxy/sdk 0.26.0 → 0.28.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/channel.d.ts +13 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +19 -0
- package/dist/channel.js.map +1 -1
- package/dist/event-store.d.ts +5 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +24 -1
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +9 -3
- package/dist/events.d.ts.map +1 -1
- package/dist/first-party.d.ts +17 -0
- package/dist/first-party.d.ts.map +1 -0
- package/dist/first-party.js +19 -0
- package/dist/first-party.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/isolation.d.ts +14 -0
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +75 -0
- package/dist/isolation.js.map +1 -1
- package/dist/mode/checkpoint.d.ts +107 -0
- package/dist/mode/checkpoint.d.ts.map +1 -0
- package/dist/mode/checkpoint.js +2 -0
- package/dist/mode/checkpoint.js.map +1 -0
- package/dist/mode/react-loop.d.ts +129 -0
- package/dist/mode/react-loop.d.ts.map +1 -0
- package/dist/mode/react-loop.js +480 -0
- package/dist/mode/react-loop.js.map +1 -0
- package/dist/mode/stuck-loop.d.ts +7 -0
- package/dist/mode/stuck-loop.d.ts.map +1 -1
- package/dist/mode/stuck-loop.js +4 -0
- package/dist/mode/stuck-loop.js.map +1 -1
- package/dist/mode-helpers.d.ts +4 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +3 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +43 -2
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reflector.d.ts +58 -0
- package/dist/reflector.d.ts.map +1 -0
- package/dist/reflector.js +2 -0
- package/dist/reflector.js.map +1 -0
- package/dist/schemas.d.ts +215 -8
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +37 -0
- package/dist/schemas.js.map +1 -1
- package/dist/session-like.d.ts +52 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/tool-dispatch.d.ts +35 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +51 -0
- package/dist/tool-dispatch.js.map +1 -1
- package/package.json +1 -1
- package/src/channel.ts +25 -0
- package/src/event-store.ts +16 -1
- package/src/events.ts +9 -2
- package/src/exit-after-pair.test.ts +32 -0
- package/src/first-party.test.ts +26 -0
- package/src/first-party.ts +19 -0
- package/src/index.ts +32 -4
- package/src/isolation-aggregate.test.ts +69 -0
- package/src/isolation.ts +67 -0
- package/src/mode/checkpoint.ts +105 -0
- package/src/mode/react-loop.ts +694 -0
- package/src/mode/stuck-loop.ts +11 -0
- package/src/mode-helpers.ts +16 -0
- package/src/mode.ts +43 -2
- package/src/plugin.ts +10 -1
- package/src/reflector.ts +61 -0
- package/src/schemas.ts +41 -0
- package/src/session-like.ts +49 -0
- package/src/tool-dispatch.test.ts +77 -0
- package/src/tool-dispatch.ts +82 -1
package/src/mode/stuck-loop.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface StuckLoopDetector {
|
|
|
32
32
|
readonly repeatThreshold: number;
|
|
33
33
|
/** Record the call and report whether the loop guard should trip. */
|
|
34
34
|
record(toolName: string, input: unknown): StuckSignal;
|
|
35
|
+
/**
|
|
36
|
+
* Clear both sliding windows. Used by the nudge (steer-don't-stop) stuck
|
|
37
|
+
* policy: after a trip is surfaced to the model, the detector starts a fresh
|
|
38
|
+
* episode — otherwise every subsequent call inside the still-full window
|
|
39
|
+
* would re-trip and re-nudge on each iteration.
|
|
40
|
+
*/
|
|
41
|
+
reset(): void;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
/**
|
|
@@ -102,5 +109,9 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
|
|
|
102
109
|
}
|
|
103
110
|
return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
|
|
104
111
|
},
|
|
112
|
+
reset(): void {
|
|
113
|
+
recent.length = 0;
|
|
114
|
+
recentNear.length = 0;
|
|
115
|
+
},
|
|
105
116
|
};
|
|
106
117
|
}
|
package/src/mode-helpers.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* - `./mode/single-shot.ts` — single-shot (no-tools) provider turn
|
|
10
10
|
* - `./mode/stuck-loop.ts` — sliding-window stuck-tool-call detector
|
|
11
11
|
* - `./mode/stable-hash.ts` — key-order-canonical input hash util
|
|
12
|
+
* - `./mode/react-loop.ts` — the shared ReAct loop core (+ checkpoint gate)
|
|
13
|
+
* - `./mode/checkpoint.ts` — turn-end checkpoint contract
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
export {
|
|
@@ -33,3 +35,17 @@ export {
|
|
|
33
35
|
type LoopGuardSettings,
|
|
34
36
|
} from './mode/stuck-loop.js';
|
|
35
37
|
export { stableHash } from './mode/stable-hash.js';
|
|
38
|
+
export {
|
|
39
|
+
runReactLoop,
|
|
40
|
+
MAX_CONSECUTIVE_RETRIES,
|
|
41
|
+
__setRetrySleepForTests,
|
|
42
|
+
type ReactLoopOptions,
|
|
43
|
+
type ProviderSuccessInfo,
|
|
44
|
+
type ToolBatchInfo,
|
|
45
|
+
type StopDirective,
|
|
46
|
+
} from './mode/react-loop.js';
|
|
47
|
+
export type {
|
|
48
|
+
CheckpointContext,
|
|
49
|
+
CheckpointResult,
|
|
50
|
+
TurnCheckpoint,
|
|
51
|
+
} from './mode/checkpoint.js';
|
package/src/mode.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface PluginHostHandle {
|
|
|
46
46
|
kinds?: ReadonlyArray<string>;
|
|
47
47
|
}>;
|
|
48
48
|
reload(): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* The plugin (package) that contributed the named tool, or undefined for
|
|
51
|
+
* unknown / dynamically-attached tools. Optional so a thin-client
|
|
52
|
+
* `RemoteSession` can omit it; powers plugin-level security routing
|
|
53
|
+
* (`security.perPlugin`) and package-scoped audit views.
|
|
54
|
+
*/
|
|
55
|
+
ownerOfTool?(toolName: string): string | undefined;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
|
@@ -124,14 +131,33 @@ export interface ModeContext {
|
|
|
124
131
|
* Absent in synthetic test contexts that don't model a full Session.
|
|
125
132
|
*/
|
|
126
133
|
readonly subagents?: SubagentSpawner;
|
|
134
|
+
/**
|
|
135
|
+
* True when this context belongs to a spawned child agent (set by the
|
|
136
|
+
* subagent runtime). The shared ReAct loop disarms turn-end checkpoints in
|
|
137
|
+
* subagent sessions — the recursion backstop that keeps a checkpoint which
|
|
138
|
+
* (mistakenly) spawns a child in a checkpoint-bearing mode from gating the
|
|
139
|
+
* child's turn-end and recursing forever.
|
|
140
|
+
*/
|
|
141
|
+
readonly isSubagent?: boolean;
|
|
127
142
|
/**
|
|
128
143
|
* Request the session switch its active mode AFTER this turn fully drains.
|
|
129
144
|
* Used by terminal workflow modes (BMAD finishing its last phase) to hand
|
|
130
145
|
* control back to a normal mode so the next message isn't trapped in the
|
|
131
|
-
* workflow
|
|
132
|
-
*
|
|
146
|
+
* workflow, and by {@link ModeDef.transient} modes (goal) to disarm
|
|
147
|
+
* themselves once their objective concludes. The switch is applied
|
|
148
|
+
* post-turn by the runner; an unknown / unregistered mode name is ignored.
|
|
149
|
+
* Absent in synthetic test contexts.
|
|
133
150
|
*/
|
|
134
151
|
readonly requestModeSwitch?: (modeName: string) => void;
|
|
152
|
+
/**
|
|
153
|
+
* Name of the mode that was active before the current one (per the mode
|
|
154
|
+
* registry), when known. Lets a {@link ModeDef.transient} mode hand control
|
|
155
|
+
* back to whatever the user was in before arming it (`/goal` from research
|
|
156
|
+
* mode reverts to research, not blindly to default). Absent when the
|
|
157
|
+
* registry has no history (first mode of the session) or in synthetic test
|
|
158
|
+
* contexts — callers should fall back to `'default'`.
|
|
159
|
+
*/
|
|
160
|
+
readonly previousModeName?: string;
|
|
135
161
|
emit(event: EmittedEvent): Promise<MoxxyEvent>;
|
|
136
162
|
}
|
|
137
163
|
|
|
@@ -218,6 +244,21 @@ export interface ModeDef {
|
|
|
218
244
|
* more later) opt in the same way. See {@link isSelectableMode}.
|
|
219
245
|
*/
|
|
220
246
|
readonly special?: ModeSpecial;
|
|
247
|
+
/**
|
|
248
|
+
* Marks a mode that arms for a SINGLE objective rather than becoming the
|
|
249
|
+
* session's standing behavior (goal mode). Uniformly across surfaces:
|
|
250
|
+
*
|
|
251
|
+
* - it is never persisted as the category default (`plugins.mode.default`)
|
|
252
|
+
* — `/goal` once must not make every future session boot autonomous;
|
|
253
|
+
* - a persisted/config default naming it is refused at boot (the
|
|
254
|
+
* protected default stays active instead);
|
|
255
|
+
* - the mode itself is expected to hand control back (via
|
|
256
|
+
* `ctx.requestModeSwitch`, typically to `ctx.previousModeName`) when
|
|
257
|
+
* its objective concludes.
|
|
258
|
+
*
|
|
259
|
+
* Switching INTO it (picker, `/goal`, `setMode` RPC) still works normally.
|
|
260
|
+
*/
|
|
261
|
+
readonly transient?: boolean;
|
|
221
262
|
run(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
|
|
222
263
|
}
|
|
223
264
|
|
package/src/plugin.ts
CHANGED
|
@@ -17,8 +17,9 @@ import type { ViewRendererDef } from './view-renderer.js';
|
|
|
17
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
18
18
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
19
19
|
import type { EventStoreDef } from './event-store.js';
|
|
20
|
+
import type { ReflectorDef } from './reflector.js';
|
|
20
21
|
|
|
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';
|
|
22
|
+
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' | 'reflector';
|
|
22
23
|
|
|
23
24
|
export interface PluginSpec {
|
|
24
25
|
readonly name: string;
|
|
@@ -53,6 +54,14 @@ export interface PluginSpec {
|
|
|
53
54
|
* boundary, since the store sees every event). See {@link EventStoreDef}.
|
|
54
55
|
*/
|
|
55
56
|
readonly eventStores?: ReadonlyArray<EventStoreDef>;
|
|
57
|
+
/**
|
|
58
|
+
* Reflector backends — the learning-loop block that watches a finished turn
|
|
59
|
+
* and *proposes* memory/skill improvements without silently writing. One
|
|
60
|
+
* active at a time, selected via `plugins.reflector.default`. NULLABLE: core
|
|
61
|
+
* seeds no floor, so reflection is opt-in — no registered reflector means the
|
|
62
|
+
* session never reflects. See {@link ReflectorDef}.
|
|
63
|
+
*/
|
|
64
|
+
readonly reflectors?: ReadonlyArray<ReflectorDef>;
|
|
56
65
|
readonly channels?: ReadonlyArray<ChannelDef>;
|
|
57
66
|
/**
|
|
58
67
|
* Interactive surfaces contributed by the plugin — long-lived panes a human
|
package/src/reflector.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EventLogReader } from './log.js';
|
|
2
|
+
import type { SessionId, TurnId } from './ids.js';
|
|
3
|
+
import type { ServiceRegistry } from './services.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The learning-loop block — a swappable strategy that watches a finished turn
|
|
7
|
+
* and *proposes* (never silently writes) memory/skill improvements. It is the
|
|
8
|
+
* def-only sibling of the other single-active registries (compactor, cache
|
|
9
|
+
* strategy, …) but NULLABLE: core seeds NO floor, so reflection is entirely
|
|
10
|
+
* opt-in — a session with no registered reflector simply never reflects.
|
|
11
|
+
*
|
|
12
|
+
* The trust boundary is the "propose, don't write" contract. A reflector reads
|
|
13
|
+
* the turn's events and returns {@link ReflectionProposal}s; the driver that
|
|
14
|
+
* hosts it delivers those as a one-time nudge on the next provider call, phrased
|
|
15
|
+
* so the *model* may choose to call `memory_save` / `synthesize_skill` — which
|
|
16
|
+
* still go through the existing permission prompts. Nothing a reflector returns
|
|
17
|
+
* mutates memory or skills on its own.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The context handed to {@link ReflectorDef.reflect}. Scoped to one finished
|
|
22
|
+
* turn: `log.byTurn(turnId)` yields exactly that turn's events. `services`
|
|
23
|
+
* exposes the host's inter-plugin registry (e.g. `services.get('providers')`
|
|
24
|
+
* for a side-channel LLM pass); `signal` bounds the whole reflection (a timeout
|
|
25
|
+
* and/or session shutdown) so a slow provider can never wedge it.
|
|
26
|
+
*/
|
|
27
|
+
export interface ReflectContext {
|
|
28
|
+
readonly sessionId: SessionId;
|
|
29
|
+
readonly turnId: TurnId;
|
|
30
|
+
readonly cwd: string;
|
|
31
|
+
readonly log: EventLogReader;
|
|
32
|
+
readonly services: ServiceRegistry;
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One improvement a reflector suggests. `kind` picks the target surface
|
|
38
|
+
* (`'memory'` → a fact worth `memory_save`; `'skill'` → a repeated procedure
|
|
39
|
+
* worth `synthesize_skill`); `title` is a short label; `nudge` is a
|
|
40
|
+
* one-paragraph suggestion addressed to the assistant. A proposal is a HINT,
|
|
41
|
+
* not a command — the model decides whether to act, and any resulting write
|
|
42
|
+
* still hits its own permission prompt.
|
|
43
|
+
*/
|
|
44
|
+
export interface ReflectionProposal {
|
|
45
|
+
readonly kind: 'memory' | 'skill';
|
|
46
|
+
readonly title: string;
|
|
47
|
+
/** One-paragraph suggestion addressed to the assistant. */
|
|
48
|
+
readonly nudge: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A registered reflector backend. `reflect` inspects the just-finished turn and
|
|
53
|
+
* returns 0 or more proposals (an empty array = "nothing worth suggesting").
|
|
54
|
+
* It MUST be side-effect-free with respect to memory/skills — it only reads and
|
|
55
|
+
* proposes — and MUST honor `ctx.signal` (abort/timeout).
|
|
56
|
+
*/
|
|
57
|
+
export interface ReflectorDef {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly displayName?: string;
|
|
60
|
+
reflect(ctx: ReflectContext): Promise<ReadonlyArray<ReflectionProposal>>;
|
|
61
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -16,6 +16,7 @@ const pluginKindSchema = z.enum([
|
|
|
16
16
|
'command',
|
|
17
17
|
'transcriber',
|
|
18
18
|
'synthesizer',
|
|
19
|
+
'reflector',
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
export const requirementSchema = z.object({
|
|
@@ -89,11 +90,51 @@ export const pluginManifestSchema = z.object({
|
|
|
89
90
|
* requirements may be authored; per-tool/per-transcriber/per-anything
|
|
90
91
|
* runtime declarations were removed in favor of static analysis.
|
|
91
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* One field of a plugin's declarative setup step (`package.json#moxxy.setup`).
|
|
95
|
+
* Declarative-only so EVERY frontend (the init wizard, the TUI, desktop
|
|
96
|
+
* onboarding) can render it without executing plugin code:
|
|
97
|
+
* - `secret` values land in the VAULT (never plaintext config); the plugin's
|
|
98
|
+
* `options.<key>` gets a `${vault:<name>}` ref, resolved at boot.
|
|
99
|
+
* - other kinds land at `plugins.packages.<pkg>.options.<key>` in the user
|
|
100
|
+
* config through the shared schema-validated writer.
|
|
101
|
+
*/
|
|
102
|
+
export const pluginSetupFieldSchema = z.object({
|
|
103
|
+
key: z.string().min(1).regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, 'key must be an identifier'),
|
|
104
|
+
label: z.string().min(1),
|
|
105
|
+
description: z.string().optional(),
|
|
106
|
+
kind: z.enum(['secret', 'string', 'boolean', 'select']),
|
|
107
|
+
/** Vault entry name for `secret` fields. Default: `<PKG>_<KEY>` upper-snake. */
|
|
108
|
+
vaultKey: z.string().min(1).optional(),
|
|
109
|
+
/** Choices for `select` fields. */
|
|
110
|
+
options: z.array(z.string().min(1)).optional(),
|
|
111
|
+
/** Required fields block completion; optional ones may stay unset. */
|
|
112
|
+
required: z.boolean().optional(),
|
|
113
|
+
placeholder: z.string().optional(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A plugin's declarative configuration step, walked by `moxxy init` (and
|
|
118
|
+
* surfaced after an on-demand install). `required: true` means the plugin is
|
|
119
|
+
* left DISABLED until its required fields are provided — the author's way to
|
|
120
|
+
* say "this cannot work unconfigured".
|
|
121
|
+
*/
|
|
122
|
+
export const pluginSetupSchema = z.object({
|
|
123
|
+
title: z.string().min(1),
|
|
124
|
+
description: z.string().optional(),
|
|
125
|
+
required: z.boolean().optional(),
|
|
126
|
+
fields: z.array(pluginSetupFieldSchema).min(1),
|
|
127
|
+
});
|
|
128
|
+
|
|
92
129
|
export const moxxyPackageSchema = z.object({
|
|
93
130
|
plugin: pluginManifestSchema.optional(),
|
|
94
131
|
requirements: z.array(requirementSchema).optional(),
|
|
132
|
+
/** Declarative setup step users walk through in init / post-install. */
|
|
133
|
+
setup: pluginSetupSchema.optional(),
|
|
95
134
|
});
|
|
96
135
|
|
|
136
|
+
export type PluginSetupField = z.infer<typeof pluginSetupFieldSchema>;
|
|
137
|
+
export type PluginSetupSpec = z.infer<typeof pluginSetupSchema>;
|
|
97
138
|
export type SkillFrontmatterInput = z.infer<typeof skillFrontmatterSchema>;
|
|
98
139
|
export type PluginManifestInput = z.infer<typeof pluginManifestSchema>;
|
|
99
140
|
export type MoxxyPackageInput = z.infer<typeof moxxyPackageSchema>;
|
package/src/session-like.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { MoxxyEvent, TriggerOrigin, UserPromptAttachment } from './events.js';
|
|
2
2
|
import type { SessionId, TurnId } from './ids.js';
|
|
3
|
+
import type { CapabilitySpec } from './isolation.js';
|
|
3
4
|
import type { EventLogReader } from './log.js';
|
|
4
5
|
import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
5
6
|
import type { PermissionResolver } from './permission.js';
|
|
6
7
|
import type { ModelDescriptor, ProviderKeyValidation } from './provider.js';
|
|
8
|
+
import type { PluginSetupSpec } from './schemas.js';
|
|
7
9
|
import type { ToolCompactPresentation } from './tool.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -365,7 +367,42 @@ export interface PluginsAdminView {
|
|
|
365
367
|
install?(idOrSpec: string): Promise<{
|
|
366
368
|
readonly installed: string;
|
|
367
369
|
readonly registered: Readonly<Partial<Record<string, ReadonlyArray<string>>>>;
|
|
370
|
+
/**
|
|
371
|
+
* Combined capability surface of the tools this install registered —
|
|
372
|
+
* the package's blast radius, so a channel can render post-install
|
|
373
|
+
* consent (third-party packages) or an info line (first-party). Absent
|
|
374
|
+
* when the install registered no tools or the host cannot introspect
|
|
375
|
+
* isolation specs.
|
|
376
|
+
*/
|
|
377
|
+
readonly capabilities?: {
|
|
378
|
+
/** Tools that declared an isolation spec. */
|
|
379
|
+
readonly declared: number;
|
|
380
|
+
/** Tools the install registered. */
|
|
381
|
+
readonly total: number;
|
|
382
|
+
/** Widest-wins union of the declared specs. */
|
|
383
|
+
readonly surface: CapabilitySpec;
|
|
384
|
+
/** Tools with NO declaration: their surface is unknown, not empty. */
|
|
385
|
+
readonly undeclaredTools?: ReadonlyArray<string>;
|
|
386
|
+
};
|
|
387
|
+
/** Present when the package declares a `moxxy.setup` step to complete. */
|
|
388
|
+
readonly needsSetup?: { readonly title: string; readonly required: boolean };
|
|
368
389
|
}>;
|
|
390
|
+
/**
|
|
391
|
+
* The package's declarative setup step (`moxxy.setup`), read from its
|
|
392
|
+
* installed package.json — plain data, safe to render anywhere. Null when
|
|
393
|
+
* absent. Powers the post-install dialog and `/setup`.
|
|
394
|
+
*/
|
|
395
|
+
setupSpec?(packageName: string): Promise<PluginSetupSpec | null>;
|
|
396
|
+
/**
|
|
397
|
+
* Persist collected setup values: secrets → vault + `${vault:NAME}` option
|
|
398
|
+
* ref; other kinds → `plugins.packages.<pkg>.options.<key>`. A complete
|
|
399
|
+
* required setup re-enables the package; an incomplete one disables it
|
|
400
|
+
* (mirrors the init wizard). Optional capability per the seam convention.
|
|
401
|
+
*/
|
|
402
|
+
applySetup?(
|
|
403
|
+
packageName: string,
|
|
404
|
+
values: Readonly<Record<string, string | boolean>>,
|
|
405
|
+
): Promise<{ readonly complete: boolean; readonly missing: ReadonlyArray<string> }>;
|
|
369
406
|
}
|
|
370
407
|
|
|
371
408
|
/** IO a channel supplies to drive an interactive OAuth flow inside its own
|
|
@@ -470,4 +507,16 @@ export interface SessionLike {
|
|
|
470
507
|
pluginsAdmin?: PluginsAdminView;
|
|
471
508
|
/** Provider onboarding slice backing in-channel connect (key entry / OAuth). */
|
|
472
509
|
providerSetup?: ProviderSetupView;
|
|
510
|
+
/**
|
|
511
|
+
* Re-read the merged config from disk and live-apply the safe subset —
|
|
512
|
+
* backs the TUI /settings panel's write-then-apply. Structurally matches
|
|
513
|
+
* @moxxy/config's ConfigApplyResult (the SDK stays config-free). Absent on
|
|
514
|
+
* a RemoteSession: writes still land, but apply waits for a restart.
|
|
515
|
+
*/
|
|
516
|
+
configAdmin?: {
|
|
517
|
+
apply(): Promise<{
|
|
518
|
+
readonly applied: ReadonlyArray<string>;
|
|
519
|
+
readonly pending: ReadonlyArray<string>;
|
|
520
|
+
}>;
|
|
521
|
+
};
|
|
473
522
|
}
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
dispatchToolCall,
|
|
10
10
|
executeToolUses,
|
|
11
11
|
emitRequestsAndDetectStuck,
|
|
12
|
+
emitRequestsAndNudgeOnStuck,
|
|
12
13
|
type StuckLoopReport,
|
|
14
|
+
type StuckNudgeReport,
|
|
15
|
+
type StuckTripInfo,
|
|
13
16
|
} from './tool-dispatch.js';
|
|
14
17
|
|
|
15
18
|
/**
|
|
@@ -213,3 +216,77 @@ describe('emitRequestsAndDetectStuck — stuck trip', () => {
|
|
|
213
216
|
expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
|
|
214
217
|
});
|
|
215
218
|
});
|
|
219
|
+
|
|
220
|
+
describe('emitRequestsAndNudgeOnStuck — steer, never stop', () => {
|
|
221
|
+
const nudgeReport: StuckNudgeReport = {
|
|
222
|
+
nearHint: 'with nearly identical input',
|
|
223
|
+
warnMessage: ({ toolName, count }) => `repetitive: ${toolName} x${count}`,
|
|
224
|
+
extraOnStuck: ({ toolName }) => [
|
|
225
|
+
{
|
|
226
|
+
type: 'plugin_event',
|
|
227
|
+
sessionId: asSessionId('s1'),
|
|
228
|
+
turnId: asTurnId('t1'),
|
|
229
|
+
source: 'plugin',
|
|
230
|
+
pluginId: 'test' as never,
|
|
231
|
+
subtype: 'test_stuck',
|
|
232
|
+
payload: { toolName },
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function nudgeDetector(trip: StuckSignal): StuckLoopDetector & { resets: number } {
|
|
238
|
+
const d = {
|
|
239
|
+
resets: 0,
|
|
240
|
+
record: () => trip,
|
|
241
|
+
reset: () => {
|
|
242
|
+
d.resets += 1;
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
return d as unknown as StuckLoopDetector & { resets: number };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
it('on a trip: warns (retryable, not fatal), emits extra events, resets the detector, and does NOT synthesize results', async () => {
|
|
249
|
+
const { ctx, events } = makeCtx();
|
|
250
|
+
const det = nudgeDetector({ stuck: true, kind: 'exact', count: 8 });
|
|
251
|
+
const trip = (await drain(
|
|
252
|
+
emitRequestsAndNudgeOnStuck(ctx, [tool], det, nudgeReport),
|
|
253
|
+
)) as StuckTripInfo | null;
|
|
254
|
+
|
|
255
|
+
// Trip info is returned for the loop's volatile nudge…
|
|
256
|
+
expect(trip).toEqual({ toolName: 'Read', count: 8, kind: 'exact', how: 'with identical input' });
|
|
257
|
+
// …the detector started a fresh episode…
|
|
258
|
+
expect(det.resets).toBe(1);
|
|
259
|
+
// …the request stands (the batch will execute — no synthesized results)…
|
|
260
|
+
expect(events.some((e) => e.type === 'tool_call_requested')).toBe(true);
|
|
261
|
+
expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
|
|
262
|
+
// …the warning is visible but NON-fatal, with the extra event before it.
|
|
263
|
+
const err = events.find((e) => e.type === 'error') as
|
|
264
|
+
| { kind: string; message: string }
|
|
265
|
+
| undefined;
|
|
266
|
+
expect(err?.kind).toBe('retryable');
|
|
267
|
+
expect(err?.message).toBe('repetitive: Read x8');
|
|
268
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'test_stuck')).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('a near trip uses the report nearHint wording', async () => {
|
|
272
|
+
const { ctx } = makeCtx();
|
|
273
|
+
const trip = (await drain(
|
|
274
|
+
emitRequestsAndNudgeOnStuck(
|
|
275
|
+
ctx,
|
|
276
|
+
[tool],
|
|
277
|
+
nudgeDetector({ stuck: true, kind: 'near', count: 5 }),
|
|
278
|
+
nudgeReport,
|
|
279
|
+
),
|
|
280
|
+
)) as StuckTripInfo | null;
|
|
281
|
+
expect(trip?.how).toBe('with nearly identical input');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('returns null and stays quiet when the detector does not trip', async () => {
|
|
285
|
+
const { ctx, events } = makeCtx();
|
|
286
|
+
const det = nudgeDetector({ stuck: false, kind: 'exact', count: 0 });
|
|
287
|
+
const trip = await drain(emitRequestsAndNudgeOnStuck(ctx, [tool], det, nudgeReport));
|
|
288
|
+
expect(trip).toBeNull();
|
|
289
|
+
expect(det.resets).toBe(0);
|
|
290
|
+
expect(events.filter((e) => e.type !== 'tool_call_requested')).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
package/src/tool-dispatch.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { asToolCallId } from './ids.js';
|
|
|
2
2
|
import type { EmittedEvent, MoxxyEvent } from './events.js';
|
|
3
3
|
import type { ModeContext } from './mode.js';
|
|
4
4
|
import type { ToolCallVerdict } from './hooks.js';
|
|
5
|
-
|
|
5
|
+
// Import the concrete modules, not the './mode-helpers.js' barrel: the barrel
|
|
6
|
+
// re-exports the ReAct loop core, which imports THIS file — going through the
|
|
7
|
+
// barrel here closes that loop into a real import cycle.
|
|
8
|
+
import type { CollectedToolUse } from './mode/collect-stream.js';
|
|
9
|
+
import type { StuckLoopDetector, StuckSignal } from './mode/stuck-loop.js';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Execute a single tool-use end-to-end: dispatch `dispatchToolCall` hooks, run
|
|
@@ -282,3 +286,80 @@ export async function* emitRequestsAndDetectStuck(
|
|
|
282
286
|
}
|
|
283
287
|
return false;
|
|
284
288
|
}
|
|
289
|
+
|
|
290
|
+
/** A stuck-detector trip, as surfaced to the nudge policy. */
|
|
291
|
+
export interface StuckTripInfo {
|
|
292
|
+
readonly toolName: string;
|
|
293
|
+
readonly count: number;
|
|
294
|
+
readonly kind: StuckSignal['kind'];
|
|
295
|
+
/** Human phrasing of HOW it repeated ("with identical input" / the near hint). */
|
|
296
|
+
readonly how: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Wording + extra events for {@link emitRequestsAndNudgeOnStuck}. */
|
|
300
|
+
export interface StuckNudgeReport {
|
|
301
|
+
/** Explanation for a `near` match (an `exact` match always reads
|
|
302
|
+
* "with identical input"). */
|
|
303
|
+
readonly nearHint: string;
|
|
304
|
+
/** Build the visible (non-fatal) warning surfaced to the user on a trip. */
|
|
305
|
+
readonly warnMessage: (info: StuckTripInfo) => string;
|
|
306
|
+
/** Extra events to emit right before the warning — goal mode surfaces a
|
|
307
|
+
* `goal_stuck` plugin_event here. */
|
|
308
|
+
readonly extraOnStuck?: StuckLoopReport['extraOnStuck'];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The steer-don't-stop counterpart of {@link emitRequestsAndDetectStuck}: emit
|
|
313
|
+
* a `tool_call_requested` per call and feed the detector, but a trip NEVER
|
|
314
|
+
* aborts the turn — the batch still executes (the repeated calls are usually
|
|
315
|
+
* legitimate work, e.g. re-running a failing build between edits). Instead the
|
|
316
|
+
* trip is surfaced as a visible warning + the report's extra events, the
|
|
317
|
+
* detector is reset (fresh episode — no re-trip on every subsequent call while
|
|
318
|
+
* the window is still full), and the trip info is returned so the loop can
|
|
319
|
+
* steer the model with a volatile nudge on its next provider call.
|
|
320
|
+
*
|
|
321
|
+
* Used by modes that must never kill an unattended run on a repetition
|
|
322
|
+
* heuristic (goal mode). Returns the first trip of the batch, or null.
|
|
323
|
+
*/
|
|
324
|
+
export async function* emitRequestsAndNudgeOnStuck(
|
|
325
|
+
ctx: ModeContext,
|
|
326
|
+
toolUses: ReadonlyArray<CollectedToolUse>,
|
|
327
|
+
detector: StuckLoopDetector,
|
|
328
|
+
report: StuckNudgeReport,
|
|
329
|
+
): AsyncGenerator<MoxxyEvent, StuckTripInfo | null, unknown> {
|
|
330
|
+
let trip: StuckTripInfo | null = null;
|
|
331
|
+
for (const t of toolUses) {
|
|
332
|
+
yield await ctx.emit({
|
|
333
|
+
type: 'tool_call_requested',
|
|
334
|
+
sessionId: ctx.sessionId,
|
|
335
|
+
turnId: ctx.turnId,
|
|
336
|
+
source: 'model',
|
|
337
|
+
callId: asToolCallId(t.id),
|
|
338
|
+
name: t.name,
|
|
339
|
+
input: t.input,
|
|
340
|
+
});
|
|
341
|
+
const sig = detector.record(t.name, t.input);
|
|
342
|
+
if (!sig.stuck || trip) continue;
|
|
343
|
+
trip = {
|
|
344
|
+
toolName: t.name,
|
|
345
|
+
count: sig.count,
|
|
346
|
+
kind: sig.kind,
|
|
347
|
+
how: sig.kind === 'near' ? report.nearHint : 'with identical input',
|
|
348
|
+
};
|
|
349
|
+
detector.reset();
|
|
350
|
+
if (report.extraOnStuck) {
|
|
351
|
+
for (const e of report.extraOnStuck({ toolName: t.name, count: sig.count, kind: sig.kind })) {
|
|
352
|
+
yield await ctx.emit(e);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
yield await ctx.emit({
|
|
356
|
+
type: 'error',
|
|
357
|
+
sessionId: ctx.sessionId,
|
|
358
|
+
turnId: ctx.turnId,
|
|
359
|
+
source: 'system',
|
|
360
|
+
kind: 'retryable',
|
|
361
|
+
message: report.warnMessage(trip),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return trip;
|
|
365
|
+
}
|