@moxxy/sdk 0.1.0 → 0.1.3
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 +239 -0
- package/dist/compactor-helpers.d.ts +4 -1
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +50 -21
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/define.d.ts +4 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +6 -0
- package/dist/define.js.map +1 -1
- package/dist/embedding.d.ts +17 -0
- package/dist/embedding.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fs-utils.d.ts +27 -0
- package/dist/fs-utils.d.ts.map +1 -0
- package/dist/fs-utils.js +44 -0
- package/dist/fs-utils.js.map +1 -0
- package/dist/http-utils.d.ts +16 -0
- package/dist/http-utils.d.ts.map +1 -0
- package/dist/http-utils.js +40 -0
- package/dist/http-utils.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +58 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +119 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +8 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mutex.d.ts +15 -0
- package/dist/mutex.d.ts.map +1 -0
- package/dist/mutex.js +15 -0
- package/dist/mutex.js.map +1 -0
- package/dist/plugin.d.ts +27 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/provider-utils.d.ts +6 -0
- package/dist/provider-utils.d.ts.map +1 -1
- package/dist/provider-utils.js +8 -0
- package/dist/provider-utils.js.map +1 -1
- package/dist/resolvers.d.ts +52 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +123 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/session-like.d.ts +85 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/voice.d.ts +36 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +82 -0
- package/dist/voice.js.map +1 -0
- package/dist/workflow.d.ts +144 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +14 -0
- package/dist/workflow.js.map +1 -0
- package/package.json +38 -2
- package/src/compactor-helpers.test.ts +41 -0
- package/src/compactor-helpers.ts +53 -19
- package/src/define.ts +10 -0
- package/src/embedding.ts +18 -0
- package/src/errors.ts +3 -0
- package/src/fs-utils.test.ts +61 -0
- package/src/fs-utils.ts +55 -0
- package/src/http-utils.test.ts +36 -0
- package/src/http-utils.ts +40 -0
- package/src/index.ts +58 -3
- package/src/mode-helpers.ts +169 -0
- package/src/mode.ts +8 -0
- package/src/mutex.test.ts +36 -0
- package/src/mutex.ts +31 -0
- package/src/plugin.ts +27 -1
- package/src/provider-utils.ts +9 -0
- package/src/resolvers.test.ts +45 -0
- package/src/resolvers.ts +164 -0
- package/src/session-like.ts +87 -1
- package/src/stuck-loop.test.ts +42 -0
- package/src/voice.ts +103 -0
- package/src/workflow.ts +165 -0
package/src/mode-helpers.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
toolResultStubbed,
|
|
13
13
|
} from './elision-state.js';
|
|
14
14
|
import { applyLazyTools } from './tool-gating.js';
|
|
15
|
+
import { runCompactionIfNeeded } from './compactor-helpers.js';
|
|
16
|
+
import { runElisionIfNeeded } from './elision-helpers.js';
|
|
17
|
+
import { usageEventFields } from './token-accounting.js';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Shared bits used by every loop strategy: a typed tool-use struct and a
|
|
@@ -481,3 +484,169 @@ export async function collectProviderStream(
|
|
|
481
484
|
}
|
|
482
485
|
return { text, toolUses: finalToolUses, stopReason, error, ...(usage ? { usage } : {}) };
|
|
483
486
|
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Run a single-shot (no-tools) provider turn — the shape every planner /
|
|
490
|
+
* synthesis phase shares. Runs context management (compaction + elision),
|
|
491
|
+
* emits the `provider_request` bookend, streams the response with tools
|
|
492
|
+
* disabled, then emits either an `error` event (returning `null`) or the
|
|
493
|
+
* `provider_response` bookend (returning the collected text).
|
|
494
|
+
*
|
|
495
|
+
* Replaces the ~40-line block each mode phase used to inline; centralizing it
|
|
496
|
+
* keeps event emission uniform and means a fix here (e.g. always running
|
|
497
|
+
* elision) lands for every loop strategy at once.
|
|
498
|
+
*/
|
|
499
|
+
export async function runSingleShotTurn(
|
|
500
|
+
ctx: ModeContext,
|
|
501
|
+
messages: ReadonlyArray<ProviderMessage>,
|
|
502
|
+
opts: { maxTokens?: number } = {},
|
|
503
|
+
): Promise<string | null> {
|
|
504
|
+
await runCompactionIfNeeded(ctx);
|
|
505
|
+
await runElisionIfNeeded(ctx);
|
|
506
|
+
|
|
507
|
+
await ctx.emit({
|
|
508
|
+
type: 'provider_request',
|
|
509
|
+
sessionId: ctx.sessionId,
|
|
510
|
+
turnId: ctx.turnId,
|
|
511
|
+
source: 'system',
|
|
512
|
+
provider: ctx.provider.name,
|
|
513
|
+
model: ctx.model,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const { text, usage, error } = await collectProviderStream(ctx, messages, {
|
|
517
|
+
includeTools: false,
|
|
518
|
+
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
519
|
+
});
|
|
520
|
+
if (error) {
|
|
521
|
+
await ctx.emit({
|
|
522
|
+
type: 'error',
|
|
523
|
+
sessionId: ctx.sessionId,
|
|
524
|
+
turnId: ctx.turnId,
|
|
525
|
+
source: 'system',
|
|
526
|
+
kind: error.retryable ? 'retryable' : 'fatal',
|
|
527
|
+
message: error.message,
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await ctx.emit({
|
|
533
|
+
type: 'provider_response',
|
|
534
|
+
sessionId: ctx.sessionId,
|
|
535
|
+
turnId: ctx.turnId,
|
|
536
|
+
source: 'system',
|
|
537
|
+
provider: ctx.provider.name,
|
|
538
|
+
model: ctx.model,
|
|
539
|
+
...usageEventFields(usage),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return text;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Sliding-window detector for "model keeps making the same tool call".
|
|
547
|
+
*
|
|
548
|
+
* When the same `(toolName, input)` pair appears `repeatThreshold` times in
|
|
549
|
+
* the last `windowSize` calls, the model is almost certainly stuck — polling a
|
|
550
|
+
* tool that returns the same thing, mis-handling an error, etc. Bail early
|
|
551
|
+
* instead of burning through the iteration cap.
|
|
552
|
+
*
|
|
553
|
+
* Shared across every loop strategy so detection is uniform — previously each
|
|
554
|
+
* mode re-rolled this, and one copy used a non-canonical `JSON.stringify`
|
|
555
|
+
* signature that silently missed key-reordered repeats.
|
|
556
|
+
*/
|
|
557
|
+
export interface StuckSignal {
|
|
558
|
+
/** True when the loop guard should trip. */
|
|
559
|
+
readonly stuck: boolean;
|
|
560
|
+
/** Repeat count behind the trip — for the error message. */
|
|
561
|
+
readonly count: number;
|
|
562
|
+
/**
|
|
563
|
+
* `exact` = the same (tool, full-input) repeated `repeatThreshold` times.
|
|
564
|
+
* `near` = the same (tool, identity arg — url / file_path / command / …)
|
|
565
|
+
* repeated `nearThreshold` times while only volatile args (maxBytes,
|
|
566
|
+
* timeoutMs) varied. Catches the "refetch the same URL with a bigger
|
|
567
|
+
* maxBytes over and over" loop the exact check sails past.
|
|
568
|
+
*/
|
|
569
|
+
readonly kind: 'exact' | 'near';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface StuckLoopDetector {
|
|
573
|
+
readonly windowSize: number;
|
|
574
|
+
readonly repeatThreshold: number;
|
|
575
|
+
/** Record the call and report whether the loop guard should trip. */
|
|
576
|
+
record(toolName: string, input: unknown): StuckSignal;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Identity arguments that pin "the same target" across volatile-arg variation,
|
|
580
|
+
* best-first. The first present string field wins. */
|
|
581
|
+
const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
|
|
582
|
+
|
|
583
|
+
function identityArg(input: unknown): string | null {
|
|
584
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
585
|
+
const o = input as Record<string, unknown>;
|
|
586
|
+
for (const k of IDENTITY_ARG_KEYS) {
|
|
587
|
+
const v = o[k];
|
|
588
|
+
if (typeof v === 'string' && v.trim().length > 0) return `${k}=${v}`;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function createStuckLoopDetector(
|
|
594
|
+
opts: {
|
|
595
|
+
windowSize?: number;
|
|
596
|
+
repeatThreshold?: number;
|
|
597
|
+
nearWindowSize?: number;
|
|
598
|
+
nearThreshold?: number;
|
|
599
|
+
} = {},
|
|
600
|
+
): StuckLoopDetector {
|
|
601
|
+
const windowSize = opts.windowSize ?? 8;
|
|
602
|
+
const repeatThreshold = opts.repeatThreshold ?? 3;
|
|
603
|
+
// Near-dups need a higher count + a wider window (they're spread out across a
|
|
604
|
+
// burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
|
|
605
|
+
const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 16);
|
|
606
|
+
const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
|
|
607
|
+
const recent: string[] = [];
|
|
608
|
+
const recentNear: string[] = [];
|
|
609
|
+
return {
|
|
610
|
+
windowSize,
|
|
611
|
+
repeatThreshold,
|
|
612
|
+
record(toolName, input): StuckSignal {
|
|
613
|
+
const key = `${toolName}|${stableHash(input)}`;
|
|
614
|
+
recent.push(key);
|
|
615
|
+
if (recent.length > windowSize) recent.shift();
|
|
616
|
+
const exactCount = recent.filter((k) => k === key).length;
|
|
617
|
+
if (exactCount >= repeatThreshold) return { stuck: true, count: exactCount, kind: 'exact' };
|
|
618
|
+
|
|
619
|
+
let nearCount = 0;
|
|
620
|
+
const id = identityArg(input);
|
|
621
|
+
if (id !== null) {
|
|
622
|
+
const nearKey = `${toolName}|${id}`;
|
|
623
|
+
recentNear.push(nearKey);
|
|
624
|
+
if (recentNear.length > nearWindowSize) recentNear.shift();
|
|
625
|
+
nearCount = recentNear.filter((k) => k === nearKey).length;
|
|
626
|
+
if (nearCount >= nearThreshold) return { stuck: true, count: nearCount, kind: 'near' };
|
|
627
|
+
}
|
|
628
|
+
return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Stable, key-order-canonical hash of a tool call's input, so `{a:1,b:2}` and
|
|
635
|
+
* `{b:2,a:1}` produce the same key. Use for any "have I seen this call before"
|
|
636
|
+
* comparison — a raw `JSON.stringify` is NOT order-stable.
|
|
637
|
+
*/
|
|
638
|
+
export function stableHash(input: unknown): string {
|
|
639
|
+
return canonicalize(input);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function canonicalize(value: unknown): string {
|
|
643
|
+
if (value === null || value === undefined) return 'null';
|
|
644
|
+
if (typeof value !== 'object') return JSON.stringify(value);
|
|
645
|
+
if (Array.isArray(value)) {
|
|
646
|
+
return '[' + value.map(canonicalize).join(',') + ']';
|
|
647
|
+
}
|
|
648
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
649
|
+
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
|
|
650
|
+
);
|
|
651
|
+
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v)).join(',') + '}';
|
|
652
|
+
}
|
package/src/mode.ts
CHANGED
|
@@ -88,6 +88,14 @@ export interface ModeContext {
|
|
|
88
88
|
* Absent in synthetic test contexts that don't model a full Session.
|
|
89
89
|
*/
|
|
90
90
|
readonly subagents?: SubagentSpawner;
|
|
91
|
+
/**
|
|
92
|
+
* Request the session switch its active mode AFTER this turn fully drains.
|
|
93
|
+
* Used by terminal workflow modes (BMAD finishing its last phase) to hand
|
|
94
|
+
* control back to a normal mode so the next message isn't trapped in the
|
|
95
|
+
* workflow. The switch is applied post-turn by the runner; an unknown /
|
|
96
|
+
* unregistered mode name is ignored. Absent in synthetic test contexts.
|
|
97
|
+
*/
|
|
98
|
+
readonly requestModeSwitch?: (modeName: string) => void;
|
|
91
99
|
emit(event: EmittedEvent): Promise<MoxxyEvent>;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMutex } from './mutex.js';
|
|
3
|
+
|
|
4
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe('createMutex', () => {
|
|
7
|
+
it('serializes overlapping runs (no interleave)', async () => {
|
|
8
|
+
const mutex = createMutex();
|
|
9
|
+
const order: string[] = [];
|
|
10
|
+
const slow = mutex.run(async () => {
|
|
11
|
+
order.push('a:start');
|
|
12
|
+
await tick(20);
|
|
13
|
+
order.push('a:end');
|
|
14
|
+
});
|
|
15
|
+
const fast = mutex.run(async () => {
|
|
16
|
+
order.push('b:start');
|
|
17
|
+
await tick(1);
|
|
18
|
+
order.push('b:end');
|
|
19
|
+
});
|
|
20
|
+
await Promise.all([slow, fast]);
|
|
21
|
+
expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns the callback result', async () => {
|
|
25
|
+
const mutex = createMutex();
|
|
26
|
+
await expect(mutex.run(() => 42)).resolves.toBe(42);
|
|
27
|
+
await expect(mutex.run(async () => 'x')).resolves.toBe('x');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps the lock alive after a rejection', async () => {
|
|
31
|
+
const mutex = createMutex();
|
|
32
|
+
await expect(mutex.run(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom');
|
|
33
|
+
// Next run must still execute — the chain isn't poisoned.
|
|
34
|
+
await expect(mutex.run(() => 'recovered')).resolves.toBe('recovered');
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/mutex.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance write mutex: a promise chain that serializes async mutators so
|
|
3
|
+
* two overlapping read-modify-write cycles can't both read the same snapshot
|
|
4
|
+
* and clobber each other.
|
|
5
|
+
*
|
|
6
|
+
* The single home for the framework's "serialize file-state mutators"
|
|
7
|
+
* invariant. Stores (vault, memory, permissions, scheduler, webhooks, …) hold
|
|
8
|
+
* one `Mutex` instance and run every mutation through {@link Mutex.run}.
|
|
9
|
+
*/
|
|
10
|
+
export interface Mutex {
|
|
11
|
+
/** Run `fn` after all previously-queued runs settle. */
|
|
12
|
+
run<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createMutex(): Mutex {
|
|
16
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
17
|
+
return {
|
|
18
|
+
run<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
19
|
+
// `.then(fn, fn)` runs `fn` whether the previous run resolved or
|
|
20
|
+
// rejected, so one failed mutation doesn't deadlock the queue.
|
|
21
|
+
const next = chain.then(fn as () => T, fn as () => T);
|
|
22
|
+
// Keep the chain alive across rejections — never let a rejected link
|
|
23
|
+
// permanently poison the lock.
|
|
24
|
+
chain = next.then(
|
|
25
|
+
() => undefined,
|
|
26
|
+
() => undefined,
|
|
27
|
+
);
|
|
28
|
+
return next;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { CacheStrategyDef } from './cache-strategy.js';
|
|
|
3
3
|
import type { ChannelDef } from './channel.js';
|
|
4
4
|
import type { CommandDef } from './command.js';
|
|
5
5
|
import type { CompactorDef } from './compactor.js';
|
|
6
|
+
import type { EmbedderDef } from './embedding.js';
|
|
7
|
+
import type { Isolator } from './isolation.js';
|
|
6
8
|
import type { LifecycleHooks } from './hooks.js';
|
|
7
9
|
import type { ModeDef } from './mode.js';
|
|
8
10
|
import type { ProviderDef } from './provider.js';
|
|
@@ -11,8 +13,9 @@ import type { ToolDef } from './tool.js';
|
|
|
11
13
|
import type { TranscriberDef } from './transcriber.js';
|
|
12
14
|
import type { ViewRendererDef } from './view-renderer.js';
|
|
13
15
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
16
|
+
import type { WorkflowExecutorDef } from './workflow.js';
|
|
14
17
|
|
|
15
|
-
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber';
|
|
18
|
+
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'embedder' | 'isolator' | 'workflow-executor';
|
|
16
19
|
|
|
17
20
|
export interface PluginSpec {
|
|
18
21
|
readonly name: string;
|
|
@@ -47,6 +50,22 @@ export interface PluginSpec {
|
|
|
47
50
|
* the active provider does not advertise `supportsAudio`.
|
|
48
51
|
*/
|
|
49
52
|
readonly transcribers?: ReadonlyArray<TranscriberDef>;
|
|
53
|
+
/**
|
|
54
|
+
* Text-embedding backends contributed by the plugin. Selected by name via
|
|
55
|
+
* `session.embedders.setActive(name, config)`; @moxxy/plugin-memory uses the
|
|
56
|
+
* active embedder for semantic recall. `createClient` is lazy so a discovered
|
|
57
|
+
* embedder plugin never pulls its (often heavy) runtime in until selected.
|
|
58
|
+
*/
|
|
59
|
+
readonly embedders?: ReadonlyArray<EmbedderDef>;
|
|
60
|
+
/**
|
|
61
|
+
* Capability isolators contributed by the plugin (worker_threads, subprocess,
|
|
62
|
+
* wasm, …). Registered into the active security layer's `IsolatorRegistry`
|
|
63
|
+
* and selected by name via `security.isolator` config. Registration alone is
|
|
64
|
+
* inert — a contributed isolator is NEVER auto-activated as the sandbox
|
|
65
|
+
* boundary; the user must opt in by name (so a rogue plugin can't silently
|
|
66
|
+
* weaken isolation).
|
|
67
|
+
*/
|
|
68
|
+
readonly isolators?: ReadonlyArray<Isolator>;
|
|
50
69
|
/**
|
|
51
70
|
* Typed subagent kinds the plugin contributes. Each becomes
|
|
52
71
|
* dispatchable as `dispatch_agent({ agentType: <name>, ... })`.
|
|
@@ -64,6 +83,13 @@ export interface PluginSpec {
|
|
|
64
83
|
* pickers, raw-mode toggles) inside the channel itself.
|
|
65
84
|
*/
|
|
66
85
|
readonly commands?: ReadonlyArray<CommandDef>;
|
|
86
|
+
/**
|
|
87
|
+
* Workflow-execution strategies contributed by the plugin. One is active per
|
|
88
|
+
* session (selected via `session.workflowExecutors.setActive(name)`); the
|
|
89
|
+
* active executor runs a workflow DAG. `@moxxy/plugin-workflows` ships the
|
|
90
|
+
* default `dag` executor.
|
|
91
|
+
*/
|
|
92
|
+
readonly workflowExecutors?: ReadonlyArray<WorkflowExecutorDef>;
|
|
67
93
|
readonly hooks?: LifecycleHooks;
|
|
68
94
|
readonly skillsDir?: string;
|
|
69
95
|
}
|
package/src/provider-utils.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { classifyNetworkError } from './errors.js';
|
|
|
8
8
|
/** Canonical stop reasons emitted on `message_end` events. */
|
|
9
9
|
export type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Rough token estimate for a blob of text: ~4 chars per token. The shared
|
|
13
|
+
* fallback for a provider's `countTokens` when no native tokenizer is wired —
|
|
14
|
+
* previously each provider hardcoded `Math.ceil(text.length / 4)` independently.
|
|
15
|
+
*/
|
|
16
|
+
export function estimateTextTokens(text: string): number {
|
|
17
|
+
return Math.ceil(text.length / 4);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
21
|
* Heuristic: should a provider request retry on this error? Used by stream
|
|
13
22
|
* modes to attach a `retryable: boolean` flag to the `error` event so callers
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluateToolRule } from './resolvers.js';
|
|
3
|
+
import type { PendingToolCall, PermissionRule } from './permission.js';
|
|
4
|
+
|
|
5
|
+
function call(name: string, input: unknown = {}): PendingToolCall {
|
|
6
|
+
return { callId: 'c1', name, input } as unknown as PendingToolCall;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('evaluateToolRule', () => {
|
|
10
|
+
it('returns null when the tool declares no rule (defer to resolver)', () => {
|
|
11
|
+
expect(evaluateToolRule(undefined, call('reload_skills'))).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('allows a tool that declares action allow', () => {
|
|
15
|
+
const rule: PermissionRule = { action: 'allow' };
|
|
16
|
+
expect(evaluateToolRule(rule, call('reload_skills'))).toEqual({
|
|
17
|
+
mode: 'allow',
|
|
18
|
+
reason: 'tool-declared allow',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('denies a tool that declares action deny', () => {
|
|
23
|
+
const rule: PermissionRule = { action: 'deny', reason: 'nope' };
|
|
24
|
+
expect(evaluateToolRule(rule, call('danger'))).toEqual({ mode: 'deny', reason: 'nope' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('defers (null) for action prompt so the interactive resolver decides', () => {
|
|
28
|
+
expect(evaluateToolRule({ action: 'prompt' }, call('self_update_verify'))).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('only applies when the name pattern matches', () => {
|
|
32
|
+
const rule: PermissionRule = { action: 'allow', pattern: { name: 'reload_skills' } };
|
|
33
|
+
expect(evaluateToolRule(rule, call('reload_skills'))).not.toBeNull();
|
|
34
|
+
expect(evaluateToolRule(rule, call('other_tool'))).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('matches on inputMatches (string + RegExp)', () => {
|
|
38
|
+
const rule: PermissionRule = {
|
|
39
|
+
action: 'allow',
|
|
40
|
+
pattern: { inputMatches: { path: /^\/tmp\// } },
|
|
41
|
+
};
|
|
42
|
+
expect(evaluateToolRule(rule, call('write', { path: '/tmp/ok.txt' }))).not.toBeNull();
|
|
43
|
+
expect(evaluateToolRule(rule, call('write', { path: '/etc/passwd' }))).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/resolvers.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PendingToolCall,
|
|
3
|
+
PermissionContext,
|
|
4
|
+
PermissionDecision,
|
|
5
|
+
PermissionResolver,
|
|
6
|
+
PermissionRule,
|
|
7
|
+
} from './permission.js';
|
|
8
|
+
|
|
9
|
+
export const autoAllowResolver: PermissionResolver = {
|
|
10
|
+
name: 'auto-allow',
|
|
11
|
+
async check(): Promise<PermissionDecision> {
|
|
12
|
+
return { mode: 'allow', reason: 'auto-allow resolver (test mode)' };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const denyByDefaultResolver: PermissionResolver = {
|
|
17
|
+
name: 'deny-by-default',
|
|
18
|
+
async check(): Promise<PermissionDecision> {
|
|
19
|
+
return { mode: 'deny', reason: 'No interactive resolver available in headless mode. Use --allow-tools or permissions.json.' };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface CallbackResolverOptions {
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
readonly callback: (call: PendingToolCall, ctx: PermissionContext) => Promise<PermissionDecision>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createCallbackResolver(opts: CallbackResolverOptions): PermissionResolver {
|
|
29
|
+
return {
|
|
30
|
+
name: opts.name ?? 'callback',
|
|
31
|
+
check: opts.callback,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createAllowListResolver(toolNames: ReadonlyArray<string>): PermissionResolver {
|
|
36
|
+
const allowed = new Set(toolNames);
|
|
37
|
+
return {
|
|
38
|
+
name: 'allow-list',
|
|
39
|
+
async check(call) {
|
|
40
|
+
if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list' };
|
|
41
|
+
return { mode: 'deny', reason: `Tool '${call.name}' not in allow-list` };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PermissionPromptHandler = (
|
|
47
|
+
call: PendingToolCall,
|
|
48
|
+
ctx: PermissionContext,
|
|
49
|
+
) => Promise<PermissionDecision>;
|
|
50
|
+
|
|
51
|
+
export interface DeferredPermissionResolverOptions {
|
|
52
|
+
readonly prompt: PermissionPromptHandler;
|
|
53
|
+
readonly name?: string;
|
|
54
|
+
readonly sessionAllows?: Set<string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface DeferredPermissionResolver extends PermissionResolver {
|
|
58
|
+
/**
|
|
59
|
+
* Resolve all in-flight prompts with `deny`. Call from a channel's `stop`
|
|
60
|
+
* so a pending permission prompt doesn't hang forever when the host UI
|
|
61
|
+
* unmounts (the TUI bug the audit flagged).
|
|
62
|
+
*/
|
|
63
|
+
abortAll(reason?: string): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolver for channels that defer permission decisions to an external UI
|
|
68
|
+
* (Ink dialog, Telegram inline keyboard, web form). Wraps a `prompt`
|
|
69
|
+
* callback with:
|
|
70
|
+
* - sessionAllows shortcut — `allow_session` decisions skip subsequent
|
|
71
|
+
* prompts for the same tool name.
|
|
72
|
+
* - in-flight tracking — `abortAll()` resolves pending prompts with
|
|
73
|
+
* `deny`, so the channel can shut down cleanly without hangs.
|
|
74
|
+
*/
|
|
75
|
+
export function createDeferredPermissionResolver(
|
|
76
|
+
opts: DeferredPermissionResolverOptions,
|
|
77
|
+
): DeferredPermissionResolver {
|
|
78
|
+
const sessionAllows = opts.sessionAllows ?? new Set<string>();
|
|
79
|
+
const pending = new Set<(d: PermissionDecision) => void>();
|
|
80
|
+
return {
|
|
81
|
+
name: opts.name ?? 'deferred',
|
|
82
|
+
async check(call, ctx) {
|
|
83
|
+
if (sessionAllows.has(call.name)) {
|
|
84
|
+
return { mode: 'allow_session', reason: 'allow_session previously granted' };
|
|
85
|
+
}
|
|
86
|
+
const decision = await new Promise<PermissionDecision>((resolve) => {
|
|
87
|
+
pending.add(resolve);
|
|
88
|
+
opts.prompt(call, ctx).then(
|
|
89
|
+
(d) => {
|
|
90
|
+
pending.delete(resolve);
|
|
91
|
+
resolve(d);
|
|
92
|
+
},
|
|
93
|
+
(err) => {
|
|
94
|
+
pending.delete(resolve);
|
|
95
|
+
resolve({ mode: 'deny', reason: err instanceof Error ? err.message : String(err) });
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
// Both allow_session and allow_always should skip future prompts for
|
|
100
|
+
// the same tool within this resolver instance. allow_always
|
|
101
|
+
// additionally signals to the caller (via the decision flag) that
|
|
102
|
+
// the rule should be persisted to ~/.moxxy/permissions.json — but
|
|
103
|
+
// that persistence isn't our job; the channel does it when wiring
|
|
104
|
+
// up the dialog.
|
|
105
|
+
if (decision.mode === 'allow_session' || decision.mode === 'allow_always') {
|
|
106
|
+
sessionAllows.add(call.name);
|
|
107
|
+
}
|
|
108
|
+
return decision;
|
|
109
|
+
},
|
|
110
|
+
abortAll(reason = 'channel closed') {
|
|
111
|
+
for (const r of pending) r({ mode: 'deny', reason });
|
|
112
|
+
pending.clear();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Evaluate a tool's OWN declared {@link PermissionRule} against a call.
|
|
119
|
+
*
|
|
120
|
+
* Tools can ship a `permission` rule to express their author's policy — e.g.
|
|
121
|
+
* `reload_skills` / `load_tool` declare `{ action: 'allow' }` because they're
|
|
122
|
+
* safe, internal, idempotent operations that should never prompt. That rule
|
|
123
|
+
* was previously stored on the ToolDef but never consulted, so in headless
|
|
124
|
+
* runs (where the channel resolver denies by default) even these safe tools
|
|
125
|
+
* were blocked. The session resolver now consults this BETWEEN the user's
|
|
126
|
+
* `permissions.json` policy and the channel resolver, so:
|
|
127
|
+
*
|
|
128
|
+
* user policy (deny/allow) > tool-declared rule > channel resolver
|
|
129
|
+
*
|
|
130
|
+
* Returns a decision for a matching `allow`/`deny` rule, or `null` to defer
|
|
131
|
+
* (no rule, pattern didn't match, or `action: 'prompt'` — the interactive
|
|
132
|
+
* resolver should handle those).
|
|
133
|
+
*/
|
|
134
|
+
export function evaluateToolRule(
|
|
135
|
+
rule: PermissionRule | undefined,
|
|
136
|
+
call: PendingToolCall,
|
|
137
|
+
): PermissionDecision | null {
|
|
138
|
+
if (!rule || !toolRuleMatches(rule, call)) return null;
|
|
139
|
+
switch (rule.action) {
|
|
140
|
+
case 'allow':
|
|
141
|
+
return { mode: 'allow', reason: rule.reason ?? 'tool-declared allow' };
|
|
142
|
+
case 'deny':
|
|
143
|
+
return { mode: 'deny', reason: rule.reason ?? 'tool-declared deny' };
|
|
144
|
+
case 'prompt':
|
|
145
|
+
return null; // defer to the interactive resolver
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toolRuleMatches(rule: PermissionRule, call: PendingToolCall): boolean {
|
|
150
|
+
const p = rule.pattern;
|
|
151
|
+
if (!p) return true; // no pattern → applies to every call of this tool
|
|
152
|
+
if (p.name !== undefined && !patternMatch(p.name, call.name)) return false;
|
|
153
|
+
if (p.inputMatches) {
|
|
154
|
+
const input = (call.input ?? {}) as Record<string, unknown>;
|
|
155
|
+
for (const [key, matcher] of Object.entries(p.inputMatches)) {
|
|
156
|
+
if (!patternMatch(matcher, String(input[key] ?? ''))) return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function patternMatch(pattern: string | RegExp, candidate: string): boolean {
|
|
163
|
+
return pattern instanceof RegExp ? pattern.test(candidate) : pattern === candidate;
|
|
164
|
+
}
|
package/src/session-like.ts
CHANGED
|
@@ -40,10 +40,23 @@ export interface SessionLogReader extends EventLogReader {
|
|
|
40
40
|
subscribe(fn: (event: MoxxyEvent) => void | Promise<void>): () => void;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/** How a provider authenticates. UIs use this to decide whether to
|
|
44
|
+
* show an API-key field or kick off an OAuth flow. */
|
|
45
|
+
export type ProviderAuthKind = 'api-key' | 'oauth';
|
|
46
|
+
|
|
47
|
+
/** Serializable provider metadata (models + context windows + auth)
|
|
48
|
+
* for display. */
|
|
44
49
|
export interface ProviderInfo {
|
|
45
50
|
readonly name: string;
|
|
46
51
|
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
52
|
+
/** 'oauth' when the provider declares an oauth login on its plugin
|
|
53
|
+
* definition, 'api-key' otherwise. Defaults to 'api-key' for
|
|
54
|
+
* providers that don't declare. */
|
|
55
|
+
readonly authKind: ProviderAuthKind;
|
|
56
|
+
/** True when the provider's plugin can list its models live (e.g.
|
|
57
|
+
* via /v1/models). Lets the desktop's model picker show a
|
|
58
|
+
* "Fetch live" affordance only where it makes sense. */
|
|
59
|
+
readonly supportsLiveModelDiscovery: boolean;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
/** Serializable tool metadata for status lines / slash menus / compact rendering. */
|
|
@@ -94,6 +107,64 @@ export interface SessionInfo {
|
|
|
94
107
|
readonly activeTranscriber: string | null;
|
|
95
108
|
}
|
|
96
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Resolves a provider's stored credentials (vault tokens / API keys) into the
|
|
112
|
+
* config object `providers.setActive` needs. The host installs one on a local
|
|
113
|
+
* Session at boot; it is undefined across a `RemoteSession` transport (a closure
|
|
114
|
+
* can't cross the wire — the runner side resolves credentials there instead).
|
|
115
|
+
*/
|
|
116
|
+
export type CredentialResolver = (providerName: string) => Promise<Record<string, unknown>>;
|
|
117
|
+
|
|
118
|
+
/** One server's status in {@link McpAdminView.listServers}. */
|
|
119
|
+
export interface McpServerStatusView {
|
|
120
|
+
readonly name: string;
|
|
121
|
+
readonly enabled: boolean;
|
|
122
|
+
readonly connected: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The slice of the MCP admin API a channel needs to drive the MCP picker and
|
|
127
|
+
* status line. Present on a local Session when the MCP admin plugin is wired;
|
|
128
|
+
* a `RemoteSession` leaves {@link SessionLike.mcpAdmin} undefined and the UI
|
|
129
|
+
* degrades gracefully.
|
|
130
|
+
*/
|
|
131
|
+
export interface McpAdminView {
|
|
132
|
+
enableAndAttach(name: string): Promise<{ toolNames: ReadonlyArray<string> } | null>;
|
|
133
|
+
detach(name: string): Promise<boolean>;
|
|
134
|
+
listServers(): Promise<ReadonlyArray<McpServerStatusView>>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One workflow's summary for the `/workflows` modal. */
|
|
138
|
+
export interface WorkflowSummaryView {
|
|
139
|
+
readonly name: string;
|
|
140
|
+
readonly description: string;
|
|
141
|
+
readonly enabled: boolean;
|
|
142
|
+
readonly scope: string;
|
|
143
|
+
readonly steps: number;
|
|
144
|
+
/** Human-readable trigger summary, e.g. `cron(0 8 * * *)` or `on-demand`. */
|
|
145
|
+
readonly triggers: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Result of running a workflow from the modal. */
|
|
149
|
+
export interface WorkflowRunView {
|
|
150
|
+
readonly ok: boolean;
|
|
151
|
+
readonly output: string;
|
|
152
|
+
readonly error?: string;
|
|
153
|
+
readonly steps: ReadonlyArray<{ readonly id: string; readonly status: string; readonly error?: string }>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The slice of the workflows API a channel needs to drive the `/workflows`
|
|
158
|
+
* modal (list, enable/disable toggle, run). Present on a local Session when
|
|
159
|
+
* `@moxxy/plugin-workflows` is wired; a `RemoteSession` leaves
|
|
160
|
+
* {@link SessionLike.workflows} undefined and the UI degrades gracefully.
|
|
161
|
+
*/
|
|
162
|
+
export interface WorkflowsView {
|
|
163
|
+
list(): Promise<ReadonlyArray<WorkflowSummaryView>>;
|
|
164
|
+
setEnabled(name: string, enabled: boolean): Promise<void>;
|
|
165
|
+
run(name: string): Promise<WorkflowRunView>;
|
|
166
|
+
}
|
|
167
|
+
|
|
97
168
|
/**
|
|
98
169
|
* The session surface a `Channel` depends on, decoupled from whether the
|
|
99
170
|
* session runs in-process (`@moxxy/core`'s `Session`) or is a thin-client
|
|
@@ -115,4 +186,19 @@ export interface SessionLike {
|
|
|
115
186
|
/** Wire-friendly registry snapshot for rendering. */
|
|
116
187
|
getInfo(): SessionInfo;
|
|
117
188
|
close(reason?: string): Promise<void>;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Live runtime capabilities present only on an in-process Session; a
|
|
192
|
+
* `RemoteSession` thin client leaves them undefined, so callers MUST guard.
|
|
193
|
+
* For plain display prefer the serializable {@link getInfo} snapshot — these
|
|
194
|
+
* are for the mutate/guard paths a channel drives (provider switch, MCP picker).
|
|
195
|
+
*/
|
|
196
|
+
/** Providers whose credentials resolved this session (live, mutable). */
|
|
197
|
+
readyProviders?: Set<string>;
|
|
198
|
+
/** Re-resolves a provider's credentials before `providers.setActive`. */
|
|
199
|
+
credentialResolver?: CredentialResolver;
|
|
200
|
+
/** MCP admin slice backing the MCP picker / status line. */
|
|
201
|
+
mcpAdmin?: McpAdminView;
|
|
202
|
+
/** Workflows slice backing the `/workflows` modal. */
|
|
203
|
+
workflows?: WorkflowsView;
|
|
118
204
|
}
|