@theoremjs/react 0.3.0 → 0.3.2
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 +746 -202
- package/client/composer-hints.js +1 -1
- package/client/decision-images.d.ts +76 -0
- package/client/decision-images.js +139 -0
- package/client/decision-transport.d.ts +26 -4
- package/client/decision-transport.js +79 -6
- 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/failure.d.ts +7 -10
- package/client/failure.js +9 -9
- package/client/host-call.d.ts +1 -1
- package/client/host-call.js +7 -2
- package/client/host-transport.d.ts +2 -0
- package/client/host-transport.js +3 -2
- package/client/index.d.ts +19 -11
- package/client/index.js +14 -10
- package/client/ink-waveform.d.ts +1 -1
- package/client/ink-waveform.js +1 -2
- package/client/live/apply-live-turn-tool-event.js +1 -1
- package/client/live/live-captions.d.ts +30 -0
- package/client/live/live-captions.js +105 -1
- package/client/live/live-mic-forward.d.ts +1 -0
- package/client/live/live-mic-forward.js +11 -2
- 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 +4 -2
- package/client/live/live-state.js +2 -0
- package/client/live/live-video.js +1 -1
- package/client/live/run-live-tool-call.d.ts +7 -2
- package/client/live/run-live-tool-call.js +48 -10
- 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 +52 -4
- package/client/live-client.js +250 -59
- package/client/live-messages.d.ts +51 -13
- package/client/live-messages.js +42 -9
- package/client/page-dispatch.d.ts +15 -0
- package/client/page-dispatch.js +63 -0
- package/client/run-commit.d.ts +16 -43
- package/client/run-commit.js +26 -101
- package/client/run-session.d.ts +40 -41
- package/client/run-session.js +104 -112
- package/client/schema-fields.js +27 -6
- package/client/shaped-data.js +37 -8
- package/client/source-citations.js +1 -1
- package/client/space-geometry.d.ts +39 -0
- package/client/space-geometry.js +183 -0
- package/client/state-fields.js +1 -1
- package/client/tool-result.js +30 -12
- package/client/tool-resume.d.ts +18 -1
- package/client/tool-resume.js +4 -1
- package/client/trace-feed.d.ts +1 -1
- package/client/trace-story.d.ts +2 -2
- package/client/trace-story.js +43 -19
- package/client/trace-view.d.ts +2 -2
- package/client/trace-view.js +56 -16
- package/client/transcript-groups.d.ts +28 -2
- package/client/transcript-groups.js +63 -13
- package/client/transcript-schema.d.ts +10 -0
- package/client/transcript-schema.js +120 -0
- package/client/transport.d.ts +114 -26
- package/client/transport.js +106 -32
- package/client/turn-client.d.ts +17 -8
- package/client/turn-client.js +26 -31
- package/client/voice-recorder.d.ts +1 -1
- package/client/voice-recorder.js +2 -5
- package/client/wire-line.js +5 -5
- package/client/worklets/mic-capture.js +9 -9
- package/components/InkWaveform.d.ts +12 -1
- package/components/InkWaveform.js +65 -42
- package/components/live/use-live-runner-controls.d.ts +3 -4
- package/components/live/use-live-runner-controls.js +6 -10
- package/components/live/use-live-runner-model.d.ts +10 -6
- package/components/live/use-live-runner-model.js +29 -14
- package/components/live/use-live-runner-ui.d.ts +10 -8
- package/components/live/use-live-runner-ui.js +19 -11
- package/components/live/use-live-session-client.d.ts +4 -4
- package/components/live/use-live-session-client.js +11 -8
- package/components/use-composer-voice.d.ts +4 -4
- package/components/use-composer-voice.js +8 -8
- package/components/use-editor-selection.js +4 -1
- package/components/use-voice-playback.d.ts +2 -1
- package/components/use-voice-playback.js +4 -4
- package/hooks/use-described.d.ts +5 -1
- package/hooks/use-described.js +11 -9
- 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 +25 -21
- package/hooks/use-theorem-chat-actions.js +67 -45
- package/hooks/use-theorem-chat-state.d.ts +6 -6
- package/hooks/use-theorem-chat-state.js +13 -9
- package/hooks/use-theorem-chat.d.ts +48 -14
- package/hooks/use-theorem-chat.js +329 -141
- package/hooks/use-theorem-decision.d.ts +5 -10
- package/hooks/use-theorem-decision.js +4 -67
- package/hooks/use-theorem-embedding.d.ts +18 -0
- package/hooks/use-theorem-embedding.js +12 -0
- package/hooks/use-theorem-host.d.ts +5 -4
- package/hooks/use-theorem-host.js +26 -8
- package/hooks/use-theorem-interface.d.ts +6 -3
- package/hooks/use-theorem-interface.js +12 -22
- package/index.d.ts +6 -3
- package/index.js +5 -2
- package/live.d.ts +1 -0
- package/live.js +1 -1
- package/package.json +6 -5
- 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/decision-handler.d.ts +12 -4
- package/server/decision-handler.js +29 -38
- package/server/embedding-handler.d.ts +33 -0
- package/server/embedding-handler.js +45 -0
- package/server/handler.d.ts +64 -20
- package/server/handler.js +455 -248
- package/server/mcp-handler.d.ts +46 -0
- package/server/mcp-handler.js +312 -0
- package/server/mod.d.ts +17 -7
- package/server/mod.js +15 -6
- 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 +6 -1
- package/server/request-check.js +27 -12
- 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 +69 -20
- package/server/session-store.js +220 -5
- package/server/single-action.d.ts +21 -0
- package/server/single-action.js +30 -0
- package/server/steer-inbox.js +1 -1
- 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 +78 -78
- package/ui/CardWall.d.ts +10 -0
- package/ui/CardWall.js +61 -0
- package/ui/ChatComposerBar.d.ts +28 -5
- package/ui/ChatComposerBar.js +85 -30
- package/ui/ChatTranscript.d.ts +14 -1
- package/ui/ChatTranscript.js +110 -57
- package/ui/ConsoleFrame.d.ts +61 -4
- package/ui/ConsoleFrame.js +109 -9
- package/ui/DecisionAnswers.d.ts +40 -2
- package/ui/DecisionAnswers.js +166 -35
- 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 +6 -4
- package/ui/LiveRunner.js +42 -55
- package/ui/PaneState.d.ts +33 -0
- package/ui/PaneState.js +50 -0
- package/ui/SchemaFields.d.ts +1 -1
- package/ui/SchemaFields.js +54 -26
- package/ui/ShapedData.js +73 -28
- package/ui/SidePanel.d.ts +74 -5
- package/ui/SidePanel.js +82 -9
- package/ui/StateFields.d.ts +10 -2
- package/ui/StateFields.js +56 -13
- package/ui/TheoremChat.d.ts +65 -14
- package/ui/TheoremChat.js +66 -78
- package/ui/TheoremDecision.d.ts +12 -20
- package/ui/TheoremDecision.js +78 -35
- package/ui/TheoremEmbedding.d.ts +27 -0
- package/ui/TheoremEmbedding.js +122 -0
- package/ui/TheoremHost.d.ts +22 -5
- package/ui/TheoremHost.js +127 -37
- package/ui/ToolGateCard.d.ts +5 -1
- package/ui/ToolGateCard.js +30 -23
- package/ui/ToolResult.js +12 -10
- package/ui/ToolResultCharts.js +29 -12
- package/ui/TraceCharts.d.ts +2 -2
- package/ui/TraceCharts.js +48 -17
- package/ui/TraceGuardrails.d.ts +3 -2
- package/ui/TraceGuardrails.js +24 -6
- package/ui/TraceInspector.d.ts +11 -1
- package/ui/TraceInspector.js +45 -28
- package/ui/TraceInspectorPanel.d.ts +31 -9
- package/ui/TraceInspectorPanel.js +77 -39
- package/ui/TraceOverview.d.ts +7 -3
- package/ui/TraceOverview.js +44 -14
- package/ui/TraceSpanDetail.js +24 -22
- package/ui/TraceStory.js +9 -8
- package/ui/TraceValues.js +19 -9
- package/ui/TraceWaterfall.js +12 -9
- package/ui/VoiceNote.js +4 -4
- 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 +86 -68
- package/ui/built/theme.css.d.ts +9 -0
- package/ui/built/theorem.d.ts +3 -3
- package/ui/built/theorem.js +34 -5
- package/ui/built/theorem.variants.d.ts +8 -2
- package/ui/code-language.d.ts +12 -0
- package/ui/code-language.js +50 -0
- package/ui/disclosure-motion.js +53 -9
- 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.js +2 -1
- package/ui/index.d.ts +20 -9
- package/ui/index.js +20 -10
- package/ui/labels-provider.js +5 -2
- package/ui/labels.d.ts +374 -85
- package/ui/labels.js +1028 -188
- 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 +1 -1
- package/ui/theme.js +2 -2
- package/ui/theorem-theme.js +32 -10
- package/ui/trace-palette.d.ts +2 -16
- package/ui/trace-palette.js +2 -16
- package/ui/trace-patterns.d.ts +11 -3
- package/ui/trace-patterns.js +16 -3
- package/ui/transcript-copy-text.js +1 -0
- package/ui/wall-columns.d.ts +8 -0
- package/ui/wall-columns.js +19 -0
- package/client/walk-away.d.ts +0 -34
- package/client/walk-away.js +0 -48
- package/server/walk-away.d.ts +0 -22
- package/server/walk-away.js +0 -41
package/server/handler.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { withPublicLabels } from '@theoremjs/agents/guardrails';
|
|
2
|
+
import { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
|
|
1
3
|
/**
|
|
2
4
|
* One Web-standard handler that serves a Theorem chat UI for a single profile.
|
|
3
5
|
*
|
|
@@ -9,36 +11,47 @@
|
|
|
9
11
|
* waits on gates walks away from them first (`abandon`)
|
|
10
12
|
* - `POST <base>/invoke` → NDJSON events for the user's answer to a paused tool call
|
|
11
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
|
|
12
16
|
*
|
|
13
17
|
* `createTheoremHostHandler` serves a host profile the same way, with no model:
|
|
14
18
|
*
|
|
15
19
|
* - `GET <base>` → `{ interface }` the tools the page may call, with their schemas
|
|
16
20
|
* - `POST <base>/call` → NDJSON events for one call of one of those tools
|
|
17
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
|
|
18
23
|
*
|
|
19
|
-
* Trust boundary: the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
23
31
|
* Tool credentials are held on the server too: a key the user types at a gate
|
|
24
32
|
* is sent once and saved, and no credential is ever sent back to the browser.
|
|
25
33
|
*
|
|
26
34
|
* @module
|
|
27
35
|
*/
|
|
28
|
-
import {
|
|
29
|
-
import { caughtStatus, forClient, HTTP_METHOD, } from '@theoremjs/agents/host';
|
|
30
|
-
import {
|
|
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';
|
|
31
40
|
import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
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";
|
|
34
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";
|
|
35
50
|
import { checkRequest } from "./request-check.js";
|
|
36
|
-
import {
|
|
51
|
+
import { resolveSessionLimits } from "./session-limits.js";
|
|
52
|
+
import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
|
|
37
53
|
import { createMemorySteerInbox, steerStage, steerUnitOf } from "./steer-inbox.js";
|
|
38
|
-
import {
|
|
39
|
-
import { jsonResponse } from "./json-response.js";
|
|
40
|
-
const SESSION_COOKIE = 'theorem_session';
|
|
41
|
-
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
|
|
54
|
+
import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
|
|
42
55
|
const NDJSON_HEADERS = {
|
|
43
56
|
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
44
57
|
'cache-control': 'no-store',
|
|
@@ -48,82 +61,41 @@ function withSessionCookie(response, session) {
|
|
|
48
61
|
response.headers.append('set-cookie', session.setCookie);
|
|
49
62
|
return response;
|
|
50
63
|
}
|
|
51
|
-
/** Strip server-only identity (system prompts) from the projected interface. */
|
|
52
|
-
function clientInterface(profile) {
|
|
53
|
-
const iface = interfaceFromProfile(profile, defaultKernelScope.tools);
|
|
54
|
-
return { ...iface, identity: { handle: iface.identity.handle } };
|
|
55
|
-
}
|
|
56
64
|
/** A JSON body checked against `schema`; a missing or malformed field is a `request` error (400). */
|
|
57
|
-
export async function readBody(request, schema) {
|
|
58
|
-
// JSON-only POSTs can't be sent by a cross-site form, and force a CORS preflight.
|
|
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.
|
|
59
67
|
const type = request.headers.get('content-type') ?? '';
|
|
60
68
|
if (!type.toLowerCase().startsWith('application/json')) {
|
|
61
|
-
//
|
|
69
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
62
70
|
throw new TheoremError('request', 'Content-Type must be application/json');
|
|
63
71
|
}
|
|
64
72
|
let raw;
|
|
65
73
|
try {
|
|
66
|
-
raw = await request
|
|
74
|
+
raw = await boundedRequestJson(request, requestByteLimit(maxBytes));
|
|
67
75
|
}
|
|
68
|
-
catch (
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
});
|
|
71
83
|
}
|
|
72
84
|
return checkRequest(schema, raw, 'request body');
|
|
73
85
|
}
|
|
74
|
-
/**
|
|
75
|
-
function conversationOnly(messages) {
|
|
76
|
-
return messages.filter((message) => message.role === 'user' || message.role === 'assistant' || message.role === 'tool');
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Keep the user-authored parts of a turn input. Drops fields that would let a
|
|
80
|
-
* client speak as the host: system-role history, `role`, `repair` guidance,
|
|
81
|
-
* token-meter overrides, and live resumption handles.
|
|
82
|
-
*/
|
|
83
|
-
function userTurnInput(input) {
|
|
84
|
-
return {
|
|
85
|
-
...(input.text !== undefined ? { text: input.text } : {}),
|
|
86
|
-
...(input.attachments ? { attachments: input.attachments } : {}),
|
|
87
|
-
...(input.voice ? { voice: input.voice } : {}),
|
|
88
|
-
...(input.history ? { history: conversationOnly(input.history) } : {}),
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
/** Last path segment after the mount point: '', 'turn', 'invoke', 'steer', or a host's 'call'. */
|
|
86
|
+
/** Last path segment after the mount point: '', a chat's route, or a host's 'call'. */
|
|
92
87
|
function routeOf(request) {
|
|
93
88
|
const segments = new URL(request.url).pathname.split('/').filter(Boolean);
|
|
94
89
|
const last = segments.at(-1) ?? '';
|
|
95
|
-
return last === 'turn' ||
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
function newSessionId() {
|
|
106
|
-
return toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* The session id the handler issued in its cookie, for a host route outside the
|
|
110
|
-
* handler (an OAuth callback) that saves credentials under it. Hosts that pass
|
|
111
|
-
* a `session` resolver use their own id instead.
|
|
112
|
-
*/
|
|
113
|
-
export function theoremSessionId(request) {
|
|
114
|
-
const existing = readCookie(request, SESSION_COOKIE);
|
|
115
|
-
return existing && SESSION_ID_PATTERN.test(existing) ? existing : undefined;
|
|
116
|
-
}
|
|
117
|
-
function cookieSession(request) {
|
|
118
|
-
const existing = theoremSessionId(request);
|
|
119
|
-
if (existing)
|
|
120
|
-
return { id: existing };
|
|
121
|
-
const id = newSessionId();
|
|
122
|
-
const secure = new URL(request.url).protocol === 'https:' ? '; Secure' : '';
|
|
123
|
-
return {
|
|
124
|
-
id,
|
|
125
|
-
setCookie: `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Lax${secure}`,
|
|
126
|
-
};
|
|
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
|
+
: '';
|
|
127
99
|
}
|
|
128
100
|
function lockedStore(store, loaded) {
|
|
129
101
|
const locks = new Map();
|
|
@@ -147,59 +119,64 @@ function lockedStore(store, loaded) {
|
|
|
147
119
|
},
|
|
148
120
|
};
|
|
149
121
|
}
|
|
150
|
-
/**
|
|
151
|
-
function
|
|
152
|
-
return
|
|
153
|
-
...state,
|
|
154
|
-
gates: pruneGates(state.gates, Date.now(), gateTtlMs),
|
|
155
|
-
settled: pruneGates(state.settled, Date.now(), gateTtlMs),
|
|
156
|
-
}));
|
|
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);
|
|
157
125
|
}
|
|
158
126
|
async function sessionOf(ctx, request) {
|
|
159
127
|
if (!ctx.options.session)
|
|
160
|
-
return
|
|
128
|
+
return theoremSession(request);
|
|
161
129
|
const id = (await ctx.options.session(request))?.trim();
|
|
162
130
|
if (!id) {
|
|
163
|
-
//
|
|
131
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
164
132
|
throw new TheoremError('auth', 'the session resolver refused the request', {
|
|
165
133
|
copy: { key: 'session.sign_in' },
|
|
166
134
|
});
|
|
167
135
|
}
|
|
168
136
|
return { id };
|
|
169
137
|
}
|
|
170
|
-
/** Reports the error to the host, then words it for
|
|
171
|
-
function
|
|
138
|
+
/** Reports the error to the host, then words it for whoever reads this host's replies. */
|
|
139
|
+
function caughtError(ctx, err, request) {
|
|
172
140
|
ctx.options.onError?.(err, { request });
|
|
173
|
-
return
|
|
141
|
+
return clientError(err, ctx.profile.labels, ctx.options.clientEvents);
|
|
174
142
|
}
|
|
175
143
|
function eventStream(ctx, request, source) {
|
|
176
144
|
const encoder = new TextEncoder();
|
|
177
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;
|
|
178
148
|
const stream = new ReadableStream({
|
|
179
149
|
async start(controller) {
|
|
150
|
+
const send = (value) => {
|
|
151
|
+
if (!cancelled)
|
|
152
|
+
controller.enqueue(line(value));
|
|
153
|
+
};
|
|
180
154
|
try {
|
|
181
155
|
for await (const event of source()) {
|
|
182
|
-
|
|
156
|
+
send(event.type === 'conversation' ? event : forClient(event, ctx.options.clientEvents));
|
|
183
157
|
}
|
|
184
158
|
}
|
|
185
159
|
catch (err) {
|
|
186
|
-
if (!request.signal.aborted)
|
|
187
|
-
|
|
188
|
-
}
|
|
160
|
+
if (!request.signal.aborted)
|
|
161
|
+
send({ type: 'error', ...caughtError(ctx, err, request) });
|
|
189
162
|
}
|
|
190
|
-
|
|
163
|
+
if (!cancelled)
|
|
164
|
+
controller.close();
|
|
165
|
+
},
|
|
166
|
+
cancel() {
|
|
167
|
+
cancelled = true;
|
|
191
168
|
},
|
|
192
169
|
});
|
|
193
170
|
return new Response(stream, { headers: NDJSON_HEADERS });
|
|
194
171
|
}
|
|
195
172
|
function providerFor(ctx, request, model) {
|
|
196
173
|
const { provider } = ctx.options;
|
|
197
|
-
//
|
|
174
|
+
// labels-exempt: builder config error; the user reads error.config
|
|
198
175
|
if (!provider)
|
|
199
176
|
throw new TheoremError('config', `profile ${ctx.profile.id} has no provider`);
|
|
200
177
|
if (typeof provider === 'function')
|
|
201
178
|
return provider({ request, model });
|
|
202
|
-
return
|
|
179
|
+
return provider;
|
|
203
180
|
}
|
|
204
181
|
/** Steer inboxes are scoped to the session, so a turn id alone can't reach another user's turn. */
|
|
205
182
|
function inboxKey(sessionId, turnId) {
|
|
@@ -210,9 +187,16 @@ function pendingGatesFrom(events, context) {
|
|
|
210
187
|
return gatedToolsFromEvents(events).map((gated) => ({
|
|
211
188
|
callId: gated.callId,
|
|
212
189
|
gate: {
|
|
190
|
+
profileId: context.profileId,
|
|
191
|
+
operationId: requiredOperationId(gated),
|
|
192
|
+
callId: gated.callId,
|
|
213
193
|
name: gated.name,
|
|
214
194
|
arguments: gated.arguments,
|
|
215
|
-
gate: {
|
|
195
|
+
gate: {
|
|
196
|
+
kind: gated.gateKind,
|
|
197
|
+
permission: gated.permission,
|
|
198
|
+
...(gated.auth ? { auth: gated.auth } : {}),
|
|
199
|
+
},
|
|
216
200
|
snapshot: toolSnapshotFromEvents(events),
|
|
217
201
|
promoted: [...new Set([...(context.promoted ?? []), ...promotedToolIdsFromEvents(events)])],
|
|
218
202
|
turnInput: context.turnInput,
|
|
@@ -221,26 +205,31 @@ function pendingGatesFrom(events, context) {
|
|
|
221
205
|
},
|
|
222
206
|
}));
|
|
223
207
|
}
|
|
224
|
-
/**
|
|
225
|
-
* Record what the stream established: interaction ids for continuation and,
|
|
226
|
-
* when it paused on gates, the exact calls the user may now approve.
|
|
227
|
-
*/
|
|
208
|
+
/** When the stream paused on gates, record the exact calls the user may now approve. */
|
|
228
209
|
async function recordOutcome(sessions, sessionId, events, context) {
|
|
229
|
-
const interactionIds = events.flatMap((event) => event.type === 'done' && event.interactionId ? [event.interactionId] : []);
|
|
230
210
|
const pending = pendingGatesFrom(events, context);
|
|
231
|
-
if (!
|
|
211
|
+
if (!pending.length)
|
|
232
212
|
return;
|
|
233
213
|
await sessions.mutate(sessionId, (state) => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
+
}
|
|
237
226
|
});
|
|
238
227
|
}
|
|
239
|
-
async function* recorded(sessions, sessionId, events, context) {
|
|
240
|
-
const seen = [];
|
|
228
|
+
async function* recorded(sessions, sessionId, events, context, seed = []) {
|
|
229
|
+
const seen = [...seed];
|
|
241
230
|
for await (const event of events) {
|
|
242
231
|
seen.push(event);
|
|
243
|
-
if (event.type === 'done')
|
|
232
|
+
if (event.type === 'done' || event.type === 'provider_checkpoint')
|
|
244
233
|
await recordOutcome(sessions, sessionId, seen, context);
|
|
245
234
|
yield event;
|
|
246
235
|
}
|
|
@@ -268,7 +257,7 @@ async function withAuthorizationUrl(ctx, request, sessionId, event) {
|
|
|
268
257
|
return event;
|
|
269
258
|
const { tool } = event;
|
|
270
259
|
const gate = tool.gate.kind === 'auth' ? tool.gate : undefined;
|
|
271
|
-
if (
|
|
260
|
+
if (gate?.authChallenge.authType !== 'oauth2' || !ctx.options.authorizationUrl)
|
|
272
261
|
return event;
|
|
273
262
|
const challenge = gate.authChallenge;
|
|
274
263
|
const authorizationUrl = await ctx.options.authorizationUrl(challenge, { request, sessionId });
|
|
@@ -282,78 +271,106 @@ async function* withAuthorizationUrls(ctx, request, sessionId, events) {
|
|
|
282
271
|
for await (const event of events)
|
|
283
272
|
yield await withAuthorizationUrl(ctx, request, sessionId, event);
|
|
284
273
|
}
|
|
285
|
-
function answerPendingGate(state, request) {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
289
304
|
throw new TheoremError('request', `gate ${request.callId} is not pending`, {
|
|
290
305
|
copy: { key: 'session.gate_expired' },
|
|
291
306
|
});
|
|
292
307
|
}
|
|
293
|
-
// A refused answer (a refused secret, an edit without an approval) throws here and leaves the gate pending.
|
|
308
|
+
// why: A refused answer (a refused secret, an edit without an approval) throws here and leaves the gate pending.
|
|
294
309
|
const answer = answerGatedCall(request, {
|
|
295
310
|
name: pending.name,
|
|
296
311
|
arguments: pending.arguments,
|
|
297
312
|
permission: pending.gate.permission,
|
|
313
|
+
gateKind: pending.gate.kind,
|
|
298
314
|
...(pending.gate.kind === 'auth' && pending.gate.auth ? { auth: pending.gate.auth } : {}),
|
|
299
315
|
}, state.permissions);
|
|
300
|
-
delete state.gates[request.
|
|
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
|
+
};
|
|
301
325
|
state.permissions = answer.sessionPermissions;
|
|
302
326
|
return { callId: request.callId, pending, answer };
|
|
303
327
|
}
|
|
304
|
-
function walkedCallOf(state,
|
|
305
|
-
const settled = state.
|
|
306
|
-
|
|
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);
|
|
307
337
|
}
|
|
308
338
|
/**
|
|
309
|
-
* Take the calls a message walks away from out of the session,
|
|
310
|
-
* `abandon`: every one of them, or none when one is neither waiting
|
|
311
|
-
* settled.
|
|
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.
|
|
312
342
|
*/
|
|
313
|
-
function takeWalkedAway(ctx,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
+
}));
|
|
317
355
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
* keeps the event that settles it, and `ended` names the call once it has
|
|
321
|
-
* (see `restoreUnended`).
|
|
322
|
-
*/
|
|
323
|
-
async function* settleAnswered(ctx, request, session, answered, ended) {
|
|
324
|
-
const { callId, pending } = answered;
|
|
325
|
-
for await (const event of invokeAnswered(ctx, request, session.id, answered)) {
|
|
326
|
-
if (!ended.has(callId) && settlesToolCall(event, callId)) {
|
|
327
|
-
ended.add(callId);
|
|
328
|
-
await ctx.sessions.mutate(session.id, (state) => {
|
|
329
|
-
state.settled[callId] = { event, createdAt: pending.createdAt };
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
yield event;
|
|
333
|
-
}
|
|
356
|
+
async function* settleAnswered(ctx, request, session, answered) {
|
|
357
|
+
yield* invokeAnswered(ctx, request, session.id, answered);
|
|
334
358
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
* as it did, to be answered again, unless it paused on a gate again: the run
|
|
339
|
-
* recorded that gate with its outcome.
|
|
340
|
-
*/
|
|
341
|
-
async function restoreUnended(ctx, session, calls, ended) {
|
|
342
|
-
const unended = calls.flatMap((call) => ('pending' in call && !ended.has(call.callId) ? [call] : []));
|
|
343
|
-
if (!unended.length)
|
|
344
|
-
return;
|
|
345
|
-
await ctx.sessions.mutate(session.id, (state) => {
|
|
346
|
-
for (const { callId, pending } of unended)
|
|
347
|
-
state.gates[callId] ??= pending;
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
function invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
|
|
351
|
-
return invokeTool({
|
|
359
|
+
async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
|
|
360
|
+
const host = await ctx.options.host?.(request);
|
|
361
|
+
yield* invokeTool({
|
|
352
362
|
profile: ctx.profile.id,
|
|
363
|
+
execution: sessionToolCoordinator(ctx.sessions, sessionId),
|
|
364
|
+
pageExecution: sessionPageDispatch(ctx.sessions, sessionId, ctx.pageToolBroker).execution,
|
|
353
365
|
name: pending.name,
|
|
354
366
|
callId,
|
|
367
|
+
operationId: pending.operationId,
|
|
355
368
|
input: answer.input,
|
|
356
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
|
+
: {}),
|
|
357
374
|
sessionPermissions: answer.sessionPermissions,
|
|
358
375
|
credentials: sessionCredentials(ctx, sessionId),
|
|
359
376
|
turnInput: pending.turnInput,
|
|
@@ -361,58 +378,40 @@ function invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
|
|
|
361
378
|
promoted: pending.promoted,
|
|
362
379
|
model: pending.model,
|
|
363
380
|
signal: request.signal,
|
|
364
|
-
host
|
|
381
|
+
host,
|
|
365
382
|
});
|
|
366
383
|
}
|
|
367
|
-
/** Settles each walked-away call, and returns the input with their answers. */
|
|
368
|
-
async function* walkAwayFrom(ctx, request, session, walked, ended) {
|
|
369
|
-
return yield* walkAway(walked.input, walked.calls.map((call) => ({
|
|
370
|
-
callId: call.callId,
|
|
371
|
-
events: 'settled' in call
|
|
372
|
-
? once(call.settled.event)
|
|
373
|
-
: settleAnswered(ctx, request, session, call, ended),
|
|
374
|
-
})));
|
|
375
|
-
}
|
|
376
384
|
/** A settled call's answer, streamed again. */
|
|
377
|
-
async function* once(event) {
|
|
378
|
-
yield event;
|
|
385
|
+
async function* once(event, labels) {
|
|
386
|
+
yield withPublicLabels(event, labels);
|
|
379
387
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
let input;
|
|
383
|
-
try {
|
|
384
|
-
input = walked.calls.length ? yield* walkAwayFrom(ctx, request, session, walked, ended) : walked.input;
|
|
385
|
-
}
|
|
386
|
-
finally {
|
|
387
|
-
await restoreUnended(ctx, session, walked.calls, ended);
|
|
388
|
-
}
|
|
389
|
-
if (!input)
|
|
390
|
-
return;
|
|
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) {
|
|
391
390
|
const state = await ctx.sessions.read(session.id);
|
|
392
|
-
const provider = await providerFor(ctx, request,
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
? body.previousInteractionId
|
|
396
|
-
: undefined;
|
|
397
|
-
const key = body.turnId ? inboxKey(session.id, body.turnId) : undefined;
|
|
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;
|
|
398
394
|
if (key)
|
|
399
395
|
await ctx.inbox.open(key);
|
|
400
396
|
try {
|
|
401
397
|
const events = runTurn({
|
|
402
398
|
profile: ctx.profile.id,
|
|
403
|
-
|
|
404
|
-
|
|
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 } : {}),
|
|
405
403
|
sessionPermissions: state.permissions,
|
|
406
404
|
credentials: sessionCredentials(ctx, session.id),
|
|
407
405
|
signal: request.signal,
|
|
408
|
-
host
|
|
409
|
-
...(
|
|
410
|
-
...(
|
|
406
|
+
host,
|
|
407
|
+
...(turn.model ? { model: turn.model } : {}),
|
|
408
|
+
...(turn.effort ? { effort: turn.effort } : {}),
|
|
411
409
|
...(key ? { onStage: steerStage(ctx.inbox, key) } : {}),
|
|
412
410
|
}, provider);
|
|
413
411
|
yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
|
|
414
|
-
|
|
415
|
-
|
|
412
|
+
profileId: ctx.profile.id,
|
|
413
|
+
turnInput: turn.input,
|
|
414
|
+
model: turn.model,
|
|
416
415
|
});
|
|
417
416
|
}
|
|
418
417
|
finally {
|
|
@@ -420,27 +419,122 @@ async function* turnEvents(ctx, request, session, body, walked) {
|
|
|
420
419
|
await ctx.inbox.close(key);
|
|
421
420
|
}
|
|
422
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
|
+
}
|
|
423
512
|
async function* invokeEvents(ctx, request, session, answered) {
|
|
424
513
|
const { pending, answer } = answered;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
]);
|
|
439
533
|
}
|
|
440
534
|
async function steer(ctx, request, session) {
|
|
441
|
-
const body = await readBody(request, theoremSteerRequestSchema);
|
|
535
|
+
const body = await readBody(request, theoremSteerRequestSchema, ctx.options.maxRequestBytes);
|
|
442
536
|
if (!(await ctx.inbox.enqueue(inboxKey(session.id, body.turnId), steerUnitOf(body)))) {
|
|
443
|
-
//
|
|
537
|
+
// labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
|
|
444
538
|
throw new TheoremError('request', `turn ${body.turnId} is not running`, {
|
|
445
539
|
copy: { key: 'session.turn_ended' },
|
|
446
540
|
});
|
|
@@ -450,47 +544,118 @@ async function steer(ctx, request, session) {
|
|
|
450
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. */
|
|
451
545
|
async function* callEvents(ctx, request, session, body) {
|
|
452
546
|
const state = await ctx.sessions.read(session.id);
|
|
547
|
+
const host = await ctx.options.host?.(request);
|
|
453
548
|
const events = invokeTool({
|
|
454
549
|
profile: ctx.profile.id,
|
|
550
|
+
execution: sessionToolCoordinator(ctx.sessions, session.id),
|
|
551
|
+
pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
|
|
455
552
|
name: body.name,
|
|
456
553
|
input: body.input,
|
|
457
554
|
sessionPermissions: state.permissions,
|
|
458
555
|
credentials: sessionCredentials(ctx, session.id),
|
|
459
556
|
signal: request.signal,
|
|
460
|
-
host
|
|
557
|
+
host,
|
|
461
558
|
});
|
|
462
559
|
yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
|
|
560
|
+
profileId: ctx.profile.id,
|
|
463
561
|
turnInput: {},
|
|
464
562
|
});
|
|
465
563
|
}
|
|
466
564
|
function answerRequest(body) {
|
|
467
|
-
return {
|
|
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
|
+
};
|
|
468
573
|
}
|
|
469
574
|
/** A route this profile doesn't serve. */
|
|
470
575
|
function refused(ctx) {
|
|
471
576
|
return jsonResponse(HTTP_METHOD, {
|
|
472
|
-
error:
|
|
577
|
+
error: labelText('error.request', {}, ctx.profile.labels),
|
|
473
578
|
errorKind: 'request',
|
|
474
579
|
});
|
|
475
580
|
}
|
|
476
|
-
|
|
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
|
+
*/
|
|
477
604
|
async function invoke(ctx, request, session) {
|
|
478
|
-
const body = await readBody(request, theoremInvokeRequestSchema);
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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);
|
|
482
652
|
}
|
|
483
653
|
async function hostRoute(ctx, request, session) {
|
|
484
654
|
const target = routeOf(request);
|
|
485
|
-
if (request.method === 'GET' && target === '')
|
|
486
|
-
|
|
487
|
-
const registered = defaultKernelScope.tools.get(name);
|
|
488
|
-
return registered?.type === 'builtin' ? undefined : registered;
|
|
489
|
-
};
|
|
490
|
-
return jsonResponse(200, { interface: hostInterface(ctx.profile, tool) });
|
|
491
|
-
}
|
|
655
|
+
if (request.method === 'GET' && target === '')
|
|
656
|
+
return jsonResponse(200, { interface: hostInterface(ctx.profile, hostTool) });
|
|
492
657
|
if (request.method === 'POST' && target === 'call') {
|
|
493
|
-
const body = await readBody(request, theoremHostCallRequestSchema);
|
|
658
|
+
const body = await readBody(request, theoremHostCallRequestSchema, ctx.options.maxRequestBytes);
|
|
494
659
|
return eventStream(ctx, request, () => callEvents(ctx, request, session, body));
|
|
495
660
|
}
|
|
496
661
|
if (request.method === 'POST' && target === 'invoke')
|
|
@@ -498,60 +663,103 @@ async function hostRoute(ctx, request, session) {
|
|
|
498
663
|
return refused(ctx);
|
|
499
664
|
}
|
|
500
665
|
async function route(ctx, request, session) {
|
|
666
|
+
if (request.method === 'POST' && routeOf(request) === 'page-result') {
|
|
667
|
+
const body = await readBody(request, pageToolResultSchema, ctx.options.maxRequestBytes);
|
|
668
|
+
await sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).answer(body);
|
|
669
|
+
return jsonResponse(200, { accepted: true });
|
|
670
|
+
}
|
|
501
671
|
if (ctx.profile.type === 'host')
|
|
502
672
|
return hostRoute({ ...ctx, profile: ctx.profile }, request, session);
|
|
673
|
+
const { chat } = ctx;
|
|
503
674
|
const target = routeOf(request);
|
|
675
|
+
if (!chat)
|
|
676
|
+
return refused(ctx);
|
|
504
677
|
if (request.method === 'GET' && target === '')
|
|
505
|
-
return jsonResponse(200, {
|
|
678
|
+
return jsonResponse(200, {
|
|
679
|
+
interface: clientProfileInterface(chat.profile, defaultKernelScope.tools),
|
|
680
|
+
});
|
|
506
681
|
if (request.method !== 'POST' || target === '' || target === 'call')
|
|
507
682
|
return refused(ctx);
|
|
508
|
-
if (target === 'turn')
|
|
509
|
-
|
|
510
|
-
const input = userTurnInput(body.input);
|
|
511
|
-
// Take the walked-away calls before streaming, so a stale one is a reply status, not a stream error.
|
|
512
|
-
const calls = body.abandon ? await takeWalkedAway(ctx, session, input, body.abandon) : [];
|
|
513
|
-
return eventStream(ctx, request, () => turnEvents(ctx, request, session, body, { input, calls }));
|
|
514
|
-
}
|
|
683
|
+
if (target === 'turn')
|
|
684
|
+
return turn(ctx, chat, request, session);
|
|
515
685
|
if (target === 'invoke')
|
|
516
686
|
return invoke(ctx, request, session);
|
|
687
|
+
if (target === 'conversation')
|
|
688
|
+
return conversation(ctx, request, session);
|
|
689
|
+
if (target === 'rewind')
|
|
690
|
+
return rewind(ctx, request, session);
|
|
517
691
|
return steer(ctx, request, session);
|
|
518
692
|
}
|
|
519
693
|
export function createTheoremHandler(options) {
|
|
520
694
|
const profile = defineProfile(options.profile);
|
|
521
695
|
if (profile.type === 'decision') {
|
|
522
|
-
//
|
|
696
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
523
697
|
throw new Error("createTheoremHandler serves turn-based profiles; serve type 'decision' with createTheoremDecisionHandler.");
|
|
524
698
|
}
|
|
525
699
|
if (profile.type === 'host') {
|
|
526
|
-
//
|
|
700
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
527
701
|
throw new Error("createTheoremHandler serves turn-based profiles; serve type 'host' with createTheoremHostHandler.");
|
|
528
702
|
}
|
|
529
|
-
if (profile.type === 'live') {
|
|
530
|
-
//
|
|
703
|
+
if (profile.type === 'live' || profile.type === 'embedding') {
|
|
704
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
531
705
|
throw new Error(`createTheoremHandler serves turn-based profiles; got type '${profile.type}'.`);
|
|
532
706
|
}
|
|
533
|
-
|
|
707
|
+
const stateless = profile.providerContinuation?.history === 'stateless';
|
|
708
|
+
if (stateless === (options.conversationStore !== undefined)) {
|
|
709
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
710
|
+
cause: {
|
|
711
|
+
code: stateless ? 'conversation_store_stateless' : 'conversation_store_missing',
|
|
712
|
+
profile: profile.id,
|
|
713
|
+
},
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
return serve('createTheoremHandler', profile, options, {
|
|
717
|
+
steerInbox: options.steerInbox,
|
|
718
|
+
chat: {
|
|
719
|
+
profile,
|
|
720
|
+
...(options.conversationStore ? { conversations: options.conversationStore } : {}),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
534
723
|
}
|
|
535
724
|
/**
|
|
536
725
|
* Serves a host profile to `<TheoremHost />`: the page calls the profile's tools
|
|
537
726
|
* directly, and every call runs through the kernel's gates and guardrails.
|
|
538
727
|
*/
|
|
539
728
|
export function createTheoremHostHandler(options) {
|
|
540
|
-
|
|
729
|
+
return serve('createTheoremHostHandler', defineHostProfile('createTheoremHostHandler', options.profile), options);
|
|
730
|
+
}
|
|
731
|
+
/** The host profile a handler serves; any other type is the builder's mistake, refused at setup. */
|
|
732
|
+
export function defineHostProfile(handler, definition) {
|
|
733
|
+
const profile = defineProfile(definition);
|
|
541
734
|
if (profile.type !== 'host') {
|
|
542
|
-
//
|
|
543
|
-
throw new Error(
|
|
735
|
+
// labels-exempt: builder config error at setup; no user sees it
|
|
736
|
+
throw new Error(`${handler} serves type 'host'; got type '${profile.type}'.`);
|
|
544
737
|
}
|
|
545
|
-
return
|
|
738
|
+
return profile;
|
|
546
739
|
}
|
|
547
|
-
|
|
740
|
+
/** A registered tool a host profile can serve: builtins run only inside a model's turn. */
|
|
741
|
+
export function hostTool(name) {
|
|
742
|
+
const registered = defaultKernelScope.tools.get(name);
|
|
743
|
+
return registered?.type === 'builtin' ? undefined : registered;
|
|
744
|
+
}
|
|
745
|
+
function serve(name, profile, options, chatOptions = {}) {
|
|
548
746
|
const gateTtlMs = resolveGateTtlMs(name, options.gateTtlMs);
|
|
747
|
+
const operationRetentionMs = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
|
|
748
|
+
if (!Number.isSafeInteger(operationRetentionMs) || operationRetentionMs <= 0) {
|
|
749
|
+
throw new TheoremError('config', labelText('error.config'), {
|
|
750
|
+
cause: { code: 'operation_retention' },
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const limits = resolveSessionLimits(options.sessionLimits);
|
|
754
|
+
requestByteLimit(options.maxRequestBytes);
|
|
549
755
|
registerProfile(profile);
|
|
550
756
|
const ctx = {
|
|
551
757
|
options,
|
|
758
|
+
pageToolBroker: options.pageToolBroker ?? createMemoryPageToolBroker(),
|
|
552
759
|
profile,
|
|
553
|
-
|
|
554
|
-
|
|
760
|
+
...(chatOptions.chat ? { chat: chatOptions.chat } : {}),
|
|
761
|
+
inbox: chatOptions.steerInbox ?? createMemorySteerInbox(),
|
|
762
|
+
sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore({ sessionLimits: limits }), gateTtlMs, operationRetentionMs, limits),
|
|
555
763
|
credentials: lockedStore(options.credentialStore ?? createMemoryCredentialStore(), (saved = {}) => saved),
|
|
556
764
|
};
|
|
557
765
|
return async (request) => {
|
|
@@ -561,8 +769,7 @@ function serve(name, profile, options, steerInbox) {
|
|
|
561
769
|
return withSessionCookie(await route(ctx, request, session), session);
|
|
562
770
|
}
|
|
563
771
|
catch (err) {
|
|
564
|
-
|
|
565
|
-
return withSessionCookie(jsonResponse(caughtStatus(err), { error, errorKind: errorKind(err) }), session);
|
|
772
|
+
return withSessionCookie(jsonResponse(caughtStatus(err), caughtError(ctx, err, request)), session);
|
|
566
773
|
}
|
|
567
774
|
};
|
|
568
775
|
}
|