feat: schema + execute abstraction for Agent-friendly deploy

- Agent provides schema (JSON Schema) + execute (function body)
- Sigil auto-generates full Worker code via codegen.ts
- Input parsing: GET query params + POST JSON body, auto type conversion
- Required field validation, default values
- find mode returns schema so Agent knows how to call
- Backward compatible: raw code deploy still works
This commit is contained in:
2026-04-03 08:34:49 +00:00
parent c3f3b822f1
commit fd210c0edd
6 changed files with 206 additions and 4 deletions
+7 -1
View File
@@ -1,6 +1,10 @@
import type { InputSchema } from '../codegen.js'
export interface DeployParams {
name: string | null // null = 自动生成 t-{hash}
code: string
code?: string // 模式 A:完整 Worker 代码
schema?: InputSchema // 模式 B:输入 schema
execute?: string // 模式 B:函数体
type: 'persistent' | 'normal' | 'ephemeral'
ttl?: number // 秒,仅 ephemeral
bindings?: string[]
@@ -29,6 +33,7 @@ export interface Capability {
description?: string
tags?: string[]
examples?: string[]
schema?: InputSchema // 新增:find 模式返回,让 Agent 知道怎么调用
}
export interface QueryParams {
@@ -47,6 +52,7 @@ export interface QueryItem {
deployed?: boolean
access_count?: number
score: number
schema?: InputSchema // 新增:find 模式返回
}
export interface QueryResult {
+9 -1
View File
@@ -44,7 +44,11 @@ export class WorkerPool implements SigilBackend {
}
async deploy(params: DeployParams): Promise<DeployResult> {
const { name, code, type, ttl, bindings, description, tags, examples } = params
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params
if (!code) {
throw new Error('deploy: code is required (should be pre-generated by router)')
}
// Determine capability name
let capability: string
@@ -97,6 +101,7 @@ export class WorkerPool implements SigilBackend {
description,
tags,
examples,
schema,
})
await this.kv.setLru(capability, {
last_access: now,
@@ -396,6 +401,7 @@ export class WorkerPool implements SigilBackend {
description: meta.description,
tags: meta.tags,
examples: meta.examples,
schema: meta.schema,
})
}
}
@@ -419,6 +425,7 @@ export class WorkerPool implements SigilBackend {
deployed: cap.deployed,
access_count: cap.access_count,
score: 0.5, // Default score for fallback
schema: cap.schema,
}))
const effectiveMode = (mode === 'find' && !q) ? 'explore' : mode
@@ -443,6 +450,7 @@ export class WorkerPool implements SigilBackend {
deployed: c.lru.deployed,
access_count: c.lru.access_count,
score: Math.round(c.score * 1000) / 1000,
schema: c.meta.schema,
}))
// Merge embedding results with fallback results (embedding takes priority)
+3
View File
@@ -1,5 +1,7 @@
// KV key prefixes and data types
import type { InputSchema } from './codegen.js'
export interface KvCodeValue {
code: string
}
@@ -12,6 +14,7 @@ export interface KvMetaValue {
description?: string
tags?: string[]
examples?: string[]
schema?: InputSchema // 新增:模式 B deploy 时存储,find 模式返回
}
export interface KvLruValue {
+30 -2
View File
@@ -2,6 +2,8 @@ import type { SigilBackend } from './backend/types.js'
import { AuthModule, AuthError, DeployCooldownError } from './auth.js'
import { KvStore } from './kv.js'
import { PageRateLimitError } from './lru.js'
import { generateWorkerCode } from './codegen.js'
import type { InputSchema } from './codegen.js'
export interface RouterEnv {
SIGIL_KV: KVNamespace
@@ -64,7 +66,9 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
const body = await request.json() as {
name: string | null
code: string
code?: string
schema?: InputSchema
execute?: string
type: 'persistent' | 'normal' | 'ephemeral'
ttl?: number
bindings?: string[]
@@ -73,12 +77,36 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
examples?: string[]
}
// Route validation
if (body.code && (body.schema || body.execute)) {
return jsonError(400, 'Cannot specify both code and schema/execute')
}
if (!body.code && !body.execute) {
return jsonError(400, 'Must specify either code or schema+execute')
}
let code: string
let schema: InputSchema | undefined
if (body.code) {
// 模式 A:直接部署
code = body.code
} else {
// 模式 B:schema + execute
if (!body.execute) {
return jsonError(400, 'execute is required when using schema mode')
}
schema = body.schema || { type: 'object', properties: {} }
code = generateWorkerCode(schema, body.execute)
}
// Check deploy cooldown
await env.auth.checkDeployCooldown()
const result = await env.backend.deploy({
name: body.name,
code: body.code,
code,
schema,
type: body.type,
ttl: body.ttl,
bindings: body.bindings,
+40
View File
@@ -331,4 +331,44 @@ describe('Query API', () => {
expect(Math.abs(item.score - rounded)).toBeLessThan(0.0001)
}
})
// Test 15: find 模式返回 schema(如果有)
it('find 模式返回 schema 字段(via fallback path)', async () => {
const now = Date.now()
const kv2 = new KvStore(mockKv)
const testSchema = {
type: 'object' as const,
properties: {
from: { type: 'string', description: 'Source currency' },
to: { type: 'string', description: 'Target currency' },
},
required: ['from', 'to'],
}
// Insert capability with schema manually (simulating schema+execute deploy)
await kv2.setMeta('schema-cap', {
type: 'persistent',
created_at: now,
description: 'schema capability for testing',
tags: ['schema-test'],
schema: testSchema,
})
await kv2.setLru('schema-cap', { last_access: now, access_count: 0, deployed: true })
const result = await pool.query({ q: 'schema-test', mode: 'find' })
const item = result.items.find(i => i.capability === 'schema-cap')
expect(item).toBeDefined()
expect(item!.schema).toBeDefined()
expect(item!.schema?.properties.from.type).toBe('string')
expect(item!.schema?.required).toContain('from')
expect(item!.schema?.required).toContain('to')
})
// Test 16: explore 模式不返回 schema
it('explore 模式不返回 schema 字段', async () => {
const result = await pool.query({ mode: 'explore' })
for (const item of result.items) {
expect(item).not.toHaveProperty('schema')
}
})
})
+117
View File
@@ -78,4 +78,121 @@ describe('S1: 部署能力', () => {
const route = await kv.getRoute('ping')
expect(route?.worker_name).toBe('s-ping')
})
// --- 模式 B: schema + execute ---
it('模式 B: schema + execute 通过 API 部署', async () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token',
body: {
name: 'adder',
type: 'normal',
schema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' },
},
required: ['a', 'b'],
},
execute: 'return JSON.stringify({ sum: input.a + input.b })',
},
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(201)
const body = await resp.json() as { capability: string; url: string }
expect(body.capability).toBe('adder')
expect(body.url).toBe('https://sigil.shazhou.workers.dev/run/adder')
})
it('模式 B: 生成的 code 存入 KV(包含 export default)', async () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token',
body: {
name: 'greeter',
type: 'normal',
schema: {
type: 'object',
properties: {
name: { type: 'string', default: 'World' },
},
},
execute: 'return "Hello, " + input.name + "!"',
},
})
await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
const code = await kv.getCode('greeter')
expect(code).toBeTruthy()
expect(code).toContain('export default')
expect(code).toContain('async fetch(request)')
})
it('模式 B: schema 存入 KV meta', async () => {
const schema = {
type: 'object' as const,
properties: {
from: { type: 'string', description: 'Source currency' },
to: { type: 'string', description: 'Target currency' },
amount: { type: 'number', description: 'Amount', default: 1 },
},
required: ['from', 'to'],
}
const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token',
body: {
name: 'currency',
type: 'persistent',
description: 'Currency converter',
tags: ['finance'],
schema,
execute: 'return JSON.stringify({ from: input.from, to: input.to, amount: input.amount })',
},
})
await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
const meta = await kv.getMeta('currency')
expect(meta?.schema).toBeDefined()
expect(meta?.schema?.properties.from.type).toBe('string')
expect(meta?.schema?.required).toContain('from')
expect(meta?.schema?.required).toContain('to')
})
it('模式 B + A 同时提供 → 400 错误', async () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token',
body: {
name: 'bad',
type: 'normal',
code: 'export default { fetch() { return new Response("hi") } }',
schema: { properties: {} },
execute: 'return "hello"',
},
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(400)
const body = await resp.json() as { error: string }
expect(body.error).toContain('Cannot specify both code and schema/execute')
})
it('code 和 execute 都不提供 → 400 错误', async () => {
const req = makeRequest('POST', '/_api/deploy', {
token: 'deploy-token',
body: {
name: 'bad',
type: 'normal',
},
})
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
expect(resp.status).toBe(400)
const body = await resp.json() as { error: string }
expect(body.error).toContain('Must specify either code or schema+execute')
})
})