@moxxy/sdk 0.41.2 → 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 +11 -7
- 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
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
|
package/src/resolvers.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { createDeferredPermissionResolver, evaluateToolRule } from './resolvers.js';
|
|
2
|
+
import { createAllowListResolver, createDeferredPermissionResolver, evaluateToolRule } from './resolvers.js';
|
|
3
3
|
import type { PendingToolCall, PermissionRule } from './permission.js';
|
|
4
4
|
|
|
5
5
|
function call(name: string, input: unknown = {}): PendingToolCall {
|
|
@@ -70,3 +70,16 @@ describe('createDeferredPermissionResolver scoped session grants', () => {
|
|
|
70
70
|
expect(prompts).toBe(2);
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
describe('a decision made now', () => {
|
|
75
|
+
it('is marked when the prompt was answered for this call, not when an earlier answer is reused', async () => {
|
|
76
|
+
const resolver = createDeferredPermissionResolver({ prompt: async () => ({ mode: 'allow_session' }) });
|
|
77
|
+
const ctx = { sessionId: 's' };
|
|
78
|
+
expect((await resolver.check(call('computer_run', { app: 'Notes' }), ctx)).decidedNow).toBe(true);
|
|
79
|
+
expect((await resolver.check(call('computer_run', { app: 'Mail' }), ctx)).decidedNow).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('is marked for a tool the run was started with on its allow-list', async () => {
|
|
83
|
+
expect((await createAllowListResolver(['computer_run']).check(call('computer_run', {}), { sessionId: 's' })).decidedNow).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/resolvers.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function createAllowListResolver(toolNames: ReadonlyArray<string>): Permi
|
|
|
37
37
|
return {
|
|
38
38
|
name: 'allow-list',
|
|
39
39
|
async check(call) {
|
|
40
|
-
if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list' };
|
|
40
|
+
if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list', decidedNow: true };
|
|
41
41
|
return { mode: 'deny', reason: `Tool '${call.name}' not in allow-list` };
|
|
42
42
|
},
|
|
43
43
|
};
|
|
@@ -123,7 +123,7 @@ export function createDeferredPermissionResolver(
|
|
|
123
123
|
} else if (decision.mode === 'allow_session' || decision.mode === 'allow_always') {
|
|
124
124
|
sessionAllows.add(call.name);
|
|
125
125
|
}
|
|
126
|
-
return decision;
|
|
126
|
+
return decision.mode === 'deny' ? decision : { ...decision, decidedNow: true };
|
|
127
127
|
},
|
|
128
128
|
abortAll(reason = 'channel closed') {
|
|
129
129
|
for (const r of pending) r({ mode: 'deny', reason });
|
package/src/schemas.ts
CHANGED
|
@@ -66,6 +66,9 @@ export const skillFrontmatterSchema = z.object({
|
|
|
66
66
|
description: z.string().min(1).max(240),
|
|
67
67
|
triggers: z.array(z.string().min(1)).optional(),
|
|
68
68
|
'allowed-tools': z.array(z.string().min(1)).optional(),
|
|
69
|
+
aliases: z.array(z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, 'alias must be slug-like')).optional(),
|
|
70
|
+
'disallowed-tools': z.array(z.string().min(1)).optional(),
|
|
71
|
+
label: z.string().min(1).max(40).optional(),
|
|
69
72
|
version: z.string().optional(),
|
|
70
73
|
tags: z.array(z.string().min(1)).optional(),
|
|
71
74
|
/** Opt the skill into automatic recurring/one-shot execution. */
|
package/src/server.ts
CHANGED
|
@@ -41,6 +41,8 @@ export {
|
|
|
41
41
|
redactProxyUrl,
|
|
42
42
|
hasProxy,
|
|
43
43
|
} from './egress.js';
|
|
44
|
+
// The UTF-8 character type a GUI-launched process lacks (read from process.env).
|
|
45
|
+
export { utf8Locale } from './locale.js';
|
|
44
46
|
// Cross-process "fire exactly once" lock (node:fs). Value lives here; its
|
|
45
47
|
// options type is re-exported from the main barrel like other erased types.
|
|
46
48
|
export { CrossProcessFireLock } from './cross-process-lock.js';
|
package/src/session-like.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { CapabilitySpec } from './isolation.js';
|
|
|
4
4
|
import type { EventLogReader } from './log.js';
|
|
5
5
|
import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
6
6
|
import type { PermissionResolver } from './permission.js';
|
|
7
|
-
import type { ModelDescriptor, ProviderKeyValidation } from './provider.js';
|
|
7
|
+
import type { ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
|
|
8
8
|
import type { PluginSetupSpec } from './schemas.js';
|
|
9
9
|
import type { ToolIcon } from './tool-icon.js';
|
|
10
10
|
import type { ToolCompactPresentation } from './tool.js';
|
|
@@ -18,6 +18,8 @@ import type { ComputerControlService } from './computer-control.js';
|
|
|
18
18
|
*/
|
|
19
19
|
export interface RunTurnOptions {
|
|
20
20
|
readonly model?: string;
|
|
21
|
+
/** Optional context-window estimate for a custom model that is not in the provider catalog. */
|
|
22
|
+
readonly contextWindow?: number;
|
|
21
23
|
readonly systemPrompt?: string;
|
|
22
24
|
readonly maxIterations?: number;
|
|
23
25
|
/**
|
|
@@ -91,6 +93,11 @@ export interface ToolInfo {
|
|
|
91
93
|
export interface SkillInfo {
|
|
92
94
|
readonly id: string;
|
|
93
95
|
readonly name: string;
|
|
96
|
+
readonly description?: string;
|
|
97
|
+
/** Other names an @ mention can use; the first is what the @ menu inserts. */
|
|
98
|
+
readonly aliases?: ReadonlyArray<string>;
|
|
99
|
+
/** How the @ menu shows the skill ("Computer Use"); a skill with one is offered first. */
|
|
100
|
+
readonly label?: string;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/** Serializable slash-command metadata for the picker / `/help`. */
|
|
@@ -150,6 +157,19 @@ export interface SessionInfo {
|
|
|
150
157
|
/** Name of the active synthesizer, or null. Lets a thin client proxy TTS
|
|
151
158
|
* (the desktop routes "Read aloud" through it; null → OS voice fallback). */
|
|
152
159
|
readonly activeSynthesizer: string | null;
|
|
160
|
+
/** Tool calls run without asking in this conversation (see `auto-approve.ts`).
|
|
161
|
+
* Absent from runners that predate the shared switch. */
|
|
162
|
+
readonly autoApprove?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* How deeply the model thinks in this conversation: a set effort, `default` when reasoning is on at the
|
|
165
|
+
* provider's own default effort, null when it is off. Absent from older runners.
|
|
166
|
+
*/
|
|
167
|
+
readonly reasoningEffort?: ReasoningEffort | 'default' | null;
|
|
168
|
+
/** Whether turns ask for the provider's faster tier. Absent from older runners. */
|
|
169
|
+
readonly fast?: boolean;
|
|
170
|
+
/** Turns running now, whichever client (or in-process channel) started them.
|
|
171
|
+
* Reported by a runner to its clients; absent from runners that predate it. */
|
|
172
|
+
readonly runningTurns?: ReadonlyArray<string>;
|
|
153
173
|
}
|
|
154
174
|
|
|
155
175
|
/**
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { asSkillId } from './ids.js';
|
|
3
|
+
import type { ToolRegistry } from './mode.js';
|
|
4
|
+
import type { Skill, SkillFrontmatter } from './skill.js';
|
|
5
|
+
import { insertMention, mentionedSkills, mentionOptions, mentionQueryAt, skillAttachment, withoutTools } from './skill-mentions.js';
|
|
6
|
+
import type { SkillInfo } from './session-like.js';
|
|
7
|
+
import { defineTool } from './define.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
const skill = (frontmatter: Omit<SkillFrontmatter, 'description'>, body = 'Do it on the desktop.'): Skill => ({
|
|
11
|
+
id: asSkillId(`plugin/${frontmatter.name}`),
|
|
12
|
+
path: `/skills/${frontmatter.name}.md`,
|
|
13
|
+
scope: 'plugin',
|
|
14
|
+
frontmatter: { description: 'test skill', ...frontmatter },
|
|
15
|
+
body,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const computer = skill({ name: 'computer-control', aliases: ['computer-use'], 'disallowed-tools': ['browser_*'] });
|
|
19
|
+
const browser = skill({ name: 'browser' });
|
|
20
|
+
|
|
21
|
+
describe('mentionedSkills', () => {
|
|
22
|
+
it('finds a skill the prompt names with @, by an alias written with underscores', () => {
|
|
23
|
+
expect(mentionedSkills('@computer_use otwórz Canvę w Arc', [browser, computer])).toEqual([computer]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('finds a skill by its own name, in any letter case, anywhere in the prompt', () => {
|
|
27
|
+
expect(mentionedSkills('zrób to przez @Computer-Control, proszę', [computer])).toEqual([computer]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('names each skill once however often it is mentioned', () => {
|
|
31
|
+
expect(mentionedSkills('@computer_use i jeszcze raz @computer-control', [computer])).toEqual([computer]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('ignores an e-mail address and a mention of no skill', () => {
|
|
35
|
+
expect(mentionedSkills('napisz do kamil@computer-use.pl o @nobody', [computer])).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('skillAttachment', () => {
|
|
40
|
+
it('carries the skill to the model as a file, saying the user asked for it and which tools are off', () => {
|
|
41
|
+
const attachment = skillAttachment(computer);
|
|
42
|
+
|
|
43
|
+
expect(attachment).toMatchObject({ kind: 'file', name: 'computer-control skill' });
|
|
44
|
+
expect(attachment.content).toContain('The user called this skill with an @ mention');
|
|
45
|
+
expect(attachment.content).toContain('browser_*');
|
|
46
|
+
expect(attachment.content.endsWith('Do it on the desktop.')).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('withoutTools', () => {
|
|
51
|
+
const tool = (name: string) => defineTool({ name, description: name, inputSchema: z.object({}), handler: async () => name });
|
|
52
|
+
const registry: ToolRegistry = {
|
|
53
|
+
list: () => [tool('browser_click'), tool('computer_click'), tool('web_fetch')],
|
|
54
|
+
get: (name) => registry.list().find((t) => t.name === name),
|
|
55
|
+
execute: async (name) => name,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
it('leaves the withheld tools out of the list and out of reach by name', async () => {
|
|
59
|
+
const narrowed = withoutTools(registry, ['browser_*']);
|
|
60
|
+
|
|
61
|
+
expect(narrowed.list().map((t) => t.name)).toEqual(['computer_click', 'web_fetch']);
|
|
62
|
+
expect(narrowed.get('browser_click')).toBeUndefined();
|
|
63
|
+
await expect(narrowed.execute('computer_click', {}, new AbortController().signal)).resolves.toBe('computer_click');
|
|
64
|
+
await expect(narrowed.execute('browser_click', {}, new AbortController().signal)).rejects.toThrow(/browser_click is off for this request/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('mentionQueryAt', () => {
|
|
69
|
+
it('reads the @ word the caret is in, from its @ to its end', () => {
|
|
70
|
+
expect(mentionQueryAt('zrób to @com', 12)).toEqual({ start: 8, end: 12, query: 'com' });
|
|
71
|
+
expect(mentionQueryAt('@comp reszta', 3)).toEqual({ start: 0, end: 5, query: 'comp' });
|
|
72
|
+
expect(mentionQueryAt('@', 1)).toEqual({ start: 0, end: 1, query: '' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is nothing outside an @ word, in an e-mail address, or after a space', () => {
|
|
76
|
+
expect(mentionQueryAt('zwykły tekst', 5)).toBeNull();
|
|
77
|
+
expect(mentionQueryAt('kamil@gmail', 11)).toBeNull();
|
|
78
|
+
expect(mentionQueryAt('@computer_use ', 14)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('mentionOptions', () => {
|
|
83
|
+
const skills: SkillInfo[] = [
|
|
84
|
+
{ id: 'builtin/web-research', name: 'web-research', description: 'Research a topic on the web' },
|
|
85
|
+
{ id: 'builtin/browser', name: 'browser', label: 'Moxxy Browser', aliases: ['moxxy_browser', 'przegladarka'], description: 'Drive the in-window browser' },
|
|
86
|
+
{ id: 'plugin/computer-control', name: 'computer-control', label: 'Computer Use', aliases: ['computer_use', 'komputer'], description: 'Operate desktop apps' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
it('offers the labelled skills first, each to be inserted by its first alias', () => {
|
|
90
|
+
expect(mentionOptions(skills, '').map((o) => o.token)).toEqual(['computer_use', 'moxxy_browser', 'web-research']);
|
|
91
|
+
expect(mentionOptions(skills, '')[0]).toEqual({ name: 'computer-control', token: 'computer_use', label: 'Computer Use', description: 'Operate desktop apps' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('matches what was typed against names, aliases and labels, diacritics and _ aside', () => {
|
|
95
|
+
expect(mentionOptions(skills, 'com').map((o) => o.name)).toEqual(['computer-control']);
|
|
96
|
+
expect(mentionOptions(skills, 'przeglą').map((o) => o.name)).toEqual(['browser']);
|
|
97
|
+
expect(mentionOptions(skills, 'computer-u').map((o) => o.name)).toEqual(['computer-control']);
|
|
98
|
+
expect(mentionOptions(skills, 'brow').map((o) => o.name)).toEqual(['browser']);
|
|
99
|
+
expect(mentionOptions(skills, 'zzz')).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('puts a word that starts with the query before one that only holds it', () => {
|
|
103
|
+
expect(mentionOptions(skills, 'res').map((o) => o.name)).toEqual(['web-research']);
|
|
104
|
+
expect(mentionOptions(skills, 'r').map((o) => o.name)).toEqual(['web-research', 'computer-control', 'browser']);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('insertMention', () => {
|
|
109
|
+
it('replaces the @ word with the token and a space, the caret after it', () => {
|
|
110
|
+
expect(insertMention('zrób @com', { start: 5, end: 9, query: 'com' }, 'computer_use')).toEqual({ text: 'zrób @computer_use ', caret: 19 });
|
|
111
|
+
expect(insertMention('@co reszta', { start: 0, end: 3, query: 'co' }, 'computer_use')).toEqual({ text: '@computer_use reszta', caret: 14 });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { UserPromptAttachment } from './events.js';
|
|
2
|
+
import type { ToolRegistry } from './mode.js';
|
|
3
|
+
import type { SkillInfo } from './session-like.js';
|
|
4
|
+
import type { Skill } from './skill.js';
|
|
5
|
+
|
|
6
|
+
/** `@computer_use`, but not the `@` inside an e-mail address. */
|
|
7
|
+
const MENTION = /(?<![\p{L}\p{N}_@.])@(\p{L}[\p{L}\p{N}_-]*)/gu;
|
|
8
|
+
|
|
9
|
+
/** `computer_use`, `Computer-Use` and `computer-use` name the same skill; so do `przeglądarka` and `przegladarka`. */
|
|
10
|
+
const key = (name: string): string =>
|
|
11
|
+
name.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/ł/gu, 'l').replace(/[_\s]+/gu, '-');
|
|
12
|
+
|
|
13
|
+
/** The skills a prompt calls by an @ mention of their name or an alias, each once, in mention order. */
|
|
14
|
+
export function mentionedSkills(text: string, skills: ReadonlyArray<Skill>): Skill[] {
|
|
15
|
+
const byKey = new Map<string, Skill>();
|
|
16
|
+
for (const skill of skills) {
|
|
17
|
+
for (const name of [skill.frontmatter.name, ...(skill.frontmatter.aliases ?? [])]) byKey.set(key(name), skill);
|
|
18
|
+
}
|
|
19
|
+
const found = new Set<Skill>();
|
|
20
|
+
for (const [, name] of text.matchAll(MENTION)) {
|
|
21
|
+
const skill = byKey.get(key(name ?? ''));
|
|
22
|
+
if (skill) found.add(skill);
|
|
23
|
+
}
|
|
24
|
+
return [...found];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The mentioned skill, recorded on the prompt so every surface and every replay sees the same request. */
|
|
28
|
+
export function skillAttachment(skill: Skill): UserPromptAttachment {
|
|
29
|
+
const withheld = skill.frontmatter['disallowed-tools'] ?? [];
|
|
30
|
+
const off = withheld.length > 0 ? ` The tools ${withheld.join(', ')} are off for this request.` : '';
|
|
31
|
+
return {
|
|
32
|
+
kind: 'file',
|
|
33
|
+
name: `${skill.frontmatter.name} skill`,
|
|
34
|
+
content: `The user called this skill with an @ mention: follow it for this request.${off}\n\n${skill.body}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether a tool name fits an `allowed-tools`-style pattern (`browser_*` or an exact name). */
|
|
39
|
+
export function toolPatternMatches(pattern: string, name: string): boolean {
|
|
40
|
+
return pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The registry without the tools the patterns name: not offered to the model, and refused if called anyway. */
|
|
44
|
+
export function withoutTools(tools: ToolRegistry, patterns: ReadonlyArray<string>): ToolRegistry {
|
|
45
|
+
const withheld = (name: string) => patterns.some((pattern) => toolPatternMatches(pattern, name));
|
|
46
|
+
return {
|
|
47
|
+
list: () => tools.list().filter((tool) => !withheld(tool.name)),
|
|
48
|
+
get: (name) => (withheld(name) ? undefined : tools.get(name)),
|
|
49
|
+
execute: async (name, input, signal, opts) => {
|
|
50
|
+
if (withheld(name)) throw new Error(`${name} is off for this request: the user called a skill that rules it out`);
|
|
51
|
+
return tools.execute(name, input, signal, opts);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The @ word being typed: where it starts (its `@`), where it ends, and what follows the `@`. */
|
|
57
|
+
export interface MentionQuery {
|
|
58
|
+
readonly start: number;
|
|
59
|
+
readonly end: number;
|
|
60
|
+
readonly query: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TYPED = /(?:^|[\s(])@([\p{L}\p{N}_-]*)$/u;
|
|
64
|
+
const REST = /^[\p{L}\p{N}_-]*/u;
|
|
65
|
+
|
|
66
|
+
/** The @ word the caret is in, or null when it is in none (an e-mail address is none). */
|
|
67
|
+
export function mentionQueryAt(text: string, caret: number): MentionQuery | null {
|
|
68
|
+
const typed = TYPED.exec(text.slice(0, caret));
|
|
69
|
+
if (!typed) return null;
|
|
70
|
+
const query = typed[1] ?? '';
|
|
71
|
+
const rest = REST.exec(text.slice(caret))?.[0] ?? '';
|
|
72
|
+
return { start: caret - query.length - 1, end: caret + rest.length, query: query + rest };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One skill the @ menu offers: shown by `label`, inserted as `@token`. */
|
|
76
|
+
export interface MentionOption {
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly token: string;
|
|
79
|
+
readonly label: string;
|
|
80
|
+
readonly description: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The skills that answer an @ query: a word of a name, alias or label that starts with the query
|
|
85
|
+
* before one that only holds it, skills with a label (the tool-like ones) first, then by label.
|
|
86
|
+
*/
|
|
87
|
+
export function mentionOptions(skills: ReadonlyArray<SkillInfo>, query: string, limit = 8): MentionOption[] {
|
|
88
|
+
const wanted = key(query);
|
|
89
|
+
const ranked = skills.flatMap((skill) => {
|
|
90
|
+
const keys = [skill.name, ...(skill.aliases ?? []), ...(skill.label ? [skill.label] : [])].map(key);
|
|
91
|
+
const rank = keys.some((k) => k.split('-').some((word) => word.startsWith(wanted)) || k.startsWith(wanted))
|
|
92
|
+
? 0
|
|
93
|
+
: keys.some((k) => k.includes(wanted)) ? 1 : undefined;
|
|
94
|
+
if (rank === undefined) return [];
|
|
95
|
+
const option = { name: skill.name, token: skill.aliases?.[0] ?? skill.name, label: skill.label ?? skill.name, description: skill.description ?? '' };
|
|
96
|
+
return [{ option, rank, labelled: skill.label ? 0 : 1 }];
|
|
97
|
+
});
|
|
98
|
+
ranked.sort((a, b) => a.rank - b.rank || a.labelled - b.labelled || a.option.label.localeCompare(b.option.label));
|
|
99
|
+
return ranked.slice(0, limit).map((entry) => entry.option);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The text with the @ word replaced by `@token` and a space, and the caret after that space. */
|
|
103
|
+
export function insertMention(text: string, at: MentionQuery, token: string): { text: string; caret: number } {
|
|
104
|
+
const after = text.slice(at.end);
|
|
105
|
+
const spaced = /^\s/u.test(after) ? after : ` ${after}`;
|
|
106
|
+
const next = `${text.slice(0, at.start)}@${token}${spaced}`;
|
|
107
|
+
return { text: next, caret: at.start + token.length + 2 };
|
|
108
|
+
}
|
package/src/skill.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface SkillFrontmatter {
|
|
|
13
13
|
readonly description: string;
|
|
14
14
|
readonly triggers?: ReadonlyArray<string>;
|
|
15
15
|
readonly 'allowed-tools'?: ReadonlyArray<string>;
|
|
16
|
+
/** Other names a prompt can call the skill by with an @ mention (`computer_use`; `_` and `-` read the same). */
|
|
17
|
+
readonly aliases?: ReadonlyArray<string>;
|
|
18
|
+
/** How the chat's @ menu shows the skill ("Computer Use"); a skill with one is offered first. */
|
|
19
|
+
readonly label?: string;
|
|
20
|
+
/** Tools withheld from a turn whose prompt calls the skill by an @ mention (`browser_*` or exact names). */
|
|
21
|
+
readonly 'disallowed-tools'?: ReadonlyArray<string>;
|
|
16
22
|
readonly version?: string;
|
|
17
23
|
readonly tags?: ReadonlyArray<string>;
|
|
18
24
|
readonly schedule?: SkillSchedule;
|
package/src/stuck-loop.test.ts
CHANGED
|
@@ -18,6 +18,18 @@ describe('createStuckLoopDetector', () => {
|
|
|
18
18
|
expect(d.record('Read', input).stuck).toBe(true); // 8th identical call trips
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it('lets a live-state tool repeat between other calls, and still trips on a back-to-back run', () => {
|
|
22
|
+
const d = createStuckLoopDetector({ repeatThreshold: 3 });
|
|
23
|
+
const look = { app: 'Finder' };
|
|
24
|
+
for (let i = 0; i < 10; i++) {
|
|
25
|
+
expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
|
|
26
|
+
expect(d.record('computer_click', { element_index: i }).stuck).toBe(false);
|
|
27
|
+
}
|
|
28
|
+
expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
|
|
29
|
+
expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
|
|
30
|
+
expect(d.record('computer_get_app_state', look, { liveState: true })).toMatchObject({ stuck: true, count: 3, kind: 'exact' });
|
|
31
|
+
});
|
|
32
|
+
|
|
21
33
|
it('never trips when disabled (relies on maxIterations alone)', () => {
|
|
22
34
|
const d = createStuckLoopDetector({ enabled: false, repeatThreshold: 2 });
|
|
23
35
|
const input = { x: 1 };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { assertDefined } from './assert.js';
|
|
3
|
+
import { asEventId, asSessionId, asTurnId } from './ids.js';
|
|
4
|
+
import type { EventLogReader } from './log.js';
|
|
5
|
+
import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from './events.js';
|
|
6
|
+
import type { TurnId } from './ids.js';
|
|
7
|
+
import { computeElisionState, toolResultStubbed } from './elision-state.js';
|
|
8
|
+
import { estimateContextTokens } from './compactor-helpers.js';
|
|
9
|
+
import { projectMessagesFromLog } from './mode/project-messages.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A read of a page stops meaning anything once the same page has been read
|
|
13
|
+
* whole again. In the Coolify task the first turn's reads (one of them 178,073
|
|
14
|
+
* characters) were sent again on each of the 44 calls of the second turn, long
|
|
15
|
+
* after the agent had moved on to other pages. A result that names what it
|
|
16
|
+
* reads (`supersede.key`) is sent as a short marker once a later result reads
|
|
17
|
+
* the same thing whole; `recall` still brings it back.
|
|
18
|
+
*/
|
|
19
|
+
const sid = asSessionId('s1');
|
|
20
|
+
const t1 = asTurnId('t1');
|
|
21
|
+
|
|
22
|
+
function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
|
|
23
|
+
return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
|
|
27
|
+
return {
|
|
28
|
+
length: events.length,
|
|
29
|
+
at: (seq) => events[seq],
|
|
30
|
+
slice: (from = 0, to = events.length) => events.slice(from, to),
|
|
31
|
+
ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
|
|
32
|
+
events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
|
|
33
|
+
byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
|
|
34
|
+
toJSON: () => events,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A tool call and its result, at seq and seq + 1. */
|
|
39
|
+
function read(seq: number, callId: string, key: string, whole: boolean, text = 'x'.repeat(5_000)): MoxxyEvent[] {
|
|
40
|
+
return [
|
|
41
|
+
event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'browser_snapshot', input: {} }),
|
|
42
|
+
event(seq + 1, {
|
|
43
|
+
type: 'tool_result',
|
|
44
|
+
turnId: t1,
|
|
45
|
+
source: 'tool',
|
|
46
|
+
callId,
|
|
47
|
+
ok: true,
|
|
48
|
+
output: { text, supersede: { key, whole } },
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const log: MoxxyEvent[] = [
|
|
54
|
+
event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'go' }),
|
|
55
|
+
...read(1, 'page-1', 'browser:t1', true),
|
|
56
|
+
...read(3, 'diff-1', 'browser:t1', false),
|
|
57
|
+
...read(5, 'other-tab', 'browser:t2', true),
|
|
58
|
+
...read(7, 'page-2', 'browser:t1', true, 'the page now'),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function resultOf(callId: string): Extract<MoxxyEvent, { type: 'tool_result' }> {
|
|
62
|
+
const found = log.find(
|
|
63
|
+
(e): e is Extract<MoxxyEvent, { type: 'tool_result' }> => e.type === 'tool_result' && e.callId === callId,
|
|
64
|
+
);
|
|
65
|
+
assertDefined(found, `result ${callId}`);
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sentFor(events: MoxxyEvent[], callId: string): string {
|
|
70
|
+
for (const m of projectMessagesFromLog({ log: reader(events) })) {
|
|
71
|
+
for (const block of m.content) {
|
|
72
|
+
if (block.type === 'tool_result' && block.toolUseId === callId) return String(block.content);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('a result read again whole is superseded', () => {
|
|
79
|
+
it('marks every earlier result of the same key once a later one is whole', () => {
|
|
80
|
+
const state = computeElisionState(log);
|
|
81
|
+
|
|
82
|
+
expect(toolResultStubbed(resultOf('page-1'), state)).toBe(true);
|
|
83
|
+
expect(toolResultStubbed(resultOf('diff-1'), state)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('keeps the newest whole read, and what another key read', () => {
|
|
87
|
+
const state = computeElisionState(log);
|
|
88
|
+
|
|
89
|
+
expect(toolResultStubbed(resultOf('page-2'), state)).toBe(false);
|
|
90
|
+
expect(toolResultStubbed(resultOf('other-tab'), state)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not let a partial read stand in for the whole one before it', () => {
|
|
94
|
+
// The changes alone mean something only against the read they change.
|
|
95
|
+
const partialLast = [...log.slice(0, 5)];
|
|
96
|
+
const state = computeElisionState(partialLast);
|
|
97
|
+
|
|
98
|
+
expect(toolResultStubbed(resultOf('page-1'), state)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('sends a superseded result as a marker that says how to get it back', () => {
|
|
102
|
+
const sent = sentFor(log, 'page-1');
|
|
103
|
+
|
|
104
|
+
expect(sent).toContain('recall("page-1")');
|
|
105
|
+
expect(sent.length).toBeLessThan(200);
|
|
106
|
+
expect(sentFor(log, 'page-2')).toContain('the page now');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('counts the marker, not the read, when estimating the context', () => {
|
|
110
|
+
const withoutLater = log.slice(0, 7);
|
|
111
|
+
|
|
112
|
+
// Adding a short whole read takes the earlier reads out of the estimate.
|
|
113
|
+
expect(estimateContextTokens(reader(log))).toBeLessThan(estimateContextTokens(reader(withoutLater)));
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/supersede.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { MoxxyEvent } from './events.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Results that a later result made stale.
|
|
5
|
+
*
|
|
6
|
+
* A tool that reads something which changes — a page in a browser tab — can
|
|
7
|
+
* name what it read in its result (`supersede.key`) and say whether the read is
|
|
8
|
+
* whole or only what changed since the last one. Once a later result reads the
|
|
9
|
+
* same key whole, every earlier result of that key says nothing the model still
|
|
10
|
+
* needs, and is sent as a short marker instead (`recall` brings it back).
|
|
11
|
+
*
|
|
12
|
+
* In the Coolify task the first turn's page reads, one of them 178,073
|
|
13
|
+
* characters, were sent again on each of the 44 calls of the second turn, long
|
|
14
|
+
* after the agent had moved on to other pages. A partial read never stands in
|
|
15
|
+
* for a whole one: what changed means something only against what it changed.
|
|
16
|
+
*
|
|
17
|
+
* Decided from the log alone, so projection and the context estimate agree, and
|
|
18
|
+
* deterministic, so a prefix only changes when a new whole read arrives.
|
|
19
|
+
*/
|
|
20
|
+
export interface Supersede {
|
|
21
|
+
/** What the result read, e.g. `browser:t1`. Results of different keys never touch. */
|
|
22
|
+
readonly key: string;
|
|
23
|
+
/** The whole of it, rather than only what changed since the last read. */
|
|
24
|
+
readonly whole: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The `supersede` a tool output carries, if it carries a well-formed one. */
|
|
28
|
+
export function supersedeOf(output: unknown): Supersede | null {
|
|
29
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
30
|
+
const s = (output as { supersede?: unknown }).supersede;
|
|
31
|
+
if (typeof s !== 'object' || s === null) return null;
|
|
32
|
+
const { key, whole } = s as { key?: unknown; whole?: unknown };
|
|
33
|
+
return typeof key === 'string' && key !== '' && typeof whole === 'boolean' ? { key, whole } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** callIds of results a later whole result of the same key replaced. */
|
|
37
|
+
export function supersededCallIds(events: ReadonlyArray<MoxxyEvent>): ReadonlySet<string> {
|
|
38
|
+
const superseded = new Set<string>();
|
|
39
|
+
const readWholeLater = new Set<string>();
|
|
40
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
41
|
+
const e = events[i];
|
|
42
|
+
if (e === undefined || e.type !== 'tool_result' || e.error) continue;
|
|
43
|
+
const s = supersedeOf(e.output);
|
|
44
|
+
if (!s) continue;
|
|
45
|
+
if (readWholeLater.has(s.key)) superseded.add(e.callId);
|
|
46
|
+
else if (s.whole) readWholeLater.add(s.key);
|
|
47
|
+
}
|
|
48
|
+
return superseded;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Deterministic marker for a superseded result. Stable bytes → cache-safe. */
|
|
52
|
+
export function supersededStub(callId: string, bytes: number): string {
|
|
53
|
+
const label = bytes >= 1024 ? `${(Math.round(bytes / 102.4) / 10).toFixed(1)} KB` : `${bytes} B`;
|
|
54
|
+
return `[superseded — a later result read the same thing whole · ${label} · recall("${callId}") to view]`;
|
|
55
|
+
}
|