ravensight-playtest 0.7.0 → 0.7.5

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.
@@ -0,0 +1,102 @@
1
+ import { OUTPUT_WEIGHT } from './usage.js';
2
+
3
+ /**
4
+ * Keeping enough of a run's model budget back to write its report.
5
+ *
6
+ * The proxy reserves each call's pessimistic projection before sending it:
7
+ * the whole request body at the full input rate (characters / 3.5) plus the
8
+ * output ceiling at the output rate. A call whose projection does not fit
9
+ * what is left of the run's budget is refused with 402 before anything is
10
+ * sent. The server's own warning comes at 80 percent of the budget, counting
11
+ * the call being made, and in the October 26 runs it never came: one act
12
+ * turn's projection jumped the run from under 80 percent to a refusal, and the
13
+ * report that followed did not fit either.
14
+ *
15
+ * So before every act turn the loop asks this module whether, after that
16
+ * turn, the report call would still be admitted. Both projections are sized
17
+ * from the requests themselves (the act turn about to be sent and the report
18
+ * prompt as it would be built now), in input-token units, and priced at what
19
+ * one unit has actually cost this run (the server's spend over the ledger's
20
+ * weighted tokens, ./usage.js). No price is held here.
21
+ */
22
+
23
+ /** Characters per input token, the proxy's own projection rule. */
24
+ export const CHARS_PER_INPUT_TOKEN = 3.5;
25
+
26
+ /**
27
+ * Headroom on the report's projection: the prompt grows by the notes and
28
+ * observations of the turn still to be played, and a persona that escalates
29
+ * may write it on a dearer tier than the one the unit price was learned on.
30
+ */
31
+ export const REPORT_RESERVE_MARGIN = 1.3;
32
+
33
+ /** Headroom on what the next act turn will really cost, over the dearest recent one. */
34
+ export const ACT_COST_MARGIN = 1.5;
35
+
36
+ /** A flat share of the run's budget never planned against. */
37
+ export const FLAT_MARGIN_RATIO = 0.03;
38
+
39
+ /**
40
+ * The proxy's projection of a request, in input-token units.
41
+ *
42
+ * @param {number} bodyChars the request's JSON length
43
+ * @param {number} maxTokens its output ceiling
44
+ * @returns {number}
45
+ */
46
+ export function projectionUnits(bodyChars, maxTokens) {
47
+ return Math.max(0, Number(bodyChars) || 0) / CHARS_PER_INPUT_TOKEN + Math.max(0, Number(maxTokens) || 0) * OUTPUT_WEIGHT;
48
+ }
49
+
50
+ /**
51
+ * Whether play has to stop now so the report still fits.
52
+ *
53
+ * @param {Object} args
54
+ * @param {number|null} args.usdMax the run's model budget, from register
55
+ * @param {{usd: number, usdPerUnit: number}|null} args.spend the ledger's estimate
56
+ * @param {number} args.actChars the next act request's JSON length
57
+ * @param {number} args.actMaxTokens
58
+ * @param {number} args.actCostUnits what an act turn has really cost, at most, lately
59
+ * @param {number} args.reportChars the report request's JSON length if it were sent now
60
+ * @param {number} args.reportMaxTokens
61
+ * @returns {{stop: boolean, known: boolean, figures: Object}}
62
+ */
63
+ export function reserveDecision({ usdMax, spend, actChars, actMaxTokens, actCostUnits, reportChars, reportMaxTokens }) {
64
+ const max = Number(usdMax);
65
+ if (!Number.isFinite(max) || max <= 0 || !spend || !Number.isFinite(spend.usdPerUnit) || spend.usdPerUnit <= 0) {
66
+ return { stop: false, known: false, figures: {} };
67
+ }
68
+ const price = spend.usdPerUnit;
69
+ const actProjected = price * projectionUnits(actChars, actMaxTokens);
70
+ const actCost = price * Math.max(0, Number(actCostUnits) || 0) * ACT_COST_MARGIN;
71
+ const reportProjected = price * projectionUnits(reportChars, reportMaxTokens) * REPORT_RESERVE_MARGIN;
72
+ const flat = max * FLAT_MARGIN_RATIO;
73
+ // The act turn itself has to be admitted, and after it the report has to be.
74
+ const actFits = spend.usd + actProjected <= max;
75
+ const reportFitsAfter = spend.usd + actCost + reportProjected + flat <= max;
76
+ const round = value => Math.round(value * 1e6) / 1e6;
77
+ return {
78
+ stop: !actFits || !reportFitsAfter,
79
+ known: true,
80
+ figures: {
81
+ usd_max: round(max),
82
+ usd_spent: round(spend.usd),
83
+ act_projected_usd: round(actProjected),
84
+ act_cost_usd: round(actCost),
85
+ report_reserve_usd: round(reportProjected),
86
+ margin_usd: round(flat)
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A registered run's model budget in USD: `limits.model_usd`, or the older
93
+ * `budget_usd` beside it. Null when the server sent neither, which leaves the
94
+ * reserve off and the server's own warning in charge.
95
+ *
96
+ * @param {Object} entry a run from the register answer
97
+ * @returns {number|null}
98
+ */
99
+ export function runModelBudgetUsd(entry) {
100
+ const value = Number(entry && ((entry.limits && entry.limits.model_usd) ?? entry.budget_usd));
101
+ return Number.isFinite(value) && value > 0 ? value : null;
102
+ }
@@ -165,17 +165,136 @@ export function pruneObservations(messages, keep = KEEP_OBSERVATIONS) {
165
165
  return out;
166
166
  }
167
167
 
168
+ /** What a tool_result with nothing in it is sent as. */
169
+ export const EMPTY_TOOL_RESULT_TEXT = '(no output)';
170
+
171
+ /** The tool_result for a tool_use the runner never answered. */
172
+ export const MISSING_TOOL_RESULT_TEXT = 'Not run: the runner did not play this call.';
173
+
174
+ /**
175
+ * Make every tool_result in the conversation one the Messages API accepts.
176
+ *
177
+ * The rules the API enforces, and what each repair is:
178
+ *
179
+ * - **An `is_error` result is text only.** A result flagged `is_error` that
180
+ * also carries an image block is refused with a 400. That is the 0.7.2
181
+ * failure: a refused off-site `navigate` on a canvas build came back as
182
+ * "That did not work: ..." plus the screenshot, flagged as an error, and
183
+ * every persona of the job died on its next turn. The screenshot is the
184
+ * persona's only view of a canvas game, so the image stays and the flag
185
+ * goes: the text already says the action did not work.
186
+ * - **Content is never empty.** An empty string, an empty array or a text
187
+ * block with no text becomes {@link EMPTY_TOOL_RESULT_TEXT}; a non-string,
188
+ * non-array content becomes its string form.
189
+ * - **Exactly one result per tool_use, in the very next user message.** A
190
+ * missing result is added ({@link MISSING_TOOL_RESULT_TEXT}); a second
191
+ * result for one id, and a result whose id the assistant turn before it
192
+ * never used, are dropped. A user message left with nothing gets the empty
193
+ * text, because an empty message is refused too.
194
+ *
195
+ * A new array; the input is not mutated.
196
+ *
197
+ * @param {Array} messages
198
+ * @returns {Array}
199
+ */
200
+ export function validToolResults(messages) {
201
+ const out = messages.map(message => message);
202
+ for (let i = 0; i < out.length; i += 1) {
203
+ const message = out[i];
204
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
205
+ const previous = i > 0 ? out[i - 1] : null;
206
+ const expected = previous && previous.role === 'assistant' && Array.isArray(previous.content)
207
+ ? previous.content.filter(block => block && block.type === 'tool_use').map(block => block.id)
208
+ : [];
209
+ const seen = new Set();
210
+ const content = [];
211
+ for (const block of message.content) {
212
+ if (!block || block.type !== 'tool_result') {
213
+ content.push(block);
214
+ continue;
215
+ }
216
+ if (!expected.includes(block.tool_use_id) || seen.has(block.tool_use_id)) continue;
217
+ seen.add(block.tool_use_id);
218
+ content.push(validToolResult(block));
219
+ }
220
+ const missing = expected.filter(id => !seen.has(id))
221
+ .map(id => ({ type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text: MISSING_TOOL_RESULT_TEXT }] }));
222
+ // Results go first in the message: the API wants them before any text.
223
+ const results = [...content.filter(block => block && block.type === 'tool_result'), ...missing];
224
+ const rest = content.filter(block => !(block && block.type === 'tool_result'));
225
+ const next = [...results, ...rest];
226
+ out[i] = { ...message, content: next.length > 0 ? next : [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }] };
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * One tool_result made valid. See {@link validToolResults}.
233
+ *
234
+ * @param {Object} block
235
+ * @returns {Object}
236
+ */
237
+ export function validToolResult(block) {
238
+ let content = block.content;
239
+ if (typeof content === 'string') {
240
+ content = content.trim() ? content : EMPTY_TOOL_RESULT_TEXT;
241
+ } else if (Array.isArray(content)) {
242
+ content = content
243
+ .filter(child => child && (child.type !== 'text' || (typeof child.text === 'string' && child.text.trim())))
244
+ .map(child => child);
245
+ if (content.length === 0) content = [{ type: 'text', text: EMPTY_TOOL_RESULT_TEXT }];
246
+ } else {
247
+ content = content === undefined || content === null || String(content).trim() === '' ? EMPTY_TOOL_RESULT_TEXT : String(content);
248
+ }
249
+ const result = { ...block, content };
250
+ if (result.is_error && Array.isArray(content) && content.some(child => child.type !== 'text')) delete result.is_error;
251
+ return result;
252
+ }
253
+
254
+ /**
255
+ * Every tool_result in the newest user message rewritten as plain text with
256
+ * no `is_error` flag and no image: the one repair the persona loop makes
257
+ * when the provider refuses a turn it cannot otherwise explain, before it
258
+ * gives up on the conversation. The text keeps what the persona was told.
259
+ *
260
+ * @param {Array} messages
261
+ * @returns {Array} a new array; the input is not mutated
262
+ */
263
+ export function repairLastTurn(messages) {
264
+ const out = validToolResults(messages);
265
+ for (let i = out.length - 1; i >= 0; i -= 1) {
266
+ const message = out[i];
267
+ if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue;
268
+ out[i] = {
269
+ ...message,
270
+ content: message.content.map(block => {
271
+ if (!block || block.type !== 'tool_result') return block;
272
+ const texts = Array.isArray(block.content)
273
+ ? block.content.filter(child => child.type === 'text').map(child => child.text)
274
+ : [String(block.content)];
275
+ const text = texts.join('\n').trim() || EMPTY_TOOL_RESULT_TEXT;
276
+ const { is_error: _flag, ...rest } = block;
277
+ return { ...rest, content: [{ type: 'text', text }] };
278
+ })
279
+ };
280
+ break;
281
+ }
282
+ return out;
283
+ }
284
+
168
285
  /**
169
286
  * Both ceilings, in the order that matters: drop stale observations first
170
287
  * (which removes their images too), then enforce the image budget on what
171
- * is left.
288
+ * is left, then make every tool_result one the API accepts (which has to
289
+ * come last: whether an `is_error` result still carries an image is only
290
+ * known once the image budget is applied).
172
291
  *
173
292
  * @param {Array} messages
174
293
  * @param {{maxImages?: number, keepObservations?: number}} [opts]
175
294
  * @returns {Array}
176
295
  */
177
296
  export function prepareMessages(messages, { maxImages = MAX_IMAGES_IN_CONTEXT, keepObservations = KEEP_OBSERVATIONS } = {}) {
178
- return pruneImages(pruneObservations(messages, keepObservations), maxImages);
297
+ return validToolResults(pruneImages(pruneObservations(messages, keepObservations), maxImages));
179
298
  }
180
299
 
181
300
  /**
@@ -189,4 +308,4 @@ export function shrinkImages(current) {
189
308
  return Math.max(0, Math.floor(current / 2));
190
309
  }
191
310
 
192
- export default { prepareMessages, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };
311
+ export default { prepareMessages, validToolResults, validToolResult, repairLastTurn, pruneImages, pruneObservations, countImages, shrinkImages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS };