@moxxy/sdk 0.44.0 → 0.46.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 (246) hide show
  1. package/dist/agent-account-limit.d.ts +41 -0
  2. package/dist/agent-account-limit.d.ts.map +1 -0
  3. package/dist/agent-account-limit.js +64 -0
  4. package/dist/agent-account-limit.js.map +1 -0
  5. package/dist/agent-computer-use.d.ts +10 -0
  6. package/dist/agent-computer-use.d.ts.map +1 -0
  7. package/dist/agent-computer-use.js +24 -0
  8. package/dist/agent-computer-use.js.map +1 -0
  9. package/dist/agent-context-window.d.ts +22 -0
  10. package/dist/agent-context-window.d.ts.map +1 -0
  11. package/dist/agent-context-window.js +40 -0
  12. package/dist/agent-context-window.js.map +1 -0
  13. package/dist/agent-no-reply.d.ts +11 -0
  14. package/dist/agent-no-reply.d.ts.map +1 -0
  15. package/dist/agent-no-reply.js +13 -0
  16. package/dist/agent-no-reply.js.map +1 -0
  17. package/dist/agent-step.d.ts +14 -0
  18. package/dist/agent-step.d.ts.map +1 -0
  19. package/dist/agent-step.js +26 -0
  20. package/dist/agent-step.js.map +1 -0
  21. package/dist/agent-work.d.ts +34 -0
  22. package/dist/agent-work.d.ts.map +1 -0
  23. package/dist/agent-work.js +80 -0
  24. package/dist/agent-work.js.map +1 -0
  25. package/dist/attachment-payload.d.ts +1 -1
  26. package/dist/attachment-payload.js +1 -1
  27. package/dist/auto-approve.d.ts +9 -0
  28. package/dist/auto-approve.d.ts.map +1 -1
  29. package/dist/auto-approve.js +18 -0
  30. package/dist/auto-approve.js.map +1 -1
  31. package/dist/channel-auth.d.ts +3 -3
  32. package/dist/channel-auth.d.ts.map +1 -1
  33. package/dist/channel-auth.js +1 -1
  34. package/dist/channel.d.ts +1 -1
  35. package/dist/client-session.d.ts +9 -5
  36. package/dist/client-session.d.ts.map +1 -1
  37. package/dist/computer-control.d.ts +11 -11
  38. package/dist/computer-control.d.ts.map +1 -1
  39. package/dist/computer-control.js +1 -1
  40. package/dist/computer-control.js.map +1 -1
  41. package/dist/conversation-history.d.ts +50 -0
  42. package/dist/conversation-history.d.ts.map +1 -0
  43. package/dist/conversation-history.js +227 -0
  44. package/dist/conversation-history.js.map +1 -0
  45. package/dist/cron.d.ts +58 -0
  46. package/dist/cron.d.ts.map +1 -0
  47. package/dist/cron.js +320 -0
  48. package/dist/cron.js.map +1 -0
  49. package/dist/define.d.ts +2 -1
  50. package/dist/define.d.ts.map +1 -1
  51. package/dist/define.js +1 -0
  52. package/dist/define.js.map +1 -1
  53. package/dist/event-store.d.ts +1 -1
  54. package/dist/event-store.d.ts.map +1 -1
  55. package/dist/event-store.js +1 -0
  56. package/dist/event-store.js.map +1 -1
  57. package/dist/events.d.ts +17 -1
  58. package/dist/events.d.ts.map +1 -1
  59. package/dist/file-diff.d.ts +31 -0
  60. package/dist/file-diff.d.ts.map +1 -0
  61. package/dist/file-diff.js +196 -0
  62. package/dist/file-diff.js.map +1 -0
  63. package/dist/index.d.ts +22 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +16 -4
  66. package/dist/index.js.map +1 -1
  67. package/dist/mode/agent-step-loop.d.ts +13 -0
  68. package/dist/mode/agent-step-loop.d.ts.map +1 -0
  69. package/dist/mode/agent-step-loop.js +578 -0
  70. package/dist/mode/agent-step-loop.js.map +1 -0
  71. package/dist/mode/checkpoint-gate.d.ts +25 -0
  72. package/dist/mode/checkpoint-gate.d.ts.map +1 -0
  73. package/dist/mode/checkpoint-gate.js +150 -0
  74. package/dist/mode/checkpoint-gate.js.map +1 -0
  75. package/dist/mode/checkpoint.d.ts +6 -0
  76. package/dist/mode/checkpoint.d.ts.map +1 -1
  77. package/dist/mode/collect-stream.d.ts +2 -0
  78. package/dist/mode/collect-stream.d.ts.map +1 -1
  79. package/dist/mode/collect-stream.js +1 -1
  80. package/dist/mode/collect-stream.js.map +1 -1
  81. package/dist/mode/project-messages.d.ts.map +1 -1
  82. package/dist/mode/project-messages.js +9 -67
  83. package/dist/mode/project-messages.js.map +1 -1
  84. package/dist/mode/react-loop-options.d.ts +113 -0
  85. package/dist/mode/react-loop-options.d.ts.map +1 -0
  86. package/dist/mode/react-loop-options.js +2 -0
  87. package/dist/mode/react-loop-options.js.map +1 -0
  88. package/dist/mode/react-loop.d.ts +2 -99
  89. package/dist/mode/react-loop.d.ts.map +1 -1
  90. package/dist/mode/react-loop.js +7 -145
  91. package/dist/mode/react-loop.js.map +1 -1
  92. package/dist/mode.d.ts +19 -7
  93. package/dist/mode.d.ts.map +1 -1
  94. package/dist/mode.js +1 -1
  95. package/dist/mode.js.map +1 -1
  96. package/dist/npm-registry.d.ts +22 -0
  97. package/dist/npm-registry.d.ts.map +1 -0
  98. package/dist/npm-registry.js +54 -0
  99. package/dist/npm-registry.js.map +1 -0
  100. package/dist/permission.d.ts +31 -0
  101. package/dist/permission.d.ts.map +1 -1
  102. package/dist/process-invocation.d.ts +2 -0
  103. package/dist/process-invocation.d.ts.map +1 -1
  104. package/dist/process-invocation.js +2 -2
  105. package/dist/process-invocation.js.map +1 -1
  106. package/dist/process-tree.d.ts +9 -0
  107. package/dist/process-tree.d.ts.map +1 -0
  108. package/dist/process-tree.js +40 -0
  109. package/dist/process-tree.js.map +1 -0
  110. package/dist/provider-login-bridge.d.ts +1 -2
  111. package/dist/provider-login-bridge.d.ts.map +1 -1
  112. package/dist/provider-login-bridge.js +2 -3
  113. package/dist/provider-login-bridge.js.map +1 -1
  114. package/dist/provider.d.ts +409 -1
  115. package/dist/provider.d.ts.map +1 -1
  116. package/dist/question-draft.d.ts +19 -0
  117. package/dist/question-draft.d.ts.map +1 -0
  118. package/dist/question-draft.js +30 -0
  119. package/dist/question-draft.js.map +1 -0
  120. package/dist/question.d.ts +204 -0
  121. package/dist/question.d.ts.map +1 -0
  122. package/dist/question.js +141 -0
  123. package/dist/question.js.map +1 -0
  124. package/dist/questions.d.ts +7 -0
  125. package/dist/questions.d.ts.map +1 -0
  126. package/dist/questions.js +7 -0
  127. package/dist/questions.js.map +1 -0
  128. package/dist/resolvers.d.ts +14 -1
  129. package/dist/resolvers.d.ts.map +1 -1
  130. package/dist/resolvers.js +33 -21
  131. package/dist/resolvers.js.map +1 -1
  132. package/dist/schemas.d.ts +53 -53
  133. package/dist/schemas.d.ts.map +1 -1
  134. package/dist/schemas.js +3 -2
  135. package/dist/schemas.js.map +1 -1
  136. package/dist/server.d.ts +2 -0
  137. package/dist/server.d.ts.map +1 -1
  138. package/dist/server.js +3 -0
  139. package/dist/server.js.map +1 -1
  140. package/dist/session-like.d.ts +19 -1
  141. package/dist/session-like.d.ts.map +1 -1
  142. package/dist/starting-model.d.ts +12 -0
  143. package/dist/starting-model.d.ts.map +1 -0
  144. package/dist/starting-model.js +9 -0
  145. package/dist/starting-model.js.map +1 -0
  146. package/dist/tool-dispatch.d.ts.map +1 -1
  147. package/dist/tool-dispatch.js +10 -4
  148. package/dist/tool-dispatch.js.map +1 -1
  149. package/dist/tool-display.d.ts +79 -3
  150. package/dist/tool-display.d.ts.map +1 -1
  151. package/dist/tool-display.js +117 -3
  152. package/dist/tool-display.js.map +1 -1
  153. package/dist/tool-modes.d.ts +1 -1
  154. package/dist/tool-modes.d.ts.map +1 -1
  155. package/dist/tool-modes.js +8 -2
  156. package/dist/tool-modes.js.map +1 -1
  157. package/dist/tool-output.d.ts +15 -0
  158. package/dist/tool-output.d.ts.map +1 -0
  159. package/dist/tool-output.js +39 -0
  160. package/dist/tool-output.js.map +1 -0
  161. package/dist/tool.d.ts +14 -1
  162. package/dist/tool.d.ts.map +1 -1
  163. package/dist/tunnel.d.ts +3 -3
  164. package/dist/tunnel.d.ts.map +1 -1
  165. package/dist/tunnel.js +2 -2
  166. package/dist/workflow-approval.d.ts +9 -9
  167. package/dist/workflow-approval.d.ts.map +1 -1
  168. package/dist/workflow-approval.js +1 -1
  169. package/dist/workflow-approval.js.map +1 -1
  170. package/package.json +37 -5
  171. package/src/agent-account-limit.test.ts +87 -0
  172. package/src/agent-account-limit.ts +89 -0
  173. package/src/agent-computer-use.test.ts +26 -0
  174. package/src/agent-computer-use.ts +29 -0
  175. package/src/agent-context-window.ts +51 -0
  176. package/src/agent-no-reply.ts +16 -0
  177. package/src/agent-step.ts +26 -0
  178. package/src/agent-work.ts +102 -0
  179. package/src/attachment-payload.ts +1 -1
  180. package/src/auto-approve.test.ts +13 -1
  181. package/src/auto-approve.ts +19 -0
  182. package/src/channel-auth.ts +3 -3
  183. package/src/channel.ts +1 -1
  184. package/src/client-session.ts +9 -2
  185. package/src/computer-control.ts +1 -1
  186. package/src/conversation-history.test.ts +184 -0
  187. package/src/conversation-history.ts +272 -0
  188. package/src/cron.test.ts +280 -0
  189. package/src/cron.ts +355 -0
  190. package/src/define.test.ts +1 -1
  191. package/src/define.ts +3 -1
  192. package/src/event-store.ts +2 -1
  193. package/src/events.ts +17 -1
  194. package/src/file-diff.test.ts +103 -0
  195. package/src/file-diff.ts +208 -0
  196. package/src/index.ts +121 -1
  197. package/src/json-file-store.test.ts +1 -1
  198. package/src/loop-helpers.test.ts +15 -0
  199. package/src/mode/agent-step-loop.ts +645 -0
  200. package/src/mode/checkpoint-gate.ts +198 -0
  201. package/src/mode/checkpoint.ts +6 -0
  202. package/src/mode/collect-stream.ts +1 -1
  203. package/src/mode/project-messages.test.ts +11 -0
  204. package/src/mode/project-messages.ts +8 -61
  205. package/src/mode/react-loop-options.ts +124 -0
  206. package/src/mode/react-loop.ts +10 -296
  207. package/src/mode.ts +19 -7
  208. package/src/npm-registry.test.ts +95 -0
  209. package/src/npm-registry.ts +57 -0
  210. package/src/package-root.test.ts +2 -2
  211. package/src/permission.ts +30 -0
  212. package/src/process-invocation.test.ts +25 -0
  213. package/src/process-invocation.ts +8 -3
  214. package/src/process-tree.ts +37 -0
  215. package/src/provider-login-bridge.ts +2 -3
  216. package/src/provider-schema-constraints.test.ts +1 -1
  217. package/src/provider-utils.test.ts +1 -1
  218. package/src/provider.ts +347 -1
  219. package/src/question-draft.test.ts +57 -0
  220. package/src/question-draft.ts +47 -0
  221. package/src/question.test.ts +130 -0
  222. package/src/question.ts +229 -0
  223. package/src/questions.ts +6 -0
  224. package/src/redact-subpath.test.ts +10 -0
  225. package/src/resolvers.test.ts +84 -1
  226. package/src/resolvers.ts +40 -27
  227. package/src/schemas.test.ts +9 -4
  228. package/src/schemas.ts +3 -2
  229. package/src/server.ts +3 -0
  230. package/src/session-like.ts +19 -1
  231. package/src/skill-mentions.test.ts +1 -1
  232. package/src/starting-model.test.ts +19 -0
  233. package/src/starting-model.ts +11 -0
  234. package/src/token-efficiency.test.ts +1 -1
  235. package/src/tool-dispatch.ts +10 -4
  236. package/src/tool-display.test.ts +119 -0
  237. package/src/tool-display.ts +162 -4
  238. package/src/tool-gating.test.ts +1 -1
  239. package/src/tool-icon.test.ts +1 -1
  240. package/src/tool-modes.test.ts +18 -1
  241. package/src/tool-modes.ts +6 -2
  242. package/src/tool-output.test.ts +46 -0
  243. package/src/tool-output.ts +46 -0
  244. package/src/tool.ts +14 -1
  245. package/src/tunnel.ts +3 -3
  246. package/src/workflow-approval.ts +1 -1
