@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.
- package/CHANGELOG.md +54 -0
- package/README.md +5 -79
- package/dist/build/bind-tool.js +4 -3
- package/dist/build/helpers.d.ts +2 -2
- package/dist/build/schema.d.ts +11 -16
- package/dist/build/schema.js +141 -22
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +2 -0
- package/dist/model/adapters.d.ts +48 -5
- package/dist/model/adapters.js +128 -14
- package/dist/model/normalize.js +34 -3
- package/dist/model/prepared.d.ts +16 -0
- package/dist/model/prepared.js +11 -0
- package/dist/session/event-log.d.ts +4 -3
- package/dist/session/input-queue.d.ts +7 -4
- package/dist/session/input-queue.js +12 -0
- package/dist/session/output-contract.d.ts +6 -0
- package/dist/session/output-contract.js +12 -0
- package/dist/session/scheduler.js +1 -1
- package/dist/session/seed.js +78 -4
- package/dist/session/session.d.ts +8 -5
- package/dist/session/session.js +38 -2
- package/dist/session/state.d.ts +1 -1
- package/dist/session/submission-stream.d.ts +5 -4
- package/dist/step/context-draft.js +1 -7
- package/dist/step/model-configuration.js +5 -19
- package/dist/step/project.js +25 -3
- package/dist/step/resolve.d.ts +1 -0
- package/dist/step/resolve.js +1 -0
- package/dist/step/run.d.ts +1 -0
- package/dist/step/run.js +24 -3
- package/dist/step/seal.d.ts +4 -2
- package/dist/step/seal.js +63 -12
- package/dist/turn/plan-runner.js +38 -3
- package/dist/turn/runner.d.ts +6 -4
- package/dist/turn/runner.js +6 -4
- package/dist/types/middleware.d.ts +1 -1
- package/dist/types/model.d.ts +21 -13
- package/dist/types/session.d.ts +34 -13
- package/dist/types/shared.d.ts +16 -3
- package/dist/types/tool.d.ts +54 -17
- package/package.json +11 -12
- package/dist/utils/digest.d.ts +0 -1
- package/dist/utils/digest.js +0 -14
package/dist/model/adapters.js
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
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) =>
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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("");
|
package/dist/model/normalize.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { HarnessError, isHarnessError } from "../errors.js";
|
|
2
|
-
import {
|
|
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
|
|
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:
|
|
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) {
|
package/dist/session/seed.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
package/dist/session/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/session/state.d.ts
CHANGED
|
@@ -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:
|
|
15
|
+
export declare const commitFinal: (state: SessionSnapshot, turnId: string, stepId: string, output: import("../types/shared.js").JsonValue) => SessionSnapshot;
|