@tangle-network/create-agent-app 0.46.40 → 0.46.42
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/package.json +1 -1
- package/template/_package.json +3 -3
- package/template-chat/CUSTOMIZE.md +2 -0
- package/template-chat/_package.json +2 -2
- package/template-chat/migrations/0001_init.sql +10 -0
- package/template-chat/src/sandbox.ts +26 -5
- package/template-chat/tests/chat-turn.e2e.test.ts +6 -0
- package/template-chat/tests/sandbox-fence.test.ts +76 -0
package/package.json
CHANGED
package/template/_package.json
CHANGED
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"@tangle-network/agent-eval": "0.172.1",
|
|
25
25
|
"@tangle-network/agent-integrations": ">=0.53.55 <0.54.0",
|
|
26
26
|
"@tangle-network/agent-interface": "2.2.0",
|
|
27
|
-
"@tangle-network/agent-runtime": "0.
|
|
27
|
+
"@tangle-network/agent-runtime": "0.189.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@tangle-network/agent-eval": "0.172.1",
|
|
31
31
|
"@tangle-network/agent-integrations": "0.53.55",
|
|
32
32
|
"@tangle-network/agent-interface": "2.2.0",
|
|
33
33
|
"@tangle-network/agent-knowledge": "13.0.0",
|
|
34
|
-
"@tangle-network/agent-runtime": "0.
|
|
35
|
-
"@tangle-network/sandbox": "0.36.
|
|
34
|
+
"@tangle-network/agent-runtime": "0.189.0",
|
|
35
|
+
"@tangle-network/sandbox": "0.36.4",
|
|
36
36
|
"@types/node": "^22.20.1",
|
|
37
37
|
"typescript": "^7.0.2",
|
|
38
38
|
"viem": "^2.0.0",
|
|
@@ -46,6 +46,8 @@ Discovery: **Where does this app live and what may it spend?**
|
|
|
46
46
|
- [ ] `pnpm db:migrate:local` — applies `migrations/0001_init.sql` (auth +
|
|
47
47
|
chat + turn buffer). The e2e test executes this same file, so it cannot
|
|
48
48
|
silently drift from the schema.
|
|
49
|
+
- [ ] Existing app only: add `sandbox_prewarm_claims` in a new migration.
|
|
50
|
+
Do not edit an applied `0001_init.sql`; Wrangler will not run it again.
|
|
49
51
|
- [ ] R2 stays commented out unless the product stores artifacts.
|
|
50
52
|
|
|
51
53
|
## ④ Prove the loop — `pnpm dev`
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@tangle-network/agent-app": "__AGENT_APP_VERSION__",
|
|
23
23
|
"@tangle-network/agent-interface": "2.2.0",
|
|
24
|
-
"@tangle-network/agent-runtime": "0.
|
|
25
|
-
"@tangle-network/sandbox": "0.36.
|
|
24
|
+
"@tangle-network/agent-runtime": "0.189.0",
|
|
25
|
+
"@tangle-network/sandbox": "0.36.4",
|
|
26
26
|
"better-auth": "^1.7.2",
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"viem": "^2.0.0"
|
|
@@ -96,6 +96,16 @@ CREATE TABLE IF NOT EXISTS message (
|
|
|
96
96
|
CREATE INDEX IF NOT EXISTS idx_message_thread ON message (thread_id);
|
|
97
97
|
CREATE INDEX IF NOT EXISTS idx_message_thread_created ON message (thread_id, created_at);
|
|
98
98
|
|
|
99
|
+
-- ── sandbox provisioning claim (PREWARM_CLAIM_TABLE_DDL from
|
|
100
|
+
-- @tangle-network/agent-app/sandbox) ──────────────────────────────────────
|
|
101
|
+
-- A positive value is a live fenced lease. A negative value is the retained
|
|
102
|
+
-- release marker that prevents an expired owner from releasing its successor.
|
|
103
|
+
|
|
104
|
+
CREATE TABLE IF NOT EXISTS sandbox_prewarm_claims (
|
|
105
|
+
key TEXT PRIMARY KEY,
|
|
106
|
+
expires_at INTEGER NOT NULL
|
|
107
|
+
);
|
|
108
|
+
|
|
99
109
|
-- ── turn buffer (TURN_EVENTS_MIGRATION_SQL from @tangle-network/agent-app/stream)
|
|
100
110
|
-- Byte-for-byte the constant the D1 turn-event store expects; the e2e test
|
|
101
111
|
-- asserts this block matches the exported SQL so it cannot drift silently.
|
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
} from '@tangle-network/agent-app/chat-routes'
|
|
27
27
|
import type { SidecarInteractionsConnection } from '@tangle-network/agent-app/interactions'
|
|
28
28
|
import {
|
|
29
|
+
createD1PrewarmClaimStore,
|
|
29
30
|
ensureWorkspaceSandbox,
|
|
31
|
+
peekWorkspaceSandbox,
|
|
32
|
+
runForegroundSandboxSingleFlight,
|
|
30
33
|
streamSandboxPrompt,
|
|
31
34
|
type SandboxRuntimeConfig,
|
|
32
35
|
} from '@tangle-network/agent-app/sandbox'
|
|
@@ -72,6 +75,26 @@ export function createSandboxShell(env: AppEnv): SandboxRuntimeConfig {
|
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Provision once across Worker isolates. A turn, upload, and interaction can
|
|
80
|
+
* arrive together for one new workspace. They must adopt one box, not create
|
|
81
|
+
* three boxes with the same name.
|
|
82
|
+
*/
|
|
83
|
+
async function ensureForegroundWorkspaceSandbox(
|
|
84
|
+
env: AppEnv,
|
|
85
|
+
shell: SandboxRuntimeConfig,
|
|
86
|
+
scope: { workspaceId: string; userId: string },
|
|
87
|
+
) {
|
|
88
|
+
return runForegroundSandboxSingleFlight({
|
|
89
|
+
claim: createD1PrewarmClaimStore(env.DB),
|
|
90
|
+
workspaceId: scope.workspaceId,
|
|
91
|
+
harness: config.harness,
|
|
92
|
+
provision: () => ensureWorkspaceSandbox(shell, { ...scope, harness: config.harness }),
|
|
93
|
+
peek: () => peekWorkspaceSandbox(shell, scope),
|
|
94
|
+
adopt: ({ box }) => box,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
/**
|
|
76
99
|
* The `produce` seam for `createChatTurnRoutes`: one call per turn. The
|
|
77
100
|
* chat thread id doubles as the agent session id, so follow-up turns land in
|
|
@@ -85,10 +108,9 @@ export function createSandboxProduce(env: AppEnv) {
|
|
|
85
108
|
prompt,
|
|
86
109
|
executionId,
|
|
87
110
|
}: ChatTurnProduceArgs<void>): Promise<ChatTurnRouteProducer> => {
|
|
88
|
-
const box = await
|
|
111
|
+
const box = await ensureForegroundWorkspaceSandbox(env, shell, {
|
|
89
112
|
workspaceId: identity.tenantId,
|
|
90
113
|
userId: identity.userId,
|
|
91
|
-
harness: config.harness,
|
|
92
114
|
})
|
|
93
115
|
const model = body.model ?? env.MODEL_NAME ?? config.model.default
|
|
94
116
|
return createSandboxChatProducer({
|
|
@@ -133,7 +155,7 @@ export async function resolveUploadSink(
|
|
|
133
155
|
): Promise<SandboxUploadSink | null> {
|
|
134
156
|
if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
|
|
135
157
|
const shell = createSandboxShell(env)
|
|
136
|
-
const box = await
|
|
158
|
+
const box = await ensureForegroundWorkspaceSandbox(env, shell, scope)
|
|
137
159
|
return box.fs
|
|
138
160
|
}
|
|
139
161
|
|
|
@@ -145,10 +167,9 @@ export async function resolveSidecarConnection(
|
|
|
145
167
|
): Promise<SidecarInteractionsConnection | null> {
|
|
146
168
|
if (!env.SANDBOX_API_KEY?.trim() || !env.SANDBOX_GATEWAY_URL?.trim()) return null
|
|
147
169
|
const shell = createSandboxShell(env)
|
|
148
|
-
const box = await
|
|
170
|
+
const box = await ensureForegroundWorkspaceSandbox(env, shell, {
|
|
149
171
|
workspaceId: scope.workspaceId,
|
|
150
172
|
userId: scope.userId,
|
|
151
|
-
harness: config.harness,
|
|
152
173
|
})
|
|
153
174
|
const connection = box.connection
|
|
154
175
|
if (!connection?.runtimeUrl) return null
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type ChatTurnRouteProducer,
|
|
30
30
|
} from '@tangle-network/agent-app/chat-routes'
|
|
31
31
|
import type { ChatDatabase } from '@tangle-network/agent-app/chat-store'
|
|
32
|
+
import { PREWARM_CLAIM_TABLE_DDL } from '@tangle-network/agent-app/sandbox'
|
|
32
33
|
import {
|
|
33
34
|
createMemoryTurnEventStore,
|
|
34
35
|
TURN_EVENTS_MIGRATION_SQL,
|
|
@@ -274,6 +275,11 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
|
|
|
274
275
|
expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
|
|
275
276
|
})
|
|
276
277
|
|
|
278
|
+
it('the migration carries the fenced sandbox claim table, verbatim', () => {
|
|
279
|
+
const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
|
|
280
|
+
expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(PREWARM_CLAIM_TABLE_DDL))
|
|
281
|
+
})
|
|
282
|
+
|
|
277
283
|
it('agent.config carries a real system prompt (prompts/system.md is wired)', () => {
|
|
278
284
|
expect(config.systemPrompt.length).toBeGreaterThan(0)
|
|
279
285
|
expect(config.name.length).toBeGreaterThan(0)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const sandboxMocks = vi.hoisted(() => ({
|
|
4
|
+
createClaimStore: vi.fn(),
|
|
5
|
+
ensureWorkspace: vi.fn(),
|
|
6
|
+
peekWorkspace: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
vi.mock('@tangle-network/agent-app/sandbox', async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import('@tangle-network/agent-app/sandbox')>()
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
createD1PrewarmClaimStore: sandboxMocks.createClaimStore,
|
|
14
|
+
ensureWorkspaceSandbox: sandboxMocks.ensureWorkspace,
|
|
15
|
+
peekWorkspaceSandbox: sandboxMocks.peekWorkspace,
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
import { createSandboxProduce } from '../src/sandbox'
|
|
20
|
+
import type { AppEnv } from '../src/env'
|
|
21
|
+
|
|
22
|
+
describe('sandbox foreground provisioning', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('runs createSandboxProduce through the shared fenced claim', async () => {
|
|
28
|
+
const lease = { key: 'workspace-1::opencode', expiresAt: 123 }
|
|
29
|
+
const claim = {
|
|
30
|
+
acquire: vi.fn(async () => true),
|
|
31
|
+
release: vi.fn(async () => undefined),
|
|
32
|
+
isHeld: vi.fn(async () => false),
|
|
33
|
+
inspect: vi.fn(async () => ({ status: 'absent' as const })),
|
|
34
|
+
acquireLease: vi.fn(async () => lease),
|
|
35
|
+
releaseLease: vi.fn(async () => undefined),
|
|
36
|
+
}
|
|
37
|
+
const box = { id: 'box-1' }
|
|
38
|
+
const db = {} as D1Database
|
|
39
|
+
const env: AppEnv = {
|
|
40
|
+
DB: db,
|
|
41
|
+
BETTER_AUTH_URL: 'http://localhost:8787',
|
|
42
|
+
BETTER_AUTH_SECRET: 'test-secret',
|
|
43
|
+
SANDBOX_API_KEY: 'sandbox-key',
|
|
44
|
+
SANDBOX_GATEWAY_URL: 'https://sandbox.example.test',
|
|
45
|
+
}
|
|
46
|
+
sandboxMocks.createClaimStore.mockReturnValue(claim)
|
|
47
|
+
sandboxMocks.ensureWorkspace.mockResolvedValue(box)
|
|
48
|
+
|
|
49
|
+
const producer = await createSandboxProduce(env)({
|
|
50
|
+
request: new Request('http://localhost:8787/api/chat', { method: 'POST' }),
|
|
51
|
+
body: { threadId: 'thread-1', content: 'Hello' },
|
|
52
|
+
identity: {
|
|
53
|
+
tenantId: 'workspace-1',
|
|
54
|
+
sessionId: 'thread-1',
|
|
55
|
+
userId: 'user-1',
|
|
56
|
+
turnIndex: 0,
|
|
57
|
+
},
|
|
58
|
+
context: undefined,
|
|
59
|
+
prompt: 'Hello',
|
|
60
|
+
executionId: 'execution-1',
|
|
61
|
+
turnStreamId: 'turn-1',
|
|
62
|
+
priorMessages: [],
|
|
63
|
+
userMessageId: 'message-1',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(producer.stream).toBeDefined()
|
|
67
|
+
expect(sandboxMocks.createClaimStore).toHaveBeenCalledWith(db)
|
|
68
|
+
expect(claim.acquireLease).toHaveBeenCalledWith('workspace-1::opencode', 180)
|
|
69
|
+
expect(sandboxMocks.ensureWorkspace).toHaveBeenCalledWith(
|
|
70
|
+
expect.objectContaining({ name: expect.any(Function) }),
|
|
71
|
+
{ workspaceId: 'workspace-1', userId: 'user-1', harness: 'opencode' },
|
|
72
|
+
)
|
|
73
|
+
expect(claim.releaseLease).toHaveBeenCalledWith(lease)
|
|
74
|
+
expect(sandboxMocks.peekWorkspace).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
})
|