@@ -0,0 +1,198 @@
1
+ import { asPluginId } from '../ids.js';
2
+ import type { MoxxyEvent } from '../events.js';
3
+ import type { ModeContext } from '../mode.js';
4
+ import type { StopReason } from '../provider-utils.js';
5
+ import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
6
+
7
+ /**
8
+ * The turn-end checkpoint gate and the loop's own lifecycle events, shared by
9
+ * the ReAct loop and the agent-connection loop.
10
+ */
11
+
12
+ const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
13
+ const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
14
+
15
+ /** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
16
+ export type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
17
+
18
+ export interface GateRound {
19
+ readonly candidateText: string;
20
+ readonly stopReason: StopReason;
21
+ readonly iteration: number;
22
+ readonly consecutiveIdle: number;
23
+ readonly injectionsUsed: number;
24
+ readonly injectionBudget: number;
25
+ readonly maxInjectChars: number;
26
+ }
27
+
28
+ export async function* runCheckpointGate(
29
+ ctx: ModeContext,
30
+ checkpoints: ReadonlyArray<TurnCheckpoint>,
31
+ round: GateRound,
32
+ ): AsyncGenerator<MoxxyEvent, GateVerdict> {
33
+ // Natural completions face every checkpoint; truncated/errored candidates
34
+ // only face the idle-tolerant ones — reviewing a half-sentence as if it
35
+ // were a completion claim wastes a checker run and confuses the model.
36
+ const eligible = checkpoints.filter(
37
+ (cp) =>
38
+ (round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
39
+ );
40
+ if (eligible.length === 0) return { kind: 'end' };
41
+
42
+ // Budget exhausted → the answer ships as-is, but LOUDLY: silent
43
+ // degradation would let the user believe a gated answer passed its gates.
44
+ if (round.injectionsUsed >= round.injectionBudget) {
45
+ yield await emitError(
46
+ ctx,
47
+ 'retryable',
48
+ `checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
49
+ `ending the turn with unresolved checkpoint feedback; verify the result manually.`,
50
+ );
51
+ return { kind: 'end' };
52
+ }
53
+
54
+ for (const cp of eligible) {
55
+ // User cancelled mid-gate: end the turn WITHOUT retracting the
56
+ // already-logged answer — an abort must never un-say what was said.
57
+ if (ctx.signal.aborted) return { kind: 'end' };
58
+
59
+ yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
60
+ name: cp.name,
61
+ iteration: round.iteration,
62
+ });
63
+
64
+ const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
65
+ const timeoutCtl = new AbortController();
66
+ const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
67
+ timer.unref?.();
68
+ let result: CheckpointResult;
69
+ try {
70
+ result = await cp.run(
71
+ {
72
+ candidateText: round.candidateText,
73
+ stopReason: round.stopReason,
74
+ iteration: round.iteration,
75
+ consecutiveIdle: round.consecutiveIdle,
76
+ injectionsUsed: round.injectionsUsed,
77
+ injectionBudget: round.injectionBudget,
78
+ signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
79
+ },
80
+ ctx,
81
+ );
82
+ } catch (err) {
83
+ if (ctx.signal.aborted) return { kind: 'end' };
84
+ // Fail OPEN, visibly. A crashed or timed-out checker downgrades the
85
+ // gate to a warning — it must never wedge the turn (fail-closed is a
86
+ // turn that can never end) and never crash it (the answer is already
87
+ // logged).
88
+ const why = timeoutCtl.signal.aborted
89
+ ? `timed out after ${timeoutMs}ms`
90
+ : `failed: ${err instanceof Error ? err.message : String(err)}`;
91
+ yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
92
+ continue;
93
+ } finally {
94
+ clearTimeout(timer);
95
+ }
96
+
97
+ switch (result.action) {
98
+ case 'pass': {
99
+ yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
100
+ continue;
101
+ }
102
+ case 'stop': {
103
+ // The checkpoint already emitted its own wrap-up events.
104
+ yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
105
+ return { kind: 'end' };
106
+ }
107
+ case 'retry': {
108
+ yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
109
+ return { kind: 'loop' };
110
+ }
111
+ case 'inject': {
112
+ // Guard the guard: an inject with blank text would loop the turn
113
+ // with no new signal for the model — a checker bug; fail open.
114
+ const feedback = result.text?.trim();
115
+ if (!feedback) {
116
+ yield await emitError(
117
+ ctx,
118
+ 'retryable',
119
+ `checkpoint "${cp.name}" injected empty feedback — ignored`,
120
+ );
121
+ continue;
122
+ }
123
+ const clamped = clampChars(feedback, round.maxInjectChars);
124
+ if (result.volatile) {
125
+ yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
126
+ name: cp.name,
127
+ volatile: true,
128
+ });
129
+ return { kind: 'loop', volatileText: clamped };
130
+ }
131
+ // Persistent: a checkpoint-origin user prompt. Projected as a
132
+ // user-role message by the existing projection case, marked via the
133
+ // same `origin` machinery trigger prompts use, cache-safe because it
134
+ // is an ordinary append at the log tail.
135
+ yield await ctx.emit({
136
+ type: 'user_prompt',
137
+ sessionId: ctx.sessionId,
138
+ turnId: ctx.turnId,
139
+ source: 'system',
140
+ text: clamped,
141
+ origin: { kind: 'checkpoint', name: cp.name },
142
+ });
143
+ yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
144
+ name: cp.name,
145
+ volatile: false,
146
+ });
147
+ return { kind: 'loop' };
148
+ }
149
+ }
150
+ }
151
+ return { kind: 'end' };
152
+ }
153
+
154
+ function clampChars(text: string, max: number): string {
155
+ if (text.length <= max) return text;
156
+ return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
157
+ }
158
+
159
+ export async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
160
+ return ctx.emit({
161
+ type: 'abort',
162
+ sessionId: ctx.sessionId,
163
+ turnId: ctx.turnId,
164
+ source: 'system',
165
+ reason,
166
+ });
167
+ }
168
+
169
+ export async function emitError(
170
+ ctx: ModeContext,
171
+ kind: 'retryable' | 'fatal',
172
+ message: string,
173
+ ): Promise<MoxxyEvent> {
174
+ return ctx.emit({
175
+ type: 'error',
176
+ sessionId: ctx.sessionId,
177
+ turnId: ctx.turnId,
178
+ source: 'system',
179
+ kind,
180
+ message,
181
+ });
182
+ }
183
+
184
+ export async function emitCheckpointEvent(
185
+ ctx: ModeContext,
186
+ subtype: string,
187
+ payload: unknown,
188
+ ): Promise<MoxxyEvent> {
189
+ return ctx.emit({
190
+ type: 'plugin_event',
191
+ sessionId: ctx.sessionId,
192
+ turnId: ctx.turnId,
193
+ source: 'system',
194
+ pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
195
+ subtype,
196
+ payload,
197
+ });
198
+ }
@@ -101,6 +101,12 @@ export interface TurnCheckpoint {
101
101
  * (goal/collab-style stall handling wants these too).
102
102
  */
