@nylorun/harness 0.9.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 (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +5 -79
  3. package/dist/build/bind-tool.js +4 -3
  4. package/dist/build/helpers.d.ts +2 -2
  5. package/dist/build/schema.d.ts +11 -16
  6. package/dist/build/schema.js +141 -22
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +6 -3
  9. package/dist/index.js +2 -0
  10. package/dist/model/adapters.d.ts +48 -5
  11. package/dist/model/adapters.js +128 -14
  12. package/dist/model/normalize.js +34 -3
  13. package/dist/model/prepared.d.ts +16 -0
  14. package/dist/model/prepared.js +11 -0
  15. package/dist/session/event-log.d.ts +4 -3
  16. package/dist/session/input-queue.d.ts +7 -4
  17. package/dist/session/input-queue.js +12 -0
  18. package/dist/session/output-contract.d.ts +6 -0
  19. package/dist/session/output-contract.js +12 -0
  20. package/dist/session/scheduler.js +1 -1
  21. package/dist/session/seed.js +78 -4
  22. package/dist/session/session.d.ts +8 -5
  23. package/dist/session/session.js +38 -2
  24. package/dist/session/state.d.ts +1 -1
  25. package/dist/session/submission-stream.d.ts +5 -4
  26. package/dist/step/context-draft.js +1 -7
  27. package/dist/step/model-configuration.js +5 -19
  28. package/dist/step/project.js +25 -3
  29. package/dist/step/resolve.d.ts +1 -0
  30. package/dist/step/resolve.js +1 -0
  31. package/dist/step/run.d.ts +1 -0
  32. package/dist/step/run.js +24 -3
  33. package/dist/step/seal.d.ts +4 -2
  34. package/dist/step/seal.js +63 -12
  35. package/dist/turn/plan-runner.js +38 -3
  36. package/dist/turn/runner.d.ts +6 -4
  37. package/dist/turn/runner.js +6 -4
  38. package/dist/types/middleware.d.ts +1 -1
  39. package/dist/types/model.d.ts +21 -13
  40. package/dist/types/session.d.ts +34 -13
  41. package/dist/types/shared.d.ts +16 -3
  42. package/dist/types/tool.d.ts +54 -17
  43. package/package.json +11 -12
  44. package/dist/utils/digest.d.ts +0 -1
  45. package/dist/utils/digest.js +0 -14
@@ -1,5 +1,6 @@
1
1
  import { HarnessError } from "../errors.js";
2
2
  import { copyJsonObject } from "../utils/immutable.js";
3
+ import { preparedModel } from "./prepared.js";
3
4
  /** Translate a Harness call to the OpenAI Chat Completions request shape. */
4
5
  export function toChatCompletions(call) {
5
6
  const messages = call.prompt.map((item) => {
@@ -20,7 +21,7 @@ export function toChatCompletions(call) {
20
21
  ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }),
21
22
  };
22
23
  }
23
- return { role: "user", content: textOf(item) };
24
+ return { role: "user", content: chatContent(item.content) };
24
25
  });
25
26
  return {
26
27
  messages,
@@ -37,10 +38,18 @@ export function toChatCompletions(call) {
37
38
  })),
38
39
  }),
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
+ }),
40
49
  };
41
50
  }
42
51
  /** Translate a Chat Completions response into a Harness candidate. */
