@agentswf/contract 0.0.0-stage → 0.1.0-beta.1
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/package.json +32 -4
- package/src/call.ts +6 -0
- package/src/index.ts +4 -0
- package/src/participant.ts +15 -0
- package/src/records.ts +893 -0
- package/src/records.typecheck.ts +5 -0
- package/src/schema.ts +342 -0
- package/src/semantic.ts +15 -0
- package/src/testing/fixtures.ts +11 -0
- package/src/wire.ts +578 -0
- package/src/workflow/agents.ts +427 -0
- package/src/workflow/composition.ts +18 -0
- package/src/workflow/deadlines.typecheck.ts +76 -0
- package/src/workflow/decisions.ts +176 -0
- package/src/workflow/decisions.typecheck.ts +97 -0
- package/src/workflow/executable.ts +87 -0
- package/src/workflow/index.ts +10 -0
- package/src/workflow/json.ts +74 -0
- package/src/workflow/messaging.ts +31 -0
- package/src/workflow/messaging.typecheck.ts +114 -0
- package/src/workflow/participants.ts +33 -0
- package/src/workflow/sandboxes.ts +87 -0
- package/src/workflow/sandboxes.typecheck.ts +64 -0
- package/src/workflow/timing.ts +14 -0
- package/src/workflow/workflow.ts +128 -0
- package/README.md +0 -3
package/src/schema.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON Schema subset, small on purpose: enough shape for a workflow step's return value,
|
|
3
|
+
* and no keyword whose failure a model could not act on.
|
|
4
|
+
*/
|
|
5
|
+
export type JsonSchema =
|
|
6
|
+
| { enum: JsonPrimitive[]; description?: string }
|
|
7
|
+
| { anyOf: JsonSchema[]; description?: string }
|
|
8
|
+
| { type: "string"; const?: string; enum?: string[]; minLength?: number; description?: string }
|
|
9
|
+
| {
|
|
10
|
+
type: "number" | "integer";
|
|
11
|
+
const?: number;
|
|
12
|
+
minimum?: number;
|
|
13
|
+
maximum?: number;
|
|
14
|
+
description?: string;
|
|
15
|
+
}
|
|
16
|
+
| { type: "boolean"; const?: boolean; description?: string }
|
|
17
|
+
| { type: "null"; description?: string }
|
|
18
|
+
| { type: "array"; items: JsonSchema; minItems?: number; description?: string }
|
|
19
|
+
| {
|
|
20
|
+
type: "object";
|
|
21
|
+
properties: Record<string, JsonSchema>;
|
|
22
|
+
required?: string[];
|
|
23
|
+
additionalProperties?: boolean;
|
|
24
|
+
description?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
28
|
+
|
|
29
|
+
export type SchemaError = { path: string; message: string };
|
|
30
|
+
|
|
31
|
+
const ROOT = "value";
|
|
32
|
+
const ANNOTATION_KEYS = [
|
|
33
|
+
"$id",
|
|
34
|
+
"$schema",
|
|
35
|
+
"title",
|
|
36
|
+
"description",
|
|
37
|
+
"default",
|
|
38
|
+
"examples",
|
|
39
|
+
"readOnly",
|
|
40
|
+
"writeOnly",
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/** Checks an author-supplied JSON Schema before the engine relies on this module to validate it. */
|
|
44
|
+
export function parseJsonSchema(value: unknown): JsonSchema {
|
|
45
|
+
if (!isRecord(value)) throw new Error("output schema must be an object");
|
|
46
|
+
if ("anyOf" in value) {
|
|
47
|
+
assertKeys(value, ["anyOf", ...ANNOTATION_KEYS]);
|
|
48
|
+
if (!Array.isArray(value.anyOf) || value.anyOf.length === 0) {
|
|
49
|
+
throw new Error("output schema anyOf must contain at least one schema");
|
|
50
|
+
}
|
|
51
|
+
value.anyOf.forEach(parseJsonSchema);
|
|
52
|
+
return value as JsonSchema;
|
|
53
|
+
}
|
|
54
|
+
if (!("type" in value) && "enum" in value) {
|
|
55
|
+
assertKeys(value, ["enum", ...ANNOTATION_KEYS]);
|
|
56
|
+
assertPrimitiveEnum(value.enum);
|
|
57
|
+
return value as JsonSchema;
|
|
58
|
+
}
|
|
59
|
+
switch (value.type) {
|
|
60
|
+
case "string":
|
|
61
|
+
assertKeys(value, ["type", "const", "enum", "minLength", ...ANNOTATION_KEYS]);
|
|
62
|
+
if (value.const !== undefined && typeof value.const !== "string") invalid("string const");
|
|
63
|
+
if (value.enum !== undefined) assertStringEnum(value.enum);
|
|
64
|
+
if (value.minLength !== undefined) assertNonNegativeInteger(value.minLength, "minLength");
|
|
65
|
+
break;
|
|
66
|
+
case "number":
|
|
67
|
+
case "integer":
|
|
68
|
+
assertKeys(value, ["type", "const", "minimum", "maximum", ...ANNOTATION_KEYS]);
|
|
69
|
+
for (const key of ["const", "minimum", "maximum"] as const) {
|
|
70
|
+
if (value[key] !== undefined && !isFiniteNumber(value[key])) invalid(key);
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
case "boolean":
|
|
74
|
+
assertKeys(value, ["type", "const", ...ANNOTATION_KEYS]);
|
|
75
|
+
if (value.const !== undefined && typeof value.const !== "boolean") invalid("boolean const");
|
|
76
|
+
break;
|
|
77
|
+
case "null":
|
|
78
|
+
assertKeys(value, ["type", ...ANNOTATION_KEYS]);
|
|
79
|
+
break;
|
|
80
|
+
case "array":
|
|
81
|
+
assertKeys(value, ["type", "items", "minItems", ...ANNOTATION_KEYS]);
|
|
82
|
+
parseJsonSchema(value.items);
|
|
83
|
+
if (value.minItems !== undefined) assertNonNegativeInteger(value.minItems, "minItems");
|
|
84
|
+
break;
|
|
85
|
+
case "object": {
|
|
86
|
+
assertKeys(value, [
|
|
87
|
+
"type",
|
|
88
|
+
"properties",
|
|
89
|
+
"required",
|
|
90
|
+
"additionalProperties",
|
|
91
|
+
...ANNOTATION_KEYS,
|
|
92
|
+
]);
|
|
93
|
+
if (!isRecord(value.properties)) invalid("object properties");
|
|
94
|
+
Object.values(value.properties).forEach(parseJsonSchema);
|
|
95
|
+
if (value.required !== undefined && !isStringArray(value.required)) invalid("required");
|
|
96
|
+
if (
|
|
97
|
+
value.additionalProperties !== undefined &&
|
|
98
|
+
typeof value.additionalProperties !== "boolean"
|
|
99
|
+
)
|
|
100
|
+
invalid("additionalProperties");
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
throw new Error("output schema uses an unsupported shape");
|
|
105
|
+
}
|
|
106
|
+
return value as JsonSchema;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function validate(schema: JsonSchema, value: unknown): SchemaError[] {
|
|
110
|
+
return check(schema, value, ROOT);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The text an agent reads on its own terminal and is expected to correct from, so every line
|
|
115
|
+
* names one location, what was wanted there, and what arrived.
|
|
116
|
+
*/
|
|
117
|
+
export function formatErrors(errors: readonly SchemaError[]): string {
|
|
118
|
+
if (errors.length === 0) return "";
|
|
119
|
+
const lines = errors.map((error) => ` ${error.path}: ${error.message}`);
|
|
120
|
+
return [`the value does not match the schema for this call:`, ...lines].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether every constant-valued property this branch declares is present and equal in value. */
|
|
124
|
+
function tagMatches(candidate: JsonSchema, value: unknown): boolean {
|
|
125
|
+
if (!("type" in candidate) || candidate.type !== "object" || !isRecord(value)) return false;
|
|
126
|
+
const tags = Object.entries(candidate.properties).filter(
|
|
127
|
+
([, property]) => "const" in property && property.const !== undefined,
|
|
128
|
+
);
|
|
129
|
+
return (
|
|
130
|
+
tags.length > 0 &&
|
|
131
|
+
tags.every(([key, property]) => value[key] === (property as { const: unknown }).const)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function check(schema: JsonSchema, value: unknown, path: string): SchemaError[] {
|
|
136
|
+
if ("anyOf" in schema) {
|
|
137
|
+
if (schema.anyOf.some((candidate) => check(candidate, value, path).length === 0)) return [];
|
|
138
|
+
// A tagged union fails inside one branch, for one reason. Reporting only that the value
|
|
139
|
+
// matched none of three shapes names neither the branch nor the field, and re-states as
|
|
140
|
+
// "wanted" the very thing that was supplied. E5 is what that costs in correction attempts.
|
|
141
|
+
const tagged = schema.anyOf.filter((candidate) => tagMatches(candidate, value));
|
|
142
|
+
if (tagged.length === 1) return check(tagged[0]!, value, path);
|
|
143
|
+
const options = schema.anyOf.map(describe).join(", ");
|
|
144
|
+
return [{ path, message: `expected one of ${options}; got ${preview(value)}` }];
|
|
145
|
+
}
|
|
146
|
+
if (!("type" in schema)) {
|
|
147
|
+
if (schema.enum.includes(value as JsonPrimitive)) return [];
|
|
148
|
+
const options = schema.enum.map(preview).join(", ");
|
|
149
|
+
return [{ path, message: `expected one of ${options}; got ${preview(value)}` }];
|
|
150
|
+
}
|
|
151
|
+
switch (schema.type) {
|
|
152
|
+
case "string": {
|
|
153
|
+
if (typeof value !== "string") return [wrongType(path, "a string", value)];
|
|
154
|
+
const errors: SchemaError[] = [];
|
|
155
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
156
|
+
errors.push({ path, message: `expected ${quote(schema.const)}; got ${quote(value)}` });
|
|
157
|
+
}
|
|
158
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
159
|
+
errors.push({
|
|
160
|
+
path,
|
|
161
|
+
message: `expected one of ${schema.enum.map(quote).join(", ")}; got ${quote(value)}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
165
|
+
errors.push({
|
|
166
|
+
path,
|
|
167
|
+
message: `expected at least ${schema.minLength} characters; got ${value.length}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return errors;
|
|
171
|
+
}
|
|
172
|
+
case "number":
|
|
173
|
+
case "integer": {
|
|
174
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
175
|
+
return [wrongType(path, schema.type === "integer" ? "an integer" : "a number", value)];
|
|
176
|
+
}
|
|
177
|
+
const errors: SchemaError[] = [];
|
|
178
|
+
if (schema.const !== undefined && value !== schema.const) {
|
|
179
|
+
errors.push({ path, message: `expected ${schema.const}; got ${value}` });
|
|
180
|
+
}
|
|
181
|
+
if (schema.type === "integer" && !Number.isInteger(value)) {
|
|
182
|
+
errors.push({ path, message: `expected an integer; got ${value}` });
|
|
183
|
+
}
|
|
184
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
185
|
+
errors.push({ path, message: `expected at least ${schema.minimum}; got ${value}` });
|
|
186
|
+
}
|
|
187
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
188
|
+
errors.push({ path, message: `expected at most ${schema.maximum}; got ${value}` });
|
|
189
|
+
}
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
case "boolean":
|
|
193
|
+
if (typeof value !== "boolean") return [wrongType(path, "a boolean", value)];
|
|
194
|
+
return schema.const === undefined || value === schema.const
|
|
195
|
+
? []
|
|
196
|
+
: [{ path, message: `expected ${schema.const}; got ${value}` }];
|
|
197
|
+
case "null":
|
|
198
|
+
return value === null ? [] : [wrongType(path, "null", value)];
|
|
199
|
+
case "array": {
|
|
200
|
+
if (!Array.isArray(value)) return [wrongType(path, "an array", value)];
|
|
201
|
+
const errors: SchemaError[] = [];
|
|
202
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
203
|
+
errors.push({
|
|
204
|
+
path,
|
|
205
|
+
message: `expected at least ${schema.minItems} items; got ${value.length}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
value.forEach((item, index) => {
|
|
209
|
+
errors.push(...check(schema.items, item, `${path}[${index}]`));
|
|
210
|
+
});
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
case "object": {
|
|
214
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
215
|
+
return [wrongType(path, "an object", value)];
|
|
216
|
+
}
|
|
217
|
+
const record = value as Record<string, unknown>;
|
|
218
|
+
const errors: SchemaError[] = [];
|
|
219
|
+
for (const key of schema.required ?? []) {
|
|
220
|
+
// `in` reaches Object.prototype, so a property named `constructor` or `toString`
|
|
221
|
+
// would read as present when it is absent.
|
|
222
|
+
if (!Object.hasOwn(record, key)) {
|
|
223
|
+
const expected = schema.properties[key];
|
|
224
|
+
const shape = expected ? ` (${describe(expected)})` : "";
|
|
225
|
+
errors.push({ path: `${path}.${key}`, message: `required property is missing${shape}` });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const [key, item] of Object.entries(record)) {
|
|
229
|
+
// Same reach, worse symptom: a value carrying a `toString` key would resolve to
|
|
230
|
+
// Object.prototype.toString and be switched on as if it were a schema.
|
|
231
|
+
const property = Object.hasOwn(schema.properties, key) ? schema.properties[key] : undefined;
|
|
232
|
+
if (!property) {
|
|
233
|
+
if (schema.additionalProperties === false) {
|
|
234
|
+
const known = Object.keys(schema.properties).map(quote).join(", ");
|
|
235
|
+
errors.push({
|
|
236
|
+
path: `${path}.${key}`,
|
|
237
|
+
message: `unexpected property; the schema accepts only ${known}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
errors.push(...check(property, item, `${path}.${key}`));
|
|
243
|
+
}
|
|
244
|
+
return errors;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function wrongType(path: string, expected: string, value: unknown): SchemaError {
|
|
250
|
+
return { path, message: `expected ${expected}; got ${typeName(value)} ${preview(value)}` };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function typeName(value: unknown): string {
|
|
254
|
+
if (value === null) return "null";
|
|
255
|
+
if (Array.isArray(value)) return "an array";
|
|
256
|
+
return `a ${typeof value}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function preview(value: unknown): string {
|
|
260
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
261
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function quote(value: string): string {
|
|
265
|
+
return JSON.stringify(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A JSON object: not null, not an array. */
|
|
269
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
270
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function assertKeys(value: Record<string, unknown>, allowed: readonly string[]): void {
|
|
274
|
+
const accepted = new Set(allowed);
|
|
275
|
+
const unsupported = Object.keys(value).find((key) => !accepted.has(key));
|
|
276
|
+
if (unsupported) {
|
|
277
|
+
throw new Error(`output schema keyword ${JSON.stringify(unsupported)} is unsupported`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function assertPrimitiveEnum(value: unknown): asserts value is JsonPrimitive[] {
|
|
282
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(isJsonPrimitive)) invalid("enum");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function assertStringEnum(value: unknown): asserts value is string[] {
|
|
286
|
+
const strings = Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
287
|
+
if (!strings || value.length === 0) invalid("string enum");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isJsonPrimitive(value: unknown): value is JsonPrimitive {
|
|
291
|
+
return (
|
|
292
|
+
value === null ||
|
|
293
|
+
typeof value === "string" ||
|
|
294
|
+
typeof value === "boolean" ||
|
|
295
|
+
isFiniteNumber(value)
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
300
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isStringArray(value: unknown): value is string[] {
|
|
304
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function assertNonNegativeInteger(value: unknown, field: string): void {
|
|
308
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) invalid(field);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function invalid(field: string): never {
|
|
312
|
+
throw new Error(`output schema has an invalid ${field}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** A one-line rendering of a schema, used inside the prompt and inside error messages. */
|
|
316
|
+
export function describe(schema: JsonSchema): string {
|
|
317
|
+
if ("anyOf" in schema) return `one of ${schema.anyOf.map(describe).join(", ")}`;
|
|
318
|
+
if (!("type" in schema)) return `one of ${schema.enum.map(preview).join(", ")}`;
|
|
319
|
+
switch (schema.type) {
|
|
320
|
+
case "string":
|
|
321
|
+
return schema.const !== undefined
|
|
322
|
+
? quote(schema.const)
|
|
323
|
+
: schema.enum
|
|
324
|
+
? `one of ${schema.enum.map(quote).join(", ")}`
|
|
325
|
+
: "string";
|
|
326
|
+
case "number":
|
|
327
|
+
case "integer":
|
|
328
|
+
case "boolean":
|
|
329
|
+
return schema.const === undefined ? schema.type : String(schema.const);
|
|
330
|
+
case "null":
|
|
331
|
+
return "null";
|
|
332
|
+
case "array":
|
|
333
|
+
return `array of ${describe(schema.items)}`;
|
|
334
|
+
case "object": {
|
|
335
|
+
const required = new Set(schema.required ?? []);
|
|
336
|
+
const fields = Object.entries(schema.properties).map(
|
|
337
|
+
([key, item]) => `${key}${required.has(key) ? "" : "?"}: ${describe(item)}`,
|
|
338
|
+
);
|
|
339
|
+
return `{ ${fields.join(", ")} }`;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/semantic.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam for the optional semantic check: does the value actually answer what was asked,
|
|
3
|
+
* as opposed to merely matching the schema. Nothing here calls a model. A small-model call
|
|
4
|
+
* is dropped in by passing a different `SemanticCheck` to the result layer; the engine and
|
|
5
|
+
* the CLI never change.
|
|
6
|
+
*/
|
|
7
|
+
export type SemanticVerdict = { kind: "accepted" } | { kind: "rejected"; reason: string };
|
|
8
|
+
|
|
9
|
+
export type SemanticCheck = (input: {
|
|
10
|
+
/** What the step asked for, recorded with the call so the checker sees it without the prompt. */
|
|
11
|
+
question: string;
|
|
12
|
+
value: unknown;
|
|
13
|
+
}) => Promise<SemanticVerdict>;
|
|
14
|
+
|
|
15
|
+
export const acceptAny: SemanticCheck = async () => ({ kind: "accepted" });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { JsonSchema } from "../schema";
|
|
2
|
+
|
|
3
|
+
export const COUNT_SCHEMA: JsonSchema = {
|
|
4
|
+
type: "object",
|
|
5
|
+
properties: {
|
|
6
|
+
count: { type: "integer", minimum: 0 },
|
|
7
|
+
even: { type: "boolean" },
|
|
8
|
+
},
|
|
9
|
+
required: ["count", "even"],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
};
|