103
103
  readonly gateOn?: 'end_turn' | 'idle';
104
+ /**
105
+ * Also gate turns done by a vendor's agent (an agent connection). Off by
106
+ * default: the agent decides when its turn is done, as in its own tool, so
107
+ * Moxxy's end-of-turn reminders stay out of it.
108
+ */
109
+ readonly agentConnections?: boolean;
104
110
  /**
105
111
  * A cheap test of whether this turn needs the checkpoint at all. When it
106
112
  * says no, the checkpoint is skipped without a trace — no lifecycle events —
@@ -22,7 +22,7 @@ export interface CollectedToolUse {
22
22
  }
23
23
 
24
24
  /** Sum two usage frames so multi-`message_end` responses don't undercount. */
25
- function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
25
+ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
26
26
  const cacheRead =
27
27
  a.cacheReadTokens !== undefined || b.cacheReadTokens !== undefined
28
28
  ? (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0)
@@ -187,6 +187,17 @@ describe('projectMessagesFromLog tool_result stringify hardening', () => {
187
187
  it('passes a string output through verbatim', () => {
188
188
  expect(toolResultText(logWith('plain text'))).toBe('plain text');
189
189
  });
190
+
191
+ it('shows the model every image a result carries, after its text', () => {
192
+ const output = { forModel: 'Two windows', mediaType: 'image/png', base64: 'AAA', moreImages: [{ mediaType: 'image/jpeg', base64: 'BBB' }] };
193
+ const msgs = projectMessagesFromLog({ log: reader(logWith(output)) });
194
+
195
+ expect(msgs.find((m) => m.role === 'tool_result')?.content).toEqual([
196
+ { type: 'tool_result', toolUseId: 'c1', content: 'Two windows', isError: false },
197
+ { type: 'image', mediaType: 'image/png', data: 'AAA' },
198
+ { type: 'image', mediaType: 'image/jpeg', data: 'BBB' },
199
+ ]);
200
+ });
190
201
  });
