@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.
Files changed (64) hide show
  1. package/dist/attachment-payload.d.ts +95 -0
  2. package/dist/attachment-payload.d.ts.map +1 -0
  3. package/dist/attachment-payload.js +155 -0
  4. package/dist/attachment-payload.js.map +1 -0
  5. package/dist/conduct.d.ts.map +1 -1
  6. package/dist/conduct.js +16 -0
  7. package/dist/conduct.js.map +1 -1
  8. package/dist/define.d.ts +1 -0
  9. package/dist/define.d.ts.map +1 -1
  10. package/dist/define.js +1 -0
  11. package/dist/define.js.map +1 -1
  12. package/dist/events.d.ts +12 -0
  13. package/dist/events.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/mode/collect-stream.d.ts +2 -1
  19. package/dist/mode/collect-stream.d.ts.map +1 -1
  20. package/dist/mode/collect-stream.js +5 -1
  21. package/dist/mode/collect-stream.js.map +1 -1
  22. package/dist/mode/project-messages.d.ts +3 -1
  23. package/dist/mode/project-messages.d.ts.map +1 -1
  24. package/dist/mode/project-messages.js +117 -17
  25. package/dist/mode/project-messages.js.map +1 -1
  26. package/dist/mode/react-loop.d.ts.map +1 -1
  27. package/dist/mode/react-loop.js +1 -0
  28. package/dist/mode/react-loop.js.map +1 -1
  29. package/dist/mode.d.ts +5 -0
  30. package/dist/mode.d.ts.map +1 -1
  31. package/dist/mode.js.map +1 -1
  32. package/dist/provider.d.ts +3 -0
  33. package/dist/provider.d.ts.map +1 -1
  34. package/dist/session-like.d.ts +5 -0
  35. package/dist/session-like.d.ts.map +1 -1
  36. package/dist/shortfall.d.ts +32 -0
  37. package/dist/shortfall.d.ts.map +1 -0
  38. package/dist/shortfall.js +66 -0
  39. package/dist/shortfall.js.map +1 -0
  40. package/dist/tool-dispatch.d.ts +3 -2
  41. package/dist/tool-dispatch.d.ts.map +1 -1
  42. package/dist/tool-dispatch.js +39 -4
  43. package/dist/tool-dispatch.js.map +1 -1
  44. package/dist/tool.d.ts +8 -0
  45. package/dist/tool.d.ts.map +1 -1
  46. package/package.json +5 -1
  47. package/src/attachment-payload.test.ts +165 -0
  48. package/src/attachment-payload.ts +187 -0
  49. package/src/conduct.test.ts +35 -2
  50. package/src/conduct.ts +18 -0
  51. package/src/define.ts +2 -0
  52. package/src/events.ts +13 -0
  53. package/src/index.ts +21 -0
  54. package/src/mode/collect-stream.ts +6 -2
  55. package/src/mode/project-messages.test.ts +107 -1
  56. package/src/mode/project-messages.ts +131 -18
  57. package/src/mode/react-loop.ts +1 -0
  58. package/src/mode.ts +5 -0
  59. package/src/provider.ts +3 -1
  60. package/src/session-like.ts +5 -0
  61. package/src/shortfall.test.ts +93 -0
  62. package/src/shortfall.ts +87 -0
  63. package/src/tool-dispatch.ts +41 -4
  64. package/src/tool.ts +8 -0
@@ -2,7 +2,13 @@ import type { ContentBlock, ProviderMessage } from '../provider.js';
2
2
  import type { ModeContext } from '../mode.js';
3
3
  import type { Skill } from '../skill.js';
4
4
  import { toolPatternMatches } from '../skill-mentions.js';
