@moxxy/sdk 0.42.1 → 0.43.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 (56) hide show
  1. package/dist/conduct.d.ts.map +1 -1
  2. package/dist/conduct.js +16 -0
  3. package/dist/conduct.js.map +1 -1
  4. package/dist/define.d.ts +1 -0
  5. package/dist/define.d.ts.map +1 -1
  6. package/dist/define.js +1 -0
  7. package/dist/define.js.map +1 -1
  8. package/dist/events.d.ts +6 -0
  9. package/dist/events.d.ts.map +1 -1
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/mode/collect-stream.d.ts +2 -1
  15. package/dist/mode/collect-stream.d.ts.map +1 -1
  16. package/dist/mode/collect-stream.js +5 -1
  17. package/dist/mode/collect-stream.js.map +1 -1
  18. package/dist/mode/project-messages.d.ts.map +1 -1
  19. package/dist/mode/project-messages.js +2 -1
  20. package/dist/mode/project-messages.js.map +1 -1
  21. package/dist/mode/react-loop.d.ts.map +1 -1
  22. package/dist/mode/react-loop.js +1 -0
  23. package/dist/mode/react-loop.js.map +1 -1
  24. package/dist/mode.d.ts +5 -0
  25. package/dist/mode.d.ts.map +1 -1
  26. package/dist/mode.js.map +1 -1
  27. package/dist/provider.d.ts +3 -0
  28. package/dist/provider.d.ts.map +1 -1
  29. package/dist/session-like.d.ts +5 -0
  30. package/dist/session-like.d.ts.map +1 -1
  31. package/dist/shortfall.d.ts +32 -0
  32. package/dist/shortfall.d.ts.map +1 -0
  33. package/dist/shortfall.js +66 -0
  34. package/dist/shortfall.js.map +1 -0
  35. package/dist/tool-dispatch.d.ts +3 -2
  36. package/dist/tool-dispatch.d.ts.map +1 -1
  37. package/dist/tool-dispatch.js +39 -4
  38. package/dist/tool-dispatch.js.map +1 -1
  39. package/dist/tool.d.ts +8 -0
  40. package/dist/tool.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/conduct.test.ts +35 -2
  43. package/src/conduct.ts +18 -0
  44. package/src/define.ts +2 -0
  45. package/src/events.ts +7 -0
  46. package/src/index.ts +2 -0
  47. package/src/mode/collect-stream.ts +6 -2
  48. package/src/mode/project-messages.ts +2 -1
  49. package/src/mode/react-loop.ts +1 -0
  50. package/src/mode.ts +5 -0
  51. package/src/provider.ts +3 -1
  52. package/src/session-like.ts +5 -0
  53. package/src/shortfall.test.ts +93 -0
  54. package/src/shortfall.ts +87 -0
  55. package/src/tool-dispatch.ts +41 -4
  56. package/src/tool.ts +8 -0
@@ -355,13 +355,14 @@ export function projectMessages(
355
355
  // Render-only reasoning (no signature/encrypted) is never replayed —
356
356
  // it exists only for the live/scrollback "Thinking" view. Replayable
357
357
  // reasoning is stashed for content[0] of this turn's assistant message.
358
- if (e.signature || e.encrypted) {
358
+ if (e.signature || e.encrypted || e.replayItems?.length) {
359
359
  pendingReasoning = {
360
360
  type: 'reasoning',
361
361
  text: e.content,
362
362
  ...(e.signature ? { signature: e.signature } : {}),
363
363
  ...(e.redacted ? { redacted: true } : {}),
364
364
  ...(e.encrypted ? { encrypted: e.encrypted } : {}),
365
+ ...(e.replayItems ? { replayItems: e.replayItems } : {}),
365
366
  };
366
367
  pendingReasoningSeq = e.seq;
367
368
  }
@@ -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
  }