diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/index.ts b/apps/mesh/src/mcp-clients/virtual-mcp/index.ts index 595a80d080..9ed18e5af7 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/index.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/index.ts @@ -12,6 +12,7 @@ import { getMcpListCache } from "../mcp-list-cache"; import type { StudioContext } from "../../core/studio-context"; import type { ConnectionEntity } from "../../tools/connection/schema"; import type { VirtualMCPEntity } from "../../tools/virtual/schema"; +import { loadInstructionsFileText } from "./instructions-file"; import { PassthroughClient } from "./passthrough-client"; import { renderSkillsCatalogBlock } from "./skills-instructions"; import type { VirtualClientOptions } from "./types"; @@ -139,6 +140,12 @@ export async function createVirtualClientFrom( ? ((await renderSkillsCatalogBlock(ctx, virtualMcp)) ?? undefined) : undefined; + // Prompt-linked-to-file: read the linked org-fs file now (async, same seam + // as the skills block) so getInstructions() can prefer it over the inline + // mirror on every run path. No-op (no read) for agents without a link. + const instructionsOverride = + (await loadInstructionsFileText(ctx, virtualMcp)) ?? undefined; + // Build aggregator options const clientOptions: VirtualClientOptions = { connections: loadedConnections, @@ -147,6 +154,7 @@ export async function createVirtualClientFrom( mcpListCache: getMcpListCache() ?? undefined, listTimeoutMs: options?.listTimeoutMs, skillsBlock, + instructionsOverride, }; return new PassthroughClient(clientOptions, ctx); diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/instructions-file.ts b/apps/mesh/src/mcp-clients/virtual-mcp/instructions-file.ts new file mode 100644 index 0000000000..287c414a9a --- /dev/null +++ b/apps/mesh/src/mcp-clients/virtual-mcp/instructions-file.ts @@ -0,0 +1,39 @@ +/** + * Resolves the agent's system prompt from its linked org-fs file + * (`metadata.instructionsFile`). Read here (async, factory-side) — NOT inside + * the synchronous `getInstructions()` — and stashed on the client options, + * the same seam as the skills block, so it reaches both the cluster engine + * and the sandbox/desktop daemon. Best-effort: any failure degrades to the + * inline `metadata.instructions` mirror, never a failed client creation. + */ + +import type { StudioContext } from "../../core/studio-context"; +import { + buildPublicOrgFs, + isPublicVolume, +} from "../../file-storage/public-sets"; +import type { VirtualMCPEntity } from "../../tools/virtual/schema"; + +export async function loadInstructionsFileText( + ctx: StudioContext, + virtualMcp: VirtualMCPEntity, +): Promise { + const ref = virtualMcp.metadata?.instructionsFile; + if (!ref) return null; + try { + // `public-*` volumes live under the shared public scope, not the org's. + const orgFs = isPublicVolume(ref.volume) + ? buildPublicOrgFs(ctx) + : ctx.orgFs; + if (!orgFs) return null; + return new TextDecoder().decode(await orgFs.read(ref.volume, ref.path)); + } catch (err) { + console.warn("[virtual-mcp] failed to read linked instructions file", { + virtualMcpId: virtualMcp.id, + volume: ref.volume, + path: ref.path, + err, + }); + return null; + } +} diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts index 233a881196..304da79c5e 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/passthrough-client.ts @@ -211,7 +211,11 @@ export class PassthroughClient extends GatewayClient { // daemon both read instructions; only the cluster runs the richer // buildAgentSystemPrompt). const base = withKnowledge( - this.options.virtualMcp.metadata?.instructions ?? undefined, + // The linked instructions file (factory-read) wins over the inline + // mirror; the mirror is the fallback when the file is unreadable. + this.options.instructionsOverride ?? + this.options.virtualMcp.metadata?.instructions ?? + undefined, this.options.virtualMcp.metadata?.knowledge, ); // Append the pre-rendered catalog (built async in the diff --git a/apps/mesh/src/mcp-clients/virtual-mcp/types.ts b/apps/mesh/src/mcp-clients/virtual-mcp/types.ts index c1c725337c..db6484f02c 100644 --- a/apps/mesh/src/mcp-clients/virtual-mcp/types.ts +++ b/apps/mesh/src/mcp-clients/virtual-mcp/types.ts @@ -25,4 +25,11 @@ export interface VirtualClientOptions { * cluster engine and the sandbox/desktop daemon. Only set for agent runtimes. */ skillsBlock?: string; + /** + * System prompt read from the agent's linked org-fs file + * (`metadata.instructionsFile`). Same factory-side async pattern as + * `skillsBlock`; when set, `getInstructions()` prefers it over the inline + * `metadata.instructions` mirror. + */ + instructionsOverride?: string; } diff --git a/apps/mesh/src/tools/virtual/update.ts b/apps/mesh/src/tools/virtual/update.ts index 4690175415..1a30453498 100644 --- a/apps/mesh/src/tools/virtual/update.ts +++ b/apps/mesh/src/tools/virtual/update.ts @@ -12,6 +12,7 @@ import { requireOrganization, } from "../../core/studio-context"; import { writeAgentPrompts } from "../../file-storage/agent-prompts"; +import { isPublicVolume } from "../../file-storage/public-sets"; import { VirtualMCPEntitySchema, VirtualMCPUpdateDataSchema } from "./schema"; import { requireOrgAdminForPinnedField } from "./require-org-admin-for-pin"; @@ -79,6 +80,37 @@ export const COLLECTION_VIRTUAL_MCP_UPDATE = defineTool({ requireOrgAdminForPinnedField(ctx); } + // Prompt-linked-to-file: an actual instructions change writes through to + // the linked org-fs file (the runtime source of truth). Gated on the + // incoming text differing from BOTH the stored mirror (so unrelated + // autosaves echoing an unchanged/stale mirror never clobber external file + // edits) and the current file content (so link-time saves don't churn the + // change feed). Runs before the row update so a failed write fails the + // whole update instead of silently diverging mirror and file. `public-*` + // volumes are readonly — the synced file is canonical there, never written. + const fileRef = data.metadata?.instructionsFile; + const incomingInstructions = input.data.metadata?.instructions; + if ( + fileRef && + typeof incomingInstructions === "string" && + incomingInstructions !== existing.metadata?.instructions && + !isPublicVolume(fileRef.volume) && + ctx.orgFs + ) { + const fileText = await ctx.orgFs + .read(fileRef.volume, fileRef.path) + .then((bytes) => new TextDecoder().decode(bytes)) + .catch(() => null); + if (fileText !== incomingInstructions) { + await ctx.orgFs.write( + fileRef.volume, + fileRef.path, + incomingInstructions, + { actor: userId }, + ); + } + } + const virtualMcp = await ctx.storage.virtualMcps.update( input.id, userId, diff --git a/apps/mesh/src/web/hooks/use-org-fs.ts b/apps/mesh/src/web/hooks/use-org-fs.ts index a9c0fb8cbf..63a4ea9349 100644 --- a/apps/mesh/src/web/hooks/use-org-fs.ts +++ b/apps/mesh/src/web/hooks/use-org-fs.ts @@ -240,7 +240,7 @@ export async function fetchOrgFsSkillCatalog( } /** Read a file's contents as UTF-8 text (org-fs `/read` endpoint). */ -async function fetchOrgFsText( +export async function fetchOrgFsText( orgSlug: string, volume: string, path: string, @@ -249,6 +249,16 @@ async function fetchOrgFsText( return res.text(); } +/** A text file's content, cached — powers the prompt-linked-file editor. */ +export function useOrgFsReadText(volume: string | null, path: string) { + const { org } = useProjectContext(); + return useQuery({ + queryKey: KEYS.orgFsReadText(org.id, volume ?? "", path), + enabled: volume !== null, + queryFn: () => fetchOrgFsText(org.slug, volume ?? "", path), + }); +} + /** A text file inside a skill folder, collected for inlining into chat. */ export interface OrgFsSkillFile { /** Path relative to the skill dir (e.g. "SKILL.md", "references/style.md"). */ diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 8e6972567c..eb97f3f550 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -409,6 +409,10 @@ export const KEYS = { ["org-fs", orgId, volume, "usage"] as const, orgFsStat: (orgId: string, volume: string, path: string) => ["org-fs", orgId, volume, "stat", path] as const, + // A text file's content (prompt-linked-file editor). Nested under the + // volume prefix so write mutations refresh it with the listings. + orgFsReadText: (orgId: string, volume: string, path: string) => + ["org-fs", orgId, volume, "read-text", path] as const, orgFsPublicSets: (orgId: string) => ["org-fs-public-sets", orgId] as const, // Cross-volume recent-files feed (Library home). Separate root key so a // volume named like the segment can never collide; mutations invalidate it diff --git a/apps/mesh/src/web/views/virtual-mcp/index.tsx b/apps/mesh/src/web/views/virtual-mcp/index.tsx index ea87b1ff14..42912f31bd 100644 --- a/apps/mesh/src/web/views/virtual-mcp/index.tsx +++ b/apps/mesh/src/web/views/virtual-mcp/index.tsx @@ -10,6 +10,8 @@ import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; import { usePanelActions } from "@/web/layouts/shell-layout"; import { User } from "@/web/components/user/user"; import { useMCPAuthStatus } from "@/web/hooks/use-mcp-auth-status"; +import { fetchOrgFsText, useOrgFsReadText } from "@/web/hooks/use-org-fs.ts"; +import { publicSetOf } from "@/web/layouts/library/location"; import { authenticateMcp, @@ -88,6 +90,7 @@ import { IconPicker } from "../../components/icon-picker"; import { SimpleIconPicker } from "../../components/simple-icon-picker"; import { Page } from "@/web/components/page"; import { AddConnectionDialog } from "./add-connection-dialog"; +import { PromptFileLink, type PromptFileRef } from "./prompt-file-link"; import { FilesSection } from "./files-section"; import { SubAgentsSection } from "./sub-agents-section"; import { track } from "@/web/lib/posthog-client"; @@ -1153,6 +1156,29 @@ function VirtualMcpDetailViewWithData({ // GitHub repo connected (real auth) — instructions become read-only const hasGithubRepo = agentHasConnectedGithub(virtualMcp); + // Prompt linked to an org-fs file: the file is the runtime source of truth + // (the update tool writes edits made here through to it); `public-*` + // volumes are readonly and lock the editor. Display prefers the fresh file + // content over the possibly-stale inline mirror; `promptDraft` holds + // in-progress edits until the post-save refetch echoes them back. + const promptFile = form.watch("metadata.instructionsFile") ?? null; + const promptFileReadOnly = + promptFile !== null && publicSetOf(promptFile.volume) !== null; + const { data: promptFileText } = useOrgFsReadText( + promptFile?.volume ?? null, + promptFile?.path ?? "", + ); + const [promptDraft, setPromptDraft] = useState(null); + // Post-save echo: the refetched file matches the draft — drop it so later + // external file updates show through. Render-phase adjust, not an effect. + if (promptDraft !== null && promptFileText === promptDraft) { + setPromptDraft(null); + } + const promptDisplayValue = (fieldValue: string | null | undefined) => + promptFile + ? (promptDraft ?? promptFileText ?? fieldValue ?? "") + : (fieldValue ?? ""); + // Repo info for the Runtime card (display-only — loose check is intentional) const githubRepoForRuntimeCard = getActiveGithubRepo(virtualMcp); const runtimeCardRepo = githubRepoForRuntimeCard @@ -1178,8 +1204,10 @@ function VirtualMcpDetailViewWithData({ const handleImprovePrompt = async () => { if (isImproving) return; - const currentInstructions = form.getValues("metadata.instructions"); - if (!currentInstructions?.trim()) return; + const currentInstructions = promptDisplayValue( + form.getValues("metadata.instructions"), + ); + if (!currentInstructions.trim()) return; setIsImproving(true); try { @@ -1271,6 +1299,19 @@ function VirtualMcpDetailViewWithData({ data: payload, }); + // Refetch the linked prompt file after the server's write-through so the + // draft-clearing echo check (above) sees the new content. + const linkedFile = formData.metadata?.instructionsFile; + if (linkedFile) { + queryClient.invalidateQueries({ + queryKey: KEYS.orgFsReadText( + org.id, + linkedFile.volume, + linkedFile.path, + ), + }); + } + // Accumulate into the current edit session and (re)schedule a flush // 30s after the last save. dispatchEditSession({ @@ -1548,6 +1589,34 @@ Define step-by-step how the agent should handle requests. form.setValue("metadata.instructions", next, { shouldDirty: true }); }; + const handleLinkPromptFile = async (file: PromptFileRef) => { + try { + const text = await fetchOrgFsText(org.slug, file.volume, file.path); + form.setValue("metadata.instructionsFile", file, { shouldDirty: true }); + // Mirror the file content at link time so the entity fallback stays + // coherent (the server skips the no-op file write). + form.setValue("metadata.instructions", text, { shouldDirty: true }); + setPromptDraft(null); + flushAndSave(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to read file.", + ); + } + }; + + const handleUnlinkPromptFile = () => { + // Keep the currently-displayed text inline so unlinking never loses it. + form.setValue( + "metadata.instructions", + promptDisplayValue(form.getValues("metadata.instructions")), + { shouldDirty: true }, + ); + form.setValue("metadata.instructionsFile", null, { shouldDirty: true }); + setPromptDraft(null); + flushAndSave(); + }; + const addedConnectionIds = new Set(connections.map((c) => c.connection_id)); const navigate = useNavigate(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -1796,20 +1865,28 @@ Define step-by-step how the agent should handle requests. Instructions
- {!form.watch("metadata.instructions")?.trim() && ( - - )} + + {!promptFile && + !form.watch("metadata.instructions")?.trim() && ( + + )}