5
- import type { MoxxyEvent, UserPromptEvent } from '../events.js';
5
+ import type { MoxxyEvent, UserPromptAttachment, UserPromptEvent } from '../events.js';
6
+ import {
7
+ MODEL_ATTACHMENT_BUDGET_CHARS,
8
+ attachmentBytes,
9
+ attachmentKey,
10
+ restoredAttachmentRef,
11
+ } from '../attachment-payload.js';
6
12
  import {
7
13
  activeCompactionRanges,
8
14
  makeCompactionLookup,
@@ -135,6 +141,96 @@ export interface ProjectMessagesOptions {
135
141
  readonly precomputedElisionState?: ElisionState;
136
142
  }
137
143
 
144
+ function hasModelPayload(att: UserPromptAttachment): boolean {
145
+ return att.kind === 'image' || att.kind === 'document';
146
+ }
147
+
148
+ function attachmentLeftOutNote(att: UserPromptAttachment, seq: number, index: number): string {
149
+ const label = `${att.kind}${att.name ? ` "${att.name}"` : ''}`;
150
+ const mb = (attachmentBytes(att) / (1024 * 1024)).toFixed(1);
151
+ return (
152
+ `[${label} (${mb} MB) was attached earlier in this conversation and is no longer sent ` +
153
+ `with each request, to keep the request within the model's size limit. ` +
154
+ `To see it again, call recall({ attachment: "${attachmentKey(seq, index)}" }).]`
155
+ );
156
+ }
157
+
158
+ /** The image or document block an attachment the model reads as bytes becomes. */
159
+ function attachmentBlock(att: UserPromptAttachment): ContentBlock {
160
+ if (att.kind === 'image') {
161
+ return { type: 'image', mediaType: att.mediaType ?? 'image/png', data: att.content };
162
+ }
163
+ return {
164
+ type: 'document',
165
+ mediaType: att.mediaType ?? 'application/pdf',
166
+ data: att.content,
167
+ ...(att.name ? { name: att.name } : {}),
168
+ };
169
+ }
170
+
171
+ /** The prompts that carry attachments, by seq, so a restoring recall finds its original. */
172
+ function attachmentPromptsBySeq(events: ReadonlyArray<MoxxyEvent>): ReadonlyMap<number, UserPromptEvent> {
173
+ const prompts = new Map<number, UserPromptEvent>();
174
+ for (const e of events) {
175
+ if (e.type === 'user_prompt' && e.attachments && e.attachments.length > 0) prompts.set(e.seq, e);
176
+ }
177
+ return prompts;
178
+ }
179
+
180
+ /** The image/document a `recall({ attachment })` result brings back, or null. */
181
+ function restoredAttachment(
182
+ e: Extract<MoxxyEvent, { type: 'tool_result' }>,
183
+ prompts: ReadonlyMap<number, UserPromptEvent>,
184
+ ): UserPromptAttachment | null {
185
+ if (!e.ok || e.error) return null;
186
+ const ref = restoredAttachmentRef(e.output);
187
+ if (!ref) return null;
188
+ const prompt = prompts.get(ref.seq);
189
+ const att = prompt ? prompt.attachments?.[ref.index] : undefined;
190
+ return att && hasModelPayload(att) && att.omittedBytes === undefined ? att : null;
191
+ }
192
+
193
+ /** The budget key of what a restoring recall result shows. */
194
+ const restoreKey = (seq: number): string => `r:${seq}`;
195
+
196
+ /**
197
+ * Keys of the image/document payloads that go to the model in full: an
198
+ * attachment's `seq:index`, or `r:seq` for one a recall result restores.
199
+ * Newest first, while they fit {@link MODEL_ATTACHMENT_BUDGET_CHARS}, so a
200
+ * restored attachment takes its place among the newest. Deterministic for a
201
+ * given log, so the prompt cache sees a stable prefix.
202
+ */
203
+ function attachmentsSentInFull(
204
+ events: ReadonlyArray<MoxxyEvent>,
205
+ el: ElisionState,
206
+ isCompacted: (seq: number) => boolean,
207
+ prompts: ReadonlyMap<number, UserPromptEvent>,
208
+ ): ReadonlySet<string> {
209
+ const keys = new Set<string>();
210
+ let room = MODEL_ATTACHMENT_BUDGET_CHARS;
211
+ const take = (key: string, att: UserPromptAttachment): void => {
212
+ if (att.content.length > room) return;
213
+ room -= att.content.length;
214
+ keys.add(key);
215
+ };
216
+ for (let i = events.length - 1; i >= 0; i -= 1) {
217
+ const e = events[i];
218
+ if (!e || isCompacted(e.seq)) continue;
219
+ if (e.type === 'tool_result') {
220
+ if (toolResultStubbed(e, el)) continue;
221
+ const att = restoredAttachment(e, prompts);
222
+ if (att) take(restoreKey(e.seq), att);
223
+ continue;
224
+ }
225
+ if (e.type !== 'user_prompt' || !e.attachments || conversationalStubbed(e, el)) continue;
226
+ for (const [index, att] of e.attachments.entries()) {
227
+ if (!hasModelPayload(att) || att.omittedBytes !== undefined) continue;
228
+ take(attachmentKey(e.seq, index), att);
229
+ }
230
+ }
231
+ return keys;
232
+ }
233
+
138
234
  /**
139
235
  * Pure projection of a single `user_prompt` event to its content blocks.
140
236
  *
@@ -144,27 +240,23 @@ export interface ProjectMessagesOptions {
144
240
  * what the inline switch arm produced. Caller decides how to wrap it as a
145
241
  * message / record the stable prefix.
146
242
  */
147
- export function projectUserPrompt(event: UserPromptEvent, el: ElisionState): ContentBlock[] {
243
+ export function projectUserPrompt(
244
+ event: UserPromptEvent,
245
+ el: ElisionState,
246
+ /** Which attachments go to the model in full; the rest become a short note. Default: all. */
247
+ sendsInFull: (index: number) => boolean = () => true,
248
+ ): ContentBlock[] {
148
249
  // Elided + conversational: collapse to a stub (anchor/tiny kept full).
149
250
  if (conversationalStubbed(event, el)) {
150
251
  return [{ type: 'text', text: conversationalStub('user', event.seq) }];
151
252
  }
152
253
  const blocks: ContentBlock[] = [{ type: 'text', text: event.text }];
153
254
  if (event.attachments) {
154
- for (const att of event.attachments) {
155
- if (att.kind === 'image') {
156
- blocks.push({
157
- type: 'image',
158
- mediaType: att.mediaType ?? 'image/png',
159
- data: att.content,
160
- });
161
- } else if (att.kind === 'document') {
162
- blocks.push({
163
- type: 'document',
164
- mediaType: att.mediaType ?? 'application/pdf',
165
- data: att.content,
166
- ...(att.name ? { name: att.name } : {}),
167
- });
255
+ for (const [index, att] of event.attachments.entries()) {
256
+ if (att.omittedBytes !== undefined || (hasModelPayload(att) && !sendsInFull(index))) {
257
+ blocks.push({ type: 'text', text: attachmentLeftOutNote(att, event.seq, index) });
258
+ } else if (hasModelPayload(att)) {
259
+ blocks.push(attachmentBlock(att));
168
260
  } else {
169
261
  blocks.push({
170
262
  type: 'text',
@@ -277,6 +369,8 @@ export function projectMessages(
277
369
  // Only VISIBLE results count: one swallowed by a compaction is never sent, so
278
370
  // its still-visible tool_use needs the fallback just like a missing one.
279
371
  const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
372
+ const attachmentPrompts = attachmentPromptsBySeq(allEvents);
373
+ const sentInFull = attachmentsSentInFull(allEvents, el, (seq) => compactionFor(seq) !== null, attachmentPrompts);
280
374
  // callIds whose tool_use block is projected. A tool_result outside this set
281
375
  // answers a call a compaction summarized away (the result landed after later
282
376
  // turns began); providers reject a result with no matching call.
@@ -347,7 +441,10 @@ export function projectMessages(
347
441
  case 'user_prompt': {
348
442
  flush();
349
443
  pendingReasoning = null;
350
- messages.push({ role: 'user', content: projectUserPrompt(e, el) });
444
+ messages.push({
445
+ role: 'user',
446
+ content: projectUserPrompt(e, el, (index) => sentInFull.has(`${e.seq}:${index}`)),
447
+ });
351
448
  recordStable(e.seq);
352
449
  break;
353
450
  }
@@ -355,13 +452,14 @@ export function projectMessages(
355
452
  // Render-only reasoning (no signature/encrypted) is never replayed —
356
453
  // it exists only for the live/scrollback "Thinking" view. Replayable
357
454
  // reasoning is stashed for content[0] of this turn's assistant message.
358
- if (e.signature || e.encrypted) {
455
+ if (e.signature || e.encrypted || e.replayItems?.length) {
359
456
  pendingReasoning = {
360
457
  type: 'reasoning',
361
458
  text: e.content,
362
459
  ...(e.signature ? { signature: e.signature } : {}),
363
460
  ...(e.redacted ? { redacted: true } : {}),
364
461
  ...(e.encrypted ? { encrypted: e.encrypted } : {}),
462
+ ...(e.replayItems ? { replayItems: e.replayItems } : {}),
365
463
  };
366
464
  pendingReasoningSeq = e.seq;
367
465
  }
@@ -430,6 +528,10 @@ export function projectMessages(
430
528
  // provider `image` block so the model SEES the pixels — only when the
431
529
  // result isn't stubbed (elided) or an error and the call succeeded.
432
530
  let image: Extract<ContentBlock, { type: 'image' }> | null = null;
531
+ // A `recall({ attachment })` result: the original image/document goes
532
+ // beside it, from the prompt that carried it, while the budget allows.
533
+ let restored: ContentBlock | null = null;
534
+ const restoredAtt = toolResultStubbed(e, el) ? null : restoredAttachment(e, attachmentPrompts);
433
535
  if (toolResultStubbed(e, el)) {
434
536
  text = toolResultStubText(e, el);
435
537
  } else if (e.error) {
@@ -438,6 +540,16 @@ export function projectMessages(
438
540
  // Rich result (e.g. a file diff): the model only needs the short
439
541
  // `forModel` summary — the structured `display` is for channels.
440
542
  text = e.output.forModel;
543
+ } else if (restoredAtt) {
544
+ const description = (e.output as { forModel?: unknown }).forModel;
545
+ text = typeof description === 'string' ? description : '[attachment restored — see below]';
546
+ if (sentInFull.has(restoreKey(e.seq))) {
547
+ restored = attachmentBlock(restoredAtt);
548
+ } else {
549
+ text +=
550
+ ' [It could not be shown again: newer attachments already fill what one request may carry.' +
551
+ ' Ask the person to attach it again if you need it now.]';
552
+ }
441
553
  } else if (e.ok && (image = imageBlockFromOutput(e.output))) {
442
554
  // Short marker satisfies the tool_use→tool_result pairing; the image
443
555
  // block (appended below) carries the actual pixels.
@@ -452,6 +564,7 @@ export function projectMessages(
452
564
  { type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
453
565
  ];
454
566
  if (image) content.push(image);
567
+ if (restored) content.push(restored);
455
568
  messages.push({ role: 'tool_result', content });
456
569
  recordStable(e.seq);
457
570
  break;
@@ -391,6 +391,7 @@ export async function* runReactLoop(
391
391
  ...(reasoning.signature ? { signature: reasoning.signature } : {}),
392
392
  ...(reasoning.redacted ? { redacted: true } : {}),
393
393
  ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
394
+ ...(reasoning.replayItems ? { replayItems: reasoning.replayItems } : {}),
394
395
  });
395
396
  }
396
397
 
package/src/mode.ts CHANGED
@@ -110,6 +110,11 @@ export interface ModeContext {
110
110
  readonly reasoning?: { readonly effort?: ReasoningEffort } | boolean;
111
111
  /** Ask for the provider's faster tier; forwarded only to a model with `supportsFast`. */
112
112
  readonly fast?: boolean;
113
+ /**
114
+ * The reminder at the end of a turn that ended on a step that did not get
115
+ * done (config `context.unfinishedStepCheck`). Unset means on.
116
+ */
117
+ readonly unfinishedStepCheck?: boolean;
113
118
  readonly permissions: PermissionResolver;
114
119
  /**
115
120
  * Optional generic "ask the user a question" gate. Any loop strategy can
package/src/provider.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { HostedTool, ToolDef } from './tool.js';
2
+ import type { ReasoningReplayItem } from './events.js';
2
3
 
3
4
  export interface ProviderMessage {
4
5
  readonly role: 'system' | 'user' | 'assistant' | 'tool_result';
@@ -36,6 +37,7 @@ export type ContentBlock =
36
37
  readonly signature?: string;
37
38
  readonly redacted?: boolean;
38
39
  readonly encrypted?: string;
40
+ readonly replayItems?: ReadonlyArray<ReasoningReplayItem>;
39
41
  };
40
42
 
41
43
  /**
@@ -110,7 +112,7 @@ export type ProviderEvent =
110
112
  * Codex reasoning encrypted_content) that must be replayed verbatim;
111
113
  * `redacted` marks reasoning that must never be displayed.
112
114
  */
113
- | { readonly type: 'reasoning_signature'; readonly signature?: string; readonly redacted?: boolean; readonly encrypted?: string };
115
+ | { readonly type: 'reasoning_signature'; readonly signature?: string; readonly redacted?: boolean; readonly encrypted?: string; readonly replayItem?: ReasoningReplayItem };
114
116
 
115
117
  export interface TokenUsage {
116
118
  /** Non-cached prompt tokens. Providers whose raw usage includes cached
@@ -167,6 +167,11 @@ export interface SessionInfo {
167
167
  readonly reasoningEffort?: ReasoningEffort | 'default' | null;
168
168
  /** Whether turns ask for the provider's faster tier. Absent from older runners. */
169
169
  readonly fast?: boolean;
170
+ /**
171
+ * The model a turn runs on when it names none: the one the conversation last ran on, else the one set as the
172
+ * active provider's default, else the first that provider lists. Absent without an active provider, and from older runners.
173
+ */
174
+ readonly defaultModel?: string;
170
175
  /** Turns running now, whichever client (or in-process channel) started them.
171
176
  * Reported by a runner to its clients; absent from runners that predate it. */
172
177
  readonly runningTurns?: ReadonlyArray<string>;
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asEventId, asSessionId, asTurnId } from './ids.js';
3
+ import type { MoxxyEvent } from './events.js';
4
+ import { openShortfall, shortfallNudge, shortfallOf } from './shortfall.js';
5
+
6
+ /**
7
+ * A tool that carries out steps can stop short of them and still answer
8
+ * without an error: `browser_run` says "0 of 1 steps done". In a form trial
9
+ * the agent pressed Send, the page answered "503, try again", and the agent
10
+ * ended the turn there. A result that names what it did not get done
11
+ * (`shortfall.what`) lets the loop see that the turn ended on such a step.
12
+ */
13
+ const sid = asSessionId('s1');
14
+ const t1 = asTurnId('t1');
15
+
16
+ function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
17
+ return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
18
+ }
19
+
20
+ function step(seq: number, callId: string, output: unknown, error?: { message: string; kind: 'aborted' | 'threw' | 'denied' | 'timeout' }): MoxxyEvent[] {
21
+ return [
22
+ event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'run', input: {} }),
23
+ event(seq + 1, { type: 'tool_result', turnId: t1, source: 'tool', callId, ok: error === undefined, ...(error ? { error } : { output }) }),
24
+ ];
25
+ }
26
+
27
+ const SHORT = { text: '0 of 1 steps done', shortfall: { what: 'step 1, click "Send": the page answered 503' } };
28
+
29
+ describe('shortfallOf', () => {
30
+ it('reads what a result says it did not get done, and whether the step was delivered unseen', () => {
31
+ expect(shortfallOf(SHORT)).toEqual({ what: 'step 1, click "Send": the page answered 503' });
32
+ expect(shortfallOf({ shortfall: { what: 'step 2: not seen', unverified: true } })).toEqual({ what: 'step 2: not seen', unverified: true });
33
+ });
34
+
35
+ it('is null for a result that says nothing of it', () => {
36
+ expect(shortfallOf('plain text')).toBeNull();
37
+ expect(shortfallOf({ text: '3 of 3 steps done' })).toBeNull();
38
+ expect(shortfallOf({ shortfall: { unverified: true } })).toBeNull();
39
+ expect(shortfallOf({ shortfall: { what: ' ' } })).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe('openShortfall', () => {
44
+ it('is the step the turn ended on, when that step did not get done', () => {
45
+ expect(openShortfall([...step(0, 'c1', { text: 'ok' }), ...step(2, 'c2', SHORT)])).toEqual(SHORT.shortfall);
46
+ });
47
+
48
+ it('is settled by any step after it: another route that worked, a hand-over, a look', () => {
49
+ expect(openShortfall([...step(0, 'c1', SHORT), ...step(2, 'c2', { text: 'sent through the other form' })])).toBeNull();
50
+ expect(openShortfall([...step(0, 'c1', SHORT), ...step(2, 'c2', { completed: false })])).toBeNull();
51
+ });
52
+
53
+ it('is not an error, a refusal or a cancel — those say nothing of a step left to finish', () => {
54
+ for (const kind of ['threw', 'denied', 'aborted', 'timeout'] as const) {
55
+ expect(openShortfall([...step(0, 'c1', SHORT), ...step(2, 'c2', undefined, { message: 'no', kind })])).toBeNull();
56
+ }
57
+ });
58
+
59
+ it('is null in a turn that called no tool', () => {
60
+ expect(openShortfall([event(0, { type: 'assistant_message', turnId: t1, source: 'model', content: 'hello', stopReason: 'end_turn' })])).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe('shortfallNudge', () => {
65
+ it('does not assert delivery when the helper did not answer', () => {
66
+ const text = shortfallNudge({ what: 'Save: helper timed out', unverified: true });
67
+ expect(text).not.toContain('It was delivered');
68
+ expect(text).toContain('read the page or window to see whether it took effect');
69
+ expect(text).toContain('do not repeat it blind');
70
+ });
71
+ it('names the step, offers another way and leaves room to say what blocks', () => {
72
+ const text = shortfallNudge({ what: 'step 1, click "Send": the page answered 503' });
73
+
74
+ expect(text).toContain('step 1, click "Send": the page answered 503');
75
+ expect(text).toMatch(/another way/);
76
+ expect(text).toMatch(/say plainly what stops you/);
77
+ expect(text).toMatch(/do not work around a refusal/);
78
+ });
79
+
80
+ it('tells to check the effect first when the step was delivered, never to send it again blind', () => {
81
+ const text = shortfallNudge({ what: 'step 1, click "Pay": delivered, but "paid" was not seen', unverified: true });
82
+
83
+ expect(text).toMatch(/read the page or window to see whether it took effect/);
84
+ expect(text).toMatch(/do not repeat it blind/);
85
+ });
86
+
87
+ it('says to do the step again when the page shows it did not take: an error that says "try again" is not a risk of a duplicate', () => {
88
+ // In a form trial the agent was reminded, read "503, try again" on the page and still asked whether to resend.
89
+ const text = shortfallNudge({ what: 'step 1, click "Send": delivered, but "a ticket number" was not seen', unverified: true });
90
+
91
+ expect(text).toMatch(/If it shows that it did not — an error, a "try again" — do the step again/);
92
+ });
93
+ });
@@ -0,0 +1,87 @@
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 it was asked to do and did not get done: `what`
7
+ * names the step and why, `unverified` marks an action whose effect is unknown,
8
+ * including delivery without confirmation and a helper that did not answer.
9
+ * A result that got everything done leaves `shortfall` out, and so does one
10
+ * stopped by a refusal — that is the user's
11
+ * answer, not a step left to finish.
12
+ */
13
+ export interface Shortfall {
14
+ readonly what: string;
15
+ readonly unverified?: boolean;
16
+ }
17
+
18
+ export function shortfallOf(output: unknown): Shortfall | null {
19
+ if (typeof output !== 'object' || output === null) return null;
20
+ const shortfall = (output as { shortfall?: unknown }).shortfall;
21
+ if (typeof shortfall !== 'object' || shortfall === null) return null;
22
+ const { what, unverified } = shortfall as { what?: unknown; unverified?: unknown };
23
+ if (typeof what !== 'string' || what.trim() === '') return null;
24
+ return { what, ...(unverified === true ? { unverified: true } : {}) };
25
+ }
26
+
27
+ /**
28
+ * The step `events` ended on, when it did not get done. Any step after it
29
+ * settles it — another route that worked, a hand-over to the user, a look at
30
+ * the result — and an error, a refusal or a cancel is not one: those say
31
+ * nothing of a step left to finish.
32
+ */
33
+ export function openShortfall(events: ReadonlyArray<MoxxyEvent>): Shortfall | null {
34
+ const last = events.findLast((e) => e.type === 'tool_result');
35
+ if (last?.type !== 'tool_result' || !last.ok) return null;
36
+ return shortfallOf(last.output);
37
+ }
38
+
39
+ export function shortfallNudge(shortfall: Shortfall): string {
40
+ const next = shortfall.unverified
41
+ ? 'Its effect is unconfirmed, so first read the page or window to see whether it took effect. ' +
42
+ 'If it shows that it did not — an error, a "try again" — do the step again. ' +
43
+ 'If it may have gone through (a send, a payment, a tick), do not repeat it blind. '
44
+ : 'Try it again where the error or the page invites that, or go another way: a different element, tool or route. ';
45
+ return (
46
+ `Before you report: your last step did not get done — ${shortfall.what}. ` +
47
+ 'If what the user asked for is not done yet and there is still a way to get there, take it now. ' +
48
+ next +
49
+ 'If the user has to do something themselves (sign in, solve a CAPTCHA, decide, grant access), or nothing is left to try, ' +
50
+ 'say plainly what stops you — and do not work around a refusal.'
51
+ );
52
+ }
53
+
54
+ const NAME = 'unfinished-step';
55
+
56
+ /** Whether this turn was already reminded: read from its log, so a resumed turn is not asked twice. */
57
+ const reminded = (events: ReadonlyArray<MoxxyEvent>): boolean =>
58
+ events.some(
59
+ (e) =>
60
+ e.type === 'plugin_event' &&
61
+ e.subtype === 'checkpoint_injected' &&
62
+ (e.payload as { name?: unknown } | null)?.name === NAME,
63
+ );
64
+
65
+ /**
66
+ * At the end of a turn, one reminder when the turn ended on a step that did
67
+ * not get done. In a form trial the agent pressed Send, the page answered
68
+ * "503, try again", and the agent reported that it could not send. Asked once
69
+ * per turn: the agent may be right that nothing is left to try, and it is told
70
+ * how to say so. Off with `unfinishedStepCheck: false`.
71
+ */
72
+ export function unfinishedStepCheckpoint(): TurnCheckpoint {
73
+ const due = (ctx: ModeContext): Shortfall | null => {
74
+ if (ctx.unfinishedStepCheck === false) return null;
75
+ const events = ctx.log.byTurn(ctx.turnId);
76
+ return reminded(events) ? null : openShortfall(events);
77
+ };
78
+ return {
79
+ name: NAME,
80
+ applies: (ctx: ModeContext) => due(ctx) !== null,
81
+ async run(check, ctx: ModeContext) {
82
+ const shortfall = check.signal.aborted ? null : due(ctx);
83
+ if (!shortfall) return { action: 'pass' };
84
+ return { action: 'inject', text: shortfallNudge(shortfall), volatile: true };
85
+ },
86
+ };
87
+ }
@@ -63,7 +63,13 @@ export async function* dispatchToolCall(
63
63
  { sessionId: String(ctx.sessionId), turnId: String(ctx.turnId), toolDescription: ctx.tools.get(t.name)?.description },
64
64
  );
65
65
  if (decision.mode === 'deny') {
66
- yield* emitDenied(ctx, t, decision.reason ?? 'denied by resolver', 'resolver');
66
+ // The session's resolver consults standing rules before asking anyone;
67
+ // name such a refusal as policy, so 'resolver' means the answer given.
68
+ const standing = await ctx.permissions.policyCheck?.(
69
+ { callId: asToolCallId(t.id), name: t.name, input: actualInput },
70
+ { sessionId: String(ctx.sessionId), turnId: String(ctx.turnId) },
71
+ );
72
+ yield* emitDenied(ctx, t, decision.reason ?? 'denied by resolver', standing?.mode === 'deny' ? 'policy' : 'resolver');
67
73
  return;
68
74
  }
69
75
  // A deferred approval may outlive its turn, or policy may change while
@@ -183,8 +189,9 @@ async function* emitDenied(
183
189
  * Run a batch of tool uses in order, handling mid-batch abort. On
184
190
  * `ctx.signal.aborted` it synthesizes a failed `tool_result` for every
185
191
  * not-yet-run call (so the log never ends on an orphan `tool_call_requested`),
186
- * emits an `abort`, and returns `true` to tell the caller to stop. Returns
187
- * `false` after a clean batch. Shared by every loop strategy so the
192
+ * emits an `abort`, and returns `true` to tell the caller to stop. The user's
193
+ * refusal of a tool with `refusalEndsTurn` stops the turn the same way, ending
194
+ * it with that tool's text. Returns `false` after a clean batch. Shared by every loop strategy so the
188
195
  * orphan-on-abort guarantee lives in one place.
189
196
  */
190
197
  export async function* executeToolUses(
@@ -219,11 +226,41 @@ export async function* executeToolUses(
219
226
  });
220
227
  return true;
221
228
  }
229
+ const endsTurn = ctx.tools.get(t.name)?.refusalEndsTurn;
230
+ let refused = false;
222
231
  try {
223
- yield* dispatchToolCall(ctx, t, iteration);
232
+ for await (const event of dispatchToolCall(ctx, t, iteration)) {
233
+ // Only the person's answer ends the turn; a rule or a hook is a failed step to route around.
234
+ if (event.type === 'tool_call_denied' && event.decidedBy === 'resolver') refused = true;
235
+ yield event;
236
+ }
224
237
  } finally {
225
238
  unresolved.delete(t.id);
226
239
  }
240
+ // For such a tool a refusal is a terminal boundary, not a failed task step.
241
+ // Drain pending requests without invoking hooks, permissions or handlers.
242
+ if (refused && endsTurn !== undefined) {
243
+ for (const orphanId of unresolved) {
244
+ yield await ctx.emit({
245
+ type: 'tool_result',
246
+ sessionId: ctx.sessionId,
247
+ turnId: ctx.turnId,
248
+ source: 'system',
249
+ callId: asToolCallId(orphanId),
250
+ ok: false,
251
+ error: { kind: 'denied', message: `${t.name} was refused, so the rest of this turn was cancelled` },
252
+ });
253
+ }
254
+ yield await ctx.emit({
255
+ type: 'assistant_message',
256
+ sessionId: ctx.sessionId,
257
+ turnId: ctx.turnId,
258
+ source: 'system',
259
+ stopReason: 'end_turn',
260
+ content: endsTurn,
261
+ });
262
+ return true;
263
+ }
227
264
  }
228
265
  return false;
229
266
  }
package/src/tool.ts CHANGED
@@ -230,4 +230,12 @@ export interface ToolDef {
230
230
  * a task in a mode that never asked for it. See `toolsForMode`.
231
231
  */
232
232
  readonly modes?: ReadonlyArray<string>;
233
+ /**
234
+ * The user's refusal of this tool ends the turn, with this text as its last
235
+ * message, instead of letting the model try another way round it. For tools
236
+ * whose refusal means "not like this, not now" (Computer Use). A standing
237
+ * rule or a hook refusing it stays a failed step. The rest of the batch is
238
+ * cancelled unrun; the next user turn asks again.
239
+ */
240
+ readonly refusalEndsTurn?: string;
233
241
  }