43
- export function fromChatCompletions(value) {
52
+ export function fromChatCompletions(value, call) {
44
53
  const response = record(value, "response");
45
54
  const choices = array(response.choices, "response.choices");
46
55
  if (choices.length === 0)
@@ -49,7 +58,7 @@ export function fromChatCompletions(value) {
49
58
  const message = record(choice.message, "response.choices[0].message");
50
59
  const output = [];
51
60
  if (typeof message.content === "string" && message.content !== "")
52
- output.push({ type: "text", text: message.content });
61
+ output.push(outputText(message.content, call?.outputSchema !== undefined));
53
62
  if (typeof message.reasoning_content === "string" && message.reasoning_content !== "")
54
63
  output.push({ type: "reasoning", text: message.reasoning_content });
55
64
  for (const [index, raw] of optionalArray(message.tool_calls, "response.choices[0].message.tool_calls").entries()) {
@@ -71,7 +80,15 @@ export function fromChatCompletions(value) {
71
80
  }
72
81
  /** Return a Harness adapter backed by an application-owned Chat Completions send function. */
73
82
  export function chatCompletionsAdapter(send) {
74
- return async (call, context) => fromChatCompletions(await send(toChatCompletions(call), call, context));
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
+ });
75
92
  }
76
93
  /** Translate a Harness call to the OpenAI Responses request shape. */
77
94
  export function toResponses(call) {
@@ -95,7 +112,7 @@ export function toResponses(call) {
95
112
  })),
96
113
  ];
97
114
  }
98
- return [{ type: "message", role: "user", content: textOf(item) }];
115
+ return [{ type: "message", role: "user", content: responsesContent(item.content) }];
99
116
  });
100
117
  return {
101
118
  ...(instructions.length === 0 ? {} : { instructions: instructions.join("\n\n") }),
@@ -111,10 +128,21 @@ export function toResponses(call) {
111
128
  })),
112
129
  }),
113
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
+ }),
114
142
  };
115
143
  }
116
144
  /** Translate an OpenAI Responses response into a Harness candidate. */
117
- export function fromResponses(value) {
145
+ export function fromResponses(value, call) {
118
146
  const response = record(value, "response");
119
147
  if (response.error !== undefined && response.error !== null)
120
148
  throw invalidResponse("response.error is present", "response.error");
@@ -134,7 +162,7 @@ export function fromResponses(value) {
134
162
  for (const [partIndex, rawPart] of optionalArray(item.content, `response.output[${index}].content`).entries()) {
135
163
  const part = record(rawPart, `response.output[${index}].content[${partIndex}]`);
136
164
  if (part.type === "output_text" && typeof part.text === "string")
137
- output.push({ type: "text", text: part.text });
165
+ output.push(outputText(part.text, call?.outputSchema !== undefined));
138
166
  }
139
167
  continue;
140
168
  }
@@ -157,11 +185,21 @@ export function fromResponses(value) {
157
185
  }
158
186
  /** Return a Harness adapter backed by an application-owned Responses send function. */
159
187
  export function responsesAdapter(send) {
160
- return async (call, context) => fromResponses(await send(toResponses(call), call, context));
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
+ });
161
197
  }
162
198
  /** Translate a Harness call to the Anthropic Messages request shape. */
163
199
  export function toMessages(call, defaultMaxOutputTokens) {
164
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");
165
203
  const instructions = call.prompt.filter((item) => item.kind === "instructions").map(textOf);
166
204
  const messages = call.prompt.flatMap((item) => {
167
205
  if (item.kind === "instructions")
@@ -184,12 +222,16 @@ export function toMessages(call, defaultMaxOutputTokens) {
184
222
  return [
185
223
  {
186
224
  role: "assistant",
187
- content: item.content.map((part) => part.type === "text"
188
- ? { type: "text", text: part.text }
189
- : { type: "tool_use", id: part.id, name: part.name, input: part.args }),
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
+ }),
190
232
  },
191
233
  ];
192
- return [{ role: "user", content: textOf(item) }];
234
+ return [{ role: "user", content: messagesContent(item.content) }];
193
235
  });
194
236
  return {
195
237
  ...(instructions.length === 0 ? {} : { system: instructions.join("\n\n") }),
@@ -209,6 +251,16 @@ export function toMessages(call, defaultMaxOutputTokens) {
209
251
  max_tokens: call.model?.controls?.maxOutputTokens ?? defaultMaxOutputTokens,
210
252
  };
211
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
+ }
212
264
  /** Translate an Anthropic Messages response into a Harness candidate. */
