theorum 0.1.5 → 0.1.7
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/docs/AGENT_PROFILE_CONTRACT.md +3 -2
- package/esm/mod.d.ts +1 -1
- package/esm/mod.js +1 -1
- package/esm/src/guardrails/error.d.ts +7 -1
- package/esm/src/guardrails/error.js +31 -1
- package/esm/src/guardrails/mod.d.ts +1 -1
- package/esm/src/guardrails/mod.js +1 -1
- package/esm/src/kernel/engine/runner/gates.js +29 -23
- package/esm/src/kernel/engine/runner/mod.js +2 -6
- package/esm/src/kernel/engine/runner/schema-validation.d.ts +22 -0
- package/esm/src/kernel/engine/runner/schema-validation.js +113 -0
- package/esm/src/kernel/engine/runner/steps.js +16 -5
- package/esm/src/kernel/engine/runner/stream.d.ts +1 -0
- package/esm/src/kernel/engine/runner/stream.js +5 -2
- package/esm/src/kernel/types.d.ts +13 -2
- package/esm/src/observability/trace-record.js +4 -3
- package/esm/src/providers/keys.js +13 -3
- package/esm/src/providers/openrouter.js +5 -1
- package/esm/src/providers/provider.js +5 -2
- package/esm/src/providers/speech.js +1 -0
- package/package.json +1 -1
|
@@ -169,8 +169,8 @@ export interface Profile {
|
|
|
169
169
|
- `outputs.structured`: Structured JSON schema specification (or slot-based schema routing).
|
|
170
170
|
- `outputs.image`: Pins for an image-role profile (`aspectRatio`, `size`, `mimeType`, optional `allowsGrounding`, `maxInputImages`). The image model itself is selected via `model.allow` / `model.config`. Slot overrides use `slots.aspectRatio` / `slots.size` when the profile lists allowlists under `inputs.slots`. Adapters map `size` to provider wire keys (e.g. Google Interactions `imageSize`).
|
|
171
171
|
- `outputs.speech`: Pins for a speech-role profile (`voice`, optional `format: 'pcm' | 'mp3'`). The speech model itself is selected via `model.allow` / `model.config`. Bind with `createProvider(profile, …)` — same door as chat/image. `geminiInteractions` uses Interactions (`response_format: audio` + `speech_config`); `openAi`/`openrouter` speech roles use `/audio/speech` with the same `openRouter` credentials. `format: 'pcm'` (default) yields WAV media on both. `format: 'mp3'` is only valid on `openAi` speech — Interactions rejects it at resolve.
|
|
172
|
-
- `outputs.validation`:
|
|
173
|
-
- `outputs.streaming`: SSE streaming behaviors (`streamThoughts`, `gateMedia`). `gateMedia`
|
|
172
|
+
- `outputs.validation`: Schema-driven in-harness auto-correction. Required vs optional comes only from the structured JSON Schema. Host `fields` validators (dotted paths such as `diagram.mermaid`) run for required paths and for optional paths that are present. Omitted optional paths are skipped. Setting `validation` without a structured `jsonSchema` is an error.
|
|
173
|
+
- `outputs.streaming`: SSE streaming behaviors (`streamThoughts`, `gateMedia`). `gateMedia` holds stream `media` events until validation/egress. With validation only, thought and text still stream live (structured is held until accepted). With egress enforcement, user-visible thought/text stay buffered until the egress gate passes.
|
|
174
174
|
|
|
175
175
|
### `guardrails`
|
|
176
176
|
- `guardrails.quota.perDay`: Optional daily turn quota enforced per client IP. If omitted, quota enforcement is explicitly `not_configured`.
|
|
@@ -181,6 +181,7 @@ export interface Profile {
|
|
|
181
181
|
|
|
182
182
|
### Per-turn Interactions state
|
|
183
183
|
- `TurnRequest.input`: Optional turn input object. If omitted, Theorum normalizes it to an empty input and still runs the profile/provider turn.
|
|
184
|
+
- `TurnRequest.signal`: Optional `AbortSignal`. When aborted, THEORUM stops the turn and cancels in-flight provider HTTP (Gemini fetch, OpenRouter `abortSignal`, speech fetch). Traces mark `cancelled: true`.
|
|
184
185
|
- `TurnRequest.previousInteractionId`: Optional Google Interactions server-side conversation pointer. Theorum passes it through as `previous_interaction_id` for profiles using `geminiInteractions`.
|
|
185
186
|
- `TurnRequest.store`: Optional Google Interactions storage override. If omitted, Theorum does not send `store`; provider/project policy remains the authority. If supplied, Theorum serializes the explicit boolean.
|
|
186
187
|
|
package/esm/mod.d.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* @module
|
|
38
38
|
*/
|
|
39
39
|
import "./_dnt.polyfills.js";
|
|
40
|
-
export {
|
|
40
|
+
export { describeError, isAbortError, publicError, TheorumError, throwIfAborted, toErrorEvent, } from './src/guardrails/error.js';
|
|
41
41
|
export type { QuotaSlotStatus } from './src/guardrails/quota.js';
|
|
42
42
|
export { clientIp, quotaMessage, releaseSlot, resetSlots, skipQuota, takeSlot, } from './src/guardrails/quota.js';
|
|
43
43
|
export { PROJECT_ID_MAX, sanitizeProjectId, sanitizeText, sanitizeTurnRequest, } from './src/guardrails/sanitize.js';
|
package/esm/mod.js
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* @module
|
|
38
38
|
*/
|
|
39
39
|
import "./_dnt.polyfills.js";
|
|
40
|
-
export {
|
|
40
|
+
export { describeError, isAbortError, publicError, TheorumError, throwIfAborted, toErrorEvent, } from './src/guardrails/error.js';
|
|
41
41
|
export { clientIp, quotaMessage, releaseSlot, resetSlots, skipQuota, takeSlot, } from './src/guardrails/quota.js';
|
|
42
42
|
export { PROJECT_ID_MAX, sanitizeProjectId, sanitizeText, sanitizeTurnRequest, } from './src/guardrails/sanitize.js';
|
|
43
43
|
export { runTurn } from './src/kernel/engine/runner.js';
|
|
@@ -28,6 +28,12 @@ declare const PUBLIC_FILE_SIZE = "That file is too large.";
|
|
|
28
28
|
declare const PUBLIC_FILE_COUNT = "Too many files for one message.";
|
|
29
29
|
/** Safe copy for unsupported generated image dimensions. */
|
|
30
30
|
declare const PUBLIC_IMAGE_SIZE = "That image size isn't supported.";
|
|
31
|
+
/** Safe copy when the host aborts a turn. */
|
|
32
|
+
declare const PUBLIC_CANCELLED = "Cancelled.";
|
|
33
|
+
/** True when `err` is an abort (DOMException or Error named AbortError). */
|
|
34
|
+
declare function isAbortError(err: unknown): boolean;
|
|
35
|
+
/** Throw if `signal` is already aborted. */
|
|
36
|
+
declare function throwIfAborted(signal?: AbortSignal): void;
|
|
31
37
|
/** Convert an unknown thrown value or internal message to user-safe text. */
|
|
32
38
|
declare function publicError(err: unknown): string;
|
|
33
39
|
/** Raw diagnostic text for hosts, traces, and logs (never shown to end users). */
|
|
@@ -42,4 +48,4 @@ declare function toErrorEvent(err: unknown): {
|
|
|
42
48
|
error: string;
|
|
43
49
|
errorInternal: string;
|
|
44
50
|
};
|
|
45
|
-
export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE,
|
|
51
|
+
export { describeError, isAbortError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_CANCELLED, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, throwIfAborted, toErrorEvent, UPSTREAM_FAILED, };
|
|
@@ -31,10 +31,33 @@ const PUBLIC_FILE_SIZE = 'That file is too large.';
|
|
|
31
31
|
const PUBLIC_FILE_COUNT = 'Too many files for one message.';
|
|
32
32
|
/** Safe copy for unsupported generated image dimensions. */
|
|
33
33
|
const PUBLIC_IMAGE_SIZE = "That image size isn't supported.";
|
|
34
|
+
/** Safe copy when the host aborts a turn. */
|
|
35
|
+
const PUBLIC_CANCELLED = 'Cancelled.';
|
|
36
|
+
/** True when `err` is an abort (DOMException or Error named AbortError). */
|
|
37
|
+
function isAbortError(err) {
|
|
38
|
+
if (!err || typeof err !== 'object') {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const name = err.name;
|
|
42
|
+
return name === 'AbortError';
|
|
43
|
+
}
|
|
44
|
+
/** Throw if `signal` is already aborted. */
|
|
45
|
+
function throwIfAborted(signal) {
|
|
46
|
+
if (!signal?.aborted) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const { reason } = signal;
|
|
50
|
+
if (isAbortError(reason)) {
|
|
51
|
+
throw reason;
|
|
52
|
+
}
|
|
53
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
54
|
+
}
|
|
34
55
|
const EXACT = {
|
|
35
56
|
[UPSTREAM_FAILED]: PUBLIC_UNAVAILABLE,
|
|
36
57
|
'empty Gemini stream': PUBLIC_UNAVAILABLE,
|
|
37
58
|
'canary leaked': PUBLIC_CANARY,
|
|
59
|
+
'The operation was aborted.': PUBLIC_CANCELLED,
|
|
60
|
+
'This operation was aborted': PUBLIC_CANCELLED,
|
|
38
61
|
'Turn withheld: egress disclosure violation': PUBLIC_CANARY,
|
|
39
62
|
'expected JSON object': 'Something was wrong with that request.',
|
|
40
63
|
'user input cannot be placed in the system block': PUBLIC_GENERIC,
|
|
@@ -98,8 +121,12 @@ const ALREADY_PUBLIC = new Set([
|
|
|
98
121
|
PUBLIC_FILE_SIZE,
|
|
99
122
|
PUBLIC_FILE_COUNT,
|
|
100
123
|
PUBLIC_IMAGE_SIZE,
|
|
124
|
+
PUBLIC_CANCELLED,
|
|
101
125
|
]);
|
|
102
126
|
function publicText(text) {
|
|
127
|
+
if (/aborted/i.test(text)) {
|
|
128
|
+
return PUBLIC_CANCELLED;
|
|
129
|
+
}
|
|
103
130
|
if (ALREADY_PUBLIC.has(text)) {
|
|
104
131
|
return text;
|
|
105
132
|
}
|
|
@@ -116,6 +143,9 @@ function publicText(text) {
|
|
|
116
143
|
}
|
|
117
144
|
/** Convert an unknown thrown value or internal message to user-safe text. */
|
|
118
145
|
function publicError(err) {
|
|
146
|
+
if (isAbortError(err)) {
|
|
147
|
+
return PUBLIC_CANCELLED;
|
|
148
|
+
}
|
|
119
149
|
if (typeof err === 'string') {
|
|
120
150
|
return publicText(err);
|
|
121
151
|
}
|
|
@@ -146,4 +176,4 @@ function toErrorEvent(err) {
|
|
|
146
176
|
errorInternal: describeError(err),
|
|
147
177
|
};
|
|
148
178
|
}
|
|
149
|
-
export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE,
|
|
179
|
+
export { describeError, isAbortError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_CANCELLED, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, throwIfAborted, toErrorEvent, UPSTREAM_FAILED, };
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import "../../_dnt.polyfills.js";
|
|
11
|
-
export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE,
|
|
11
|
+
export { describeError, isAbortError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_CANCELLED, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, throwIfAborted, toErrorEvent, UPSTREAM_FAILED, } from './error.js';
|
|
12
12
|
export { injectionSpans } from './injection.js';
|
|
13
13
|
export type { QuotaSlotStatus } from './quota.js';
|
|
14
14
|
export { clientIp, quotaMessage, releaseSlot, resetSlots, skipQuota, takeSlot, } from './quota.js';
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import "../../_dnt.polyfills.js";
|
|
11
|
-
export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE,
|
|
11
|
+
export { describeError, isAbortError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_CANCELLED, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, throwIfAborted, toErrorEvent, UPSTREAM_FAILED, } from './error.js';
|
|
12
12
|
export { injectionSpans } from './injection.js';
|
|
13
13
|
export { clientIp, quotaMessage, releaseSlot, resetSlots, skipQuota, takeSlot, } from './quota.js';
|
|
14
14
|
export { PROJECT_ID_MAX, sanitizeProjectId, sanitizeText, sanitizeTurnRequest, } from './sanitize.js';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { toErrorEvent } from '../../../guardrails/error.js';
|
|
1
|
+
import { TheorumError, throwIfAborted, toErrorEvent } from '../../../guardrails/error.js';
|
|
2
2
|
import { sanitizeTurnRequest } from '../../../guardrails/sanitize.js';
|
|
3
3
|
import { resolveTurn } from '../../registry/resolve.js';
|
|
4
|
+
import { getStructured } from '../../registry/schemas.js';
|
|
5
|
+
import { collectValidationFailures, formatValidationFailures } from './schema-validation.js';
|
|
4
6
|
import { executeAttempt } from './steps.js';
|
|
5
7
|
function collectAttemptText(events) {
|
|
6
8
|
return events
|
|
@@ -21,19 +23,6 @@ function buildRepairRequest(safe, previousOutput, rejection, repairGuidance) {
|
|
|
21
23
|
},
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
|
-
function hasValidatableOutput(validation, latestStructured) {
|
|
25
|
-
if (!validation || latestStructured === undefined) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
const candidateOutput = validation.extract?.(latestStructured) ?? latestStructured;
|
|
29
|
-
return candidateOutput !== undefined && candidateOutput !== null;
|
|
30
|
-
}
|
|
31
|
-
async function evaluateValidationAttempt(validation, latestStructured, slots) {
|
|
32
|
-
const candidateOutput = validation.extract?.(latestStructured) ?? latestStructured;
|
|
33
|
-
const check = await validation.validate(candidateOutput, slots);
|
|
34
|
-
const error = check.error || check.finding || 'Validation failed';
|
|
35
|
-
return { candidateOutput, isValid: Boolean(check.isValid), error };
|
|
36
|
-
}
|
|
37
26
|
async function evaluateEgressOutcome(args) {
|
|
38
27
|
const { egress, attemptEvents, generation, request, profile, canRetry } = args;
|
|
39
28
|
const attemptText = collectAttemptText(attemptEvents);
|
|
@@ -63,16 +52,25 @@ async function evaluateEgressOutcome(args) {
|
|
|
63
52
|
};
|
|
64
53
|
}
|
|
65
54
|
async function evaluateValidationOutcome(args) {
|
|
66
|
-
const { validation, latestStructured, request, canRetry } = args;
|
|
67
|
-
if (
|
|
55
|
+
const { validation, generation, latestStructured, request, canRetry } = args;
|
|
56
|
+
if (latestStructured === undefined) {
|
|
68
57
|
return { action: 'pass' };
|
|
69
58
|
}
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
59
|
+
const structuredId = generation.structured;
|
|
60
|
+
if (!structuredId) {
|
|
61
|
+
throw new TheorumError('outputs.validation requires outputs.structured with a JSON Schema');
|
|
62
|
+
}
|
|
63
|
+
const spec = getStructured(structuredId);
|
|
64
|
+
if (!spec.jsonSchema) {
|
|
65
|
+
throw new TheorumError(`structured schema '${structuredId}' has no jsonSchema for validation`);
|
|
66
|
+
}
|
|
67
|
+
const failures = await collectValidationFailures(spec.jsonSchema, latestStructured, validation.fields, request.input?.slots);
|
|
68
|
+
if (failures.length === 0) {
|
|
72
69
|
return { action: 'pass' };
|
|
73
70
|
}
|
|
71
|
+
const error = formatValidationFailures(failures);
|
|
74
72
|
if (canRetry) {
|
|
75
|
-
const nextRequest = buildRepairRequest(request,
|
|
73
|
+
const nextRequest = buildRepairRequest(request, latestStructured, error, validation.repairGuidance);
|
|
76
74
|
return { action: 'retry', nextRequest };
|
|
77
75
|
}
|
|
78
76
|
return {
|
|
@@ -80,11 +78,16 @@ async function evaluateValidationOutcome(args) {
|
|
|
80
78
|
event: { type: 'structured', structured: latestStructured },
|
|
81
79
|
};
|
|
82
80
|
}
|
|
83
|
-
function* yieldBufferedAttemptEvents(events) {
|
|
81
|
+
function* yieldBufferedAttemptEvents(events, alreadyStreamedUserVisible) {
|
|
84
82
|
for (const ev of events) {
|
|
85
|
-
if (ev.type
|
|
86
|
-
|
|
83
|
+
if (ev.type === 'tokens') {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// When validation-only, thought/text already streamed live.
|
|
87
|
+
if (alreadyStreamedUserVisible && (ev.type === 'thought' || ev.type === 'text')) {
|
|
88
|
+
continue;
|
|
87
89
|
}
|
|
90
|
+
yield ev;
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
function updateFlowForRetry(flow, nextReq) {
|
|
@@ -121,6 +124,7 @@ async function* handleValidationGate(validation, flow, state, latestStructured,
|
|
|
121
124
|
const canRetry = flow.currentAttempt < maxRetries;
|
|
122
125
|
const outcome = await evaluateValidationOutcome({
|
|
123
126
|
validation,
|
|
127
|
+
generation: flow.currentGen,
|
|
124
128
|
latestStructured,
|
|
125
129
|
request: flow.currentReq,
|
|
126
130
|
canRetry,
|
|
@@ -174,7 +178,8 @@ async function* executeSingleAttemptCycle(args) {
|
|
|
174
178
|
}
|
|
175
179
|
}
|
|
176
180
|
if (validation || egress?.enforce) {
|
|
177
|
-
|
|
181
|
+
const alreadyStreamedUserVisible = !egress?.enforce;
|
|
182
|
+
yield* yieldBufferedAttemptEvents(state.attemptEvents, alreadyStreamedUserVisible);
|
|
178
183
|
}
|
|
179
184
|
return { status: 'success' };
|
|
180
185
|
}
|
|
@@ -186,6 +191,7 @@ async function* runAttemptsWithValidation(safe, profile, generation, system, pro
|
|
|
186
191
|
currentReq: safe,
|
|
187
192
|
};
|
|
188
193
|
while (flow.currentAttempt <= maxRetries) {
|
|
194
|
+
throwIfAborted(safe.signal);
|
|
189
195
|
const step = yield* executeSingleAttemptCycle({
|
|
190
196
|
flow,
|
|
191
197
|
state,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
|
+
import { throwIfAborted } from '../../../guardrails/error.js';
|
|
10
11
|
import { sanitizeTurnRequest } from '../../../guardrails/sanitize.js';
|
|
11
12
|
import { noopSink, writeTrace } from '../../../observability/trace.js';
|
|
12
13
|
import { buildRecord } from '../../../observability/trace-record.js';
|
|
@@ -47,6 +48,7 @@ async function* runTurn(req, provider, sink = noopSink()) {
|
|
|
47
48
|
let generation;
|
|
48
49
|
try {
|
|
49
50
|
const safe = sanitizeTurnRequest(req);
|
|
51
|
+
throwIfAborted(safe.signal);
|
|
50
52
|
const { profile, generation: gen } = resolveTurn(safe);
|
|
51
53
|
generation = gen;
|
|
52
54
|
const { model: resolvedModel, geminiBucket, canary: turnCanary } = gen;
|
|
@@ -67,12 +69,6 @@ async function* runTurn(req, provider, sink = noopSink()) {
|
|
|
67
69
|
gemini,
|
|
68
70
|
})) {
|
|
69
71
|
seen.push(event);
|
|
70
|
-
if (event.type === 'error') {
|
|
71
|
-
const detail = event.errorInternal ?? event.error;
|
|
72
|
-
if (detail) {
|
|
73
|
-
console.error(`[theorum] turn error (${req.profile}): ${detail}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
72
|
if (shouldSkipStreamEvent(event, profile)) {
|
|
77
73
|
continue;
|
|
78
74
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven structured-output validation.
|
|
3
|
+
*
|
|
4
|
+
* Required vs optional comes only from the JSON Schema. Host field validators
|
|
5
|
+
* run for required paths and for optional paths that are present.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import type { ProfileValidator } from '../../types.js';
|
|
10
|
+
interface ValidationFailure {
|
|
11
|
+
path: string;
|
|
12
|
+
error: string;
|
|
13
|
+
}
|
|
14
|
+
declare function isAbsent(value: unknown): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Collect schema presence failures and host field-validator failures.
|
|
17
|
+
* Throws when the root schema is not an object schema.
|
|
18
|
+
*/
|
|
19
|
+
declare function collectValidationFailures(jsonSchema: Record<string, unknown>, structured: unknown, fields: Record<string, ProfileValidator> | undefined, slots?: Record<string, string>): Promise<ValidationFailure[]>;
|
|
20
|
+
declare function formatValidationFailures(failures: ValidationFailure[]): string;
|
|
21
|
+
export type { ValidationFailure };
|
|
22
|
+
export { collectValidationFailures, formatValidationFailures, isAbsent };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven structured-output validation.
|
|
3
|
+
*
|
|
4
|
+
* Required vs optional comes only from the JSON Schema. Host field validators
|
|
5
|
+
* run for required paths and for optional paths that are present.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { TheorumError } from '../../../guardrails/error.js';
|
|
10
|
+
function isAbsent(value) {
|
|
11
|
+
return value === undefined || value === null;
|
|
12
|
+
}
|
|
13
|
+
function asRecord(value) {
|
|
14
|
+
if (isAbsent(value) || typeof value !== 'object' || Array.isArray(value)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function asObjectSchema(schema) {
|
|
20
|
+
const rec = asRecord(schema);
|
|
21
|
+
if (!rec) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (rec.type !== undefined && rec.type !== 'object') {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return rec;
|
|
28
|
+
}
|
|
29
|
+
function requiredKeys(schema) {
|
|
30
|
+
const raw = schema.required;
|
|
31
|
+
if (!Array.isArray(raw)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return raw.filter((k) => typeof k === 'string');
|
|
35
|
+
}
|
|
36
|
+
function propertySchemas(schema) {
|
|
37
|
+
const props = schema.properties;
|
|
38
|
+
return asRecord(props) ?? {};
|
|
39
|
+
}
|
|
40
|
+
function joinPath(prefix, key) {
|
|
41
|
+
return prefix ? `${prefix}.${key}` : key;
|
|
42
|
+
}
|
|
43
|
+
function pushMissing(path, failures) {
|
|
44
|
+
failures.push({ path, error: `required field '${path}' is missing` });
|
|
45
|
+
}
|
|
46
|
+
async function runFieldValidator(path, value, validator, slots, failures) {
|
|
47
|
+
if (!validator) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const check = await validator(value, slots);
|
|
51
|
+
if (check.isValid) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
failures.push({
|
|
55
|
+
path,
|
|
56
|
+
error: check.error || check.finding || `Validation failed for '${path}'`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function visitProperty(args) {
|
|
60
|
+
const { path, propSchema, child, isRequired, fields, slots, failures } = args;
|
|
61
|
+
if (isAbsent(child)) {
|
|
62
|
+
if (isRequired) {
|
|
63
|
+
pushMissing(path, failures);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await runFieldValidator(path, child, fields?.[path], slots, failures);
|
|
68
|
+
const nested = asObjectSchema(propSchema);
|
|
69
|
+
const nestedValue = nested ? asRecord(child) : null;
|
|
70
|
+
if (nested && nestedValue) {
|
|
71
|
+
await walkObject(nested, nestedValue, path, fields, slots, failures);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function walkObject(schema, value, pathPrefix, fields, slots, failures) {
|
|
75
|
+
const props = propertySchemas(schema);
|
|
76
|
+
const required = new Set(requiredKeys(schema));
|
|
77
|
+
const keys = new Set([...Object.keys(props), ...required]);
|
|
78
|
+
const record = asRecord(value);
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
await visitProperty({
|
|
81
|
+
path: joinPath(pathPrefix, key),
|
|
82
|
+
propSchema: props[key],
|
|
83
|
+
child: record?.[key],
|
|
84
|
+
isRequired: required.has(key),
|
|
85
|
+
fields,
|
|
86
|
+
slots,
|
|
87
|
+
failures,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Collect schema presence failures and host field-validator failures.
|
|
93
|
+
* Throws when the root schema is not an object schema.
|
|
94
|
+
*/
|
|
95
|
+
async function collectValidationFailures(jsonSchema, structured, fields, slots) {
|
|
96
|
+
const root = asObjectSchema(jsonSchema);
|
|
97
|
+
if (!root) {
|
|
98
|
+
throw new TheorumError('structured validation requires a JSON Schema object root');
|
|
99
|
+
}
|
|
100
|
+
const failures = [];
|
|
101
|
+
if (!asRecord(structured)) {
|
|
102
|
+
for (const key of requiredKeys(root)) {
|
|
103
|
+
pushMissing(key, failures);
|
|
104
|
+
}
|
|
105
|
+
return failures;
|
|
106
|
+
}
|
|
107
|
+
await walkObject(root, structured, '', fields, slots, failures);
|
|
108
|
+
return failures;
|
|
109
|
+
}
|
|
110
|
+
function formatValidationFailures(failures) {
|
|
111
|
+
return failures.map((f) => f.error).join('; ');
|
|
112
|
+
}
|
|
113
|
+
export { collectValidationFailures, formatValidationFailures, isAbsent };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { throwIfAborted } from '../../../guardrails/error.js';
|
|
1
2
|
import { recordStepEvent } from './state.js';
|
|
2
3
|
import { yieldProviderEvents } from './stream.js';
|
|
3
4
|
import { executeDynamicDeclaration, findDynamicDeclaration, formatToolFinding, isActionableDynamicDeclaration, } from './tools.js';
|
|
@@ -7,8 +8,11 @@ function isStepLimitReached(step, maxSteps) {
|
|
|
7
8
|
}
|
|
8
9
|
return step >= maxSteps;
|
|
9
10
|
}
|
|
10
|
-
async function* executeAutonomousStep(args, state,
|
|
11
|
-
|
|
11
|
+
async function* executeAutonomousStep(args, state, buffer = {
|
|
12
|
+
holdLate: false,
|
|
13
|
+
holdUserVisible: false,
|
|
14
|
+
}) {
|
|
15
|
+
const { profile, generation, system, provider, gemini, signal } = args;
|
|
12
16
|
const genForStep = { ...generation, history: state.currentHistory };
|
|
13
17
|
const pendingTools = [];
|
|
14
18
|
let latestStructured;
|
|
@@ -18,6 +22,7 @@ async function* executeAutonomousStep(args, state, bufferOutputs = false) {
|
|
|
18
22
|
system,
|
|
19
23
|
provider,
|
|
20
24
|
gemini,
|
|
25
|
+
signal,
|
|
21
26
|
})) {
|
|
22
27
|
if (event.type === 'structured') {
|
|
23
28
|
latestStructured = event.structured;
|
|
@@ -30,7 +35,11 @@ async function* executeAutonomousStep(args, state, bufferOutputs = false) {
|
|
|
30
35
|
continue;
|
|
31
36
|
}
|
|
32
37
|
recordStepEvent(event, state);
|
|
33
|
-
|
|
38
|
+
const isUserVisible = event.type === 'thought' || event.type === 'text';
|
|
39
|
+
// Egress must not stream user-visible text before the gate runs.
|
|
40
|
+
// Validation-only may stream thought/text live and only hold structured.
|
|
41
|
+
const streamNow = !buffer.holdLate || event.type === 'tokens' || (isUserVisible && !buffer.holdUserVisible);
|
|
42
|
+
if (streamNow) {
|
|
34
43
|
yield event;
|
|
35
44
|
}
|
|
36
45
|
}
|
|
@@ -97,11 +106,13 @@ async function* executeAttempt(args) {
|
|
|
97
106
|
let latestStructured;
|
|
98
107
|
let pendingTools = [];
|
|
99
108
|
let stepInAttempt = 0;
|
|
100
|
-
const
|
|
109
|
+
const holdUserVisible = Boolean(profile.guardrails.egress?.enforce);
|
|
110
|
+
const holdLate = Boolean(profile.outputs.validation) || holdUserVisible;
|
|
101
111
|
while (!isStepLimitReached(stepInAttempt, generation.maxSteps)) {
|
|
112
|
+
throwIfAborted(args.safe.signal);
|
|
102
113
|
stepInAttempt++;
|
|
103
114
|
state.stepCount++;
|
|
104
|
-
const stepResult = yield* executeAutonomousStep({ profile, generation, system, provider, gemini }, state,
|
|
115
|
+
const stepResult = yield* executeAutonomousStep({ profile, generation, system, provider, gemini, signal: args.safe.signal }, state, { holdLate, holdUserVisible });
|
|
105
116
|
if (stepResult.latestStructured !== undefined) {
|
|
106
117
|
latestStructured = stepResult.latestStructured;
|
|
107
118
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { publicError, toErrorEvent } from '../../../guardrails/error.js';
|
|
1
|
+
import { publicError, throwIfAborted, toErrorEvent } from '../../../guardrails/error.js';
|
|
2
2
|
import { providerCompleteRequest } from '../../registry/provider-request.js';
|
|
3
3
|
import { eventHasCanary, redactCanary } from '../boundary.js';
|
|
4
4
|
import { dispatchModelTool } from './tools.js';
|
|
@@ -51,14 +51,17 @@ function* processNormalEvent(event, profile, generation) {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
async function* yieldProviderEvents(args) {
|
|
54
|
-
const { profile, generation, system, provider, gemini } = args;
|
|
54
|
+
const { profile, generation, system, provider, gemini, signal } = args;
|
|
55
55
|
const { canary } = generation;
|
|
56
|
+
throwIfAborted(signal);
|
|
56
57
|
for await (const event of provider.complete({
|
|
57
58
|
...providerCompleteRequest(generation, system),
|
|
59
|
+
signal,
|
|
58
60
|
tapGemini: (row) => {
|
|
59
61
|
gemini.push(row);
|
|
60
62
|
},
|
|
61
63
|
})) {
|
|
64
|
+
throwIfAborted(signal);
|
|
62
65
|
if (canary && eventHasCanary(event, canary)) {
|
|
63
66
|
yield redactCanary(event, canary);
|
|
64
67
|
yield toErrorEvent('canary leaked');
|
|
@@ -140,8 +140,12 @@ export interface ValidationResult {
|
|
|
140
140
|
export type ProfileValidator = (candidate: unknown, slots?: Record<string, string>) => ValidationResult | Promise<ValidationResult>;
|
|
141
141
|
/** Profile output validation and deterministic repair configuration. */
|
|
142
142
|
export interface ProfileValidationSpec {
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Host domain validators keyed by dotted paths into structured output
|
|
145
|
+
* (e.g. `diagram.mermaid`). Presence/required is owned by the JSON Schema;
|
|
146
|
+
* these run only for required paths and for optional paths that are present.
|
|
147
|
+
*/
|
|
148
|
+
fields?: Record<string, ProfileValidator>;
|
|
145
149
|
maxRetries?: number;
|
|
146
150
|
repairGuidance?: string;
|
|
147
151
|
}
|
|
@@ -383,6 +387,11 @@ export interface TurnRequest {
|
|
|
383
387
|
dynamicToolLoader?: DynamicToolLoader;
|
|
384
388
|
/** Host-owned metadata preserved for traces; the kernel does not interpret it. */
|
|
385
389
|
metadata?: Record<string, unknown>;
|
|
390
|
+
/**
|
|
391
|
+
* Optional abort signal. When aborted, THEORUM stops the turn and cancels
|
|
392
|
+
* in-flight provider HTTP where the adapter supports it.
|
|
393
|
+
*/
|
|
394
|
+
signal?: AbortSignal;
|
|
386
395
|
input?: TurnInput;
|
|
387
396
|
toolInvoke?: {
|
|
388
397
|
name: CustomToolId;
|
|
@@ -517,6 +526,8 @@ export interface ProviderCompleteRequest extends ProviderGenerationConfig {
|
|
|
517
526
|
geminiBucket?: GeminiBucket;
|
|
518
527
|
/** Scrubbed SSE / HTTP rows for traces. */
|
|
519
528
|
tapGemini?: (row: Record<string, unknown>) => void;
|
|
529
|
+
/** Host abort signal — adapters should pass this into fetch / SDK calls. */
|
|
530
|
+
signal?: AbortSignal;
|
|
520
531
|
}
|
|
521
532
|
/** Minimal adapter contract every model provider must implement. */
|
|
522
533
|
export interface ModelProvider {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { publicError } from '../guardrails/error.js';
|
|
9
|
+
import { isAbortError, publicError } from '../guardrails/error.js';
|
|
10
10
|
import { sanitizeText, sanitizeTurnRequest } from '../guardrails/sanitize.js';
|
|
11
11
|
import { OMIT_CANARY } from '../kernel/engine/boundary.js';
|
|
12
12
|
import { sha256 } from '../kernel/engine/hash.js';
|
|
@@ -109,14 +109,15 @@ async function buildRecord(args) {
|
|
|
109
109
|
const lastErr = [...snapped].reverse().find((row) => row.type === 'error');
|
|
110
110
|
const done = completedInteraction(gemini);
|
|
111
111
|
const status = done?.status;
|
|
112
|
-
const
|
|
112
|
+
const aborted = isAbortError(thrown);
|
|
113
|
+
const ok = !(thrown || lastErr) && status !== 'cancelled' && !aborted;
|
|
113
114
|
const record = {
|
|
114
115
|
v: TRACE_VERSION,
|
|
115
116
|
id: crypto.randomUUID(),
|
|
116
117
|
ts: started,
|
|
117
118
|
ms: Date.now() - started,
|
|
118
119
|
streamed: true,
|
|
119
|
-
cancelled: status === 'cancelled',
|
|
120
|
+
cancelled: status === 'cancelled' || aborted,
|
|
120
121
|
previousInteractionId: safe.previousInteractionId ?? null,
|
|
121
122
|
store: safe.store ?? null,
|
|
122
123
|
profile: safe.profile,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import { TheorumError, UPSTREAM_FAILED } from '../guardrails/error.js';
|
|
9
|
+
import { isAbortError, TheorumError, UPSTREAM_FAILED } from '../guardrails/error.js';
|
|
10
10
|
const ATTEMPTS = 3;
|
|
11
11
|
const LAST_ATTEMPT = ATTEMPTS - 1;
|
|
12
12
|
const BACKOFF_FIRST_MS = 1000;
|
|
@@ -39,6 +39,9 @@ function isTransientHttp(status) {
|
|
|
39
39
|
status === HTTP_GATEWAY_TIMEOUT);
|
|
40
40
|
}
|
|
41
41
|
function isTransientThrown(err) {
|
|
42
|
+
if (isAbortError(err)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
42
45
|
return TRANSIENT_THROWN_RE.test(String(err));
|
|
43
46
|
}
|
|
44
47
|
function requireKey(vault, bucket) {
|
|
@@ -56,7 +59,9 @@ async function runWithBackoff(apiKey, run, wait, attempt) {
|
|
|
56
59
|
return await run(apiKey);
|
|
57
60
|
}
|
|
58
61
|
catch (err) {
|
|
59
|
-
if (
|
|
62
|
+
if (isAbortError(err) ||
|
|
63
|
+
!(isQuota(err) || isTransientThrown(err)) ||
|
|
64
|
+
attempt === LAST_ATTEMPT) {
|
|
60
65
|
throw err;
|
|
61
66
|
}
|
|
62
67
|
await wait(backoffMs(attempt));
|
|
@@ -103,11 +108,16 @@ async function fetchWithBackoff(args) {
|
|
|
103
108
|
if (!isTransientHttp(last.status) || args.attempt === LAST_ATTEMPT) {
|
|
104
109
|
return last;
|
|
105
110
|
}
|
|
111
|
+
if (args.init.signal?.aborted) {
|
|
112
|
+
throw args.init.signal.reason instanceof Error
|
|
113
|
+
? args.init.signal.reason
|
|
114
|
+
: new DOMException('The operation was aborted.', 'AbortError');
|
|
115
|
+
}
|
|
106
116
|
await wait(backoffMs(args.attempt));
|
|
107
117
|
return fetchWithBackoff({ ...args, attempt: args.attempt + 1 });
|
|
108
118
|
}
|
|
109
119
|
catch (err) {
|
|
110
|
-
if (!isTransientThrown(err) || args.attempt === LAST_ATTEMPT) {
|
|
120
|
+
if (isAbortError(err) || !isTransientThrown(err) || args.attempt === LAST_ATTEMPT) {
|
|
111
121
|
throw err;
|
|
112
122
|
}
|
|
113
123
|
await wait(backoffMs(args.attempt));
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
11
11
|
import { jsonSchema, streamText, tool, } from 'ai';
|
|
12
|
-
import { toErrorEvent } from '../guardrails/error.js';
|
|
12
|
+
import { isAbortError, toErrorEvent } from '../guardrails/error.js';
|
|
13
13
|
import { tryStructured } from '../kernel/engine/delta.js';
|
|
14
14
|
import { getTool } from '../kernel/registry/catalog.js';
|
|
15
15
|
import { resolveOpenRouterModel, toOpenRouterPayload, } from './openrouter-payload.js';
|
|
@@ -499,6 +499,7 @@ function streamTextOptions(req, context) {
|
|
|
499
499
|
tools: buildTools(req.dynamicTools),
|
|
500
500
|
providerOptions: providerOptionsFor(req),
|
|
501
501
|
includeRawChunks: true,
|
|
502
|
+
abortSignal: req.signal,
|
|
502
503
|
onError: () => undefined,
|
|
503
504
|
};
|
|
504
505
|
}
|
|
@@ -544,6 +545,9 @@ async function* streamOpenRouter(req, config) {
|
|
|
544
545
|
yield* finalEvents(req, acc);
|
|
545
546
|
}
|
|
546
547
|
catch (err) {
|
|
548
|
+
if (isAbortError(err)) {
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
547
551
|
yield* yieldCapturedRawEventsUnchecked(context);
|
|
548
552
|
yield toErrorEvent(err);
|
|
549
553
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
-
import { TheorumError, toErrorEvent } from '../guardrails/error.js';
|
|
11
|
+
import { isAbortError, TheorumError, toErrorEvent } from '../guardrails/error.js';
|
|
12
12
|
import { eventsFromComplete, eventsFromDelta, extractTokenEvent, groundingFromEvent, tryStructured, } from '../kernel/engine/delta.js';
|
|
13
13
|
import { tapFetch } from './google-tap.js';
|
|
14
14
|
import { toInteractionsBody } from './interactions.js';
|
|
@@ -121,7 +121,7 @@ async function* streamComplete(req, transport) {
|
|
|
121
121
|
yield toErrorEvent('missing Gemini vault bucket for Interactions');
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
-
const res = await fetchGemini(INTERACTIONS_URL, { method: 'POST', body: JSON.stringify(toInteractionsBody(req)) }, req.geminiBucket, withTap(req, transport));
|
|
124
|
+
const res = await fetchGemini(INTERACTIONS_URL, { method: 'POST', body: JSON.stringify(toInteractionsBody(req)), signal: req.signal }, req.geminiBucket, withTap(req, transport));
|
|
125
125
|
if (res.status !== HTTP_OK) {
|
|
126
126
|
const errorBody = await res.text().catch(() => '');
|
|
127
127
|
yield toErrorEvent(`Gemini HTTP ${String(res.status)}: ${errorBody}`);
|
|
@@ -148,6 +148,9 @@ async function* streamGuarded(req, transport) {
|
|
|
148
148
|
yield* streamComplete(req, transport);
|
|
149
149
|
}
|
|
150
150
|
catch (err) {
|
|
151
|
+
if (isAbortError(err)) {
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
151
154
|
yield toErrorEvent(err);
|
|
152
155
|
}
|
|
153
156
|
}
|
|
@@ -68,6 +68,7 @@ async function requestSpeech(apiKey, text, req, config) {
|
|
|
68
68
|
method: 'POST',
|
|
69
69
|
headers: buildHeaders(apiKey, config),
|
|
70
70
|
body: JSON.stringify(buildPayload(req, text, req.speech, config.voice)),
|
|
71
|
+
signal: req.signal,
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
function* yieldSpeechSuccess(rawBytes, text, format) {
|
package/package.json
CHANGED