Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/evi/agent/extensions/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import githubExtension from '@github-tools/eve-extension'
import type { ApprovalContext, ApprovalStatus } from 'eve/tools'
import { GITHUB_CONNECTOR } from '../lib/github/credentials'
import { createLabelPolicy, writePolicy } from '../lib/github/label-approval'
import { isAutonomous, MAINTAINER_GITHUB_LOGIN } from '../lib/trust'
import { isAutonomous, isScheduleAppAuth, MAINTAINER_GITHUB_LOGIN } from '../lib/trust'

const TOOLS = [
// Repository and code
Expand Down Expand Up @@ -104,7 +104,15 @@ export default githubExtension({
requireApproval: {
// Reversible and harmless on every kind of run; a card here only slows the PR flow down.
requestReviewers: (): ApprovalStatus => 'not-applicable',
createPullRequest: policy,
createPullRequest: (ctx: ApprovalContext): ApprovalStatus => {
// A draft cannot merge: a schedule run delivering its PRs skips the
// card, and marking one ready stays a human act. Anything non-draft
// keeps the usual policy.
if (isScheduleAppAuth(ctx.session.auth.current) && (ctx.toolInput as { draft?: unknown } | undefined)?.draft === true) {
return 'not-applicable'
}
return policy(ctx)
},
updatePullRequest: policy,
createIssue: autonomousWrite,
updateIssue: policy,
Expand Down
2 changes: 1 addition & 1 deletion apps/evi/agent/schedules/digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineSchedule({
}
waitUntil(
receive(photon, {
message: 'Load the daily-digest skill and follow it for the last 24 hours.',
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.',
// Spectrum direct-chat guid: `any;-;<address>`, so the thread is
// derived from the phone number instead of a captured thread id.
target: { adapterName: 'imessage', threadId: `imessage:any;-;${MAINTAINER_PHONE}` },
Expand Down
2 changes: 1 addition & 1 deletion apps/evi/agent/schedules/upstream-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default defineSchedule({
waitUntil(
receive(photon, {
message:
'Load the upstream-sync skill and check the eve and Vercel Connect ecosystem for updates. Open draft PRs for anything warranted.',
'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.',
// Spectrum direct-chat guid: `any;-;<address>`, so the thread is
// derived from the phone number instead of a captured thread id.
target: { adapterName: 'imessage', threadId: `imessage:any;-;${MAINTAINER_PHONE}` },
Expand Down
41 changes: 20 additions & 21 deletions apps/evi/agent/tools/ai-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,20 @@ function filterReportByApiKeyName(payload: unknown, apiKeyName: string): {
}
}

function aiGatewayTools() {
// Dynamic map keys are bare tool names (no file-slug prefix), so the
// namespace is spelled out here to match every ai_gateway__* reference.
return {
// Admin-only spend observability. Keep executes inline in the resolver
// (docs/notes.md); keys carry the ai_gateway__ namespace themselves.
export default defineDynamic({
events: {
'turn.started': (_event, ctx) => {
if (!canAccessAdminTools(ctx.session.auth.current)) return null
return {
ai_gateway__credits: defineTool({
description: 'Admin: AI Gateway credit balance and lifetime spend for the entire team account (not Evi-scoped). Prefer ai_gateway__report for Evi digests.',
inputSchema: z.object({}),
async execute() {
async execute(_input, toolCtx) {
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
}
return await gatewayFetch('/credits')
},
}),
Expand All @@ -104,7 +110,10 @@ function aiGatewayTools() {
message: 'startDate must not be later than endDate',
path: ['startDate'],
}),
async execute(input) {
async execute(input, toolCtx) {
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
}
const configuredKeyName = reportApiKeyName()
// Key-name scope covers historical untagged traffic on a dedicated Evi
// key, but it spends the single `group_by` slot on `api_key_name` to do
Expand Down Expand Up @@ -166,24 +175,14 @@ function aiGatewayTools() {
inputSchema: z.object({
id: z.string().min(1).describe('Generation id, e.g. gen_01ARZ3NDEKTSV4RRFFQ69G5FAV'),
}),
async execute(input) {
async execute(input, toolCtx) {
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
return { success: false as const, error: 'AI Gateway reporting is not available in this session.' }
}
return await gatewayFetch('/generation', { id: input.id })
},
}),
}
}

// Re-resolved every turn so the gate follows the turn's actual caller and
// survives a session resumed on a fresh deployment.
export default defineDynamic({
events: {
'session.started': async (_event, ctx) => {
if (!canAccessAdminTools(ctx.session.auth.current)) return null
return aiGatewayTools()
},
'turn.started': async (_event, ctx) => {
if (!canAccessAdminTools(ctx.session.auth.current)) return null
return aiGatewayTools()
}
},
},
})
92 changes: 44 additions & 48 deletions apps/evi/agent/tools/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,51 @@ import { z } from 'zod'
import { imageContentType, MAX_IMAGE_BYTES, screenshotKey, sniffImageContentType } from '../lib/blob'
import { canAccessAdminTools } from '../lib/trust'

function blobTools() {
// Dynamic map keys are bare tool names (no file-slug prefix), so the
// namespace is spelled out here to match every blob__upload_image reference.
return {
blob__upload_image: defineTool({
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.',
inputSchema: z.object({
path: z.string().min(1).describe('Sandbox path of the image, e.g. /workspace/screenshots/after.png'),
}),
async execute(input, ctx) {
const contentType = imageContentType(input.path)
if (!contentType) {
return { success: false as const, error: `"${input.path}" is not a supported image (png/jpg/webp/gif).` }
}
if (!process.env.BLOB_READ_WRITE_TOKEN) {
return { success: false as const, error: 'BLOB_READ_WRITE_TOKEN is not configured. Locally, run `vercel env pull` in apps/evi.' }
}
const sandbox = await ctx.getSandbox()
const bytes = await sandbox.readBinaryFile({ path: input.path })
if (bytes === null) {
return { success: false as const, error: `No file at "${input.path}".` }
}
if (bytes.byteLength > MAX_IMAGE_BYTES) {
return { success: false as const, error: `Image is ${bytes.byteLength} bytes; the limit is ${MAX_IMAGE_BYTES}.` }
}
// The upload is public: the bytes must actually be the image the
// extension claims, not arbitrary data renamed to .png.
if (sniffImageContentType(bytes) !== contentType) {
return { success: false as const, error: `The content of "${input.path}" does not match its extension; only real image files are uploaded.` }
}
const blob = await put(screenshotKey(input.path), Buffer.from(bytes), {
access: 'public',
addRandomSuffix: true,
contentType,
})
return { success: true as const, url: blob.url, bytes: bytes.byteLength }
},
}),
}
}

/**
* The URL is public the instant it exists, so autonomous turns never see this
* tool. Re-resolved every turn so the gate follows the turn's actual caller
* and survives a session resumed on a fresh deployment.
*/
// Public URLs the instant they exist: autonomous turns never see this tool.
// Keep executes inline in the resolver (docs/notes.md).
export default defineDynamic({
events: {
'session.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? blobTools() : null),
'turn.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? blobTools() : null),
'turn.started': (_event, ctx) => {
if (!canAccessAdminTools(ctx.session.auth.current)) return null
return {
blob__upload_image: defineTool({
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.',
inputSchema: z.object({
path: z.string().min(1).describe('Sandbox path of the image, e.g. /workspace/screenshots/after.png'),
}),
async execute(input, toolCtx) {
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
return { success: false as const, error: 'Image upload is not available in this session.' }
}
const contentType = imageContentType(input.path)
if (!contentType) {
return { success: false as const, error: `"${input.path}" is not a supported image (png/jpg/webp/gif).` }
}
if (!process.env.BLOB_READ_WRITE_TOKEN) {
return { success: false as const, error: 'BLOB_READ_WRITE_TOKEN is not configured. Locally, run `vercel env pull` in apps/evi.' }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const sandbox = await toolCtx.getSandbox()
const bytes = await sandbox.readBinaryFile({ path: input.path })
if (bytes === null) {
return { success: false as const, error: `No file at "${input.path}".` }
}
if (bytes.byteLength > MAX_IMAGE_BYTES) {
return { success: false as const, error: `Image is ${bytes.byteLength} bytes; the limit is ${MAX_IMAGE_BYTES}.` }
}
// The upload is public: the bytes must actually be the image the
// extension claims, not arbitrary data renamed to .png.
if (sniffImageContentType(bytes) !== contentType) {
return { success: false as const, error: `The content of "${input.path}" does not match its extension; only real image files are uploaded.` }
}
const blob = await put(screenshotKey(input.path), Buffer.from(bytes), {
access: 'public',
addRandomSuffix: true,
contentType,
})
return { success: true as const, url: blob.url, bytes: bytes.byteLength }
},
}),
}
},
},
})
39 changes: 17 additions & 22 deletions apps/evi/agent/tools/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ async function hostFrame(sandbox: SandboxSession, path: string): Promise<string>
return blob.url
}

function captureTools() {
return {
capture__before_after: defineTool({
// Frames publish to public URLs the moment the tool runs: autonomous turns
// never see it. Keep executes inline in the resolver (docs/notes.md).
export default defineDynamic({
events: {
'turn.started': (_event, ctx) => {
if (!canAccessAdminTools(ctx.session.auth.current)) return null
return {
capture__before_after: defineTool({
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.',
inputSchema: z.object({
beforeUrl: z.string().min(1).describe('URL of the before state, e.g. https://evlog.dev'),
Expand All @@ -59,8 +64,8 @@ function captureTools() {
// Skill-level "review sensitive surfaces first" is not an enforceable
// control; a capture of a surface that can show real user data parks on
// an approval card before anything publishes.
approval(ctx) {
for (const raw of [ctx.toolInput?.beforeUrl, ctx.toolInput?.afterUrl]) {
approval(approvalCtx) {
for (const raw of [approvalCtx.toolInput?.beforeUrl, approvalCtx.toolInput?.afterUrl]) {
if (typeof raw !== 'string') continue
let reason: string | null
try {
Expand All @@ -73,8 +78,8 @@ function captureTools() {
}
return 'not-applicable'
},
async execute(input, ctx) {
if (!canAccessAdminTools(ctx.session.auth.current)) {
async execute(input, toolCtx) {
if (!canAccessAdminTools(toolCtx.session.auth.current)) {
return { success: false as const, error: 'Captures are not available in this session.' }
}
for (const url of [input.beforeUrl, input.afterUrl]) {
Expand All @@ -86,10 +91,10 @@ function captureTools() {
}
const viewport = input.viewport ?? 'desktop'
const selector = input.selector ?? null
const sandbox = await ctx.getSandbox()
const sandbox = await toolCtx.getSandbox()
await sandbox.run({ command: `mkdir -p ${SCREENSHOT_DIR}` })
const beforePath = await captureFrame(ctx, 'before', input.beforeUrl, selector, viewport)
const afterPath = await captureFrame(ctx, 'after', input.afterUrl, selector, viewport)
const beforePath = await captureFrame(toolCtx, 'before', input.beforeUrl, selector, viewport)
const afterPath = await captureFrame(toolCtx, 'after', input.afterUrl, selector, viewport)
const beforeImageUrl = await hostFrame(sandbox, beforePath)
const afterImageUrl = await hostFrame(sandbox, afterPath)
const capturedAt = new Date().toISOString()
Expand All @@ -110,17 +115,7 @@ function captureTools() {
}
},
}),
}
}

/**
* The frames publish to public URLs the moment the tool runs, so autonomous
* turns never see it. Re-resolved every turn so the gate follows the turn's
* actual caller and survives a session resumed on a fresh deployment.
*/
export default defineDynamic({
events: {
'session.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? captureTools() : null),
'turn.started': (_event, ctx) => (canAccessAdminTools(ctx.session.auth.current) ? captureTools() : null),
}
},
},
})
Loading
Loading