@moxxy/sdk 0.42.1 → 0.44.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/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/conduct.d.ts.map +1 -1
- package/dist/conduct.js +16 -0
- package/dist/conduct.js.map +1 -1
- package/dist/define.d.ts +1 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/events.d.ts +12 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -1
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +5 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +117 -17
- 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 +1 -0
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +5 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/provider.d.ts +3 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/session-like.d.ts +5 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/shortfall.d.ts +32 -0
- package/dist/shortfall.d.ts.map +1 -0
- package/dist/shortfall.js +66 -0
- package/dist/shortfall.js.map +1 -0
- package/dist/tool-dispatch.d.ts +3 -2
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +39 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool.d.ts +8 -0
- package/dist/tool.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/conduct.test.ts +35 -2
- package/src/conduct.ts +18 -0
- package/src/define.ts +2 -0
- package/src/events.ts +13 -0
- package/src/index.ts +21 -0
- package/src/mode/collect-stream.ts +6 -2
- package/src/mode/project-messages.test.ts +107 -1
- package/src/mode/project-messages.ts +131 -18
- package/src/mode/react-loop.ts +1 -0
- package/src/mode.ts +5 -0
- package/src/provider.ts +3 -1
- package/src/session-like.ts +5 -0
- package/src/shortfall.test.ts +93 -0
- package/src/shortfall.ts +87 -0
- package/src/tool-dispatch.ts +41 -4
- package/src/tool.ts +8 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { MoxxyEvent, UserPromptAttachment } from './events.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attachment bytes travel two very different roads. The model needs them in
|
|
5
|
+
* full; a client that only DRAWS the conversation (desktop, mobile, a channel
|
|
6
|
+
* bot) does not, and pushing a scanned 30 MB PDF through the client link on
|
|
7
|
+
* every reconnect is what once wedged a chat in a reconnect loop. So the runner
|
|
8
|
+
* sends clients {@link eventForClient} — large payloads swapped for their size —
|
|
9
|
+
* and a client that wants the bytes (an image preview) reads them back in
|
|
10
|
+
* bounded slices with {@link readAttachmentChunk}. The log itself never changes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The most attachment payload (base64 characters) one model request carries.
|
|
15
|
+
* Every request resends the whole conversation, so without a cap a chat with a
|
|
16
|
+
* few scanned PDFs outgrows the provider's request limit (Anthropic: 32 MB) and
|
|
17
|
+
* EVERY later turn fails. The newest attachments go in full; older ones that no
|
|
18
|
+
* longer fit become a note naming them.
|
|
19
|
+
*/
|
|
20
|
+
export const MODEL_ATTACHMENT_BUDGET_CHARS = 20 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** One attachment's payload above this travels to clients by size only. */
|
|
23
|
+
export const CLIENT_INLINE_ATTACHMENT_CHARS = 512 * 1024;
|
|
24
|
+
/** All of one event's inline attachment payloads together stay under this. */
|
|
25
|
+
export const CLIENT_INLINE_EVENT_CHARS = 1024 * 1024;
|
|
26
|
+
/** The largest slice one attachment read returns. */
|
|
27
|
+
export const ATTACHMENT_READ_CHUNK_CHARS = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Whether `content` is base64 bytes rather than text. Raw audio carries a media type; a transcript does not. */
|
|
30
|
+
function isBase64Payload(att: UserPromptAttachment): boolean {
|
|
31
|
+
return att.kind === 'image' || att.kind === 'document' || (att.kind === 'audio' && att.mediaType !== undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The attachment's size in bytes — its real size even on a client copy whose bytes were left out. */
|
|
35
|
+
export function attachmentBytes(att: UserPromptAttachment): number {
|
|
36
|
+
if (att.omittedBytes !== undefined) return att.omittedBytes;
|
|
37
|
+
if (!isBase64Payload(att)) return att.content.length;
|
|
38
|
+
const padding = att.content.endsWith('==') ? 2 : att.content.endsWith('=') ? 1 : 0;
|
|
39
|
+
return Math.max(0, Math.floor((att.content.length * 3) / 4) - padding);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The copy of `event` a client receives: every attachment payload too large for
|
|
44
|
+
* the client link becomes `content: ''` plus `omittedBytes`. Returns `event`
|
|
45
|
+
* itself when nothing had to change, so the common case allocates nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function eventForClient(event: MoxxyEvent): MoxxyEvent {
|
|
48
|
+
if (event.type !== 'user_prompt' || !event.attachments || event.attachments.length === 0) return event;
|
|
49
|
+
let room = CLIENT_INLINE_EVENT_CHARS;
|
|
50
|
+
let changed = false;
|
|
51
|
+
const attachments = event.attachments.map((att) => {
|
|
52
|
+
const size = att.content.length;
|
|
53
|
+
if (size <= CLIENT_INLINE_ATTACHMENT_CHARS && size <= room) {
|
|
54
|
+
room -= size;
|
|
55
|
+
return att;
|
|
56
|
+
}
|
|
57
|
+
changed = true;
|
|
58
|
+
return { ...att, content: '', omittedBytes: attachmentBytes(att) };
|
|
59
|
+
});
|
|
60
|
+
return changed ? { ...event, attachments } : event;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The most one history page may weigh on the wire, in JSON characters. Paging
|
|
65
|
+
* counts events, and a few hundred events with screenshots or attachments can
|
|
66
|
+
* add up to tens of megabytes — one frame the client has to swallow whole.
|
|
67
|
+
*/
|
|
68
|
+
export const HISTORY_PAGE_MAX_CHARS = 4 * 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
/** One newest-first history page: ascending events plus the cursor to the next older page. */
|
|
71
|
+
export interface HistoryPage {
|
|
72
|
+
readonly events: ReadonlyArray<MoxxyEvent>;
|
|
73
|
+
readonly prevCursor: number | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The page as a client receives it: every event in its client form, and the
|
|
78
|
+
* oldest events dropped until the page fits {@link HISTORY_PAGE_MAX_CHARS}. The
|
|
79
|
+
* newest event always stays, so paging always advances; the dropped events
|
|
80
|
+
* come back on the next older page.
|
|
81
|
+
*/
|
|
82
|
+
export function historyPageForClient(page: HistoryPage): HistoryPage {
|
|
83
|
+
const events = page.events.map(eventForClient);
|
|
84
|
+
let room = HISTORY_PAGE_MAX_CHARS;
|
|
85
|
+
let start = events.length;
|
|
86
|
+
while (start > 0) {
|
|
87
|
+
const event = events[start - 1];
|
|
88
|
+
if (event === undefined) break;
|
|
89
|
+
const size = JSON.stringify(event).length + 1;
|
|
90
|
+
if (size > room && start < events.length) break;
|
|
91
|
+
room -= size;
|
|
92
|
+
start -= 1;
|
|
93
|
+
}
|
|
94
|
+
if (start === 0) return { events, prevCursor: page.prevCursor };
|
|
95
|
+
const kept = events.slice(start);
|
|
96
|
+
const oldest = kept[0];
|
|
97
|
+
return { events: kept, prevCursor: oldest === undefined ? page.prevCursor : oldest.seq };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One slice of an attachment's `content`, starting at `offset` (in characters). */
|
|
101
|
+
export interface AttachmentChunk {
|
|
102
|
+
readonly data: string;
|
|
103
|
+
/** The full `content` length, so the reader knows when it has everything. */
|
|
104
|
+
readonly total: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Slice attachment `index` of `event`. Slices start on a multiple of four
|
|
109
|
+
* characters when `offset` does, so base64 slices decode on their own too.
|
|
110
|
+
*/
|
|
111
|
+
export function readAttachmentChunk(event: MoxxyEvent, index: number, offset: number): AttachmentChunk {
|
|
112
|
+
if (event.type !== 'user_prompt' || !event.attachments) {
|
|
113
|
+
throw new Error(`event ${event.seq} has no attachments`);
|
|
114
|
+
}
|
|
115
|
+
const att = event.attachments[index];
|
|
116
|
+
if (!att) throw new Error(`event ${event.seq} has no attachment ${index}`);
|
|
117
|
+
const start = Math.max(0, Math.floor(offset));
|
|
118
|
+
return { data: att.content.slice(start, start + ATTACHMENT_READ_CHUNK_CHARS), total: att.content.length };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** How an attachment is named across the conversation: `seq:index` of the prompt that carried it. */
|
|
122
|
+
export function attachmentKey(seq: number, index: number): string {
|
|
123
|
+
return `${seq}:${index}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One attachment found in the log, with where it sits. */
|
|
127
|
+
export interface FoundAttachment {
|
|
128
|
+
readonly seq: number;
|
|
129
|
+
readonly index: number;
|
|
130
|
+
readonly attachment: UserPromptAttachment;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function* attachmentsNewestFirst(events: ReadonlyArray<MoxxyEvent>): Generator<FoundAttachment> {
|
|
134
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
135
|
+
const e = events[i];
|
|
136
|
+
if (!e || e.type !== 'user_prompt' || !e.attachments) continue;
|
|
137
|
+
for (let index = e.attachments.length - 1; index >= 0; index -= 1) {
|
|
138
|
+
const attachment = e.attachments[index];
|
|
139
|
+
if (attachment) yield { seq: e.seq, index, attachment };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The attachment `query` names: its key (`seq:index`) or its file name, the
|
|
146
|
+
* newest one of that name, whatever the case. Null when none matches.
|
|
147
|
+
*/
|
|
148
|
+
export function findAttachment(events: ReadonlyArray<MoxxyEvent>, query: string): FoundAttachment | null {
|
|
149
|
+
const wanted = query.trim();
|
|
150
|
+
const byKey = /^(\d+):(\d+)$/u.exec(wanted);
|
|
151
|
+
const name = wanted.toLowerCase();
|
|
152
|
+
for (const found of attachmentsNewestFirst(events)) {
|
|
153
|
+
if (byKey) {
|
|
154
|
+
if (found.seq === Number(byKey[1]) && found.index === Number(byKey[2])) return found;
|
|
155
|
+
} else if (found.attachment.name?.toLowerCase() === name) {
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Every attachment of the conversation, oldest first, as `seq:index name`. */
|
|
163
|
+
export function listAttachments(events: ReadonlyArray<MoxxyEvent>): string[] {
|
|
164
|
+
return [...attachmentsNewestFirst(events)]
|
|
165
|
+
.reverse()
|
|
166
|
+
.map(({ seq, index, attachment }) => `${attachmentKey(seq, index)} ${attachment.name ?? attachment.kind}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What `recall({ attachment })` returns for an image or a document: a pointer,
|
|
171
|
+
* not the bytes, which stay once in the log. The projection puts the
|
|
172
|
+
* attachment itself beside this result, so the model sees the original again
|
|
173
|
+
* even after its turn was summarized away.
|
|
174
|
+
*/
|
|
175
|
+
export interface RestoredAttachmentOutput {
|
|
176
|
+
readonly forModel: string;
|
|
177
|
+
readonly restoredAttachment: { readonly seq: number; readonly index: number };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The attachment a recall result restores, or null for any other output. */
|
|
181
|
+
export function restoredAttachmentRef(output: unknown): { seq: number; index: number } | null {
|
|
182
|
+
if (typeof output !== 'object' || output === null) return null;
|
|
183
|
+
const ref = (output as { restoredAttachment?: unknown }).restoredAttachment;
|
|
184
|
+
if (typeof ref !== 'object' || ref === null) return null;
|
|
185
|
+
const { seq, index } = ref as { seq?: unknown; index?: unknown };
|
|
186
|
+
return typeof seq === 'number' && typeof index === 'number' ? { seq, index } : null;
|
|
187
|
+
}
|
package/src/conduct.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* How Moxxy conducts herself, whatever surface or mode the conversation is on.
|
|
3
3
|
* Each rule answers a fault seen in a live session: live facts stated without
|
|
4
|
-
* checking, a choice the user settled lost on a redo,
|
|
5
|
-
* agent speaking of themselves in different grammatical genders in one chat
|
|
4
|
+
* checking, a choice the user settled lost on a redo, the voice and the
|
|
5
|
+
* agent speaking of themselves in different grammatical genders in one chat,
|
|
6
|
+
* and a turn ended at the first failed step with an offer to continue.
|
|
6
7
|
*/
|
|
7
8
|
import { describe, expect, it } from 'vitest';
|
|
8
9
|
import type { ProviderRequest } from './provider.js';
|
|
@@ -38,6 +39,38 @@ describe('AGENT_CONDUCT', () => {
|
|
|
38
39
|
it('holds the self-reference rule the voice uses too', () => {
|
|
39
40
|
expect(AGENT_CONDUCT).toContain(SELF_REFERENCE_NOTE);
|
|
40
41
|
});
|
|
42
|
+
|
|
43
|
+
it('works until the request is done and tries another route when a step fails', () => {
|
|
44
|
+
expect(AGENT_CONDUCT).toMatch(/Work until the user's request is done/);
|
|
45
|
+
expect(AGENT_CONDUCT).toMatch(/When a step fails, try another route yourself before ending the turn/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('takes the next step instead of announcing it, and does not ask again for what is settled', () => {
|
|
49
|
+
expect(AGENT_CONDUCT).toMatch(/Never end your reply by announcing or offering the next step — take it/);
|
|
50
|
+
expect(AGENT_CONDUCT).toMatch(/What the user already told you is settled; do not ask for it again/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('asks for no permission in chat for a step that can be undone and stays within the request', () => {
|
|
54
|
+
expect(AGENT_CONDUCT).toMatch(/can be undone, stays within what the user asked for/);
|
|
55
|
+
expect(AGENT_CONDUCT).toMatch(/breaks no limit the user or an approval rule set/);
|
|
56
|
+
expect(AGENT_CONDUCT).toMatch(/needs no new permission in chat/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('asks for what only the user has and before anything that cannot be undone', () => {
|
|
60
|
+
expect(AGENT_CONDUCT).toMatch(/something only they have \(a password or token/);
|
|
61
|
+
expect(AGENT_CONDUCT).toMatch(/before anything that cannot be undone or reaches beyond the request/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not work around a refusal or a look-only request, and waits after Stop', () => {
|
|
65
|
+
expect(AGENT_CONDUCT).toMatch(/a tool reports a missing permission/);
|
|
66
|
+
expect(AGENT_CONDUCT).toMatch(/only look and not change anything, do not work around it/);
|
|
67
|
+
expect(AGENT_CONDUCT).toMatch(/After the user presses Stop, stop and wait to be resumed/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('ends a turn unfinished only at a real block, naming what was tried', () => {
|
|
71
|
+
expect(AGENT_CONDUCT).toMatch(/End the turn unfinished only at a real block/);
|
|
72
|
+
expect(AGENT_CONDUCT).toMatch(/name what you tried\.$/);
|
|
73
|
+
});
|
|
41
74
|
});
|
|
42
75
|
|
|
43
76
|
describe('withAgentConduct', () => {
|
package/src/conduct.ts
CHANGED
|
@@ -11,6 +11,23 @@ export const SELF_REFERENCE_NOTE =
|
|
|
11
11
|
'(Polish: "sprawdziłam", "ustawiłam", "jestem gotowa"; never "sprawdziłem"), in every reply, ' +
|
|
12
12
|
'whatever earlier messages in the chat used.';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A turn ends when the model answers without calling a tool, so a model that
|
|
16
|
+
* reports the first failed step and offers to go on leaves the user typing
|
|
17
|
+
* "continue". This names when ending early is right and when it is not.
|
|
18
|
+
*/
|
|
19
|
+
const PERSISTENCE_NOTE =
|
|
20
|
+
"Work until the user's request is done. When a step fails, try another route yourself before ending " +
|
|
21
|
+
'the turn. A step that can be undone, stays within what the user asked for and breaks no limit the ' +
|
|
22
|
+
'user or an approval rule set needs no new permission in chat — take it and say afterwards what you ' +
|
|
23
|
+
'did. Never end your reply by announcing or offering the next step — take it. What the user already ' +
|
|
24
|
+
'told you is settled; do not ask for it again. Ask the user when you need something only they have ' +
|
|
25
|
+
'(a password or token, a choice between results that really differ) and before anything that cannot ' +
|
|
26
|
+
'be undone or reaches beyond the request. When a tool reports a missing permission, or the user said ' +
|
|
27
|
+
'to only look and not change anything, do not work around it. After the user presses Stop, stop and ' +
|
|
28
|
+
'wait to be resumed. End the turn unfinished only at a real block — one of the above, or no route ' +
|
|
29
|
+
'left after several different ones failed — and name what you tried.';
|
|
30
|
+
|
|
14
31
|
const MARKER = '## How Moxxy works';
|
|
15
32
|
|
|
16
33
|
/** Appended to every agent request, in every mode and on every surface. */
|
|
@@ -25,6 +42,7 @@ export const AGENT_CONDUCT = [
|
|
|
25
42
|
'choice the user already settled — dates, one-way or return, sizes, names, filters — unless the user ' +
|
|
26
43
|
'changed it, and check that the result shows them before you report.',
|
|
27
44
|
SELF_REFERENCE_NOTE,
|
|
45
|
+
PERSISTENCE_NOTE,
|
|
28
46
|
].join('\n');
|
|
29
47
|
|
|
30
48
|
/**
|
package/src/define.ts
CHANGED
|
@@ -52,6 +52,7 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
|
|
|
52
52
|
liveState?: boolean;
|
|
53
53
|
alwaysLoaded?: boolean;
|
|
54
54
|
modes?: ReadonlyArray<string>;
|
|
55
|
+
refusalEndsTurn?: string;
|
|
55
56
|
}): ToolDef {
|
|
56
57
|
return Object.freeze({
|
|
57
58
|
name: spec.name,
|
|
@@ -67,6 +68,7 @@ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
|
|
|
67
68
|
isolation: spec.isolation,
|
|
68
69
|
...(spec.liveState ? { liveState: true } : {}),
|
|
69
70
|
...(spec.alwaysLoaded ? { alwaysLoaded: true } : {}),
|
|
71
|
+
...(spec.refusalEndsTurn !== undefined ? { refusalEndsTurn: spec.refusalEndsTurn } : {}),
|
|
70
72
|
...(spec.modes ? { modes: Object.freeze([...spec.modes]) } : {}),
|
|
71
73
|
});
|
|
72
74
|
}
|
package/src/events.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface UserPromptAttachment {
|
|
|
48
48
|
readonly name?: string;
|
|
49
49
|
/** MIME type — required for `image`, `document`, and raw `audio` so providers translate correctly. */
|
|
50
50
|
readonly mediaType?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Set only on a client's copy of the event: the payload was too large to send
|
|
53
|
+
* to clients, so `content` is empty and this is the real size in bytes. The
|
|
54
|
+
* bytes stay in the runner's log; `session.readAttachment` reads them back.
|
|
55
|
+
*/
|
|
56
|
+
readonly omittedBytes?: number;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
/**
|
|
@@ -107,6 +113,12 @@ export interface ReasoningChunkEvent extends EventBase {
|
|
|
107
113
|
readonly delta: string;
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/** Provider-owned reasoning state, kept opaque and replayed only to its origin. */
|
|
117
|
+
export interface ReasoningReplayItem {
|
|
118
|
+
readonly provider: string;
|
|
119
|
+
readonly item: Readonly<Record<string, unknown>>;
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
/**
|
|
111
123
|
* A finalized reasoning summary for ONE provider call. Parallels
|
|
112
124
|
* {@link AssistantMessageEvent}: it is persisted + replayed, and because
|
|
@@ -126,6 +138,7 @@ export interface ReasoningMessageEvent extends EventBase {
|
|
|
126
138
|
readonly redacted?: boolean;
|
|
127
139
|
/** Opaque provider blob (Anthropic redacted_thinking data / Codex encrypted_content) replayed as-is. */
|
|
128
140
|
readonly encrypted?: string;
|
|
141
|
+
readonly replayItems?: ReadonlyArray<ReasoningReplayItem>;
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
export interface ToolCallRequestedEvent extends EventBase {
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type {
|
|
|
22
22
|
AssistantMessageEvent,
|
|
23
23
|
ReasoningChunkEvent,
|
|
24
24
|
ReasoningMessageEvent,
|
|
25
|
+
ReasoningReplayItem,
|
|
25
26
|
ToolCallRequestedEvent,
|
|
26
27
|
ToolCallApprovedEvent,
|
|
27
28
|
ToolCallDeniedEvent,
|
|
@@ -311,6 +312,25 @@ export {
|
|
|
311
312
|
type JsonFileStoreOptions,
|
|
312
313
|
} from './json-file-store.js';
|
|
313
314
|
export { assertDefined, assertNever, invariant } from './assert.js';
|
|
315
|
+
export {
|
|
316
|
+
ATTACHMENT_READ_CHUNK_CHARS,
|
|
317
|
+
CLIENT_INLINE_ATTACHMENT_CHARS,
|
|
318
|
+
CLIENT_INLINE_EVENT_CHARS,
|
|
319
|
+
HISTORY_PAGE_MAX_CHARS,
|
|
320
|
+
MODEL_ATTACHMENT_BUDGET_CHARS,
|
|
321
|
+
attachmentBytes,
|
|
322
|
+
attachmentKey,
|
|
323
|
+
eventForClient,
|
|
324
|
+
findAttachment,
|
|
325
|
+
historyPageForClient,
|
|
326
|
+
listAttachments,
|
|
327
|
+
readAttachmentChunk,
|
|
328
|
+
restoredAttachmentRef,
|
|
329
|
+
type AttachmentChunk,
|
|
330
|
+
type FoundAttachment,
|
|
331
|
+
type HistoryPage,
|
|
332
|
+
type RestoredAttachmentOutput,
|
|
333
|
+
} from './attachment-payload.js';
|
|
314
334
|
export { compareSemver, parseSemverCore } from './semver.js';
|
|
315
335
|
// readRequestBody/bearerTokenMatches (http-utils) and the channel-auth value
|
|
316
336
|
// helpers live on the './server' subpath — they reach node:http/crypto/fs/path.
|
|
@@ -423,6 +443,7 @@ export {
|
|
|
423
443
|
unfinishedWorkNudge,
|
|
424
444
|
type Progress,
|
|
425
445
|
} from './progress.js';
|
|
446
|
+
export { openShortfall, shortfallNudge, shortfallOf, unfinishedStepCheckpoint, type Shortfall } from './shortfall.js';
|
|
426
447
|
export { AGENT_CONDUCT, SELF_REFERENCE_NOTE, withAgentConduct } from './conduct.js';
|
|
427
448
|
export {
|
|
428
449
|
applyLazyTools,
|
|
@@ -2,7 +2,7 @@ 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
4
|
import { applyLazyTools, shouldGateTools } from '../tool-gating.js';
|
|
5
|
-
import type { ProviderCallTiming } from '../events.js';
|
|
5
|
+
import type { ProviderCallTiming, ReasoningReplayItem } from '../events.js';
|
|
6
6
|
import { providerTiming } from './provider-timing.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -58,6 +58,7 @@ export interface StreamResult {
|
|
|
58
58
|
readonly signature?: string;
|
|
59
59
|
readonly redacted?: boolean;
|
|
60
60
|
readonly encrypted?: string;
|
|
61
|
+
readonly replayItems?: ReadonlyArray<ReasoningReplayItem>;
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
|
|
@@ -185,6 +186,7 @@ export async function collectProviderStream(
|
|
|
185
186
|
let reasoningSignature: string | undefined;
|
|
186
187
|
let reasoningRedacted = false;
|
|
187
188
|
let reasoningEncrypted: string | undefined;
|
|
189
|
+
const reasoningReplayItems: ReasoningReplayItem[] = [];
|
|
188
190
|
|
|
189
191
|
let stream: AsyncIterable<ProviderEvent>;
|
|
190
192
|
try {
|
|
@@ -255,6 +257,7 @@ export async function collectProviderStream(
|
|
|
255
257
|
if (event.signature) reasoningSignature = event.signature;
|
|
256
258
|
if (event.encrypted) reasoningEncrypted = event.encrypted;
|
|
257
259
|
if (event.redacted) reasoningRedacted = true;
|
|
260
|
+
if (event.replayItem) reasoningReplayItems.push(event.replayItem);
|
|
258
261
|
break;
|
|
259
262
|
}
|
|
260
263
|
case 'message_start':
|
|
@@ -286,12 +289,13 @@ export async function collectProviderStream(
|
|
|
286
289
|
// Surface reasoning when there's visible text OR an opaque blob to replay
|
|
287
290
|
// (a redacted_thinking block has no text but must still round-trip).
|
|
288
291
|
const reasoning =
|
|
289
|
-
reasoningText.trim().length > 0 || reasoningEncrypted
|
|
292
|
+
reasoningText.trim().length > 0 || reasoningEncrypted || reasoningReplayItems.length > 0
|
|
290
293
|
? {
|
|
291
294
|
text: reasoningText,
|
|
292
295
|
...(reasoningSignature ? { signature: reasoningSignature } : {}),
|
|
293
296
|
...(reasoningRedacted ? { redacted: true } : {}),
|
|
294
297
|
...(reasoningEncrypted ? { encrypted: reasoningEncrypted } : {}),
|
|
298
|
+
...(reasoningReplayItems.length > 0 ? { replayItems: reasoningReplayItems } : {}),
|
|
295
299
|
}
|
|
296
300
|
: undefined;
|
|
297
301
|
return {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { MODEL_ATTACHMENT_BUDGET_CHARS } from '../attachment-payload.js';
|
|
3
|
+
import {
|
|
4
|
+
projectMessagesFromLog,
|
|
5
|
+
projectUserPrompt,
|
|
6
|
+
resolvedCallIdSet,
|
|
7
|
+
skillsWithinReach,
|
|
8
|
+
} from './project-messages.js';
|
|
3
9
|
import type { Skill } from '../skill.js';
|
|
4
10
|
import { computeElisionState } from '../elision-state.js';
|
|
5
11
|
import { asEventId, asSessionId, asTurnId } from '../ids.js';
|
|
@@ -252,3 +258,103 @@ describe('projectMessagesFromLog tool pairing across a compaction boundary', ()
|
|
|
252
258
|
});
|
|
253
259
|
});
|
|
254
260
|
});
|
|
261
|
+
|
|
262
|
+
describe('projectMessagesFromLog attachment budget', () => {
|
|
263
|
+
const pdf = (name: string, chars: number) => ({
|
|
264
|
+
kind: 'document' as const,
|
|
265
|
+
content: 'A'.repeat(chars),
|
|
266
|
+
mediaType: 'application/pdf',
|
|
267
|
+
name,
|
|
268
|
+
});
|
|
269
|
+
const blocksOf = (events: ReadonlyArray<MoxxyEvent>) =>
|
|
270
|
+
projectMessagesFromLog({ log: reader(events) }).flatMap((m) => m.content);
|
|
271
|
+
|
|
272
|
+
it('sends the newest attachments in full and names the older ones that no longer fit', () => {
|
|
273
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
274
|
+
const blocks = blocksOf([
|
|
275
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
276
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
277
|
+
]);
|
|
278
|
+
const documents = blocks.filter((b) => b.type === 'document');
|
|
279
|
+
expect(documents).toHaveLength(1);
|
|
280
|
+
expect(documents[0]).toMatchObject({ name: 'new-scan.pdf' });
|
|
281
|
+
const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
|
|
282
|
+
expect(note).toBeDefined();
|
|
283
|
+
const sent = blocks.reduce((n, b) => n + (b.type === 'document' ? b.data.length : 0), 0);
|
|
284
|
+
expect(sent).toBeLessThanOrEqual(MODEL_ATTACHMENT_BUDGET_CHARS);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('tells the model how to bring a left-out attachment back', () => {
|
|
288
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
289
|
+
const blocks = blocksOf([
|
|
290
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
291
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
292
|
+
]);
|
|
293
|
+
const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
|
|
294
|
+
expect(note).toMatchObject({ text: expect.stringContaining('recall({ attachment: "0:0" })') });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const restore = (seq: number, callId: string, ref: { seq: number; index: number }): MoxxyEvent[] => [
|
|
298
|
+
event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'recall', input: { attachment: `${ref.seq}:${ref.index}` } }),
|
|
299
|
+
event(seq + 1, {
|
|
300
|
+
type: 'tool_result',
|
|
301
|
+
turnId: t1,
|
|
302
|
+
source: 'tool',
|
|
303
|
+
callId,
|
|
304
|
+
ok: true,
|
|
305
|
+
output: { forModel: 'restored old-scan.pdf', restoredAttachment: ref },
|
|
306
|
+
}),
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
it('shows a restored attachment again beside the recall result, within the same budget', () => {
|
|
310
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
311
|
+
const messages = projectMessagesFromLog({
|
|
312
|
+
log: reader([
|
|
313
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
314
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
315
|
+
userPrompt(2, { text: 'back to the first one' }),
|
|
316
|
+
...restore(3, 'r1', { seq: 0, index: 0 }),
|
|
317
|
+
]),
|
|
318
|
+
});
|
|
319
|
+
const result = messages.find((m) => m.role === 'tool_result');
|
|
320
|
+
expect(result?.content).toEqual([
|
|
321
|
+
{ type: 'tool_result', toolUseId: 'r1', content: 'restored old-scan.pdf', isError: false },
|
|
322
|
+
{ type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(half), name: 'old-scan.pdf' },
|
|
323
|
+
]);
|
|
324
|
+
const blocks = messages.flatMap((m) => m.content);
|
|
325
|
+
expect(blocks.filter((b) => b.type === 'document')).toHaveLength(1);
|
|
326
|
+
expect(blocks.some((b) => b.type === 'text' && b.text.includes('new-scan.pdf'))).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('restores an attachment whose turn a compaction summarized away', () => {
|
|
330
|
+
const messages = projectMessagesFromLog({
|
|
331
|
+
log: reader([
|
|
332
|
+
userPrompt(0, { text: 'the contract', attachments: [pdf('umowa.pdf', 100)] }),
|
|
333
|
+
event(1, {
|
|
334
|
+
type: 'compaction',
|
|
335
|
+
turnId: t1,
|
|
336
|
+
source: 'compactor',
|
|
337
|
+
compactor: 'segments',
|
|
338
|
+
replacedRange: [0, 0],
|
|
339
|
+
summary: 'the person shared umowa.pdf',
|
|
340
|
+
tokensSaved: 10,
|
|
341
|
+
}),
|
|
342
|
+
userPrompt(2, { text: 'what was in point 7?' }),
|
|
343
|
+
...restore(3, 'r1', { seq: 0, index: 0 }),
|
|
344
|
+
]),
|
|
345
|
+
});
|
|
346
|
+
const documents = messages.flatMap((m) => m.content).filter((b) => b.type === 'document');
|
|
347
|
+
expect(documents).toEqual([{ type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(100), name: 'umowa.pdf' }]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('never sends an empty payload for an attachment whose bytes were left out', () => {
|
|
351
|
+
const blocks = projectUserPrompt(
|
|
352
|
+
userPrompt(0, {
|
|
353
|
+
attachments: [{ kind: 'image', content: '', mediaType: 'image/png', name: 'shot.png', omittedBytes: 2048 }],
|
|
354
|
+
}),
|
|
355
|
+
noElision,
|
|
356
|
+
);
|
|
357
|
+
expect(blocks.some((b) => b.type === 'image')).toBe(false);
|
|
358
|
+
expect(blocks.some((b) => b.type === 'text' && b.text.includes('shot.png'))).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
});
|