@nylorun/harness 0.8.0-beta.1 → 0.11.0-beta

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 (51) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +5 -38
  3. package/dist/build/assemble.js +1 -0
  4. package/dist/build/bind-tool.js +4 -3
  5. package/dist/build/builder.js +26 -0
  6. package/dist/build/helpers.d.ts +2 -2
  7. package/dist/build/manifest.js +12 -1
  8. package/dist/build/schema.d.ts +11 -16
  9. package/dist/build/schema.js +141 -22
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/index.d.ts +8 -5
  12. package/dist/index.js +2 -0
  13. package/dist/model/adapters.d.ts +160 -0
  14. package/dist/model/adapters.js +545 -0
  15. package/dist/{model-normalize.d.ts → model/normalize.d.ts} +2 -2
  16. package/dist/{model-normalize.js → model/normalize.js} +35 -4
  17. package/dist/model/prepared.d.ts +16 -0
  18. package/dist/model/prepared.js +11 -0
  19. package/dist/session/event-log.d.ts +4 -3
  20. package/dist/session/input-queue.d.ts +7 -4
  21. package/dist/session/input-queue.js +12 -0
  22. package/dist/session/output-contract.d.ts +6 -0
  23. package/dist/session/output-contract.js +12 -0
  24. package/dist/session/scheduler.js +1 -1
  25. package/dist/session/seed.js +79 -5
  26. package/dist/session/session.d.ts +8 -5
  27. package/dist/session/session.js +38 -2
  28. package/dist/session/state.d.ts +1 -1
  29. package/dist/session/submission-stream.d.ts +5 -4
  30. package/dist/step/context-draft.js +1 -7
  31. package/dist/step/model-configuration.js +6 -20
  32. package/dist/step/project.js +25 -3
  33. package/dist/step/resolve.d.ts +1 -0
  34. package/dist/step/resolve.js +1 -0
  35. package/dist/step/run.d.ts +1 -0
  36. package/dist/step/run.js +25 -4
  37. package/dist/step/seal.d.ts +4 -2
  38. package/dist/step/seal.js +64 -13
  39. package/dist/step/step-context.js +1 -1
  40. package/dist/turn/plan-runner.js +38 -3
  41. package/dist/turn/runner.d.ts +6 -4
  42. package/dist/turn/runner.js +6 -4
  43. package/dist/types/manifest.d.ts +5 -4
  44. package/dist/types/middleware.d.ts +10 -1
  45. package/dist/types/model.d.ts +21 -13
  46. package/dist/types/session.d.ts +34 -13
  47. package/dist/types/shared.d.ts +16 -3
  48. package/dist/types/tool.d.ts +54 -17
  49. package/package.json +15 -12
  50. package/dist/utils/digest.d.ts +0 -1
  51. package/dist/utils/digest.js +0 -14
