@checkstack/ai-backend 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/package.json +7 -7
- package/src/agent-runner.test.ts +50 -0
- package/src/agent-runner.ts +13 -3
- package/src/chat/chat-handler.ts +6 -0
- package/src/chat/chat-service.ts +13 -18
- package/src/chat/classifier.logic.test.ts +11 -0
- package/src/chat/classifier.logic.ts +16 -9
- package/src/chat/model-schema.test.ts +264 -0
- package/src/chat/model-schema.ts +334 -0
- package/src/chat/sdk-tools.ts +32 -35
- package/src/chat/system-prompt.test.ts +113 -0
- package/src/chat/system-prompt.ts +146 -0
- package/src/generated/docs-index.ts +6 -5
- package/src/projection.test.ts +3 -1
- package/src/registry-wiring.test.ts +3 -1
- package/src/serializer.test.ts +22 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tool as aiTool, asSchema } from "ai";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
dateSafeModelSchema,
|
|
6
|
+
coerceDateValues,
|
|
7
|
+
collectDateOffsetIssues,
|
|
8
|
+
schemaContainsDate,
|
|
9
|
+
toModelSchema,
|
|
10
|
+
} from "./model-schema";
|
|
11
|
+
|
|
12
|
+
describe("schemaContainsDate", () => {
|
|
13
|
+
test("detects dates in object / array / optional / coerce positions", () => {
|
|
14
|
+
expect(schemaContainsDate(z.object({ at: z.date() }))).toBe(true);
|
|
15
|
+
expect(schemaContainsDate(z.object({ at: z.date().optional() }))).toBe(true);
|
|
16
|
+
expect(schemaContainsDate(z.object({ at: z.coerce.date() }))).toBe(true);
|
|
17
|
+
expect(schemaContainsDate(z.object({ seen: z.array(z.date()) }))).toBe(true);
|
|
18
|
+
expect(
|
|
19
|
+
schemaContainsDate(z.object({ d: z.date() }).refine(() => true)),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns false when there is no date", () => {
|
|
24
|
+
expect(
|
|
25
|
+
schemaContainsDate(z.object({ name: z.string(), n: z.number() })),
|
|
26
|
+
).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("coerceDateValues", () => {
|
|
31
|
+
test("coerces ISO strings to Date only at date positions", () => {
|
|
32
|
+
const schema = z.object({ at: z.date(), name: z.string() });
|
|
33
|
+
const out = coerceDateValues(
|
|
34
|
+
{ at: "2026-01-02T03:04:05.000Z", name: "2026-01-02T03:04:05.000Z" },
|
|
35
|
+
schema,
|
|
36
|
+
) as { at: unknown; name: unknown };
|
|
37
|
+
expect(out.at).toBeInstanceOf(Date);
|
|
38
|
+
// A string field that merely looks like a date is left a string.
|
|
39
|
+
expect(out.name).toBe("2026-01-02T03:04:05.000Z");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("recurses arrays and optionals", () => {
|
|
43
|
+
const schema = z.object({
|
|
44
|
+
seen: z.array(z.date()),
|
|
45
|
+
at: z.date().optional(),
|
|
46
|
+
});
|
|
47
|
+
const out = coerceDateValues(
|
|
48
|
+
{ seen: ["2026-01-02T00:00:00.000Z"], at: undefined },
|
|
49
|
+
schema,
|
|
50
|
+
) as { seen: unknown[]; at: unknown };
|
|
51
|
+
expect(out.seen[0]).toBeInstanceOf(Date);
|
|
52
|
+
expect(out.at).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("dateSafeModelSchema", () => {
|
|
57
|
+
// The core regression: the AI SDK would throw "Date cannot be represented in
|
|
58
|
+
// JSON Schema" building the model-facing schema for these inputs.
|
|
59
|
+
test("produces a date-time string schema without throwing", async () => {
|
|
60
|
+
const schema = dateSafeModelSchema(
|
|
61
|
+
z.object({ id: z.string(), createdAt: z.date() }),
|
|
62
|
+
);
|
|
63
|
+
const js = (await schema.jsonSchema) as {
|
|
64
|
+
properties: Record<string, Record<string, unknown>>;
|
|
65
|
+
additionalProperties?: unknown;
|
|
66
|
+
};
|
|
67
|
+
expect(js.properties.createdAt?.type).toBe("string");
|
|
68
|
+
expect(js.properties.createdAt?.format).toBe("date-time");
|
|
69
|
+
// The model is told the offset contract right on the field.
|
|
70
|
+
expect(String(js.properties.createdAt?.description)).toContain(
|
|
71
|
+
"explicit timezone offset",
|
|
72
|
+
);
|
|
73
|
+
// Strict-provider friendly (matches the SDK's own zod adapter).
|
|
74
|
+
expect(js.additionalProperties).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("validator coerces the model's ISO string into a Date", async () => {
|
|
78
|
+
const schema = dateSafeModelSchema(z.object({ at: z.date() }));
|
|
79
|
+
const result = await schema.validate?.({ at: "2026-01-02T03:04:05.000Z" });
|
|
80
|
+
expect(result?.success).toBe(true);
|
|
81
|
+
if (result?.success) {
|
|
82
|
+
expect((result.value as { at: Date }).at).toBeInstanceOf(Date);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("validator preserves the original schema's refinement", async () => {
|
|
87
|
+
const schema = dateSafeModelSchema(
|
|
88
|
+
z
|
|
89
|
+
.object({ startAt: z.coerce.date(), endAt: z.coerce.date() })
|
|
90
|
+
.refine((v) => v.endAt > v.startAt, { message: "endAt after startAt" }),
|
|
91
|
+
);
|
|
92
|
+
const bad = await schema.validate?.({
|
|
93
|
+
startAt: "2026-01-02T00:00:00.000Z",
|
|
94
|
+
endAt: "2026-01-01T00:00:00.000Z",
|
|
95
|
+
});
|
|
96
|
+
expect(bad?.success).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("date format matrix (the wire contract)", () => {
|
|
101
|
+
// Run every case through BOTH a raw `z.date()` (which exercises OUR coercion)
|
|
102
|
+
// and a `z.coerce.date()` (whose own `new Date()` coercion is lenient and
|
|
103
|
+
// MUST still be gated). A model can emit any of these shapes; the contract is:
|
|
104
|
+
// only an RFC 3339 date-time WITH an explicit offset is accepted, and it maps
|
|
105
|
+
// to the one unambiguous instant. Zone-less, date-only, numeric and garbage
|
|
106
|
+
// values are rejected so the model self-repairs instead of us guessing a zone.
|
|
107
|
+
const schemas = {
|
|
108
|
+
"z.date()": z.object({ at: z.date() }),
|
|
109
|
+
"z.coerce.date()": z.object({ at: z.coerce.date() }),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// Offset-bearing inputs and the single UTC instant they must resolve to.
|
|
113
|
+
// Deterministic regardless of the machine's local timezone (each carries Z or
|
|
114
|
+
// an explicit offset), so the exact ISO is safe to assert in CI.
|
|
115
|
+
const accepted: Array<[input: string, iso: string]> = [
|
|
116
|
+
["2026-07-01T22:00:00.000Z", "2026-07-01T22:00:00.000Z"],
|
|
117
|
+
["2026-07-01T22:00:00Z", "2026-07-01T22:00:00.000Z"],
|
|
118
|
+
["2026-07-01T22:00Z", "2026-07-01T22:00:00.000Z"], // no seconds
|
|
119
|
+
["2026-07-01T22:00:00.123Z", "2026-07-01T22:00:00.123Z"], // sub-seconds
|
|
120
|
+
["2026-07-01T22:00:00+00:00", "2026-07-01T22:00:00.000Z"],
|
|
121
|
+
["2026-07-01T22:00:00+02:00", "2026-07-01T20:00:00.000Z"],
|
|
122
|
+
["2026-07-01T22:00:00-05:00", "2026-07-02T03:00:00.000Z"],
|
|
123
|
+
["2026-07-01T22:00:00+0200", "2026-07-01T20:00:00.000Z"], // offset w/o colon
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// Rejected: zone-less (would be interpreted server-local), date-only (drops
|
|
127
|
+
// the time), non-ISO human forms, and outright garbage.
|
|
128
|
+
const rejected = [
|
|
129
|
+
"2026-07-01T22:00:00", // no offset
|
|
130
|
+
"2026-07-01 22:00:00", // space + no offset
|
|
131
|
+
"2026-07-01", // date only
|
|
132
|
+
"2026/07/01", // slashes
|
|
133
|
+
"July 1, 2026", // human
|
|
134
|
+
"Wed, 01 Jul 2026 22:00:00 GMT", // RFC 1123 (no offset designator we accept)
|
|
135
|
+
"2026-13-01T00:00:00Z", // matches the offset shape but is not a real date
|
|
136
|
+
"not a date",
|
|
137
|
+
"",
|
|
138
|
+
"tomorrow",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
for (const [label, schema] of Object.entries(schemas)) {
|
|
142
|
+
for (const [input, iso] of accepted) {
|
|
143
|
+
test(`${label}: accepts "${input}" -> ${iso}`, async () => {
|
|
144
|
+
const result = await dateSafeModelSchema(schema).validate?.({
|
|
145
|
+
at: input,
|
|
146
|
+
});
|
|
147
|
+
expect(result?.success).toBe(true);
|
|
148
|
+
if (result?.success) {
|
|
149
|
+
const at = (result.value as { at: Date }).at;
|
|
150
|
+
expect(at).toBeInstanceOf(Date);
|
|
151
|
+
expect(at.toISOString()).toBe(iso);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const input of rejected) {
|
|
157
|
+
test(`${label}: rejects ${JSON.stringify(input)}`, async () => {
|
|
158
|
+
const result = await dateSafeModelSchema(schema).validate?.({
|
|
159
|
+
at: input,
|
|
160
|
+
});
|
|
161
|
+
expect(result?.success).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test(`${label}: rejects a bare epoch number`, async () => {
|
|
166
|
+
const result = await dateSafeModelSchema(schema).validate?.({
|
|
167
|
+
at: 1782000000000,
|
|
168
|
+
});
|
|
169
|
+
expect(result?.success).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test("rejection message names the field and the offset requirement", () => {
|
|
174
|
+
const issues = collectDateOffsetIssues(
|
|
175
|
+
{ startAt: "2026-07-01T22:00:00" },
|
|
176
|
+
z.object({ startAt: z.date() }),
|
|
177
|
+
);
|
|
178
|
+
expect(issues).toHaveLength(1);
|
|
179
|
+
expect(issues[0]).toContain("startAt");
|
|
180
|
+
expect(issues[0]).toContain("explicit timezone offset");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a regex-shaped but impossible date reports an invalid-date message", () => {
|
|
184
|
+
const issues = collectDateOffsetIssues(
|
|
185
|
+
{ at: "2026-13-01T00:00:00Z" },
|
|
186
|
+
z.object({ at: z.date() }),
|
|
187
|
+
);
|
|
188
|
+
expect(issues[0]).toContain("not a valid calendar date-time");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("nested arrays and optionals are gated too", () => {
|
|
192
|
+
const schema = z.object({
|
|
193
|
+
windows: z.array(z.object({ at: z.date() })),
|
|
194
|
+
maybe: z.date().optional(),
|
|
195
|
+
});
|
|
196
|
+
const issues = collectDateOffsetIssues(
|
|
197
|
+
{ windows: [{ at: "2026-07-01" }], maybe: "2026-07-01T00:00:00" },
|
|
198
|
+
schema,
|
|
199
|
+
);
|
|
200
|
+
expect(issues).toHaveLength(2);
|
|
201
|
+
expect(issues.some((m) => m.includes("windows[0].at"))).toBe(true);
|
|
202
|
+
expect(issues.some((m) => m.includes("maybe"))).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("an absent optional date is not flagged", () => {
|
|
206
|
+
expect(
|
|
207
|
+
collectDateOffsetIssues({}, z.object({ at: z.date().optional() })),
|
|
208
|
+
).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("toModelSchema (the single boundary entry)", () => {
|
|
213
|
+
test("returns the raw Zod schema when there is no date", () => {
|
|
214
|
+
const schema = z.object({ q: z.string() });
|
|
215
|
+
expect(toModelSchema(schema)).toBe(schema);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("returns a date-safe Schema when a date is present", () => {
|
|
219
|
+
const schema = z.object({ at: z.date() });
|
|
220
|
+
expect(toModelSchema(schema)).not.toBe(schema);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The full inbound round-trip exactly as the AI SDK runtime drives it: the
|
|
224
|
+
// model emits an object with an ISO date STRING, the tool's inputSchema
|
|
225
|
+
// validates it, and `execute` is called with the validated value. We assert
|
|
226
|
+
// `execute` receives a real `Date` - i.e. the model can create date-bearing
|
|
227
|
+
// objects and they are parsed back to Date in our backend. Uses a raw
|
|
228
|
+
// `z.date()` (not coerce.date) so this proves OUR coercion, not Zod's.
|
|
229
|
+
//
|
|
230
|
+
// The input string is the EXACT shape a real model emits, captured from a
|
|
231
|
+
// live deepseek-v4-flash maintenance-window creation: ISO 8601 with a `Z`
|
|
232
|
+
// offset and NO milliseconds (`...T22:00:00Z`, not `...T22:00:00.000Z`). The
|
|
233
|
+
// less-precise form is what providers actually return, so the test asserts
|
|
234
|
+
// `new Date()` normalizes it to a real Date with the milliseconds filled in.
|
|
235
|
+
test("model's ISO date object (no millis) is parsed to a real Date for execute", async () => {
|
|
236
|
+
const schema = z.object({ startAt: z.date(), label: z.string() });
|
|
237
|
+
let received: { startAt: unknown; label: unknown } | undefined;
|
|
238
|
+
const t = aiTool({
|
|
239
|
+
inputSchema: toModelSchema(schema) as never,
|
|
240
|
+
execute: async (input: unknown) => {
|
|
241
|
+
received = input as { startAt: unknown; label: unknown };
|
|
242
|
+
return { ok: true };
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const validated = await asSchema(t.inputSchema).validate?.({
|
|
247
|
+
startAt: "2026-07-01T22:00:00Z",
|
|
248
|
+
label: "window",
|
|
249
|
+
});
|
|
250
|
+
expect(validated?.success).toBe(true);
|
|
251
|
+
if (validated?.success) {
|
|
252
|
+
await t.execute?.(validated.value, {
|
|
253
|
+
toolCallId: "call-1",
|
|
254
|
+
messages: [],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
expect(received?.startAt).toBeInstanceOf(Date);
|
|
259
|
+
expect((received?.startAt as Date).toISOString()).toBe(
|
|
260
|
+
"2026-07-01T22:00:00.000Z",
|
|
261
|
+
);
|
|
262
|
+
expect(received?.label).toBe("window");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { jsonSchema, type Schema } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** The slice of a JSON Schema node this module reads/writes. */
|
|
5
|
+
interface JsonSchemaNode {
|
|
6
|
+
type?: unknown;
|
|
7
|
+
properties?: Record<string, JsonSchemaNode>;
|
|
8
|
+
items?: JsonSchemaNode | JsonSchemaNode[];
|
|
9
|
+
additionalProperties?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An RFC 3339 / ISO 8601 date-time WITH an explicit timezone designator (`Z` or
|
|
14
|
+
* `±HH:MM` / `±HHMM`). Seconds (and sub-seconds) are optional; the offset is
|
|
15
|
+
* NOT. We require the offset because the only alternative - feeding a zone-less
|
|
16
|
+
* string to `new Date()` - interprets it in the RUNTIME's local zone, so the
|
|
17
|
+
* same model string would resolve to different instants on pods in different
|
|
18
|
+
* timezones (this platform runs N pods sharing one DB). Date-only strings
|
|
19
|
+
* (`2026-07-01`) are likewise rejected: they silently mean UTC midnight and
|
|
20
|
+
* throw away the time the operator asked for. The model is told the reference
|
|
21
|
+
* timezone in the system prompt, so it can always produce an offset.
|
|
22
|
+
*/
|
|
23
|
+
const RFC3339_WITH_OFFSET =
|
|
24
|
+
/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:?\d{2})$/;
|
|
25
|
+
|
|
26
|
+
/** Model-facing hint stamped on every date field's JSON Schema description. */
|
|
27
|
+
const DATE_FIELD_HINT =
|
|
28
|
+
"RFC 3339 date-time WITH an explicit timezone offset, e.g. " +
|
|
29
|
+
'"2026-07-01T22:00:00Z" or "2026-07-01T22:00:00+02:00". ' +
|
|
30
|
+
"Zone-less and date-only values are rejected.";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The single model-boundary date handler. The AI SDK builds the model-facing
|
|
34
|
+
* JSON Schema from a raw Zod schema via Zod v4's `toJSONSchema()` with the
|
|
35
|
+
* default `unrepresentable: "throw"`, which throws "Date cannot be represented
|
|
36
|
+
* in JSON Schema" for `z.date()` AND `z.coerce.date()`. A single date field in
|
|
37
|
+
* any tool input or `generateObject` output would therefore crash the model
|
|
38
|
+
* call (for the chat, before the model is even invoked).
|
|
39
|
+
*
|
|
40
|
+
* For date-bearing schemas we hand the SDK a ready-made schema (so it never
|
|
41
|
+
* runs the throwing converter) plus our own validator:
|
|
42
|
+
* - the model-facing schema renders dates as `{ type: "string", format:
|
|
43
|
+
* "date-time" }` (their wire shape) - matching the SDK's own options
|
|
44
|
+
* (draft-7 / input) so non-date parts are byte-identical to before;
|
|
45
|
+
* - the validator coerces the ISO strings the model emits back into real
|
|
46
|
+
* `Date`s before parsing with the ORIGINAL schema, so refinements and the
|
|
47
|
+
* downstream RPC client (which expects `Date`s) keep working.
|
|
48
|
+
*
|
|
49
|
+
* Apply this at EVERY model-schema boundary (gated by {@link schemaContainsDate})
|
|
50
|
+
* so individual tool / agent definitions never have to special-case dates - the
|
|
51
|
+
* thing that would otherwise regress one tool at a time. Non-date schemas are
|
|
52
|
+
* left as raw Zod so the SDK handles them exactly as it always has.
|
|
53
|
+
*/
|
|
54
|
+
export function dateSafeModelSchema(input: z.ZodTypeAny): Schema<unknown> {
|
|
55
|
+
// Cast: bridges Zod's own JSONSchema type to the node shape this module
|
|
56
|
+
// mutates. It is the same JSON Schema object, just a narrower view.
|
|
57
|
+
const modelSchema = z.toJSONSchema(input, {
|
|
58
|
+
target: "draft-7",
|
|
59
|
+
io: "input",
|
|
60
|
+
unrepresentable: "any",
|
|
61
|
+
override: (ctx) => {
|
|
62
|
+
if (ctx.zodSchema instanceof z.ZodDate) {
|
|
63
|
+
ctx.jsonSchema.type = "string";
|
|
64
|
+
ctx.jsonSchema.format = "date-time";
|
|
65
|
+
// Tell the model the exact contract right on the field, so it emits an
|
|
66
|
+
// offset the first time instead of learning via a rejected tool call.
|
|
67
|
+
ctx.jsonSchema.description = ctx.jsonSchema.description
|
|
68
|
+
? `${ctx.jsonSchema.description} ${DATE_FIELD_HINT}`
|
|
69
|
+
: DATE_FIELD_HINT;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}) as JsonSchemaNode;
|
|
73
|
+
lockAdditionalProperties(modelSchema);
|
|
74
|
+
|
|
75
|
+
// Cast: the SDK's `jsonSchema()` wants its JSONSchema7 type; the value above
|
|
76
|
+
// is exactly that JSON Schema object (just typed as our narrower view).
|
|
77
|
+
return jsonSchema<unknown>(modelSchema as Parameters<typeof jsonSchema>[0], {
|
|
78
|
+
validate: (value) => {
|
|
79
|
+
// Enforce the offset contract BEFORE parsing. This must run for
|
|
80
|
+
// `z.coerce.date()` too: Zod's own coercion is `new Date()`, which would
|
|
81
|
+
// happily accept a zone-less string and interpret it server-local - the
|
|
82
|
+
// exact ambiguity we reject. A plain Error (not a ZodError) carries the
|
|
83
|
+
// most readable message back to the model for self-repair.
|
|
84
|
+
const violations = collectDateOffsetIssues(value, input);
|
|
85
|
+
if (violations.length > 0) {
|
|
86
|
+
return { success: false, error: new Error(violations.join(" ")) };
|
|
87
|
+
}
|
|
88
|
+
const result = input.safeParse(coerceDateValues(value, input));
|
|
89
|
+
return result.success
|
|
90
|
+
? { success: true, value: result.data }
|
|
91
|
+
: { success: false, error: result.error };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The ONE entry point for handing a Zod schema to the model. Date-bearing
|
|
98
|
+
* schemas get the date-safe + coercing treatment ({@link dateSafeModelSchema});
|
|
99
|
+
* everything else passes through as raw Zod so the SDK handles it natively.
|
|
100
|
+
*
|
|
101
|
+
* Call this at EVERY model-schema boundary (chat tool inputs, agent-runner tool
|
|
102
|
+
* inputs, `generateObject` output) so the gate can never be wired into some
|
|
103
|
+
* branches and forgotten in others.
|
|
104
|
+
*/
|
|
105
|
+
export function toModelSchema(
|
|
106
|
+
input: z.ZodTypeAny,
|
|
107
|
+
): Schema<unknown> | z.ZodTypeAny {
|
|
108
|
+
return schemaContainsDate(input) ? dateSafeModelSchema(input) : input;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Does any node of this schema declare a `Date`? Only such inputs need the
|
|
113
|
+
* special handling above; everything else stays on the SDK's native path.
|
|
114
|
+
*/
|
|
115
|
+
export function schemaContainsDate(schema: z.ZodTypeAny): boolean {
|
|
116
|
+
if (schema instanceof z.ZodDate) return true;
|
|
117
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
118
|
+
return schemaContainsDate(schema.unwrap() as z.ZodTypeAny);
|
|
119
|
+
}
|
|
120
|
+
if (schema instanceof z.ZodDefault) {
|
|
121
|
+
return schemaContainsDate(schema.def.innerType as z.ZodTypeAny);
|
|
122
|
+
}
|
|
123
|
+
if (schema instanceof z.ZodArray) {
|
|
124
|
+
return schemaContainsDate(
|
|
125
|
+
(schema as z.ZodArray<z.ZodTypeAny>).element,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (schema instanceof z.ZodUnion) {
|
|
129
|
+
return (schema.options as z.ZodTypeAny[]).some((option) =>
|
|
130
|
+
schemaContainsDate(option),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (schema instanceof z.ZodRecord) {
|
|
134
|
+
return schemaContainsDate(schema.valueType as z.ZodTypeAny);
|
|
135
|
+
}
|
|
136
|
+
if ("shape" in schema) {
|
|
137
|
+
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
|
|
138
|
+
return Object.values(shape).some((field) =>
|
|
139
|
+
schemaContainsDate(field as z.ZodTypeAny),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Convert the ISO date strings the model sends into `Date`s at the positions
|
|
147
|
+
* the schema declares as dates. Schema-guided (never touches a plain string
|
|
148
|
+
* field) and value-level (the original schema still validates), so refinements,
|
|
149
|
+
* metadata and unrecognized shapes are preserved untouched.
|
|
150
|
+
*/
|
|
151
|
+
export function coerceDateValues(
|
|
152
|
+
value: unknown,
|
|
153
|
+
schema: z.ZodTypeAny,
|
|
154
|
+
): unknown {
|
|
155
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
156
|
+
return value === null || value === undefined
|
|
157
|
+
? value
|
|
158
|
+
: coerceDateValues(value, schema.unwrap() as z.ZodTypeAny);
|
|
159
|
+
}
|
|
160
|
+
if (schema instanceof z.ZodDefault) {
|
|
161
|
+
return value === undefined
|
|
162
|
+
? value
|
|
163
|
+
: coerceDateValues(value, schema.def.innerType as z.ZodTypeAny);
|
|
164
|
+
}
|
|
165
|
+
if (schema instanceof z.ZodDate) {
|
|
166
|
+
// Only convert strings that carry an explicit offset, so the resulting
|
|
167
|
+
// instant is unambiguous regardless of the pod's local timezone. Anything
|
|
168
|
+
// else is left as-is and is rejected upstream by collectDateOffsetIssues
|
|
169
|
+
// (this branch never silently local-interprets a zone-less string).
|
|
170
|
+
return typeof value === "string" && RFC3339_WITH_OFFSET.test(value)
|
|
171
|
+
? new Date(value)
|
|
172
|
+
: value;
|
|
173
|
+
}
|
|
174
|
+
if (schema instanceof z.ZodArray) {
|
|
175
|
+
if (!Array.isArray(value)) return value;
|
|
176
|
+
const element = (schema as z.ZodArray<z.ZodTypeAny>).element;
|
|
177
|
+
return value.map((item) => coerceDateValues(item, element));
|
|
178
|
+
}
|
|
179
|
+
if (schema instanceof z.ZodUnion) {
|
|
180
|
+
// Coerce against the first option that declares a date; unions of
|
|
181
|
+
// non-overlapping shapes are rare at model boundaries but cheap to support.
|
|
182
|
+
const dateOption = (schema.options as z.ZodTypeAny[]).find((option) =>
|
|
183
|
+
schemaContainsDate(option),
|
|
184
|
+
);
|
|
185
|
+
return dateOption ? coerceDateValues(value, dateOption) : value;
|
|
186
|
+
}
|
|
187
|
+
if (schema instanceof z.ZodRecord) {
|
|
188
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
const valueType = schema.valueType as z.ZodTypeAny;
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
194
|
+
key,
|
|
195
|
+
coerceDateValues(item, valueType),
|
|
196
|
+
]),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if ("shape" in schema) {
|
|
200
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
|
|
204
|
+
const out: Record<string, unknown> = {
|
|
205
|
+
...(value as Record<string, unknown>),
|
|
206
|
+
};
|
|
207
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
208
|
+
if (key in out) {
|
|
209
|
+
out[key] = coerceDateValues(out[key], fieldSchema as z.ZodTypeAny);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Walk a value against its schema and report every date position whose value is
|
|
219
|
+
* NOT an RFC 3339 date-time with an explicit timezone offset (zone-less,
|
|
220
|
+
* date-only, a bare number, or otherwise unparseable). Mirrors
|
|
221
|
+
* {@link coerceDateValues}' traversal so the gate covers exactly the positions
|
|
222
|
+
* the schema declares as dates. Returns one human-readable sentence per
|
|
223
|
+
* violation (consumed by the model for self-repair); an empty array means the
|
|
224
|
+
* value is offset-clean. `undefined`/`null` at optional positions are skipped -
|
|
225
|
+
* a genuinely missing required date is left to the schema's own parse to report.
|
|
226
|
+
*/
|
|
227
|
+
export function collectDateOffsetIssues(
|
|
228
|
+
value: unknown,
|
|
229
|
+
schema: z.ZodTypeAny,
|
|
230
|
+
path: string = "",
|
|
231
|
+
): string[] {
|
|
232
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
|
|
233
|
+
return value === null || value === undefined
|
|
234
|
+
? []
|
|
235
|
+
: collectDateOffsetIssues(value, schema.unwrap() as z.ZodTypeAny, path);
|
|
236
|
+
}
|
|
237
|
+
if (schema instanceof z.ZodDefault) {
|
|
238
|
+
return value === undefined
|
|
239
|
+
? []
|
|
240
|
+
: collectDateOffsetIssues(
|
|
241
|
+
value,
|
|
242
|
+
schema.def.innerType as z.ZodTypeAny,
|
|
243
|
+
path,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (schema instanceof z.ZodDate) {
|
|
247
|
+
if (value === undefined || value === null) return [];
|
|
248
|
+
const where = path ? `\`${path}\`` : "the date value";
|
|
249
|
+
if (typeof value !== "string") {
|
|
250
|
+
return [
|
|
251
|
+
`${where} must be a ${DATE_FIELD_HINT} (got a ${typeof value}).`,
|
|
252
|
+
];
|
|
253
|
+
}
|
|
254
|
+
if (!RFC3339_WITH_OFFSET.test(value)) {
|
|
255
|
+
return [`${where} must be a ${DATE_FIELD_HINT} (got "${value}").`];
|
|
256
|
+
}
|
|
257
|
+
if (Number.isNaN(new Date(value).getTime())) {
|
|
258
|
+
return [`${where} is not a valid calendar date-time (got "${value}").`];
|
|
259
|
+
}
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
if (schema instanceof z.ZodArray) {
|
|
263
|
+
if (!Array.isArray(value)) return [];
|
|
264
|
+
const element = (schema as z.ZodArray<z.ZodTypeAny>).element;
|
|
265
|
+
return value.flatMap((item, i) =>
|
|
266
|
+
collectDateOffsetIssues(item, element, `${path}[${i}]`),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (schema instanceof z.ZodUnion) {
|
|
270
|
+
const dateOption = (schema.options as z.ZodTypeAny[]).find((option) =>
|
|
271
|
+
schemaContainsDate(option),
|
|
272
|
+
);
|
|
273
|
+
return dateOption ? collectDateOffsetIssues(value, dateOption, path) : [];
|
|
274
|
+
}
|
|
275
|
+
if (schema instanceof z.ZodRecord) {
|
|
276
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const valueType = schema.valueType as z.ZodTypeAny;
|
|
280
|
+
return Object.entries(value as Record<string, unknown>).flatMap(
|
|
281
|
+
([key, item]) =>
|
|
282
|
+
collectDateOffsetIssues(
|
|
283
|
+
item,
|
|
284
|
+
valueType,
|
|
285
|
+
path ? `${path}.${key}` : key,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if ("shape" in schema) {
|
|
290
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
|
|
294
|
+
const record = value as Record<string, unknown>;
|
|
295
|
+
return Object.entries(shape).flatMap(([key, fieldSchema]) =>
|
|
296
|
+
key in record
|
|
297
|
+
? collectDateOffsetIssues(
|
|
298
|
+
record[key],
|
|
299
|
+
fieldSchema as z.ZodTypeAny,
|
|
300
|
+
path ? `${path}.${key}` : key,
|
|
301
|
+
)
|
|
302
|
+
: [],
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stamp `additionalProperties: false` on every fixed-property object node,
|
|
310
|
+
* mirroring what the SDK's zod adapter does so strict providers (e.g. OpenAI)
|
|
311
|
+
* accept the schema. Records already carry an `additionalProperties` schema, so
|
|
312
|
+
* they are left untouched.
|
|
313
|
+
*/
|
|
314
|
+
function lockAdditionalProperties(node: JsonSchemaNode): void {
|
|
315
|
+
if (typeof node !== "object" || node === null) return;
|
|
316
|
+
if (node.properties && node.additionalProperties === undefined) {
|
|
317
|
+
node.additionalProperties = false;
|
|
318
|
+
}
|
|
319
|
+
if (node.properties) {
|
|
320
|
+
for (const child of Object.values(node.properties)) {
|
|
321
|
+
if (typeof child === "object") lockAdditionalProperties(child);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (node.items && typeof node.items === "object") {
|
|
325
|
+
const items = node.items;
|
|
326
|
+
if (Array.isArray(items)) {
|
|
327
|
+
for (const item of items) {
|
|
328
|
+
if (typeof item === "object") lockAdditionalProperties(item);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
lockAdditionalProperties(items);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
package/src/chat/sdk-tools.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AuthUser } from "@checkstack/backend-api";
|
|
|
3
3
|
import type { AiPermissionMode, AiFieldDiff } from "@checkstack/ai-common";
|
|
4
4
|
import type { RegisteredAiTool } from "../tool-registry";
|
|
5
5
|
import { decideToolDisposition } from "./permission-mode.logic";
|
|
6
|
+
import { toModelSchema } from "./model-schema";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Result a mutate/destructive tool's `execute` returns to the model in APPROVE
|
|
@@ -134,45 +135,41 @@ export function buildAgentSdkTools({
|
|
|
134
135
|
for (const t of tools) {
|
|
135
136
|
const disposition = decideToolDisposition({ effect: t.effect, mode });
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
execute: async (input: unknown) => {
|
|
142
|
-
await callbacks.enforceBudget(principal);
|
|
143
|
-
return callbacks.runRead({ principal, tool: t, input });
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
138
|
+
// Single model-boundary date handling (see toModelSchema): a raw z.date() /
|
|
139
|
+
// z.coerce.date() input would otherwise make the SDK's Zod->JSON-Schema
|
|
140
|
+
// conversion throw "Date cannot be represented...", crashing the turn.
|
|
141
|
+
const inputSchema = toModelSchema(t.input);
|
|
148
142
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
143
|
+
// Disposition decides ONLY the description note + which callback runs; the
|
|
144
|
+
// tool is constructed in ONE place below so the schema/budget wiring can
|
|
145
|
+
// never drift between branches (the bug this consolidation removes).
|
|
146
|
+
const variant: { note: string; run: (input: unknown) => Promise<unknown> } =
|
|
147
|
+
disposition === "auto-run"
|
|
148
|
+
? {
|
|
149
|
+
note: "",
|
|
150
|
+
run: (input) => callbacks.runRead({ principal, tool: t, input }),
|
|
151
|
+
}
|
|
152
|
+
: disposition === "auto-apply"
|
|
153
|
+
? {
|
|
154
|
+
// AUTO mode + mutate: apply immediately server-side under the SAME
|
|
155
|
+
// propose/apply service (authz re-check + audit) as a human apply.
|
|
156
|
+
note: " (auto-applied immediately in this conversation's auto mode)",
|
|
157
|
+
run: (input) =>
|
|
158
|
+
callbacks.autoApply({ principal, tool: t, input }),
|
|
159
|
+
}
|
|
160
|
+
: {
|
|
161
|
+
// propose: mutate-in-APPROVE or ANY destructive tool. Returns a
|
|
162
|
+
// confirm card; nothing commits until the human applies.
|
|
163
|
+
note: " (requires human confirmation before it takes effect)",
|
|
164
|
+
run: (input) => callbacks.propose({ principal, tool: t, input }),
|
|
165
|
+
};
|
|
164
166
|
|
|
165
|
-
// disposition === "propose": mutate-in-APPROVE or ANY destructive tool. The
|
|
166
|
-
// returned confirm card is what the chat UI renders; nothing is committed
|
|
167
|
-
// until the human applies.
|
|
168
167
|
sdkTools[t.name] = aiTool({
|
|
169
|
-
description: `${t.description}
|
|
170
|
-
inputSchema
|
|
171
|
-
execute: async (
|
|
172
|
-
input: unknown,
|
|
173
|
-
): Promise<ConfirmCardResult | DuplicateToolCallResult> => {
|
|
168
|
+
description: `${t.description}${variant.note}`,
|
|
169
|
+
inputSchema,
|
|
170
|
+
execute: async (input: unknown) => {
|
|
174
171
|
await callbacks.enforceBudget(principal);
|
|
175
|
-
return
|
|
172
|
+
return variant.run(input);
|
|
176
173
|
},
|
|
177
174
|
});
|
|
178
175
|
}
|