Skip to content

Commit d323963

Browse files
authored
fix(evi): inline dynamic tool executes so resumed sessions keep their tools (#551)
1 parent 9eb98cf commit d323963

9 files changed

Lines changed: 192 additions & 197 deletions

File tree

apps/evi/agent/extensions/github.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import githubExtension from '@github-tools/eve-extension'
22
import type { ApprovalContext, ApprovalStatus } from 'eve/tools'
33
import { GITHUB_CONNECTOR } from '../lib/github/credentials'
44
import { createLabelPolicy, writePolicy } from '../lib/github/label-approval'
5-
import { isAutonomous, MAINTAINER_GITHUB_LOGIN } from '../lib/trust'
5+
import { isAutonomous, isScheduleAppAuth, MAINTAINER_GITHUB_LOGIN } from '../lib/trust'
66

77
const TOOLS = [
88
// Repository and code
@@ -104,7 +104,15 @@ export default githubExtension({
104104
requireApproval: {
105105
// Reversible and harmless on every kind of run; a card here only slows the PR flow down.
106106
requestReviewers: (): ApprovalStatus => 'not-applicable',
107-
createPullRequest: policy,
107+
createPullRequest: (ctx: ApprovalContext): ApprovalStatus => {
108+
// A draft cannot merge: a schedule run delivering its PRs skips the
109+
// card, and marking one ready stays a human act. Anything non-draft
110+
// keeps the usual policy.
111+
if (isScheduleAppAuth(ctx.session.auth.current) && (ctx.toolInput as { draft?: unknown } | undefined)?.draft === true) {
112+
return 'not-applicable'
113+
}
114+
return policy(ctx)
115+
},
108116
updatePullRequest: policy,
109117
createIssue: autonomousWrite,
110118
updateIssue: policy,

apps/evi/agent/schedules/digest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default defineSchedule({
1313
}
1414
waitUntil(
1515
receive(photon, {
16-
message: 'Load the daily-digest skill and follow it for the last 24 hours.',
16+
message: 'Load the daily-digest skill and follow it for the last 24 hours. This scheduled turn resumes a long-lived thread: ignore earlier conversation topics and stale pending requests, and do only this task.',
1717
// Spectrum direct-chat guid: `any;-;<address>`, so the thread is
1818
// derived from the phone number instead of a captured thread id.
1919
target: { adapterName: 'imessage', threadId: `imessage:any;-;${MAINTAINER_PHONE}` },

apps/evi/agent/schedules/upstream-sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default defineSchedule({
1414
waitUntil(
1515
receive(photon, {
1616
message:
17-
'Load the upstream-sync skill and check the eve and Vercel Connect ecosystem for updates. Open draft PRs for anything warranted.',
17+
'Load the upstream-sync skill and check the eve and Vercel Connect ecosystem for updates. Open draft PRs for anything warranted. This scheduled turn resumes a long-lived thread: ignore earlier conversation topics and stale pending requests, and do only this task.',
1818
// Spectrum direct-chat guid: `any;-;<address>`, so the thread is
1919
// derived from the phone number instead of a captured thread id.
2020
target: { adapterName: 'imessage', threadId: `imessage:any;-;${MAINTAINER_PHONE}` },

apps/evi/agent/tools/ai-gateway.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,20 @@ function filterReportByApiKeyName(payload: unknown, apiKeyName: string): {
7676
}
7777
}
7878

79-
function aiGatewayTools() {
80-
// Dynamic map keys are bare tool names (no file-slug prefix), so the
81-
// namespace is spelled out here to match every ai_gateway__* reference.
82-
return {
79+
// Admin-only spend observability. Keep executes inline in the resolver
80+
// (docs/notes.md); keys carry the ai_gateway__ namespace themselves.
81+
export default defineDynamic({
82+
events: {
83+
'turn.started': (_event, ctx) => {
84+
if (!canAccessAdminTools(ctx.session.auth.current)) return null
85+
return {
8386
ai_gateway__credits: defineTool({
8487
description: 'Admin: AI Gateway credit balance and lifetime spend for the entire team account (not Evi-scoped). Prefer ai_gateway__report for Evi digests.',
8588
inputSchema: z.object({}),
86-
async execute() {
89+
async execute(_input, toolCtx) {
90+
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
91+
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
92+
}
8793
return await gatewayFetch('/credits')
8894
},
8995
}),
@@ -104,7 +110,10 @@ function aiGatewayTools() {
104110
message: 'startDate must not be later than endDate',
105111
path: ['startDate'],
106112
}),
107-
async execute(input) {
113+
async execute(input, toolCtx) {
114+
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
115+
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
116+
}
108117
const configuredKeyName = reportApiKeyName()
109118
// Key-name scope covers historical untagged traffic on a dedicated Evi
110119
// key, but it spends the single `group_by` slot on `api_key_name` to do
@@ -166,24 +175,14 @@ function aiGatewayTools() {
166175
inputSchema: z.object({
167176
id: z.string().min(1).describe('Generation id, e.g. gen_01ARZ3NDEKTSV4RRFFQ69G5FAV'),
168177
}),
169-
async execute(input) {
178+
async execute(input, toolCtx) {
179+
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
180+
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
181+
}
170182
return await gatewayFetch('/generation', { id: input.id })
171183
},
172184
}),
173-
}
174-
}
175-
176-
// Re-resolved every turn so the gate follows the turn's actual caller and
177-
// survives a session resumed on a fresh deployment.
178-
export default defineDynamic({
179-
events: {
180-
'session.started': async (_event, ctx) => {
181-
if (!canAccessAdminTools(ctx.session.auth.current)) return null
182-
return aiGatewayTools()
183-
},
184-
'turn.started': async (_event, ctx) => {
185-
if (!canAccessAdminTools(ctx.session.auth.current)) return null
186-
return aiGatewayTools()
185+
}
187186
},
188187
},
189188
})

apps/evi/agent/tools/blob.ts

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,51 @@ import { z } from 'zod'
44
import { imageContentType, MAX_IMAGE_BYTES, screenshotKey, sniffImageContentType } from '../lib/blob'
55
import { canAccessAdminTools } from '../lib/trust'
66

7-
function blobTools() {
8-
// Dynamic map keys are bare tool names (no file-slug prefix), so the
9-
// namespace is spelled out here to match every blob__upload_image reference.
10-
return {
11-
blob__upload_image: defineTool({
12-
description: 'Upload an image file from the sandbox to the evlog Vercel Blob store and return its public URL. Use it to share screenshots (before/after comparisons, visual evidence) in pull requests and conversations. png/jpg/webp/gif, 8 MB max. The URL is public: upload only captures of evlog surfaces.',
13-
inputSchema: z.object({
14-
path: z.string().min(1).describe('Sandbox path of the image, e.g. /workspace/screenshots/after.png'),
15-
}),
16-
async execute(input, ctx) {
17-
const contentType = imageContentType(input.path)
18-
if (!contentType) {
19-
return { success: false as const, error: `"${input.path}" is not a supported image (png/jpg/webp/gif).` }
20-
}
21-
if (!process.env.BLOB_READ_WRITE_TOKEN) {
22-
return { success: false as const, error: 'BLOB_READ_WRITE_TOKEN is not configured. Locally, run `vercel env pull` in apps/evi.' }
23-
}
24-
const sandbox = await ctx.getSandbox()
25-
const bytes = await sandbox.readBinaryFile({ path: input.path })
26-
if (bytes === null) {
27-
return { success: false as const, error: `No file at "${input.path}".` }
28-
}
29-
if (bytes.byteLength > MAX_IMAGE_BYTES) {
30-
return { success: false as const, error: `Image is ${bytes.byteLength} bytes; the limit is ${MAX_IMAGE_BYTES}.` }
31-
}
32-
// The upload is public: the bytes must actually be the image the
33-
// extension claims, not arbitrary data renamed to .png.
34-
if (sniffImageContentType(bytes) !== contentType) {
35-
return { success: false as const, error: `The content of "${input.path}" does not match its extension; only real image files are uploaded.` }
36-
}
37-
const blob = await put(screenshotKey(input.path), Buffer.from(bytes), {
38-
access: 'public',
39-
addRandomSuffix: true,
40-
contentType,
41-
})
42-
return { success: true as const, url: blob.url, bytes: bytes.byteLength }
43-
},
44-
}),
45-
}
46-
}
47-
48-
/**
49-
* The URL is public the instant it exists, so autonomous turns never see this
50-
* tool. Re-resolved every turn so the gate follows the turn's actual caller
51-
* and survives a session resumed on a fresh deployment.
52-
*/
7+
// Public URLs the instant they exist: autonomous turns never see this tool.
8+
// Keep executes inline in the resolver (docs/notes.md).
539
export default defineDynamic({
5410
events: {
55-
'session.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? blobTools() : null),
56-
'turn.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? blobTools() : null),
11+
'turn.started': (_event, ctx) => {
12+
if (!canAccessAdminTools(ctx.session.auth.current)) return null
13+
return {
14+
blob__upload_image: defineTool({
15+
description: 'Upload an image file from the sandbox to the evlog Vercel Blob store and return its public URL. Use it to share screenshots (before/after comparisons, visual evidence) in pull requests and conversations. png/jpg/webp/gif, 8 MB max. The URL is public: upload only captures of evlog surfaces.',
16+
inputSchema: z.object({
17+
path: z.string().min(1).describe('Sandbox path of the image, e.g. /workspace/screenshots/after.png'),
18+
}),
19+
async execute(input, toolCtx) {
20+
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
21+
return { success: false as const, error: 'Image upload is not available in this session.' }
22+
}
23+
const contentType = imageContentType(input.path)
24+
if (!contentType) {
25+
return { success: false as const, error: `"${input.path}" is not a supported image (png/jpg/webp/gif).` }
26+
}
27+
if (!process.env.BLOB_READ_WRITE_TOKEN) {
28+
return { success: false as const, error: 'BLOB_READ_WRITE_TOKEN is not configured. Locally, run `vercel env pull` in apps/evi.' }
29+
}
30+
const sandbox = await toolCtx.getSandbox()
31+
const bytes = await sandbox.readBinaryFile({ path: input.path })
32+
if (bytes === null) {
33+
return { success: false as const, error: `No file at "${input.path}".` }
34+
}
35+
if (bytes.byteLength > MAX_IMAGE_BYTES) {
36+
return { success: false as const, error: `Image is ${bytes.byteLength} bytes; the limit is ${MAX_IMAGE_BYTES}.` }
37+
}
38+
// The upload is public: the bytes must actually be the image the
39+
// extension claims, not arbitrary data renamed to .png.
40+
if (sniffImageContentType(bytes) !== contentType) {
41+
return { success: false as const, error: `The content of "${input.path}" does not match its extension; only real image files are uploaded.` }
42+
}
43+
const blob = await put(screenshotKey(input.path), Buffer.from(bytes), {
44+
access: 'public',
45+
addRandomSuffix: true,
46+
contentType,
47+
})
48+
return { success: true as const, url: blob.url, bytes: bytes.byteLength }
49+
},
50+
}),
51+
}
52+
},
5753
},
5854
})

apps/evi/agent/tools/capture.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,14 @@ async function hostFrame(sandbox: SandboxSession, path: string): Promise<string>
4545
return blob.url
4646
}
4747

48-
function captureTools() {
49-
return {
50-
capture__before_after: defineTool({
48+
// Frames publish to public URLs the moment the tool runs: autonomous turns
49+
// never see it. Keep executes inline in the resolver (docs/notes.md).
50+
export default defineDynamic({
51+
events: {
52+
'turn.started': (_event, ctx) => {
53+
if (!canAccessAdminTools(ctx.session.auth.current)) return null
54+
return {
55+
capture__before_after: defineTool({
5156
description: 'Capture a before/after comparison of an evlog surface in one call: for each URL, open it in the sandbox browser, wait 5s for animations to settle, screenshot (cropped to the selector when given), validate and upload both frames to the Blob store, and return the finished markdown table with an attestation receipt. Origins are restricted to evlog domains, Vercel previews, and sandbox dev servers. For surfaces that can show real user data (telemetry), review the pages with browser__screenshot before calling this: the returned URLs are public immediately.',
5257
inputSchema: z.object({
5358
beforeUrl: z.string().min(1).describe('URL of the before state, e.g. https://evlog.dev'),
@@ -59,8 +64,8 @@ function captureTools() {
5964
// Skill-level "review sensitive surfaces first" is not an enforceable
6065
// control; a capture of a surface that can show real user data parks on
6166
// an approval card before anything publishes.
62-
approval(ctx) {
63-
for (const raw of [ctx.toolInput?.beforeUrl, ctx.toolInput?.afterUrl]) {
67+
approval(approvalCtx) {
68+
for (const raw of [approvalCtx.toolInput?.beforeUrl, approvalCtx.toolInput?.afterUrl]) {
6469
if (typeof raw !== 'string') continue
6570
let reason: string | null
6671
try {
@@ -73,8 +78,8 @@ function captureTools() {
7378
}
7479
return 'not-applicable'
7580
},
76-
async execute(input, ctx) {
77-
if (!canAccessAdminTools(ctx.session.auth.current)) {
81+
async execute(input, toolCtx) {
82+
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
7883
return { success: false as const, error: 'Captures are not available in this session.' }
7984
}
8085
for (const url of [input.beforeUrl, input.afterUrl]) {
@@ -86,10 +91,10 @@ function captureTools() {
8691
}
8792
const viewport = input.viewport ?? 'desktop'
8893
const selector = input.selector ?? null
89-
const sandbox = await ctx.getSandbox()
94+
const sandbox = await toolCtx.getSandbox()
9095
await sandbox.run({ command: `mkdir -p ${SCREENSHOT_DIR}` })
91-
const beforePath = await captureFrame(ctx, 'before', input.beforeUrl, selector, viewport)
92-
const afterPath = await captureFrame(ctx, 'after', input.afterUrl, selector, viewport)
96+
const beforePath = await captureFrame(toolCtx, 'before', input.beforeUrl, selector, viewport)
97+
const afterPath = await captureFrame(toolCtx, 'after', input.afterUrl, selector, viewport)
9398
const beforeImageUrl = await hostFrame(sandbox, beforePath)
9499
const afterImageUrl = await hostFrame(sandbox, afterPath)
95100
const capturedAt = new Date().toISOString()
@@ -110,17 +115,7 @@ function captureTools() {
110115
}
111116
},
112117
}),
113-
}
114-
}
115-
116-
/**
117-
* The frames publish to public URLs the moment the tool runs, so autonomous
118-
* turns never see it. Re-resolved every turn so the gate follows the turn's
119-
* actual caller and survives a session resumed on a fresh deployment.
120-
*/
121-
export default defineDynamic({
122-
events: {
123-
'session.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? captureTools() : null),
124-
'turn.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? captureTools() : null),
118+
}
119+
},
125120
},
126121
})

0 commit comments

Comments
 (0)