@@ -0,0 +1,545 @@
1
+ import { HarnessError } from "../errors.js";
2
+ import { copyJsonObject } from "../utils/immutable.js";
3
+ import { preparedModel } from "./prepared.js";
4
+ /** Translate a Harness call to the OpenAI Chat Completions request shape. */
5
+ export function toChatCompletions(call) {
6
+ const messages = call.prompt.map((item) => {
7
+ if (item.kind === "instructions")
8
+ return { role: "system", content: textOf(item) };
9
+ if (item.kind === "tool-result")
10
+ return { role: "tool", tool_call_id: item.toolCallId, content: textOf(item) };
11
+ if (item.kind === "message" && item.role === "assistant") {
12
+ const toolCalls = toolCallsOf(item.content).map((part) => ({
13
+ id: part.id,
14
+ type: "function",
15
+ function: { name: part.name, arguments: JSON.stringify(part.args) },
16
+ }));
17
+ const text = textOf(item);
18
+ return {
19
+ role: "assistant",
20
+ content: text === "" ? null : text,
21
+ ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }),
22
+ };
23
+ }
24
+ return { role: "user", content: chatContent(item.content) };
25
+ });
26
+ return {
27
+ messages,
28
+ ...(call.tools.length === 0
29
+ ? {}
30
+ : {
31
+ tools: call.tools.map((tool) => ({
32
+ type: "function",
33
+ function: {
34
+ name: tool.name,
35
+ ...(tool.description === undefined ? {} : { description: tool.description }),
36
+ parameters: tool.inputSchema,
37
+ },
38
+ })),
39
+ }),
40
+ ...chatControls(call),
41
+ ...(call.outputSchema === undefined
42
+ ? {}
43
+ : {
44
+ response_format: {
45
+ type: "json_schema",
46
+ json_schema: { name: "harness_output", schema: call.outputSchema },
47
+ },
48
+ }),
49
+ };
50
+ }
51
+ /** Translate a Chat Completions response into a Harness candidate. */
52
+ export function fromChatCompletions(value, call) {
53
+ const response = record(value, "response");
54
+ const choices = array(response.choices, "response.choices");
55
+ if (choices.length === 0)
56
+ throw invalidResponse("response.choices must contain a choice", "response.choices");
57
+ const choice = record(choices[0], "response.choices[0]");
58
+ const message = record(choice.message, "response.choices[0].message");
59
+ const output = [];
60
+ if (typeof message.content === "string" && message.content !== "")
61
+ output.push(outputText(message.content, call?.outputSchema !== undefined));
62
+ if (typeof message.reasoning_content === "string" && message.reasoning_content !== "")
63
+ output.push({ type: "reasoning", text: message.reasoning_content });
64
+ for (const [index, raw] of optionalArray(message.tool_calls, "response.choices[0].message.tool_calls").entries()) {
65
+ const call = record(raw, `response.choices[0].message.tool_calls[${index}]`);
66
+ const fn = record(call.function, `response.choices[0].message.tool_calls[${index}].function`);
67
+ output.push({
68
+ type: "tool-call",
69
+ id: string(call.id, `response.choices[0].message.tool_calls[${index}].id`),
70
+ name: string(fn.name, `response.choices[0].message.tool_calls[${index}].function.name`),
71
+ ...argumentsOf(fn.arguments, `response.choices[0].message.tool_calls[${index}].function.arguments`),
72
+ });
73
+ }
74
+ return candidate({
75
+ output,
76
+ finishReason: chatFinishReason(choice.finish_reason, output),
77
+ usage: chatUsage(response.usage),
78
+ evidence: evidence(response),
79
+ });
80
+ }
81
+ /** Return a Harness adapter backed by an application-owned Chat Completions send function. */
82
+ export function chatCompletionsAdapter(send) {
83
+ return preparedModel({
84
+ adapter: "openai.chat-completions",
85
+ async prepare(call) {
86
+ const request = toChatCompletions(call);
87
+ return { request, observed: request };
88
+ },
89
+ send,
90
+ decode: fromChatCompletions,
91
+ });
92
+ }
93
+ /** Translate a Harness call to the OpenAI Responses request shape. */
94
+ export function toResponses(call) {
95
+ const instructions = call.prompt.filter((item) => item.kind === "instructions").map(textOf);
96
+ const input = call.prompt.flatMap((item) => {
97
+ if (item.kind === "instructions")
98
+ return [];
99
+ if (item.kind === "tool-result")
100
+ return [{ type: "function_call_output", call_id: item.toolCallId, output: textOf(item) }];
101
+ if (item.kind === "message" && item.role === "assistant") {
102
+ const text = textOf(item);
103
+ return [
104
+ ...(text === ""
105
+ ? []
106
+ : [{ type: "message", role: "assistant", content: text }]),
107
+ ...toolCallsOf(item.content).map((part) => ({
108
+ type: "function_call",
109
+ call_id: part.id,
110
+ name: part.name,
111
+ arguments: JSON.stringify(part.args),
112
+ })),
113
+ ];
114
+ }
115
+ return [{ type: "message", role: "user", content: responsesContent(item.content) }];
116
+ });
117
+ return {
118
+ ...(instructions.length === 0 ? {} : { instructions: instructions.join("\n\n") }),
119
+ input,
120
+ ...(call.tools.length === 0
121
+ ? {}
122
+ : {
123
+ tools: call.tools.map((tool) => ({
124
+ type: "function",
125
+ name: tool.name,
126
+ ...(tool.description === undefined ? {} : { description: tool.description }),
127
+ parameters: tool.inputSchema,
128
+ })),
129
+ }),
130
+ ...responsesControls(call),
131
+ ...(call.outputSchema === undefined
132
+ ? {}
133
+ : {
134
+ text: {
135
+ format: {
136
+ type: "json_schema",
137
+ name: "harness_output",
138
+ schema: call.outputSchema,
139
+ },
140
+ },
141
+ }),
142
+ };
143
+ }
144
+ /** Translate an OpenAI Responses response into a Harness candidate. */
145
+ export function fromResponses(value, call) {
146
+ const response = record(value, "response");
147
+ if (response.error !== undefined && response.error !== null)
148
+ throw invalidResponse("response.error is present", "response.error");
149
+ const output = [];
150
+ for (const [index, raw] of array(response.output, "response.output").entries()) {
151
+ const item = record(raw, `response.output[${index}]`);
152
+ if (item.type === "function_call") {
153
+ output.push({
154
+ type: "tool-call",
155
+ id: string(item.call_id, `response.output[${index}].call_id`),
156
+ name: string(item.name, `response.output[${index}].name`),
157
+ ...argumentsOf(item.arguments, `response.output[${index}].arguments`),
158
+ });
159
+ continue;
160
+ }
161
+ if (item.type === "message") {
162
+ for (const [partIndex, rawPart] of optionalArray(item.content, `response.output[${index}].content`).entries()) {
163
+ const part = record(rawPart, `response.output[${index}].content[${partIndex}]`);
164
+ if (part.type === "output_text" && typeof part.text === "string")
165
+ output.push(outputText(part.text, call?.outputSchema !== undefined));
166
+ }
167
+ continue;
168
+ }
169
+ if (item.type === "reasoning") {
170
+ const summary = optionalArray(item.summary, `response.output[${index}].summary`)
171
+ .map((rawPart, partIndex) => record(rawPart, `response.output[${index}].summary[${partIndex}]`))
172
+ .filter((part) => part.type === "summary_text" && typeof part.text === "string")
173
+ .map((part) => part.text)
174
+ .join("\n");
175
+ if (summary !== "")
176
+ output.push({ type: "reasoning", text: summary });
177
+ }
178
+ }
179
+ return candidate({
180
+ output,
181
+ finishReason: responsesFinishReason(response, output),
182
+ usage: responsesUsage(response.usage),
183
+ evidence: evidence(response),
184
+ });
185
+ }
186
+ /** Return a Harness adapter backed by an application-owned Responses send function. */
187
+ export function responsesAdapter(send) {
188
+ return preparedModel({
189
+ adapter: "openai.responses",
190
+ async prepare(call) {
191
+ const request = toResponses(call);
192
+ return { request, observed: request };
193
+ },
194
+ send,
195
+ decode: fromResponses,
196
+ });
197
+ }
198
+ /** Translate a Harness call to the Anthropic Messages request shape. */
199
+ export function toMessages(call, defaultMaxOutputTokens) {
200
+ checkedMaxOutputTokens(defaultMaxOutputTokens);
201
+ if (call.outputSchema !== undefined)
202
+ throw new HarnessError("model.unsupported-output-schema", "Anthropic Messages output schemas require a custom prepared adapter");
203
+ const instructions = call.prompt.filter((item) => item.kind === "instructions").map(textOf);
204
+ const messages = call.prompt.flatMap((item) => {
205
+ if (item.kind === "instructions")
206
+ return [];
207
+ if (item.kind === "tool-result")
208
+ return [
209
+ {
210
+ role: "user",
211
+ content: [
212
+ {
213
+ type: "tool_result",
214
+ tool_use_id: item.toolCallId,
215
+ content: textOf(item),
216
+ ...(item.status === "completed" ? {} : { is_error: true }),
217
+ },
218
+ ],
219
+ },
220
+ ];
221
+ if (item.kind === "message" && item.role === "assistant")
222
+ return [
223
+ {
224
+ role: "assistant",
225
+ content: item.content.map((part) => {
226
+ if (part.type === "text")
227
+ return { type: "text", text: part.text };
228
+ if (part.type === "tool-call")
229
+ return { type: "tool_use", id: part.id, name: part.name, input: part.args };
230
+ throw unsupportedContent(part);
231
+ }),
232
+ },
233
+ ];
234
+ return [{ role: "user", content: messagesContent(item.content) }];
235
+ });
236
+ return {
237
+ ...(instructions.length === 0 ? {} : { system: instructions.join("\n\n") }),
238
+ messages,
239
+ ...(call.tools.length === 0
240
+ ? {}
241
+ : {
242
+ tools: call.tools.map((tool) => ({
243
+ name: tool.name,
244
+ ...(tool.description === undefined ? {} : { description: tool.description }),
245
+ input_schema: tool.inputSchema,
246
+ })),
247
+ }),
248
+ ...(call.model?.controls?.temperature === undefined
249
+ ? {}
250
+ : { temperature: call.model.controls.temperature }),
251
+ max_tokens: call.model?.controls?.maxOutputTokens ?? defaultMaxOutputTokens,
252
+ };
253
+ }
254
+ function outputText(text, structured) {
255
+ if (!structured)
256
+ return { type: "text", text };
257
+ try {
258
+ return { type: "json", value: JSON.parse(text) };
259
+ }
260
+ catch {
261
+ return { type: "text", text };
262
+ }
263
+ }
264
+ /** Translate an Anthropic Messages response into a Harness candidate. */
265
+ export function fromMessages(value) {
266
+ const response = record(value, "response");
267
+ const output = [];
268
+ for (const [index, raw] of array(response.content, "response.content").entries()) {
269
+ const part = record(raw, `response.content[${index}]`);
270
+ if (part.type === "text" && typeof part.text === "string") {
271
+ output.push({ type: "text", text: part.text });
272
+ continue;
273
+ }
274
+ if (part.type === "thinking" && typeof part.thinking === "string") {
275
+ output.push({ type: "reasoning", text: part.thinking });
276
+ continue;
277
+ }
278
+ if (part.type === "tool_use") {
279
+ output.push({
280
+ type: "tool-call",
281
+ id: string(part.id, `response.content[${index}].id`),
282
+ name: string(part.name, `response.content[${index}].name`),
283
+ args: jsonObject(part.input, `response.content[${index}].input`),
284
+ });
285
+ }
286
+ }
287
+ return candidate({
288
+ output,
289
+ finishReason: messagesFinishReason(response.stop_reason, output),
290
+ usage: messagesUsage(response.usage),
291
+ evidence: evidence(response),
292
+ });
293
+ }
294
+ /** Return a Harness adapter backed by an application-owned Anthropic Messages send function. */
295
+ export function anthropicAdapter(options) {
296
+ checkedMaxOutputTokens(options.defaultMaxOutputTokens);
297
+ return preparedModel({
298
+ adapter: "anthropic.messages",
299
+ async prepare(call) {
300
+ const request = toMessages(call, options.defaultMaxOutputTokens);
301
+ return { request, observed: request };
302
+ },
303
+ send: options.send,
304
+ decode: fromMessages,
305
+ });
306
+ }
307
+ function chatContent(parts) {
308
+ if (!parts.some((part) => part.type === "media"))
309
+ return textOfParts(parts);
310
+ return parts.map((part) => {
311
+ if (part.type === "text")
312
+ return { type: "text", text: part.text };
313
+ if (part.type === "media")
314
+ return { type: "image_url", image_url: { url: imageUrl(part) } };
315
+ throw unsupportedContent(part);
316
+ });
317
+ }
318
+ function responsesContent(parts) {
319
+ if (!parts.some((part) => part.type === "media"))
320
+ return textOfParts(parts);
321
+ return parts.map((part) => {
322
+ if (part.type === "text")
323
+ return { type: "input_text", text: part.text };
324
+ if (part.type === "media")
325
+ return { type: "input_image", image_url: imageUrl(part) };
326
+ throw unsupportedContent(part);
327
+ });
328
+ }
329
+ function messagesContent(parts) {
330
+ if (!parts.some((part) => part.type === "media"))
331
+ return textOfParts(parts);
332
+ return parts.map((part) => {
333
+ if (part.type === "text")
334
+ return { type: "text", text: part.text };
335
+ if (part.type === "media")
336
+ return { type: "image", source: { type: "url", url: imageUrl(part) } };
337
+ throw unsupportedContent(part);
338
+ });
339
+ }
340
+ function imageUrl(part) {
341
+ if (!part.mediaType.startsWith("image/"))
342
+ throw unsupportedContent(part);
343
+ const reference = part.reference;
344
+ if (!reference || typeof reference !== "object" || Array.isArray(reference))
345
+ throw unsupportedContent(part);
346
+ const url = reference.url;
347
+ if (typeof url !== "string" || url === "")
348
+ throw unsupportedContent(part);
349
+ return url;
350
+ }
351
+ function unsupportedContent(part) {
352
+ const label = part.type === "media" ? part.mediaType : part.type;
353
+ return new HarnessError("model.unsupported-content", `Adapter does not support ${label} content without a custom prepared adapter`);
354
+ }
355
+ function textOf(item) {
356
+ const unsupported = item.content.find((part) => part.type === "media");
357
+ if (unsupported)
358
+ throw unsupportedContent(unsupported);
359
+ return textOfParts(item.content);
360
+ }
361
+ function textOfParts(parts) {
362
+ return parts
363
+ .filter((part) => part.type === "text")
364
+ .map((part) => part.text)
365
+ .join("");
366
+ }
367
+ function toolCallsOf(parts) {
368
+ return parts.filter((part) => part.type === "tool-call");
369
+ }
370
+ function chatControls(call) {
371
+ return {
372
+ ...(call.model?.controls?.temperature === undefined
373
+ ? {}
374
+ : { temperature: call.model.controls.temperature }),
375
+ ...(call.model?.controls?.maxOutputTokens === undefined
376
+ ? {}
377
+ : { max_completion_tokens: call.model.controls.maxOutputTokens }),
378
+ };
379
+ }
380
+ function responsesControls(call) {
381
+ return {
382
+ ...(call.model?.controls?.temperature === undefined
383
+ ? {}
384
+ : { temperature: call.model.controls.temperature }),
385
+ ...(call.model?.controls?.maxOutputTokens === undefined
386
+ ? {}
387
+ : { max_output_tokens: call.model.controls.maxOutputTokens }),
388
+ };
389
+ }
390
+ function argumentsOf(value, path) {
391
+ const raw = string(value, path);
392
+ try {
393
+ return { args: jsonObject(JSON.parse(raw), path), raw };
394
+ }
395
+ catch (error) {
396
+ throw invalidResponse(`${path} must be a JSON object`, path, error);
397
+ }
398
+ }
399
+ function jsonObject(value, path) {
400
+ try {
401
+ return copyJsonObject(value, path);
402
+ }
403
+ catch (error) {
404
+ throw invalidResponse(`${path} must be a JSON object`, path, error);
405
+ }
406
+ }
407
+ function candidate(value) {
408
+ return {
409
+ output: value.output,
410
+ ...(value.finishReason === undefined ? {} : { finishReason: value.finishReason }),
411
+ ...(value.usage === undefined ? {} : { usage: value.usage }),
412
+ ...(value.evidence === undefined ? {} : { evidence: value.evidence }),
413
+ };
414
+ }
415
+ function chatFinishReason(value, output) {
416
+ if (value === "tool_calls" || value === "function_call")
417
+ return "tool-calls";
418
+ if (value === "length")
419
+ return "length";
420
+ if (value === "content_filter")
421
+ return "content-filter";
422
+ if (value === "stop" || value === null || value === undefined)
423
+ return hasToolCall(output) ? "tool-calls" : "stop";
424
+ return "other";
425
+ }
426
+ function responsesFinishReason(response, output) {
427
+ if (response.status === "incomplete") {
428
+ const details = response.incomplete_details === undefined
429
+ ? undefined
430
+ : record(response.incomplete_details, "response.incomplete_details");
431
+ return details?.reason === "max_output_tokens" ? "length" : "other";
432
+ }
433
+ if (response.status === "failed" || response.status === "cancelled")
434
+ throw invalidResponse(`response.status is ${String(response.status)}`, "response.status");
435
+ return hasToolCall(output) ? "tool-calls" : "stop";
436
+ }
437
+ function messagesFinishReason(value, output) {
438
+ if (value === "tool_use")
439
+ return "tool-calls";
440
+ if (value === "max_tokens")
441
+ return "length";
442
+ if (value === "end_turn" || value === "stop_sequence" || value === undefined || value === null)
443
+ return hasToolCall(output) ? "tool-calls" : "stop";
444
+ return "other";
445
+ }
446
+ function chatUsage(value) {
447
+ const usage = optionalRecord(value, "response.usage");
448
+ if (usage === undefined)
449
+ return undefined;
450
+ return usageOf({
451
+ inputTokens: usage.prompt_tokens,
452
+ outputTokens: usage.completion_tokens,
453
+ totalTokens: usage.total_tokens,
454
+ cachedTokens: optionalRecord(usage.prompt_tokens_details, "response.usage.prompt_tokens_details")?.cached_tokens,
455
+ reasoningTokens: optionalRecord(usage.completion_tokens_details, "response.usage.completion_tokens_details")?.reasoning_tokens,
456
+ }, "response.usage");
457
+ }
458
+ function responsesUsage(value) {
459
+ const usage = optionalRecord(value, "response.usage");
460
+ if (usage === undefined)
461
+ return undefined;
462
+ return usageOf({
463
+ inputTokens: usage.input_tokens,
464
+ outputTokens: usage.output_tokens,
465
+ totalTokens: usage.total_tokens,
466
+ cachedTokens: optionalRecord(usage.input_tokens_details, "response.usage.input_tokens_details")?.cached_tokens,
467
+ reasoningTokens: optionalRecord(usage.output_tokens_details, "response.usage.output_tokens_details")?.reasoning_tokens,
468
+ }, "response.usage");
469
+ }
470
+ function messagesUsage(value) {
471
+ const usage = optionalRecord(value, "response.usage");
472
+ if (usage === undefined)
473
+ return undefined;
474
+ return usageOf({
475
+ inputTokens: usage.input_tokens,
476
+ outputTokens: usage.output_tokens,
477
+ cachedTokens: usage.cache_read_input_tokens,
478
+ }, "response.usage");
479
+ }
480
+ function usageOf(value, path) {
481
+ const fields = Object.entries(value).flatMap(([key, raw]) => {
482
+ if (raw === undefined)
483
+ return [];
484
+ if (!isNonNegativeInteger(raw))
485
+ throw invalidResponse(`${path}.${key} must be a non-negative integer`, `${path}.${key}`);
486
+ return [[key, raw]];
487
+ });
488
+ return Object.fromEntries(fields);
489
+ }
490
+ function evidence(response) {
491
+ const requestId = optionalString(response.id, "response.id");
492
+ const resolvedModel = optionalString(response.model, "response.model");
493
+ return requestId === undefined && resolvedModel === undefined
494
+ ? undefined
495
+ : {
496
+ ...(requestId === undefined ? {} : { requestId }),
497
+ ...(resolvedModel === undefined ? {} : { resolvedModel }),
498
+ };
499
+ }
500
+ function record(value, path) {
501
+ if (!value || typeof value !== "object" || Array.isArray(value))
502
+ throw invalidResponse(`${path} must be an object`, path);
503
+ return value;
504
+ }
505
+ function optionalRecord(value, path) {
506
+ return value === undefined || value === null ? undefined : record(value, path);
507
+ }
508
+ function array(value, path) {
509
+ if (!Array.isArray(value))
510
+ throw invalidResponse(`${path} must be an array`, path);
511
+ return value;
512
+ }
513
+ function optionalArray(value, path) {
514
+ if (value === undefined || value === null)
515
+ return [];
516
+ if (!Array.isArray(value))
517
+ throw invalidResponse(`${path} must be an array`, path);
518
+ return value;
519
+ }
520
+ function string(value, path) {
521
+ if (typeof value !== "string")
522
+ throw invalidResponse(`${path} must be a string`, path);
523
+ return value;
524
+ }
525
+ function optionalString(value, path) {
526
+ if (value === undefined || value === null)
527
+ return undefined;
528
+ return string(value, path);
529
+ }
530
+ function checkedMaxOutputTokens(value) {
531
+ if (!isNonNegativeInteger(value))
532
+ throw new HarnessError("model.adapter-invalid-options", "Anthropic defaultMaxOutputTokens must be a non-negative integer", { details: { path: "defaultMaxOutputTokens" } });
533
+ }
534
+ function isNonNegativeInteger(value) {
535
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
536
+ }
537
+ function hasToolCall(output) {
538
+ return output.some((part) => part.type === "tool-call");
539
+ }
540
+ function invalidResponse(message, path, cause) {
541
+ return new HarnessError("model.adapter-invalid-response", message, {
542
+ ...(cause === undefined ? {} : { cause }),
543
+ details: { path },
544
+ });
545
+ }
@@ -1,5 +1,5 @@
1
- import { HarnessError } from "./errors.js";
2
- import type { ModelCandidate, ModelDirective, ModelOutputBlock } from "./types/model.js";
1
+ import { HarnessError } from "../errors.js";
2
+ import type { ModelCandidate, ModelDirective, ModelOutputBlock } from "../types/model.js";
3
3
  export declare function normalizeDirective(value: unknown): ModelDirective | HarnessError;
