@theoremjs/web 0.3.4-next.544.gb5ef325 → 0.3.4-next.550.gf20b9cd

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/README.md CHANGED
@@ -236,6 +236,7 @@ const transport = withPageToolDispatch(
236
236
  | `provider` | Host options, including the credential vault. A function can return these options per request, for example for tenant-specific credentials. |
237
237
  | `session` | Returns the session id of the caller, for example `${userId}:${conversationId}`. Return `undefined` to refuse the request with a 401. Default: a random id in an HttpOnly, SameSite=Lax cookie that lasts 30 days. |
238
238
  | `host` | App data for the tool handlers (`ctx.host`), such as the signed-in user. |
239
+ | `prepare` | Your app's own turn, from the one the handler builds (`TurnWiring.prepare` from `@theoremjs/agents`). Set what your app sets per turn, such as `system`, `credentials`, `onStage` and `onAgentCall`; read your app data as `turn.host`. The handler keeps its profile, its tool execution and its abort. Your `onStage` runs before the handler's steering, and your `credentials` are read before the session's. |
239
240
  | `pageToolBroker` | Delivers results to active page dispatches. Default: bounded process memory. |
240
241
  | `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
241
242
  | `sessionStore`, `steerInbox` | Where open gates are kept, and where steers and stops reach a running turn. Default: process memory, which reaches only turns this process runs. On more than one instance, give a shared `SteerInbox`. |
package/client/chat.d.ts CHANGED
@@ -59,9 +59,17 @@ export type ChatOptions = {
59
59
  * (`sent`), reached the model (`delivered`), answered (`read`).
60
60
  */
61
61
  export type MessageDelivery = 'sending' | 'sent' | 'delivered' | 'read';
62
+ /** One declared slot as the composer shows it (group label + choices with optional blurbs). */
63
+ export type ChatSlotDecl = {
64
+ label?: string;
65
+ choices: readonly {
66
+ value: string;
67
+ description?: string;
68
+ }[];
69
+ };
62
70
  /** The slots the profile declares, and the value of each: the page's, else the picked one, else the first allowed. */
63
71
  export type ChatSlots = {
64
- declared: Record<string, string[]>;
72
+ declared: Record<string, ChatSlotDecl>;
65
73
  values: Record<string, string>;
66
74
  };
67
75
  /** Everything a chat shows. A new object on each change, the same one between changes. */
package/client/chat.js CHANGED
@@ -48,8 +48,9 @@ function withTurnError(blocks, error) {
48
48
  function slotsOf(iface, fixed, picked) {
49
49
  const declared = iface?.inputs.slots ?? {};
50
50
  const values = { ...fixed };
51
- for (const [name, allowed] of Object.entries(declared)) {
52
- const value = fixed?.[name] ?? picked[name] ?? allowed[0];
51
+ for (const [name, decl] of Object.entries(declared)) {
52
+ const first = decl.choices[0]?.value;
53
+ const value = fixed?.[name] ?? picked[name] ?? first;
53
54
  if (value !== undefined)
54
55
  values[name] = value;
55
56
  }
@@ -5,6 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import { type DecisionAsset, type DecisionJson, type DecisionProfile, type DecisionQuestion, type DecisionResult, type TraceRecord } from '@theoremjs/agents';
8
+ import type { JsonSchema } from './schema-fields.ts';
8
9
  import type { TraceFeed } from './trace-feed.ts';
9
10
  import { type HttpOptions } from './transport.ts';
10
11
  /** One question as the page sees it: its answer type and the names of its options or levels. Instructions stay on the host. */
@@ -24,13 +25,27 @@ export type DecisionInterface = {
24
25
  };
25
26
  contract: string;
26
27
  maxStateBytes?: number;
28
+ /** The questions every decision asks. Empty when the host builds them for each decision. */
27
29
  questions: DecisionQuestionView[];
30
+ /**
31
+ * Set when the host builds the questions for each decision, from its state and, when `input`
32
+ * is set, from what the page fills in by that JSON Schema. Each result names the questions it answers.
33
+ */
34
+ build?: {
35
+ input?: JsonSchema;
36
+ };
28
37
  /** The host's own images, by the reference its questions write, sent with every decision. */
29
38
  images?: Record<string, DecisionAsset>;
30
39
  observability?: {
31
40
  record: boolean;
32
41
  };
33
42
  };
43
+ /** A decision's result: the answers and, when the host built its questions for it, those questions. */
44
+ export type DecisionReplyResult = DecisionResult & {
45
+ questions?: DecisionQuestionView[];
46
+ };
47
+ /** Questions as the page sees them: their answer types and labels, never their instructions. */
48
+ export declare function questionViews(questions: Record<string, DecisionQuestion>): DecisionQuestionView[];
34
49
  /** The page's view of a decision profile, the questions its host asks, and the host's own images. */
35
50
  export declare function decisionInterface(profile: Pick<DecisionProfile, 'identity' | 'decision' | 'inputs' | 'observability'>, questions: Record<string, DecisionQuestion>, images?: Record<string, DecisionAsset>): DecisionInterface;
36
51
  /**
@@ -40,13 +55,13 @@ export declare function decisionInterface(profile: Pick<DecisionProfile, 'identi
40
55
  export declare function decisionAssets(host: Record<string, DecisionAsset> | undefined, page: Record<string, DecisionAsset> | undefined): Record<string, DecisionAsset> | undefined;
41
56
  /** A decision's reply: the answers, and the trace records a host that delivers them sent back. */
42
57
  export type DecisionReply = {
43
- result: DecisionResult;
58
+ result: DecisionReplyResult;
44
59
  traces?: TraceRecord[];
45
60
  };
46
61
  export interface DecisionTransport {
47
62
  describe(signal?: AbortSignal): Promise<DecisionInterface>;
48
- /** `assets` are the images the state names, by reference. */
49
- decide(state: Exclude<DecisionJson, null>, signal?: AbortSignal, assets?: Record<string, DecisionAsset>): Promise<DecisionReply>;
63
+ /** `assets` are the images the state names, by reference; `input` is what the host's question builder takes. */
64
+ decide(state: Exclude<DecisionJson, null>, signal?: AbortSignal, assets?: Record<string, DecisionAsset>, input?: unknown): Promise<DecisionReply>;
50
65
  /** Where the host's trace records land, for the inspector. */
51
66
  traces?: TraceFeed;
52
67
  }
@@ -14,6 +14,19 @@ function entryLabel(entry, fallback) {
14
14
  const entries = Array.isArray(entry) ? entry : Object.keys(entry);
15
15
  return entries.length ? entryLabel(entries[0], fallback) : fallback;
16
16
  }
17
+ /** Questions as the page sees them: their answer types and labels, never their instructions. */
18
+ export function questionViews(questions) {
19
+ return Object.entries(questions).map(([id, question]) => ({
20
+ id,
21
+ type: question.type,
22
+ labels: question.type === 'choice'
23
+ ? Object.keys(question.criteria)
24
+ : question.type === 'score'
25
+ ? question.criteria.map((entry, level) => entryLabel(entry, String(level)))
26
+ : [],
27
+ ...(question.type === 'box' && question.image !== undefined ? { image: question.image } : {}),
28
+ }));
29
+ }
17
30
  /** The page's view of a decision profile, the questions its host asks, and the host's own images. */
18
31
  export function decisionInterface(profile, questions, images) {
19
32
  return {
@@ -23,16 +36,7 @@ export function decisionInterface(profile, questions, images) {
23
36
  ...(profile.inputs.maxStateBytes === undefined
24
37
  ? {}
25
38
  : { maxStateBytes: profile.inputs.maxStateBytes }),
26
- questions: Object.entries(questions).map(([id, question]) => ({
27
- id,
28
- type: question.type,
29
- labels: question.type === 'choice'
30
- ? Object.keys(question.criteria)
31
- : question.type === 'score'
32
- ? question.criteria.map((entry, level) => entryLabel(entry, String(level)))
33
- : [],
34
- ...(question.type === 'box' && question.image !== undefined ? { image: question.image } : {}),
35
- })),
39
+ questions: questionViews(questions),
36
40
  ...(images && Object.keys(images).length > 0 ? { images } : {}),
37
41
  ...(profile.observability
38
42
  ? { observability: { record: resolveObservabilityPolicy(profile.observability).record } }
@@ -75,9 +79,16 @@ const answerSchema = z.discriminatedUnion('type', [
75
79
  probabilities,
76
80
  }),
77
81
  ]);
82
+ const questionViewSchema = z.object({
83
+ id: z.string(),
84
+ type: z.enum(['choice', 'noul', 'score', 'box']),
85
+ labels: z.array(z.string()),
86
+ image: z.string().optional(),
87
+ });
78
88
  const replySchema = z.object({
79
89
  result: z.object({
80
90
  model: z.string().min(1),
91
+ questions: z.array(questionViewSchema).optional(),
81
92
  answers: z.record(z.string(), answerSchema),
82
93
  usage: z
83
94
  .object({
@@ -132,12 +143,8 @@ const describedSchema = z.object({
132
143
  identity: z.object({ handle: z.string() }),
133
144
  contract: z.string(),
134
145
  maxStateBytes: z.number().optional(),
135
- questions: z.array(z.object({
136
- id: z.string(),
137
- type: z.enum(['choice', 'noul', 'score', 'box']),
138
- labels: z.array(z.string()),
139
- image: z.string().optional(),
140
- })),
146
+ questions: z.array(questionViewSchema),
147
+ build: z.object({ input: z.record(z.string(), z.unknown()).optional() }).optional(),
141
148
  images: z
142
149
  .record(z.string(), z.object({ mimeType: z.enum(['image/png', 'image/jpeg']), data: z.string() }))
143
150
  .optional(),
@@ -151,6 +158,6 @@ export function createDecisionTransport(options = {}) {
151
158
  async describe(signal) {
152
159
  return checkWire(describedSchema, await fetchJson(base, { signal }, options), 'the decision description').interface;
153
160
  },
154
- decide: (state, signal, assets) => postDecision(`${base}/decide`, { state, ...(assets ? { assets } : {}) }, signal, options),
161
+ decide: (state, signal, assets, input) => postDecision(`${base}/decide`, { state, ...(assets ? { assets } : {}), ...(input === undefined ? {} : { input }) }, signal, options),
155
162
  };
156
163
  }
package/client/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export { float32Rms, float32RmsToLevel, INPUT_LEVEL_GAIN, timeDomainBytesToLevel, } from './audio-level.ts';
2
- export { type ChatController, type ChatOptions, type ChatSlots, type ChatState, type ChatUpdate, createChatController, type MessageDelivery, type SentTurn, } from './chat.ts';
2
+ export { type ChatController, type ChatOptions, type ChatSlotDecl, type ChatSlots, type ChatState, type ChatUpdate, createChatController, type MessageDelivery, type SentTurn, } from './chat.ts';
3
3
  export { type ComposerDrawerKind, type ComposerDrawerSummary, composerDrawerSummary, } from './composer-drawer.ts';
4
4
  export { type ComposerHint, isStashShortcut, resolveComposerHint, STASH_SHORTCUT, } from './composer-hints.ts';
5
5
  export { type ComposerActionState, composerActionState, composerHasPayload, } from './composer-primary.ts';
6
- export { createDecisionTransport, type DecisionInterface, type DecisionQuestionView, type DecisionReply, type DecisionTransport, decisionAssets, decisionInterface, failedTraces, postDecision, readDecisionReply, withFailedTraces, } from './decision-transport.ts';
6
+ export { createDecisionTransport, type DecisionInterface, type DecisionQuestionView, type DecisionReply, type DecisionReplyResult, type DecisionTransport, decisionAssets, decisionInterface, failedTraces, postDecision, questionViews, readDecisionReply, withFailedTraces, } from './decision-transport.ts';
7
7
  export { type ComposerDraftFields, composerFieldsFromDraft } from './decode-composer-draft.ts';
8
8
  export { addUsage, checkEmbeddingInputs, checkInputs, cosineSimilarity, type EmbeddingFields, type EmbeddingInputCheck, type EmbeddingMatch, type EmbeddingMediaPart, type EmbeddingSplit, embeddingBatches, fieldsInput, inputFields, MAX_BATCH_INPUTS, rankMatches, readEmbeddingInput, readEmbeddingInputs, readsAsText, splitText, } from './embedding-input.ts';
9
9
  export { type EmbeddingSpace, layoutSpace, layoutSpaceSoon, outlierThreshold, placeQuery, type SpaceCluster, type SpaceDocument, type SpaceLink, type SpacePoint, } from './embedding-space.ts';
@@ -32,6 +32,6 @@ export { DOT_FLOATS, FRAME_SLOT, queryGeometry, SEGMENT_FLOATS, type SpaceGeomet
32
32
  export { hasLead, layoutResult, type ResultChart, type ResultFigure, type ResultImage, type ResultLayout, type ResultMedia, returnedMedia, timeFormats, } from './tool-result.ts';
33
33
  export type { AnsweringGate, ToolDecisionAction, ToolGateResolution, } from './tool-resume.ts';
34
34
  export { continueGatedToolInvocation, type PersonGateResolution } from './tool-resume.ts';
35
- export { createTraceFeed, type TraceFeed } from './trace-feed.ts';
35
+ export { createTraceFeed, recordedFeed, type TraceFeed } from './trace-feed.ts';
36
36
  export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, postJson, postNdjson, readNdjsonStream, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
37
37
  export { buildInvokeRequest, buildTurnRequest, foldAssistantTurn, issuedOperationId, type RunScope, type WalkAway, } from './turn-client.ts';
package/client/index.js CHANGED
@@ -3,7 +3,7 @@ export { createChatController, } from "./chat.js";
3
3
  export { composerDrawerSummary, } from "./composer-drawer.js";
4
4
  export { isStashShortcut, resolveComposerHint, STASH_SHORTCUT, } from "./composer-hints.js";
5
5
  export { composerActionState, composerHasPayload, } from "./composer-primary.js";
6
- export { createDecisionTransport, decisionAssets, decisionInterface, failedTraces, postDecision, readDecisionReply, withFailedTraces, } from "./decision-transport.js";
6
+ export { createDecisionTransport, decisionAssets, decisionInterface, failedTraces, postDecision, questionViews, readDecisionReply, withFailedTraces, } from "./decision-transport.js";
7
7
  export { composerFieldsFromDraft } from "./decode-composer-draft.js";
8
8
  export { addUsage, checkEmbeddingInputs, checkInputs, cosineSimilarity, embeddingBatches, fieldsInput, inputFields, MAX_BATCH_INPUTS, rankMatches, readEmbeddingInput, readEmbeddingInputs, readsAsText, splitText, } from "./embedding-input.js";
9
9
  export { layoutSpace, layoutSpaceSoon, outlierThreshold, placeQuery, } from "./embedding-space.js";
@@ -26,6 +26,6 @@ export { citationsFromBlock, } from "./source-citations.js";
26
26
  export { DOT_FLOATS, FRAME_SLOT, queryGeometry, SEGMENT_FLOATS, spaceGeometry, } from "./space-geometry.js";
27
27
  export { hasLead, layoutResult, returnedMedia, timeFormats, } from "./tool-result.js";
28
28
  export { continueGatedToolInvocation } from "./tool-resume.js";
29
- export { createTraceFeed } from "./trace-feed.js";
29
+ export { createTraceFeed, recordedFeed } from "./trace-feed.js";
30
30
  export { CHAT_LINES, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, hostError, isTheoremStreamError, postJson, postNdjson, readNdjsonStream, TheoremStreamError, } from "./transport.js";
31
31
  export { buildInvokeRequest, buildTurnRequest, foldAssistantTurn, issuedOperationId, } from "./turn-client.js";
@@ -5,7 +5,7 @@
5
5
  const GROUNDING_REDIRECT_HOST = 'vertexaisearch.cloud.google.com';
6
6
  /** A citation's sources, in the order they were cited. */
7
7
  export function citationsFromBlock(block) {
8
- return block.sources.map((source, i) => citationFromSource(source, `c-${String(i)}`));
8
+ return block.sources.map((source, i) => citationFromSource(source, `${block.id}-c-${String(i)}`));
9
9
  }
10
10
  function citationFromSource(source, key) {
11
11
  const url = webUrl(source.uri);
@@ -11,3 +11,5 @@ export interface TraceFeed {
11
11
  subscribe(listener: () => void): () => void;
12
12
  }
13
13
  export declare function createTraceFeed(): TraceFeed;
14
+ /** A feed already holding `records` — a recording opened for View Trace. */
15
+ export declare function recordedFeed(records: readonly TraceRecord[]): TraceFeed;
@@ -16,3 +16,10 @@ export function createTraceFeed() {
16
16
  },
17
17
  };
18
18
  }
19
+ /** A feed already holding `records` — a recording opened for View Trace. */
20
+ export function recordedFeed(records) {
21
+ const feed = createTraceFeed();
22
+ for (const record of records)
23
+ feed.push(record);
24
+ return feed;
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.544.gb5ef325",
3
+ "version": "0.3.4-next.550.gf20b9cd",
4
4
  "description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,6 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "zod": "^4.6.5",
28
- "@theoremjs/agents": "^0.3.4-next.544.gb5ef325"
28
+ "@theoremjs/agents": "^0.3.4-next.550.gf20b9cd"
29
29
  }
30
30
  }
@@ -4,19 +4,35 @@
4
4
  * - `GET <base>` → `{ interface }` the profile's handle, contract, and questions
5
5
  * - `POST <base>/decide` → `{ result }` the answers for the posted `{ state }`
6
6
  *
7
- * The host owns the questions: the browser sends only the state and the images it names,
8
- * so a visitor can't spend the host's key asking questions of their own.
7
+ * The host owns the questions: the browser sends only the state, the images it names and,
8
+ * when the host builds its questions for each decision, the input its builder takes. A
9
+ * visitor can't spend the host's key asking questions of their own.
9
10
  *
10
11
  * @module
11
12
  */
12
- import { type DecisionAsset, type DecisionProfile, type DecisionQuestion, type KeyVault, type TraceSink } from '@theoremjs/agents';
13
+ import { type DecisionAsset, type DecisionJson, type DecisionProfile, type DecisionQuestion, type KeyVault, type TraceSink, z } from '@theoremjs/agents';
13
14
  import type { ClientTurnOptions } from '@theoremjs/agents/host';
14
- import type { DecisionProfileDefinition } from '@theoremjs/agents/kernel';
15
- export type TheoremDecisionHandlerOptions = {
15
+ import { type DecisionProfileDefinition } from '@theoremjs/agents/kernel';
16
+ /**
17
+ * Questions built for each decision: from its state and, when `input` is set, from what the page
18
+ * fills in beside it. The handler checks the input against `input` before `build` sees it.
19
+ */
20
+ export type DecisionQuestionsBuilder<Input = undefined> = {
21
+ /** What the page fills in for each decision, beyond the state. */
22
+ input?: z.ZodType<Input>;
23
+ /** The questions one decision asks, by id. */
24
+ build(call: {
25
+ state: Exclude<DecisionJson, null>;
26
+ input: Input;
27
+ }): Record<string, DecisionQuestion> | Promise<Record<string, DecisionQuestion>>;
28
+ };
29
+ /** Whether `questions` are built for each decision, not fixed. */
30
+ export declare function isQuestionsBuilder(questions: unknown): questions is DecisionQuestionsBuilder<unknown>;
31
+ export type TheoremDecisionHandlerOptions<Input = unknown> = {
16
32
  /** Decision profile to serve. Registered with the kernel when the handler is created. */
17
33
  profile: DecisionProfile | DecisionProfileDefinition;
18
- /** The questions every decision asks, by id. */
19
- questions: Record<string, DecisionQuestion>;
34
+ /** The questions every decision asks, by id, or a builder that makes them for each decision. */
35
+ questions: Record<string, DecisionQuestion> | DecisionQuestionsBuilder<Input>;
20
36
  /**
21
37
  * Images every decision sends, by the reference its questions write, such as `<ghs02>`. The page
22
38
  * sees them, to draw a box on one; a page image with one of their names is refused.
@@ -39,4 +55,4 @@ export type TheoremDecisionHandlerOptions = {
39
55
  request: Request;
40
56
  }) => void;
41
57
  };
42
- export declare function createTheoremDecisionHandler(options: TheoremDecisionHandlerOptions): (request: Request) => Promise<Response>;
58
+ export declare function createTheoremDecisionHandler<Input = unknown>(options: TheoremDecisionHandlerOptions<Input>): (request: Request) => Promise<Response>;
@@ -4,18 +4,27 @@
4
4
  * - `GET <base>` → `{ interface }` the profile's handle, contract, and questions
5
5
  * - `POST <base>/decide` → `{ result }` the answers for the posted `{ state }`
6
6
  *
7
- * The host owns the questions: the browser sends only the state and the images it names,
8
- * so a visitor can't spend the host's key asking questions of their own.
7
+ * The host owns the questions: the browser sends only the state, the images it names and,
8
+ * when the host builds its questions for each decision, the input its builder takes. A
9
+ * visitor can't spend the host's key asking questions of their own.
9
10
  *
10
11
  * @module
11
12
  */
12
- import { defineProfile, registerProfile, runDecision, z, } from '@theoremjs/agents';
13
- import { decisionAssets, decisionInterface } from "../client/decision-transport.js";
13
+ import { defineProfile, registerProfile, runDecision, TheoremError, z, } from '@theoremjs/agents';
14
+ import { jsonSchemaFromZod } from '@theoremjs/agents/kernel';
15
+ import { decisionAssets, decisionInterface, questionViews } from "../client/decision-transport.js";
14
16
  import { readBody } from "./handler.js";
15
17
  import { singleActionHandler } from "./single-action.js";
18
+ /** Whether `questions` are built for each decision, not fixed. */
19
+ export function isQuestionsBuilder(questions) {
20
+ return (typeof questions === 'object' &&
21
+ questions !== null &&
22
+ typeof questions.build === 'function');
23
+ }
16
24
  const decideBodySchema = z.object({
17
25
  state: z.unknown(),
18
26
  assets: z.record(z.string(), z.object({ mimeType: z.string(), data: z.string() })).optional(),
27
+ input: z.unknown().optional(),
19
28
  });
20
29
  export function createTheoremDecisionHandler(options) {
21
30
  const profile = defineProfile(options.profile);
@@ -24,23 +33,51 @@ export function createTheoremDecisionHandler(options) {
24
33
  throw new Error(`createTheoremDecisionHandler serves type 'decision'; got type '${profile.type}'.`);
25
34
  }
26
35
  registerProfile(profile);
36
+ const { questions } = options;
37
+ const builder = isQuestionsBuilder(questions)
38
+ ? questions
39
+ : undefined;
40
+ const fixed = builder ? {} : questions;
27
41
  return singleActionHandler({
28
42
  action: 'decide',
29
- iface: decisionInterface(profile, options.questions, options.images),
43
+ iface: builder
44
+ ? {
45
+ ...decisionInterface(profile, {}, options.images),
46
+ build: builder.input ? { input: jsonSchemaFromZod(builder.input, 'input') } : {},
47
+ }
48
+ : decisionInterface(profile, fixed, options.images),
30
49
  labels: profile.labels,
31
50
  clientEvents: options.clientEvents,
32
51
  onError: options.onError,
33
52
  }, async (request) => {
34
53
  const body = await readBody(request, decideBodySchema);
35
- return runDecision({
54
+ if (builder && (body.state === null || body.state === undefined))
55
+ // labels-exempt: internal diagnostic; the user reads error.request
56
+ throw new TheoremError('request', 'a decision needs a state');
57
+ // why: The kernel checks the state is JSON within the profile's size; a builder is never handed null.
58
+ const state = body.state;
59
+ const asked = builder
60
+ ? await builder.build({ state, input: builtInput(builder, body.input) })
61
+ : fixed;
62
+ const result = await runDecision({
36
63
  profile: profile.id,
37
- // why: The kernel checks the state is non-null JSON within the profile's size.
38
- state: body.state,
64
+ state,
39
65
  // why: The kernel checks each image's type and bytes before any request leaves.
40
66
  assets: decisionAssets(options.images, body.assets),
41
- questions: options.questions,
67
+ questions: asked,
42
68
  signal: request.signal,
43
69
  metadata: options.metadata?.(request),
44
70
  }, { vault: options.vault, fetch: options.fetch, sink: options.sink });
71
+ return builder ? { ...result, questions: questionViews(asked) } : result;
45
72
  });
46
73
  }
74
+ /** The page's input, checked against the builder's: refused as a bad request when it does not fit. */
75
+ function builtInput(builder, input) {
76
+ if (!builder.input)
77
+ return undefined;
78
+ const parsed = builder.input.safeParse(input);
79
+ if (!parsed.success)
80
+ // labels-exempt: internal diagnostic; the user reads error.request
81
+ throw new TheoremError('request', `the questions' input does not fit: ${parsed.error.message}`);
82
+ return parsed.data;
83
+ }
@@ -32,7 +32,7 @@ import { type PageToolBroker } from './page-dispatch.ts';
32
32
  *
