-
Notifications
You must be signed in to change notification settings - Fork 49
feat(agents): link an agent's system prompt to an org-fs file #4488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null> { | ||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two small notes on the write-through, neither blocking: 1. Permission scope (defense-in-depth). This writes via 2. No |
||
| fileRef.volume, | ||
| fileRef.path, | ||
| incomingInstructions, | ||
| { actor: userId }, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const virtualMcp = await ctx.storage.virtualMcps.update( | ||
| input.id, | ||
| userId, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string | null>(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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit, not blocking: the draft-clear leans on exact string equality ( |
||
| 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 | ||
| </h2> | ||
| <div className="flex items-center gap-2"> | ||
| {!form.watch("metadata.instructions")?.trim() && ( | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handleInsertTemplate} | ||
| > | ||
| + Prompt template | ||
| </Button> | ||
| )} | ||
| <PromptFileLink | ||
| file={promptFile} | ||
| readOnly={promptFileReadOnly} | ||
| onLink={handleLinkPromptFile} | ||
| onUnlink={handleUnlinkPromptFile} | ||
| /> | ||
| {!promptFile && | ||
| !form.watch("metadata.instructions")?.trim() && ( | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={handleInsertTemplate} | ||
| > | ||
| + Prompt template | ||
| </Button> | ||
| )} | ||
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| disabled={ | ||
| isImproving || | ||
| promptFileReadOnly || | ||
| !form.watch("metadata.instructions")?.trim() | ||
| } | ||
| onClick={handleImprovePrompt} | ||
|
|
@@ -1826,14 +1903,16 @@ Define step-by-step how the agent should handle requests. | |
| <div className="relative rounded-xl card-shadow bg-card focus-within:ring-1 focus-within:ring-ring"> | ||
| <Textarea | ||
| {...field} | ||
| value={field.value ?? ""} | ||
| value={promptDisplayValue(field.value)} | ||
| onChange={(e) => { | ||
| if (promptFile) setPromptDraft(e.target.value); | ||
| field.onChange(e); | ||
| }} | ||
| onBlur={() => { | ||
| field.onBlur(); | ||
| flushAndSave(); | ||
| }} | ||
| disabled={promptFileReadOnly} | ||
| placeholder="Define how this agent should behave, what tone to use, any constraints or guidelines..." | ||
| className="min-h-[200px] max-h-[360px] overflow-auto resize-none text-base text-muted-foreground placeholder:text-muted-foreground/40 leading-relaxed border-0 shadow-none px-4 py-3 pr-11 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent" | ||
| style={{ boxShadow: "none" }} | ||
|
|
@@ -1999,15 +2078,16 @@ Define step-by-step how the agent should handle requests. | |
| render={({ field }) => ( | ||
| <Textarea | ||
| {...field} | ||
| value={field.value ?? ""} | ||
| value={promptDisplayValue(field.value)} | ||
| onChange={(e) => { | ||
| if (promptFile) setPromptDraft(e.target.value); | ||
| field.onChange(e); | ||
| }} | ||
| onBlur={() => { | ||
| field.onBlur(); | ||
| flushAndSave(); | ||
| }} | ||
| disabled={hasGithubRepo} | ||
| disabled={hasGithubRepo || promptFileReadOnly} | ||
| placeholder="Define how this agent should behave, what tone to use, any constraints or guidelines..." | ||
| className="w-full h-full resize-none text-base text-muted-foreground placeholder:text-muted-foreground/40 leading-relaxed rounded-xl card-shadow px-4 py-3 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 bg-card border-0" | ||
| style={{ boxShadow: "none" }} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not blocking — two minor observations:
skillsBlock), just flagging the per-run latency/cost.console.warnlogs the fullerrobject every run when a linked file is missing/unreadable — a deleted linked file will spam this once per run. Might drop todebugor trim the payload.