fix: steer ai chat away from draft-blind api catalog reads and runs (#10202)

* fix: steer ai chat away from draft-blind api catalog reads and runs

* fix: support api catalog tools in ai eval harness mock backend

* fix: block remaining draft-blind read and list endpoints in api catalog

* fix: scope eval fetch stub to handled benchmark api paths

* feat: add deployed version read option to read_workspace_item

* fix: include input schema in script workspace item reads

* feat: add pagination to list_workspace_items

* fix: paginate list_workspace_items per item type without cross-type loss

* fix: window draft overlay in list_workspace_items by page and limit

* refactor: simplify list draft overlay to capped page-1 merge

* fix: label server-synthesized draft-only rows as drafts in listings

* chore: fix stale eval comment and pin draft_only listing label
This commit is contained in:
hugocasa
2026-07-20 18:45:14 +02:00
committed by GitHub
parent 38ceae1a09
commit 0e04bc6991
8 changed files with 465 additions and 38 deletions
+136
View File
@@ -10,6 +10,7 @@ import type {
import type {
DataTableTables,
DataTableTableSchema,
EndpointTool,
GetDraftForUserResponse,
GetOwnDraftResponse,
ListDraftsResponse,
@@ -694,3 +695,138 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
raw_app: true
}
}
// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) =============
// The global chat's API catalog tools list endpoints via McpService and execute
// them with a plain relative fetch('/api/...'), which has no meaning in the
// vitest environment. A representative slice of the real catalog is served here,
// and `handleBenchmarkApiFetch` answers the executed calls.
const BENCHMARK_MCP_TOOLS: EndpointTool[] = [
{
name: 'listWorkers',
description: 'List workers',
instructions: 'List all workers with their last ping and job counts.',
path: '/workers/list',
method: 'GET',
query_params_schema: {
type: 'object',
properties: { page: { type: 'integer' }, per_page: { type: 'integer' } }
}
},
{
name: 'listQueue',
description: 'List queued jobs',
instructions: '',
path: '/w/{workspace}/jobs/queue/list',
method: 'GET',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' } },
required: ['workspace']
}
},
{
name: 'runScriptByPath',
description: 'Run the deployed version of a script by path',
instructions: '',
path: '/w/{workspace}/jobs/run/p/{path}',
method: 'POST',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
required: ['workspace', 'path']
},
body_schema: { type: 'object', properties: {} }
},
{
name: 'runFlowByPath',
description: 'Run the deployed version of a flow by path',
instructions: '',
path: '/w/{workspace}/jobs/run/f/{path}',
method: 'POST',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
required: ['workspace', 'path']
},
body_schema: { type: 'object', properties: {} }
},
// Draft-covered endpoints, present so steering cases exercise the guard the
// way production does (hidden from search, refused at call time).
{
name: 'getScriptByPath',
description: 'Get a script by path',
instructions: '',
path: '/w/{workspace}/scripts/get/p/{path}',
method: 'GET'
},
{
name: 'createFlow',
description: 'Create a flow',
instructions: '',
path: '/w/{workspace}/flows/create',
method: 'POST'
},
{
name: 'deleteSchedule',
description: 'Delete a schedule',
instructions: '',
path: '/w/{workspace}/schedules/delete/{path}',
method: 'DELETE'
},
{
name: 'getVariable',
description: 'Get a variable',
instructions: '',
path: '/w/{workspace}/variables/get/{path}',
method: 'GET'
}
]
export function listBenchmarkMcpTools(): EndpointTool[] {
return BENCHMARK_MCP_TOOLS
}
const BENCHMARK_WORKERS = [
{
worker: 'wk-benchmark-1',
worker_instance: 'benchmark-host',
last_ping: 2,
started_at: BENCHMARK_TIMESTAMP,
jobs_executed: 42,
custom_tags: null,
worker_group: 'default',
wm_version: 'benchmark'
},
{
worker: 'wk-benchmark-2',
worker_instance: 'benchmark-host',
last_ping: 5,
started_at: BENCHMARK_TIMESTAMP,
jobs_executed: 17,
custom_tags: null,
worker_group: 'default',
wm_version: 'benchmark'
}
]
/** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url.
* Any other relative fetch must keep its normal (non-benchmark) behavior —
* intercepting it with a synthetic 404 sends the model into retry loops. */
export function hasBenchmarkApiHandler(url: string): boolean {
const path = url.split('?')[0]
return path === '/api/workers/list' || /^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)
}
/** Answer a relative `/api/...` fetch issued by the API catalog executor. */
export function handleBenchmarkApiFetch(url: string): Response {
const path = url.split('?')[0]
if (path === '/api/workers/list') {
return Response.json(BENCHMARK_WORKERS)
}
if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) {
return Response.json([])
}
return Response.json({ error: `no benchmark handler for ${path}` }, { status: 404 })
}
@@ -3,6 +3,20 @@ import { expect, it, vi } from 'vitest'
import { mkdir, writeFile } from 'fs/promises'
// @ts-ignore - Node.js path
import { dirname, resolve } from 'path'
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
// The API catalog executor issues relative fetch('/api/...') calls, which have
// no meaning in the vitest environment — serve the ones the benchmark handles.
// Every other relative fetch keeps its normal behavior (it fails the same way
// it does without this stub) so unrelated tools see an unchanged environment.
const ORIGINAL_FETCH = globalThis.fetch
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = typeof input === 'string' ? input : ((input as Request | URL | null)?.url ?? '')
if (typeof url === 'string' && hasBenchmarkApiHandler(url)) {
return handleBenchmarkApiFetch(url)
}
return ORIGINAL_FETCH(input as Parameters<typeof fetch>[0], init)
}) as typeof fetch
vi.mock('monaco-editor', () => ({
editor: {},
@@ -57,7 +71,8 @@ vi.mock('$lib/gen', async () => {
runBenchmarkDatatableSql,
runBenchmarkFlowByPath,
runBenchmarkScriptPreview,
updateBenchmarkDraft
updateBenchmarkDraft,
listBenchmarkMcpTools
} = await import('./mockBackend')
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
@@ -299,6 +314,12 @@ vi.mock('$lib/gen', async () => {
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
}),
McpService: wrapService(actual.McpService, {
listMcpTools: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? listBenchmarkMcpTools()
: actual.McpService.listMcpTools(data)
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
+83
View File
@@ -1561,3 +1561,86 @@
- fetches the schema through get_db_schema with the resource path f/data/reports_pg
- when the lookup fails, tells the user instead of inventing table names
- does not write scripts or resources to answer a read-only question
# --- API catalog (search_api_endpoints / call_api_get / call_api_endpoint) ---
# The harness serves the catalog and the executed calls itself (mock
# listMcpTools + benchmark fetch handlers in adapters/frontend), so these cases
# do not require an mcp-enabled eval backend.
- id: global-test30-api-catalog-workers
prompt: |-
Are any workers online right now? Give me a quick summary of the workers and when they last pinged.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- search_api_endpoints
- call_api_get
forbiddenToolsUsed:
- call_api_endpoint
- write_script
- deploy_workspace_item
toolCallArgs:
- tool: call_api_get
field: name
stringIncludesAnyOf:
- listWorkers
# Read-only workspace inspection produces no draft; validate via tool use.
skipJudge: true
judgeChecklist:
- discovers the workers endpoint through the API catalog instead of guessing or fabricating
- reports worker status from the returned data
- id: global-test31-draft-test-run-not-deployed
prompt: |-
Update `f/evals/global/format_greeting` so the provided name is uppercased in the greeting, then run it with name "ada" to check it works.
Keep it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/format_greeting
language: bun
valueIncludes:
- toUpperCase
toolExpect:
requiredToolsUsed:
- test_run_script
forbiddenToolsUsed:
- call_api_endpoint
- deploy_workspace_item
- delete_workspace_item
# The judge only sees the drafts artifact and cannot observe runs, so it always
# docks the prompt's "run it" requirement — validate deterministically instead:
# draft content via valueIncludes, the test run via toolExpect (test_run_script
# required, call_api_endpoint forbidden).
skipJudge: true
judgeChecklist:
- creates an AI draft of f/evals/global/format_greeting with the name uppercased in the greeting
- does not deploy or save the draft
- id: global-test32-delete-via-dedicated-tool
prompt: |-
Delete the deployed script `f/evals/global/send_report_digest` from the workspace — we don't use it anymore.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- delete_workspace_item
forbiddenToolsUsed:
- call_api_endpoint
- search_api_endpoints
- write_script
# Deletion produces no draft; validate via tool use.
skipJudge: true
judgeChecklist:
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
@@ -69,6 +69,13 @@ const CATALOG = [
path: '/w/{workspace}/variables/get/{path}',
method: 'GET'
},
{
name: 'getScriptByPath',
description: 'Get script by path',
instructions: '',
path: '/w/{workspace}/scripts/get/p/{path}',
method: 'GET'
},
{
name: 'deleteScriptByHash',
description: 'Delete a script by hash',
@@ -171,6 +178,22 @@ describe('call_api_get', () => {
expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash')
})
it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => {
for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) {
const result = await run('call_api_get', { name })
expect(result.success).toBe(false)
expect(result.error).toContain('read_workspace_item')
}
for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules']) {
const result = await run('call_api_get', { name })
expect(result.success).toBe(false)
expect(result.error).toContain('list_workspace_items')
}
const search = await run('search_api_endpoints', { query: 'get script' })
expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath')
})
it('refuses variable reads so variable values never reach the model', async () => {
const result = await run('call_api_get', { name: 'getVariable' })
expect(result.success).toBe(false)
@@ -17,12 +17,21 @@ import { createToolDef, type Tool } from '../shared'
// explicit deploy, draft cleanup on delete), the variable reads would expose
// variable values to the model (getVariable even decrypts secrets by default),
// and the rest are exact duplicates that would fragment behavior across two
// code paths. getResource stays available: resource values are readable in
// this chat by design (read_workspace_item returns them too) — secrets belong
// in variables referenced as "$var:path", which a plain get leaves unresolved.
// code paths.
const COVERED_ENDPOINTS: Record<string, string> = {
getVariable: 'read_workspace_item (variable values are never readable in chat)',
listVariable: 'list_workspace_items (variable values are never readable in chat)',
// The item read/list endpoints return deployed state only, blind to the user's
// drafts; read_workspace_item / list_workspace_items merge drafts, and for
// flows return the compact JSON that patch_flow_json matches against.
getScriptByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
getFlowByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
getResource: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
getSchedule: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)',
listScripts: 'list_workspace_items (it includes your drafts)',
listFlows: 'list_workspace_items (it includes your drafts)',
listResource: 'list_workspace_items (it includes your drafts)',
listSchedules: 'list_workspace_items (it includes your drafts)',
deleteScriptByPath: 'delete_workspace_item',
deleteScriptByHash: 'delete_workspace_item',
deleteFlowByPath: 'delete_workspace_item',
@@ -492,6 +492,42 @@ describe('global AI tools', () => {
])
})
it('reads the deployed state, skipping chat and DB drafts, with version: deployed', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/greet',
language: 'bun',
content: 'export async function main(renamed_input: string) {}'
})
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 1,
path: 'f/scripts/greet',
summary: 'Deployed greet',
content: 'export async function main(name: string) {}',
schema: { properties: { name: { type: 'string' } } },
language: 'bun',
kind: 'script',
draft: { content: 'export async function main(db_draft_input: string) {}' }
} as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'script',
path: 'f/scripts/greet',
version: 'deployed'
})
const item = JSON.parse(raw)
expect(ScriptService.getScriptByPath).toHaveBeenCalledWith({
workspace: WORKSPACE,
path: 'f/scripts/greet',
getDraft: false
})
expect(item.isDraft).toBe(false)
expect(item.schema).toEqual({ properties: { name: { type: 'string' } } })
expect(raw).toContain('main(name: string)')
expect(raw).not.toContain('renamed_input')
expect(raw).not.toContain('db_draft_input')
})
it('redacts variable draft values when reading workspace items', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
@@ -874,6 +910,59 @@ describe('global AI tools', () => {
])
})
it('forwards page to the list calls, capping page-1 drafts at limit per type', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/draft_a',
language: 'bun',
content: 'export async function main() {}'
})
await callGlobalTool('write_script', {
path: 'f/scripts/draft_b',
language: 'bun',
content: 'export async function main() {}'
})
const page1 = await callGlobalTool('list_workspace_items', {
types: ['script'],
limit: 1,
page: 1
})
const page2 = await callGlobalTool('list_workspace_items', {
types: ['script'],
limit: 1,
page: 2
})
expect(ScriptService.listScripts).toHaveBeenCalledWith(expect.objectContaining({ page: 2 }))
// Bounded on page 1, no draft rows on later pages; the capped-out draft
// stays reachable through the query filter.
expect(JSON.parse(page1)).toHaveLength(1)
expect(JSON.parse(page2)).toEqual([])
const byQuery = await callGlobalTool('list_workspace_items', {
types: ['script'],
query: 'draft_b'
})
expect(JSON.parse(byQuery).map((i: any) => i.path)).toEqual(['f/scripts/draft_b'])
})
it('applies limit per item type so a full page of one type cannot hide another', async () => {
vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([
{ path: 'f/scripts/s1', language: 'bun' },
{ path: 'f/scripts/s2', language: 'bun', draft_only: true }
] as any)
vi.mocked(FlowService.listFlows).mockResolvedValueOnce([{ path: 'f/flows/f1' }] as any)
const raw = await callGlobalTool('list_workspace_items', {
types: ['script', 'flow'],
limit: 2
})
const items = JSON.parse(raw)
expect(items.map((i: any) => i.path)).toEqual(['f/scripts/s1', 'f/scripts/s2', 'f/flows/f1'])
// Server-synthesized draft-only rows must read as drafts, not deployed items.
expect(items.map((i: any) => i.isDraft)).toEqual([false, true, false])
})
it('lists and edits the live script editor draft through its effective path', async () => {
seedBackendDraft(
'script',
@@ -295,7 +295,17 @@ const listWorkspaceItemsSchema = z.object({
.min(1)
.max(MAX_LIST_LIMIT)
.optional()
.describe('Maximum number of items to return. Defaults to 50 and is capped at 100.')
.describe(
'Maximum items per item type per page (for triggers, per trigger kind). Defaults to 50 and is capped at 100.'
),
page: z
.number()
.int()
.min(1)
.optional()
.describe(
'Page number, starting at 1. Each item type pages independently: request the next page while any type still returns a full page. Drafts appear on page 1 only, capped at limit per type.'
)
})
const readWorkspaceItemSchema = z.object({
@@ -303,7 +313,13 @@ const readWorkspaceItemSchema = z.object({
path: z.string().describe('Workspace path of the item to read.'),
trigger_kind: triggerKindSchema
.optional()
.describe('Required when type is trigger. Identifies which trigger service to call.')
.describe('Required when type is trigger. Identifies which trigger service to call.'),
version: z
.enum(['deployed'])
.optional()
.describe(
'Pass "deployed" to read the deployed workspace state even when a draft exists (e.g. to learn the deployed input schema before running the deployed version). Default reads your draft when one exists.'
)
})
const draftOverrideField = z
@@ -976,7 +992,8 @@ ${pipelineBullet}
- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.
- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run.
- Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself.
- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, running deployed items, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead.
- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft.
- When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive).
- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.
- Keep context targeted.${
@@ -1062,7 +1079,10 @@ function scriptToItem(script: Script | NewScript, includeValue: boolean): Worksp
summary: script.summary,
language: script.language,
value: includeValue ? script.content : undefined,
isDraft: false
schema: includeValue ? (script as Script).schema : undefined,
// Listings with includeDraftOnly synthesize rows for editor drafts that
// have no deployed counterpart — label those honestly.
isDraft: (script as Script).draft_only ?? false
}
}
@@ -1074,7 +1094,7 @@ function flowToItem(flow: Flow, includeValue: boolean): WorkspaceItem {
value: includeValue
? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null }
: undefined,
isDraft: false
isDraft: (flow as Flow & { draft_only?: boolean }).draft_only ?? false
}
}
@@ -1461,7 +1481,12 @@ function triggerToItem(
type TriggerService = {
exists(args: { workspace: string; path: string }): Promise<boolean>
get(args: { workspace: string; path: string }): Promise<TriggerLike>
list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise<TriggerLike[]>
list(args: {
workspace: string
pathStart?: string
perPage?: number
page?: number
}): Promise<TriggerLike[]>
create(args: { workspace: string; requestBody: any }): Promise<string>
update(args: { workspace: string; path: string; requestBody: any }): Promise<string>
delete(args: { workspace: string; path: string }): Promise<string>
@@ -1546,18 +1571,27 @@ async function readWorkspaceItem(
type: WorkspaceItemType,
path: string,
workspace: string,
triggerKind?: TriggerKind
triggerKind?: TriggerKind,
deployedOnly = false
): Promise<WorkspaceItem> {
switch (type) {
case 'script': {
// Prefer the DB draft (newer than the deployed version) when one exists.
const script = await ScriptService.getScriptByPath({ workspace, path, getDraft: true })
return scriptToItem((script.draft as Script | undefined) ?? script, true)
// Prefer the DB draft (newer than the deployed version) when one exists,
// unless the caller explicitly asked for the deployed state.
const script = await ScriptService.getScriptByPath({
workspace,
path,
getDraft: !deployedOnly
})
const draft = deployedOnly ? undefined : (script.draft as Script | undefined)
return scriptToItem(draft ?? script, true)
}
case 'flow': {
// Prefer the DB draft (newer than the deployed version) when one exists.
const flow = await FlowService.getFlowByPath({ workspace, path, getDraft: true })
return flowToItem((flow.draft as Flow | undefined) ?? flow, true)
// Prefer the DB draft (newer than the deployed version) when one exists,
// unless the caller explicitly asked for the deployed state.
const flow = await FlowService.getFlowByPath({ workspace, path, getDraft: !deployedOnly })
const draft = deployedOnly ? undefined : (flow.draft as Flow | undefined)
return flowToItem(draft ?? flow, true)
}
case 'schedule':
return scheduleToItem(await ScheduleService.getSchedule({ workspace, path }), true)
@@ -1598,7 +1632,8 @@ async function listWorkspaceItems(
types: WorkspaceItemType[],
workspace: string,
pathPrefix: string | undefined,
perPage: number
perPage: number,
page?: number
): Promise<WorkspaceItem[]> {
const items: WorkspaceItem[] = []
@@ -1607,6 +1642,7 @@ async function listWorkspaceItems(
workspace,
pathStart: pathPrefix,
perPage,
page,
includeDraftOnly: true,
withoutDescription: true
})
@@ -1618,6 +1654,7 @@ async function listWorkspaceItems(
workspace,
pathStart: pathPrefix,
perPage,
page,
includeDraftOnly: true,
withoutDescription: true
})
@@ -1628,7 +1665,8 @@ async function listWorkspaceItems(
const schedules = await ScheduleService.listSchedules({
workspace,
pathStart: pathPrefix,
perPage
perPage,
page
})
for (const schedule of schedules) items.push(scheduleToItem(schedule, false))
}
@@ -1638,7 +1676,8 @@ async function listWorkspaceItems(
const triggers = await triggerServices[kind].list({
workspace,
pathStart: pathPrefix,
perPage
perPage,
page
})
for (const trigger of triggers) items.push(triggerToItem(kind, trigger, false))
}
@@ -1648,7 +1687,8 @@ async function listWorkspaceItems(
const resources = await ResourceService.listResource({
workspace,
pathStart: pathPrefix,
perPage
perPage,
page
})
for (const resource of resources) items.push(resourceToItem(resource, false))
}
@@ -1657,7 +1697,8 @@ async function listWorkspaceItems(
const variables = await VariableService.listVariable({
workspace,
pathStart: pathPrefix,
perPage
perPage,
page
})
for (const variable of variables) items.push(variableToItem(variable))
}
@@ -1666,7 +1707,8 @@ async function listWorkspaceItems(
const apps = await AppService.listApps({
workspace,
pathStart: pathPrefix,
perPage
perPage,
page
})
for (const app of apps) items.push(appToItem(app, false))
}
@@ -2348,7 +2390,7 @@ export const globalTools: Tool<{}>[] = [
def: createToolDef(
listWorkspaceItemsSchema,
'list_workspace_items',
'List workspace items and drafts. Returns metadata only.'
'List workspace items and drafts. Returns metadata only, up to limit items per item type per page (default 50); pass page to continue past a full page.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = listWorkspaceItemsSchema.parse(args)
@@ -2361,24 +2403,37 @@ export const globalTools: Tool<{}>[] = [
types,
workspace,
parsed.path_prefix,
Math.min(limit, MAX_LIST_LIMIT)
Math.min(limit, MAX_LIST_LIMIT),
parsed.page
)
for (const item of workspaceItems) {
byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item)
}
for (const draft of await listGlobalDrafts(workspace)) {
if (!types.includes(draft.type)) continue
if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue
byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), {
...draft,
value: undefined
})
// Drafts are not paginated server-side; overlay them on page 1 only,
// capped at `limit` per type, so results stay bounded and later pages
// never repeat a page-1 item as its draft twin. Chat draft counts are
// small — past the cap, a narrower path_prefix still finds any draft
// (it filters before the cap; query filters after).
if ((parsed.page ?? 1) === 1) {
const draftCountByType = new Map<string, number>()
for (const draft of await listGlobalDrafts(workspace)) {
if (!types.includes(draft.type)) continue
if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue
const count = draftCountByType.get(draft.type) ?? 0
if (count >= limit) continue
draftCountByType.set(draft.type, count + 1)
byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), {
...draft,
value: undefined
})
}
}
const results = Array.from(byKey.values())
.filter((item) => itemMatches(item, parsed.query))
.slice(0, limit)
// No cross-type truncation: each type is already capped at `limit` rows by
// its own list call, and slicing the concatenation would silently drop the
// later types' rows while their next page skips past them.
const results = Array.from(byKey.values()).filter((item) => itemMatches(item, parsed.query))
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${results.length} workspace item(s)`
@@ -2390,7 +2445,7 @@ export const globalTools: Tool<{}>[] = [
def: createToolDef(
readWorkspaceItemSchema,
'read_workspace_item',
'Read one workspace item or draft.'
'Read one workspace item or draft. Prefers your draft when one exists; pass version: "deployed" to read the deployed state instead.'
),
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsed = readWorkspaceItemSchema.parse(args)
@@ -2399,7 +2454,10 @@ export const globalTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, { content: message, error: message })
return JSON.stringify({ success: false, error: message })
}
const draft = await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind)
const draft =
parsed.version === 'deployed'
? null
: await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind)
if (draft) {
toolCallbacks.setToolStatus(toolId, {
content: `Read draft ${parsed.type} "${parsed.path}"`
@@ -2410,7 +2468,13 @@ export const globalTools: Tool<{}>[] = [
toolCallbacks.setToolStatus(toolId, {
content: `Reading ${parsed.type} "${parsed.path}"...`
})
const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind)
const item = await readWorkspaceItem(
parsed.type,
parsed.path,
workspace,
parsed.trigger_kind,
parsed.version === 'deployed'
)
toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` })
return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2)
}
@@ -123,6 +123,8 @@ export type WorkspaceItem = {
| CreateResource
| CreateVariable
| AppDraftValue
/** Input schema of a script read (flows carry theirs inside `value`). */
schema?: unknown
isDraft: boolean
isLiveDraft?: boolean
}