@abide/abide 0.114.1 → 0.116.0
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/CHANGELOG.md +18 -0
- package/docs/REGISTRY.md +38 -16
- package/docs/RULEBOOK.md +173 -12
- package/docs/abide.md +266 -24
- package/package.json +4 -2
- package/src/compiler/REACTIVE_MEMBERS.ts +1 -0
- package/src/compiler/classifyTemplate.ts +21 -14
- package/src/compiler/emitClient.ts +5 -1
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/IMAGE_TYPES.ts +10 -0
- package/src/server/OFFLINE_STALE.ts +10 -0
- package/src/server/answerRequest.ts +39 -10
- package/src/server/answerRpc.ts +25 -5
- package/src/server/answerWorker.ts +173 -28
- package/src/server/assistant.ts +81 -12
- package/src/server/claudeApi.ts +25 -20
- package/src/server/claudeCode.ts +30 -10
- package/src/server/createApp.ts +8 -2
- package/src/server/generatedSurfaces.ts +48 -14
- package/src/server/index.ts +2 -0
- package/src/server/render.ts +3 -2
- package/src/server/seedBuffer.ts +28 -9
- package/src/shared/KEEP_HEADER.ts +7 -0
- package/src/shared/KEEP_OPENED.ts +7 -0
- package/src/shared/KEPT_HEADER.ts +6 -0
- package/src/shared/LOAD_OFFLINE.ts +15 -0
- package/src/shared/OUTBOX_SENDING.ts +9 -0
- package/src/shared/REACHED_MESSAGE.ts +7 -0
- package/src/shared/answerOf.ts +14 -2
- package/src/shared/framing.ts +10 -2
- package/src/shared/index.ts +2 -0
- package/src/shared/outbox.ts +116 -0
- package/src/shared/reactive/Keyed.ts +9 -0
- package/src/shared/reactive/Reactive.ts +197 -3
- package/src/shared/reactive/SELECTION_MEMBERS.ts +1 -0
- package/src/shared/reactive/callLoad.ts +8 -0
- package/src/shared/reactive/index.ts +23 -1
- package/src/shared/rpc.ts +25 -5
- package/src/shared/rpcRequest.ts +12 -8
- package/src/ui/Router.ts +28 -7
- package/src/ui/bindFiles.ts +42 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/outboxQueue.ts +427 -0
- package/src/ui/socketWire.ts +31 -2
- package/src/ui/wire.ts +111 -10
package/src/server/assistant.ts
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
import { namedLogger } from '#shared/log.ts'
|
|
14
14
|
import { refuse } from '#shared/refuse.ts'
|
|
15
|
+
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
15
16
|
import { type McpReply, McpSurface } from './generatedSurfaces.ts'
|
|
16
17
|
import { request, within } from './requestScope.ts'
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
export type
|
|
19
|
+
/** A piece of a turn: words, or an image as base64 with its type. */
|
|
20
|
+
export type AssistantContent =
|
|
21
|
+
| { type: 'text'; text: string }
|
|
22
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
23
|
+
|
|
24
|
+
/** One turn of the transcript a caller sends: its words, or its pieces in order. */
|
|
25
|
+
export type AssistantMessage = {
|
|
26
|
+
role: 'user' | 'assistant'
|
|
27
|
+
content: string | AssistantContent[]
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
/** What a conversation yields, the same from every provider. */
|
|
22
31
|
export type AssistantEvent =
|
|
@@ -43,19 +52,19 @@ export type ToolDefinition = {
|
|
|
43
52
|
outputSchema: unknown
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/** What a tool call answered: its pieces, and whether it failed. 20.21, 44.35. */
|
|
56
|
+
export type ToolAnswer = { content: AssistantContent[]; failed: boolean }
|
|
57
|
+
|
|
46
58
|
/** The app's MCP tools, called as the asker. */
|
|
47
59
|
export type Toolbox = {
|
|
48
60
|
list: ToolDefinition[]
|
|
49
|
-
call(
|
|
50
|
-
name: string,
|
|
51
|
-
input: unknown,
|
|
52
|
-
): Promise<{ text: string; failed: boolean }>
|
|
61
|
+
call(name: string, input: unknown): Promise<ToolAnswer>
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** What runs a conversation: a model's API, or something that runs its own loop. */
|
|
56
65
|
export type Provider = {
|
|
57
66
|
run(
|
|
58
|
-
messages: AssistantMessage[],
|
|
67
|
+
messages: (AssistantMessage & { content: AssistantContent[] })[],
|
|
59
68
|
tools: Toolbox,
|
|
60
69
|
limits: { maxTurns: number; signal: AbortSignal },
|
|
61
70
|
): AsyncGenerator<AssistantEvent>
|
|
@@ -81,11 +90,38 @@ export async function* assistant(
|
|
|
81
90
|
400,
|
|
82
91
|
'The transcript has to end on a user turn for the assistant to answer it. RULEBOOK 44.3.',
|
|
83
92
|
)
|
|
93
|
+
// Every turn as its pieces, so a provider reads one shape: words alone are one text piece.
|
|
94
|
+
const turns: Parameters<Provider['run']>[0] = []
|
|
95
|
+
for (const message of messages) {
|
|
96
|
+
if (typeof message.content === 'string') {
|
|
97
|
+
turns.push({
|
|
98
|
+
role: message.role,
|
|
99
|
+
content: [{ type: 'text', text: message.content }],
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
turns.push({ role: message.role, content: message.content })
|
|
104
|
+
for (const piece of message.content) {
|
|
105
|
+
if (piece?.type !== 'image') continue
|
|
106
|
+
// 44.37 — an image is the asker's to show, never a turn the model answered.
|
|
107
|
+
if (message.role !== 'user')
|
|
108
|
+
throw refuse(
|
|
109
|
+
400,
|
|
110
|
+
'An image can only stand in a user turn of the transcript. RULEBOOK 44.37.',
|
|
111
|
+
)
|
|
112
|
+
// 44.36.
|
|
113
|
+
if (!IMAGE_TYPES.has(piece.mimeType))
|
|
114
|
+
throw refuse(
|
|
115
|
+
400,
|
|
116
|
+
`An image in the transcript is ${piece.mimeType}, and a model is shown only ${[...IMAGE_TYPES].join(', ')}. RULEBOOK 44.36.`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
84
120
|
const tools = toolboxOver(
|
|
85
121
|
options.mcp === true ? appSurface() : NO_SURFACE,
|
|
86
122
|
askerOf(request()),
|
|
87
123
|
)
|
|
88
|
-
const events = provider.run(
|
|
124
|
+
const events = provider.run(turns, tools, {
|
|
89
125
|
maxTurns: options.maxTurns ?? MAX_TURNS,
|
|
90
126
|
signal: request().signal,
|
|
91
127
|
})
|
|
@@ -181,10 +217,15 @@ function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
|
|
|
181
217
|
return await surface.call(name, input, asker)
|
|
182
218
|
} catch (thrown) {
|
|
183
219
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
:
|
|
220
|
+
content: [
|
|
221
|
+
{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text:
|
|
224
|
+
thrown instanceof Error
|
|
225
|
+
? thrown.message
|
|
226
|
+
: String(thrown),
|
|
227
|
+
},
|
|
228
|
+
],
|
|
188
229
|
failed: true,
|
|
189
230
|
}
|
|
190
231
|
}
|
|
@@ -199,3 +240,31 @@ function toolboxOver(surface: McpSurface, asker: Request): Toolbox {
|
|
|
199
240
|
)
|
|
200
241
|
return tools
|
|
201
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Pieces as Messages API blocks, in order: text as text, an image as base64 with its type. A text of
|
|
246
|
+
* whitespace alone is left out, as the API refuses one. 44.38.
|
|
247
|
+
*/
|
|
248
|
+
export function blocksOf(content: AssistantContent[]): unknown[] {
|
|
249
|
+
const blocks: unknown[] = []
|
|
250
|
+
for (const piece of content)
|
|
251
|
+
if (piece.type === 'image')
|
|
252
|
+
blocks.push({
|
|
253
|
+
type: 'image',
|
|
254
|
+
source: {
|
|
255
|
+
type: 'base64',
|
|
256
|
+
media_type: piece.mimeType,
|
|
257
|
+
data: piece.data,
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
else if (/\S/.test(piece.text))
|
|
261
|
+
blocks.push({ type: 'text', text: piece.text })
|
|
262
|
+
return blocks
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The words of `content`, its images left out. */
|
|
266
|
+
export function textOf(content: AssistantContent[]): string {
|
|
267
|
+
let text = ''
|
|
268
|
+
for (const piece of content) if (piece.type === 'text') text += piece.text
|
|
269
|
+
return text
|
|
270
|
+
}
|
package/src/server/claudeApi.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { framedStream } from '#shared/framing.ts'
|
|
10
10
|
import { refuse } from '#shared/refuse.ts'
|
|
11
|
-
import
|
|
11
|
+
import { blocksOf, type Provider, textOf } from './assistant.ts'
|
|
12
12
|
|
|
13
13
|
/** A Messages API request body, less what abide writes, with the client's key and origin. */
|
|
14
14
|
export type ClaudeApiConfig = {
|
|
@@ -80,8 +80,12 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
|
|
|
80
80
|
input_schema: tool.inputSchema,
|
|
81
81
|
})
|
|
82
82
|
const history: { role: string; content: unknown }[] = []
|
|
83
|
+
// 44.38 — each image in its place in its turn.
|
|
83
84
|
for (const message of messages)
|
|
84
|
-
history.push({
|
|
85
|
+
history.push({
|
|
86
|
+
role: message.role,
|
|
87
|
+
content: blocksOf(message.content),
|
|
88
|
+
})
|
|
85
89
|
const usage = { input: 0, output: 0 }
|
|
86
90
|
for (let turn = 1; ; turn++) {
|
|
87
91
|
const answered = await fetch(
|
|
@@ -189,28 +193,29 @@ export function claudeApi(config: ClaudeApiConfig): Provider {
|
|
|
189
193
|
}
|
|
190
194
|
return
|
|
191
195
|
}
|
|
192
|
-
const
|
|
193
|
-
called.map(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
block.name as string,
|
|
201
|
-
block.input,
|
|
202
|
-
)),
|
|
203
|
-
}),
|
|
204
|
-
),
|
|
196
|
+
const answers = await Promise.all(
|
|
197
|
+
called.map(async (block) => ({
|
|
198
|
+
id: block.id as string,
|
|
199
|
+
...(await tools.call(
|
|
200
|
+
block.name as string,
|
|
201
|
+
block.input,
|
|
202
|
+
)),
|
|
203
|
+
})),
|
|
205
204
|
)
|
|
206
205
|
const content: unknown[] = []
|
|
207
|
-
for (const
|
|
208
|
-
|
|
206
|
+
for (const answer of answers) {
|
|
207
|
+
// 44.35 — the event carries the answer's words; an image goes to the model alone.
|
|
208
|
+
yield {
|
|
209
|
+
type: 'result',
|
|
210
|
+
id: answer.id,
|
|
211
|
+
text: textOf(answer.content),
|
|
212
|
+
failed: answer.failed,
|
|
213
|
+
}
|
|
209
214
|
content.push({
|
|
210
215
|
type: 'tool_result',
|
|
211
|
-
tool_use_id:
|
|
212
|
-
content:
|
|
213
|
-
is_error:
|
|
216
|
+
tool_use_id: answer.id,
|
|
217
|
+
content: blocksOf(answer.content),
|
|
218
|
+
is_error: answer.failed,
|
|
214
219
|
})
|
|
215
220
|
}
|
|
216
221
|
// 44.12 — every result of one turn in one message.
|
package/src/server/claudeCode.ts
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
import { mounted } from '#shared/mount.ts'
|
|
12
12
|
import { refuse } from '#shared/refuse.ts'
|
|
13
13
|
import {
|
|
14
|
+
type AssistantContent,
|
|
14
15
|
type AssistantEvent,
|
|
15
16
|
type AssistantMessage,
|
|
17
|
+
blocksOf,
|
|
16
18
|
forwardOf,
|
|
17
19
|
type Provider,
|
|
18
20
|
} from './assistant.ts'
|
|
@@ -65,7 +67,7 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
|
|
|
65
67
|
bridge.frames.push({
|
|
66
68
|
type: 'turn',
|
|
67
69
|
id: turn.id,
|
|
68
|
-
prompt:
|
|
70
|
+
prompt: foldedContent(messages),
|
|
69
71
|
config,
|
|
70
72
|
maxTurns,
|
|
71
73
|
})
|
|
@@ -105,16 +107,34 @@ function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The transcript as the one message Claude Code is sent, as Messages API blocks: earlier turns folded
|
|
112
|
+
* above the last, and each image in its place. 44.5.
|
|
113
|
+
*/
|
|
114
|
+
function foldedContent(
|
|
115
|
+
messages: (AssistantMessage & { content: AssistantContent[] })[],
|
|
116
|
+
): unknown[] {
|
|
117
|
+
const pieces: AssistantContent[] = []
|
|
118
|
+
let words = messages.length > 1 ? '<conversation>\n' : ''
|
|
119
|
+
const last = messages.length - 1
|
|
120
|
+
for (let index = 0; index <= last; index++) {
|
|
121
|
+
const message = messages[index] as (typeof messages)[number]
|
|
122
|
+
if (index < last) words += `<${message.role}>\n`
|
|
123
|
+
for (let at = 0; at < message.content.length; at++) {
|
|
124
|
+
const piece = message.content[at] as AssistantContent
|
|
125
|
+
if (at > 0) words += '\n'
|
|
126
|
+
if (piece.type === 'text') {
|
|
127
|
+
words += piece.text
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
pieces.push({ type: 'text', text: words }, piece)
|
|
131
|
+
words = ''
|
|
132
|
+
}
|
|
133
|
+
if (index < last) words += `\n</${message.role}>\n`
|
|
134
|
+
if (index === last - 1) words += '</conversation>\n\n'
|
|
116
135
|
}
|
|
117
|
-
|
|
136
|
+
pieces.push({ type: 'text', text: words })
|
|
137
|
+
return blocksOf(pieces)
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
/** The browser asking, which a pairing is to. 44.13. */
|
package/src/server/createApp.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
|
|
|
12
12
|
import { namedLogger } from '#shared/log.ts'
|
|
13
13
|
import { compose } from '#shared/Middleware.ts'
|
|
14
14
|
import { MOUNT, unmounted } from '#shared/mount.ts'
|
|
15
|
+
import { PRINCIPAL_HEADER } from '#shared/PRINCIPAL_HEADER.ts'
|
|
15
16
|
import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
|
|
16
17
|
import { servedTable } from './abideModules.ts'
|
|
17
18
|
import { type AppSources, answerRequest } from './answerRequest.ts'
|
|
@@ -329,7 +330,7 @@ export async function bootEntered(
|
|
|
329
330
|
reportUnexpected(thrown)
|
|
330
331
|
answered = plainAnswer('internal server error', 500)
|
|
331
332
|
}
|
|
332
|
-
const stamped = beating(stamp(answered))
|
|
333
|
+
const stamped = beating(stamp(answered, booted.offline))
|
|
333
334
|
// 28.7 — a closed namespace does not pay for the line it would have written.
|
|
334
335
|
if (REQUEST_LOG.enabled())
|
|
335
336
|
REQUEST_LOG.debug(
|
|
@@ -494,8 +495,11 @@ export async function bootEntered(
|
|
|
494
495
|
*
|
|
495
496
|
* 22.9 — AND WHAT WAS WRITTEN THROUGH `response`, laid over every answer here and nowhere else: a
|
|
496
497
|
* lane builds its answer at the written status and leaves the headers to this.
|
|
498
|
+
*
|
|
499
|
+
* 21.24 — and where the worker keeps answers (`offline`), a sign-in or a sign-out has the browser's
|
|
500
|
+
* own cache drop what it kept for the caller before, as the worker drops its copies (45.7).
|
|
497
501
|
*/
|
|
498
|
-
function stamp(answered: Response): Response {
|
|
502
|
+
function stamp(answered: Response, offline: boolean): Response {
|
|
499
503
|
let stamped = answered
|
|
500
504
|
try {
|
|
501
505
|
stamped.headers.set('x-content-type-options', 'nosniff')
|
|
@@ -507,6 +511,8 @@ function stamp(answered: Response): Response {
|
|
|
507
511
|
const timing = serverTiming()
|
|
508
512
|
if (timing !== undefined) stamped.headers.set('server-timing', timing)
|
|
509
513
|
const written = applyWritten(stamped.headers)
|
|
514
|
+
if (offline && stamped.headers.has(PRINCIPAL_HEADER))
|
|
515
|
+
stamped.headers.set('clear-site-data', '"cache"')
|
|
510
516
|
const status = written !== 200 ? written : stamped.status
|
|
511
517
|
// 17.8 — a failure nobody named a reason for is given its registered one.
|
|
512
518
|
const statusText =
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// schema (19.9) as `argsDocument`.
|
|
15
15
|
|
|
16
16
|
import { isFailed } from '#shared/Failed.ts'
|
|
17
|
+
import { essenceOf } from '#shared/framing.ts'
|
|
17
18
|
import { namedLogger } from '#shared/log.ts'
|
|
18
19
|
import { mounted } from '#shared/mount.ts'
|
|
19
20
|
import { type Reactive, reactive } from '#shared/reactive/index.ts'
|
|
@@ -21,9 +22,10 @@ import { parseRoutePattern } from '#shared/routePattern.ts'
|
|
|
21
22
|
import { argsInURL, rpcRequest } from '#shared/rpcRequest.ts'
|
|
22
23
|
import { answered } from './answerFailure.ts'
|
|
23
24
|
import { bodyWithin, type Mounted } from './answerRpc.ts'
|
|
24
|
-
import type { ToolDefinition } from './assistant.ts'
|
|
25
|
+
import type { ToolAnswer, ToolDefinition } from './assistant.ts'
|
|
25
26
|
import { appIdentity } from './config.ts'
|
|
26
27
|
import { crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
|
|
28
|
+
import { IMAGE_TYPES } from './IMAGE_TYPES.ts'
|
|
27
29
|
import { MCP_PROTOCOL_VERSION } from './MCP_PROTOCOL_VERSION.ts'
|
|
28
30
|
import { type Listed, surfaceOf } from './surfaceOf.ts'
|
|
29
31
|
|
|
@@ -307,16 +309,12 @@ export class McpSurface {
|
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
/** What calling the tool `name` as `asker` answered, a refusal or a missing tool a failed one. */
|
|
310
|
-
call(
|
|
311
|
-
name: string,
|
|
312
|
-
args: unknown,
|
|
313
|
-
asker: Request,
|
|
314
|
-
): Promise<{ text: string; failed: boolean }> {
|
|
312
|
+
call(name: string, args: unknown, asker: Request): Promise<ToolAnswer> {
|
|
315
313
|
const tool = this.#tool(name)
|
|
316
314
|
if (tool === undefined) {
|
|
317
315
|
logCall(String(name), 404, undefined)
|
|
318
316
|
return Promise.resolve({
|
|
319
|
-
text: `No tool named ${name}
|
|
317
|
+
content: [{ type: 'text', text: `No tool named ${name}.` }],
|
|
320
318
|
failed: true,
|
|
321
319
|
})
|
|
322
320
|
}
|
|
@@ -331,7 +329,7 @@ export class McpSurface {
|
|
|
331
329
|
one: Published,
|
|
332
330
|
args: Record<string, unknown> | undefined,
|
|
333
331
|
asker: Request,
|
|
334
|
-
): Promise<
|
|
332
|
+
): Promise<ToolAnswer> {
|
|
335
333
|
// 28.7 — a closed namespace does not pay for the line it would have written.
|
|
336
334
|
const started = MCP_LOG.enabled() ? performance.now() : undefined
|
|
337
335
|
let answer: Response
|
|
@@ -345,10 +343,41 @@ export class McpSurface {
|
|
|
345
343
|
}
|
|
346
344
|
answer = answered(thrown)
|
|
347
345
|
}
|
|
346
|
+
const mimeType = essenceOf(answer.headers.get('content-type'))
|
|
347
|
+
if (answer.status < 400 && mimeType.startsWith('image/')) {
|
|
348
|
+
// 20.22 — a tool that promised a value and answered a picture has no structured content
|
|
349
|
+
// to carry: its handler's type is wrong, and the call says so where it happened.
|
|
350
|
+
if (structured(one)) {
|
|
351
|
+
await answer.body?.cancel()
|
|
352
|
+
logCall(one.name, 500, started)
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
{
|
|
356
|
+
type: 'text',
|
|
357
|
+
text: `${one.name} declares a result schema but answered an image, ${mimeType}.`,
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
failed: true,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 20.21 — AN IMAGE A MODEL IS SHOWN IS THE IMAGE, handed on as one rather than its bytes
|
|
364
|
+
// read as text, so a model looks at what a handler drew. Any other (an svg) is its text.
|
|
365
|
+
if (IMAGE_TYPES.has(mimeType)) {
|
|
366
|
+
const data = (await answer.bytes()).toBase64()
|
|
367
|
+
logCall(one.name, answer.status, started)
|
|
368
|
+
return {
|
|
369
|
+
content: [{ type: 'image', data, mimeType }],
|
|
370
|
+
failed: false,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
348
374
|
// The body is the call's too: a handler that streams has only begun when its head arrives.
|
|
349
375
|
const text = await answer.text()
|
|
350
376
|
logCall(one.name, answer.status, started)
|
|
351
|
-
return {
|
|
377
|
+
return {
|
|
378
|
+
content: [{ type: 'text', text }],
|
|
379
|
+
failed: answer.status >= 400,
|
|
380
|
+
}
|
|
352
381
|
}
|
|
353
382
|
|
|
354
383
|
/** One MCP request, made as `asker`, as JSON-RPC's `result` or `error`. */
|
|
@@ -379,20 +408,25 @@ export class McpSurface {
|
|
|
379
408
|
`No tool named ${String(params?.name)}.`,
|
|
380
409
|
)
|
|
381
410
|
}
|
|
382
|
-
const {
|
|
411
|
+
const { content, failed } = await this.#called(
|
|
383
412
|
tool,
|
|
384
413
|
params?.arguments as Record<string, unknown> | undefined,
|
|
385
414
|
asker,
|
|
386
415
|
)
|
|
416
|
+
const [piece] = content
|
|
387
417
|
return {
|
|
388
418
|
result: {
|
|
389
|
-
|
|
419
|
+
// 20.21 — an AssistantContent piece is already an MCP content item.
|
|
420
|
+
content,
|
|
390
421
|
// 20.16 — the same value as structured content, where the tool declares
|
|
391
|
-
// its shape; a refusal is not that value.
|
|
422
|
+
// its shape; a refusal is not that value. A tool declaring one answers no
|
|
423
|
+
// image (20.22), so its one piece is its text.
|
|
392
424
|
structuredContent:
|
|
393
|
-
failed ||
|
|
425
|
+
failed ||
|
|
426
|
+
!structured(tool) ||
|
|
427
|
+
piece?.type !== 'text'
|
|
394
428
|
? undefined
|
|
395
|
-
: JSON.parse(text),
|
|
429
|
+
: JSON.parse(piece.text),
|
|
396
430
|
isError: failed,
|
|
397
431
|
},
|
|
398
432
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -27,11 +27,13 @@ export {
|
|
|
27
27
|
} from '#shared/rpc.ts'
|
|
28
28
|
// 45 — a conversation with a model, piped through a handler, and the two providers abide ships.
|
|
29
29
|
export {
|
|
30
|
+
type AssistantContent,
|
|
30
31
|
type AssistantEvent,
|
|
31
32
|
type AssistantMessage,
|
|
32
33
|
type AssistantOptions,
|
|
33
34
|
assistant,
|
|
34
35
|
type Provider,
|
|
36
|
+
type ToolAnswer,
|
|
35
37
|
type Toolbox,
|
|
36
38
|
type ToolDefinition,
|
|
37
39
|
} from './assistant.ts'
|
package/src/server/render.ts
CHANGED
|
@@ -379,8 +379,9 @@ function earlyCallScript(calls: PageCall[]): string {
|
|
|
379
379
|
// THE RUNNER RECORDS, IT NEVER CLASSIFIES: the `Response`, and a painted call's body read to its end
|
|
380
380
|
// — the one decision is `painted`, which the server made. `answerOf` reads the record as it reads an
|
|
381
381
|
// answer it fetched, so no second reading of a content type lives in a string no typecheck reaches.
|
|
382
|
-
// The shape is `EarlyCall`'s.
|
|
383
|
-
|
|
382
|
+
// The shape is `EarlyCall`'s. 16.80 — a page the worker answers has it answer the page's calls too,
|
|
383
|
+
// which the browser alone can tell: the server cannot see whether a worker will replay this page.
|
|
384
|
+
export const EARLY_CALL_RUNNER = `(function(s,c,w){for(var i=0;i<c.length;i++)(function(k,u,n,h){if(w)n.cache='force-cache';var e=s[k]={painted:h,response:void 0,bytes:void 0};e.promise=fetch(u,n).then(function(r){return h?r.arrayBuffer().then(function(b){e.bytes=b;e.response=r}):e.response=r}).catch(function(){})})(c[i][0],c[i][1],c[i][2],c[i][3])})(self.${EARLY_CALLS}||(self.${EARLY_CALLS}={}),JSON.parse(document.currentScript.previousElementSibling.textContent),(self.navigator.serviceWorker||{}).controller)`
|
|
384
385
|
|
|
385
386
|
/** The early-call runner's hash, as `script-src` names it. 29.9. */
|
|
386
387
|
export const EARLY_CALL_HASH = `'sha256-${new Bun.CryptoHasher('sha256').update(EARLY_CALL_RUNNER).digest('base64')}'`
|
package/src/server/seedBuffer.ts
CHANGED
|
@@ -83,6 +83,32 @@ type Entry = {
|
|
|
83
83
|
// which is how a refresh of it is told from a call the render never made (41.12).
|
|
84
84
|
const BUFFER = new Map<string, Map<object, Map<string, Entry[]>>>()
|
|
85
85
|
|
|
86
|
+
/** A render's one report of each call (1.8): the call's key where it is new, `undefined` where not. */
|
|
87
|
+
function reportsOnce(): (rpc: object, args: unknown) => string | undefined {
|
|
88
|
+
const reported = new Map<object, Set<string>>()
|
|
89
|
+
return (rpc, args) => {
|
|
90
|
+
const key = seedKey(args)
|
|
91
|
+
if (key === undefined) return undefined
|
|
92
|
+
let keys = reported.get(rpc)
|
|
93
|
+
if (keys === undefined) {
|
|
94
|
+
keys = new Set()
|
|
95
|
+
reported.set(rpc, keys)
|
|
96
|
+
}
|
|
97
|
+
if (keys.has(key)) return undefined
|
|
98
|
+
keys.add(key)
|
|
99
|
+
return key
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 41.30 — the seed sink of a page the worker keeps: each call reported once, for the early-call
|
|
105
|
+
* script, and none of them left in the seed buffer (41.29) — the page is opened later, if at all.
|
|
106
|
+
*/
|
|
107
|
+
export function callsOnly(): PageRender['seed'] {
|
|
108
|
+
const firstReport = reportsOnce()
|
|
109
|
+
return (rpc, args) => firstReport(rpc, args) !== undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
/**
|
|
87
113
|
* The sink a page's render scope reports each call into, for one caller.
|
|
88
114
|
*
|
|
@@ -91,17 +117,10 @@ const BUFFER = new Map<string, Map<object, Map<string, Entry[]>>>()
|
|
|
91
117
|
* browser's `Rpc` joins a standing call (1.8).
|
|
92
118
|
*/
|
|
93
119
|
export function seedFor(caller: string): PageRender['seed'] {
|
|
94
|
-
const
|
|
120
|
+
const firstReport = reportsOnce()
|
|
95
121
|
return (rpc, args, reached) => {
|
|
96
|
-
const key =
|
|
122
|
+
const key = firstReport(rpc, args)
|
|
97
123
|
if (key === undefined) return false
|
|
98
|
-
let keys = reported.get(rpc)
|
|
99
|
-
if (keys === undefined) {
|
|
100
|
-
keys = new Set()
|
|
101
|
-
reported.set(rpc, keys)
|
|
102
|
-
}
|
|
103
|
-
if (keys.has(key)) return false
|
|
104
|
-
keys.add(key)
|
|
105
124
|
const entry: Entry = {
|
|
106
125
|
rpc,
|
|
107
126
|
key,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header the offline worker asks for a page it keeps under, so the page carries the early calls
|
|
3
|
+
* a later open of it starts (41.30, 45.23).
|
|
4
|
+
*
|
|
5
|
+
* A leaf, because the server writes it into the worker's script and reads it off the request.
|
|
6
|
+
*/
|
|
7
|
+
export const KEEP_HEADER = 'abide-keep'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value of the offline worker's `abide-keep` header on the page a navigation opens, which then
|
|
3
|
+
* hydrates and so is seeded as one (41.29, 45.23) — a page saved behind the one opened is not.
|
|
4
|
+
*
|
|
5
|
+
* A leaf, because the server writes it into the worker's script and reads it off the request.
|
|
6
|
+
*/
|
|
7
|
+
export const KEEP_OPENED = 'open'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `offline` of the call that is starting a load, while it starts.
|
|
3
|
+
*
|
|
4
|
+
* 16.81 — SET AROUND THE REACH as `LOAD_SIGNAL` is, and read by the browser's wire as its body
|
|
5
|
+
* starts: what stands in for an answer the call could not get. A leaf, because `callLoad` sets it
|
|
6
|
+
* and the wire reads it.
|
|
7
|
+
*/
|
|
8
|
+
export const LOAD_OFFLINE: {
|
|
9
|
+
current: ((args: unknown) => unknown) | undefined
|
|
10
|
+
/**
|
|
11
|
+
* Where the body that queues the call asks for the value the call reached, which `callLoad`
|
|
12
|
+
* fills once the reach returns: what the delivered answer lands in (46.7).
|
|
13
|
+
*/
|
|
14
|
+
standing: { value: unknown } | undefined
|
|
15
|
+
} = { current: undefined, standing: undefined }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the outbox has a call in flight, as a bare `busy` asks it (46.11).
|
|
3
|
+
*
|
|
4
|
+
* A leaf, because `outbox` fills it and the core's `busy` reads it from below `outbox`. Unfilled — an
|
|
5
|
+
* app holding no handler — no call is ever in flight.
|
|
6
|
+
*/
|
|
7
|
+
export const OUTBOX_SENDING: { any: (() => boolean) | undefined } = {
|
|
8
|
+
any: undefined,
|
|
9
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key of the message the router posts the offline worker, naming the address a navigation
|
|
3
|
+
* reached, which the worker then keeps (45.24).
|
|
4
|
+
*
|
|
5
|
+
* A leaf, because the server writes it into the worker's script and the router posts it.
|
|
6
|
+
*/
|
|
7
|
+
export const REACHED_MESSAGE = 'reached'
|
package/src/shared/answerOf.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
framedChunks,
|
|
12
12
|
framingOf,
|
|
13
13
|
} from './framing.ts'
|
|
14
|
+
import { KEPT_HEADER } from './KEPT_HEADER.ts'
|
|
14
15
|
import { principalRotated } from './principalRotated.ts'
|
|
15
16
|
import { refuse } from './refuse.ts'
|
|
16
17
|
import type { RpcRequest } from './rpcRequest.ts'
|
|
@@ -35,6 +36,8 @@ export type Answer = {
|
|
|
35
36
|
version: number | undefined
|
|
36
37
|
/** The `max-age` its `cache-control` named, in ms (18.14, 21.8). */
|
|
37
38
|
life: number | undefined
|
|
39
|
+
/** 16.85 — whether the worker answered from a copy it kept rather than the network (45.22). */
|
|
40
|
+
kept: boolean
|
|
38
41
|
text: string | undefined
|
|
39
42
|
/**
|
|
40
43
|
* A binary answer's body read to the end (16.34), set in place of `text` — a body read as text
|
|
@@ -92,7 +95,7 @@ async function fetched(
|
|
|
92
95
|
if (signal?.aborted === true) throw error
|
|
93
96
|
// 16.79 — NO ANSWER AT ALL, as a status the app can narrow on rather than the browser's
|
|
94
97
|
// own `TypeError`, whose message differs in each.
|
|
95
|
-
return
|
|
98
|
+
return noAnswer()
|
|
96
99
|
}
|
|
97
100
|
// THE HANDLER SIGNED THIS CALLER IN OR OUT, and the cookie is already the browser's.
|
|
98
101
|
principalRotated(response)
|
|
@@ -100,7 +103,7 @@ async function fetched(
|
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/** The answer a call that reached no server stands in for: an empty 503, which `answered` refuses. */
|
|
103
|
-
function
|
|
106
|
+
export function noAnswer(): Answer {
|
|
104
107
|
return {
|
|
105
108
|
status: 503,
|
|
106
109
|
statusText: '',
|
|
@@ -109,12 +112,20 @@ function unanswered(): Answer {
|
|
|
109
112
|
followable: false,
|
|
110
113
|
version: undefined,
|
|
111
114
|
life: undefined,
|
|
115
|
+
kept: false,
|
|
112
116
|
text: '',
|
|
113
117
|
bytes: undefined,
|
|
114
118
|
body: undefined,
|
|
115
119
|
}
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
/** 46.4 — no answer at all (16.79), or a gateway's: try again later. */
|
|
123
|
+
export function unanswered(answer: Answer): boolean {
|
|
124
|
+
return (
|
|
125
|
+
answer.status === 502 || answer.status === 503 || answer.status === 504
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
118
129
|
/**
|
|
119
130
|
* `response`, classified: a body `read` to its end already (an early call the server painted)
|
|
120
131
|
* decoded as what it carried, and one unread read as bytes where it is a binary (16.34), as text
|
|
@@ -139,6 +150,7 @@ function answerFrom(
|
|
|
139
150
|
followable: ok && !binary,
|
|
140
151
|
version: versionInEtag(headers.get(VERSION_HEADER)),
|
|
141
152
|
life: seconds === null ? undefined : Number(seconds[1]) * 1000,
|
|
153
|
+
kept: headers.has(KEPT_HEADER),
|
|
142
154
|
text: undefined,
|
|
143
155
|
bytes: undefined,
|
|
144
156
|
body: undefined,
|
package/src/shared/framing.ts
CHANGED
|
@@ -33,11 +33,19 @@ export function framingOf(type: string | null | undefined): Framed | null {
|
|
|
33
33
|
export function carriesBytes(type: string | null | undefined): boolean {
|
|
34
34
|
if (type === null || type === undefined || type === '') return false
|
|
35
35
|
if (framingOf(type) !== null) return false
|
|
36
|
-
const
|
|
37
|
-
const essence = semicolon === -1 ? type : type.slice(0, semicolon)
|
|
36
|
+
const essence = essenceOf(type)
|
|
38
37
|
return essence !== 'application/json' && !essence.endsWith('+json')
|
|
39
38
|
}
|
|
40
39
|
|
|
40
|
+
/** A content type less its parameters, folded to lower case, as a type is compared; `''` for none. */
|
|
41
|
+
export function essenceOf(type: string | null | undefined): string {
|
|
42
|
+
if (type === null || type === undefined) return ''
|
|
43
|
+
const semicolon = type.indexOf(';')
|
|
44
|
+
return (semicolon === -1 ? type : type.slice(0, semicolon))
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
41
49
|
/** One frame: an sse event's name — `message` where it named none — `id` and `data`, or a jsonl line. */
|
|
42
50
|
export type Frame = { name: string; id: string | undefined; data: string }
|
|
43
51
|
|