@moxxy/sdk 0.41.3 → 0.42.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/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
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, readFile, readdir,
|
|
1
|
+
import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { posixFileModes } from '@moxxy/vitest-preset/platform';
|
|
5
6
|
import { z } from 'zod';
|
|
6
7
|
import { createJsonFileStore } from './json-file-store.js';
|
|
8
|
+
import { removeDir } from '@moxxy/vitest-preset/fs';
|
|
7
9
|
|
|
8
10
|
interface Item {
|
|
9
11
|
id: string;
|
|
@@ -34,7 +36,7 @@ describe('createJsonFileStore', () => {
|
|
|
34
36
|
});
|
|
35
37
|
|
|
36
38
|
afterEach(async () => {
|
|
37
|
-
await
|
|
39
|
+
await removeDir(dir);
|
|
38
40
|
});
|
|
39
41
|
|
|
40
42
|
function store() {
|
|
@@ -230,7 +232,7 @@ describe('createJsonFileStore', () => {
|
|
|
230
232
|
// versionless, name-key `rows`, compact, trailing newline
|
|
231
233
|
expect(raw).toBe('{"rows":[{"id":"a","n":1}]}\n');
|
|
232
234
|
const { stat } = await import('node:fs/promises');
|
|
233
|
-
expect((await stat(f)).mode & 0o777).toBe(0o600);
|
|
235
|
+
if (posixFileModes) expect((await stat(f)).mode & 0o777).toBe(0o600);
|
|
234
236
|
});
|
|
235
237
|
|
|
236
238
|
it('invalidate() forces a re-read from disk', async () => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { utf8Locale } from './locale.js';
|
|
3
|
+
|
|
4
|
+
describe('utf8Locale', () => {
|
|
5
|
+
it('gives a GUI-launched runner on macOS a UTF-8 character type, so pbcopy keeps Polish letters', () => {
|
|
6
|
+
expect(utf8Locale({}, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('uses the C.UTF-8 locale on Linux, where en_US may not be installed', () => {
|
|
10
|
+
expect(utf8Locale({ LANG: '' }, 'linux')).toEqual({ LC_CTYPE: 'C.UTF-8' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('treats the C and POSIX locales as unset', () => {
|
|
14
|
+
expect(utf8Locale({ LANG: 'C' }, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
|
|
15
|
+
expect(utf8Locale({ LC_CTYPE: 'POSIX' }, 'darwin')).toEqual({ LC_CTYPE: 'en_US.UTF-8' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('leaves a locale that already speaks UTF-8 alone', () => {
|
|
19
|
+
expect(utf8Locale({ LANG: 'pl_PL.UTF-8' }, 'darwin')).toEqual({});
|
|
20
|
+
expect(utf8Locale({ LC_CTYPE: 'UTF-8' }, 'darwin')).toEqual({});
|
|
21
|
+
expect(utf8Locale({ LC_ALL: 'en_US.utf8' }, 'linux')).toEqual({});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('respects a non-UTF-8 locale the user chose on purpose', () => {
|
|
25
|
+
expect(utf8Locale({ LANG: 'pl_PL.ISO8859-2' }, 'darwin')).toEqual({});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('changes nothing on Windows, which has no POSIX locale variables', () => {
|
|
29
|
+
expect(utf8Locale({}, 'win32')).toEqual({});
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/locale.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A process started from the Dock or Finder gets no `LANG`, so every command
|
|
3
|
+
* the agent runs falls back to the legacy encoding: `pbcopy` then reads UTF-8
|
|
4
|
+
* as Mac Central European and "pamięć" lands in the clipboard as "pamińôńá".
|
|
5
|
+
* Returns the variables to add so child processes read and write UTF-8; only
|
|
6
|
+
* the character type is set, so messages keep the user's language.
|
|
7
|
+
*/
|
|
8
|
+
export function utf8Locale(
|
|
9
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
10
|
+
platform: NodeJS.Platform,
|
|
11
|
+
): Record<string, string> {
|
|
12
|
+
if (platform === 'win32') return {};
|
|
13
|
+
const effective = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '';
|
|
14
|
+
if (effective !== '' && effective !== 'C' && effective !== 'POSIX') return {};
|
|
15
|
+
return { LC_CTYPE: platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8' };
|
|
16
|
+
}
|
package/src/mode/checkpoint.ts
CHANGED
|
@@ -101,5 +101,12 @@ export interface TurnCheckpoint {
|
|
|
101
101
|
* (goal/collab-style stall handling wants these too).
|
|
102
102
|
*/
|
|
103
103
|
readonly gateOn?: 'end_turn' | 'idle';
|
|
104
|
+
/**
|
|
105
|
+
* A cheap test of whether this turn needs the checkpoint at all. When it
|
|
106
|
+
* says no, the checkpoint is skipped without a trace — no lifecycle events —
|
|
107
|
+
* so a check that rarely applies can sit on every turn without adding to
|
|
108
|
+
* every conversation's log.
|
|
109
|
+
*/
|
|
110
|
+
applies?(ctx: ModeContext): boolean;
|
|
104
111
|
run(check: CheckpointContext, ctx: ModeContext): Promise<CheckpointResult>;
|
|
105
112
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ProviderEvent, ProviderMessage, TokenUsage } from '../provider.js';
|
|
2
2
|
import type { ModeContext } from '../mode.js';
|
|
3
3
|
import type { StopReason } from '../provider-utils.js';
|
|
4
|
-
import { applyLazyTools } from '../tool-gating.js';
|
|
4
|
+
import { applyLazyTools, shouldGateTools } from '../tool-gating.js';
|
|
5
5
|
import type { ProviderCallTiming } from '../events.js';
|
|
6
6
|
import { providerTiming } from './provider-timing.js';
|
|
7
7
|
|
|
@@ -110,7 +110,7 @@ export async function collectProviderStream(
|
|
|
110
110
|
opts.includeTools === false || descriptor?.supportsTools === false
|
|
111
111
|
? undefined
|
|
112
112
|
: ctx.tools.list();
|
|
113
|
-
if (ctx.lazyTools
|
|
113
|
+
if (toolList && shouldGateTools(ctx.lazyTools, toolList.length)) {
|
|
114
114
|
const gated = applyLazyTools(messages, toolList, ctx.log);
|
|
115
115
|
effectiveMessages = gated.messages;
|
|
116
116
|
toolList = gated.tools;
|
|
@@ -151,6 +151,7 @@ export async function collectProviderStream(
|
|
|
151
151
|
...(cacheHints && cacheHints.length > 0 ? { cacheHints } : {}),
|
|
152
152
|
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
153
153
|
...(reqReasoning ? { reasoning: reqReasoning } : {}),
|
|
154
|
+
...(ctx.fast && descriptor?.supportsFast ? { fast: true } : {}),
|
|
154
155
|
signal: ctx.signal,
|
|
155
156
|
};
|
|
156
157
|
const prepared=performance.now();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet } from './project-messages.js';
|
|
2
|
+
import { projectMessagesFromLog, projectUserPrompt, resolvedCallIdSet, skillsWithinReach } from './project-messages.js';
|
|
3
|
+
import type { Skill } from '../skill.js';
|
|
3
4
|
import { computeElisionState } from '../elision-state.js';
|
|
4
5
|
import { asEventId, asSessionId, asTurnId } from '../ids.js';
|
|
5
6
|
import type { EventLogReader } from '../log.js';
|
|
@@ -181,3 +182,73 @@ describe('projectMessagesFromLog tool_result stringify hardening', () => {
|
|
|
181
182
|
expect(toolResultText(logWith('plain text'))).toBe('plain text');
|
|
182
183
|
});
|
|
183
184
|
});
|
|
185
|
+
|
|
186
|
+
describe('skillsWithinReach', () => {
|
|
187
|
+
const skill = (name: string, tools?: string[]): Skill => ({
|
|
188
|
+
id: name as Skill['id'], path: `/skills/${name}.md`, scope: 'user', body: '',
|
|
189
|
+
frontmatter: { name, description: name, ...(tools ? { 'allowed-tools': tools } : {}) },
|
|
190
|
+
});
|
|
191
|
+
const names = (skills: ReadonlyArray<Skill>) => skills.map((entry) => entry.frontmatter.name);
|
|
192
|
+
const tools = ['Bash', 'Read', 'mcp__gmail__send', 'computer_click'];
|
|
193
|
+
|
|
194
|
+
it('keeps a skill that names no tools or at least one tool of the session', () => {
|
|
195
|
+
const kept = skillsWithinReach([skill('prose'), skill('empty', []), skill('mixed', ['Bash', 'web-research']), skill('mail', ['mcp__gmail__*'])], tools);
|
|
196
|
+
expect(names(kept)).toEqual(['prose', 'empty', 'mixed', 'mail']);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('drops a skill when none of the tools it is written for exists', () => {
|
|
200
|
+
const kept = skillsWithinReach([skill('charts', ['mcp__tradingview__tv_quote']), skill('zoho', ['mcp__zoho__*']), skill('old', ['AppleScript', 'computer'])], tools);
|
|
201
|
+
expect(kept).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('projectMessagesFromLog tool pairing across a compaction boundary', () => {
|
|
206
|
+
const t2 = asTurnId('t2');
|
|
207
|
+
const call = (seq: number, callId: string): MoxxyEvent =>
|
|
208
|
+
event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'terminal', input: {} });
|
|
209
|
+
const result = (seq: number, callId: string): MoxxyEvent =>
|
|
210
|
+
event(seq, { type: 'tool_result', turnId: t1, source: 'tool', callId, ok: true, output: 'installed' });
|
|
211
|
+
const compaction = (seq: number, replacedRange: [number, number]): MoxxyEvent =>
|
|
212
|
+
event(seq, {
|
|
213
|
+
type: 'compaction',
|
|
214
|
+
turnId: t2,
|
|
215
|
+
source: 'compactor',
|
|
216
|
+
compactor: 'segments',
|
|
217
|
+
replacedRange,
|
|
218
|
+
summary: 'earlier work',
|
|
219
|
+
tokensSaved: 10,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const blocksOf = (events: ReadonlyArray<MoxxyEvent>) =>
|
|
223
|
+
projectMessagesFromLog({ log: reader(events) }).flatMap((m) => m.content);
|
|
224
|
+
|
|
225
|
+
it('drops a late tool_result whose tool call was summarized away', () => {
|
|
226
|
+
const blocks = blocksOf([
|
|
227
|
+
userPrompt(0, { text: 'install it' }),
|
|
228
|
+
call(1, 'late'),
|
|
229
|
+
userPrompt(2, { text: 'anything new?', turnId: t2 }),
|
|
230
|
+
result(3, 'late'),
|
|
231
|
+
compaction(4, [0, 1]),
|
|
232
|
+
]);
|
|
233
|
+
expect(blocks.filter((b) => b.type === 'tool_result')).toEqual([]);
|
|
234
|
+
expect(blocks.filter((b) => b.type === 'tool_use')).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('answers a visible tool call whose result was summarized away', () => {
|
|
238
|
+
const messages = projectMessagesFromLog({
|
|
239
|
+
log: reader([
|
|
240
|
+
userPrompt(0, { text: 'install it' }),
|
|
241
|
+
call(1, 'cut'),
|
|
242
|
+
userPrompt(2, { text: 'anything new?', turnId: t2 }),
|
|
243
|
+
result(3, 'cut'),
|
|
244
|
+
compaction(4, [2, 3]),
|
|
245
|
+
]),
|
|
246
|
+
});
|
|
247
|
+
const useIndex = messages.findIndex((m) => m.content.some((b) => b.type === 'tool_use' && b.id === 'cut'));
|
|
248
|
+
expect(useIndex).toBeGreaterThanOrEqual(0);
|
|
249
|
+
expect(messages[useIndex + 1]).toMatchObject({
|
|
250
|
+
role: 'tool_result',
|
|
251
|
+
content: [{ type: 'tool_result', toolUseId: 'cut', isError: true }],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ContentBlock, ProviderMessage } from '../provider.js';
|
|
2
2
|
import type { ModeContext } from '../mode.js';
|
|
3
3
|
import type { Skill } from '../skill.js';
|
|
4
|
+
import { toolPatternMatches } from '../skill-mentions.js';
|
|
4
5
|
import type { MoxxyEvent, UserPromptEvent } from '../events.js';
|
|
5
6
|
import {
|
|
6
7
|
activeCompactionRanges,
|
|
@@ -11,8 +12,7 @@ import {
|
|
|
11
12
|
computeElisionState,
|
|
12
13
|
conversationalStub,
|
|
13
14
|
conversationalStubbed,
|
|
14
|
-
|
|
15
|
-
toolResultStub,
|
|
15
|
+
toolResultStubText,
|
|
16
16
|
toolResultStubbed,
|
|
17
17
|
type ElisionState,
|
|
18
18
|
} from '../elision-state.js';
|
|
@@ -70,6 +70,20 @@ export const ELISION_SYSTEM_NOTE =
|
|
|
70
70
|
'the full text before relying on any detail from an elided turn. Recent turns are always ' +
|
|
71
71
|
'shown verbatim.';
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Skills worth advertising: one that lists `allowed-tools` is written for those
|
|
75
|
+
* tools, so it is left out when none of them exists in the session (an MCP
|
|
76
|
+
* server that is not connected, a plugin that was replaced). Lists are loose in
|
|
77
|
+
* practice, so one tool that exists is enough; a trailing `*` matches a prefix.
|
|
78
|
+
*/
|
|
79
|
+
export function skillsWithinReach(skills: ReadonlyArray<Skill>, toolNames: ReadonlyArray<string>): ReadonlyArray<Skill> {
|
|
80
|
+
const exists = (pattern: string) => toolNames.some((name) => toolPatternMatches(pattern, name));
|
|
81
|
+
return skills.filter((skill) => {
|
|
82
|
+
const wanted = skill.frontmatter['allowed-tools'] ?? [];
|
|
83
|
+
return wanted.length === 0 || wanted.some(exists);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
/**
|
|
74
88
|
* Compose a model-facing system prompt that includes any base prompt
|
|
75
89
|
* plus a COMPACT skill index (name + description + triggers only).
|
|
@@ -260,7 +274,13 @@ export function projectMessages(
|
|
|
260
274
|
// (or tool_call_denied) somewhere in the log. Used to synthesize a
|
|
261
275
|
// fallback `[interrupted]` tool_result for orphan tool_use blocks
|
|
262
276
|
// when the assistant message gets flushed.
|
|
263
|
-
|
|
277
|
+
// Only VISIBLE results count: one swallowed by a compaction is never sent, so
|
|
278
|
+
// its still-visible tool_use needs the fallback just like a missing one.
|
|
279
|
+
const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
|
|
280
|
+
// callIds whose tool_use block is projected. A tool_result outside this set
|
|
281
|
+
// answers a call a compaction summarized away (the result landed after later
|
|
282
|
+
// turns began); providers reject a result with no matching call.
|
|
283
|
+
const projectedCallIds = new Set<string>();
|
|
264
284
|
|
|
265
285
|
let pendingAssistant: ProviderMessage | null = null;
|
|
266
286
|
let pendingAssistantMaxSeq = -1;
|
|
@@ -391,6 +411,7 @@ export function projectMessages(
|
|
|
391
411
|
}
|
|
392
412
|
}
|
|
393
413
|
pendingAssistantMaxSeq = Math.max(pendingAssistantMaxSeq, e.seq);
|
|
414
|
+
projectedCallIds.add(e.callId);
|
|
394
415
|
(pendingAssistant.content as Array<ProviderMessage['content'][number]>).push({
|
|
395
416
|
type: 'tool_use',
|
|
396
417
|
id: e.callId,
|
|
@@ -400,6 +421,7 @@ export function projectMessages(
|
|
|
400
421
|
break;
|
|
401
422
|
}
|
|
402
423
|
case 'tool_result': {
|
|
424
|
+
if (!projectedCallIds.has(e.callId)) break;
|
|
403
425
|
flush();
|
|
404
426
|
// Stub bulky old tool output to a recall-able marker (decision shared
|
|
405
427
|
// with estimateContextTokens via toolResultStubbed).
|
|
@@ -409,8 +431,7 @@ export function projectMessages(
|
|
|
409
431
|
// result isn't stubbed (elided) or an error and the call succeeded.
|
|
410
432
|
let image: Extract<ContentBlock, { type: 'image' }> | null = null;
|
|
411
433
|
if (toolResultStubbed(e, el)) {
|
|
412
|
-
|
|
413
|
-
text = toolResultStub(e.callId, toolResultBytes(e.output), recalled);
|
|
434
|
+
text = toolResultStubText(e, el);
|
|
414
435
|
} else if (e.error) {
|
|
415
436
|
text = `[error:${e.error.kind}] ${e.error.message}`;
|
|
416
437
|
} else if (isToolDisplayResult(e.output)) {
|
package/src/mode/react-loop.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
|
|
17
17
|
import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
|
|
18
18
|
import { collectProviderStream, type CollectedToolUse } from './collect-stream.js';
|
|
19
|
-
import { buildSystemPromptWithSkills, projectMessages } from './project-messages.js';
|
|
19
|
+
import { buildSystemPromptWithSkills, projectMessages, skillsWithinReach } from './project-messages.js';
|
|
20
20
|
import { createStuckLoopDetector } from './stuck-loop.js';
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -269,7 +269,9 @@ export async function* runReactLoop(
|
|
|
269
269
|
// conservative capability fallback.
|
|
270
270
|
const projectionStarted=performance.now();
|
|
271
271
|
const descriptor = ctx.provider.models.find((candidate) => candidate.id === ctx.model);
|
|
272
|
-
const availableSkills = descriptor?.supportsTools === false
|
|
272
|
+
const availableSkills = descriptor?.supportsTools === false
|
|
273
|
+
? []
|
|
274
|
+
: skillsWithinReach(ctx.skills.list(), ctx.tools.list().map((tool) => tool.name));
|
|
273
275
|
const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, availableSkills);
|
|
274
276
|
const { messages, stablePrefixIndex } = projectMessages(ctx, {
|
|
275
277
|
...(systemPrompt ? { systemPrompt } : {}),
|
|
@@ -518,7 +520,8 @@ async function* runCheckpointGate(
|
|
|
518
520
|
// only face the idle-tolerant ones — reviewing a half-sentence as if it
|
|
519
521
|
// were a completion claim wastes a checker run and confuses the model.
|
|
520
522
|
const eligible = checkpoints.filter(
|
|
521
|
-
(cp) =>
|
|
523
|
+
(cp) =>
|
|
524
|
+
(round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
|
|
522
525
|
);
|
|
523
526
|
if (eligible.length === 0) return { kind: 'end' };
|
|
524
527
|
|
package/src/mode/stuck-loop.ts
CHANGED
|
@@ -30,8 +30,12 @@ export interface StuckSignal {
|
|
|
30
30
|
export interface StuckLoopDetector {
|
|
31
31
|
readonly windowSize: number;
|
|
32
32
|
readonly repeatThreshold: number;
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Record the call and report whether the loop guard should trip. A
|
|
35
|
+
* `liveState` tool (see `ToolDef.liveState`) is expected to repeat between
|
|
36
|
+
* other calls, so only a back-to-back run of the same call counts for it.
|
|
37
|
+
*/
|
|
38
|
+
record(toolName: string, input: unknown, opts?: { readonly liveState?: boolean }): StuckSignal;
|
|
35
39
|
/**
|
|
36
40
|
* Clear both sliding windows. Used by the nudge (steer-don't-stop) stuck
|
|
37
41
|
* policy: after a trip is surfaced to the model, the detector starts a fresh
|
|
@@ -86,13 +90,18 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
|
|
|
86
90
|
const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
|
|
87
91
|
const recent: string[] = [];
|
|
88
92
|
const recentNear: string[] = [];
|
|
93
|
+
let last: string | null = null;
|
|
94
|
+
let streak = 0;
|
|
89
95
|
return {
|
|
90
96
|
windowSize,
|
|
91
97
|
repeatThreshold,
|
|
92
|
-
record(toolName, input): StuckSignal {
|
|
98
|
+
record(toolName, input, opts): StuckSignal {
|
|
93
99
|
// Disabled → never trip (rely on the maxIterations cap alone).
|
|
94
100
|
if (!enabled) return { stuck: false, count: 0, kind: 'exact' };
|
|
95
101
|
const key = `${toolName}|${stableHash(input)}`;
|
|
102
|
+
streak = key === last ? streak + 1 : 1;
|
|
103
|
+
last = key;
|
|
104
|
+
if (opts?.liveState) return { stuck: streak >= repeatThreshold, count: streak, kind: 'exact' };
|
|
96
105
|
recent.push(key);
|
|
97
106
|
if (recent.length > windowSize) recent.shift();
|
|
98
107
|
const exactCount = recent.filter((k) => k === key).length;
|
|
@@ -112,6 +121,8 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
|
|
|
112
121
|
reset(): void {
|
|
113
122
|
recent.length = 0;
|
|
114
123
|
recentNear.length = 0;
|
|
124
|
+
last = null;
|
|
125
|
+
streak = 0;
|
|
115
126
|
},
|
|
116
127
|
};
|
|
117
128
|
}
|
package/src/mode-helpers.ts
CHANGED
package/src/mode.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ServiceRegistry } from './services.js';
|
|
|
6
6
|
import type { SessionId, TurnId } from './ids.js';
|
|
7
7
|
import type { EventLogReader } from './log.js';
|
|
8
8
|
import type { PermissionResolver } from './permission.js';
|
|
9
|
-
import type { LLMProvider } from './provider.js';
|
|
9
|
+
import type { LLMProvider, ReasoningEffort } from './provider.js';
|
|
10
10
|
import type { LoopGuardSettings } from './mode/stuck-loop.js';
|
|
11
11
|
import type { Skill } from './skill.js';
|
|
12
12
|
import type { SubagentSpawner } from './subagent.js';
|
|
@@ -84,6 +84,8 @@ export interface ModeContext {
|
|
|
84
84
|
readonly cwd: string;
|
|
85
85
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
86
86
|
readonly model: string;
|
|
87
|
+
/** User-supplied context-window estimate for a custom model, if known. */
|
|
88
|
+
readonly contextWindowOverride?: number;
|
|
87
89
|
readonly systemPrompt?: string;
|
|
88
90
|
readonly provider: LLMProvider;
|
|
89
91
|
readonly tools: ToolRegistry;
|
|
@@ -94,7 +96,10 @@ export interface ModeContext {
|
|
|
94
96
|
readonly cacheStrategy: CacheStrategyDef | null;
|
|
95
97
|
/** Elision (context-on-demand) settings; undefined → defaults apply. */
|
|
96
98
|
readonly elision?: ElisionSettings;
|
|
97
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Send only always-on + loaded tool schemas and index the rest. Unset means
|
|
101
|
+
* automatic: on once the registry holds more than `LAZY_TOOLS_AUTO_THRESHOLD` tools.
|
|
102
|
+
*/
|
|
98
103
|
readonly lazyTools?: boolean;
|
|
99
104
|
/**
|
|
100
105
|
* Per-provider reasoning/thinking preference, resolved from the active
|
|
@@ -102,7 +107,9 @@ export interface ModeContext {
|
|
|
102
107
|
* `ProviderRequest.reasoning` by {@link collectProviderStream}, gated on the
|
|
103
108
|
* active model's `supportsReasoning`. Absent/false → reasoning off.
|
|
104
109
|
*/
|
|
105
|
-
readonly reasoning?: { readonly effort?:
|
|
110
|
+
readonly reasoning?: { readonly effort?: ReasoningEffort } | boolean;
|
|
111
|
+
/** Ask for the provider's faster tier; forwarded only to a model with `supportsFast`. */
|
|
112
|
+
readonly fast?: boolean;
|
|
106
113
|
readonly permissions: PermissionResolver;
|
|
107
114
|
/**
|
|
108
115
|
* Optional generic "ask the user a question" gate. Any loop strategy can
|
package/src/permission.ts
CHANGED
|
@@ -7,6 +7,12 @@ export interface PermissionDecision {
|
|
|
7
7
|
readonly reason?: string;
|
|
8
8
|
/** Narrow an `allow_session` grant to calls with matching input values. */
|
|
9
9
|
readonly sessionScope?: { readonly inputKeys: ReadonlyArray<string> };
|
|
10
|
+
/**
|
|
11
|
+
* This call was decided now: a prompt answered for it, or the run's own
|
|
12
|
+
* allow-list or auto-approve. Left unset by a standing rule and by an earlier
|
|
13
|
+
* answer reused, so a tool can tell consent to this call from a blanket one.
|
|
14
|
+
*/
|
|
15
|
+
readonly decidedNow?: boolean;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export interface PermissionRule {
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
chmodSync,
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from 'node:fs';
|
|
2
|
+
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
3
|
import { tmpdir } from 'node:os';
|
|
11
4
|
import * as path from 'node:path';
|
|
12
5
|
|
|
@@ -19,6 +12,7 @@ import {
|
|
|
19
12
|
resolveExecutableTarget,
|
|
20
13
|
spawnExecutableTarget,
|
|
21
14
|
} from './process-invocation.js';
|
|
15
|
+
import { removeDirSync } from '@moxxy/vitest-preset/fs';
|
|
22
16
|
|
|
23
17
|
let root: string;
|
|
24
18
|
|
|
@@ -27,7 +21,7 @@ beforeEach(() => {
|
|
|
27
21
|
});
|
|
28
22
|
|
|
29
23
|
afterEach(() => {
|
|
30
|
-
|
|
24
|
+
removeDirSync(root);
|
|
31
25
|
});
|
|
32
26
|
|
|
33
27
|
function put(relative: string, body = ''): string {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { asEventId, asSessionId, asTurnId } from './ids.js';
|
|
3
|
+
import type { MoxxyEvent } from './events.js';
|
|
4
|
+
import { progressOf, unfinishedWork } from './progress.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a tool last saw of something may be work that has not finished yet: a
|
|
8
|
+
* page still busy, a job still running. In the Coolify task the agent pressed
|
|
9
|
+
* Deploy and reported the address as down from a check made before it — the
|
|
10
|
+
* service came up minutes later. A result that names what it looked at
|
|
11
|
+
* (`progress.key`) and says whether that was still under way
|
|
12
|
+
* (`progress.pending`) lets the loop tell finished from not-yet.
|
|
13
|
+
*/
|
|
14
|
+
const sid = asSessionId('s1');
|
|
15
|
+
const t1 = asTurnId('t1');
|
|
16
|
+
|
|
17
|
+
function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
|
|
18
|
+
return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function look(seq: number, callId: string, output: unknown, ok = true): MoxxyEvent[] {
|
|
22
|
+
return [
|
|
23
|
+
event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'look', input: {} }),
|
|
24
|
+
event(seq + 1, { type: 'tool_result', turnId: t1, source: 'tool', callId, ok, output }),
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('progressOf', () => {
|
|
29
|
+
it('reads what a result says it looked at and whether that was still under way', () => {
|
|
30
|
+
expect(progressOf({ text: 'x', progress: { key: 'browser:t1', pending: 'uploading' } })).toEqual({
|
|
31
|
+
key: 'browser:t1',
|
|
32
|
+
pending: 'uploading',
|
|
33
|
+
});
|
|
34
|
+
expect(progressOf({ progress: { key: 'browser:t1', pending: null } })).toEqual({ key: 'browser:t1', pending: null });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('is null for a result that says nothing about it', () => {
|
|
38
|
+
expect(progressOf('plain text')).toBeNull();
|
|
39
|
+
expect(progressOf({ text: 'x' })).toBeNull();
|
|
40
|
+
expect(progressOf({ progress: { pending: 'no key' } })).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('unfinishedWork', () => {
|
|
45
|
+
it('names what was still under way the last time it was looked at', () => {
|
|
46
|
+
const events = [
|
|
47
|
+
...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'progressbar "Deploying"' } }),
|
|
48
|
+
...look(2, 'c2', { text: 'something else entirely' }),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
expect(unfinishedWork(events)).toEqual(['progressbar "Deploying"']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('lets a later look that found it finished settle it', () => {
|
|
55
|
+
const events = [
|
|
56
|
+
...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
|
|
57
|
+
...look(2, 'c2', { progress: { key: 'browser:t1', pending: null } }),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
expect(unfinishedWork(events)).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('keeps one thing still running while another finished', () => {
|
|
64
|
+
const events = [
|
|
65
|
+
...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
|
|
66
|
+
...look(2, 'c2', { progress: { key: 'browser:t2', pending: 'uploading' } }),
|
|
67
|
+
...look(4, 'c3', { progress: { key: 'browser:t1', pending: null } }),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
expect(unfinishedWork(events)).toEqual(['uploading']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not count a failed call as a look', () => {
|
|
74
|
+
const events = [
|
|
75
|
+
...look(0, 'c1', { progress: { key: 'browser:t1', pending: 'busy' } }),
|
|
76
|
+
...look(2, 'c2', { progress: { key: 'browser:t1', pending: null } }, false),
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
expect(unfinishedWork(events)).toEqual(['busy']);
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { MoxxyEvent } from './events.js';
|
|
2
|
+
import type { ModeContext } from './mode.js';
|
|
3
|
+
import type { TurnCheckpoint } from './mode/checkpoint.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a tool result says about the thing it looked at: `key` names it (a
|
|
7
|
+
* browser tab, a job), `pending` says what was still under way there, or null
|
|
8
|
+
* when nothing was. A result that knows nothing about it leaves `progress` out.
|
|
9
|
+
*/
|
|
10
|
+
export interface Progress {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly pending: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function progressOf(output: unknown): Progress | null {
|
|
16
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
17
|
+
const progress = (output as { progress?: unknown }).progress;
|
|
18
|
+
if (typeof progress !== 'object' || progress === null) return null;
|
|
19
|
+
const { key, pending } = progress as { key?: unknown; pending?: unknown };
|
|
20
|
+
if (typeof key !== 'string') return null;
|
|
21
|
+
return { key, pending: typeof pending === 'string' ? pending : null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What was still under way the last time each thing was looked at, in `events`. */
|
|
25
|
+
export function unfinishedWork(events: ReadonlyArray<MoxxyEvent>): string[] {
|
|
26
|
+
const latest = new Map<string, string | null>();
|
|
27
|
+
for (const e of events) {
|
|
28
|
+
if (e.type !== 'tool_result' || !e.ok) continue;
|
|
29
|
+
const progress = progressOf(e.output);
|
|
30
|
+
if (progress) latest.set(progress.key, progress.pending);
|
|
31
|
+
}
|
|
32
|
+
return [...latest.values()].filter((pending): pending is string => pending !== null);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function unfinishedWorkNudge(pending: ReadonlyArray<string>): string {
|
|
36
|
+
return (
|
|
37
|
+
`Before you report: the last time you looked, this was still in progress — ${pending.join('; ')}. ` +
|
|
38
|
+
'What it leads to is not known yet, and anything you checked before it may no longer hold. ' +
|
|
39
|
+
'Wait for it to finish (with a tool’s own wait, if it has one), look again, and report what you see then. ' +
|
|
40
|
+
'If it does not finish, say plainly that it has not.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const turnOf = (ctx: ModeContext): string => `${ctx.sessionId}/${ctx.turnId}`;
|
|
45
|
+
|
|
46
|
+
/** Turns already asked, so one turn is asked at most once. Bounded: oldest go first. */
|
|
47
|
+
const MAX_REMEMBERED_TURNS = 256;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* At the end of a turn, one reminder when the last look at something found
|
|
51
|
+
* work still under way. The report is then based on a state that has not
|
|
52
|
+
* settled — the agent pressed Deploy and reported the address from a check
|
|
53
|
+
* made before it. Asked once per turn: the agent may well have good reason to
|
|
54
|
+
* report anyway, and it is told how to say so.
|
|
55
|
+
*/
|
|
56
|
+
export function unfinishedWorkCheckpoint(): TurnCheckpoint {
|
|
57
|
+
const asked = new Set<string>();
|
|
58
|
+
return {
|
|
59
|
+
name: 'unfinished-work',
|
|
60
|
+
applies: (ctx: ModeContext) =>
|
|
61
|
+
!asked.has(turnOf(ctx)) && unfinishedWork(ctx.log.byTurn(ctx.turnId)).length > 0,
|
|
62
|
+
async run(_check, ctx: ModeContext) {
|
|
63
|
+
const pending = unfinishedWork(ctx.log.byTurn(ctx.turnId));
|
|
64
|
+
if (pending.length === 0) return { action: 'pass' };
|
|
65
|
+
asked.add(turnOf(ctx));
|
|
66
|
+
for (const oldest of asked) {
|
|
67
|
+
if (asked.size <= MAX_REMEMBERED_TURNS) break;
|
|
68
|
+
asked.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
return { action: 'inject', text: unfinishedWorkNudge(pending), volatile: true };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface CacheHint {
|
|
|
53
53
|
readonly target: 'tools' | 'system' | { readonly messageIndex: number };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** How deeply a reasoning model should think; `xhigh` is the deepest. */
|
|
57
|
+
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
|
58
|
+
|
|
56
59
|
export interface ProviderRequest {
|
|
57
60
|
readonly model: string;
|
|
58
61
|
/**
|
|
@@ -81,7 +84,13 @@ export interface ProviderRequest {
|
|
|
81
84
|
* each provider's native knob (Anthropic thinking budget, OpenAI/Codex
|
|
82
85
|
* `reasoning.effort`).
|
|
83
86
|
*/
|
|
84
|
-
readonly reasoning?: { readonly effort?:
|
|
87
|
+
readonly reasoning?: { readonly effort?: ReasoningEffort } | boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Serve this request on the provider's faster, pricier tier (OpenAI
|
|
90
|
+
* `service_tier: "priority"`). Set by the loop only for a model whose
|
|
91
|
+
* descriptor has `supportsFast`.
|
|
92
|
+
*/
|
|
93
|
+
readonly fast?: boolean;
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
export type ProviderEvent =
|
|
@@ -148,6 +157,8 @@ export interface ModelDescriptor {
|
|
|
148
157
|
* for this model. When false, `ProviderRequest.reasoning` is ignored.
|
|
149
158
|
*/
|
|
150
159
|
readonly supportsReasoning?: boolean;
|
|
160
|
+
/** Whether the provider can serve this model on a faster tier (see `ProviderRequest.fast`). */
|
|
161
|
+
readonly supportsFast?: boolean;
|
|
151
162
|
/**
|
|
152
163
|
* Tools executed by the provider on behalf of this model (for example
|
|
153
164
|
* OpenAI Responses or Anthropic server tools). A matching client ToolDef
|