@moxxy/sdk 0.41.3 → 0.42.1
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/auto-approve.d.ts +14 -0
- package/dist/auto-approve.d.ts.map +1 -0
- package/dist/auto-approve.js +29 -0
- package/dist/auto-approve.js.map +1 -0
- package/dist/channel.d.ts +9 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +14 -0
- package/dist/channel.js.map +1 -1
- package/dist/client-session.d.ts +3 -0
- package/dist/client-session.d.ts.map +1 -1
- package/dist/compactor-helpers.d.ts +1 -1
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +14 -4
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/computer-control.d.ts +77 -3
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +12 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conduct.d.ts +16 -0
- package/dist/conduct.d.ts.map +1 -0
- package/dist/conduct.js +33 -0
- package/dist/conduct.js.map +1 -0
- package/dist/define.d.ts +3 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +3 -0
- package/dist/define.js.map +1 -1
- package/dist/elision-state.d.ts +9 -0
- package/dist/elision-state.d.ts.map +1 -1
- package/dist/elision-state.js +16 -3
- package/dist/elision-state.js.map +1 -1
- package/dist/events.d.ts +13 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +43 -2
- package/dist/fs-utils.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/locale.d.ts +9 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +16 -0
- package/dist/locale.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +7 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +3 -2
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +7 -0
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +26 -4
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +5 -3
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode/stuck-loop.d.ts +8 -2
- package/dist/mode/stuck-loop.d.ts.map +1 -1
- package/dist/mode/stuck-loop.js +9 -1
- 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 +1 -1
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +10 -3
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/permission.d.ts +6 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/progress.d.ts +24 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +59 -0
- package/dist/progress.js.map +1 -0
- package/dist/provider.d.ts +11 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/resolvers.js +2 -2
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +9 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -0
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +21 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/skill-mentions.d.ts +38 -0
- package/dist/skill-mentions.d.ts.map +1 -0
- package/dist/skill-mentions.js +84 -0
- package/dist/skill-mentions.js.map +1 -0
- package/dist/skill.d.ts +6 -0
- package/dist/skill.d.ts.map +1 -1
- package/dist/supersede.d.ts +31 -0
- package/dist/supersede.d.ts.map +1 -0
- package/dist/supersede.js +34 -0
- package/dist/supersede.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +3 -2
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-gating.d.ts +9 -1
- package/dist/tool-gating.d.ts.map +1 -1
- package/dist/tool-gating.js +57 -7
- package/dist/tool-gating.js.map +1 -1
- package/dist/tool-modes.d.ts +8 -0
- package/dist/tool-modes.d.ts.map +1 -0
- package/dist/tool-modes.js +21 -0
- package/dist/tool-modes.js.map +1 -0
- package/dist/tool.d.ts +18 -0
- package/dist/tool.d.ts.map +1 -1
- package/dist/wait-for.d.ts +34 -0
- package/dist/wait-for.d.ts.map +1 -0
- package/dist/wait-for.js +67 -0
- package/dist/wait-for.js.map +1 -0
- package/package.json +9 -5
- package/src/auto-approve.test.ts +29 -0
- package/src/auto-approve.ts +29 -0
- package/src/channel-auth.test.ts +2 -1
- package/src/channel-control.test.ts +2 -1
- package/src/channel.ts +18 -0
- package/src/client-session.ts +3 -0
- package/src/compactor-helpers.test.ts +26 -0
- package/src/compactor-helpers.ts +16 -4
- package/src/computer-control.test.ts +11 -0
- package/src/computer-control.ts +16 -1
- package/src/conduct.test.ts +62 -0
- package/src/conduct.ts +37 -0
- package/src/cross-process-lock.test.ts +3 -2
- package/src/define.ts +6 -0
- package/src/elision-state.ts +24 -3
- package/src/events.ts +13 -1
- package/src/finish-pairing.test.ts +45 -0
- package/src/fs-utils.test.ts +40 -6
- package/src/fs-utils.ts +42 -2
- package/src/index.ts +38 -1
- package/src/json-file-store.test.ts +5 -3
- package/src/locale.test.ts +31 -0
- package/src/locale.ts +16 -0
- package/src/mode/checkpoint.ts +7 -0
- package/src/mode/collect-stream.ts +3 -2
- package/src/mode/project-messages.test.ts +72 -1
- package/src/mode/project-messages.ts +26 -5
- package/src/mode/react-loop.ts +6 -3
- package/src/mode/stuck-loop.ts +14 -3
- package/src/mode-helpers.ts +1 -0
- package/src/mode.ts +10 -3
- package/src/permission.ts +6 -0
- package/src/process-invocation.test.ts +3 -9
- package/src/progress.test.ts +81 -0
- package/src/progress.ts +73 -0
- package/src/provider.ts +12 -1
- package/src/resolvers.test.ts +14 -1
- package/src/resolvers.ts +2 -2
- package/src/schemas.ts +3 -0
- package/src/server.ts +2 -0
- package/src/session-like.ts +21 -1
- package/src/skill-mentions.test.ts +113 -0
- package/src/skill-mentions.ts +108 -0
- package/src/skill.ts +6 -0
- package/src/stuck-loop.test.ts +12 -0
- package/src/supersede.test.ts +115 -0
- package/src/supersede.ts +55 -0
- package/src/test-isolation.test.ts +26 -0
- package/src/token-efficiency.test.ts +7 -1
- package/src/tool-dispatch.test.ts +13 -0
- package/src/tool-dispatch.ts +3 -2
- package/src/tool-gating.test.ts +81 -0
- package/src/tool-gating.ts +53 -7
- package/src/tool-modes.test.ts +45 -0
- package/src/tool-modes.ts +23 -0
- package/src/tool.ts +18 -0
- package/src/wait-for.test.ts +132 -0
- package/src/wait-for.ts +79 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool } from './define.js';
|
|
4
|
+
import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from './events.js';
|
|
5
|
+
import { asEventId, asSessionId, asToolCallId, asTurnId, type TurnId } from './ids.js';
|
|
6
|
+
import type { EventLogReader } from './log.js';
|
|
7
|
+
import type { ProviderMessage } from './provider.js';
|
|
8
|
+
import { LAZY_TOOLS_AUTO_THRESHOLD, applyLazyTools, matchLoadableTools, shouldGateTools } from './tool-gating.js';
|
|
9
|
+
|
|
10
|
+
const mk = (name: string) => defineTool({ name, description: `desc ${name}`, inputSchema: z.object({}), handler: () => '' });
|
|
11
|
+
const messages: ProviderMessage[] = [{ role: 'system', content: [{ type: 'text', text: 'sys' }] }];
|
|
12
|
+
const systemText = (out: ReadonlyArray<ProviderMessage>) => (out[0]?.content[0] as { text: string }).text;
|
|
13
|
+
|
|
14
|
+
function logOfLoads(...names: string[]): EventLogReader {
|
|
15
|
+
const events = names.map((name, seq) => ({
|
|
16
|
+
id: asEventId(`e${seq}`), seq, ts: seq, sessionId: asSessionId('s'), turnId: asTurnId('t'), source: 'model',
|
|
17
|
+
type: 'tool_call_requested', callId: asToolCallId(`c${seq}`), name: 'load_tool', input: { name },
|
|
18
|
+
}) as MoxxyEvent);
|
|
19
|
+
return {
|
|
20
|
+
length: events.length,
|
|
21
|
+
at: (seq) => events[seq],
|
|
22
|
+
slice: (from = 0, to = events.length) => events.slice(from, to),
|
|
23
|
+
ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> => events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
|
|
24
|
+
byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
|
|
25
|
+
toJSON: () => events,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const computer = ['computer_status', 'computer_click', 'computer_drag', 'computer_zoom'].map(mk);
|
|
30
|
+
const zoho = ['mcp__zoho__list', 'mcp__zoho__get', 'mcp__zoho__add', 'mcp__zoho__drop'].map(mk);
|
|
31
|
+
|
|
32
|
+
describe('tool families', () => {
|
|
33
|
+
it('lists a family of tools as one line that names how to load all of it', () => {
|
|
34
|
+
const { messages: out, tools } = applyLazyTools(messages, [mk('Read'), ...computer, mk('web_fetch')], logOfLoads());
|
|
35
|
+
expect(tools.map((t) => t.name)).toEqual(['Read']);
|
|
36
|
+
const text = systemText(out);
|
|
37
|
+
expect(text).toContain('- **computer_*** (4 tools: computer_status, computer_click, computer_drag, computer_zoom)');
|
|
38
|
+
expect(text).toContain('load_tool({ name: "computer_*" })');
|
|
39
|
+
expect(text).toContain('- **web_fetch** — desc web_fetch');
|
|
40
|
+
expect(text).not.toContain('desc computer_click');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('groups tools of one MCP server by the server, not by "mcp"', () => {
|
|
44
|
+
const text = systemText(applyLazyTools(messages, [...zoho, mk('mcp__mail__send')], logOfLoads()).messages);
|
|
45
|
+
expect(text).toContain('- **mcp__zoho__*** (4 tools: ');
|
|
46
|
+
expect(text).toContain('- **mcp__mail__send** — ');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('loads the whole family from one load_tool call, and leaves other families indexed', () => {
|
|
50
|
+
const { messages: out, tools } = applyLazyTools(messages, [mk('Read'), ...computer, ...zoho], logOfLoads('computer_*'));
|
|
51
|
+
expect(tools.map((t) => t.name)).toEqual(['Read', ...computer.map((t) => t.name)]);
|
|
52
|
+
expect(systemText(out)).not.toContain('computer_click');
|
|
53
|
+
expect(systemText(out)).toContain('mcp__zoho__*');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('sends a tool marked always loaded in full, and indexes the rest of its family', () => {
|
|
57
|
+
const run = defineTool({ name: 'computer_run', description: 'desc computer_run', inputSchema: z.object({}), alwaysLoaded: true, handler: () => '' });
|
|
58
|
+
const { messages: out, tools } = applyLazyTools(messages, [mk('Read'), run, ...computer], logOfLoads());
|
|
59
|
+
expect(tools.map((t) => t.name)).toEqual(['Read', 'computer_run']);
|
|
60
|
+
expect(systemText(out)).toContain('- **computer_*** (4 tools: computer_status, computer_click, computer_drag, computer_zoom)');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('resolves what a load_tool name stands for', () => {
|
|
64
|
+
const all = [...computer, ...zoho];
|
|
65
|
+
expect(matchLoadableTools('computer_*', all).map((t) => t.name)).toEqual(computer.map((t) => t.name));
|
|
66
|
+
expect(matchLoadableTools('computer_click', all).map((t) => t.name)).toEqual(['computer_click']);
|
|
67
|
+
expect(matchLoadableTools('nothing_*', all)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('shouldGateTools', () => {
|
|
72
|
+
it('follows an explicit setting', () => {
|
|
73
|
+
expect(shouldGateTools(true, 3)).toBe(true);
|
|
74
|
+
expect(shouldGateTools(false, 5000)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('turns itself on when nothing is set and the tool list is long', () => {
|
|
78
|
+
expect(shouldGateTools(undefined, LAZY_TOOLS_AUTO_THRESHOLD)).toBe(false);
|
|
79
|
+
expect(shouldGateTools(undefined, LAZY_TOOLS_AUTO_THRESHOLD + 1)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/tool-gating.ts
CHANGED
|
@@ -50,16 +50,61 @@ function truncate(s: string, n: number): string {
|
|
|
50
50
|
return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/** Above this many registered tools, lazy loading turns itself on unless the config says otherwise. */
|
|
54
|
+
export const LAZY_TOOLS_AUTO_THRESHOLD = 200;
|
|
55
|
+
|
|
56
|
+
/** Whether to gate tools: an explicit setting wins; unset means "only when the list is long". */
|
|
57
|
+
export function shouldGateTools(setting: boolean | undefined, toolCount: number): boolean {
|
|
58
|
+
return setting ?? toolCount > LAZY_TOOLS_AUTO_THRESHOLD;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A family needs this many members before the index folds it into one line. */
|
|
62
|
+
const FAMILY_MIN = 4;
|
|
63
|
+
|
|
64
|
+
/** The prefix a tool shares with its siblings: its MCP server, or the word before the first underscore. */
|
|
65
|
+
function familyPrefix(name: string): string | null {
|
|
66
|
+
const mcp = /^mcp__[^_]+(?:_[^_]+)*__/.exec(name);
|
|
67
|
+
if (mcp) return mcp[0];
|
|
68
|
+
const cut = name.indexOf('_');
|
|
69
|
+
return cut > 0 ? name.slice(0, cut + 1) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The tools a `load_tool` name stands for: one tool, or a family written as `prefix*`. */
|
|
73
|
+
export function matchLoadableTools<T extends { readonly name: string }>(name: string, tools: ReadonlyArray<T>): T[] {
|
|
74
|
+
if (!name.endsWith('*')) return tools.filter((t) => t.name === name);
|
|
75
|
+
const prefix = name.slice(0, -1);
|
|
76
|
+
return prefix.length === 0 ? [] : tools.filter((t) => t.name.startsWith(prefix));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Compact index of not-yet-loaded tools: one line per family, one per tool outside a family. */
|
|
54
80
|
export function buildToolIndex(hidden: ReadonlyArray<ToolDef>): string {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
.
|
|
81
|
+
const families = new Map<string, string[]>();
|
|
82
|
+
for (const t of hidden) {
|
|
83
|
+
const prefix = familyPrefix(t.name);
|
|
84
|
+
if (prefix === null) continue;
|
|
85
|
+
const members = families.get(prefix);
|
|
86
|
+
if (members) members.push(t.name);
|
|
87
|
+
else families.set(prefix, [t.name]);
|
|
88
|
+
}
|
|
89
|
+
const listed = new Set<string>();
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
for (const t of hidden) {
|
|
92
|
+
const prefix = familyPrefix(t.name);
|
|
93
|
+
const members = prefix === null ? undefined : families.get(prefix);
|
|
94
|
+
if (prefix !== null && members && members.length >= FAMILY_MIN) {
|
|
95
|
+
if (listed.has(prefix)) continue;
|
|
96
|
+
listed.add(prefix);
|
|
97
|
+
lines.push(`- **${prefix}*** (${members.length} tools: ${members.join(', ')})`);
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(`- **${t.name}** — ${truncate(oneLine(t.description ?? ''), 100)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
58
102
|
return (
|
|
59
103
|
`## Loadable tools\n\n` +
|
|
60
104
|
`These tools exist but their full schemas are not loaded right now. When a ` +
|
|
61
105
|
`task needs one, call \`load_tool({ name: "<tool-name>" })\` first, then call ` +
|
|
62
|
-
`the tool on the next turn
|
|
106
|
+
`the tool on the next turn. A line ending in \`*\` is a family: load all of it ` +
|
|
107
|
+
`at once with its name, e.g. \`load_tool({ name: "computer_*" })\`.\n\n${lines.join('\n')}`
|
|
63
108
|
);
|
|
64
109
|
}
|
|
65
110
|
|
|
@@ -99,7 +144,8 @@ export function applyLazyTools(
|
|
|
99
144
|
tools: ReadonlyArray<ToolDef>,
|
|
100
145
|
log: EventLogReader,
|
|
101
146
|
): GatedTools {
|
|
102
|
-
const loaded =
|
|
147
|
+
const loaded = new Set<string>();
|
|
148
|
+
for (const name of loadedToolNames(log)) for (const t of matchLoadableTools(name, tools)) loaded.add(t.name);
|
|
103
149
|
// Single partition pass: each tool is either visible (always-on or loaded) or
|
|
104
150
|
// hidden, never both — so one loop yields the exact same two arrays (same
|
|
105
151
|
// elements, same input order) the prior pair of complementary `filter`s did,
|
|
@@ -107,7 +153,7 @@ export function applyLazyTools(
|
|
|
107
153
|
const visible: ToolDef[] = [];
|
|
108
154
|
const hidden: ToolDef[] = [];
|
|
109
155
|
for (const t of tools) {
|
|
110
|
-
(ALWAYS_ON_TOOLS.has(t.name) || loaded.has(t.name) ? visible : hidden).push(t);
|
|
156
|
+
(ALWAYS_ON_TOOLS.has(t.name) || t.alwaysLoaded === true || loaded.has(t.name) ? visible : hidden).push(t);
|
|
111
157
|
}
|
|
112
158
|
if (hidden.length === 0) return { messages, tools };
|
|
113
159
|
return { messages: injectIntoSystem(messages, buildToolIndex(hidden)), tools: visible };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool } from './define.js';
|
|
4
|
+
import type { ToolRegistry } from './mode.js';
|
|
5
|
+
import { toolsForMode } from './tool-modes.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A tool that only means something in one mode — goal mode's goal_abandon, a
|
|
9
|
+
* collaboration's collab_inbox — was offered in every mode. In a plain browser
|
|
10
|
+
* task the model called collab_inbox ("Not in a collaboration") and then
|
|
11
|
+
* goal_abandon, and gave up on a task it could do. Such a tool names its modes,
|
|
12
|
+
* and the others do not see it.
|
|
13
|
+
*/
|
|
14
|
+
const tool = (name: string, modes?: string[]) =>
|
|
15
|
+
defineTool({ name, description: name, inputSchema: z.object({}), handler: async () => name, ...(modes ? { modes } : {}) });
|
|
16
|
+
|
|
17
|
+
const registry: ToolRegistry = {
|
|
18
|
+
list: () => [tool('Read'), tool('goal_abandon', ['goal']), tool('collab_inbox', ['collab-architect', 'collab-peer'])],
|
|
19
|
+
get: (name) => registry.list().find((t) => t.name === name),
|
|
20
|
+
execute: async (name) => name,
|
|
21
|
+
};
|
|
22
|
+
const signal = new AbortController().signal;
|
|
23
|
+
|
|
24
|
+
describe('defineTool', () => {
|
|
25
|
+
it('keeps the modes a tool is for', () => {
|
|
26
|
+
expect(tool('goal_abandon', ['goal']).modes).toEqual(['goal']);
|
|
27
|
+
expect(tool('Read')).not.toHaveProperty('modes');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('toolsForMode', () => {
|
|
32
|
+
it('leaves a tool meant for other modes out of the list and out of reach', async () => {
|
|
33
|
+
const tools = toolsForMode(registry, 'default');
|
|
34
|
+
|
|
35
|
+
expect(tools.list().map((t) => t.name)).toEqual(['Read']);
|
|
36
|
+
expect(tools.get('goal_abandon')).toBeUndefined();
|
|
37
|
+
await expect(tools.execute('Read', {}, signal)).resolves.toBe('Read');
|
|
38
|
+
await expect(tools.execute('goal_abandon', {}, signal)).rejects.toThrow(/goal_abandon is not available in default mode/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('offers it in a mode it names', () => {
|
|
42
|
+
expect(toolsForMode(registry, 'goal').list().map((t) => t.name)).toEqual(['Read', 'goal_abandon']);
|
|
43
|
+
expect(toolsForMode(registry, 'collab-peer').get('collab_inbox')?.name).toBe('collab_inbox');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolRegistry } from './mode.js';
|
|
2
|
+
import type { ToolDef } from './tool.js';
|
|
3
|
+
|
|
4
|
+
const offeredIn = (tool: ToolDef | undefined, mode: string): boolean => !tool?.modes || tool.modes.includes(mode);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The registry as a mode sees it: a tool that names its modes is left out of
|
|
8
|
+
* the others — not offered to the model, and refused if called anyway. It stays
|
|
9
|
+
* registered, so its permission and isolation are looked up as before.
|
|
10
|
+
*/
|
|
11
|
+
export function toolsForMode(tools: ToolRegistry, mode: string): ToolRegistry {
|
|
12
|
+
return {
|
|
13
|
+
list: () => tools.list().filter((tool) => offeredIn(tool, mode)),
|
|
14
|
+
get: (name) => {
|
|
15
|
+
const tool = tools.get(name);
|
|
16
|
+
return offeredIn(tool, mode) ? tool : undefined;
|
|
17
|
+
},
|
|
18
|
+
execute: async (name, input, signal, opts) => {
|
|
19
|
+
if (!offeredIn(tools.get(name), mode)) throw new Error(`${name} is not available in ${mode} mode`);
|
|
20
|
+
return tools.execute(name, input, signal, opts);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
package/src/tool.ts
CHANGED
|
@@ -212,4 +212,22 @@ export interface ToolDef {
|
|
|
212
212
|
* enforces these bounds at every call. See `ToolIsolationSpec`.
|
|
213
213
|
*/
|
|
214
214
|
readonly isolation?: ToolIsolationSpec;
|
|
215
|
+
/**
|
|
216
|
+
* The tool reads state that changes outside the conversation (an app's
|
|
217
|
+
* window, a running job), so the same call between other calls is normal.
|
|
218
|
+
* The stuck-loop guard then counts only a back-to-back run of it.
|
|
219
|
+
*/
|
|
220
|
+
readonly liveState?: boolean;
|
|
221
|
+
/**
|
|
222
|
+
* Sent in full even when the tool list is gated: a tool a task starts with,
|
|
223
|
+
* where loading it first would cost a model round. Each one is paid for on
|
|
224
|
+
* every request, so mark the entry point of a family, not the family.
|
|
225
|
+
*/
|
|
226
|
+
readonly alwaysLoaded?: boolean;
|
|
227
|
+
/**
|
|
228
|
+
* The modes this tool is offered in; omitted = every mode. For a tool that
|
|
229
|
+
* means nothing elsewhere (goal mode's goal_abandon), so it is no way out of
|
|
230
|
+
* a task in a mode that never asked for it. See `toolsForMode`.
|
|
231
|
+
*/
|
|
232
|
+
readonly modes?: ReadonlyArray<string>;
|
|
215
233
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { EventEmitter, getEventListeners } from 'node:events';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { waitFor, wakeAfter, type WakeSource } from './wait-for.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.useRealTimers();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/** A job that finishes when told to and wakes whoever listens. */
|
|
10
|
+
function job() {
|
|
11
|
+
const emitter = new EventEmitter();
|
|
12
|
+
let result: string | undefined;
|
|
13
|
+
const changed: WakeSource = (wake) => {
|
|
14
|
+
emitter.on('change', wake);
|
|
15
|
+
return () => emitter.off('change', wake);
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
changed,
|
|
19
|
+
result: () => result,
|
|
20
|
+
finish(value: string) {
|
|
21
|
+
result = value;
|
|
22
|
+
emitter.emit('change');
|
|
23
|
+
},
|
|
24
|
+
listeners: () => emitter.listenerCount('change'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('waitFor', () => {
|
|
29
|
+
it('wakes the moment the job reports completion, without any timer tick', async () => {
|
|
30
|
+
vi.useFakeTimers();
|
|
31
|
+
const j = job();
|
|
32
|
+
const waiting = waitFor(j.result, { wakeOn: [j.changed], timeoutMs: 60_000 });
|
|
33
|
+
|
|
34
|
+
j.finish('built');
|
|
35
|
+
|
|
36
|
+
await expect(waiting).resolves.toEqual({ status: 'ready', value: 'built' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns at once when the job finished before the wait began', async () => {
|
|
40
|
+
const j = job();
|
|
41
|
+
j.finish('already done');
|
|
42
|
+
|
|
43
|
+
await expect(waitFor(j.result, { wakeOn: [j.changed] })).resolves.toEqual({
|
|
44
|
+
status: 'ready',
|
|
45
|
+
value: 'already done',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps waiting through wakes that do not settle the condition', async () => {
|
|
50
|
+
const emitter = new EventEmitter();
|
|
51
|
+
let lines = 0;
|
|
52
|
+
const waiting = waitFor(() => (lines >= 3 ? lines : undefined), {
|
|
53
|
+
wakeOn: [(wake) => (emitter.on('line', wake), () => emitter.off('line', wake))],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < 3; i++) {
|
|
57
|
+
lines += 1;
|
|
58
|
+
emitter.emit('line');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await expect(waiting).resolves.toEqual({ status: 'ready', value: 3 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports a deadline as "still running", not as finished', async () => {
|
|
65
|
+
vi.useFakeTimers();
|
|
66
|
+
const j = job();
|
|
67
|
+
const waiting = waitFor(j.result, { wakeOn: [j.changed], timeoutMs: 5_000 });
|
|
68
|
+
|
|
69
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
70
|
+
|
|
71
|
+
await expect(waiting).resolves.toEqual({ status: 'timeout' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('stops waiting when the turn is aborted', async () => {
|
|
75
|
+
const j = job();
|
|
76
|
+
const ac = new AbortController();
|
|
77
|
+
const waiting = waitFor(j.result, { wakeOn: [j.changed], signal: ac.signal });
|
|
78
|
+
|
|
79
|
+
ac.abort();
|
|
80
|
+
|
|
81
|
+
await expect(waiting).resolves.toEqual({ status: 'aborted' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('leaves no listener or timer behind after any outcome', async () => {
|
|
85
|
+
vi.useFakeTimers();
|
|
86
|
+
const ac = new AbortController();
|
|
87
|
+
const done = job();
|
|
88
|
+
await (done.finish('x'), waitFor(done.result, { wakeOn: [done.changed], timeoutMs: 1_000, signal: ac.signal }));
|
|
89
|
+
const late = job();
|
|
90
|
+
const timedOut = waitFor(late.result, { wakeOn: [late.changed], timeoutMs: 1_000, signal: ac.signal });
|
|
91
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
92
|
+
await timedOut;
|
|
93
|
+
|
|
94
|
+
expect(done.listeners()).toBe(0);
|
|
95
|
+
expect(late.listeners()).toBe(0);
|
|
96
|
+
expect(getEventListeners(ac.signal, 'abort')).toHaveLength(0);
|
|
97
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects with the check’s error and cleans up', async () => {
|
|
101
|
+
const j = job();
|
|
102
|
+
let broken = false;
|
|
103
|
+
const waiting = waitFor(
|
|
104
|
+
() => {
|
|
105
|
+
if (broken) throw new Error('status read failed');
|
|
106
|
+
return undefined;
|
|
107
|
+
},
|
|
108
|
+
{ wakeOn: [j.changed] },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
broken = true;
|
|
112
|
+
j.finish('ignored');
|
|
113
|
+
|
|
114
|
+
await expect(waiting).rejects.toThrow('status read failed');
|
|
115
|
+
expect(j.listeners()).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('wakeAfter', () => {
|
|
120
|
+
it('re-checks a time-based condition once its moment passes', async () => {
|
|
121
|
+
vi.useFakeTimers();
|
|
122
|
+
const bootDeadline = Date.now() + 1_000;
|
|
123
|
+
const waiting = waitFor(() => (Date.now() >= bootDeadline ? 'gave up booting' : undefined), {
|
|
124
|
+
wakeOn: [wakeAfter(1_000)],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
128
|
+
|
|
129
|
+
await expect(waiting).resolves.toEqual({ status: 'ready', value: 'gave up booting' });
|
|
130
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/wait-for.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-driven waiting: sleep until something says "look again", not on a
|
|
3
|
+
* fixed poll interval. A waiter resumes the moment its job reports progress,
|
|
4
|
+
* and a deadline only bounds the wait — it never means the job finished.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Subscribes `wake` to a change signal; returns the unsubscribe. */
|
|
8
|
+
export type WakeSource = (wake: () => void) => () => void;
|
|
9
|
+
|
|
10
|
+
export type WaitOutcome<T> =
|
|
11
|
+
| { readonly status: 'ready'; readonly value: T }
|
|
12
|
+
/** The deadline passed while the job was still running. */
|
|
13
|
+
| { readonly status: 'timeout' }
|
|
14
|
+
| { readonly status: 'aborted' };
|
|
15
|
+
|
|
16
|
+
export interface WaitForOptions {
|
|
17
|
+
/** Every signal after which `check` may give a different answer. */
|
|
18
|
+
readonly wakeOn: ReadonlyArray<WakeSource>;
|
|
19
|
+
readonly timeoutMs?: number;
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve once `check` returns a value (anything but `undefined`), re-checking
|
|
25
|
+
* only when a wake source fires. Subscribes BEFORE the first check, so a job
|
|
26
|
+
* that finishes between "read the status" and "start listening" is never
|
|
27
|
+
* missed. Every listener and timer is released on any outcome.
|
|
28
|
+
*/
|
|
29
|
+
export function waitFor<T>(
|
|
30
|
+
check: () => T | undefined,
|
|
31
|
+
opts: WaitForOptions,
|
|
32
|
+
): Promise<WaitOutcome<T>> {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const { signal, timeoutMs } = opts;
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
resolve({ status: 'aborted' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let settled = false;
|
|
40
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
const unsubscribes: Array<() => void> = [];
|
|
42
|
+
|
|
43
|
+
const finish = (outcome: WaitOutcome<T> | Error): void => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
47
|
+
signal?.removeEventListener('abort', onAbort);
|
|
48
|
+
for (const unsubscribe of unsubscribes) unsubscribe();
|
|
49
|
+
if (outcome instanceof Error) reject(outcome);
|
|
50
|
+
else resolve(outcome);
|
|
51
|
+
};
|
|
52
|
+
const onAbort = (): void => finish({ status: 'aborted' });
|
|
53
|
+
const recheck = (): void => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
let value: T | undefined;
|
|
56
|
+
try {
|
|
57
|
+
value = check();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (value !== undefined) finish({ status: 'ready', value });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const source of opts.wakeOn) unsubscribes.push(source(recheck));
|
|
66
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
67
|
+
if (timeoutMs !== undefined) timer = setTimeout(() => finish({ status: 'timeout' }), timeoutMs);
|
|
68
|
+
recheck();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A wake source that fires once after `ms` — for conditions that turn on time
|
|
73
|
+
* itself (a boot deadline), so they are re-checked exactly when they can flip. */
|
|
74
|
+
export function wakeAfter(ms: number): WakeSource {
|
|
75
|
+
return (wake) => {
|
|
76
|
+
const timer = setTimeout(wake, ms);
|
|
77
|
+
return () => clearTimeout(timer);
|
|
78
|
+
};
|
|
79
|
+
}
|