213
265
  export function fromMessages(value) {
214
266
  const response = record(value, "response");
@@ -242,10 +294,72 @@ export function fromMessages(value) {
242
294
  /** Return a Harness adapter backed by an application-owned Anthropic Messages send function. */
243
295
  export function anthropicAdapter(options) {
244
296
  checkedMaxOutputTokens(options.defaultMaxOutputTokens);
245
- return async (call, context) => fromMessages(await options.send(toMessages(call, options.defaultMaxOutputTokens), call, context));
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`);
246
354
  }
247
355
  function textOf(item) {
248
- return item.content
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
249
363
  .filter((part) => part.type === "text")
250
364
  .map((part) => part.text)
251
365
  .join("");
@@ -1,6 +1,5 @@
1
1
  import { HarnessError, isHarnessError } from "../errors.js";
2
- import { digest } from "../utils/digest.js";
3
- import { copyJsonObject } from "../utils/immutable.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;
@@ -7,6 +7,18 @@ export function snapshotInput(event) {
7
7
  switch (event.kind) {
8
8
  case "user-message":
9
9
  case "interrupt":
10
+ if ("content" in event)
11
+ return Object.freeze({
12
+ kind: event.kind,
13
+ content: Object.freeze(event.content.map((part) => part.type === "text"
14
+ ? Object.freeze({ type: "text", text: part.text })
15
+ : Object.freeze({
16
+ type: "media",
17
+ mediaType: part.mediaType,
18
+ reference: copyJson(part.reference),
19
+ }))),
20
+ ...(event.metadata ? { metadata: copyJsonObject(event.metadata, "input metadata") } : {}),
21
+ });
10
22
  return Object.freeze({
11
23
  kind: event.kind,
12
24
  text: event.text,
@@ -0,0 +1,6 @@
1
+ import type { BoundToolSchema, ToolSchemaSource } from "../types/tool.js";
2
+ /** Runtime-only validator paired with the portable JSON Schema projected to models. */
3
+ export interface TurnOutputContract {
4
+ readonly schema: BoundToolSchema<unknown>;
5
+ }
6
+ export declare function bindOutputContract(source: ToolSchemaSource): TurnOutputContract;
@@ -0,0 +1,12 @@
1
+ import { HarnessError, isHarnessError } from "../errors.js";
2
+ import { normalizeSchema } from "../build/schema.js";
3
+ export function bindOutputContract(source) {
4
+ try {
5
+ return Object.freeze({ schema: normalizeSchema(source, "output") });
6
+ }
7
+ catch (cause) {
8
+ if (isHarnessError(cause))
9
+ throw new HarnessError("output.invalid-schema", cause.message, { cause });
10
+ throw new HarnessError("output.invalid-schema", String(cause), { cause });
11
+ }
12
+ }
@@ -257,7 +257,7 @@ export class SessionScheduler {
257
257
  ? await this.resumeTurn(submission, context)
258
258
  : submission.event.kind === "continue"
259
259
  ? await this.turns.continue(this.snapshotValue, context)
260
- : await this.turns.start(this.snapshotValue, submission.event, context);
260
+ : await this.turns.start(this.snapshotValue, submission.event, context, submission.options?.output);
261
261
  this.applyTurnOutcome(submission.stream, outcome);
262
262
  }
263
263
  catch (error) {
@@ -82,13 +82,12 @@ function entryAt(value, index) {
82
82
  case "final":
83
83
  exactKeys(entry, ["kind", "turnId", "stepId", "output"], `Transcript entry ${index}`);
84
84
  requiredString(entry.stepId, `Transcript entry ${index} stepId`);
85
- if (typeof entry.output !== "string")
86
- fail(`Transcript entry ${index} output must be a string`);
85
+ assertJson(entry.output, `Transcript entry ${index} output`);
87
86
  return Object.freeze({
88
87
  kind: "final",
89
88
  turnId: entry.turnId,
90
89
  stepId: entry.stepId,
91
- output: entry.output,
90
+ output: copyJson(entry.output),
92
91
  });
93
92
  default:
94
93
  fail(`Transcript entry ${index} has unknown kind '${String(entry.kind)}'`);
@@ -101,6 +100,18 @@ function inputEvent(value, index) {
101
100
  switch (event.kind) {
102
101
  case "user-message":
103
102
  case "interrupt":
103
+ if ("content" in event) {
104
+ exactKeys(event, ["kind", "content", "metadata"], `Transcript input ${index}`);
105
+ if (!Array.isArray(event.content))
106
+ fail(`Transcript input ${index} content must be an array`);
107
+ return Object.freeze({
108
+ kind: event.kind,
109
+ content: contentParts(event.content, index),
110
+ ...(event.metadata === undefined
111
+ ? {}
112
+ : { metadata: copyJsonObject(event.metadata, `transcript input ${index} metadata`) }),
113
+ });
114
+ }
104
115
  exactKeys(event, ["kind", "text", "metadata"], `Transcript input ${index}`);
105
116
  if (typeof event.text !== "string")
106
117
  fail(`Transcript input ${index} text must be a string`);
@@ -134,6 +145,31 @@ function inputEvent(value, index) {
134
145
  fail(`Transcript input ${index} has unknown kind '${String(event.kind)}'`);
135
146
  }
136
147
  }
148
+ function contentParts(value, inputIndex) {
149
+ return Object.freeze(value.map((value, partIndex) => {
150
+ if (!value || typeof value !== "object" || Array.isArray(value))
151
+ fail(`Transcript input ${inputIndex} content part ${partIndex} must be an object`);
152
+ const part = value;
153
+ if (part.type === "text") {
154
+ exactKeys(part, ["type", "text"], `Transcript input ${inputIndex} content part ${partIndex}`);
155
+ if (typeof part.text !== "string")
156
+ fail(`Transcript input ${inputIndex} text part ${partIndex} must contain text`);
157
+ return Object.freeze({ type: "text", text: part.text });
158
+ }
159
+ if (part.type === "media") {
160
+ exactKeys(part, ["type", "mediaType", "reference"], `Transcript input ${inputIndex} content part ${partIndex}`);
161
+ if (typeof part.mediaType !== "string" || part.mediaType === "")
162
+ fail(`Transcript input ${inputIndex} media part ${partIndex} must contain mediaType`);
163
+ assertJson(part.reference, `transcript input ${inputIndex} media reference ${partIndex}`);
164
+ return Object.freeze({
165
+ type: "media",
166
+ mediaType: part.mediaType,
167
+ reference: copyJson(part.reference),
168
+ });
169
+ }
170
+ fail(`Transcript input ${inputIndex} content part ${partIndex} has unknown type`);
171
+ }));
172
+ }
137
173
  function toolResult(value, index) {
138
174
  if (!value || typeof value !== "object")
139
175
  fail(`Tool result ${index} must be an object`);
@@ -152,7 +188,7 @@ function toolResult(value, index) {
152
188
  fail(`Tool result ${index} reason must be a string`);
153
189
  return Object.freeze({ ...base, kind: "denied", reason: result.reason });
154
190
  case "failed":
155
- exactKeys(result, ["callId", "toolName", "kind", "code", "message"], `Tool result ${index}`);
191
+ exactKeys(result, ["callId", "toolName", "kind", "code", "message", "details"], `Tool result ${index}`);
156
192
  if (typeof result.code !== "string" || typeof result.message !== "string")
157
193
  fail(`Tool result ${index} code and message must be strings`);
158
194
  return Object.freeze({
@@ -160,11 +196,44 @@ function toolResult(value, index) {
160
196
  kind: "failed",
161
197
  code: result.code,
162
198
  message: result.message,
199
+ ...(result.details === undefined
200
+ ? {}
201
+ : { details: validationDetails(result.details, index) }),
163
202
  });
164
203
  default:
165
204
  fail(`Tool result ${index} has unknown kind '${String(result.kind)}'`);
166
205
  }
167
206
  }
207
+ function validationDetails(value, index) {
208
+ if (!value || typeof value !== "object" || Array.isArray(value))
209
+ fail(`Tool result ${index} details must be an object`);
210
+ const details = value;
211
+ exactKeys(details, ["phase", "issues"], `Tool result ${index} details`);
212
+ if (details.phase !== "input" && details.phase !== "output")
213
+ fail(`Tool result ${index} details phase must be input or output`);
214
+ if (!Array.isArray(details.issues))
215
+ fail(`Tool result ${index} details issues must be an array`);
216
+ return Object.freeze({
217
+ phase: details.phase,
218
+ issues: Object.freeze(details.issues.map((issue, issueIndex) => {
219
+ if (!issue || typeof issue !== "object" || Array.isArray(issue))
220
+ fail(`Tool result ${index} details issue ${issueIndex} must be an object`);
221
+ const item = issue;
222
+ exactKeys(item, ["path", "code", "message"], `Tool result ${index} details issue ${issueIndex}`);
223
+ if (!Array.isArray(item.path))
224
+ fail(`Tool result ${index} details issue ${issueIndex} path must be an array`);
225
+ if (item.path.some((part) => typeof part !== "string" && typeof part !== "number"))
226
+ fail(`Tool result ${index} details issue ${issueIndex} path must contain strings or numbers`);
227
+ if (typeof item.code !== "string" || typeof item.message !== "string")
228
+ fail(`Tool result ${index} details issue ${issueIndex} code and message must be strings`);
229
+ return Object.freeze({
230
+ path: Object.freeze([...item.path]),
231
+ code: item.code,
232
+ message: item.message,
233
+ });
234
+ })),
235
+ });
236
+ }
168
237
  function validateSeedCandidate(value, index) {
169
238
  if (!value || typeof value !== "object" || Array.isArray(value))
170
239
  fail(`Candidate at transcript ${index} must be an object`);
@@ -182,6 +251,11 @@ function validateSeedCandidate(value, index) {
182
251
  fail(`Candidate output ${blockIndex} text must be a string`);
183
252
  return;
184
253
  }
254
+ if (block.type === "json") {
255
+ exactKeys(block, ["type", "value"], `Candidate output ${blockIndex}`);
256
+ assertJson(block.value, `Candidate output ${blockIndex} value`);
257
+ return;
258
+ }
185
259
  if (block.type === "tool-call") {
186
260
  exactKeys(block, ["type", "id", "name", "args", "raw"], `Candidate output ${blockIndex}`);
187
261
  requiredString(block.id, `Candidate output ${blockIndex} id`);
@@ -1,15 +1,18 @@
1
1
  import type { InputHandle, InputOptions, MessageInput, Session, SessionEvent, SessionRunOptions, SessionSnapshot, SessionInput } from "../types/session.js";
2
- import type { Observer } from "../types/shared.js";
2
+ import type { ToolSchemaSource } from "../types/tool.js";
3
+ import type { JsonValue, Observer } from "../types/shared.js";
3
4
  import type { LoopAgent } from "../build/agent.js";
4
5
  export declare class LiveSession implements Session {
5
6
  readonly id: string;
6
7
  private readonly scheduler;
7
8
  constructor(id: string, agent: LoopAgent, options: SessionRunOptions);
8
9
  get state(): SessionSnapshot;
9
- input(event: SessionInput, options?: InputOptions): InputHandle;
10
- interrupt(event: MessageInput, options?: InputOptions): InputHandle;
11
- continue(options?: InputOptions): InputHandle;
12
- stream(): AsyncIterable<SessionEvent>;
10
+ input(event: SessionInput, options?: InputOptions & {
11
+ readonly outputSchema?: ToolSchemaSource;
12
+ }): InputHandle<any>;
13
+ interrupt(event: MessageInput, options?: InputOptions): InputHandle<any>;
14
+ continue(options?: InputOptions): InputHandle<any>;
15
+ stream(): AsyncIterable<SessionEvent<JsonValue>>;
13
16
  observe(listener: Observer): () => void;
14
17
  stop(reason?: string): Promise<void>;
15
18
  }
@@ -1,6 +1,8 @@
1
+ import { HarnessError } from "../errors.js";
1
2
  import { SessionScheduler } from "./scheduler.js";
2
- import { copyJsonObject } from "../utils/immutable.js";
3
+ import { copyJson, copyJsonObject } from "../utils/immutable.js";
3
4
  import { normalizeSessionSeed } from "./seed.js";
5
+ import { bindOutputContract } from "./output-contract.js";
4
6
  export class LiveSession {
5
7
  id;
6
8
  scheduler;
@@ -24,7 +26,13 @@ export class LiveSession {
24
26
  return this.scheduler.snapshot;
25
27
  }
26
28
  input(event, options) {
27
- return this.scheduler.submit(normalizeInput(event), options);
29
+ const output = options?.outputSchema === undefined ? undefined : bindOutputContract(options.outputSchema);
30
+ if (output && isInteractionReply(event))
31
+ throw new HarnessError("output.invalid-schema", "Interaction replies cannot define a turn outputSchema");
32
+ return this.scheduler.submit(normalizeInput(event), {
33
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
34
+ ...(output === undefined ? {} : { output }),
35
+ });
28
36
  }
29
37
  interrupt(event, options) {
30
38
  return this.scheduler.submit(normalizeMessage("interrupt", event), options);
@@ -50,6 +58,14 @@ function isInteractionReply(value) {
50
58
  function normalizeMessage(kind, value) {
51
59
  if (typeof value === "string")
52
60
  return { kind, text: value };
61
+ if ("content" in value)
62
+ return {
63
+ kind,
64
+ content: snapshotContent(value.content),
65
+ ...(value.metadata === undefined
66
+ ? {}
67
+ : { metadata: copyJsonObject(value.metadata, "input metadata") }),
68
+ };
53
69
  return {
54
70
  kind,
55
71
  text: value.text,
@@ -65,3 +81,23 @@ function normalizeInput(input) {
65
81
  return input;
66
82
  return normalizeMessage("user-message", input);
67
83
  }
84
+ function snapshotContent(value) {
85
+ if (!Array.isArray(value))
86
+ throw new HarnessError("input.invalid-content", "Input content must be an array");
87
+ return Object.freeze(value.map((part, index) => {
88
+ if (!part || typeof part !== "object")
89
+ throw new HarnessError("input.invalid-content", `Input content part ${index} must be an object`);
90
+ if (part.type === "text") {
91
+ if (typeof part.text !== "string")
92
+ throw new HarnessError("input.invalid-content", `Input text part ${index} must contain text`);
93
+ return Object.freeze({ type: "text", text: part.text });
94
+ }
95
+ if (part.type !== "media" || typeof part.mediaType !== "string" || part.mediaType === "")
96
+ throw new HarnessError("input.invalid-content", `Input media part ${index} must contain a non-empty mediaType`);
97
+ return Object.freeze({
98
+ type: "media",
99
+ mediaType: part.mediaType,
100
+ reference: copyJson(part.reference),
101
+ });
102
+ }));
103
+ }
@@ -12,4 +12,4 @@ export declare function beginTurn(state: SessionSnapshot, turnId: string, event?
12
12
  export declare const commitInput: (state: SessionSnapshot, turnId: string, event: InputEvent) => SessionSnapshot;
13
13
  export declare const commitCandidate: (state: SessionSnapshot, turnId: string, stepId: string, candidate: ModelCandidate) => SessionSnapshot;
14
14
  export declare const commitToolResults: (state: SessionSnapshot, turnId: string, stepId: string, results: readonly ToolResult[]) => SessionSnapshot;
15
- export declare const commitFinal: (state: SessionSnapshot, turnId: string, stepId: string, output: string) => SessionSnapshot;
15
+ export declare const commitFinal: (state: SessionSnapshot, turnId: string, stepId: string, output: import("../types/shared.js").JsonValue) => SessionSnapshot;