mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
refactor: assemble session prompt and tools through one seam
This commit is contained in:
@@ -121,27 +121,21 @@ import type { WorkspaceMutationTarget } from './workspaceTools'
|
||||
import { resolveSessionAccess, type SessionAccess } from './global/sessionAccess'
|
||||
import { filterSessionTools } from './global/sessionToolset'
|
||||
import {
|
||||
globalToolsFor,
|
||||
loadWorkspaceSkills,
|
||||
prepareGlobalSystemMessage,
|
||||
resolveGlobalPromptIdentity,
|
||||
type GlobalPromptIdentity,
|
||||
prepareGlobalUserMessage,
|
||||
type AiSkillListItem,
|
||||
type ChatCommandItem,
|
||||
type SessionPromptContext,
|
||||
getSessionContextPromptSection,
|
||||
type GlobalToolHelpers,
|
||||
type GlobalActivePreviewContext
|
||||
} from './global/core'
|
||||
import { assembleGlobalSystemMessage, assembleGlobalTools } from './global/sessionAssembly'
|
||||
import { formatChatJobCompletion } from './datatableTools'
|
||||
import { isGlobalAiEnabled } from './global/gate'
|
||||
import { createMcpTools, loadMcpServers, type McpServer } from './global/mcpTools'
|
||||
import {
|
||||
pipelineTools,
|
||||
getPipelinePromptSection,
|
||||
type PipelineAIChatHelpers
|
||||
} from './pipeline/core'
|
||||
import { loadMcpServers, type McpServer } from './global/mcpTools'
|
||||
import { type PipelineAIChatHelpers } from './pipeline/core'
|
||||
import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage'
|
||||
import { getLocalSetting, storeLocalSetting } from '$lib/utils'
|
||||
import { AttachedFilesStore } from './files/attachedFiles.svelte'
|
||||
@@ -2135,17 +2129,16 @@ export class AIChatManager {
|
||||
// Public because it is purely local, unlike `changeMode(GLOBAL)`, which also
|
||||
// fires the three network refreshes.
|
||||
configureGlobalMode = () => {
|
||||
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
const pipelineCtx = this.pipelineAiChatHelpers?.getPipelineContext()
|
||||
const systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
user: this.globalIdentity,
|
||||
skills: this.globalSkills,
|
||||
mcpServers: this.mcpServers,
|
||||
access: this.sessionAccess
|
||||
access: this.sessionAccess,
|
||||
sessionContext: this.sessionContextResolver?.(),
|
||||
pipelineContext: pipelineCtx
|
||||
})
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess)
|
||||
}
|
||||
const baseHelpers: GlobalToolHelpers = {
|
||||
// A session targets its own fixed (possibly forked) workspace, so capture it for
|
||||
// permission gating. The global side-panel chat follows the live navigation
|
||||
@@ -2175,25 +2168,12 @@ export class AIChatManager {
|
||||
}
|
||||
}
|
||||
const pipeline = this.pipelineAiChatHelpers
|
||||
const mcpTools = createMcpTools(this.mcpServers)
|
||||
// Every tool source assembled below needs an entry in SESSION_TOOL_POLICIES: the
|
||||
// session filter fails closed, so a source added here without one is withheld from
|
||||
// restricted sessions. sessionToolset.test.ts enumerates these sources to catch it.
|
||||
if (pipeline) {
|
||||
systemMessage.content += getPipelinePromptSection(
|
||||
pipeline.getPipelineContext(),
|
||||
this.sessionAccess
|
||||
)
|
||||
this.tools = [
|
||||
...globalToolsFor({ sessionPreview: this.isSessionChat }),
|
||||
...pipelineTools,
|
||||
...mcpTools
|
||||
]
|
||||
this.helpers = { ...baseHelpers, pipeline }
|
||||
} else {
|
||||
this.tools = [...globalToolsFor({ sessionPreview: this.isSessionChat }), ...mcpTools]
|
||||
this.helpers = baseHelpers
|
||||
}
|
||||
this.tools = assembleGlobalTools({
|
||||
sessionPreview: this.isSessionChat,
|
||||
pipeline: !!pipeline,
|
||||
mcpServers: this.mcpServers
|
||||
})
|
||||
this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers
|
||||
this.systemMessage = systemMessage
|
||||
this.syncArtifactsSession()
|
||||
}
|
||||
@@ -2288,28 +2268,15 @@ export class AIChatManager {
|
||||
if (this.mode !== AIMode.GLOBAL) {
|
||||
return
|
||||
}
|
||||
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
|
||||
previewTools: this.isSessionChat,
|
||||
user: this.globalIdentity,
|
||||
skills: this.globalSkills,
|
||||
mcpServers: this.mcpServers,
|
||||
access: this.sessionAccess
|
||||
access: this.sessionAccess,
|
||||
sessionContext: this.sessionContextResolver?.(),
|
||||
pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext()
|
||||
})
|
||||
// Preserve the session-state and active pipeline-editor augmentations that
|
||||
// configureGlobalMode adds — otherwise update_user_instructions (which calls
|
||||
// this) would drop them mid-session.
|
||||
const sessionCtx = this.sessionContextResolver?.()
|
||||
if (sessionCtx) {
|
||||
systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess)
|
||||
}
|
||||
const pipeline = this.pipelineAiChatHelpers
|
||||
if (pipeline) {
|
||||
systemMessage.content += getPipelinePromptSection(
|
||||
pipeline.getPipelineContext(),
|
||||
this.sessionAccess
|
||||
)
|
||||
}
|
||||
this.systemMessage = systemMessage
|
||||
}
|
||||
|
||||
private expandGlobalSkillCommand = (instructions: string): string => {
|
||||
|
||||
@@ -416,7 +416,7 @@ const readWorkspaceItemSchema = z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script.'
|
||||
'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from a hub search to read a hub script.'
|
||||
),
|
||||
trigger_kind: triggerKindSchema
|
||||
.optional()
|
||||
@@ -1074,7 +1074,7 @@ const openPreviewSchema = z.object({
|
||||
kind: z
|
||||
.enum(['script', 'flow', 'raw_app', 'pipeline'])
|
||||
.describe(
|
||||
'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
|
||||
'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.'
|
||||
),
|
||||
path: z
|
||||
.string()
|
||||
@@ -1391,7 +1391,7 @@ ${pipelineBullet}`
|
||||
canWriteDraft,
|
||||
`
|
||||
- After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited.
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty folder is fine${when(canDeploy, ', and a not-yet-created one too (create_folder first, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
|
||||
- Building a data pipeline: call open_preview(kind="pipeline", path="<folder>") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canDeploy, 'or not-yet-created ')}folder is fine${when(canDeploy, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.`
|
||||
)}
|
||||
- When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app").
|
||||
- To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open.
|
||||
@@ -3898,7 +3898,7 @@ export const globalTools: Tool<{}>[] = [
|
||||
def: createToolDef(
|
||||
rebaseDraftSchema,
|
||||
'rebase_draft',
|
||||
'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.',
|
||||
'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when a deploy reports the draft was started from an older deployed version.',
|
||||
{ strict: false }
|
||||
),
|
||||
showDetails: true,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Tool } from '../shared'
|
||||
import type { PipelineContext } from '../pipeline/core'
|
||||
import { getPipelinePromptSection, pipelineTools } from '../pipeline/core'
|
||||
import { createMcpTools, type McpServer } from './mcpTools'
|
||||
import {
|
||||
getSessionContextPromptSection,
|
||||
globalToolsFor,
|
||||
prepareGlobalSystemMessage,
|
||||
type AiSkillListItem,
|
||||
type SessionPromptContext
|
||||
} from './core'
|
||||
import type { SessionAccess } from './sessionAccess'
|
||||
|
||||
/**
|
||||
* Assembly of what a GLOBAL-mode chat ships: the system prompt's sections, and the
|
||||
* tool sources they document. Both live here so production and the capability tests
|
||||
* go through the same code — a section or a tool source added to one cannot be
|
||||
* missing from the other, which is how a prompt once kept naming tools the filter
|
||||
* had already withheld.
|
||||
*/
|
||||
|
||||
export type GlobalAssemblyOptions = {
|
||||
previewTools?: boolean
|
||||
user?: { username: string; is_admin?: boolean; folders?: string[]; folders_read?: string[] }
|
||||
skills?: AiSkillListItem[]
|
||||
mcpServers?: McpServer[]
|
||||
access?: SessionAccess
|
||||
/** Appended when the chat is a session; carries the operating/parent workspace. */
|
||||
sessionContext?: SessionPromptContext
|
||||
/** Appended while a /pipeline editor has registered its helpers. */
|
||||
pipelineContext?: PipelineContext
|
||||
}
|
||||
|
||||
/** The system message as sent, minus plan mode's decoration — that is applied later,
|
||||
* per request, by `planModeController`. */
|
||||
export function assembleGlobalSystemMessage(
|
||||
instructions: { workspace?: string; user?: string } | undefined,
|
||||
opts: GlobalAssemblyOptions
|
||||
): ReturnType<typeof prepareGlobalSystemMessage> {
|
||||
const message = prepareGlobalSystemMessage(instructions, opts)
|
||||
let content = typeof message.content === 'string' ? message.content : ''
|
||||
if (opts.sessionContext) {
|
||||
content += getSessionContextPromptSection(opts.sessionContext, opts.access)
|
||||
}
|
||||
if (opts.pipelineContext) {
|
||||
content += getPipelinePromptSection(opts.pipelineContext, opts.access)
|
||||
}
|
||||
return { ...message, content }
|
||||
}
|
||||
|
||||
/** Every tool source a GLOBAL chat assembles, unfiltered. Plan mode's two tools are
|
||||
* not here: they are merged at request time, next to the capability filter itself. */
|
||||
export function assembleGlobalTools(opts: {
|
||||
sessionPreview: boolean
|
||||
pipeline: boolean
|
||||
mcpServers?: McpServer[]
|
||||
}): Tool<any>[] {
|
||||
return [
|
||||
...globalToolsFor({ sessionPreview: opts.sessionPreview }),
|
||||
...(opts.pipeline ? pipelineTools : []),
|
||||
...createMcpTools(opts.mcpServers ?? [])
|
||||
]
|
||||
}
|
||||
@@ -16,23 +16,19 @@ vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () =
|
||||
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
import {
|
||||
globalTools,
|
||||
getSessionContextPromptSection,
|
||||
prepareGlobalSystemMessage,
|
||||
type SessionPromptContext
|
||||
} from './core'
|
||||
import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core'
|
||||
import { appendPlanModeInstructions } from '../planMode'
|
||||
import { getPipelinePromptSection, pipelineTools } from '../pipeline/core'
|
||||
import { pipelineTools } from '../pipeline/core'
|
||||
import { createMcpTools } from './mcpTools'
|
||||
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from '../planMode'
|
||||
import { assembleGlobalSystemMessage, assembleGlobalTools } from './sessionAssembly'
|
||||
import { SESSION_TOOL_POLICIES, filterSessionTools, sessionToolAllowed } from './sessionToolset'
|
||||
import { fullSessionAccess, type SessionAccess, type SessionCapability } from './sessionAccess'
|
||||
|
||||
/** Every tool name that can reach a session's toolset. `globalTools` is only part of
|
||||
* it — pipeline and MCP tools are appended by `configureGlobalMode`, and plan mode's
|
||||
* at request time — which is exactly why the policy table is keyed by name rather
|
||||
* than declared on `globalTools`. */
|
||||
/** Every tool name that can reach a session's toolset. Uses `globalTools` rather than
|
||||
* `assembleGlobalTools`'s `globalToolsFor` so the coverage below holds for the tools
|
||||
* that a non-session or non-Chromium host would filter out too — a superset, which is
|
||||
* the safe direction for an exhaustiveness check. */
|
||||
function assembledSessionToolNames(): string[] {
|
||||
const mcp = createMcpTools([{ path: 'f/test/server' } as any])
|
||||
return [
|
||||
@@ -44,6 +40,16 @@ function assembledSessionToolNames(): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** The tools a session actually ships, through the same assembly production uses. */
|
||||
function shippedSessionTools(access: SessionAccess) {
|
||||
const assembled = assembleGlobalTools({
|
||||
sessionPreview: true,
|
||||
pipeline: true,
|
||||
mcpServers: [{ path: 'f/test/server' } as any]
|
||||
})
|
||||
return filterSessionTools(assembled, access)
|
||||
}
|
||||
|
||||
function accessWith(capabilities: SessionCapability[]): SessionAccess {
|
||||
return { workspace: 'test', capabilities: new Set(capabilities) }
|
||||
}
|
||||
@@ -129,33 +135,36 @@ describe('session tool policies', () => {
|
||||
// every reachable profile, and every tool from the policy table — so neither a new tool
|
||||
// nor a new capability combination slips past.
|
||||
it.each([
|
||||
['read-only', []],
|
||||
['drafts, no deploy', ['write_draft', 'run_preview']],
|
||||
['drafts, no preview', ['write_draft', 'deploy']],
|
||||
['deploy, no drafts', ['deploy']]
|
||||
] as [string, SessionCapability[]][])(
|
||||
// `reachable` marks the profiles `resolveSessionAccess` can actually produce. The
|
||||
// prompt is swept for the unreachable ones too, since gating it costs nothing; the
|
||||
// tool DEFINITIONS are not, because the only way to satisfy those cases is to strip
|
||||
// a sibling tool's name out of a description that earns its place for real sessions.
|
||||
['read-only', [], true],
|
||||
['drafts, no deploy', ['write_draft', 'run_preview'], true],
|
||||
['drafts only', ['write_draft'], true],
|
||||
['drafts, no preview', ['write_draft', 'deploy'], false],
|
||||
['deploy, no drafts', ['deploy'], false]
|
||||
] as [string, SessionCapability[], boolean][])(
|
||||
'never names a withheld tool in the assembled prompt (%s)',
|
||||
(_label, capabilities) => {
|
||||
(_label, capabilities, reachable) => {
|
||||
const access = accessWith(capabilities)
|
||||
const withheld = assembledSessionToolNames().filter((n) => !sessionToolAllowed(n, access))
|
||||
expect(withheld.length).toBeGreaterThan(0)
|
||||
// The tool DEFINITIONS ship alongside the prompt, so a withheld name in a
|
||||
// description is the same broken promise as one in the prompt.
|
||||
if (reachable) {
|
||||
const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def))
|
||||
expect(withheld.filter((n) => defs.includes(n))).toEqual([])
|
||||
}
|
||||
for (const previewTools of [false, true]) {
|
||||
for (const ctx of SESSION_CONTEXTS) {
|
||||
let msg = prepareGlobalSystemMessage(undefined, {
|
||||
const msg = assembleGlobalSystemMessage(undefined, {
|
||||
previewTools,
|
||||
user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] },
|
||||
access
|
||||
access,
|
||||
sessionContext: ctx,
|
||||
pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }
|
||||
})
|
||||
msg = {
|
||||
...msg,
|
||||
content:
|
||||
(msg.content as string) +
|
||||
getSessionContextPromptSection(ctx, access) +
|
||||
getPipelinePromptSection(
|
||||
{ folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] },
|
||||
access
|
||||
)
|
||||
}
|
||||
// Both decoration variants: the escalation one adds its own tool mentions.
|
||||
for (const blocks of [0, 9]) {
|
||||
const full = appendPlanModeInstructions(msg, blocks).content as string
|
||||
|
||||
@@ -125,7 +125,9 @@ export const SESSION_TOOL_POLICIES: Record<string, SessionToolPolicy> = {
|
||||
delete_app_file: WRITE_DRAFT,
|
||||
write_app_runnable: WRITE_DRAFT,
|
||||
delete_app_runnable: WRITE_DRAFT,
|
||||
// Both act on a draft, which can only exist for someone who could write one.
|
||||
// Relevance, not authorization: discarding your OWN draft deliberately skips
|
||||
// `require_can_write_path` (drafts.rs), so the backend would allow it — but a
|
||||
// session that cannot write a draft has none to discard or rebase.
|
||||
discard_local_draft: WRITE_DRAFT,
|
||||
rebase_draft: WRITE_DRAFT,
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ const readPipelineNodeSchema = z.object({
|
||||
const readPipelineNodeToolDef = createToolDef(
|
||||
readPipelineNodeSchema,
|
||||
'read_pipeline_node',
|
||||
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.'
|
||||
'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.'
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user