@theoremjs/react 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +772 -2
- package/client/attachment-hover-preview.d.ts +11 -0
- package/client/attachment-hover-preview.js +36 -0
- package/client/audio-level.d.ts +5 -0
- package/client/audio-level.js +24 -0
- package/client/composer-attachments.d.ts +30 -0
- package/client/composer-attachments.js +33 -0
- package/client/composer-drawer.d.ts +19 -0
- package/client/composer-drawer.js +15 -0
- package/client/composer-hints.d.ts +25 -0
- package/client/composer-hints.js +20 -0
- package/client/composer-primary.d.ts +16 -0
- package/client/composer-primary.js +25 -0
- package/client/decision-images.d.ts +76 -0
- package/client/decision-images.js +139 -0
- package/client/decision-transport.d.ts +70 -0
- package/client/decision-transport.js +175 -0
- package/client/decode-composer-draft.d.ts +11 -0
- package/client/decode-composer-draft.js +12 -0
- package/client/embedding-input.d.ts +84 -0
- package/client/embedding-input.js +194 -0
- package/client/embedding-space.d.ts +82 -0
- package/client/embedding-space.js +389 -0
- package/client/embedding-transport.d.ts +40 -0
- package/client/embedding-transport.js +71 -0
- package/client/encode-composer-draft.d.ts +9 -0
- package/client/encode-composer-draft.js +34 -0
- package/client/encode-files.d.ts +18 -0
- package/client/encode-files.js +54 -0
- package/client/failure.d.ts +29 -0
- package/client/failure.js +34 -0
- package/client/generation-selection.d.ts +15 -0
- package/client/generation-selection.js +24 -0
- package/client/host-call.d.ts +51 -0
- package/client/host-call.js +47 -0
- package/client/host-transport.d.ts +70 -0
- package/client/host-transport.js +75 -0
- package/client/image-output.d.ts +6 -0
- package/client/image-output.js +13 -0
- package/client/index.d.ts +28 -0
- package/client/index.js +22 -0
- package/client/ink-waveform.d.ts +17 -0
- package/client/ink-waveform.js +68 -0
- package/client/live/apply-live-turn-tool-event.d.ts +14 -0
- package/client/live/apply-live-turn-tool-event.js +39 -0
- package/client/live/live-captions.d.ts +53 -0
- package/client/live/live-captions.js +174 -0
- package/client/live/live-mic-forward.d.ts +30 -0
- package/client/live/live-mic-forward.js +46 -0
- package/client/live/live-page-tool.d.ts +34 -0
- package/client/live/live-page-tool.js +11 -0
- package/client/live/live-state.d.ts +13 -0
- package/client/live/live-state.js +22 -0
- package/client/live/live-tool.d.ts +11 -0
- package/client/live/live-tool.js +1 -0
- package/client/live/live-video.d.ts +10 -0
- package/client/live/live-video.js +71 -0
- package/client/live/run-live-tool-call.d.ts +22 -0
- package/client/live/run-live-tool-call.js +70 -0
- package/client/live/use-live-caption-log.d.ts +17 -0
- package/client/live/use-live-caption-log.js +51 -0
- package/client/live-client.d.ts +189 -0
- package/client/live-client.js +829 -0
- package/client/live-errors.d.ts +2 -0
- package/client/live-errors.js +9 -0
- package/client/live-messages.d.ts +148 -0
- package/client/live-messages.js +100 -0
- package/client/mic-capture.d.ts +4 -0
- package/client/mic-capture.js +6 -0
- package/client/oauth-popup.d.ts +26 -0
- package/client/oauth-popup.js +30 -0
- package/client/page-dispatch.d.ts +15 -0
- package/client/page-dispatch.js +63 -0
- package/client/pcm-downsample.d.ts +4 -0
- package/client/pcm-downsample.js +45 -0
- package/client/run-commit.d.ts +34 -0
- package/client/run-commit.js +49 -0
- package/client/run-session.d.ts +79 -0
- package/client/run-session.js +207 -0
- package/client/schema-fields.d.ts +63 -0
- package/client/schema-fields.js +199 -0
- package/client/shaped-data.d.ts +118 -0
- package/client/shaped-data.js +343 -0
- package/client/source-citations.d.ts +13 -0
- package/client/source-citations.js +56 -0
- package/client/space-geometry.d.ts +39 -0
- package/client/space-geometry.js +183 -0
- package/client/state-fields.d.ts +16 -0
- package/client/state-fields.js +44 -0
- package/client/tool-result.d.ts +78 -0
- package/client/tool-result.js +293 -0
- package/client/tool-resume.d.ts +50 -0
- package/client/tool-resume.js +14 -0
- package/client/trace-feed.d.ts +13 -0
- package/client/trace-feed.js +18 -0
- package/client/trace-story.d.ts +163 -0
- package/client/trace-story.js +440 -0
- package/client/trace-view.d.ts +130 -0
- package/client/trace-view.js +333 -0
- package/client/transcript-groups.d.ts +140 -0
- package/client/transcript-groups.js +217 -0
- package/client/transcript-schema.d.ts +10 -0
- package/client/transcript-schema.js +120 -0
- package/client/transcript-scroll.d.ts +7 -0
- package/client/transcript-scroll.js +6 -0
- package/client/transport.d.ts +264 -0
- package/client/transport.js +330 -0
- package/client/turn-client.d.ts +44 -0
- package/client/turn-client.js +88 -0
- package/client/voice-label.d.ts +2 -0
- package/client/voice-label.js +15 -0
- package/client/voice-recorder.d.ts +27 -0
- package/client/voice-recorder.js +162 -0
- package/client/wire-line.d.ts +56 -0
- package/client/wire-line.js +90 -0
- package/client/worklets/mic-capture.js +16 -0
- package/components/InkWaveform.d.ts +21 -0
- package/components/InkWaveform.js +131 -0
- package/components/live/use-live-runner-controls.d.ts +47 -0
- package/components/live/use-live-runner-controls.js +96 -0
- package/components/live/use-live-runner-model.d.ts +49 -0
- package/components/live/use-live-runner-model.js +190 -0
- package/components/live/use-live-runner-ui.d.ts +67 -0
- package/components/live/use-live-runner-ui.js +167 -0
- package/components/live/use-live-session-client.d.ts +41 -0
- package/components/live/use-live-session-client.js +126 -0
- package/components/use-composer-voice.d.ts +28 -0
- package/components/use-composer-voice.js +90 -0
- package/components/use-editor-selection.d.ts +3 -0
- package/components/use-editor-selection.js +19 -0
- package/components/use-voice-playback.d.ts +17 -0
- package/components/use-voice-playback.js +68 -0
- package/hooks/use-described.d.ts +12 -0
- package/hooks/use-described.js +25 -0
- package/hooks/use-embedding-index.d.ts +27 -0
- package/hooks/use-embedding-index.js +43 -0
- package/hooks/use-one-call.d.ts +31 -0
- package/hooks/use-one-call.js +76 -0
- package/hooks/use-page-tool-warnings.d.ts +6 -0
- package/hooks/use-page-tool-warnings.js +25 -0
- package/hooks/use-second-ticker.d.ts +2 -0
- package/hooks/use-second-ticker.js +13 -0
- package/hooks/use-theorem-chat-actions.d.ts +79 -0
- package/hooks/use-theorem-chat-actions.js +287 -0
- package/hooks/use-theorem-chat-state.d.ts +66 -0
- package/hooks/use-theorem-chat-state.js +122 -0
- package/hooks/use-theorem-chat.d.ts +97 -0
- package/hooks/use-theorem-chat.js +528 -0
- package/hooks/use-theorem-decision.d.ts +18 -0
- package/hooks/use-theorem-decision.js +12 -0
- package/hooks/use-theorem-embedding.d.ts +18 -0
- package/hooks/use-theorem-embedding.js +12 -0
- package/hooks/use-theorem-host.d.ts +45 -0
- package/hooks/use-theorem-host.js +135 -0
- package/hooks/use-theorem-interface.d.ts +22 -0
- package/hooks/use-theorem-interface.js +15 -0
- package/hooks/use-trace-records.d.ts +4 -0
- package/hooks/use-trace-records.js +7 -0
- package/index.d.ts +17 -0
- package/index.js +17 -0
- package/live.d.ts +9 -0
- package/live.js +10 -0
- package/package.json +74 -4
- package/server/checkpoint-ownership.d.ts +6 -0
- package/server/checkpoint-ownership.js +42 -0
- package/server/conversation-store.d.ts +92 -0
- package/server/conversation-store.js +131 -0
- package/server/conversation.d.ts +193 -0
- package/server/conversation.js +552 -0
- package/server/credential-store.d.ts +24 -0
- package/server/credential-store.js +14 -0
- package/server/decision-handler.d.ts +42 -0
- package/server/decision-handler.js +46 -0
- package/server/embedding-handler.d.ts +33 -0
- package/server/embedding-handler.js +45 -0
- package/server/handler.d.ts +152 -0
- package/server/handler.js +771 -0
- package/server/json-response.d.ts +2 -0
- package/server/json-response.js +7 -0
- package/server/mod.d.ts +25 -0
- package/server/mod.js +23 -0
- package/server/page-dispatch.d.ts +22 -0
- package/server/page-dispatch.js +127 -0
- package/server/request-body.d.ts +2 -0
- package/server/request-body.js +42 -0
- package/server/request-check.d.ts +21 -0
- package/server/request-check.js +49 -0
- package/server/session-authority.d.ts +8 -0
- package/server/session-authority.js +78 -0
- package/server/session-boundary.d.ts +26 -0
- package/server/session-boundary.js +64 -0
- package/server/session-identity.d.ts +16 -0
- package/server/session-identity.js +41 -0
- package/server/session-limits.d.ts +12 -0
- package/server/session-limits.js +59 -0
- package/server/session-store.d.ts +123 -0
- package/server/session-store.js +264 -0
- package/server/single-action.d.ts +21 -0
- package/server/single-action.js +30 -0
- package/server/steer-inbox.d.ts +35 -0
- package/server/steer-inbox.js +52 -0
- package/server/tool-operations.d.ts +6 -0
- package/server/tool-operations.js +132 -0
- package/server/turn-input.d.ts +10 -0
- package/server/turn-input.js +29 -0
- package/styles/ink-controls.css +134 -0
- package/ui/CardWall.d.ts +10 -0
- package/ui/CardWall.js +61 -0
- package/ui/ChatComposerBar.d.ts +62 -0
- package/ui/ChatComposerBar.js +306 -0
- package/ui/ChatTranscript.d.ts +43 -0
- package/ui/ChatTranscript.js +527 -0
- package/ui/ConsoleFrame.d.ts +104 -0
- package/ui/ConsoleFrame.js +146 -0
- package/ui/DecisionAnswers.d.ts +53 -0
- package/ui/DecisionAnswers.js +233 -0
- package/ui/DecisionImages.d.ts +16 -0
- package/ui/DecisionImages.js +46 -0
- package/ui/EmbeddingDocuments.d.ts +78 -0
- package/ui/EmbeddingDocuments.js +278 -0
- package/ui/EmbeddingQuery.d.ts +27 -0
- package/ui/EmbeddingQuery.js +95 -0
- package/ui/EmbeddingResults.d.ts +41 -0
- package/ui/EmbeddingResults.js +165 -0
- package/ui/EmbeddingSpace.d.ts +26 -0
- package/ui/EmbeddingSpace.js +106 -0
- package/ui/LiveCaptionsPanel.d.ts +22 -0
- package/ui/LiveCaptionsPanel.js +27 -0
- package/ui/LiveRunner.d.ts +30 -0
- package/ui/LiveRunner.js +180 -0
- package/ui/PaneState.d.ts +33 -0
- package/ui/PaneState.js +50 -0
- package/ui/SchemaFields.d.ts +10 -0
- package/ui/SchemaFields.js +150 -0
- package/ui/ShapedData.d.ts +11 -0
- package/ui/ShapedData.js +196 -0
- package/ui/SidePanel.d.ts +126 -0
- package/ui/SidePanel.js +123 -0
- package/ui/StateFields.d.ts +44 -0
- package/ui/StateFields.js +139 -0
- package/ui/TheoremChat.d.ts +122 -0
- package/ui/TheoremChat.js +134 -0
- package/ui/TheoremDecision.d.ts +30 -0
- package/ui/TheoremDecision.js +130 -0
- package/ui/TheoremEmbedding.d.ts +27 -0
- package/ui/TheoremEmbedding.js +122 -0
- package/ui/TheoremHost.d.ts +55 -0
- package/ui/TheoremHost.js +240 -0
- package/ui/ToolGateCard.d.ts +32 -0
- package/ui/ToolGateCard.js +107 -0
- package/ui/ToolResult.d.ts +10 -0
- package/ui/ToolResult.js +62 -0
- package/ui/ToolResultCharts.d.ts +5 -0
- package/ui/ToolResultCharts.js +103 -0
- package/ui/TraceCharts.d.ts +13 -0
- package/ui/TraceCharts.js +266 -0
- package/ui/TraceGuardrails.d.ts +11 -0
- package/ui/TraceGuardrails.js +74 -0
- package/ui/TraceInspector.d.ts +16 -0
- package/ui/TraceInspector.js +206 -0
- package/ui/TraceInspectorPanel.d.ts +46 -0
- package/ui/TraceInspectorPanel.js +95 -0
- package/ui/TraceOverview.d.ts +38 -0
- package/ui/TraceOverview.js +126 -0
- package/ui/TraceSpanDetail.d.ts +10 -0
- package/ui/TraceSpanDetail.js +190 -0
- package/ui/TraceStory.d.ts +20 -0
- package/ui/TraceStory.js +108 -0
- package/ui/TraceValues.d.ts +36 -0
- package/ui/TraceValues.js +123 -0
- package/ui/TraceWaterfall.d.ts +13 -0
- package/ui/TraceWaterfall.js +135 -0
- package/ui/VoiceNote.d.ts +12 -0
- package/ui/VoiceNote.js +28 -0
- package/ui/approval-heading.d.ts +4 -0
- package/ui/approval-heading.js +9 -0
- package/ui/arrive.d.ts +18 -0
- package/ui/arrive.js +46 -0
- package/ui/built/theme.css +792 -0
- package/ui/built/theme.css.d.ts +9 -0
- package/ui/built/theorem.d.ts +13 -0
- package/ui/built/theorem.js +660 -0
- package/ui/built/theorem.variants.d.ts +22 -0
- package/ui/code-language.d.ts +12 -0
- package/ui/code-language.js +50 -0
- package/ui/disclosure-motion.d.ts +6 -0
- package/ui/disclosure-motion.js +207 -0
- package/ui/embedding-space-gl.d.ts +37 -0
- package/ui/embedding-space-gl.js +663 -0
- package/ui/embedding-space-view.d.ts +8 -0
- package/ui/embedding-space-view.js +8 -0
- package/ui/glide.d.ts +7 -0
- package/ui/glide.js +44 -0
- package/ui/icons.d.ts +4 -0
- package/ui/icons.js +38 -0
- package/ui/index.d.ts +36 -0
- package/ui/index.js +39 -0
- package/ui/labels-provider.d.ts +16 -0
- package/ui/labels-provider.js +64 -0
- package/ui/labels.d.ts +1442 -0
- package/ui/labels.js +1443 -0
- package/ui/markdown-plugins.d.ts +9 -0
- package/ui/markdown-plugins.js +13 -0
- package/ui/mcp-icon.d.ts +6 -0
- package/ui/mcp-icon.js +8 -0
- package/ui/row-keys.d.ts +19 -0
- package/ui/row-keys.js +33 -0
- package/ui/theme.d.ts +10 -0
- package/ui/theme.js +13 -0
- package/ui/theorem-theme.d.ts +10 -0
- package/ui/theorem-theme.js +105 -0
- package/ui/trace-palette.d.ts +10 -0
- package/ui/trace-palette.js +10 -0
- package/ui/trace-patterns.d.ts +40 -0
- package/ui/trace-patterns.js +57 -0
- package/ui/transcript-copy-text.d.ts +4 -0
- package/ui/transcript-copy-text.js +40 -0
- package/ui/wall-columns.d.ts +8 -0
- package/ui/wall-columns.js +19 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
import { withPublicLabels } from '@theoremjs/agents/guardrails';
|
|
2
|
+
import { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
|
|
3
|
+
/**
|
|
4
|
+
* One Web-standard handler that serves a Theorem chat UI for a single profile.
|
|
5
|
+
*
|
|
6
|
+
* Mount it on any framework that speaks `Request` → `Response` (Deno.serve,
|
|
7
|
+
* Hono, Next route handlers, SvelteKit, Workers) under a catch-all route:
|
|
8
|
+
*
|
|
9
|
+
* - `GET <base>` → `{ interface }` client-safe profile interface
|
|
10
|
+
* - `POST <base>/turn` → NDJSON turn events; a message sent while the reply
|
|
11
|
+
* waits on gates walks away from them first (`abandon`)
|
|
12
|
+
* - `POST <base>/invoke` → NDJSON events for the user's answer to a paused tool call
|
|
13
|
+
* - `POST <base>/steer` → inject messages into a running turn
|
|
14
|
+
* - `POST <base>/conversation` → the transcript and the paused reply, to draw
|
|
15
|
+
* - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
|
|
16
|
+
*
|
|
17
|
+
* `createTheoremHostHandler` serves a host profile the same way, with no model:
|
|
18
|
+
*
|
|
19
|
+
* - `GET <base>` → `{ interface }` the tools the page may call, with their schemas
|
|
20
|
+
* - `POST <base>/call` → NDJSON events for one call of one of those tools
|
|
21
|
+
* - `POST <base>/invoke` → NDJSON events for the user's answer to a paused call
|
|
22
|
+
* - `POST <base>/page-result` → grant-correlated browser result receipt
|
|
23
|
+
*
|
|
24
|
+
* Trust boundary: the host owns the conversation and every decision that grants
|
|
25
|
+
* authority. Two stores keep them. The `conversationStore` owns the transcript,
|
|
26
|
+
* the history the model reads and the paused reply; the browser sends only what
|
|
27
|
+
* the person did now, with the opaque ref the store issued. The session store
|
|
28
|
+
* owns which paused calls may run (and with what input) and the tool
|
|
29
|
+
* permissions — request bodies can't grant any of them. A profile whose `providerContinuation.history` is
|
|
30
|
+
* `'stateless'` keeps no conversation, and takes no store.
|
|
31
|
+
* Tool credentials are held on the server too: a key the user types at a gate
|
|
32
|
+
* is sent once and saved, and no credential is ever sent back to the browser.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
import { authorizeToolReplay, compactHistory, defaultKernelScope, defineProfile, invokeTool, jsonObjectSchema, labelText, registerProfile, runTurn, TheoremError, toolInvocationHash, } from '@theoremjs/agents';
|
|
37
|
+
import { caughtStatus, clientError, forClient, HTTP_METHOD, } from '@theoremjs/agents/host';
|
|
38
|
+
import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
|
|
39
|
+
import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
|
|
40
|
+
import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
|
|
41
|
+
import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
|
|
42
|
+
import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
|
|
43
|
+
import { unknownConversation } from "./conversation-store.js";
|
|
44
|
+
import { createMemoryCredentialStore, } from "./credential-store.js";
|
|
45
|
+
import { sessionStateStore } from "./session-authority.js";
|
|
46
|
+
import { theoremSession } from "./session-identity.js";
|
|
47
|
+
export { theoremSession, theoremSessionId } from "./session-identity.js";
|
|
48
|
+
import { jsonResponse } from "./json-response.js";
|
|
49
|
+
import { boundedRequestJson, requestByteLimit } from "./request-body.js";
|
|
50
|
+
import { checkRequest } from "./request-check.js";
|
|
51
|
+
import { resolveSessionLimits } from "./session-limits.js";
|
|
52
|
+
import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
|
|
53
|
+
import { createMemorySteerInbox, steerStage, steerUnitOf } from "./steer-inbox.js";
|
|
54
|
+
import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
|
|
55
|
+
const NDJSON_HEADERS = {
|
|
56
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
57
|
+
'cache-control': 'no-store',
|
|
58
|
+
};
|
|
59
|
+
function withSessionCookie(response, session) {
|
|
60
|
+
if (session?.setCookie)
|
|
61
|
+
response.headers.append('set-cookie', session.setCookie);
|
|
62
|
+
return response;
|
|
63
|
+
}
|
|
64
|
+
/** A JSON body checked against `schema`; a missing or malformed field is a `request` error (400). */
|
|
65
|
+
export async function readBody(request, schema, maxBytes) {
|
|
66
|
+
// why: JSON-only POSTs can't be sent by a cross-site form, and force a CORS preflight.
|
|
67
|
+
const type = request.headers.get('content-type') ?? '';
|
|
68
|
+
if (!type.toLowerCase().startsWith('application/json')) {
|
|
69
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
70
|
+
throw new TheoremError('request', 'Content-Type must be application/json');
|
|
71
|
+
}
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await boundedRequestJson(request, requestByteLimit(maxBytes));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof TheoremError)
|
|
78
|
+
throw error;
|
|
79
|
+
// invariant: A JSON parser's message can contain request values and must not enter diagnostics.
|
|
80
|
+
throw new TheoremError('request', labelText('error.request'), {
|
|
81
|
+
cause: { code: 'request_json' },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return checkRequest(schema, raw, 'request body');
|
|
85
|
+
}
|
|
86
|
+
/** Last path segment after the mount point: '', a chat's route, or a host's 'call'. */
|
|
87
|
+
function routeOf(request) {
|
|
88
|
+
const segments = new URL(request.url).pathname.split('/').filter(Boolean);
|
|
89
|
+
const last = segments.at(-1) ?? '';
|
|
90
|
+
return last === 'turn' ||
|
|
91
|
+
last === 'invoke' ||
|
|
92
|
+
last === 'steer' ||
|
|
93
|
+
last === 'conversation' ||
|
|
94
|
+
last === 'rewind' ||
|
|
95
|
+
last === 'call' ||
|
|
96
|
+
last === 'page-result'
|
|
97
|
+
? last
|
|
98
|
+
: '';
|
|
99
|
+
}
|
|
100
|
+
function lockedStore(store, loaded) {
|
|
101
|
+
const locks = new Map();
|
|
102
|
+
const read = async (sessionId) => loaded(await store.load(sessionId));
|
|
103
|
+
return {
|
|
104
|
+
read,
|
|
105
|
+
mutate(sessionId, change) {
|
|
106
|
+
const next = (locks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
107
|
+
const value = await read(sessionId);
|
|
108
|
+
const result = change(value);
|
|
109
|
+
await store.save(sessionId, value);
|
|
110
|
+
return result;
|
|
111
|
+
});
|
|
112
|
+
const settled = next.catch(() => { });
|
|
113
|
+
locks.set(sessionId, settled);
|
|
114
|
+
void settled.then(() => {
|
|
115
|
+
if (locks.get(sessionId) === settled)
|
|
116
|
+
locks.delete(sessionId);
|
|
117
|
+
});
|
|
118
|
+
return next;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** The profile as the server reads a message against it, its tools as registered now. */
|
|
123
|
+
function chatInterface(chat) {
|
|
124
|
+
return interfaceFromProfile(chat.profile, defaultKernelScope.tools);
|
|
125
|
+
}
|
|
126
|
+
async function sessionOf(ctx, request) {
|
|
127
|
+
if (!ctx.options.session)
|
|
128
|
+
return theoremSession(request);
|
|
129
|
+
const id = (await ctx.options.session(request))?.trim();
|
|
130
|
+
if (!id) {
|
|
131
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
132
|
+
throw new TheoremError('auth', 'the session resolver refused the request', {
|
|
133
|
+
copy: { key: 'session.sign_in' },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return { id };
|
|
137
|
+
}
|
|
138
|
+
/** Reports the error to the host, then words it for whoever reads this host's replies. */
|
|
139
|
+
function caughtError(ctx, err, request) {
|
|
140
|
+
ctx.options.onError?.(err, { request });
|
|
141
|
+
return clientError(err, ctx.profile.labels, ctx.options.clientEvents);
|
|
142
|
+
}
|
|
143
|
+
function eventStream(ctx, request, source) {
|
|
144
|
+
const encoder = new TextEncoder();
|
|
145
|
+
const line = (value) => encoder.encode(`${JSON.stringify(value)}\n`);
|
|
146
|
+
// invariant: The source runs to its end after the browser goes, so a stopped reply still reaches its save.
|
|
147
|
+
let cancelled = false;
|
|
148
|
+
const stream = new ReadableStream({
|
|
149
|
+
async start(controller) {
|
|
150
|
+
const send = (value) => {
|
|
151
|
+
if (!cancelled)
|
|
152
|
+
controller.enqueue(line(value));
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
for await (const event of source()) {
|
|
156
|
+
send(event.type === 'conversation' ? event : forClient(event, ctx.options.clientEvents));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (!request.signal.aborted)
|
|
161
|
+
send({ type: 'error', ...caughtError(ctx, err, request) });
|
|
162
|
+
}
|
|
163
|
+
if (!cancelled)
|
|
164
|
+
controller.close();
|
|
165
|
+
},
|
|
166
|
+
cancel() {
|
|
167
|
+
cancelled = true;
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
return new Response(stream, { headers: NDJSON_HEADERS });
|
|
171
|
+
}
|
|
172
|
+
function providerFor(ctx, request, model) {
|
|
173
|
+
const { provider } = ctx.options;
|
|
174
|
+
// labels-exempt: builder config error; the user reads error.config
|
|
175
|
+
if (!provider)
|
|
176
|
+
throw new TheoremError('config', `profile ${ctx.profile.id} has no provider`);
|
|
177
|
+
if (typeof provider === 'function')
|
|
178
|
+
return provider({ request, model });
|
|
179
|
+
return provider;
|
|
180
|
+
}
|
|
181
|
+
/** Steer inboxes are scoped to the session, so a turn id alone can't reach another user's turn. */
|
|
182
|
+
function inboxKey(sessionId, turnId) {
|
|
183
|
+
return `${sessionId}\u0000${turnId}`;
|
|
184
|
+
}
|
|
185
|
+
/** The exact paused calls the user may now approve, when the stream stopped on gates. */
|
|
186
|
+
function pendingGatesFrom(events, context) {
|
|
187
|
+
return gatedToolsFromEvents(events).map((gated) => ({
|
|
188
|
+
callId: gated.callId,
|
|
189
|
+
gate: {
|
|
190
|
+
profileId: context.profileId,
|
|
191
|
+
operationId: requiredOperationId(gated),
|
|
192
|
+
callId: gated.callId,
|
|
193
|
+
name: gated.name,
|
|
194
|
+
arguments: gated.arguments,
|
|
195
|
+
gate: {
|
|
196
|
+
kind: gated.gateKind,
|
|
197
|
+
permission: gated.permission,
|
|
198
|
+
...(gated.auth ? { auth: gated.auth } : {}),
|
|
199
|
+
},
|
|
200
|
+
snapshot: toolSnapshotFromEvents(events),
|
|
201
|
+
promoted: [...new Set([...(context.promoted ?? []), ...promotedToolIdsFromEvents(events)])],
|
|
202
|
+
turnInput: context.turnInput,
|
|
203
|
+
model: context.model,
|
|
204
|
+
createdAt: Date.now(),
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
/** When the stream paused on gates, record the exact calls the user may now approve. */
|
|
209
|
+
async function recordOutcome(sessions, sessionId, events, context) {
|
|
210
|
+
const pending = pendingGatesFrom(events, context);
|
|
211
|
+
if (!pending.length)
|
|
212
|
+
return;
|
|
213
|
+
await sessions.mutate(sessionId, (state) => {
|
|
214
|
+
for (const { gate } of pending) {
|
|
215
|
+
const operation = state.operations[gate.operationId];
|
|
216
|
+
if (operation && operation.status !== 'ready')
|
|
217
|
+
throw new TheoremError('unavailable', labelText('error.unavailable'));
|
|
218
|
+
delete state.operations[gate.operationId];
|
|
219
|
+
Object.defineProperty(state.gates, gate.operationId, {
|
|
220
|
+
value: gate,
|
|
221
|
+
writable: true,
|
|
222
|
+
enumerable: true,
|
|
223
|
+
configurable: true,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function* recorded(sessions, sessionId, events, context, seed = []) {
|
|
229
|
+
const seen = [...seed];
|
|
230
|
+
for await (const event of events) {
|
|
231
|
+
seen.push(event);
|
|
232
|
+
if (event.type === 'done' || event.type === 'provider_checkpoint')
|
|
233
|
+
await recordOutcome(sessions, sessionId, seen, context);
|
|
234
|
+
yield event;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Save one credential, leaving the session's others as they are. */
|
|
238
|
+
function saveCredential(ctx, sessionId, slot, credential) {
|
|
239
|
+
return ctx.credentials.mutate(sessionId, (saved) => {
|
|
240
|
+
saved[slot] = credential;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The session's credentials as a call reads them: one slot when a tool needs it,
|
|
245
|
+
* saved the moment the kernel sets one, so a rotated refresh token is never lost
|
|
246
|
+
* to a closed stream.
|
|
247
|
+
*/
|
|
248
|
+
function sessionCredentials(ctx, sessionId) {
|
|
249
|
+
return {
|
|
250
|
+
get: async (slot) => (await ctx.credentials.read(sessionId))[slot],
|
|
251
|
+
set: (slot, credential) => saveCredential(ctx, sessionId, slot, credential),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/** An OAuth gate given the host's sign-in URL. */
|
|
255
|
+
async function withAuthorizationUrl(ctx, request, sessionId, event) {
|
|
256
|
+
if (event.type !== 'tool' || event.tool.phase !== 'gate')
|
|
257
|
+
return event;
|
|
258
|
+
const { tool } = event;
|
|
259
|
+
const gate = tool.gate.kind === 'auth' ? tool.gate : undefined;
|
|
260
|
+
if (gate?.authChallenge.authType !== 'oauth2' || !ctx.options.authorizationUrl)
|
|
261
|
+
return event;
|
|
262
|
+
const challenge = gate.authChallenge;
|
|
263
|
+
const authorizationUrl = await ctx.options.authorizationUrl(challenge, { request, sessionId });
|
|
264
|
+
return {
|
|
265
|
+
...event,
|
|
266
|
+
tool: { ...tool, gate: { ...gate, authChallenge: { ...challenge, authorizationUrl } } },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Every OAuth gate in the stream, given the host's sign-in URL. */
|
|
270
|
+
async function* withAuthorizationUrls(ctx, request, sessionId, events) {
|
|
271
|
+
for await (const event of events)
|
|
272
|
+
yield await withAuthorizationUrl(ctx, request, sessionId, event);
|
|
273
|
+
}
|
|
274
|
+
function answerPendingGate(state, request, profileId) {
|
|
275
|
+
const operation = Object.hasOwn(state.operations, request.operationId)
|
|
276
|
+
? state.operations[request.operationId]
|
|
277
|
+
: undefined;
|
|
278
|
+
if (operation?.status === 'ready' &&
|
|
279
|
+
operation.profileId === profileId &&
|
|
280
|
+
operation.pending.callId === request.callId) {
|
|
281
|
+
const accepted = answerGatedCall(request, {
|
|
282
|
+
name: operation.pending.name,
|
|
283
|
+
arguments: operation.pending.arguments,
|
|
284
|
+
permission: operation.pending.gate.permission,
|
|
285
|
+
gateKind: operation.pending.gate.kind,
|
|
286
|
+
...(operation.pending.gate.kind === 'auth' && operation.pending.gate.auth
|
|
287
|
+
? { auth: operation.pending.gate.auth }
|
|
288
|
+
: {}),
|
|
289
|
+
}, operation.answer.sessionPermissions);
|
|
290
|
+
const { typed: _typed, ...plain } = accepted;
|
|
291
|
+
if (!sameAcceptedAnswer(plain, operation.answer))
|
|
292
|
+
throw new TheoremError('request', labelText('error.request'));
|
|
293
|
+
return {
|
|
294
|
+
callId: request.callId,
|
|
295
|
+
pending: operation.pending,
|
|
296
|
+
answer: { ...operation.answer, ...(accepted.typed ? { typed: accepted.typed } : {}) },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const pending = Object.hasOwn(state.gates, request.operationId)
|
|
300
|
+
? state.gates[request.operationId]
|
|
301
|
+
: undefined;
|
|
302
|
+
if (!pending || pending.profileId !== profileId || pending.callId !== request.callId) {
|
|
303
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
304
|
+
throw new TheoremError('request', `gate ${request.callId} is not pending`, {
|
|
305
|
+
copy: { key: 'session.gate_expired' },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
// why: A refused answer (a refused secret, an edit without an approval) throws here and leaves the gate pending.
|
|
309
|
+
const answer = answerGatedCall(request, {
|
|
310
|
+
name: pending.name,
|
|
311
|
+
arguments: pending.arguments,
|
|
312
|
+
permission: pending.gate.permission,
|
|
313
|
+
gateKind: pending.gate.kind,
|
|
314
|
+
...(pending.gate.kind === 'auth' && pending.gate.auth ? { auth: pending.gate.auth } : {}),
|
|
315
|
+
}, state.permissions);
|
|
316
|
+
delete state.gates[request.operationId];
|
|
317
|
+
const { typed: _typed, ...accepted } = answer;
|
|
318
|
+
state.operations[request.operationId] = {
|
|
319
|
+
status: 'ready',
|
|
320
|
+
profileId,
|
|
321
|
+
pending,
|
|
322
|
+
answer: accepted,
|
|
323
|
+
readyAt: Date.now(),
|
|
324
|
+
};
|
|
325
|
+
state.permissions = answer.sessionPermissions;
|
|
326
|
+
return { callId: request.callId, pending, answer };
|
|
327
|
+
}
|
|
328
|
+
function walkedCallOf(state, reference, profileId) {
|
|
329
|
+
const settled = Object.hasOwn(state.operations, reference.operationId)
|
|
330
|
+
? state.operations[reference.operationId]
|
|
331
|
+
: undefined;
|
|
332
|
+
return settled?.status === 'settled' &&
|
|
333
|
+
settled.callId === reference.callId &&
|
|
334
|
+
settled.profileId === profileId
|
|
335
|
+
? { callId: reference.callId, settled }
|
|
336
|
+
: answerPendingGate(state, { ...reference, decision: 'abandon' }, profileId);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Take the calls a message or a rewind walks away from out of the session,
|
|
340
|
+
* answered `abandon`: every one of them, or none when one is neither waiting
|
|
341
|
+
* nor settled. Each comes back with the run that settles it.
|
|
342
|
+
*/
|
|
343
|
+
async function takeWalkedAway(ctx, request, session, abandon) {
|
|
344
|
+
// why: A throw leaves the session unsaved, so no call is taken unless all are.
|
|
345
|
+
const calls = await ctx.sessions.mutate(session.id, (state) => abandon.map((reference) => ({
|
|
346
|
+
reference,
|
|
347
|
+
call: walkedCallOf(state, reference, ctx.profile.id),
|
|
348
|
+
})));
|
|
349
|
+
return calls.map(({ reference, call }) => ({
|
|
350
|
+
...reference,
|
|
351
|
+
events: 'settled' in call
|
|
352
|
+
? replayOperation(ctx, request, call.settled)
|
|
353
|
+
: settleAnswered(ctx, request, session, call),
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
async function* settleAnswered(ctx, request, session, answered) {
|
|
357
|
+
yield* invokeAnswered(ctx, request, session.id, answered);
|
|
358
|
+
}
|
|
359
|
+
async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
|
|
360
|
+
const host = await ctx.options.host?.(request);
|
|
361
|
+
yield* invokeTool({
|
|
362
|
+
profile: ctx.profile.id,
|
|
363
|
+
execution: sessionToolCoordinator(ctx.sessions, sessionId),
|
|
364
|
+
pageExecution: sessionPageDispatch(ctx.sessions, sessionId, ctx.pageToolBroker).execution,
|
|
365
|
+
name: pending.name,
|
|
366
|
+
callId,
|
|
367
|
+
operationId: pending.operationId,
|
|
368
|
+
input: answer.input,
|
|
369
|
+
resume: answer.resume,
|
|
370
|
+
// why: An approval that names no answer is the page saying it has none, so the call is not held again.
|
|
371
|
+
...(pending.gate.kind === 'page' && answer.resume.granted
|
|
372
|
+
? { page: answer.page ?? { unanswered: true } }
|
|
373
|
+
: {}),
|
|
374
|
+
sessionPermissions: answer.sessionPermissions,
|
|
375
|
+
credentials: sessionCredentials(ctx, sessionId),
|
|
376
|
+
turnInput: pending.turnInput,
|
|
377
|
+
snapshot: pending.snapshot,
|
|
378
|
+
promoted: pending.promoted,
|
|
379
|
+
model: pending.model,
|
|
380
|
+
signal: request.signal,
|
|
381
|
+
host,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** A settled call's answer, streamed again. */
|
|
385
|
+
async function* once(event, labels) {
|
|
386
|
+
yield withPublicLabels(event, labels);
|
|
387
|
+
}
|
|
388
|
+
/** One model run of a chat's reply, its gates recorded in the session as they pause. */
|
|
389
|
+
async function* chatTurn(ctx, request, session, turn, turnId) {
|
|
390
|
+
const state = await ctx.sessions.read(session.id);
|
|
391
|
+
const provider = await providerFor(ctx, request, turn.model);
|
|
392
|
+
const host = await ctx.options.host?.(request);
|
|
393
|
+
const key = turnId ? inboxKey(session.id, turnId) : undefined;
|
|
394
|
+
if (key)
|
|
395
|
+
await ctx.inbox.open(key);
|
|
396
|
+
try {
|
|
397
|
+
const events = runTurn({
|
|
398
|
+
profile: ctx.profile.id,
|
|
399
|
+
execution: sessionToolCoordinator(ctx.sessions, session.id),
|
|
400
|
+
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
|
|
401
|
+
input: turn.input,
|
|
402
|
+
...(turn.providerState ? { providerState: turn.providerState } : {}),
|
|
403
|
+
sessionPermissions: state.permissions,
|
|
404
|
+
credentials: sessionCredentials(ctx, session.id),
|
|
405
|
+
signal: request.signal,
|
|
406
|
+
host,
|
|
407
|
+
...(turn.model ? { model: turn.model } : {}),
|
|
408
|
+
...(turn.effort ? { effort: turn.effort } : {}),
|
|
409
|
+
...(key ? { onStage: steerStage(ctx.inbox, key) } : {}),
|
|
410
|
+
}, provider);
|
|
411
|
+
yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
|
|
412
|
+
profileId: ctx.profile.id,
|
|
413
|
+
turnInput: turn.input,
|
|
414
|
+
model: turn.model,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
if (key)
|
|
419
|
+
await ctx.inbox.close(key);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** How this request runs the model for a reply: its generation, and the request's abort. */
|
|
423
|
+
function chatRunner(ctx, request, session, generation) {
|
|
424
|
+
return {
|
|
425
|
+
run: (turn) => chatTurn(ctx, request, session, turn, generation.turnId),
|
|
426
|
+
async compact(history, tokens, model) {
|
|
427
|
+
const provider = await providerFor(ctx, request, model);
|
|
428
|
+
const compacted = await compactHistory({
|
|
429
|
+
profile: ctx.profile.id,
|
|
430
|
+
history,
|
|
431
|
+
tokens,
|
|
432
|
+
signal: request.signal,
|
|
433
|
+
...(model ? { model } : {}),
|
|
434
|
+
}, provider);
|
|
435
|
+
return compacted?.history;
|
|
436
|
+
},
|
|
437
|
+
...(generation.model ? { model: generation.model } : {}),
|
|
438
|
+
...(generation.effort ? { effort: generation.effort } : {}),
|
|
439
|
+
signal: request.signal,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** The chat's conversation as this session's runs read and write it; undefined when the profile keeps none. */
|
|
443
|
+
function conversationHost(ctx, session) {
|
|
444
|
+
const store = ctx.chat?.conversations;
|
|
445
|
+
if (!ctx.chat || !store)
|
|
446
|
+
return undefined;
|
|
447
|
+
return readerConversationHost({
|
|
448
|
+
sessionId: session.id,
|
|
449
|
+
store,
|
|
450
|
+
profile: { outputs: ctx.chat.profile.outputs, labels: ctx.profile.labels },
|
|
451
|
+
clientEvents: ctx.options.clientEvents,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/** The person's message: a new reply, run against the conversation the host keeps. */
|
|
455
|
+
async function turn(ctx, chat, request, session) {
|
|
456
|
+
const body = await readBody(request, theoremTurnRequestSchema, ctx.options.maxRequestBytes);
|
|
457
|
+
const message = postedMessage(chatInterface(chat), body.input, await ctx.options.context?.(request));
|
|
458
|
+
const runner = chatRunner(ctx, request, session, body);
|
|
459
|
+
const host = conversationHost(ctx, session);
|
|
460
|
+
if (!host) {
|
|
461
|
+
if (body.conversation !== undefined)
|
|
462
|
+
throw statelessRefusal('continue');
|
|
463
|
+
// labels-exempt: internal diagnostic; the user reads error.request
|
|
464
|
+
if (body.abandon)
|
|
465
|
+
throw statelessRefusal('walk away from');
|
|
466
|
+
return eventStream(ctx, request, () => runner.run({ input: message.input, model: body.model, effort: body.effort }));
|
|
467
|
+
}
|
|
468
|
+
const opened = await openConversation(host, body.conversation, {
|
|
469
|
+
kind: 'message',
|
|
470
|
+
...(body.abandon ? { abandon: body.abandon } : {}),
|
|
471
|
+
});
|
|
472
|
+
// why: Take the walked-away calls before streaming, so a stale one is a reply status, not a stream error.
|
|
473
|
+
const walkAway = body.abandon
|
|
474
|
+
? await takeWalkedAway(ctx, request, session, body.abandon)
|
|
475
|
+
: undefined;
|
|
476
|
+
return eventStream(ctx, request, () => messageReply({ host, opened, message, ...(walkAway ? { walkAway } : {}), runner }));
|
|
477
|
+
}
|
|
478
|
+
/** The conversation as the browser draws it. */
|
|
479
|
+
async function conversation(ctx, request, session) {
|
|
480
|
+
const body = await readBody(request, theoremConversationRequestSchema, ctx.options.maxRequestBytes);
|
|
481
|
+
const host = conversationHost(ctx, session);
|
|
482
|
+
if (!host)
|
|
483
|
+
throw statelessRefusal('read');
|
|
484
|
+
const opened = await openConversation(host, body.conversation, { kind: 'read' });
|
|
485
|
+
return jsonResponse(200, conversationView(host, opened.conversation));
|
|
486
|
+
}
|
|
487
|
+
/** The conversation cut after one block; a reply that waits on gates is walked away from first. */
|
|
488
|
+
async function rewind(ctx, request, session) {
|
|
489
|
+
const body = await readBody(request, theoremRewindRequestSchema, ctx.options.maxRequestBytes);
|
|
490
|
+
const host = conversationHost(ctx, session);
|
|
491
|
+
if (!host)
|
|
492
|
+
throw statelessRefusal('rewind');
|
|
493
|
+
const opened = await openConversation(host, body.conversation, { kind: 'rewind', at: body.at });
|
|
494
|
+
const waiting = waitingCalls(opened.conversation);
|
|
495
|
+
const walkAway = waiting.length
|
|
496
|
+
? await takeWalkedAway(ctx, request, session, waiting)
|
|
497
|
+
: undefined;
|
|
498
|
+
const next = await rewindConversation({
|
|
499
|
+
host,
|
|
500
|
+
opened,
|
|
501
|
+
at: body.at,
|
|
502
|
+
...(walkAway ? { walkAway } : {}),
|
|
503
|
+
});
|
|
504
|
+
if (next.ref === null)
|
|
505
|
+
throw new TheoremError('internal', labelText('error.internal'));
|
|
506
|
+
const rewound = {
|
|
507
|
+
conversation: next.ref,
|
|
508
|
+
view: conversationView(host, next.conversation),
|
|
509
|
+
};
|
|
510
|
+
return jsonResponse(200, rewound);
|
|
511
|
+
}
|
|
512
|
+
async function* invokeEvents(ctx, request, session, answered) {
|
|
513
|
+
const { pending, answer } = answered;
|
|
514
|
+
if (answer.typed)
|
|
515
|
+
await saveCredential(ctx, session.id, answer.typed.slot, answer.typed.credential);
|
|
516
|
+
const events = settleAnswered(ctx, request, session, answered);
|
|
517
|
+
yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
|
|
518
|
+
profileId: ctx.profile.id,
|
|
519
|
+
turnInput: pending.turnInput,
|
|
520
|
+
model: pending.model,
|
|
521
|
+
promoted: pending.promoted,
|
|
522
|
+
}, [
|
|
523
|
+
{
|
|
524
|
+
type: 'tool',
|
|
525
|
+
tool: {
|
|
526
|
+
name: pending.name,
|
|
527
|
+
callId: pending.callId,
|
|
528
|
+
operationId: pending.operationId,
|
|
529
|
+
arguments: jsonObjectSchema.parse(answer.input),
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
}
|
|
534
|
+
async function steer(ctx, request, session) {
|
|
535
|
+
const body = await readBody(request, theoremSteerRequestSchema, ctx.options.maxRequestBytes);
|
|
536
|
+
if (!(await ctx.inbox.enqueue(inboxKey(session.id, body.turnId), steerUnitOf(body)))) {
|
|
537
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
538
|
+
throw new TheoremError('request', `turn ${body.turnId} is not running`, {
|
|
539
|
+
copy: { key: 'session.turn_ended' },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return jsonResponse(200, { ok: true });
|
|
543
|
+
}
|
|
544
|
+
/** One call of one of a host's tools, run as the page asked it; a gate it pauses on waits in the session. */
|
|
545
|
+
async function* callEvents(ctx, request, session, body) {
|
|
546
|
+
const state = await ctx.sessions.read(session.id);
|
|
547
|
+
const host = await ctx.options.host?.(request);
|
|
548
|
+
const events = invokeTool({
|
|
549
|
+
profile: ctx.profile.id,
|
|
550
|
+
execution: sessionToolCoordinator(ctx.sessions, session.id),
|
|
551
|
+
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
|
|
552
|
+
name: body.name,
|
|
553
|
+
input: body.input,
|
|
554
|
+
sessionPermissions: state.permissions,
|
|
555
|
+
credentials: sessionCredentials(ctx, session.id),
|
|
556
|
+
signal: request.signal,
|
|
557
|
+
host,
|
|
558
|
+
});
|
|
559
|
+
yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
|
|
560
|
+
profileId: ctx.profile.id,
|
|
561
|
+
turnInput: {},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function answerRequest(body) {
|
|
565
|
+
return {
|
|
566
|
+
callId: body.gateId,
|
|
567
|
+
operationId: body.operationId,
|
|
568
|
+
decision: body.decision,
|
|
569
|
+
input: body.input,
|
|
570
|
+
secret: body.secret,
|
|
571
|
+
page: body.page,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/** A route this profile doesn't serve. */
|
|
575
|
+
function refused(ctx) {
|
|
576
|
+
return jsonResponse(HTTP_METHOD, {
|
|
577
|
+
error: labelText('error.request', {}, ctx.profile.labels),
|
|
578
|
+
errorKind: 'request',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function* replayOperation(ctx, request, operation) {
|
|
582
|
+
const tool = defaultKernelScope.tools.get(operation.toolId);
|
|
583
|
+
if (operation.profileId !== ctx.profile.id ||
|
|
584
|
+
!tool ||
|
|
585
|
+
tool.type === 'builtin' ||
|
|
586
|
+
!('tools' in ctx.profile) ||
|
|
587
|
+
!ctx.profile.tools?.allow.includes(operation.toolId) ||
|
|
588
|
+
(ctx.options.authorizeToolReplay &&
|
|
589
|
+
!(await ctx.options.authorizeToolReplay({ request, operation }))))
|
|
590
|
+
throw new TheoremError('blocked', labelText('error.blocked'));
|
|
591
|
+
authorizeToolReplay({
|
|
592
|
+
tools: defaultKernelScope.tools,
|
|
593
|
+
profile: ctx.profile,
|
|
594
|
+
name: operation.toolId,
|
|
595
|
+
resume: undefined,
|
|
596
|
+
outcome: operation.outcome,
|
|
597
|
+
});
|
|
598
|
+
yield* once(operation.outcome.event, ctx.profile.labels);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The user's answer to a paused call. In a kept conversation the reply goes on
|
|
602
|
+
* in the same stream once no call waits; otherwise the stream is the call's.
|
|
603
|
+
*/
|
|
604
|
+
async function invoke(ctx, request, session) {
|
|
605
|
+
const body = await readBody(request, theoremInvokeRequestSchema, ctx.options.maxRequestBytes);
|
|
606
|
+
if (body.page && 'output' in body.page)
|
|
607
|
+
throw new TheoremError('request', labelText('error.request'));
|
|
608
|
+
const host = conversationHost(ctx, session);
|
|
609
|
+
if (!host) {
|
|
610
|
+
if (body.conversation !== undefined)
|
|
611
|
+
throw statelessRefusal('continue');
|
|
612
|
+
return eventStream(ctx, request, await settlingOf(ctx, request, session, body));
|
|
613
|
+
}
|
|
614
|
+
if (body.conversation === undefined)
|
|
615
|
+
throw unknownConversation();
|
|
616
|
+
const call = { callId: body.gateId, operationId: body.operationId };
|
|
617
|
+
const opened = await openConversation(host, body.conversation, { kind: 'answer', call });
|
|
618
|
+
const settling = await settlingOf(ctx, request, session, body);
|
|
619
|
+
const runner = chatRunner(ctx, request, session, body);
|
|
620
|
+
return eventStream(ctx, request, () => answerReply({ host, opened, call, settling: settling(), runner }));
|
|
621
|
+
}
|
|
622
|
+
/** The answered call's events: its saved outcome again, or the call run as answered. */
|
|
623
|
+
async function settlingOf(ctx, request, session, body) {
|
|
624
|
+
const saved = await ctx.sessions.read(session.id);
|
|
625
|
+
const operation = Object.hasOwn(saved.operations, body.operationId)
|
|
626
|
+
? saved.operations[body.operationId]
|
|
627
|
+
: undefined;
|
|
628
|
+
if (operation &&
|
|
629
|
+
operation.status !== 'ready' &&
|
|
630
|
+
operation.callId === body.gateId &&
|
|
631
|
+
operation.profileId === ctx.profile.id &&
|
|
632
|
+
(operation.status === 'running' || operation.status === 'uncertain')) {
|
|
633
|
+
throw new TheoremError('unavailable', labelText('tool.execution_uncertain'), {
|
|
634
|
+
copy: { key: 'tool.execution_uncertain' },
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (operation?.status === 'settled' &&
|
|
638
|
+
operation.profileId === ctx.profile.id &&
|
|
639
|
+
operation.callId === body.gateId) {
|
|
640
|
+
if (body.input !== undefined &&
|
|
641
|
+
(!operation.invocation ||
|
|
642
|
+
(await toolInvocationHash(body.input)) !==
|
|
643
|
+
(await toolInvocationHash(operation.invocation.accepted.input))))
|
|
644
|
+
throw new TheoremError('request', labelText('error.request'), {
|
|
645
|
+
copy: { key: 'error.request' },
|
|
646
|
+
});
|
|
647
|
+
return () => replayOperation(ctx, request, operation);
|
|
648
|
+
}
|
|
649
|
+
// why: Take the answered call before streaming, so a stale answer is a reply status, not a stream error.
|
|
650
|
+
const answered = await ctx.sessions.mutate(session.id, (state) => answerPendingGate(state, answerRequest(body), ctx.profile.id));
|
|
651
|
+
return () => invokeEvents(ctx, request, session, answered);
|
|
652
|
+
}
|
|
653
|
+
async function hostRoute(ctx, request, session) {
|
|
654
|
+
const target = routeOf(request);
|
|
655
|
+
if (request.method === 'GET' && target === '') {
|
|
656
|
+
const tool = (name) => {
|
|
657
|
+
const registered = defaultKernelScope.tools.get(name);
|
|
658
|
+
return registered?.type === 'builtin' ? undefined : registered;
|
|
659
|
+
};
|
|
660
|
+
return jsonResponse(200, { interface: hostInterface(ctx.profile, tool) });
|
|
661
|
+
}
|
|
662
|
+
if (request.method === 'POST' && target === 'call') {
|
|
663
|
+
const body = await readBody(request, theoremHostCallRequestSchema, ctx.options.maxRequestBytes);
|
|
664
|
+
return eventStream(ctx, request, () => callEvents(ctx, request, session, body));
|
|
665
|
+
}
|
|
666
|
+
if (request.method === 'POST' && target === 'invoke')
|
|
667
|
+
return invoke(ctx, request, session);
|
|
668
|
+
return refused(ctx);
|
|
669
|
+
}
|
|
670
|
+
async function route(ctx, request, session) {
|
|
671
|
+
if (request.method === 'POST' && routeOf(request) === 'page-result') {
|
|
672
|
+
const body = await readBody(request, pageToolResultSchema, ctx.options.maxRequestBytes);
|
|
673
|
+
await sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).answer(body);
|
|
674
|
+
return jsonResponse(200, { accepted: true });
|
|
675
|
+
}
|
|
676
|
+
if (ctx.profile.type === 'host')
|
|
677
|
+
return hostRoute({ ...ctx, profile: ctx.profile }, request, session);
|
|
678
|
+
const { chat } = ctx;
|
|
679
|
+
const target = routeOf(request);
|
|
680
|
+
if (!chat)
|
|
681
|
+
return refused(ctx);
|
|
682
|
+
if (request.method === 'GET' && target === '')
|
|
683
|
+
return jsonResponse(200, {
|
|
684
|
+
interface: clientProfileInterface(chat.profile, defaultKernelScope.tools),
|
|
685
|
+
});
|
|
686
|
+
if (request.method !== 'POST' || target === '' || target === 'call')
|
|
687
|
+
return refused(ctx);
|
|
688
|
+
if (target === 'turn')
|
|
689
|
+
return turn(ctx, chat, request, session);
|
|
690
|
+
if (target === 'invoke')
|
|
691
|
+
return invoke(ctx, request, session);
|
|
692
|
+
if (target === 'conversation')
|
|
693
|
+
return conversation(ctx, request, session);
|
|
694
|
+
if (target === 'rewind')
|
|
695
|
+
return rewind(ctx, request, session);
|
|
696
|
+
return steer(ctx, request, session);
|
|
697
|
+
}
|
|
698
|
+
export function createTheoremHandler(options) {
|
|
699
|
+
const profile = defineProfile(options.profile);
|
|
700
|
+
if (profile.type === 'decision') {
|
|
701
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
702
|
+
throw new Error("createTheoremHandler serves turn-based profiles; serve type 'decision' with createTheoremDecisionHandler.");
|
|
703
|
+
}
|
|
704
|
+
if (profile.type === 'host') {
|
|
705
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
706
|
+
throw new Error("createTheoremHandler serves turn-based profiles; serve type 'host' with createTheoremHostHandler.");
|
|
707
|
+
}
|
|
708
|
+
if (profile.type === 'live' || profile.type === 'embedding') {
|
|
709
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
710
|
+
throw new Error(`createTheoremHandler serves turn-based profiles; got type '${profile.type}'.`);
|
|
711
|
+
}
|
|
712
|
+
const stateless = profile.providerContinuation?.history === 'stateless';
|
|
713
|
+
if (stateless === (options.conversationStore !== undefined)) {
|
|
714
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
715
|
+
cause: {
|
|
716
|
+
code: stateless ? 'conversation_store_stateless' : 'conversation_store_missing',
|
|
717
|
+
profile: profile.id,
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return serve('createTheoremHandler', profile, options, {
|
|
722
|
+
steerInbox: options.steerInbox,
|
|
723
|
+
chat: {
|
|
724
|
+
profile,
|
|
725
|
+
...(options.conversationStore ? { conversations: options.conversationStore } : {}),
|
|
726
|
+
},
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Serves a host profile to `<TheoremHost />`: the page calls the profile's tools
|
|
731
|
+
* directly, and every call runs through the kernel's gates and guardrails.
|
|
732
|
+
*/
|
|
733
|
+
export function createTheoremHostHandler(options) {
|
|
734
|
+
const profile = defineProfile(options.profile);
|
|
735
|
+
if (profile.type !== 'host') {
|
|
736
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
737
|
+
throw new Error(`createTheoremHostHandler serves type 'host'; got type '${profile.type}'.`);
|
|
738
|
+
}
|
|
739
|
+
return serve('createTheoremHostHandler', profile, options);
|
|
740
|
+
}
|
|
741
|
+
function serve(name, profile, options, chatOptions = {}) {
|
|
742
|
+
const gateTtlMs = resolveGateTtlMs(name, options.gateTtlMs);
|
|
743
|
+
const operationRetentionMs = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
|
|
744
|
+
if (!Number.isSafeInteger(operationRetentionMs) || operationRetentionMs <= 0) {
|
|
745
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
746
|
+
cause: { code: 'operation_retention' },
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
const limits = resolveSessionLimits(options.sessionLimits);
|
|
750
|
+
requestByteLimit(options.maxRequestBytes);
|
|
751
|
+
registerProfile(profile);
|
|
752
|
+
const ctx = {
|
|
753
|
+
options,
|
|
754
|
+
pageToolBroker: options.pageToolBroker ?? createMemoryPageToolBroker(),
|
|
755
|
+
profile,
|
|
756
|
+
...(chatOptions.chat ? { chat: chatOptions.chat } : {}),
|
|
757
|
+
inbox: chatOptions.steerInbox ?? createMemorySteerInbox(),
|
|
758
|
+
sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore({ sessionLimits: limits }), gateTtlMs, operationRetentionMs, limits),
|
|
759
|
+
credentials: lockedStore(options.credentialStore ?? createMemoryCredentialStore(), (saved = {}) => saved),
|
|
760
|
+
};
|
|
761
|
+
return async (request) => {
|
|
762
|
+
let session;
|
|
763
|
+
try {
|
|
764
|
+
session = await sessionOf(ctx, request);
|
|
765
|
+
return withSessionCookie(await route(ctx, request, session), session);
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
return withSessionCookie(jsonResponse(caughtStatus(err), caughtError(ctx, err, request)), session);
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
}
|