theorum 0.1.5 → 0.1.6

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.
@@ -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`: In-harness auto-correction validator (`validate`, optional `extract`, `maxRetries`, `repairGuidance`). If `extract` is omitted, the structured output itself is validated.
173
- - `outputs.streaming`: SSE streaming behaviors (`streamThoughts`, `gateMedia`). `gateMedia` controls whether stream `media` events are held until validation/egress unrelated to a profile output flag.
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`.
package/esm/mod.d.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  * @module
38
38
  */
39
39
  import "./_dnt.polyfills.js";
40
- export { publicError, describeError, TheorumError, toErrorEvent } from './src/guardrails/error.js';
40
+ export { describeError, publicError, TheorumError, 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 { publicError, describeError, TheorumError, toErrorEvent } from './src/guardrails/error.js';
40
+ export { describeError, publicError, TheorumError, 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';
@@ -42,4 +42,4 @@ declare function toErrorEvent(err: unknown): {
42
42
  error: string;
43
43
  errorInternal: string;
44
44
  };
45
- export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, describeError, publicError, TheorumError, toErrorEvent, UPSTREAM_FAILED, };
45
+ export { describeError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, toErrorEvent, UPSTREAM_FAILED, };
@@ -146,4 +146,4 @@ function toErrorEvent(err) {
146
146
  errorInternal: describeError(err),
147
147
  };
148
148
  }
149
- export { PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, describeError, publicError, TheorumError, toErrorEvent, UPSTREAM_FAILED, };
149
+ export { describeError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, 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, describeError, publicError, TheorumError, toErrorEvent, UPSTREAM_FAILED, } from './error.js';
11
+ export { describeError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, 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, describeError, publicError, TheorumError, toErrorEvent, UPSTREAM_FAILED, } from './error.js';
11
+ export { describeError, PUBLIC_ACTION, PUBLIC_CANARY, PUBLIC_FILE_COUNT, PUBLIC_FILE_SIZE, PUBLIC_FILE_TYPE, PUBLIC_GENERIC, PUBLIC_IMAGE_SIZE, PUBLIC_UNAVAILABLE, publicError, TheorumError, 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, 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 (!hasValidatableOutput(validation, latestStructured)) {
55
+ const { validation, generation, latestStructured, request, canRetry } = args;
56
+ if (latestStructured === undefined) {
68
57
  return { action: 'pass' };
69
58
  }
70
- const { candidateOutput, isValid, error } = await evaluateValidationAttempt(validation, latestStructured, request.input?.slots);
71
- if (isValid) {
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, candidateOutput, error, validation.repairGuidance);
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 !== 'tokens') {
86
- yield ev;
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
- yield* yieldBufferedAttemptEvents(state.attemptEvents);
181
+ const alreadyStreamedUserVisible = !egress?.enforce;
182
+ yield* yieldBufferedAttemptEvents(state.attemptEvents, alreadyStreamedUserVisible);
178
183
  }
179
184
  return { status: 'success' };
180
185
  }
@@ -67,12 +67,6 @@ async function* runTurn(req, provider, sink = noopSink()) {
67
67
  gemini,
68
68
  })) {
69
69
  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
70
  if (shouldSkipStreamEvent(event, profile)) {
77
71
  continue;
78
72
  }
@@ -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 };
@@ -7,7 +7,10 @@ function isStepLimitReached(step, maxSteps) {
7
7
  }
8
8
  return step >= maxSteps;
9
9
  }
10
- async function* executeAutonomousStep(args, state, bufferOutputs = false) {
10
+ async function* executeAutonomousStep(args, state, buffer = {
11
+ holdLate: false,
12
+ holdUserVisible: false,
13
+ }) {
11
14
  const { profile, generation, system, provider, gemini } = args;
12
15
  const genForStep = { ...generation, history: state.currentHistory };
13
16
  const pendingTools = [];
@@ -30,7 +33,11 @@ async function* executeAutonomousStep(args, state, bufferOutputs = false) {
30
33
  continue;
31
34
  }
32
35
  recordStepEvent(event, state);
33
- if (!bufferOutputs || event.type === 'tokens') {
36
+ const isUserVisible = event.type === 'thought' || event.type === 'text';
37
+ // Egress must not stream user-visible text before the gate runs.
38
+ // Validation-only may stream thought/text live and only hold structured.
39
+ const streamNow = !buffer.holdLate || event.type === 'tokens' || (isUserVisible && !buffer.holdUserVisible);
40
+ if (streamNow) {
34
41
  yield event;
35
42
  }
36
43
  }
@@ -97,11 +104,12 @@ async function* executeAttempt(args) {
97
104
  let latestStructured;
98
105
  let pendingTools = [];
99
106
  let stepInAttempt = 0;
100
- const shouldBuffer = Boolean(profile.outputs.validation) || Boolean(profile.guardrails.egress?.enforce);
107
+ const holdUserVisible = Boolean(profile.guardrails.egress?.enforce);
108
+ const holdLate = Boolean(profile.outputs.validation) || holdUserVisible;
101
109
  while (!isStepLimitReached(stepInAttempt, generation.maxSteps)) {
102
110
  stepInAttempt++;
103
111
  state.stepCount++;
104
- const stepResult = yield* executeAutonomousStep({ profile, generation, system, provider, gemini }, state, shouldBuffer);
112
+ const stepResult = yield* executeAutonomousStep({ profile, generation, system, provider, gemini }, state, { holdLate, holdUserVisible });
105
113
  if (stepResult.latestStructured !== undefined) {
106
114
  latestStructured = stepResult.latestStructured;
107
115
  }
@@ -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
- extract?: (structured: unknown) => unknown;
144
- validate: ProfileValidator;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theorum",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, dynamic tools, provider adapters, guardrails, and host-injected traces.",
5
5
  "keywords": [
6
6
  "agent",