mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-04 08:02:23 +00:00
* feat: filter ai session tools to the user's workspace capabilities Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct and tighten comments on the session capability filter Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: gate session deploy tools on DisableDirectDeployment and the pipeline prompt * fix: gate create_folder on the deploy capability * refactor: assemble session prompt and tools through one seam * docs: state the capability filter as best-effort, not a guarantee * refactor: take the whole deploy gate from the shared preflight `checkDeployPermission` now evaluates `DisableDirectDeployment` and folds superadmin into the admin bypass itself, so the resolver's local composition of those two terms is redundant. Delegate outright and drop the protection-rule fetch it needed, along with the two tests that restated rule semantics the preflight's own suite now pins. The preflight's per-kind narrowing stays unused: the filter runs on tool names, before the model has named a kind, so a direct-deployment lock withholds the deploy tools for schedules and triggers too. * fix: address review findings on the session capability filter Six findings from the Claude and Codex review rounds. - `discard_local_draft` is ungated. The backend exempts discarding your OWN draft from `require_can_write_path` precisely so drafts stay cleanable after a role change; gating it stranded that cleanup. - `deploy` splits into `deploy` and `deploy_gated_kinds`, mirroring `deployPermissionForKind`. A direct-deployment lock stops only the kinds that reach `check_deploy_rules`, so schedules and triggers stay deployable and the two kind-taking deploy tools survive the lock; `create_folder` does not, folder being a gated kind. The prompt now names the lock and what it leaves deployable, instead of implying nothing can be deployed. - `COVERED_ENDPOINTS` keyed `createApp` / `updateApp`, which the MCP catalog does not expose; the app-authoring endpoints it does expose, `createAppRawSource` and `updateAppRawSource`, were uncovered and reachable through `call_api_endpoint`. - The YOLO tooltip listed tools a restricted session never ships. Both it and the token estimate now read one `shippedTools`, and `sessionAccess` is reactive so the UI follows the resolution. * fix: restore the covered API-catalog names for the raw-app endpoints `COVERED_ENDPOINTS` is matched against `EndpointTool.name`, which openapi.yaml overrides with `x-mcp-tool-name` for these two operations: `createAppRawSource` and `updateAppRawSource` are served as `createApp` and `updateApp` (`mcp/auto_generated_endpoints.rs`). Keying them by operationId left both raw-app POST endpoints discoverable and callable through the API catalog tools. Restore the exposed names and record why they differ from the operationIds. * docs: state each capability invariant once, and document the draft discard The asymmetric admin/operator precedence was restated three times in sessionAccess.ts and again in its test, the fail-open rationale twice, and the deploy split across four sites. Each now lives at the one place someone would break it, within the four-line budget, with the other sites pointing at it. Ungating discard_local_draft left it undocumented for the read-only profile, which is the profile the backend exemption exists for: the only bullet naming it sits under the draft-writing gate, beneath an opener saying no change is possible. Add the one line that profile needs. * docs: record why the session tool filter runs unconditionally The filter would strip everything from a non-GLOBAL toolset, whose names carry no policy entries. That cannot happen — `changeMode` refuses to move a session chat out of GLOBAL, and `sessionAccess` is only ever set for session chats — but the dependency was not visible at the filter itself. * fix: match the server's deploy gate exactly, never exceed it The filter must be as strict as the server and no stricter. Schedules and triggers reach no deploy rule — `check_deploy_rules` runs only from the gated kinds' handlers — so no workspace refuses `deploy_workspace_item` or `delete_workspace_item` outright, whatever refusal `checkDeployPermission` reports. Gating them on a deploy capability withheld operations the server performs. Neither tool now requires a capability. Deploying still needs a draft to deploy, so it keeps the authoring relevance; deleting a deployed item does not, so it is ungated. `deploy` returns to one capability, covering the kinds the rules gate, and `create_folder` — whose kind is one of them — is the only tool that names it. The session-state note now states which kinds a refusing workspace still accepts. * feat: gate the new app-runnable preview tool on run_preview Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: fail open when whoami resolves without a role Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: count plan-mode tools in the shipped toolset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * docs: drop the dead capability assertion and the repeated deploy rationale Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: drop dead code and a duplicated invariant from the session filter Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: reduce SessionAccess to the capability set it is read for Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: gate session tools on permission alone, never on relevance Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * test: pin the filter to the outbound request and widen the description sweep Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: collapse SessionAccess to a capability set and merge adjacent gates Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: gate get_db_schema on run_preview, it runs a query script Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: reuse the cached workspace role and derive the exhaustiveness list from assembly Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * revert: keep tool names in descriptions that ship with them Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: let an admin who is also an operator deploy, as the server does Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: derive the deploy capability from the protection rules alone Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: stop the datatable instructions naming a tool a session may not have Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: keep the prompt and tool results honest for a profile that cannot draft Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: move tool policies onto the tools and gate kinds per handler Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * chore: tighten stale comments and name deploy in the operator prompt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: keep the assembled tool list raw so narrowing can clone its defs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: point an operator at a workspace admin for code the role refuses Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * fix: resolve session permissions when the assistant settings modal opens Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH * refactor: return per-chat tool schemas instead of writing them to shared tools Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: forward this through the eval tool wrapper so tools see their sent def Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: pin identity and contents in the session filter and schema tests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: note why the overhead estimate skips per-chat tool schemas Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
12 KiB
TypeScript
273 lines
12 KiB
TypeScript
/**
|
|
* AI tools and system-prompt roster for files attached to the GLOBAL chat.
|
|
*
|
|
* The model is made aware of attached files via a metadata-only roster appended to
|
|
* the system message (see `appendAttachedFilesRoster`). Their contents are NEVER
|
|
* inlined — the model pulls only the slices it needs through these two read-only
|
|
* tools, which stream from disk via ./fileEngine.
|
|
*/
|
|
import { z } from 'zod'
|
|
import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
|
|
import { createToolDef } from '../shared'
|
|
import { NONE, type SessionTool } from '../sessionCapabilities'
|
|
import {
|
|
readFile,
|
|
searchFilesInWorker,
|
|
numberLines,
|
|
FileReadError,
|
|
type SearchHit
|
|
} from './fileEngine'
|
|
import type { AttachedFile, AttachedFilesStore } from './attachedFiles.svelte'
|
|
import { sanitizeAttachmentName } from '../textFileUtils'
|
|
|
|
/** Slice of the GLOBAL tool helpers that exposes the attached-files store. */
|
|
export interface AttachedFilesHelper {
|
|
attachedFiles?: AttachedFilesStore
|
|
}
|
|
|
|
function storeFrom(helpers: unknown): AttachedFilesStore | undefined {
|
|
return (helpers as AttachedFilesHelper | undefined)?.attachedFiles
|
|
}
|
|
|
|
/**
|
|
* For a specifically requested attached file, a message describing why it can't be read /
|
|
* searched yet (still indexing, locked, unavailable, errored) or that it isn't attached —
|
|
* or undefined when it's `ready`. Shared by read_file and search_files so both report the
|
|
* same accurate status instead of search_files claiming a non-ready file isn't attached.
|
|
*/
|
|
function notReadyMessage(store: AttachedFilesStore, file: string): string | undefined {
|
|
const entry = store.resolve(file)
|
|
if (entry?.status === 'ready') return undefined
|
|
if (entry?.status === 'indexing')
|
|
return `File "${file}" is still being indexed. Try again shortly.`
|
|
if (entry?.status === 'locked')
|
|
return `File "${file}" is locked after a reload. Ask the user to restore access (send a message, or click "Restore access").`
|
|
if (entry?.status === 'unavailable')
|
|
return `File "${file}" is no longer available (moved, deleted, or its local copy was evicted). Ask the user to re-link it.`
|
|
if (entry?.status === 'error')
|
|
return `File "${file}" failed to load: ${entry.error ?? 'unknown error'}.`
|
|
// Re-sanitized: folder-root placeholder rows carry the raw folder key.
|
|
const names = store
|
|
.list()
|
|
.map((f) =>
|
|
f.id ? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})` : sanitizeAttachmentName(f.name)
|
|
)
|
|
.join(', ')
|
|
return `No attached file matching "${file}". Attached files: ${names || '(none)'}.`
|
|
}
|
|
|
|
/**
|
|
* When attachments exist but none expose a readable target (`readyFiles()` is empty),
|
|
* explain the actual reason instead of always claiming files are still indexing. Empty
|
|
* or binary-only linked folders leave only `ready` placeholder rows (filtered out of
|
|
* `readyFiles`), while a locked/unavailable restore surfaces those statuses on the rows.
|
|
*/
|
|
function noReadyFilesMessage(store: AttachedFilesStore): string {
|
|
const statuses = new Set(store.list().map((f) => f.status))
|
|
if (statuses.has('indexing')) return 'Attached files are still being indexed. Try again shortly.'
|
|
if (statuses.has('locked'))
|
|
return 'The attached files are locked after a reload. Ask the user to restore access (send a message, or click "Restore access").'
|
|
if (statuses.has('unavailable'))
|
|
return 'The attached files are no longer available (moved, deleted, or their local copies were evicted). Ask the user to re-link them.'
|
|
if (statuses.has('error')) return 'The attached files failed to load.'
|
|
return 'No searchable text files are attached (a linked folder may be empty or contain only non-text files).'
|
|
}
|
|
|
|
function humanSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
const searchFilesSchema = z.object({
|
|
pattern: z.string().describe('JavaScript regular expression to search for.'),
|
|
file: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Optional file to restrict the search to: its file id when one is listed, otherwise its exact filename. Omit to search across all attached files.'
|
|
),
|
|
ignore_case: z.boolean().optional().describe('Case-insensitive matching. Defaults to false.')
|
|
})
|
|
|
|
const searchFilesToolDef = createToolDef(
|
|
searchFilesSchema,
|
|
'search_files',
|
|
'Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.'
|
|
)
|
|
|
|
export const searchFilesTool: SessionTool<{}> = {
|
|
requires: NONE,
|
|
def: searchFilesToolDef,
|
|
planModeSafe: true,
|
|
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
|
const store = storeFrom(helpers)
|
|
if (!store || store.count === 0) {
|
|
return 'No files are attached to this conversation.'
|
|
}
|
|
const parsed = searchFilesSchema.parse(args)
|
|
// Validate a specifically requested file against the full store first, so a non-ready
|
|
// target reports its real status (indexing/locked/…) instead of "not attached".
|
|
if (parsed.file) {
|
|
const notReady = notReadyMessage(store, parsed.file)
|
|
if (notReady) return notReady
|
|
}
|
|
// Restrict to the resolved row itself, not a name filter: display names may
|
|
// collide (same-named attachments on different messages), and a name filter
|
|
// would silently search all of them under one label.
|
|
const target = parsed.file ? store.resolve(parsed.file) : undefined
|
|
const ready = target ? [target] : store.readyFiles()
|
|
if (ready.length === 0) {
|
|
return noReadyFilesMessage(store)
|
|
}
|
|
toolCallbacks.setToolStatus(toolId, {
|
|
content: `Searching attached files for /${parsed.pattern}/...`
|
|
})
|
|
|
|
// Hit lines are the model's only handle on which row matched, and display
|
|
// names may collide — label id-bearing rows with the reference that
|
|
// resolves back to exactly that row.
|
|
const rows = ready.map((f) => (f.id ? { ...f, name: `${f.name} (file id: ${f.id})` } : f))
|
|
// Run in a Worker so a pathological model-supplied regex can't freeze the tab.
|
|
const result = await searchFilesInWorker(rows, parsed.pattern, {
|
|
flags: parsed.ignore_case ? 'i' : ''
|
|
})
|
|
if (result.error) {
|
|
return `Error: ${result.error}`
|
|
}
|
|
const scope = target ? `"${target.name}"` : `${ready.length} file(s)`
|
|
if (result.hits.length === 0) {
|
|
return `No matches for /${parsed.pattern}/ in ${scope}.`
|
|
}
|
|
const body = result.hits.map((h: SearchHit) => `${h.file}:${h.line}: ${h.text}`).join('\n')
|
|
const header = `Found ${result.hits.length} match(es) in ${scope}:`
|
|
const footer = result.truncated
|
|
? '\n\n(Stopped at the result limit — refine your pattern or pass a `file` to narrow the search.)'
|
|
: ''
|
|
return `${header}\n${body}${footer}`
|
|
}
|
|
}
|
|
|
|
const readFileSchema = z.object({
|
|
file: z
|
|
.string()
|
|
.describe('File to read: its file id when one is listed, otherwise its exact filename.'),
|
|
start_line: z.number().int().optional().describe('1-based first line to read. Defaults to 1.'),
|
|
end_line: z
|
|
.number()
|
|
.int()
|
|
.optional()
|
|
.describe('1-based last line to read. The window is capped at 200 lines.')
|
|
})
|
|
|
|
const readFileToolDef = createToolDef(
|
|
readFileSchema,
|
|
'read_file',
|
|
'Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.'
|
|
)
|
|
|
|
export const readFileTool: SessionTool<{}> = {
|
|
requires: NONE,
|
|
def: readFileToolDef,
|
|
planModeSafe: true,
|
|
fn: async ({ args, helpers, toolId, toolCallbacks }) => {
|
|
const store = storeFrom(helpers)
|
|
if (!store || store.count === 0) {
|
|
return 'No files are attached to this conversation.'
|
|
}
|
|
const parsed = readFileSchema.parse(args)
|
|
const notReady = notReadyMessage(store, parsed.file)
|
|
if (notReady) return notReady
|
|
const entry = store.resolve(parsed.file)!
|
|
toolCallbacks.setToolStatus(toolId, { content: `Reading "${entry.name}"...` })
|
|
|
|
try {
|
|
const res = await readFile(entry, {
|
|
startLine: parsed.start_line,
|
|
endLine: parsed.end_line
|
|
})
|
|
return res.text ? `${res.note}\n\n${numberLines(res.text, res.startLine)}` : res.note
|
|
} catch (e) {
|
|
if (e instanceof FileReadError) {
|
|
return `Could not read "${parsed.file}": ${e.message}. The file may have been moved or deleted since it was attached.`
|
|
}
|
|
return `Error reading "${parsed.file}": ${e instanceof Error ? e.message : String(e)}`
|
|
}
|
|
}
|
|
}
|
|
|
|
export const fileTools: SessionTool<{}>[] = [searchFilesTool, readFileTool]
|
|
|
|
function rosterLine(f: AttachedFile): string {
|
|
// Message rows are addressed by their stable id (names may collide); session
|
|
// rows by name. Names are sanitized at render: this block is model-facing
|
|
// prompt text and stored names (legacy, folder children) may carry controls.
|
|
const ref = f.id
|
|
? `${sanitizeAttachmentName(f.name)} (file id: ${f.id})`
|
|
: sanitizeAttachmentName(f.name)
|
|
if (f.status === 'indexing') return `- ${ref} (indexing…)`
|
|
if (f.status === 'locked') return `- ${ref} (locked — needs the user to restore access)`
|
|
if (f.status === 'unavailable') return `- ${ref} (unavailable)`
|
|
if (f.status === 'error') return `- ${ref} (failed to load)`
|
|
return `- ${ref} — ${f.lineCount} lines, ${humanSize(f.size)}`
|
|
}
|
|
|
|
/** Build the `## Attached files` system-prompt section (metadata only, never content). */
|
|
export function buildAttachedFilesRoster(
|
|
store: AttachedFilesStore,
|
|
orphanedMessageFileIds?: Set<string>
|
|
): string {
|
|
const lines: string[] = []
|
|
for (const folder of store.folders) {
|
|
// Folder names are RAW disk keys (never sanitized in the store — they must
|
|
// match the handle, children, and persistence record), so this model-facing
|
|
// render is where control characters get stripped.
|
|
const folderName = sanitizeAttachmentName(folder.name)
|
|
// A locked/unavailable folder has no readable children — one line for the whole folder.
|
|
if (folder.status === 'locked') {
|
|
lines.push(`- ${folderName} (locked — needs the user to restore access)`)
|
|
} else if (folder.status === 'unavailable') {
|
|
lines.push(`- ${folderName} (unavailable)`)
|
|
} else {
|
|
lines.push(...folder.files.map(rosterLine))
|
|
}
|
|
}
|
|
// Message-attached files are deliberately NOT listed here: their reference
|
|
// lives inside the message that carried them (or the compaction summary),
|
|
// exactly like DOM picks — the roster only advertises session-wide links.
|
|
lines.push(...store.standalone.map(rosterLine))
|
|
// Exception: a message whose API counterpart was dropped by drop-oldest
|
|
// compaction takes its in-message reference with it, so those attachments must
|
|
// be advertised here or the model can no longer see they exist.
|
|
if (orphanedMessageFileIds?.size) {
|
|
lines.push(
|
|
...store.messageAttached
|
|
.filter((f) => f.id && orphanedMessageFileIds.has(f.id))
|
|
.map(rosterLine)
|
|
)
|
|
}
|
|
if (lines.length === 0) return ''
|
|
return [
|
|
'## Attached files',
|
|
'The user has attached the following files to this conversation. Their contents are NOT included here.',
|
|
'Use the `search_files` tool to find content with a regex, and `read_file` to read a bounded window of lines.',
|
|
'Reference a file by its file id when one is shown, otherwise by its filename.',
|
|
'',
|
|
lines.join('\n')
|
|
].join('\n')
|
|
}
|
|
|
|
/**
|
|
* Return a copy of the system message with the attached-files roster appended.
|
|
* Always derives from the provided base so the roster never accumulates across turns.
|
|
*/
|
|
export function appendAttachedFilesRoster(
|
|
base: ChatCompletionSystemMessageParam,
|
|
store: AttachedFilesStore,
|
|
orphanedMessageFileIds?: Set<string>
|
|
): ChatCompletionSystemMessageParam {
|
|
const roster = buildAttachedFilesRoster(store, orphanedMessageFileIds)
|
|
if (!roster || typeof base.content !== 'string') return base
|
|
return { ...base, content: `${base.content}\n\n${roster}` }
|
|
}
|