191
202
 
192
203
  describe('skillsWithinReach', () => {
@@ -22,51 +22,7 @@ import {
22
22
  toolResultStubbed,
23
23
  type ElisionState,
24
24
  } from '../elision-state.js';
25
- import { isToolDisplayResult } from '../tool-display.js';
26
-
27
- /**
28
- * Stringify an arbitrary tool output for the model-facing tool_result text.
29
- * `tool_result.output` is `unknown` and comes straight from whatever a tool
30
- * returned, so it can be circular or contain a BigInt — either of which makes
31
- * `JSON.stringify` THROW. Because projection runs on every request over the
32
- * append-only log, an unguarded throw here permanently wedges the session
33
- * (every subsequent re-projection re-throws). Mirrors the try/catch guard the
34
- * sibling sizing paths (`safeJsonLen`, `toolResultBytes`) already use.
35
- */
36
- function safeStringifyOutput(output: unknown): string {
37
- if (typeof output === 'string') return output;
38
- try {
39
- return JSON.stringify(output ?? '');
40
- } catch {
41
- try {
42
- return String(output ?? '');
43
- } catch {
44
- return '[unserializable tool output]';
45
- }
46
- }
47
- }
48
-
49
- /**
50
- * An image ContentBlock extracted from an image-shaped tool output. A tool can
51
- * return either `{ mediaType, base64 }` (the desktop screenshot/clipboard shape)
52
- * or the provider-native `{ type:'image', mediaType, data }` — both normalize to
53
- * the same provider `image` block so the model SEES the pixels instead of a
54
- * stringified blob of base64.
55
- */
56
- function imageBlockFromOutput(output: unknown): Extract<ContentBlock, { type: 'image' }> | null {
57
- if (typeof output !== 'object' || output === null) return null;
58
- const o = output as { type?: unknown; mediaType?: unknown; base64?: unknown; data?: unknown };
59
- if (typeof o.mediaType !== 'string') return null;
60
- // `{ mediaType, base64 }` (raw shape) or `{ type:'image', mediaType, data }`.
61
- const data =
62
- typeof o.base64 === 'string'
63
- ? o.base64
64
- : o.type === 'image' && typeof o.data === 'string'
65
- ? o.data
66
- : null;
67
- if (data === null) return null;
68
- return { type: 'image', mediaType: o.mediaType, data };
69
- }
25
+ import { toolOutputForModel, type ToolImageBlock } from '../tool-output.js';
70
26
 
71
27
  /** Appended to the system prompt while elision is active (see projection). */
72
28
  export const ELISION_SYSTEM_NOTE =
@@ -524,10 +480,10 @@ export function projectMessages(
524
480
  // Stub bulky old tool output to a recall-able marker (decision shared
525
481
  // with estimateContextTokens via toolResultStubbed).
526
482
  let text: string;
527
- // An image-shaped output (a screenshot/clipboard grab) is carried as a
528
- // provider `image` block so the model SEES the pixels — only when the
483
+ // An image-shaped output (a screenshot/clipboard grab) is carried as
484
+ // provider `image` blocks so the model SEES the pixels — only when the
529
485
  // result isn't stubbed (elided) or an error and the call succeeded.
530
- let image: Extract<ContentBlock, { type: 'image' }> | null = null;
486
+ let images: ReadonlyArray<ToolImageBlock> = [];
531
487
  // A `recall({ attachment })` result: the original image/document goes
532
488
  // beside it, from the prompt that carried it, while the budget allows.
533
489
  let restored: ContentBlock | null = null;
@@ -536,10 +492,6 @@ export function projectMessages(
536
492
  text = toolResultStubText(e, el);
537
493
  } else if (e.error) {
538
494
  text = `[error:${e.error.kind}] ${e.error.message}`;
539
- } else if (isToolDisplayResult(e.output)) {
540
- // Rich result (e.g. a file diff): the model only needs the short
541
- // `forModel` summary — the structured `display` is for channels.
542
- text = e.output.forModel;
543
495
  } else if (restoredAtt) {
544
496
  const description = (e.output as { forModel?: unknown }).forModel;
545
497
  text = typeof description === 'string' ? description : '[attachment restored — see below]';
@@ -550,20 +502,15 @@ export function projectMessages(
550
502
  ' [It could not be shown again: newer attachments already fill what one request may carry.' +
551
503
  ' Ask the person to attach it again if you need it now.]';
552
504
  }
553
- } else if (e.ok && (image = imageBlockFromOutput(e.output))) {
554
- // Short marker satisfies the tool_use→tool_result pairing; the image
555
- // block (appended below) carries the actual pixels.
556
- const description = (e.output as { forModel?: unknown }).forModel;
557
- text = typeof description === 'string'
558
- ? description
559
- : '[image returned by tool — see attached image]';
560
505
  } else {
561
- text = safeStringifyOutput(e.output);
506
+ const forModel = toolOutputForModel(e.output);
507
+ text = forModel.text;
508
+ if (e.ok) images = forModel.images;
562
509
  }
563
510
  const content: ContentBlock[] = [
564
511
  { type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
512
+ ...images,
565
513
  ];
566
- if (image) content.push(image);
567
514
  if (restored) content.push(restored);
568
515
  messages.push({ role: 'tool_result', content });
569
516
  recordStable(e.seq);
@@ -0,0 +1,124 @@
1
+ import type { ModeContext } from '../mode.js';
2
+ import type { AgentGoalStatus, TokenUsage } from '../provider.js';
3
+ import type { StopReason } from '../provider-utils.js';
4
+ import type { StuckLoopReport, StuckTripInfo } from '../tool-dispatch.js';
5
+ import type { TurnCheckpoint } from './checkpoint.js';
6
+ import type { CollectedToolUse } from './collect-stream.js';
7
+
8
+ /** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
9
+ export interface ProviderSuccessInfo {
10
+ readonly text: string;
11
+ readonly stopReason: StopReason;
12
+ readonly toolUses: ReadonlyArray<CollectedToolUse>;
13
+ readonly usage?: TokenUsage;
14
+ readonly iteration: number;
15
+ }
16
+
17
+ /** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
18
+ export interface ToolBatchInfo {
19
+ readonly toolUses: ReadonlyArray<CollectedToolUse>;
20
+ readonly iteration: number;
21
+ }
22
+
23
+ /** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
24
+ export interface StopDirective {
25
+ readonly action: 'stop';
26
+ }
27
+
28
+ export interface ReactLoopOptions {
29
+ /** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
30
+ readonly strategyName: string;
31
+ /**
32
+ * Iteration cap when the context doesn't supply one. Default 500. May be
33
+ * `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
34
+ * ends only via a terminal signal (checkpoint/hook stop, abort, fatal
35
+ * error) — an explicit `ctx.maxIterations` still takes precedence.
36
+ */
37
+ readonly defaultMaxIterations?: number;
38
+ /** Prefix for provider-error messages (goal mode uses `'goal: '`). */
39
+ readonly errorPrefix?: string;
40
+ /**
41
+ * When set, an already-aborted signal is reported (with this reason) BEFORE
42
+ * the first iteration — goal/collab preflight. When omitted the first
43
+ * iteration's own abort check covers it with the generic reason.
44
+ */
45
+ readonly preflightAbortReason?: string;
46
+ /** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
47
+ readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
48
+ /**
49
+ * Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
50
+ * consecutive gate rounds with no tool work between them; the count resets
51
+ * whenever a tool batch executes (default 3). When exhausted the turn ends
52
+ * with the model's answer as-is plus a visible warning — a
53
+ * permanently-failing gate degrades loudly instead of looping forever.
54
+ */
55
+ readonly maxInjections?: number;
56
+ /** Clamp on injected feedback length in characters (default 16_384). */
57
+ readonly maxInjectChars?: number;
58
+ /**
59
+ * Stuck-loop policy + wording overrides; sensible defaults from
60
+ * `strategyName`. `action` picks what a detector trip does:
61
+ *
62
+ * - `'abort'` (default): fail the batch and end the turn with a fatal
63
+ * error — the historical behavior, right for attended modes where the
64
+ * user is present to redirect.
65
+ * - `'nudge'`: never stop. The batch still executes (repeated calls are
66
+ * usually legitimate work — re-running a failing build between edits),
67
+ * a visible warning + `extraOnStuck` events are emitted, the detector
68
+ * resets, and `nudgeText` (or a default) rides the next provider call
69
+ * as a volatile steer. For unattended modes (goal) where a heuristic
70
+ * must never kill the run.
71
+ */
72
+ readonly stuck?: Partial<StuckLoopReport> & {
73
+ readonly action?: 'abort' | 'nudge';
74
+ /** Volatile steer for `'nudge'` trips; default wording when omitted. */
75
+ readonly nudgeText?: (info: StuckTripInfo) => string;
76
+ };
77
+ /**
78
+ * Runs after compaction/elision, before the provider call. May return a
79
+ * volatile user message to ride ONLY the next call (collab's inbox/pause
80
+ * awareness) — never appended to the log; the cache strategy is told via
81
+ * `volatileTailCount` so its tail breakpoint stays ahead of it.
82
+ */
83
+ readonly onIterationStart?: (
84
+ ctx: ModeContext,
85
+ iteration: number,
86
+ ) => Promise<{ readonly volatileUserText?: string } | undefined>;
87
+ /**
88
+ * Runs after every clean provider call (reasoning already logged, tools not
89
+ * yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
90
+ * its own wrap-up events first (goal mode's token-budget backstop).
91
+ */
92
+ readonly onProviderSuccess?: (
93
+ ctx: ModeContext,
94
+ info: ProviderSuccessInfo,
95
+ ) => Promise<StopDirective | undefined>;
96
+ /**
97
+ * Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
98
+ * end the turn (goal/collab terminal-tool detection: `goal_complete`,
99
+ * `collab_done`); the hook emits its own completion events first.
100
+ */
101
+ readonly onToolBatchEnd?: (
102
+ ctx: ModeContext,
103
+ info: ToolBatchInfo,
104
+ ) => Promise<StopDirective | undefined>;
105
+ /**
106
+ * Runs after an agent connection's turn that reported the state of the
107
+ * agent's own goal. Return `{action: 'stop'}` to end the turn (goal mode
108
+ * wraps up a met goal); the hook emits its own completion events first.
109
+ */
110
+ readonly onAgentGoal?: (ctx: ModeContext, status: AgentGoalStatus) => Promise<StopDirective | undefined>;
111
+ /**
112
+ * Agent connections only: the agent finished its own plan (Markdown). Plan
113
+ * mode shows it as its plan card and ends the turn; without this hook the
114
+ * plan is shown as the agent's message.
115
+ */
116
+ readonly onAgentPlan?: (ctx: ModeContext, plan: string) => Promise<StopDirective | undefined>;
117
+ /**
118
+ * Replaces the default "exceeded maxIterations" fatal error — the hook
119
+ * emits its own cap-reached events (goal mode adds a plugin event and its
120
+ * own wording). The loop returns right after.
121
+ */
122
+ readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
123
+ }
124
+