experimental-a2 0.18.1 → 0.19.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 +28 -0
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +30 -10
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
- package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +55 -10
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +118 -50
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +1 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/docs/agents/03-conversations.mdx +25 -13
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/reference/04-ai.mdx +27 -18
- package/docs/reference/05-adapters.mdx +2 -2
- package/docs/server/02-timers.mdx +1 -1
- package/docs/server/05-production.mdx +2 -2
- package/examples/playground/DEPLOY.md +9 -0
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +504 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +3 -1
- package/package.json +1 -1
- package/src/ai-agent.ts +4 -0
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +58 -20
- package/src/ai-harness.ts +153 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/libfx.d.ts +8 -1
- package/src/scheduler-vercel.ts +1 -12
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import type { KeyboardEvent, ReactNode } from 'react'
|
|
2
|
+
import type { KeyboardEvent, ReactNode, RefObject } from 'react'
|
|
3
3
|
import {
|
|
4
4
|
useEffect,
|
|
5
5
|
useEffectEvent,
|
|
@@ -7,27 +7,38 @@ import {
|
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
} from 'react'
|
|
10
|
+
import { Check } from 'lucide-react'
|
|
10
11
|
|
|
11
12
|
export type MenuItem = {
|
|
12
13
|
label: string
|
|
13
14
|
onSelect: () => void
|
|
14
15
|
danger?: boolean
|
|
16
|
+
/** One of a set of choices; a checked one is the current choice. */
|
|
17
|
+
checked?: boolean
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
const ITEMS = '[role="menuitem"], [role="menuitemradio"]'
|
|
21
|
+
|
|
17
22
|
// Keeps the menu this far inside the window's edges.
|
|
18
23
|
const MARGIN = 8
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
|
-
* A menu at a point, as a right-click opens one.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* A menu at a point, as a right-click opens one. The point is the menu's top
|
|
27
|
+
* left corner, or its bottom right for a button near the window's foot. It
|
|
28
|
+
* takes focus, moves with the arrow keys, and closes on Escape, a press
|
|
29
|
+
* outside, or a pick; Escape and a pick hand focus back through `onClose`.
|
|
24
30
|
*/
|
|
25
31
|
export function ContextMenu({
|
|
26
32
|
at,
|
|
33
|
+
corner = 'top-left',
|
|
34
|
+
trigger,
|
|
27
35
|
items,
|
|
28
36
|
onClose,
|
|
29
37
|
}: {
|
|
30
38
|
at: { x: number; y: number }
|
|
39
|
+
corner?: 'top-left' | 'bottom-right'
|
|
40
|
+
/** The button that opens the menu; a press on it is its own, not outside. */
|
|
41
|
+
trigger?: RefObject<HTMLElement | null>
|
|
31
42
|
items: MenuItem[]
|
|
32
43
|
onClose: (closed: { returnFocus: boolean }) => void
|
|
33
44
|
}): ReactNode {
|
|
@@ -39,19 +50,26 @@ export function ContextMenu({
|
|
|
39
50
|
const element = menu.current
|
|
40
51
|
if (!element) return
|
|
41
52
|
const { width, height } = element.getBoundingClientRect()
|
|
53
|
+
const x = corner === 'top-left' ? at.x : at.x - width
|
|
54
|
+
const y = corner === 'top-left' ? at.y : at.y - height
|
|
42
55
|
setPosition({
|
|
43
|
-
x: Math.max(MARGIN, Math.min(
|
|
44
|
-
y: Math.max(MARGIN, Math.min(
|
|
56
|
+
x: Math.max(MARGIN, Math.min(x, innerWidth - width - MARGIN)),
|
|
57
|
+
y: Math.max(MARGIN, Math.min(y, innerHeight - height - MARGIN)),
|
|
45
58
|
})
|
|
46
|
-
element.querySelector<HTMLElement>(
|
|
47
|
-
}, [at])
|
|
59
|
+
element.querySelector<HTMLElement>(ITEMS)?.focus()
|
|
60
|
+
}, [at, corner])
|
|
48
61
|
|
|
49
62
|
const dismiss = useEffectEvent((returnFocus: boolean) =>
|
|
50
63
|
onClose({ returnFocus }),
|
|
51
64
|
)
|
|
52
65
|
useEffect(() => {
|
|
53
66
|
const outside = (event: PointerEvent): void => {
|
|
54
|
-
|
|
67
|
+
const target = event.target as Node
|
|
68
|
+
if (
|
|
69
|
+
!menu.current?.contains(target) &&
|
|
70
|
+
!trigger?.current?.contains(target)
|
|
71
|
+
)
|
|
72
|
+
dismiss(false)
|
|
55
73
|
}
|
|
56
74
|
const away = (): void => dismiss(false)
|
|
57
75
|
document.addEventListener('pointerdown', outside, true)
|
|
@@ -66,7 +84,7 @@ export function ContextMenu({
|
|
|
66
84
|
|
|
67
85
|
const move = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
68
86
|
const buttons = [
|
|
69
|
-
...event.currentTarget.querySelectorAll<HTMLElement>(
|
|
87
|
+
...event.currentTarget.querySelectorAll<HTMLElement>(ITEMS),
|
|
70
88
|
]
|
|
71
89
|
const focused = buttons.indexOf(document.activeElement as HTMLElement)
|
|
72
90
|
const next =
|
|
@@ -101,13 +119,22 @@ export function ContextMenu({
|
|
|
101
119
|
<button
|
|
102
120
|
key={item.label}
|
|
103
121
|
type="button"
|
|
104
|
-
role=
|
|
122
|
+
role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
|
|
123
|
+
aria-checked={item.checked}
|
|
105
124
|
data-danger={item.danger || undefined}
|
|
106
125
|
onClick={() => {
|
|
107
126
|
onClose({ returnFocus: true })
|
|
108
127
|
item.onSelect()
|
|
109
128
|
}}
|
|
110
129
|
>
|
|
130
|
+
{item.checked !== undefined && (
|
|
131
|
+
<Check
|
|
132
|
+
size={12}
|
|
133
|
+
className="a0-menu-check"
|
|
134
|
+
visibility={item.checked ? 'visible' : 'hidden'}
|
|
135
|
+
aria-hidden
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
111
138
|
{item.label}
|
|
112
139
|
</button>
|
|
113
140
|
))}
|
|
@@ -71,3 +71,31 @@ it("reads the app's harness from its URL, the AI SDK's by default", () => {
|
|
|
71
71
|
'a0-pi',
|
|
72
72
|
])
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
const sendWithFile = (url: string) =>
|
|
76
|
+
appAgent.events['ai.control.requested']['~standard'].validate({
|
|
77
|
+
action: 'send',
|
|
78
|
+
message: {
|
|
79
|
+
id: 'u1',
|
|
80
|
+
role: 'user',
|
|
81
|
+
parts: [
|
|
82
|
+
{ type: 'file', mediaType: 'image/png', url },
|
|
83
|
+
{ type: 'text', text: 'Like this.' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("only takes a user's files from the playground's Blob store", () => {
|
|
89
|
+
expect(
|
|
90
|
+
sendWithFile(
|
|
91
|
+
'https://store.public.blob.vercel-storage.com/a0/app/shot-x1.png',
|
|
92
|
+
),
|
|
93
|
+
).not.toHaveProperty('issues')
|
|
94
|
+
for (const url of [
|
|
95
|
+
'data:image/png;base64,iVBORw0KGgo=',
|
|
96
|
+
'http://store.public.blob.vercel-storage.com/shot.png',
|
|
97
|
+
'http://169.254.169.254/latest/meta-data',
|
|
98
|
+
'https://example.com/shot.png',
|
|
99
|
+
])
|
|
100
|
+
expect(sendWithFile(url)).toHaveProperty('issues')
|
|
101
|
+
})
|
|
@@ -35,6 +35,35 @@ export const defaultModelChoice: ModelChoice = {
|
|
|
35
35
|
|
|
36
36
|
export type AppMessage = UIMessage
|
|
37
37
|
|
|
38
|
+
/** A file the user attached, uploaded to the playground's public Blob store. */
|
|
39
|
+
export function isAttachmentUrl(value: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const { protocol, hostname } = new URL(value)
|
|
42
|
+
return (
|
|
43
|
+
protocol === 'https:' &&
|
|
44
|
+
hostname.endsWith('.public.blob.vercel-storage.com')
|
|
45
|
+
)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A user's files must be attachments. Whatever hands them to the model
|
|
53
|
+
* fetches them by URL, so the URL can't point anywhere else, and a message
|
|
54
|
+
* never holds a file's bytes.
|
|
55
|
+
*/
|
|
56
|
+
const messageSchema = z
|
|
57
|
+
.custom<AppMessage>()
|
|
58
|
+
.refine(
|
|
59
|
+
(message) =>
|
|
60
|
+
message.role !== 'user' ||
|
|
61
|
+
message.parts.every(
|
|
62
|
+
(part) => part.type !== 'file' || isAttachmentUrl(part.url),
|
|
63
|
+
),
|
|
64
|
+
"A message's files must be uploaded attachments",
|
|
65
|
+
)
|
|
66
|
+
|
|
38
67
|
/**
|
|
39
68
|
* The loop an app's agent runs: the AI SDK's, FX's, or Pi's. An
|
|
40
69
|
* app picks it before its first message and keeps it.
|
|
@@ -56,6 +85,7 @@ export const harnessFrom = (value: unknown): Harness =>
|
|
|
56
85
|
const appContract = (name: string) =>
|
|
57
86
|
agentContract({
|
|
58
87
|
name,
|
|
88
|
+
messageSchema,
|
|
59
89
|
events: { 'model.chosen': modelChoiceSchema },
|
|
60
90
|
reducer: {
|
|
61
91
|
name: 'a0.model-choice.v1',
|
|
@@ -5,6 +5,7 @@ import { handlerContext } from 'experimental-a2/ai'
|
|
|
5
5
|
import { fx } from 'experimental-a2/ai/fx'
|
|
6
6
|
import { pi } from 'experimental-a2/ai/pi'
|
|
7
7
|
import { agent } from 'experimental-a2/ai/server'
|
|
8
|
+
import { otel } from 'experimental-a2/otel'
|
|
8
9
|
import { createServer } from 'experimental-a2/server'
|
|
9
10
|
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
10
11
|
import { z } from 'zod'
|
|
@@ -33,6 +34,7 @@ const instructions = (
|
|
|
33
34
|
- If the preview stops answering, read /tmp/next.log, then start next dev with \`${START_DEV}\`; it does nothing while one is already running.
|
|
34
35
|
- next.config allows the preview's *.vercel.run host (allowedDevOrigins); keep that when you edit it.
|
|
35
36
|
- Background anything long-running; a bash call stops after its timeout.
|
|
37
|
+
- The user can attach files. Images come with their message; any other file is a "name: URL" line in it. Download one with curl when you need its contents.
|
|
36
38
|
- Keep replies short and in plain text, without Markdown: what you changed.`
|
|
37
39
|
|
|
38
40
|
/** A tool's result, with how long it took; the chat shows it. */
|
|
@@ -118,10 +120,13 @@ function chosenModel({
|
|
|
118
120
|
return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
const telemetry = otel()
|
|
124
|
+
|
|
121
125
|
const shared = (harness: Harness) => ({
|
|
122
126
|
...(store ? { store } : {}),
|
|
123
127
|
...(appSchedulers[harness] ? { scheduler: appSchedulers[harness] } : {}),
|
|
124
128
|
contract: harnessAgents[harness],
|
|
129
|
+
telemetry,
|
|
125
130
|
maxSteps: 80,
|
|
126
131
|
})
|
|
127
132
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
|
|
2
|
+
import { isAppId } from '../apps'
|
|
3
|
+
|
|
4
|
+
/** Tokens for the browser to upload an app's attachments straight to Blob. */
|
|
5
|
+
export async function POST(request: Request): Promise<Response> {
|
|
6
|
+
const body = (await request.json()) as HandleUploadBody
|
|
7
|
+
try {
|
|
8
|
+
return Response.json(
|
|
9
|
+
await handleUpload({
|
|
10
|
+
request,
|
|
11
|
+
body,
|
|
12
|
+
onBeforeGenerateToken: async (pathname, appId) => {
|
|
13
|
+
if (
|
|
14
|
+
appId === null ||
|
|
15
|
+
!isAppId(appId) ||
|
|
16
|
+
!pathname.startsWith(`a0/${appId}/`)
|
|
17
|
+
)
|
|
18
|
+
throw new Error("An attachment goes in its app's folder")
|
|
19
|
+
return { addRandomSuffix: true }
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ error: cause instanceof Error ? cause.message : String(cause) },
|
|
26
|
+
{ status: 400 },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -393,10 +393,11 @@ export function AgentClient({
|
|
|
393
393
|
>
|
|
394
394
|
Stop
|
|
395
395
|
</button>
|
|
396
|
+
{/* With no turn to steer, steering would only send. */}
|
|
396
397
|
<button
|
|
397
398
|
type="button"
|
|
398
399
|
className="secondary"
|
|
399
|
-
disabled={!canSend || !prompt.trim()}
|
|
400
|
+
disabled={!canSend || !prompt.trim() || !state.active}
|
|
400
401
|
onClick={() => void send(undefined, true)}
|
|
401
402
|
>
|
|
402
403
|
Steer
|
|
@@ -29,9 +29,18 @@ it('folds compaction settings beside the AI state', () => {
|
|
|
29
29
|
)
|
|
30
30
|
state = reducer.fold(
|
|
31
31
|
state,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// A send's id is its message's.
|
|
33
|
+
{
|
|
34
|
+
...event(2, 'ai.control.requested', {
|
|
35
|
+
action: 'send',
|
|
36
|
+
message: { id: 'user', role: 'user', parts: [] },
|
|
37
|
+
}),
|
|
38
|
+
id: 'user',
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
state = reducer.fold(
|
|
42
|
+
state,
|
|
43
|
+
event(3, 'ai.message.created', { messageId: 'user', role: 'user' }),
|
|
35
44
|
)
|
|
36
45
|
expect(state.compactionSettings).toEqual({ mode: 'disabled' })
|
|
37
46
|
expect(state.compactionSettingsId).toBe('event-1')
|
|
@@ -19,11 +19,7 @@ function running() {
|
|
|
19
19
|
active: {
|
|
20
20
|
turnId: 'turn',
|
|
21
21
|
phase: 'generating',
|
|
22
|
-
input: {
|
|
23
|
-
id: 'input',
|
|
24
|
-
generate: true,
|
|
25
|
-
message: { id: 'input', role: 'user', parts: [] },
|
|
26
|
-
},
|
|
22
|
+
input: { id: 'input', role: 'user', generate: true },
|
|
27
23
|
},
|
|
28
24
|
}
|
|
29
25
|
const append = (event: { type: string; payload: unknown; id?: string }) => {
|
|
@@ -6,13 +6,16 @@ import type { NextConfig } from 'next'
|
|
|
6
6
|
|
|
7
7
|
const nextConfig: NextConfig = {
|
|
8
8
|
// libfx loads a native binary for the platform, falling back to its
|
|
9
|
-
// WebAssembly,
|
|
10
|
-
//
|
|
11
|
-
|
|
9
|
+
// WebAssembly, and photon, which Pi resizes images with, loads its
|
|
10
|
+
// WebAssembly, by paths the bundler cannot follow; Node loads them from
|
|
11
|
+
// node_modules at runtime. Pi stays bundled (externalized, it makes
|
|
12
|
+
// Turbopack panic on its WebAssembly), so it finds photon through this
|
|
13
|
+
// app: package.json depends on photon at the version Pi pins, and the two
|
|
14
|
+
// move together.
|
|
15
|
+
serverExternalPackages: ['libfx', '@silvia-odwyer/photon-node'],
|
|
12
16
|
turbopack: {
|
|
13
17
|
// WebAssembly Pi's code reaches that the bundler cannot trace.
|
|
14
18
|
resolveAlias: {
|
|
15
|
-
'@silvia-odwyer/photon-node': './app/a0/no-photon.cjs',
|
|
16
19
|
'quickjs-wasi': './app/a0/no-quickjs.cjs',
|
|
17
20
|
'quickjs-wasi/quickjs.wasm': './app/a0/no-quickjs.cjs',
|
|
18
21
|
},
|
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
"@earendil-works/pi-ai": "1.0.3",
|
|
23
23
|
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
24
24
|
"@opentelemetry/api": "^1.9.0",
|
|
25
|
+
"@silvia-odwyer/photon-node": "0.3.4",
|
|
26
|
+
"@vercel/blob": "^2.8.0",
|
|
25
27
|
"@vercel/functions": "^3.9.3",
|
|
26
28
|
"@vercel/otel": "^2.1.0",
|
|
27
29
|
"@vercel/queue": "^0.2.0",
|
|
28
30
|
"@vercel/sandbox": "^3.0.0",
|
|
29
31
|
"ai": "^7.0.58",
|
|
30
32
|
"codemirror": "^6.0.2",
|
|
31
|
-
"experimental-a2": "0.
|
|
33
|
+
"experimental-a2": "0.19.0",
|
|
32
34
|
"geist": "^1.7.2",
|
|
33
35
|
"ioredis": "^5.9.0",
|
|
34
36
|
"libfx": "0.0.12",
|
package/package.json
CHANGED
package/src/ai-agent.ts
CHANGED
|
@@ -1282,6 +1282,10 @@ export function createHandlers<
|
|
|
1282
1282
|
throw new TypeError(
|
|
1283
1283
|
`a harness runs tools without approval; '${name}' sets needsApproval`,
|
|
1284
1284
|
)
|
|
1285
|
+
if (tool.type === 'provider')
|
|
1286
|
+
throw new TypeError(
|
|
1287
|
+
`a harness runs only tools A2 executes; '${name}' is provider-defined`,
|
|
1288
|
+
)
|
|
1285
1289
|
}
|
|
1286
1290
|
}
|
|
1287
1291
|
|
package/src/ai-client-state.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { UIMessage } from 'ai'
|
|
2
2
|
import type { AIState } from './ai.ts'
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
steeringEnd,
|
|
5
|
+
steersOn,
|
|
6
|
+
type ControlCommand,
|
|
7
|
+
type InboxItem,
|
|
8
|
+
} from './ai-control.ts'
|
|
4
9
|
import {
|
|
5
10
|
interruptGenerationProjection,
|
|
6
11
|
settleAbandonedCompaction,
|
|
@@ -92,10 +97,10 @@ export function projectAIClientState<M extends UIMessage>(
|
|
|
92
97
|
const admit = (): void => {
|
|
93
98
|
if (turnId !== null || paused || items.length === 0) return
|
|
94
99
|
let count = 0
|
|
95
|
-
for (const item of items) {
|
|
100
|
+
for (const [index, item] of items.entries()) {
|
|
96
101
|
count += 1
|
|
97
102
|
show(item.message)
|
|
98
|
-
if (item.generate) {
|
|
103
|
+
if (item.generate && !steersOn(item, items.slice(index + 1))) {
|
|
99
104
|
turnId = item.id
|
|
100
105
|
starting = true
|
|
101
106
|
suspended = false
|
|
@@ -118,18 +123,21 @@ export function projectAIClientState<M extends UIMessage>(
|
|
|
118
123
|
? { afterStepOf: command.turnId }
|
|
119
124
|
: {}),
|
|
120
125
|
id: command.message.id,
|
|
126
|
+
role: command.message.role,
|
|
121
127
|
message: command.message,
|
|
122
128
|
generate:
|
|
123
129
|
command.message.role === 'user' &&
|
|
124
130
|
(command.action === 'steer' || command.generate !== false),
|
|
125
131
|
}
|
|
126
|
-
|
|
132
|
+
if (command.action === 'steer') {
|
|
133
|
+
const at = steeringEnd(items, command.turnId)
|
|
134
|
+
items = [...items.slice(0, at), item, ...items.slice(at)]
|
|
135
|
+
} else items = [...items, item]
|
|
127
136
|
break
|
|
128
137
|
}
|
|
129
138
|
case 'edit':
|
|
130
139
|
items = items.map((item) =>
|
|
131
|
-
item.id === command.message.id &&
|
|
132
|
-
item.message.role === command.message.role
|
|
140
|
+
item.id === command.message.id && item.role === command.message.role
|
|
133
141
|
? { ...item, message: command.message }
|
|
134
142
|
: item,
|
|
135
143
|
)
|
|
@@ -147,20 +155,24 @@ export function projectAIClientState<M extends UIMessage>(
|
|
|
147
155
|
case 'send-now': {
|
|
148
156
|
if (turnId !== command.turnId) break
|
|
149
157
|
const selected = items.find((item) => item.id === command.inputId)
|
|
150
|
-
if (!selected || selected.
|
|
158
|
+
if (!selected || selected.role !== 'user') break
|
|
159
|
+
const { afterStepOf: _, ...promoted } = selected
|
|
151
160
|
items = items.map((item) =>
|
|
152
161
|
item.id === selected.id
|
|
153
162
|
? {
|
|
154
|
-
...
|
|
163
|
+
...promoted,
|
|
155
164
|
generate: true,
|
|
156
165
|
...(turnId === null ? {} : { afterStepOf: turnId }),
|
|
157
166
|
}
|
|
158
167
|
: item,
|
|
159
168
|
)
|
|
169
|
+
const others = items.filter((item) => item.id !== selected.id)
|
|
160
170
|
items = moveItem({
|
|
161
171
|
items,
|
|
162
172
|
inputId: command.inputId,
|
|
163
|
-
beforeId:
|
|
173
|
+
beforeId:
|
|
174
|
+
others[turnId === null ? 0 : steeringEnd(others, turnId)]?.id ??
|
|
175
|
+
null,
|
|
164
176
|
})
|
|
165
177
|
break
|
|
166
178
|
}
|
package/src/ai-context-schema.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertMessageBase, restoreMessageBase } from './ai-message-base.ts'
|
|
2
2
|
import type { UIMessage } from 'ai'
|
|
3
3
|
import { events } from './ai.ts'
|
|
4
|
+
import { isUIMessage } from './ai-message-shape.ts'
|
|
4
5
|
import type { AIContextState } from './ai-context.ts'
|
|
5
6
|
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
6
7
|
import { validateSync } from './validate.ts'
|
|
@@ -14,13 +15,34 @@ const id = (value: unknown): value is string =>
|
|
|
14
15
|
const nullableId = (value: unknown): boolean => value === null || id(value)
|
|
15
16
|
const matches = (schema: StandardSchemaV1<unknown>, value: unknown): boolean =>
|
|
16
17
|
validateSync(schema, value, 'AI context checkpoint').issues === undefined
|
|
17
|
-
const isMessage = (value: unknown): value is UIMessage =>
|
|
18
|
-
matches(events['ai.message.created'], { message: value })
|
|
19
18
|
const indexes = (value: unknown, messages: readonly UIMessage[]): boolean =>
|
|
20
19
|
record(value) &&
|
|
21
20
|
Object.values(value).every(index) &&
|
|
22
21
|
messages.every((message) => Object.hasOwn(value, message.id))
|
|
23
22
|
|
|
23
|
+
const inputs = (value: unknown): boolean =>
|
|
24
|
+
record(value) &&
|
|
25
|
+
Object.keys(value).length === 2 &&
|
|
26
|
+
Array.isArray(value['commands']) &&
|
|
27
|
+
value['commands'].every(
|
|
28
|
+
(held) =>
|
|
29
|
+
record(held) &&
|
|
30
|
+
Object.keys(held).every((key) =>
|
|
31
|
+
['commandId', 'inputId', 'message'].includes(key),
|
|
32
|
+
) &&
|
|
33
|
+
id(held['commandId']) &&
|
|
34
|
+
id(held['inputId']) &&
|
|
35
|
+
(held['message'] === undefined || isUIMessage(held['message'])),
|
|
36
|
+
) &&
|
|
37
|
+
Array.isArray(value['waiting']) &&
|
|
38
|
+
value['waiting'].every(
|
|
39
|
+
(input) =>
|
|
40
|
+
record(input) &&
|
|
41
|
+
Object.keys(input).length === 2 &&
|
|
42
|
+
id(input['inputId']) &&
|
|
43
|
+
isUIMessage(input['message']),
|
|
44
|
+
)
|
|
45
|
+
|
|
24
46
|
const compaction = (value: unknown): boolean =>
|
|
25
47
|
value === null ||
|
|
26
48
|
(record(value) &&
|
|
@@ -109,7 +131,7 @@ const restorableBase = (options: {
|
|
|
109
131
|
current: UIMessage | undefined
|
|
110
132
|
}): boolean => {
|
|
111
133
|
assertMessageBase(options.base)
|
|
112
|
-
return
|
|
134
|
+
return isUIMessage(
|
|
113
135
|
restoreMessageBase({ current: options.current, base: options.base }),
|
|
114
136
|
)
|
|
115
137
|
}
|
|
@@ -230,6 +252,7 @@ export const contextSchema = <M extends UIMessage>(
|
|
|
230
252
|
'compaction',
|
|
231
253
|
'modelMetadata',
|
|
232
254
|
'closed',
|
|
255
|
+
'inputs',
|
|
233
256
|
'authority',
|
|
234
257
|
'generation',
|
|
235
258
|
'activeProjection',
|
|
@@ -249,9 +272,10 @@ export const contextSchema = <M extends UIMessage>(
|
|
|
249
272
|
const current = value['activeProjection']
|
|
250
273
|
if (
|
|
251
274
|
!Array.isArray(messages) ||
|
|
252
|
-
!messages.every(
|
|
275
|
+
!messages.every(isUIMessage) ||
|
|
253
276
|
!compaction(value['compaction']) ||
|
|
254
277
|
typeof value['closed'] !== 'boolean' ||
|
|
278
|
+
!inputs(value['inputs']) ||
|
|
255
279
|
!authority(value['authority']) ||
|
|
256
280
|
!record(metadata) ||
|
|
257
281
|
!Object.entries(metadata).every(
|
package/src/ai-context.ts
CHANGED
|
@@ -19,7 +19,12 @@ import type {
|
|
|
19
19
|
ToolCalledPayload,
|
|
20
20
|
ToolResultPayload,
|
|
21
21
|
} from './ai.ts'
|
|
22
|
-
import
|
|
22
|
+
import {
|
|
23
|
+
type ControlCommand,
|
|
24
|
+
type ControlCommit,
|
|
25
|
+
type ControlReceipt,
|
|
26
|
+
unexplainedAdmission,
|
|
27
|
+
} from './ai-control.ts'
|
|
23
28
|
import { contextSchema } from './ai-context-schema.ts'
|
|
24
29
|
import type { Contract, ContractEvent, EventDefs } from './contract.ts'
|
|
25
30
|
import {
|
|
@@ -49,8 +54,22 @@ export type AIModelContext<M extends UIMessage = UIMessage> = {
|
|
|
49
54
|
modelMetadata: Record<string, ModelMetadataState>
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Inputs' content between their command and their admission: each input's
|
|
59
|
+
* content is stored only in the command that brought it in. Lists, not
|
|
60
|
+
* records: clients choose the ids, and a record would read an id such as
|
|
61
|
+
* `constructor` or `__proto__` from its prototype.
|
|
62
|
+
*/
|
|
63
|
+
export type AIContextInputs<M extends UIMessage> = {
|
|
64
|
+
/** Undecided input commands; a remove holds no message. */
|
|
65
|
+
commands: { commandId: string; inputId: string; message?: M }[]
|
|
66
|
+
/** Queued inputs' current content. */
|
|
67
|
+
waiting: { inputId: string; message: M }[]
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
export type AIContextState<M extends UIMessage> = AIModelContext<M> & {
|
|
53
71
|
closed: boolean
|
|
72
|
+
inputs: AIContextInputs<M>
|
|
54
73
|
authority: Pick<
|
|
55
74
|
ControlCommit['view'],
|
|
56
75
|
| 'requestId'
|
|
@@ -211,6 +230,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
|
|
|
211
230
|
compaction: null,
|
|
212
231
|
modelMetadata: {},
|
|
213
232
|
closed: false,
|
|
233
|
+
inputs: { commands: [], waiting: [] },
|
|
214
234
|
authority: null,
|
|
215
235
|
generation: null,
|
|
216
236
|
activeProjection: null,
|
|
@@ -222,7 +242,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
|
|
|
222
242
|
// The application state rides in this checkpoint so every generation
|
|
223
243
|
// reads it without another state read.
|
|
224
244
|
name:
|
|
225
|
-
app === undefined ? 'a2.ai.context.
|
|
245
|
+
app === undefined ? 'a2.ai.context.v7' : `${app.name}+a2.ai.context.v7`,
|
|
226
246
|
initialState,
|
|
227
247
|
stateSchema: contextSchema<M>(app),
|
|
228
248
|
}).fold((state, event) => {
|
|
@@ -265,19 +285,79 @@ const reduceContext = <M extends UIMessage, D extends EventDefs>(
|
|
|
265
285
|
}
|
|
266
286
|
return view.closed ? endGeneration(next) : next
|
|
267
287
|
}
|
|
288
|
+
case 'ai.control.requested': {
|
|
289
|
+
const command = event.payload as ControlCommand<M>
|
|
290
|
+
const held =
|
|
291
|
+
command.action === 'send' ||
|
|
292
|
+
command.action === 'steer' ||
|
|
293
|
+
command.action === 'edit'
|
|
294
|
+
? {
|
|
295
|
+
commandId: event.id,
|
|
296
|
+
inputId: command.message.id,
|
|
297
|
+
message: command.message,
|
|
298
|
+
}
|
|
299
|
+
: command.action === 'remove'
|
|
300
|
+
? { commandId: event.id, inputId: command.inputId }
|
|
301
|
+
: undefined
|
|
302
|
+
if (held === undefined) return state
|
|
303
|
+
return {
|
|
304
|
+
...state,
|
|
305
|
+
inputs: {
|
|
306
|
+
...state.inputs,
|
|
307
|
+
commands: [...state.inputs.commands, held],
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
case 'ai.control.decided': {
|
|
312
|
+
const receipt = event.payload as ControlReceipt
|
|
313
|
+
const held = receipt.work
|
|
314
|
+
? undefined
|
|
315
|
+
: state.inputs.commands.find(
|
|
316
|
+
(command) => command.commandId === receipt.commandId,
|
|
317
|
+
)
|
|
318
|
+
if (held === undefined) return state
|
|
319
|
+
const commands = state.inputs.commands.filter(
|
|
320
|
+
(command) => command !== held,
|
|
321
|
+
)
|
|
322
|
+
if (receipt.outcome !== 'applied')
|
|
323
|
+
return { ...state, inputs: { ...state.inputs, commands } }
|
|
324
|
+
const waiting = state.inputs.waiting.filter(
|
|
325
|
+
(input) => input.inputId !== held.inputId,
|
|
326
|
+
)
|
|
327
|
+
if (held.message !== undefined)
|
|
328
|
+
waiting.push({ inputId: held.inputId, message: held.message })
|
|
329
|
+
return { ...state, inputs: { commands, waiting } }
|
|
330
|
+
}
|
|
268
331
|
case 'ai.session.closed':
|
|
269
332
|
return endGeneration({
|
|
270
333
|
...state,
|
|
271
334
|
closed: true,
|
|
335
|
+
inputs: { commands: [], waiting: [] },
|
|
272
336
|
compaction:
|
|
273
337
|
state.compaction?.status === 'running'
|
|
274
338
|
? (state.activeProjection?.baseCompaction ?? null)
|
|
275
339
|
: state.compaction,
|
|
276
340
|
})
|
|
277
341
|
case 'ai.message.created': {
|
|
278
|
-
const {
|
|
342
|
+
const { messageId } = event.payload as MessageCreatedPayload
|
|
343
|
+
// A send or steer admitted with its own receipt is still undecided
|
|
344
|
+
// here; its command id is the input's.
|
|
345
|
+
const message =
|
|
346
|
+
state.inputs.waiting.find((input) => input.inputId === messageId)
|
|
347
|
+
?.message ??
|
|
348
|
+
state.inputs.commands.find((command) => command.commandId === messageId)
|
|
349
|
+
?.message
|
|
350
|
+
if (message === undefined) throw unexplainedAdmission(messageId)
|
|
279
351
|
return {
|
|
280
352
|
...state,
|
|
353
|
+
inputs: {
|
|
354
|
+
commands: state.inputs.commands.filter(
|
|
355
|
+
(command) => command.commandId !== messageId,
|
|
356
|
+
),
|
|
357
|
+
waiting: state.inputs.waiting.filter(
|
|
358
|
+
(input) => input.inputId !== messageId,
|
|
359
|
+
),
|
|
360
|
+
},
|
|
281
361
|
messages: upsertMessage(state.messages, message),
|
|
282
362
|
activeProjection:
|
|
283
363
|
state.activeProjection?.responseMessageId === message.id
|