experimental-a2 0.18.0 → 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 +39 -0
- package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
- package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- 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 +122 -44
- 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-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
- 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 +114 -23
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +127 -55
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
- package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +10 -14
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +25 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +55 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +35 -20
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/agents/03-conversations.mdx +27 -15
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/contract/03-sessions.mdx +1 -1
- package/docs/index.mdx +3 -3
- package/docs/reference/02-server.mdx +12 -7
- package/docs/reference/04-ai.mdx +41 -23
- package/docs/reference/05-adapters.mdx +6 -6
- package/docs/server/01-handlers.mdx +1 -1
- package/docs/server/02-timers.mdx +1 -1
- package/docs/server/03-cancellation.mdx +3 -2
- package/docs/server/05-production.mdx +4 -3
- package/docs/server/06-guarantees.mdx +20 -11
- package/examples/playground/DEPLOY.md +11 -4
- package/examples/playground/app/a0/[appId]/page.tsx +2 -8
- 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/devtools.tsx +2 -8
- 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 +13 -19
- 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 +14 -3
- 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 +184 -67
- package/src/ai-harness.ts +153 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +161 -46
- package/src/ai-stored-state.ts +1 -3
- package/src/ai-tool-input.ts +45 -0
- package/src/ai.ts +116 -77
- package/src/internal.ts +6 -0
- package/src/libfx.d.ts +8 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +15 -27
- package/src/server.ts +58 -12
- package/src/store-memory.ts +50 -9
- package/src/store-postgres.ts +79 -28
- package/src/store-redis-core.ts +112 -31
- package/src/store-sqlite.ts +61 -28
- package/src/store.ts +22 -3
- package/dist/ai-BpOGm-zB.js.map +0 -1
- package/dist/ai-DrUaMyRt.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
- package/dist/server-Dw_k_7kO.js.map +0 -1
- package/dist/server-En7cbi3v.d.ts.map +0 -1
- package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
- package/dist/store-Djdv0bnZ.d.ts.map +0 -1
- package/dist/store-redis-notify-BRITti_o.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
|
))}
|
|
@@ -20,12 +20,10 @@ const gap = (ms: number): string =>
|
|
|
20
20
|
export function DevTools({
|
|
21
21
|
appId,
|
|
22
22
|
deployed,
|
|
23
|
-
crashesAllowed,
|
|
24
23
|
}: {
|
|
25
24
|
appId: string
|
|
26
25
|
/** Function limits and crashes only mean something on Vercel. */
|
|
27
26
|
deployed: boolean
|
|
28
|
-
crashesAllowed: boolean
|
|
29
27
|
}): ReactNode {
|
|
30
28
|
const { state, events, index, history, hydrated, loadHistory } = useSession()
|
|
31
29
|
const short = useSearchParams().get('limit') === '5s'
|
|
@@ -83,12 +81,8 @@ export function DevTools({
|
|
|
83
81
|
<button
|
|
84
82
|
type="button"
|
|
85
83
|
className="a0-ghost a0-devtools-crash"
|
|
86
|
-
disabled={
|
|
87
|
-
title=
|
|
88
|
-
crashesAllowed
|
|
89
|
-
? 'Ends the process running the agent, right now'
|
|
90
|
-
: 'Crashes need a preview deployment, or A0_CRASHES=on'
|
|
91
|
-
}
|
|
84
|
+
disabled={crashes.state}
|
|
85
|
+
title="Ends the process running the agent, right now"
|
|
92
86
|
onClick={crash}
|
|
93
87
|
>
|
|
94
88
|
<Zap size={13} aria-hidden />
|
|
@@ -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. */
|
|
@@ -106,24 +108,25 @@ function chosenModel({
|
|
|
106
108
|
}) {
|
|
107
109
|
// Every step starts here, in the process that runs it, which is what
|
|
108
110
|
// the dev tools' crash has to end.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
}
|
|
111
|
+
const appId = event.sessionId
|
|
112
|
+
waitUntil(
|
|
113
|
+
crashOnRequest({
|
|
114
|
+
appId,
|
|
115
|
+
app: agentServers[harness].session(appId),
|
|
116
|
+
log: crashServer.session(crashLogId(appId)),
|
|
117
|
+
}).catch(() => {}),
|
|
118
|
+
)
|
|
119
119
|
const { model, reasoning, fast } = state.modelChoice
|
|
120
120
|
return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
const telemetry = otel()
|
|
124
|
+
|
|
123
125
|
const shared = (harness: Harness) => ({
|
|
124
126
|
...(store ? { store } : {}),
|
|
125
127
|
...(appSchedulers[harness] ? { scheduler: appSchedulers[harness] } : {}),
|
|
126
128
|
contract: harnessAgents[harness],
|
|
129
|
+
telemetry,
|
|
127
130
|
maxSteps: 80,
|
|
128
131
|
})
|
|
129
132
|
|
|
@@ -193,20 +196,11 @@ export const crashServer = createServer({
|
|
|
193
196
|
contract: crashLog,
|
|
194
197
|
})
|
|
195
198
|
|
|
196
|
-
/**
|
|
197
|
-
* A crash ends the function instance, and anything else it's serving, so
|
|
198
|
-
* only previews allow it, behind Deployment Protection, unless A0_CRASHES
|
|
199
|
-
* is on. Under next dev it would take the dev server down.
|
|
200
|
-
*/
|
|
201
|
-
export const crashesAllowed =
|
|
202
|
-
process.env.VERCEL_ENV === 'preview' || process.env.A0_CRASHES === 'on'
|
|
203
|
-
|
|
204
199
|
/** The browser can only ask for a crash; the process it ends records it. */
|
|
205
200
|
export function crashEvents(request: Request): Promise<Response> {
|
|
206
201
|
return crashServer.fetch(request, {
|
|
207
202
|
authorize: (operation) =>
|
|
208
203
|
isCrashLogId(operation.sessionId) &&
|
|
209
|
-
(crashesAllowed || operation.type !== 'push') &&
|
|
210
204
|
(operation.type !== 'push' ||
|
|
211
205
|
operation.events.every((event) => event.type === 'crash.requested')),
|
|
212
206
|
})
|
|
@@ -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
|
@@ -680,7 +680,15 @@ async function* consumeGeneration(options: {
|
|
|
680
680
|
chunks: mergeDeltas(pending.splice(0)),
|
|
681
681
|
})
|
|
682
682
|
const reader = options.source.stream.getReader()
|
|
683
|
-
|
|
683
|
+
// The next read starts before a batch is yielded, and the consumer may
|
|
684
|
+
// take a while to ask again: a stream that fails meanwhile must not leave
|
|
685
|
+
// that read rejected with no handler, which ends the process.
|
|
686
|
+
const readAhead = (): Promise<StreamRead<UIMessageChunk>> => {
|
|
687
|
+
const read = reader.read()
|
|
688
|
+
read.catch(() => {})
|
|
689
|
+
return read
|
|
690
|
+
}
|
|
691
|
+
let pendingChunk = readAhead()
|
|
684
692
|
try {
|
|
685
693
|
for (;;) {
|
|
686
694
|
let result: StreamRead<UIMessageChunk>
|
|
@@ -701,7 +709,7 @@ async function* consumeGeneration(options: {
|
|
|
701
709
|
pending.push(chunk)
|
|
702
710
|
if (chunk.type === 'finish') streamedFinishReason = chunk.finishReason
|
|
703
711
|
if (chunk.type === 'error') streamedError = chunk.errorText
|
|
704
|
-
pendingChunk =
|
|
712
|
+
pendingChunk = readAhead()
|
|
705
713
|
|
|
706
714
|
if (pending.length >= maxChunks || isBoundaryChunk(pending.at(-1))) {
|
|
707
715
|
yield batch()
|
|
@@ -712,7 +720,6 @@ async function* consumeGeneration(options: {
|
|
|
712
720
|
if (pending.length > 0) yield batch()
|
|
713
721
|
throw error
|
|
714
722
|
} finally {
|
|
715
|
-
void pendingChunk.catch(() => {})
|
|
716
723
|
try {
|
|
717
724
|
await reader.cancel()
|
|
718
725
|
} catch {
|
|
@@ -1275,6 +1282,10 @@ export function createHandlers<
|
|
|
1275
1282
|
throw new TypeError(
|
|
1276
1283
|
`a harness runs tools without approval; '${name}' sets needsApproval`,
|
|
1277
1284
|
)
|
|
1285
|
+
if (tool.type === 'provider')
|
|
1286
|
+
throw new TypeError(
|
|
1287
|
+
`a harness runs only tools A2 executes; '${name}' is provider-defined`,
|
|
1288
|
+
)
|
|
1278
1289
|
}
|
|
1279
1290
|
}
|
|
1280
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(
|