4
4
  export declare function sameDirective(left: ModelDirective, right: ModelDirective): boolean;
5
5
  export declare function textFromOutput(output: readonly ModelOutputBlock[]): string;
@@ -1,6 +1,5 @@
1
- import { HarnessError, isHarnessError } from "./errors.js";
2
- import { digest } from "./utils/digest.js";
3
- import { copyJsonObject } from "./utils/immutable.js";
1
+ import { HarnessError, isHarnessError } from "../errors.js";
2
+ import { assertJson, copyJson, copyJsonObject } from "../utils/immutable.js";
4
3
  const FINISH_REASONS = new Set([
5
4
  "stop",
6
5
  "length",
@@ -55,7 +54,25 @@ export function normalizeDirective(value) {
55
54
  });
56
55
  }
57
56
  export function sameDirective(left, right) {
58
- return digest(left) === digest(right);
57
+ return sameJson(left, right);
58
+ }
59
+ function sameJson(left, right) {
60
+ if (Object.is(left, right))
61
+ return true;
62
+ if (left === null || right === null || typeof left !== "object" || typeof right !== "object")
63
+ return false;
64
+ if (Array.isArray(left) || Array.isArray(right)) {
65
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length)
66
+ return false;
67
+ return left.every((item, index) => sameJson(item, right[index]));
68
+ }
69
+ const leftRecord = left;
70
+ const rightRecord = right;
71
+ const keys = Object.keys(leftRecord);
72
+ if (keys.length !== Object.keys(rightRecord).length)
73
+ return false;
74
+ return keys.every((key) => Object.prototype.hasOwnProperty.call(rightRecord, key) &&
75
+ sameJson(leftRecord[key], rightRecord[key]));
59
76
  }
