@moxxy/sdk 0.43.0 → 0.45.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/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- 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/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +23 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -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 +122 -81
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +37 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +23 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +140 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +118 -1
- package/src/mode/project-messages.ts +136 -77
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- 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
|
+
}
|
package/src/mode/checkpoint.ts
CHANGED
|
@@ -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)
|
|
@@ -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';
|
|
@@ -181,6 +187,17 @@ describe('projectMessagesFromLog tool_result stringify hardening', () => {
|
|
|
181
187
|
it('passes a string output through verbatim', () => {
|
|
182
188
|
expect(toolResultText(logWith('plain text'))).toBe('plain text');
|
|
183
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
|
+
});
|
|
184
201
|
});
|
|
185
202
|
|
|
186
203
|
describe('skillsWithinReach', () => {
|
|
@@ -252,3 +269,103 @@ describe('projectMessagesFromLog tool pairing across a compaction boundary', ()
|
|
|
252
269
|
});
|
|
253
270
|
});
|
|
254
271
|
});
|
|
272
|
+
|
|
273
|
+
describe('projectMessagesFromLog attachment budget', () => {
|
|
274
|
+
const pdf = (name: string, chars: number) => ({
|
|
275
|
+
kind: 'document' as const,
|
|
276
|
+
content: 'A'.repeat(chars),
|
|
277
|
+
mediaType: 'application/pdf',
|
|
278
|
+
name,
|
|
279
|
+
});
|
|
280
|
+
const blocksOf = (events: ReadonlyArray<MoxxyEvent>) =>
|
|
281
|
+
projectMessagesFromLog({ log: reader(events) }).flatMap((m) => m.content);
|
|
282
|
+
|
|
283
|
+
it('sends the newest attachments in full and names the older ones that no longer fit', () => {
|
|
284
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
285
|
+
const blocks = blocksOf([
|
|
286
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
287
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
288
|
+
]);
|
|
289
|
+
const documents = blocks.filter((b) => b.type === 'document');
|
|
290
|
+
expect(documents).toHaveLength(1);
|
|
291
|
+
expect(documents[0]).toMatchObject({ name: 'new-scan.pdf' });
|
|
292
|
+
const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
|
|
293
|
+
expect(note).toBeDefined();
|
|
294
|
+
const sent = blocks.reduce((n, b) => n + (b.type === 'document' ? b.data.length : 0), 0);
|
|
295
|
+
expect(sent).toBeLessThanOrEqual(MODEL_ATTACHMENT_BUDGET_CHARS);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('tells the model how to bring a left-out attachment back', () => {
|
|
299
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
300
|
+
const blocks = blocksOf([
|
|
301
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
302
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
303
|
+
]);
|
|
304
|
+
const note = blocks.find((b) => b.type === 'text' && b.text.includes('old-scan.pdf'));
|
|
305
|
+
expect(note).toMatchObject({ text: expect.stringContaining('recall({ attachment: "0:0" })') });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const restore = (seq: number, callId: string, ref: { seq: number; index: number }): MoxxyEvent[] => [
|
|
309
|
+
event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'recall', input: { attachment: `${ref.seq}:${ref.index}` } }),
|
|
310
|
+
event(seq + 1, {
|
|
311
|
+
type: 'tool_result',
|
|
312
|
+
turnId: t1,
|
|
313
|
+
source: 'tool',
|
|
314
|
+
callId,
|
|
315
|
+
ok: true,
|
|
316
|
+
output: { forModel: 'restored old-scan.pdf', restoredAttachment: ref },
|
|
317
|
+
}),
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
it('shows a restored attachment again beside the recall result, within the same budget', () => {
|
|
321
|
+
const half = Math.floor(MODEL_ATTACHMENT_BUDGET_CHARS * 0.6);
|
|
322
|
+
const messages = projectMessagesFromLog({
|
|
323
|
+
log: reader([
|
|
324
|
+
userPrompt(0, { text: 'first scan', attachments: [pdf('old-scan.pdf', half)] }),
|
|
325
|
+
userPrompt(1, { text: 'second scan', attachments: [pdf('new-scan.pdf', half)] }),
|
|
326
|
+
userPrompt(2, { text: 'back to the first one' }),
|
|
327
|
+
...restore(3, 'r1', { seq: 0, index: 0 }),
|
|
328
|
+
]),
|
|
329
|
+
});
|
|
330
|
+
const result = messages.find((m) => m.role === 'tool_result');
|
|
331
|
+
expect(result?.content).toEqual([
|
|
332
|
+
{ type: 'tool_result', toolUseId: 'r1', content: 'restored old-scan.pdf', isError: false },
|
|
333
|
+
{ type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(half), name: 'old-scan.pdf' },
|
|
334
|
+
]);
|
|
335
|
+
const blocks = messages.flatMap((m) => m.content);
|
|
336
|
+
expect(blocks.filter((b) => b.type === 'document')).toHaveLength(1);
|
|
337
|
+
expect(blocks.some((b) => b.type === 'text' && b.text.includes('new-scan.pdf'))).toBe(true);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('restores an attachment whose turn a compaction summarized away', () => {
|
|
341
|
+
const messages = projectMessagesFromLog({
|
|
342
|
+
log: reader([
|
|
343
|
+
userPrompt(0, { text: 'the contract', attachments: [pdf('umowa.pdf', 100)] }),
|
|
344
|
+
event(1, {
|
|
345
|
+
type: 'compaction',
|
|
346
|
+
turnId: t1,
|
|
347
|
+
source: 'compactor',
|
|
348
|
+
compactor: 'segments',
|
|
349
|
+
replacedRange: [0, 0],
|
|
350
|
+
summary: 'the person shared umowa.pdf',
|
|
351
|
+
tokensSaved: 10,
|
|
352
|
+
}),
|
|
353
|
+
userPrompt(2, { text: 'what was in point 7?' }),
|
|
354
|
+
...restore(3, 'r1', { seq: 0, index: 0 }),
|
|
355
|
+
]),
|
|
356
|
+
});
|
|
357
|
+
const documents = messages.flatMap((m) => m.content).filter((b) => b.type === 'document');
|
|
358
|
+
expect(documents).toEqual([{ type: 'document', mediaType: 'application/pdf', data: 'A'.repeat(100), name: 'umowa.pdf' }]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('never sends an empty payload for an attachment whose bytes were left out', () => {
|
|
362
|
+
const blocks = projectUserPrompt(
|
|
363
|
+
userPrompt(0, {
|
|
364
|
+
attachments: [{ kind: 'image', content: '', mediaType: 'image/png', name: 'shot.png', omittedBytes: 2048 }],
|
|
365
|
+
}),
|
|
366
|
+
noElision,
|
|
367
|
+
);
|
|
368
|
+
expect(blocks.some((b) => b.type === 'image')).toBe(false);
|
|
369
|
+
expect(blocks.some((b) => b.type === 'text' && b.text.includes('shot.png'))).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -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,
|
|
@@ -16,51 +22,7 @@ import {
|
|
|
16
22
|
toolResultStubbed,
|
|
17
23
|
type ElisionState,
|
|
18
24
|
} from '../elision-state.js';
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Stringify an arbitrary tool output for the model-facing tool_result text.
|
|
23
|
-
* `tool_result.output` is `unknown` and comes straight from whatever a tool
|
|
24
|
-
* returned, so it can be circular or contain a BigInt — either of which makes
|
|
25
|
-
* `JSON.stringify` THROW. Because projection runs on every request over the
|
|
26
|
-
* append-only log, an unguarded throw here permanently wedges the session
|
|
27
|
-
* (every subsequent re-projection re-throws). Mirrors the try/catch guard the
|
|
28
|
-
* sibling sizing paths (`safeJsonLen`, `toolResultBytes`) already use.
|
|
29
|
-
*/
|
|
30
|
-
function safeStringifyOutput(output: unknown): string {
|
|
31
|
-
if (typeof output === 'string') return output;
|
|
32
|
-
try {
|
|
33
|
-
return JSON.stringify(output ?? '');
|
|
34
|
-
} catch {
|
|
35
|
-
try {
|
|
36
|
-
return String(output ?? '');
|
|
37
|
-
} catch {
|
|
38
|
-
return '[unserializable tool output]';
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* An image ContentBlock extracted from an image-shaped tool output. A tool can
|
|
45
|
-
* return either `{ mediaType, base64 }` (the desktop screenshot/clipboard shape)
|
|
46
|
-
* or the provider-native `{ type:'image', mediaType, data }` — both normalize to
|
|
47
|
-
* the same provider `image` block so the model SEES the pixels instead of a
|
|
48
|
-
* stringified blob of base64.
|
|
49
|
-
*/
|
|
50
|
-
function imageBlockFromOutput(output: unknown): Extract<ContentBlock, { type: 'image' }> | null {
|
|
51
|
-
if (typeof output !== 'object' || output === null) return null;
|
|
52
|
-
const o = output as { type?: unknown; mediaType?: unknown; base64?: unknown; data?: unknown };
|
|
53
|
-
if (typeof o.mediaType !== 'string') return null;
|
|
54
|
-
// `{ mediaType, base64 }` (raw shape) or `{ type:'image', mediaType, data }`.
|
|
55
|
-
const data =
|
|
56
|
-
typeof o.base64 === 'string'
|
|
57
|
-
? o.base64
|
|
58
|
-
: o.type === 'image' && typeof o.data === 'string'
|
|
59
|
-
? o.data
|
|
60
|
-
: null;
|
|
61
|
-
if (data === null) return null;
|
|
62
|
-
return { type: 'image', mediaType: o.mediaType, data };
|
|
63
|
-
}
|
|
25
|
+
import { toolOutputForModel, type ToolImageBlock } from '../tool-output.js';
|
|
64
26
|
|
|
65
27
|
/** Appended to the system prompt while elision is active (see projection). */
|
|
66
28
|
export const ELISION_SYSTEM_NOTE =
|
|
@@ -135,6 +97,96 @@ export interface ProjectMessagesOptions {
|
|
|
135
97
|
readonly precomputedElisionState?: ElisionState;
|
|
136
98
|
}
|
|
137
99
|
|
|
100
|
+
function hasModelPayload(att: UserPromptAttachment): boolean {
|
|
101
|
+
return att.kind === 'image' || att.kind === 'document';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function attachmentLeftOutNote(att: UserPromptAttachment, seq: number, index: number): string {
|
|
105
|
+
const label = `${att.kind}${att.name ? ` "${att.name}"` : ''}`;
|
|
106
|
+
const mb = (attachmentBytes(att) / (1024 * 1024)).toFixed(1);
|
|
107
|
+
return (
|
|
108
|
+
`[${label} (${mb} MB) was attached earlier in this conversation and is no longer sent ` +
|
|
109
|
+
`with each request, to keep the request within the model's size limit. ` +
|
|
110
|
+
`To see it again, call recall({ attachment: "${attachmentKey(seq, index)}" }).]`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The image or document block an attachment the model reads as bytes becomes. */
|
|
115
|
+
function attachmentBlock(att: UserPromptAttachment): ContentBlock {
|
|
116
|
+
if (att.kind === 'image') {
|
|
117
|
+
return { type: 'image', mediaType: att.mediaType ?? 'image/png', data: att.content };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
type: 'document',
|
|
121
|
+
mediaType: att.mediaType ?? 'application/pdf',
|
|
122
|
+
data: att.content,
|
|
123
|
+
...(att.name ? { name: att.name } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The prompts that carry attachments, by seq, so a restoring recall finds its original. */
|
|
128
|
+
function attachmentPromptsBySeq(events: ReadonlyArray<MoxxyEvent>): ReadonlyMap<number, UserPromptEvent> {
|
|
129
|
+
const prompts = new Map<number, UserPromptEvent>();
|
|
130
|
+
for (const e of events) {
|
|
131
|
+
if (e.type === 'user_prompt' && e.attachments && e.attachments.length > 0) prompts.set(e.seq, e);
|
|
132
|
+
}
|
|
133
|
+
return prompts;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The image/document a `recall({ attachment })` result brings back, or null. */
|
|
137
|
+
function restoredAttachment(
|
|
138
|
+
e: Extract<MoxxyEvent, { type: 'tool_result' }>,
|
|
139
|
+
prompts: ReadonlyMap<number, UserPromptEvent>,
|
|
140
|
+
): UserPromptAttachment | null {
|
|
141
|
+
if (!e.ok || e.error) return null;
|
|
142
|
+
const ref = restoredAttachmentRef(e.output);
|
|
143
|
+
if (!ref) return null;
|
|
144
|
+
const prompt = prompts.get(ref.seq);
|
|
145
|
+
const att = prompt ? prompt.attachments?.[ref.index] : undefined;
|
|
146
|
+
return att && hasModelPayload(att) && att.omittedBytes === undefined ? att : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The budget key of what a restoring recall result shows. */
|
|
150
|
+
const restoreKey = (seq: number): string => `r:${seq}`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Keys of the image/document payloads that go to the model in full: an
|
|
154
|
+
* attachment's `seq:index`, or `r:seq` for one a recall result restores.
|
|
155
|
+
* Newest first, while they fit {@link MODEL_ATTACHMENT_BUDGET_CHARS}, so a
|
|
156
|
+
* restored attachment takes its place among the newest. Deterministic for a
|
|
157
|
+
* given log, so the prompt cache sees a stable prefix.
|
|
158
|
+
*/
|
|
159
|
+
function attachmentsSentInFull(
|
|
160
|
+
events: ReadonlyArray<MoxxyEvent>,
|
|
161
|
+
el: ElisionState,
|
|
162
|
+
isCompacted: (seq: number) => boolean,
|
|
163
|
+
prompts: ReadonlyMap<number, UserPromptEvent>,
|
|
164
|
+
): ReadonlySet<string> {
|
|
165
|
+
const keys = new Set<string>();
|
|
166
|
+
let room = MODEL_ATTACHMENT_BUDGET_CHARS;
|
|
167
|
+
const take = (key: string, att: UserPromptAttachment): void => {
|
|
168
|
+
if (att.content.length > room) return;
|
|
169
|
+
room -= att.content.length;
|
|
170
|
+
keys.add(key);
|
|
171
|
+
};
|
|
172
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
173
|
+
const e = events[i];
|
|
174
|
+
if (!e || isCompacted(e.seq)) continue;
|
|
175
|
+
if (e.type === 'tool_result') {
|
|
176
|
+
if (toolResultStubbed(e, el)) continue;
|
|
177
|
+
const att = restoredAttachment(e, prompts);
|
|
178
|
+
if (att) take(restoreKey(e.seq), att);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (e.type !== 'user_prompt' || !e.attachments || conversationalStubbed(e, el)) continue;
|
|
182
|
+
for (const [index, att] of e.attachments.entries()) {
|
|
183
|
+
if (!hasModelPayload(att) || att.omittedBytes !== undefined) continue;
|
|
184
|
+
take(attachmentKey(e.seq, index), att);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return keys;
|
|
188
|
+
}
|
|
189
|
+
|
|
138
190
|
/**
|
|
139
191
|
* Pure projection of a single `user_prompt` event to its content blocks.
|
|
140
192
|
*
|
|
@@ -144,27 +196,23 @@ export interface ProjectMessagesOptions {
|
|
|
144
196
|
* what the inline switch arm produced. Caller decides how to wrap it as a
|
|
145
197
|
* message / record the stable prefix.
|
|
146
198
|
*/
|
|
147
|
-
export function projectUserPrompt(
|
|
199
|
+
export function projectUserPrompt(
|
|
200
|
+
event: UserPromptEvent,
|
|
201
|
+
el: ElisionState,
|
|
202
|
+
/** Which attachments go to the model in full; the rest become a short note. Default: all. */
|
|
203
|
+
sendsInFull: (index: number) => boolean = () => true,
|
|
204
|
+
): ContentBlock[] {
|
|
148
205
|
// Elided + conversational: collapse to a stub (anchor/tiny kept full).
|
|
149
206
|
if (conversationalStubbed(event, el)) {
|
|
150
207
|
return [{ type: 'text', text: conversationalStub('user', event.seq) }];
|
|
151
208
|
}
|
|
152
209
|
const blocks: ContentBlock[] = [{ type: 'text', text: event.text }];
|
|
153
210
|
if (event.attachments) {
|
|
154
|
-
for (const att of event.attachments) {
|
|
155
|
-
if (att.
|
|
156
|
-
blocks.push({
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
});
|
|
211
|
+
for (const [index, att] of event.attachments.entries()) {
|
|
212
|
+
if (att.omittedBytes !== undefined || (hasModelPayload(att) && !sendsInFull(index))) {
|
|
213
|
+
blocks.push({ type: 'text', text: attachmentLeftOutNote(att, event.seq, index) });
|
|
214
|
+
} else if (hasModelPayload(att)) {
|
|
215
|
+
blocks.push(attachmentBlock(att));
|
|
168
216
|
} else {
|
|
169
217
|
blocks.push({
|
|
170
218
|
type: 'text',
|
|
@@ -277,6 +325,8 @@ export function projectMessages(
|
|
|
277
325
|
// Only VISIBLE results count: one swallowed by a compaction is never sent, so
|
|
278
326
|
// its still-visible tool_use needs the fallback just like a missing one.
|
|
279
327
|
const resolvedCallIds = resolvedCallIdSet(allEvents.filter((e) => !compactionFor(e.seq)));
|
|
328
|
+
const attachmentPrompts = attachmentPromptsBySeq(allEvents);
|
|
329
|
+
const sentInFull = attachmentsSentInFull(allEvents, el, (seq) => compactionFor(seq) !== null, attachmentPrompts);
|
|
280
330
|
// callIds whose tool_use block is projected. A tool_result outside this set
|
|
281
331
|
// answers a call a compaction summarized away (the result landed after later
|
|
282
332
|
// turns began); providers reject a result with no matching call.
|
|
@@ -347,7 +397,10 @@ export function projectMessages(
|
|
|
347
397
|
case 'user_prompt': {
|
|
348
398
|
flush();
|
|
349
399
|
pendingReasoning = null;
|
|
350
|
-
messages.push({
|
|
400
|
+
messages.push({
|
|
401
|
+
role: 'user',
|
|
402
|
+
content: projectUserPrompt(e, el, (index) => sentInFull.has(`${e.seq}:${index}`)),
|
|
403
|
+
});
|
|
351
404
|
recordStable(e.seq);
|
|
352
405
|
break;
|
|
353
406
|
}
|
|
@@ -427,32 +480,38 @@ export function projectMessages(
|
|
|
427
480
|
// Stub bulky old tool output to a recall-able marker (decision shared
|
|
428
481
|
// with estimateContextTokens via toolResultStubbed).
|
|
429
482
|
let text: string;
|
|
430
|
-
// An image-shaped output (a screenshot/clipboard grab) is carried as
|
|
431
|
-
// provider `image`
|
|
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
|
|
432
485
|
// result isn't stubbed (elided) or an error and the call succeeded.
|
|
433
|
-
let
|
|
486
|
+
let images: ReadonlyArray<ToolImageBlock> = [];
|
|
487
|
+
// A `recall({ attachment })` result: the original image/document goes
|
|
488
|
+
// beside it, from the prompt that carried it, while the budget allows.
|
|
489
|
+
let restored: ContentBlock | null = null;
|
|
490
|
+
const restoredAtt = toolResultStubbed(e, el) ? null : restoredAttachment(e, attachmentPrompts);
|
|
434
491
|
if (toolResultStubbed(e, el)) {
|
|
435
492
|
text = toolResultStubText(e, el);
|
|
436
493
|
} else if (e.error) {
|
|
437
494
|
text = `[error:${e.error.kind}] ${e.error.message}`;
|
|
438
|
-
} else if (
|
|
439
|
-
// Rich result (e.g. a file diff): the model only needs the short
|
|
440
|
-
// `forModel` summary — the structured `display` is for channels.
|
|
441
|
-
text = e.output.forModel;
|
|
442
|
-
} else if (e.ok && (image = imageBlockFromOutput(e.output))) {
|
|
443
|
-
// Short marker satisfies the tool_use→tool_result pairing; the image
|
|
444
|
-
// block (appended below) carries the actual pixels.
|
|
495
|
+
} else if (restoredAtt) {
|
|
445
496
|
const description = (e.output as { forModel?: unknown }).forModel;
|
|
446
|
-
text = typeof description === 'string'
|
|
447
|
-
|
|
448
|
-
|
|
497
|
+
text = typeof description === 'string' ? description : '[attachment restored — see below]';
|
|
498
|
+
if (sentInFull.has(restoreKey(e.seq))) {
|
|
499
|
+
restored = attachmentBlock(restoredAtt);
|
|
500
|
+
} else {
|
|
501
|
+
text +=
|
|
502
|
+
' [It could not be shown again: newer attachments already fill what one request may carry.' +
|
|
503
|
+
' Ask the person to attach it again if you need it now.]';
|
|
504
|
+
}
|
|
449
505
|
} else {
|
|
450
|
-
|
|
506
|
+
const forModel = toolOutputForModel(e.output);
|
|
507
|
+
text = forModel.text;
|
|
508
|
+
if (e.ok) images = forModel.images;
|
|
451
509
|
}
|
|
452
510
|
const content: ContentBlock[] = [
|
|
453
511
|
{ type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok },
|
|
512
|
+
...images,
|
|
454
513
|
];
|
|
455
|
-
if (
|
|
514
|
+
if (restored) content.push(restored);
|
|
456
515
|
messages.push({ role: 'tool_result', content });
|
|
457
516
|
recordStable(e.seq);
|
|
458
517
|
break;
|