33
33
  * @module
34
34
  */
35
- import { type Profile, type ProfileDefinition, type ProviderHostOptions, type ToolGate, type z } from '@theoremjs/agents';
35
+ import { type Profile, type ProfileDefinition, type ProviderHostOptions, type ToolGate, type TurnWiring, type z } from '@theoremjs/agents';
36
36
  import { type ClientTurnOptions } from '@theoremjs/agents/host';
37
37
  import { type TheoremConversationStore } from './conversation-store.ts';
38
38
  import { type TheoremCredentialStore } from './credential-store.ts';
@@ -64,6 +64,14 @@ export type TheoremHandlerOptions = {
64
64
  * request and is read as untrusted; this one is the host's.
65
65
  */
66
66
  context?: (request: Request) => unknown;
67
+ /**
68
+ * The application's own turn, from the one the handler builds: `TurnWiring.prepare`, the
69
+ * value the application's other call sites use too. The handler keeps its profile, its tool
70
+ * execution and its abort. The application's `onStage` runs before the handler's steering, and
71
+ * its `credentials` are read before the session's. A call held at a gate runs on, once
72
+ * answered, with the `onStage` and `credentials` it gives for that call's turn.
73
+ */
74
+ prepare?: TurnWiring['prepare'];
67
75
  /**
68
76
  * Resolve the caller's session id — e.g. `${userId}:${conversationId}` from your
69
77
  * auth. Return `undefined` to refuse the request (401). Default: an opaque id
@@ -134,7 +142,7 @@ export type TheoremHandlerOptions = {
134
142
  }) => void;
135
143
  };
136
144
  /** A host profile runs no model, and takes no turns to steer. */
137
- export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore'> & {
145
+ export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore' | 'prepare'> & {
138
146
  /** Host profile to serve. Registered with the kernel when the handler is created. */
139
147
  profile: Extract<Profile, {
140
148
  type: 'host';
package/server/handler.js CHANGED
@@ -52,6 +52,7 @@ import { resolveSessionLimits } from "./session-limits.js";
52
52
  import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
53
53
  import { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
54
54
  import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
55
+ import { preparedTurn } from "./turn-wiring.js";
55
56
  const NDJSON_HEADERS = {
56
57
  'content-type': 'application/x-ndjson; charset=utf-8',
57
58
  'cache-control': 'no-store',
@@ -359,6 +360,8 @@ async function* settleAnswered(ctx, request, session, answered) {
359
360
  }
360
361
  async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
361
362
  const host = await ctx.options.host?.(request);
363
+ // why: The held call goes on with what the application set for its turn: its stage handler and credentials.
364
+ const { credentials, onStage } = await preparedTurn(ctx.options.prepare, { profile: ctx.profile.id, input: pending.turnInput ?? {}, host, signal: request.signal }, { credentials: sessionCredentials(ctx, sessionId) });
362
365
  yield* invokeTool({
363
366
  profile: ctx.profile.id,
364
367
  execution: sessionToolCoordinator(ctx.sessions, sessionId),
@@ -373,7 +376,8 @@ async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answe
373
376
  ? { page: answer.page ?? { unanswered: true } }
374
377
  : {}),
375
378
  sessionPermissions: answer.sessionPermissions,
376
- credentials: sessionCredentials(ctx, sessionId),
379
+ credentials,
380
+ ...(onStage ? { onStage } : {}),
377
381
  turnInput: pending.turnInput,
378
382
  snapshot: pending.snapshot,
379
383
  promoted: pending.promoted,
@@ -396,20 +400,22 @@ async function* chatTurn(ctx, request, session, turn, key, stop) {
396
400
  stopOnRequest(ctx.inbox, key, stop);
397
401
  }
398
402
  try {
399
- const events = runTurn({
403
+ const prepared = await preparedTurn(ctx.options.prepare, {
400
404
  profile: ctx.profile.id,
401
405
  execution: sessionToolCoordinator(ctx.sessions, session.id),
402
406
  pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
403
407
  input: turn.input,
404
408
  ...(turn.providerState ? { providerState: turn.providerState } : {}),
405
409
  sessionPermissions: state.permissions,
406
- credentials: sessionCredentials(ctx, session.id),
407
410
  signal: AbortSignal.any([request.signal, stop.signal]),
408
411
  host,
409
412
  ...(turn.model ? { model: turn.model } : {}),
410
413
  ...(turn.effort ? { effort: turn.effort } : {}),
414
+ }, {
415
+ credentials: sessionCredentials(ctx, session.id),
411
416
  ...(key ? { onStage: steerStage(ctx.inbox, key) } : {}),
412
- }, provider);
417
+ });
418
+ const events = runTurn(prepared, provider);
413
419
  yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
414
420
  profileId: ctx.profile.id,
415
421
  turnInput: turn.input,
package/server/mod.d.ts CHANGED
@@ -11,7 +11,7 @@ export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRe
11
11
  export { answerReply, type ConversationHost, type ConversationUse, conversationView, type IssuedWalkAway, messageReply, type OpenedConversation, openConversation, type PostedMessage, postedMessage, type ReplyLine, type ReplyRunner, type ReplyTurn, readerConversationHost, rewindConversation, startConversation, statelessRefusal, type WalkedAwayCall, waitingCalls, } from './conversation.ts';
12
12
  export { type ConversationSnapshot, type ConversationWriteResult, createMemoryConversationStore, createSealedConversationStore, emptyConversation, type PausedReply, type SealedConversationStoreOptions, type StoredConversation, storedConversationSchema, type TheoremConversationStore, unknownConversation, } from './conversation-store.ts';
13
13
  export { createMemoryCredentialStore, type TheoremCredentialStore, type TheoremCredentials, } from './credential-store.ts';
14
- export { createTheoremDecisionHandler, type TheoremDecisionHandlerOptions, } from './decision-handler.ts';
14
+ export { createTheoremDecisionHandler, type DecisionQuestionsBuilder, isQuestionsBuilder, type TheoremDecisionHandlerOptions, } from './decision-handler.ts';
15
15
  export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, type TheoremEmbeddingHandlerOptions, } from './embedding-handler.ts';
16
16
  export { createTheoremHandler, createTheoremHostHandler, readBody, type TheoremHandlerOptions, type TheoremHostHandlerOptions, type TheoremRequestContext, theoremSession, theoremSessionId, } from './handler.ts';
17
17
  export { createTheoremMcpHandler, type TheoremMcpHandlerOptions } from './mcp-handler.ts';
package/server/mod.js CHANGED
@@ -10,7 +10,7 @@ export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRe
10
10
  export { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, rewindConversation, startConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
11
11
  export { createMemoryConversationStore, createSealedConversationStore, emptyConversation, storedConversationSchema, unknownConversation, } from "./conversation-store.js";
12
12
  export { createMemoryCredentialStore, } from "./credential-store.js";
13
- export { createTheoremDecisionHandler, } from "./decision-handler.js";
13
+ export { createTheoremDecisionHandler, isQuestionsBuilder, } from "./decision-handler.js";
14
14
  export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, } from "./embedding-handler.js";
15
15
  export { createTheoremHandler, createTheoremHostHandler, readBody, theoremSession, theoremSessionId, } from "./handler.js";
16
16
  export { createTheoremMcpHandler } from "./mcp-handler.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The application's turn wiring (`TurnWiring.prepare`) laid over the turn the handler builds.
3
+ * The handler keeps the fields it owns; the application's stage handler and credentials go
4
+ * first, and the handler's own run when the application's have nothing.
5
+ *
6
+ * @module
7
+ */
8
+ import type { NormalizedTurnRequest, StageHandler, TurnRequest, TurnWiring } from '@theoremjs/agents';
9
+ import type { ToolCredentialSource } from '@theoremjs/agents/kernel';
10
+ /** The fields a handler sets on every turn, which `prepare` cannot replace. */
11
+ export type HandlerOwned = Pick<TurnRequest, 'profile' | 'execution' | 'pageExecution' | 'providerState' | 'signal'>;
12
+ /** What the handler adds to a turn of its own: the session's credentials and its steering. */
13
+ export type HandlerTurnParts = {
14
+ credentials: ToolCredentialSource;
15
+ onStage?: StageHandler;
16
+ };
17
+ /**
18
+ * The turn to run: `turn` through the application's `prepare`, with the handler's own fields
19
+ * kept, and its credentials and steering after the application's.
20
+ */
21
+ export declare function preparedTurn(prepare: TurnWiring['prepare'], turn: NormalizedTurnRequest & HandlerOwned, handler: HandlerTurnParts): Promise<TurnRequest>;
22
+ /**
23
+ * The application's stage handler, then the handler's own when the application's asks for
24
+ * nothing. What the second one would have done waits for the next stage that allows it.
25
+ */
26
+ export declare function stagesInOrder(app: StageHandler | undefined, own: StageHandler | undefined): StageHandler | undefined;
27
+ /**
28
+ * The application's credentials, then the session's. A credential is saved where the slot
29
+ * already lives: a refreshed token back to the application, a sign-in at a gate to the session.
30
+ */
31
+ export declare function credentialsInOrder(app: ToolCredentialSource | undefined, session: ToolCredentialSource): ToolCredentialSource;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The application's turn wiring (`TurnWiring.prepare`) laid over the turn the handler builds.
3
+ * The handler keeps the fields it owns; the application's stage handler and credentials go
4
+ * first, and the handler's own run when the application's have nothing.
5
+ *
6
+ * @module
7
+ */
8
+ /**
9
+ * The turn to run: `turn` through the application's `prepare`, with the handler's own fields
10
+ * kept, and its credentials and steering after the application's.
11
+ */
12
+ export async function preparedTurn(prepare, turn, handler) {
13
+ const app = prepare ? await prepare(turn) : turn;
14
+ const owned = {
15
+ profile: turn.profile,
16
+ ...(turn.execution ? { execution: turn.execution } : {}),
17
+ ...(turn.pageExecution ? { pageExecution: turn.pageExecution } : {}),
18
+ ...(turn.providerState ? { providerState: turn.providerState } : {}),
19
+ ...(turn.signal ? { signal: turn.signal } : {}),
20
+ };
21
+ const onStage = stagesInOrder(app.onStage, handler.onStage);
22
+ return {
23
+ ...app,
24
+ ...owned,
25
+ credentials: credentialsInOrder(app.credentials, handler.credentials),
26
+ ...(onStage ? { onStage } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * The application's stage handler, then the handler's own when the application's asks for
31
+ * nothing. What the second one would have done waits for the next stage that allows it.
32
+ */
33
+ export function stagesInOrder(app, own) {
34
+ if (!app || !own)
35
+ return app ?? own;
36
+ return async (ctx) => (await app(ctx)) ?? (await own(ctx));
37
+ }
38
+ /**
39
+ * The application's credentials, then the session's. A credential is saved where the slot
40
+ * already lives: a refreshed token back to the application, a sign-in at a gate to the session.
41
+ */
42
+ export function credentialsInOrder(app, session) {
43
+ if (!app)
44
+ return session;
45
+ // why: Called as a method: an application's source may keep its secrets on `this`.
46
+ const secrets = app.clientSecret ? app : session;
47
+ return {
48
+ get: async (slot) => (await app.get(slot)) ?? (await session.get(slot)),
49
+ set: async (slot, credential) => (await app.get(slot)) ? await app.set(slot, credential) : await session.set(slot, credential),
50
+ ...(secrets.clientSecret
51
+ ? { clientSecret: async (clientId) => await secrets.clientSecret?.(clientId) }
52
+ : {}),
53
+ };
54
+ }