60
77
  export function textFromOutput(output) {
61
78
  return output.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
@@ -108,6 +125,20 @@ function normalizeBlock(value, index) {
108
125
  throw invalidCandidate(`Model output[${index}].text must be a string`, `output[${index}].text`);
109
126
  return Object.freeze({ type: block.type, text });
110
127
  }
128
+ if (block.type === "json") {
129
+ rejectUnknownKeys(value, ["type", "value"], `Model output[${index}]`);
130
+ try {
131
+ const raw = value.value;
132
+ assertJson(raw, `Model output[${index}].value`);
133
+ return Object.freeze({
134
+ type: "json",
135
+ value: copyJson(raw),
136
+ });
137
+ }
138
+ catch (error) {
139
+ throw invalidCandidate(`Model output[${index}].value must be JSON-safe`, `output[${index}].value`, error);
140
+ }
141
+ }
111
142
  if (block.type === "tool-call") {
112
143
  rejectUnknownKeys(value, ["type", "id", "name", "args", "raw"], `Model output[${index}]`);
113
144
  const raw = value;
@@ -0,0 +1,16 @@
1
+ import type { ModelAdapter, ModelAdapterContext, ModelCandidate, ModelCall } from "../types/model.js";
2
+ import type { DeferredOutcome, JsonValue } from "../types/shared.js";
3
+ export interface PreparedModelOptions<Wire> {
4
+ readonly adapter: string;
5
+ prepare(call: ModelCall, context: ModelAdapterContext): Promise<{
6
+ readonly request: Wire;
7
+ readonly observed: JsonValue;
8
+ }>;
9
+ send(request: Wire, call: ModelCall, context: ModelAdapterContext): Promise<unknown>;
10
+ decode(response: unknown, call: ModelCall): ModelCandidate | string | DeferredOutcome;
11
+ }
12
+ /**
13
+ * Builds a ModelAdapter that reports the provider request derived from Harness's canonical call.
14
+ * The observed value is deliberately JSON-only; opaque wire data remains adapter-local.
15
+ */
16
+ export declare function preparedModel<Wire>(options: PreparedModelOptions<Wire>): ModelAdapter;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Builds a ModelAdapter that reports the provider request derived from Harness's canonical call.
3
+ * The observed value is deliberately JSON-only; opaque wire data remains adapter-local.
4
+ */
5
+ export function preparedModel(options) {
6
+ return async (call, context) => {
7
+ const prepared = await options.prepare(call, context);
8
+ context.reportPreparedCall({ adapter: options.adapter, call: prepared.observed });
9
+ return options.decode(await options.send(prepared.request, call, context), call);
10
+ };
11
+ }
@@ -1,11 +1,12 @@
1
1
  import type { SessionEvent } from "../types/session.js";
2
+ import type { JsonValue } from "../types/shared.js";
2
3
  /** Session-lifetime conversation log. Each stream() call replays from the start. */
3
- export declare class SessionEventLog implements AsyncIterable<SessionEvent> {
4
+ export declare class SessionEventLog implements AsyncIterable<SessionEvent<JsonValue>> {
4
5
  private readonly events;
5
6
  private readonly subscribers;
6
7
  private done;
7
- emit(event: SessionEvent): void;
8
+ emit(event: SessionEvent<JsonValue>): void;
8
9
  finish(): void;
9
- [Symbol.asyncIterator](): AsyncIterator<SessionEvent>;
10
+ [Symbol.asyncIterator](): AsyncIterator<SessionEvent<JsonValue>>;
10
11
  private wake;
11
12
  }
@@ -1,18 +1,21 @@
1
1
  import type { InputEvent, InputOptions } from "../types/session.js";
2
+ import type { TurnOutputContract } from "./output-contract.js";
2
3
  import { SubmissionStream } from "./submission-stream.js";
3
4
  export type WorkEvent = InputEvent | {
4
5
  readonly kind: "continue";
5
6
  };
6
7
  export interface QueuedInput {
7
8
  readonly event: WorkEvent;
8
- readonly options?: InputOptions;
9
+ readonly options?: InputOptions & {
10
+ readonly output?: TurnOutputContract;
11
+ };
9
12
  readonly stream: SubmissionStream;
10
13
  cancelled: boolean;
11
14
  }
12
15
  export type QueuedInterrupt = Omit<QueuedInput, "event"> & {
13
- readonly event: Extract<InputEvent, {
14
- kind: "interrupt";
15
- }>;
16
+ readonly event: InputEvent & {
17
+ readonly kind: "interrupt";
18
+ };
16
19
  };
17
20
  export interface QueueAbortHandlers {
18
21
  readonly isActive: () => boolean;